Coverage for src/workstack/core/github_ops.py: 43%

81 statements  

« prev     ^ index     » next       coverage.py v7.11.0, created at 2025-10-19 09:31 -0400

1"""High-level GitHub operations interface. 

2 

3This module provides a clean abstraction over GitHub CLI (gh) calls, making the 

4codebase more testable and maintainable. 

5 

6Architecture: 

7- GitHubOps: Abstract base class defining the interface 

8- RealGitHubOps: Production implementation using gh CLI 

9- DryRunGitHubOps: Dry-run wrapper that delegates reads, prints write intentions 

10- Standalone functions: Convenience wrappers if needed 

11""" 

12 

13import json 

14import re 

15import subprocess 

16from abc import ABC, abstractmethod 

17from dataclasses import dataclass 

18from pathlib import Path 

19 

20 

21def _parse_github_pr_url(url: str) -> tuple[str, str] | None: 

22 """Parse owner and repo from GitHub PR URL. 

23 

24 Args: 

25 url: GitHub PR URL (e.g., "https://github.com/owner/repo/pull/123") 

26 

27 Returns: 

28 Tuple of (owner, repo) or None if URL doesn't match expected pattern 

29 

30 Example: 

31 >>> _parse_github_pr_url("https://github.com/dagster-io/workstack/pull/23") 

32 ("dagster-io", "workstack") 

33 """ 

34 match = re.match(r"https://github\.com/([^/]+)/([^/]+)/pull/\d+", url) 

35 if match: 

36 return (match.group(1), match.group(2)) 

37 return None 

38 

39 

40@dataclass(frozen=True) 

41class PullRequestInfo: 

42 """Information about a GitHub pull request.""" 

43 

44 number: int 

45 state: str # "OPEN", "MERGED", "CLOSED" 

46 url: str 

47 is_draft: bool 

48 checks_passing: bool | None # None if no checks, True if all pass, False if any fail 

49 owner: str # GitHub repo owner (e.g., "schrockn") 

50 repo: str # GitHub repo name (e.g., "workstack") 

51 

52 

53class GitHubOps(ABC): 

54 """Abstract interface for GitHub operations. 

55 

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

57 """ 

58 

59 @abstractmethod 

60 def get_prs_for_repo( 

61 self, repo_root: Path, *, include_checks: bool 

62 ) -> dict[str, PullRequestInfo]: 

63 """Get PR information for all branches in the repository. 

64 

65 Args: 

66 repo_root: Repository root directory 

67 include_checks: If True, fetch CI check status (slower). If False, skip check status 

68 

69 Returns: 

70 Mapping of branch name -> PullRequestInfo 

71 - checks_passing is None when include_checks=False 

72 Empty dict if gh CLI is not available or not authenticated 

73 """ 

74 ... 

75 

76 @abstractmethod 

77 def get_pr_status( 

78 self, repo_root: Path, branch: str, *, debug: bool 

79 ) -> tuple[str, int | None, str | None]: 

80 """Get PR status for a specific branch. 

81 

82 Args: 

83 repo_root: Repository root directory 

84 branch: Branch name to check 

85 debug: If True, print debug information 

86 

87 Returns: 

88 Tuple of (state, pr_number, title) 

89 - state: "OPEN", "MERGED", "CLOSED", or "NONE" if no PR exists 

90 - pr_number: PR number or None if no PR exists 

91 - title: PR title or None if no PR exists 

92 """ 

93 ... 

94 

95 

96class RealGitHubOps(GitHubOps): 

97 """Production implementation using gh CLI. 

98 

99 All GitHub operations execute actual gh commands via subprocess. 

100 """ 

101 

102 def get_prs_for_repo( 

103 self, repo_root: Path, *, include_checks: bool 

104 ) -> dict[str, PullRequestInfo]: 

105 """Get PR information for all branches in the repository. 

106 

107 Note: Uses try/except as an acceptable error boundary for handling gh CLI 

108 availability and authentication. We cannot reliably check gh installation 

109 and authentication status a priori without duplicating gh's logic. 

110 """ 

111 try: 

112 # Build JSON fields list - conditionally include statusCheckRollup for performance 

113 json_fields = "number,headRefName,url,state,isDraft" 

114 if include_checks: 

115 json_fields += ",statusCheckRollup" 

116 

117 # Fetch all PRs in one call for efficiency 

118 result = subprocess.run( 

119 [ 

120 "gh", 

121 "pr", 

122 "list", 

123 "--state", 

124 "all", 

125 "--json", 

126 json_fields, 

127 ], 

128 cwd=repo_root, 

129 capture_output=True, 

130 text=True, 

131 check=True, 

132 ) 

133 

134 prs_data = json.loads(result.stdout) 

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

136 

137 for pr in prs_data: 

138 branch = pr["headRefName"] 

139 

140 # Only determine check status if we fetched it 

141 checks_passing = None 

142 if include_checks: 

143 checks_passing = self._determine_checks_status(pr.get("statusCheckRollup", [])) 

144 

145 # Parse owner and repo from GitHub URL 

146 url = pr["url"] 

147 parsed = _parse_github_pr_url(url) 

148 if parsed is None: 

149 # Skip PRs with malformed URLs (shouldn't happen in practice) 

150 continue 

151 owner, repo = parsed 

152 

153 prs[branch] = PullRequestInfo( 

154 number=pr["number"], 

155 state=pr["state"], 

156 url=url, 

157 is_draft=pr["isDraft"], 

158 checks_passing=checks_passing, 

159 owner=owner, 

160 repo=repo, 

161 ) 

162 

163 return prs 

164 

165 except (subprocess.CalledProcessError, FileNotFoundError, json.JSONDecodeError): 

166 # gh not installed, not authenticated, or JSON parsing failed 

167 return {} 

168 

169 def get_pr_status( 

170 self, repo_root: Path, branch: str, *, debug: bool 

171 ) -> tuple[str, int | None, str | None]: 

172 """Get PR status for a specific branch. 

173 

174 Note: Uses try/except as an acceptable error boundary for handling gh CLI 

175 availability and authentication. We cannot reliably check gh installation 

176 and authentication status a priori without duplicating gh's logic. 

177 """ 

178 try: 

179 # Query gh for PR info for this specific branch 

180 cmd = [ 

181 "gh", 

182 "pr", 

183 "list", 

184 "--head", 

185 branch, 

186 "--state", 

187 "all", 

188 "--json", 

189 "number,state,title", 

190 "--limit", 

191 "1", 

192 ] 

193 

194 if debug: 

195 import click 

196 

197 click.echo(f"$ {' '.join(cmd)}") 

198 

199 result = subprocess.run( 

200 cmd, 

201 cwd=repo_root, 

202 capture_output=True, 

203 text=True, 

204 check=True, 

205 ) 

206 

207 prs_data = json.loads(result.stdout) 

208 

209 # If no PR exists for this branch 

210 if not prs_data: 

211 return ("NONE", None, None) 

212 

213 # Take the first (and should be only) PR 

214 pr = prs_data[0] 

215 return (pr["state"], pr["number"], pr["title"]) 

216 

217 except (subprocess.CalledProcessError, FileNotFoundError, json.JSONDecodeError): 

218 # gh not installed, not authenticated, or JSON parsing failed 

219 return ("NONE", None, None) 

220 

221 def _determine_checks_status(self, check_rollup: list[dict]) -> bool | None: 

222 """Determine overall CI checks status. 

223 

224 Returns: 

225 None if no checks configured 

226 True if all checks passed (SUCCESS, SKIPPED, or NEUTRAL) 

227 False if any check failed or is pending 

228 """ 

229 if not check_rollup: 

230 return None 

231 

232 # GitHub check conclusions that should be treated as passing 

233 passing_conclusions = {"SUCCESS", "SKIPPED", "NEUTRAL"} 

234 

235 for check in check_rollup: 

236 status = check.get("status") 

237 conclusion = check.get("conclusion") 

238 

239 # If any check is not completed, consider it failing 

240 if status != "COMPLETED": 

241 return False 

242 

243 # If any completed check didn't pass, consider it failing 

244 if conclusion not in passing_conclusions: 

245 return False 

246 

247 return True 

248 

249 

250# ============================================================================ 

251# Dry-Run Wrapper 

252# ============================================================================ 

253 

254 

255class DryRunGitHubOps(GitHubOps): 

256 """Dry-run wrapper for GitHub operations. 

257 

258 Read operations are delegated to the wrapped implementation. 

259 Write operations (when added) will print dry-run messages instead of executing. 

260 

261 This wrapper currently delegates all operations since GitHubOps only has 

262 read operations. It's included for consistency with the three-implementations 

263 pattern and to prepare for future write operations (e.g., create PR, update status). 

264 """ 

265 

266 def __init__(self, wrapped: GitHubOps) -> None: 

267 """Initialize dry-run wrapper with a real implementation. 

268 

269 Args: 

270 wrapped: The real GitHub operations implementation to wrap 

271 """ 

272 self._wrapped = wrapped 

273 

274 def get_prs_for_repo( 

275 self, repo_root: Path, *, include_checks: bool 

276 ) -> dict[str, PullRequestInfo]: 

277 """Delegate read operation to wrapped implementation.""" 

278 return self._wrapped.get_prs_for_repo(repo_root, include_checks=include_checks) 

279 

280 def get_pr_status( 

281 self, repo_root: Path, branch: str, *, debug: bool 

282 ) -> tuple[str, int | None, str | None]: 

283 """Delegate read operation to wrapped implementation.""" 

284 return self._wrapped.get_pr_status(repo_root, branch, debug=debug)