Coverage for src/workstack/core/graphite_ops.py: 43%
97 statements
« prev ^ index » next coverage.py v7.11.0, created at 2025-10-19 09:31 -0400
« prev ^ index » next coverage.py v7.11.0, created at 2025-10-19 09:31 -0400
1"""High-level Graphite operations interface.
3This module provides a clean abstraction over Graphite CLI (gt) calls, making the
4codebase more testable and maintainable.
6Architecture:
7- GraphiteOps: Abstract base class defining the interface
8- RealGraphiteOps: Production implementation using gt CLI
9"""
11import json
12import subprocess
13import sys
14from abc import ABC, abstractmethod
15from pathlib import Path
17from workstack.core.branch_metadata import BranchMetadata
18from workstack.core.github_ops import PullRequestInfo, _parse_github_pr_url
19from workstack.core.gitops import GitOps
22class GraphiteOps(ABC):
23 """Abstract interface for Graphite operations.
25 All implementations (real and fake) must implement this interface.
26 """
28 @abstractmethod
29 def get_graphite_url(self, owner: str, repo: str, pr_number: int) -> str:
30 """Get Graphite PR URL for a pull request.
32 Args:
33 owner: GitHub repository owner (e.g., "dagster-io")
34 repo: GitHub repository name (e.g., "workstack")
35 pr_number: GitHub PR number
37 Returns:
38 Graphite PR URL (e.g., "https://app.graphite.dev/github/pr/dagster-io/workstack/23")
39 """
40 ...
42 @abstractmethod
43 def sync(self, repo_root: Path, *, force: bool) -> None:
44 """Run gt sync to synchronize with remote.
46 Args:
47 repo_root: Repository root directory
48 force: If True, pass --force flag to gt sync
49 """
50 ...
52 @abstractmethod
53 def get_prs_from_graphite(self, git_ops: GitOps, repo_root: Path) -> dict[str, PullRequestInfo]:
54 """Get PR information from Graphite's local cache.
56 Reads .git/.graphite_pr_info and returns PR data in the same format
57 as GitHubOps.get_prs_for_repo() for compatibility.
59 Args:
60 git_ops: GitOps instance for accessing git common directory
61 repo_root: Repository root directory
63 Returns:
64 Mapping of branch name -> PullRequestInfo
65 - checks_passing is always None (CI status not available)
66 - Empty dict if .graphite_pr_info doesn't exist
67 """
68 ...
70 @abstractmethod
71 def get_all_branches(self, git_ops: GitOps, repo_root: Path) -> dict[str, BranchMetadata]:
72 """Get all gt-tracked branches with metadata.
74 Reads .git/.graphite_cache_persist and returns branch relationship data
75 along with current commit SHAs from git.
77 Args:
78 git_ops: GitOps instance for accessing git common directory and branch heads
79 repo_root: Repository root directory
81 Returns:
82 Mapping of branch name -> BranchMetadata
83 Empty dict if:
84 - .graphite_cache_persist doesn't exist
85 - Git common directory cannot be determined
86 """
87 ...
90class RealGraphiteOps(GraphiteOps):
91 """Production implementation using gt CLI.
93 All Graphite operations execute actual gt commands via subprocess.
94 """
96 def get_graphite_url(self, owner: str, repo: str, pr_number: int) -> str:
97 """Get Graphite PR URL for a pull request.
99 Constructs the Graphite URL directly from GitHub repo information.
100 No subprocess calls or external dependencies required.
102 Args:
103 owner: GitHub repository owner (e.g., "dagster-io")
104 repo: GitHub repository name (e.g., "workstack")
105 pr_number: GitHub PR number
107 Returns:
108 Graphite PR URL (e.g., "https://app.graphite.dev/github/pr/dagster-io/workstack/23")
109 """
110 return f"https://app.graphite.dev/github/pr/{owner}/{repo}/{pr_number}"
112 def sync(self, repo_root: Path, *, force: bool) -> None:
113 """Run gt sync to synchronize with remote.
115 Output goes directly to sys.stdout/sys.stderr to avoid capture by
116 CliRunner when running in shell integration mode. This ensures gt sync
117 output doesn't leak into the shell script that gets eval'd.
119 Note: Uses try/except as an acceptable error boundary for handling gt CLI
120 availability. We cannot reliably check gt installation status a priori.
121 """
122 cmd = ["gt", "sync"]
123 if force:
124 cmd.append("-f")
126 subprocess.run(
127 cmd,
128 cwd=repo_root,
129 check=True,
130 stdout=sys.stdout,
131 stderr=sys.stderr,
132 )
134 def get_prs_from_graphite(self, git_ops: GitOps, repo_root: Path) -> dict[str, PullRequestInfo]:
135 """Get PR information from Graphite's .git/.graphite_pr_info file.
137 Note: Uses try/except as an acceptable error boundary for handling file I/O
138 and JSON parsing errors. We cannot validate file existence/format a priori.
139 """
140 git_dir = git_ops.get_git_common_dir(repo_root)
141 if git_dir is None:
142 return {}
144 pr_info_file = git_dir / ".graphite_pr_info"
145 if not pr_info_file.exists():
146 return {}
148 try:
149 data = json.loads(pr_info_file.read_text(encoding="utf-8"))
150 except (json.JSONDecodeError, OSError):
151 return {}
153 prs: dict[str, PullRequestInfo] = {}
154 for pr in data.get("prInfos", []):
155 branch = pr["headRefName"]
157 graphite_url = pr["url"]
158 github_url = self._graphite_url_to_github_url(graphite_url)
159 parsed = _parse_github_pr_url(github_url)
160 if parsed is None:
161 continue
162 owner, repo = parsed
164 prs[branch] = PullRequestInfo(
165 number=pr["prNumber"],
166 state=pr["state"],
167 url=github_url,
168 is_draft=pr["isDraft"],
169 checks_passing=None,
170 owner=owner,
171 repo=repo,
172 )
174 return prs
176 def _graphite_url_to_github_url(self, graphite_url: str) -> str:
177 """Convert Graphite URL to GitHub URL.
179 Input: https://app.graphite.dev/github/pr/dagster-io/workstack/42
180 Output: https://github.com/dagster-io/workstack/pull/42
181 """
182 parts = graphite_url.split("/")
183 if len(parts) >= 8 and parts[2] == "app.graphite.dev":
184 owner = parts[5]
185 repo = parts[6]
186 pr_number = parts[7]
187 return f"https://github.com/{owner}/{repo}/pull/{pr_number}"
188 return graphite_url
190 def get_all_branches(self, git_ops: GitOps, repo_root: Path) -> dict[str, BranchMetadata]:
191 """Get all gt-tracked branches with metadata.
193 Reads .git/.graphite_cache_persist and enriches with commit SHAs from git.
194 Returns empty dict if cache doesn't exist or git operations fail.
195 """
196 git_dir = git_ops.get_git_common_dir(repo_root)
197 if git_dir is None:
198 return {}
200 cache_file = git_dir / ".graphite_cache_persist"
201 if not cache_file.exists():
202 return {}
204 try:
205 cache_data = json.loads(cache_file.read_text(encoding="utf-8"))
206 except (json.JSONDecodeError, OSError):
207 return {}
209 branches_data: list[tuple[str, dict[str, object]]] = cache_data.get("branches", [])
211 result: dict[str, BranchMetadata] = {}
212 for branch_name, info in branches_data:
213 if not isinstance(info, dict):
214 continue
216 commit_sha = git_ops.get_branch_head(repo_root, branch_name) or ""
218 parent = info.get("parentBranchName")
219 if not isinstance(parent, str | None):
220 parent = None
222 children_raw = info.get("children", [])
223 if not isinstance(children_raw, list):
224 children_raw = []
225 children = [c for c in children_raw if isinstance(c, str)]
227 is_trunk = info.get("validationResult") == "TRUNK"
229 result[branch_name] = BranchMetadata(
230 name=branch_name,
231 parent=parent,
232 children=children,
233 is_trunk=is_trunk,
234 commit_sha=commit_sha,
235 )
237 return result
240class DryRunGraphiteOps(GraphiteOps):
241 """Wrapper that prints dry-run messages instead of executing destructive operations.
243 This wrapper intercepts destructive graphite operations and prints what would happen
244 instead of executing. Read-only operations are delegated to the wrapped implementation.
246 Usage:
247 real_ops = RealGraphiteOps()
248 dry_run_ops = DryRunGraphiteOps(real_ops)
250 # Prints message instead of running gt sync
251 dry_run_ops.sync(repo_root, force=False)
252 """
254 def __init__(self, wrapped: GraphiteOps) -> None:
255 """Create a dry-run wrapper around a GraphiteOps implementation.
257 Args:
258 wrapped: The GraphiteOps implementation to wrap (usually RealGraphiteOps)
259 """
260 self._wrapped = wrapped
262 # Read-only operations: delegate to wrapped implementation
264 def get_graphite_url(self, owner: str, repo: str, pr_number: int) -> str:
265 """Get Graphite PR URL (read-only, delegates to wrapped)."""
266 return self._wrapped.get_graphite_url(owner, repo, pr_number)
268 def get_prs_from_graphite(self, git_ops: GitOps, repo_root: Path) -> dict[str, PullRequestInfo]:
269 """Get PR info from Graphite cache (read-only, delegates to wrapped)."""
270 return self._wrapped.get_prs_from_graphite(git_ops, repo_root)
272 def get_all_branches(self, git_ops: GitOps, repo_root: Path) -> dict[str, BranchMetadata]:
273 """Get all branches metadata (read-only, delegates to wrapped)."""
274 return self._wrapped.get_all_branches(git_ops, repo_root)
276 # Destructive operations: print dry-run message instead of executing
278 def sync(self, repo_root: Path, *, force: bool) -> None:
279 """Print dry-run message instead of running gt sync."""
280 import click
282 cmd = ["gt", "sync"]
283 if force:
284 cmd.append("-f")
286 click.echo(f"[DRY RUN] Would run: {' '.join(cmd)}")