import json import urllib.request from typing import Dict from ghapi.all import GhApi REVIEW_MARKER = '' class GitHubClient: """Generic GitHub client for PR operations.""" def __init__(self, token: str, repo_owner: str, repo_name: str): self.token = token self.repo_owner = repo_owner self.repo_name = repo_name self.api = GhApi(owner=repo_owner, repo=repo_name, token=token) def get_pr_details(self, pr_number: int, comment_filters: Dict = None) -> Dict: """Get PR details including files, comments, and metadata. Args: pr_number: PR number comment_filters: Optional dict with filter functions for comments """ pr_files = self.api.pulls.list_files(pull_number=pr_number, per_page=100) files = [ {'filename': file['filename'], 'patch': file.get('patch', '')} for file in pr_files ] comments = self.api.issues.list_comments(issue_number=pr_number, per_page=100) pr_commits = self.api.pulls.list_commits(pull_number=pr_number, per_page=100) pr_details = self.api.pulls.get(pull_number=pr_number) pr_title = pr_details.title pr_body = pr_details.body last_commit_date = pr_commits[-1].commit.committer.date # Apply comment filters if provided filtered_comments = [] if comment_filters: for filter_func in comment_filters.values(): filtered_comments = filter_func(comments, last_commit_date) return { 'diff': files, 'comments': comments, 'filtered_comments': filtered_comments, 'pr_title': pr_title, 'pr_body': pr_body, 'last_commit_date': last_commit_date, # Legacy field for backwards compatibility 'tf_plans': filtered_comments if comment_filters else [], } def is_draft_pr(self, pr_number: int) -> bool: """Return True if the PR is currently a draft.""" pr_details = self.api.pulls.get(pull_number=pr_number) return bool(pr_details.draft) def create_comment(self, pr_number: int, body: str) -> None: """Create a comment on the PR, prepending the review marker.""" self.api.issues.create_comment( issue_number=pr_number, body=f'{REVIEW_MARKER}\n{body}' ) def minimize_comment(self, node_id: str) -> None: """Minimize a comment via the GitHub GraphQL API (marks it as OUTDATED).""" mutation = """ mutation MinimizeComment($id: ID!) { minimizeComment(input: {subjectId: $id, classifier: OUTDATED}) { minimizedComment { isMinimized } } } """ payload = json.dumps({'query': mutation, 'variables': {'id': node_id}}).encode() req = urllib.request.Request( 'https://api.github.com/graphql', data=payload, headers={ 'Authorization': f'Bearer {self.token}', 'Content-Type': 'application/json', }, ) with urllib.request.urlopen(req) as resp: result = json.loads(resp.read()) if 'errors' in result: print(f'Warning: failed to minimize comment {node_id}: {result["errors"]}') def collapse_previous_reviews(self, pr_number: int) -> None: """Minimize all previous bot review comments on the PR.""" comments = self.api.issues.list_comments(issue_number=pr_number, per_page=100) for comment in comments: if REVIEW_MARKER in (comment.get('body') or ''): self.minimize_comment(comment['node_id']) def get_pr_status(self, pr_number: int) -> Dict: """Get status checks for the latest commit of a PR. Args: pr_number: PR number Returns: Dict with status check information """ pr_details = self.api.pulls.get(pull_number=pr_number) latest_commit_sha = pr_details.head.sha status = self.api.repos.get_combined_status_for_ref(ref=latest_commit_sha) return status