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

1"""High-level Graphite operations interface. 

2 

3This module provides a clean abstraction over Graphite CLI (gt) calls, making the 

4codebase more testable and maintainable. 

5 

6Architecture: 

7- GraphiteOps: Abstract base class defining the interface 

8- RealGraphiteOps: Production implementation using gt CLI 

9""" 

10 

11import json 

12import subprocess 

13import sys 

14from abc import ABC, abstractmethod 

15from pathlib import Path 

16 

17from workstack.core.branch_metadata import BranchMetadata 

18from workstack.core.github_ops import PullRequestInfo, _parse_github_pr_url 

19from workstack.core.gitops import GitOps 

20 

21 

22class GraphiteOps(ABC): 

23 """Abstract interface for Graphite operations. 

24 

25 All implementations (real and fake) must implement this interface. 

26 """ 

27 

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. 

31 

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 

36 

37 Returns: 

38 Graphite PR URL (e.g., "https://app.graphite.dev/github/pr/dagster-io/workstack/23") 

39 """ 

40 ... 

41 

42 @abstractmethod 

43 def sync(self, repo_root: Path, *, force: bool) -> None: 

44 """Run gt sync to synchronize with remote. 

45 

46 Args: 

47 repo_root: Repository root directory 

48 force: If True, pass --force flag to gt sync 

49 """ 

50 ... 

51 

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. 

55 

56 Reads .git/.graphite_pr_info and returns PR data in the same format 

57 as GitHubOps.get_prs_for_repo() for compatibility. 

58 

59 Args: 

60 git_ops: GitOps instance for accessing git common directory 

61 repo_root: Repository root directory 

62 

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 ... 

69 

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. 

73 

74 Reads .git/.graphite_cache_persist and returns branch relationship data 

75 along with current commit SHAs from git. 

76 

77 Args: 

78 git_ops: GitOps instance for accessing git common directory and branch heads 

79 repo_root: Repository root directory 

80 

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 ... 

88 

89 

90class RealGraphiteOps(GraphiteOps): 

91 """Production implementation using gt CLI. 

92 

93 All Graphite operations execute actual gt commands via subprocess. 

94 """ 

95 

96 def get_graphite_url(self, owner: str, repo: str, pr_number: int) -> str: 

97 """Get Graphite PR URL for a pull request. 

98 

99 Constructs the Graphite URL directly from GitHub repo information. 

100 No subprocess calls or external dependencies required. 

101 

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 

106 

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}" 

111 

112 def sync(self, repo_root: Path, *, force: bool) -> None: 

113 """Run gt sync to synchronize with remote. 

114 

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. 

118 

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") 

125 

126 subprocess.run( 

127 cmd, 

128 cwd=repo_root, 

129 check=True, 

130 stdout=sys.stdout, 

131 stderr=sys.stderr, 

132 ) 

133 

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. 

136 

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 {} 

143 

144 pr_info_file = git_dir / ".graphite_pr_info" 

145 if not pr_info_file.exists(): 

146 return {} 

147 

148 try: 

149 data = json.loads(pr_info_file.read_text(encoding="utf-8")) 

150 except (json.JSONDecodeError, OSError): 

151 return {} 

152 

153 prs: dict[str, PullRequestInfo] = {} 

154 for pr in data.get("prInfos", []): 

155 branch = pr["headRefName"] 

156 

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 

163 

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 ) 

173 

174 return prs 

175 

176 def _graphite_url_to_github_url(self, graphite_url: str) -> str: 

177 """Convert Graphite URL to GitHub URL. 

178 

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 

189 

190 def get_all_branches(self, git_ops: GitOps, repo_root: Path) -> dict[str, BranchMetadata]: 

191 """Get all gt-tracked branches with metadata. 

192 

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 {} 

199 

200 cache_file = git_dir / ".graphite_cache_persist" 

201 if not cache_file.exists(): 

202 return {} 

203 

204 try: 

205 cache_data = json.loads(cache_file.read_text(encoding="utf-8")) 

206 except (json.JSONDecodeError, OSError): 

207 return {} 

208 

209 branches_data: list[tuple[str, dict[str, object]]] = cache_data.get("branches", []) 

210 

211 result: dict[str, BranchMetadata] = {} 

212 for branch_name, info in branches_data: 

213 if not isinstance(info, dict): 

214 continue 

215 

216 commit_sha = git_ops.get_branch_head(repo_root, branch_name) or "" 

217 

218 parent = info.get("parentBranchName") 

219 if not isinstance(parent, str | None): 

220 parent = None 

221 

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)] 

226 

227 is_trunk = info.get("validationResult") == "TRUNK" 

228 

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 ) 

236 

237 return result 

238 

239 

240class DryRunGraphiteOps(GraphiteOps): 

241 """Wrapper that prints dry-run messages instead of executing destructive operations. 

242 

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. 

245 

246 Usage: 

247 real_ops = RealGraphiteOps() 

248 dry_run_ops = DryRunGraphiteOps(real_ops) 

249 

250 # Prints message instead of running gt sync 

251 dry_run_ops.sync(repo_root, force=False) 

252 """ 

253 

254 def __init__(self, wrapped: GraphiteOps) -> None: 

255 """Create a dry-run wrapper around a GraphiteOps implementation. 

256 

257 Args: 

258 wrapped: The GraphiteOps implementation to wrap (usually RealGraphiteOps) 

259 """ 

260 self._wrapped = wrapped 

261 

262 # Read-only operations: delegate to wrapped implementation 

263 

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) 

267 

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) 

271 

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) 

275 

276 # Destructive operations: print dry-run message instead of executing 

277 

278 def sync(self, repo_root: Path, *, force: bool) -> None: 

279 """Print dry-run message instead of running gt sync.""" 

280 import click 

281 

282 cmd = ["gt", "sync"] 

283 if force: 

284 cmd.append("-f") 

285 

286 click.echo(f"[DRY RUN] Would run: {' '.join(cmd)}")