Files
Aperant/apps/backend/runners/github/gh_client.py
T
Andy 217249c8a3 fix(github): add explicit GET method to gh api comment fetches (#294)
The gh api command defaults to POST for comment endpoints, causing
GitHub to reject the 'since' query parameter as an invalid POST body
field. Adding --method GET explicitly forces a GET request, allowing
the since parameter to work correctly for fetching comments.

This completes the fix started in f1cc5a09 which only changed from
-f flag to query string syntax but didn't address the HTTP method.
2025-12-26 09:24:01 +01:00

714 lines
22 KiB
Python

"""
GitHub CLI Client with Timeout and Retry Logic
==============================================
Wrapper for gh CLI commands that prevents hung processes through:
- Configurable timeouts (default 30s)
- Exponential backoff retry (3 attempts: 1s, 2s, 4s)
- Structured logging for monitoring
- Async subprocess execution for non-blocking operations
This eliminates the risk of indefinite hangs in GitHub automation workflows.
"""
from __future__ import annotations
import asyncio
import json
import logging
from dataclasses import dataclass
from pathlib import Path
from typing import Any
try:
from .rate_limiter import RateLimiter, RateLimitExceeded
except (ImportError, ValueError, SystemError):
from rate_limiter import RateLimiter, RateLimitExceeded
# Configure logger
logger = logging.getLogger(__name__)
class GHTimeoutError(Exception):
"""Raised when gh CLI command times out after all retry attempts."""
pass
class GHCommandError(Exception):
"""Raised when gh CLI command fails with non-zero exit code."""
pass
class PRTooLargeError(Exception):
"""Raised when PR diff exceeds GitHub's 20,000 line limit."""
pass
@dataclass
class GHCommandResult:
"""Result of a gh CLI command execution."""
stdout: str
stderr: str
returncode: int
command: list[str]
attempts: int
total_time: float
class GHClient:
"""
Async client for GitHub CLI with timeout and retry protection.
Usage:
client = GHClient(project_dir=Path("/path/to/project"))
# Simple command
result = await client.run(["pr", "list"])
# With custom timeout
result = await client.run(["pr", "diff", "123"], timeout=60.0)
# Convenience methods
pr_data = await client.pr_get(123)
diff = await client.pr_diff(123)
await client.pr_review(123, body="LGTM", event="approve")
"""
def __init__(
self,
project_dir: Path,
default_timeout: float = 30.0,
max_retries: int = 3,
enable_rate_limiting: bool = True,
):
"""
Initialize GitHub CLI client.
Args:
project_dir: Project directory for gh commands
default_timeout: Default timeout in seconds for commands
max_retries: Maximum number of retry attempts
enable_rate_limiting: Whether to enforce rate limiting (default: True)
"""
self.project_dir = Path(project_dir)
self.default_timeout = default_timeout
self.max_retries = max_retries
self.enable_rate_limiting = enable_rate_limiting
# Initialize rate limiter singleton
if enable_rate_limiting:
self._rate_limiter = RateLimiter.get_instance()
async def run(
self,
args: list[str],
timeout: float | None = None,
raise_on_error: bool = True,
) -> GHCommandResult:
"""
Execute a gh CLI command with timeout and retry logic.
Args:
args: Command arguments (e.g., ["pr", "list"])
timeout: Timeout in seconds (uses default if None)
raise_on_error: Raise GHCommandError on non-zero exit
Returns:
GHCommandResult with command output and metadata
Raises:
GHTimeoutError: If command times out after all retries
GHCommandError: If command fails and raise_on_error is True
"""
timeout = timeout or self.default_timeout
cmd = ["gh"] + args
start_time = asyncio.get_event_loop().time()
# Pre-flight rate limit check
if self.enable_rate_limiting:
available, msg = self._rate_limiter.check_github_available()
if not available:
# Try to acquire (will wait if needed)
logger.info(f"Rate limited, waiting for token: {msg}")
if not await self._rate_limiter.acquire_github(timeout=30.0):
raise RateLimitExceeded(f"GitHub API rate limit exceeded: {msg}")
else:
# Consume a token for this request
await self._rate_limiter.acquire_github(timeout=1.0)
for attempt in range(1, self.max_retries + 1):
try:
logger.debug(
f"Executing gh command (attempt {attempt}/{self.max_retries}): {' '.join(cmd)}"
)
# Create subprocess
proc = await asyncio.create_subprocess_exec(
*cmd,
cwd=self.project_dir,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
# Wait for completion with timeout
try:
stdout, stderr = await asyncio.wait_for(
proc.communicate(), timeout=timeout
)
except asyncio.TimeoutError:
# Kill the hung process
try:
proc.kill()
await proc.wait()
except Exception as e:
logger.warning(f"Failed to kill hung process: {e}")
# Calculate backoff delay
backoff_delay = 2 ** (attempt - 1)
logger.warning(
f"gh {args[0]} timed out after {timeout}s "
f"(attempt {attempt}/{self.max_retries})"
)
# Retry if attempts remain
if attempt < self.max_retries:
logger.info(f"Retrying in {backoff_delay}s...")
await asyncio.sleep(backoff_delay)
continue
else:
# All retries exhausted
total_time = asyncio.get_event_loop().time() - start_time
logger.error(
f"gh {args[0]} timed out after {self.max_retries} attempts "
f"({total_time:.1f}s total)"
)
raise GHTimeoutError(
f"gh {args[0]} timed out after {self.max_retries} attempts "
f"({timeout}s each, {total_time:.1f}s total)"
)
# Successful execution (no timeout)
total_time = asyncio.get_event_loop().time() - start_time
stdout_str = stdout.decode("utf-8")
stderr_str = stderr.decode("utf-8")
result = GHCommandResult(
stdout=stdout_str,
stderr=stderr_str,
returncode=proc.returncode or 0,
command=cmd,
attempts=attempt,
total_time=total_time,
)
if result.returncode != 0:
logger.warning(
f"gh {args[0]} failed with exit code {result.returncode}: {stderr_str}"
)
# Check for rate limit errors (403/429)
error_lower = stderr_str.lower()
if (
"403" in stderr_str
or "429" in stderr_str
or "rate limit" in error_lower
):
if self.enable_rate_limiting:
self._rate_limiter.record_github_error()
raise RateLimitExceeded(
f"GitHub API rate limit (HTTP 403/429): {stderr_str}"
)
if raise_on_error:
raise GHCommandError(
f"gh {args[0]} failed: {stderr_str or 'Unknown error'}"
)
else:
logger.debug(
f"gh {args[0]} completed successfully "
f"(attempt {attempt}, {total_time:.2f}s)"
)
return result
except (GHTimeoutError, GHCommandError, RateLimitExceeded):
# Re-raise our custom exceptions
raise
except Exception as e:
# Unexpected error
logger.error(f"Unexpected error in gh command: {e}")
if attempt == self.max_retries:
raise GHCommandError(f"gh {args[0]} failed: {str(e)}")
else:
# Retry on unexpected errors too
backoff_delay = 2 ** (attempt - 1)
logger.info(f"Retrying in {backoff_delay}s after error...")
await asyncio.sleep(backoff_delay)
continue
# Should never reach here, but for type safety
raise GHCommandError(f"gh {args[0]} failed after {self.max_retries} attempts")
# =========================================================================
# Convenience methods for common gh commands
# =========================================================================
async def pr_list(
self,
state: str = "open",
limit: int = 100,
json_fields: list[str] | None = None,
) -> list[dict[str, Any]]:
"""
List pull requests.
Args:
state: PR state (open, closed, merged, all)
limit: Maximum number of PRs to return
json_fields: Fields to include in JSON output
Returns:
List of PR data dictionaries
"""
if json_fields is None:
json_fields = [
"number",
"title",
"state",
"author",
"headRefName",
"baseRefName",
]
args = [
"pr",
"list",
"--state",
state,
"--limit",
str(limit),
"--json",
",".join(json_fields),
]
result = await self.run(args)
return json.loads(result.stdout)
async def pr_get(
self, pr_number: int, json_fields: list[str] | None = None
) -> dict[str, Any]:
"""
Get PR data by number.
Args:
pr_number: PR number
json_fields: Fields to include in JSON output
Returns:
PR data dictionary
"""
if json_fields is None:
json_fields = [
"number",
"title",
"body",
"state",
"headRefName",
"baseRefName",
"author",
"files",
"additions",
"deletions",
"changedFiles",
]
args = [
"pr",
"view",
str(pr_number),
"--json",
",".join(json_fields),
]
result = await self.run(args)
return json.loads(result.stdout)
async def pr_diff(self, pr_number: int) -> str:
"""
Get PR diff.
Args:
pr_number: PR number
Returns:
Unified diff string
Raises:
PRTooLargeError: If PR exceeds GitHub's 20,000 line diff limit
"""
args = ["pr", "diff", str(pr_number)]
try:
result = await self.run(args)
return result.stdout
except GHCommandError as e:
# Check if error is due to PR being too large
error_msg = str(e)
if (
"diff exceeded the maximum number of lines" in error_msg
or "HTTP 406" in error_msg
):
raise PRTooLargeError(
f"PR #{pr_number} exceeds GitHub's 20,000 line diff limit. "
"Consider splitting into smaller PRs or review files individually."
) from e
# Re-raise other command errors
raise
async def pr_review(
self,
pr_number: int,
body: str,
event: str = "comment",
) -> int:
"""
Post a review to a PR.
Args:
pr_number: PR number
body: Review comment body
event: Review event (approve, request-changes, comment)
Returns:
Review ID (currently 0, as gh CLI doesn't return ID)
"""
args = ["pr", "review", str(pr_number)]
if event.lower() == "approve":
args.append("--approve")
elif event.lower() in ["request-changes", "request_changes"]:
args.append("--request-changes")
else:
args.append("--comment")
args.extend(["--body", body])
await self.run(args)
return 0 # gh CLI doesn't return review ID
async def issue_list(
self,
state: str = "open",
limit: int = 100,
json_fields: list[str] | None = None,
) -> list[dict[str, Any]]:
"""
List issues.
Args:
state: Issue state (open, closed, all)
limit: Maximum number of issues to return
json_fields: Fields to include in JSON output
Returns:
List of issue data dictionaries
"""
if json_fields is None:
json_fields = [
"number",
"title",
"body",
"labels",
"author",
"createdAt",
"updatedAt",
"comments",
]
args = [
"issue",
"list",
"--state",
state,
"--limit",
str(limit),
"--json",
",".join(json_fields),
]
result = await self.run(args)
return json.loads(result.stdout)
async def issue_get(
self, issue_number: int, json_fields: list[str] | None = None
) -> dict[str, Any]:
"""
Get issue data by number.
Args:
issue_number: Issue number
json_fields: Fields to include in JSON output
Returns:
Issue data dictionary
"""
if json_fields is None:
json_fields = [
"number",
"title",
"body",
"state",
"labels",
"author",
"comments",
"createdAt",
"updatedAt",
]
args = [
"issue",
"view",
str(issue_number),
"--json",
",".join(json_fields),
]
result = await self.run(args)
return json.loads(result.stdout)
async def issue_comment(self, issue_number: int, body: str) -> None:
"""
Post a comment to an issue.
Args:
issue_number: Issue number
body: Comment body
"""
args = ["issue", "comment", str(issue_number), "--body", body]
await self.run(args)
async def issue_add_labels(self, issue_number: int, labels: list[str]) -> None:
"""
Add labels to an issue.
Args:
issue_number: Issue number
labels: List of label names to add
"""
if not labels:
return
args = [
"issue",
"edit",
str(issue_number),
"--add-label",
",".join(labels),
]
await self.run(args)
async def issue_remove_labels(self, issue_number: int, labels: list[str]) -> None:
"""
Remove labels from an issue.
Args:
issue_number: Issue number
labels: List of label names to remove
"""
if not labels:
return
args = [
"issue",
"edit",
str(issue_number),
"--remove-label",
",".join(labels),
]
# Don't raise on error - labels might not exist
await self.run(args, raise_on_error=False)
async def api_get(self, endpoint: str, params: dict[str, str] | None = None) -> Any:
"""
Make a GET request to GitHub API.
Args:
endpoint: API endpoint (e.g., "/repos/owner/repo/contents/path")
params: Query parameters
Returns:
JSON response
"""
args = ["api", endpoint]
if params:
for key, value in params.items():
args.extend(["-f", f"{key}={value}"])
result = await self.run(args)
return json.loads(result.stdout)
async def pr_merge(
self,
pr_number: int,
merge_method: str = "squash",
commit_title: str | None = None,
commit_message: str | None = None,
) -> None:
"""
Merge a pull request.
Args:
pr_number: PR number to merge
merge_method: Merge method - "merge", "squash", or "rebase" (default: "squash")
commit_title: Custom commit title (optional)
commit_message: Custom commit message (optional)
"""
args = ["pr", "merge", str(pr_number), f"--{merge_method}"]
if commit_title:
args.extend(["--subject", commit_title])
if commit_message:
args.extend(["--body", commit_message])
await self.run(args)
async def pr_comment(self, pr_number: int, body: str) -> None:
"""
Post a comment on a pull request.
Args:
pr_number: PR number
body: Comment body
"""
args = ["pr", "comment", str(pr_number), "--body", body]
await self.run(args)
async def pr_get_assignees(self, pr_number: int) -> list[str]:
"""
Get assignees for a pull request.
Args:
pr_number: PR number
Returns:
List of assignee logins
"""
data = await self.pr_get(pr_number, json_fields=["assignees"])
assignees = data.get("assignees", [])
return [a["login"] for a in assignees]
async def pr_assign(self, pr_number: int, assignees: list[str]) -> None:
"""
Assign users to a pull request.
Args:
pr_number: PR number
assignees: List of GitHub usernames to assign
"""
if not assignees:
return
# Use gh api to add assignees
endpoint = f"/repos/{{owner}}/{{repo}}/issues/{pr_number}/assignees"
args = [
"api",
endpoint,
"-X",
"POST",
"-f",
f"assignees={','.join(assignees)}",
]
await self.run(args)
async def compare_commits(self, base_sha: str, head_sha: str) -> dict[str, Any]:
"""
Compare two commits to get changes between them.
Uses: GET /repos/{owner}/{repo}/compare/{base}...{head}
Args:
base_sha: Base commit SHA (e.g., last reviewed commit)
head_sha: Head commit SHA (e.g., current PR HEAD)
Returns:
Dict with:
- commits: List of commits between base and head
- files: List of changed files with patches
- ahead_by: Number of commits head is ahead of base
- behind_by: Number of commits head is behind base
- total_commits: Total number of commits in comparison
"""
endpoint = f"repos/{{owner}}/{{repo}}/compare/{base_sha}...{head_sha}"
args = ["api", endpoint]
result = await self.run(args, timeout=60.0) # Longer timeout for large diffs
return json.loads(result.stdout)
async def get_comments_since(
self, pr_number: int, since_timestamp: str
) -> dict[str, list[dict]]:
"""
Get all comments (review + issue) since a timestamp.
Args:
pr_number: PR number
since_timestamp: ISO timestamp to filter from (e.g., "2025-12-25T10:30:00Z")
Returns:
Dict with:
- review_comments: Inline review comments on files
- issue_comments: General PR discussion comments
"""
# Fetch inline review comments
# Use query string syntax - the -f flag sends POST body fields, not query params
review_endpoint = f"repos/{{owner}}/{{repo}}/pulls/{pr_number}/comments?since={since_timestamp}"
review_args = ["api", "--method", "GET", review_endpoint]
review_result = await self.run(review_args, raise_on_error=False)
review_comments = []
if review_result.returncode == 0:
try:
review_comments = json.loads(review_result.stdout)
except json.JSONDecodeError:
logger.warning(f"Failed to parse review comments for PR #{pr_number}")
# Fetch general issue comments
# Use query string syntax - the -f flag sends POST body fields, not query params
issue_endpoint = f"repos/{{owner}}/{{repo}}/issues/{pr_number}/comments?since={since_timestamp}"
issue_args = ["api", "--method", "GET", issue_endpoint]
issue_result = await self.run(issue_args, raise_on_error=False)
issue_comments = []
if issue_result.returncode == 0:
try:
issue_comments = json.loads(issue_result.stdout)
except json.JSONDecodeError:
logger.warning(f"Failed to parse issue comments for PR #{pr_number}")
return {
"review_comments": review_comments,
"issue_comments": issue_comments,
}
async def get_pr_head_sha(self, pr_number: int) -> str | None:
"""
Get the current HEAD SHA of a PR.
Args:
pr_number: PR number
Returns:
HEAD commit SHA or None if not found
"""
data = await self.pr_get(pr_number, json_fields=["commits"])
commits = data.get("commits", [])
if commits:
# Last commit is the HEAD
return commits[-1].get("oid")
return None