| """In-memory fake for GitHub API.""" |
| |
| import re |
| from typing import ( |
| override, # pyrefly: ignore[missing-module-attribute] -- override available in Python 3.12+ |
| ) |
| |
| from dev.release.gh import ( |
| RELEASE_LABEL, |
| GetPrError, |
| GitHubInterface, |
| InvalidPrRefError, |
| IssueDict, |
| MultipleTrackingIssuesError, |
| NoTrackingIssueError, |
| PrDict, |
| resolve_merge_commits_for_prs, |
| ) |
| from dev.release.release_issue import BackportTask |
| |
| |
| class MockGitHub(GitHubInterface): |
| def __init__(self, repo: str = "bazel-contrib/rules_python"): |
| self.repo = repo |
| self.issues: dict[int, IssueDict] = {} |
| self.next_issue_num = 1001 |
| self.prs: dict[int, PrDict] = {} # num -> pr_info |
| self.issue_comments: dict[int, list[str]] = {} |
| self.reactions: dict[int, list[str]] = {} |
| self.pr_comments: dict[int, list[dict]] = {} |
| |
| @override |
| def post_issue_comment(self, issue_num: int, comment_body: str) -> None: |
| self.issue_comments.setdefault(issue_num, []).append(comment_body) |
| |
| @override |
| def add_comment_reaction(self, comment_id: int, reaction: str) -> None: |
| self.reactions.setdefault(comment_id, []).append(reaction) |
| |
| @override |
| def enable_auto_merge(self, pr_num: int, method: str = "squash") -> None: |
| if pr_num not in self.prs: |
| self.create_pr(title="", body="") |
| self.prs[pr_num]["auto_merge"] = {"merge_method": method} |
| |
| @override |
| def create_issue( |
| self, title: str, body: str, labels: list[str] | None = None |
| ) -> int: |
| issue_num = self.next_issue_num |
| self.next_issue_num += 1 |
| self.issues[issue_num] = { |
| "title": title, |
| "body": body, |
| "labels": labels or [], |
| "number": issue_num, |
| "url": f"https://github.com/{self.repo}/issues/{issue_num}", |
| } |
| return issue_num |
| |
| @override |
| def create_release_tracking_issue(self, version: str, template_content: str) -> int: |
| # Strip YAML frontmatter if present (simplified copy from gh.py) |
| issue_body = template_content |
| if template_content.startswith("---"): |
| parts = template_content.split("---", 2) |
| if len(parts) >= 3: |
| issue_body = parts[2].strip() |
| |
| return self.create_issue( |
| title=f"Release {version}", body=issue_body, labels=[RELEASE_LABEL] |
| ) |
| |
| @override |
| def get_issue_body(self, issue_num: int) -> str: |
| if issue_num not in self.issues: |
| raise ValueError(f"Issue #{issue_num} not found in MockGitHub") |
| return self.issues[issue_num]["body"] |
| |
| @override |
| def get_issue_title(self, issue_num: int) -> str: |
| if issue_num not in self.issues: |
| raise ValueError(f"Issue #{issue_num} not found in MockGitHub") |
| return self.issues[issue_num]["title"] |
| |
| @override |
| def update_issue_body(self, issue_num: int, body: str): |
| if issue_num not in self.issues: |
| raise ValueError(f"Issue #{issue_num} not found in MockGitHub") |
| self.issues[issue_num]["body"] = body |
| |
| @override |
| def resolve_pr_number(self, pr_ref: str) -> int: |
| # Real algorithm copy (doesn't require RPCs) |
| clean_ref = pr_ref.lstrip("#") |
| if clean_ref.isdigit(): |
| return int(clean_ref) |
| |
| if pr_ref.startswith("http"): |
| pattern = rf"github\.com/{re.escape(self.repo)}/pull/(\d+)(/|\?|\Z)" |
| match = re.search(pattern, pr_ref, re.IGNORECASE) |
| if match: |
| return int(match.group(1)) |
| raise InvalidPrRefError( |
| f"URL is not for the configured repository ({self.repo}): {pr_ref}" |
| ) |
| raise InvalidPrRefError(f"Could not resolve PR ref: {pr_ref}") |
| |
| @override |
| def get_release_tracking_issue(self, version: str) -> int: |
| search_title = f"Release {version}" |
| matching = [ |
| num |
| for num, issue in self.issues.items() |
| if issue["title"] == search_title |
| and RELEASE_LABEL in issue.get("labels", []) |
| ] |
| if not matching: |
| raise NoTrackingIssueError( |
| f"No open tracking issue found for Release {version}" |
| ) |
| if len(matching) > 1: |
| raise MultipleTrackingIssuesError( |
| f"Multiple open tracking issues found for Release {version}" |
| ) |
| return matching[0] |
| |
| @override |
| def create_pr( |
| self, |
| title: str, |
| body: str, |
| base: str = "main", |
| labels: list[str] | None = None, |
| ) -> str: |
| pr_num = self.next_issue_num |
| self.next_issue_num += 1 |
| url = f"https://github.com/{self.repo}/pull/{pr_num}" |
| self.prs[pr_num] = { |
| "title": title, |
| "body": body, |
| "base": base, |
| "labels": labels or [], |
| "number": pr_num, |
| "url": url, |
| "state": "OPEN", |
| } |
| return url |
| |
| @override |
| def get_open_pr(self, branch_name: str) -> PrDict | None: |
| for pr in self.prs.values(): |
| if pr.get("head") == branch_name and pr.get("state") == "OPEN": |
| return pr |
| return None |
| |
| @override |
| def get_open_tracking_issues(self, version: str | None = None) -> list[IssueDict]: |
| results = [] |
| for issue in self.issues.values(): |
| if RELEASE_LABEL in issue["labels"]: |
| if version: |
| if issue["title"] == f"Release {version}": |
| results.append(issue) |
| else: |
| results.append(issue) |
| return results |
| |
| @override |
| def get_pr_info(self, pr_num: int) -> PrDict: |
| if pr_num in self.prs: |
| return self.prs[pr_num] |
| return { |
| "state": "MERGED", |
| "mergeCommit": {"oid": f"mock_merge_sha_{pr_num}"}, |
| } |
| |
| @override |
| def get_pr_files(self, pr_num: int) -> list[str]: |
| if pr_num not in self.prs: |
| raise GetPrError(f"Pull Request #{pr_num} not found in MockGitHub") |
| files = self.prs[pr_num].get("files", []) |
| return [f["path"] for f in files] |
| |
| @override |
| def get_pr_comments(self, pr_num: int) -> list[dict]: |
| return self.pr_comments.get(pr_num, []) |
| |
| @override |
| def get_merge_commits_for_prs( |
| self, pending_items: list[BackportTask] |
| ) -> list[BackportTask]: |
| return resolve_merge_commits_for_prs(self, pending_items) |