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
« prev ^ index » next coverage.py v7.11.0, created at 2025-10-19 09:31 -0400
1"""High-level GitHub operations interface.
3This module provides a clean abstraction over GitHub CLI (gh) calls, making the
4codebase more testable and maintainable.
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"""
13import json
14import re
15import subprocess
16from abc import ABC, abstractmethod
17from dataclasses import dataclass
18from pathlib import Path
21def _parse_github_pr_url(url: str) -> tuple[str, str] | None:
22 """Parse owner and repo from GitHub PR URL.
24 Args:
25 url: GitHub PR URL (e.g., "https://github.com/owner/repo/pull/123")
27 Returns:
28 Tuple of (owner, repo) or None if URL doesn't match expected pattern
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
40@dataclass(frozen=True)
41class PullRequestInfo:
42 """Information about a GitHub pull request."""
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")
53class GitHubOps(ABC):
54 """Abstract interface for GitHub operations.
56 All implementations (real and fake) must implement this interface.
57 """
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.
65 Args:
66 repo_root: Repository root directory
67 include_checks: If True, fetch CI check status (slower). If False, skip check status
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 ...
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.
82 Args:
83 repo_root: Repository root directory
84 branch: Branch name to check
85 debug: If True, print debug information
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 ...
96class RealGitHubOps(GitHubOps):
97 """Production implementation using gh CLI.
99 All GitHub operations execute actual gh commands via subprocess.
100 """
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.
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"
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 )
134 prs_data = json.loads(result.stdout)
135 prs: dict[str, PullRequestInfo] = {}
137 for pr in prs_data:
138 branch = pr["headRefName"]
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", []))
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
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 )
163 return prs
165 except (subprocess.CalledProcessError, FileNotFoundError, json.JSONDecodeError):
166 # gh not installed, not authenticated, or JSON parsing failed
167 return {}
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.
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 ]
194 if debug:
195 import click
197 click.echo(f"$ {' '.join(cmd)}")
199 result = subprocess.run(
200 cmd,
201 cwd=repo_root,
202 capture_output=True,
203 text=True,
204 check=True,
205 )
207 prs_data = json.loads(result.stdout)
209 # If no PR exists for this branch
210 if not prs_data:
211 return ("NONE", None, None)
213 # Take the first (and should be only) PR
214 pr = prs_data[0]
215 return (pr["state"], pr["number"], pr["title"])
217 except (subprocess.CalledProcessError, FileNotFoundError, json.JSONDecodeError):
218 # gh not installed, not authenticated, or JSON parsing failed
219 return ("NONE", None, None)
221 def _determine_checks_status(self, check_rollup: list[dict]) -> bool | None:
222 """Determine overall CI checks status.
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
232 # GitHub check conclusions that should be treated as passing
233 passing_conclusions = {"SUCCESS", "SKIPPED", "NEUTRAL"}
235 for check in check_rollup:
236 status = check.get("status")
237 conclusion = check.get("conclusion")
239 # If any check is not completed, consider it failing
240 if status != "COMPLETED":
241 return False
243 # If any completed check didn't pass, consider it failing
244 if conclusion not in passing_conclusions:
245 return False
247 return True
250# ============================================================================
251# Dry-Run Wrapper
252# ============================================================================
255class DryRunGitHubOps(GitHubOps):
256 """Dry-run wrapper for GitHub operations.
258 Read operations are delegated to the wrapped implementation.
259 Write operations (when added) will print dry-run messages instead of executing.
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 """
266 def __init__(self, wrapped: GitHubOps) -> None:
267 """Initialize dry-run wrapper with a real implementation.
269 Args:
270 wrapped: The real GitHub operations implementation to wrap
271 """
272 self._wrapped = wrapped
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)
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)