| """GitHub CLI helper functions for the release tool.""" |
| |
| import abc |
| import enum |
| import json |
| import os |
| import re |
| import subprocess |
| import tempfile |
| from typing import ( |
| TypedDict, |
| override, # pyrefly: ignore[missing-module-attribute] -- override available in Python 3.12+ |
| ) |
| |
| from dev.release.release_issue import BackportTask |
| from dev.release.shell import run_cmd |
| |
| # GitHub label types |
| RELEASE_LABEL = "type: release" |
| BACKPORT_LABEL = "type: backport-pr" |
| RELEASE_PREPARED_LABEL = "release-prepared" |
| SYNC_CHANGELOG_LABEL = "type: sync-changelog" |
| |
| # GitHub reaction types |
| # See: https://docs.github.com/en/rest/reactions/reactions?apiVersion=2022-11-28#about-reactions |
| GH_REACTION_THUMBS_UP = "+1" |
| GH_REACTION_THUMBS_DOWN = "-1" |
| GH_REACTION_LAUGH = "laugh" |
| GH_REACTION_CONFUSED = "confused" |
| GH_REACTION_HEART = "heart" |
| GH_REACTION_HOORAY = "hooray" |
| GH_REACTION_ROCKET = "rocket" |
| GH_REACTION_EYES = "eyes" |
| |
| |
| class BackportTaskStatus(str, enum.Enum): |
| """Status strings for backport tasks on a release tracking issue.""" |
| |
| PENDING = "pending" |
| DONE = "done" |
| RESOLVED = "resolved" |
| OPEN_PR = "open-pr" |
| DRAFT_PR = "draft-pr" |
| ERROR_NOT_FOUND = "error-not-found" |
| ERROR_CLOSED_PR = "error-closed-pr" |
| ERROR_NO_MERGE_COMMIT = "error-no-merge-commit" |
| ERROR_UNKNOWN = "error-unknown" |
| ERROR_RESOLUTION_FAILED = "error-resolution-failed" |
| ERROR_MERGE_CONFLICT = "error-merge-conflict" |
| ERROR_INVALID_PR = "error-invalid-pr" |
| IGNORE = "ignore" |
| |
| def __str__(self) -> str: |
| return self.value |
| |
| |
| class IssueDict(TypedDict, total=False): |
| """In-memory representation of a GitHub Issue object. |
| |
| See GitHub API docs: |
| https://docs.github.com/en/rest/issues/issues#get-an-issue |
| """ |
| |
| number: int |
| title: str |
| body: str |
| labels: list[str] |
| url: str |
| |
| |
| class AutoMergeDict(TypedDict, total=False): |
| """Representation of auto-merge status on a Pull Request. |
| |
| See GitHub API docs: |
| https://docs.github.com/en/rest/pulls/pulls#get-a-pull-request |
| """ |
| |
| merge_method: str |
| |
| |
| class PrFileDict(TypedDict, total=False): |
| """In-memory representation of a file in a GitHub Pull Request object. |
| |
| See GitHub API docs: |
| https://docs.github.com/en/rest/pulls/pulls#list-pull-requests-files |
| """ |
| |
| path: str |
| additions: int |
| deletions: int |
| changeType: str |
| |
| |
| class PrDict(TypedDict, total=False): |
| """In-memory representation of a GitHub Pull Request object. |
| |
| See GitHub API docs: |
| https://docs.github.com/en/rest/pulls/pulls#get-a-pull-request |
| """ |
| |
| number: int |
| title: str |
| body: str |
| base: str |
| head: str |
| labels: list[str] |
| url: str |
| state: str |
| isDraft: bool |
| mergeCommit: dict[str, str] |
| auto_merge: AutoMergeDict |
| files: list[PrFileDict] |
| |
| |
| class GitHubEventPullRequestDict(TypedDict, total=False): |
| """Pull request object in a GitHub Actions event payload. |
| |
| See GitHub Webhook events docs: |
| https://docs.github.com/en/webhooks/webhook-events-and-payloads#pull_request |
| """ |
| |
| number: int |
| |
| |
| class GitHubEventIssueDict(TypedDict, total=False): |
| """Issue object in a GitHub Actions event payload. |
| |
| See GitHub Webhook events docs: |
| https://docs.github.com/en/webhooks/webhook-events-and-payloads#issues |
| """ |
| |
| number: int |
| |
| |
| class GitHubEventDict(TypedDict, total=False): |
| """Representation of a GitHub Actions event payload JSON ($GITHUB_EVENT_PATH). |
| |
| See GitHub Actions events docs: |
| https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows |
| """ |
| |
| inputs: dict[str, object] |
| pull_request: GitHubEventPullRequestDict |
| issue: GitHubEventIssueDict |
| number: int | None |
| |
| |
| def get_github_event_data() -> GitHubEventDict: |
| """Loads JSON data from GITHUB_EVENT_PATH if set.""" |
| event_path = os.environ.get("GITHUB_EVENT_PATH") |
| if not event_path or not os.path.isfile(event_path): |
| return {} |
| with open(event_path, "r", encoding="utf-8") as f: |
| return json.load(f) |
| |
| |
| def get_github_event_pr_number() -> int | None: |
| """Extracts PR number from GITHUB_EVENT_PATH if available.""" |
| data = get_github_event_data() |
| if not data: |
| return None |
| if ( |
| "inputs" in data |
| and isinstance(data["inputs"], dict) |
| and data["inputs"].get("pr") |
| ): |
| pr_val = str(data["inputs"]["pr"]).lstrip("#") |
| if pr_val.isdigit(): |
| return int(pr_val) |
| if ( |
| "pull_request" in data |
| and isinstance(data["pull_request"], dict) |
| and data["pull_request"].get("number") |
| ): |
| return int(data["pull_request"]["number"]) |
| if "number" in data and isinstance(data["number"], int): |
| return data["number"] |
| return None |
| |
| |
| def get_github_event_issue_number() -> int | None: |
| """Extracts Issue number from GITHUB_EVENT_PATH if available.""" |
| data = get_github_event_data() |
| if not data: |
| return None |
| if ( |
| "inputs" in data |
| and isinstance(data["inputs"], dict) |
| and data["inputs"].get("issue") |
| ): |
| issue_val = str(data["inputs"]["issue"]).lstrip("#") |
| if issue_val.isdigit(): |
| return int(issue_val) |
| if ( |
| "issue" in data |
| and isinstance(data["issue"], dict) |
| and data["issue"].get("number") |
| ): |
| return int(data["issue"]["number"]) |
| return None |
| |
| |
| class MultipleTrackingIssuesError(ValueError): |
| """Raised when multiple open tracking issues are found for a version.""" |
| |
| pass |
| |
| |
| class NoTrackingIssueError(ValueError): |
| """Raised when no open tracking issue is found for a version.""" |
| |
| pass |
| |
| |
| class CreatePrError(Exception): |
| """Raised when creating a pull request fails.""" |
| |
| pass |
| |
| |
| class GetPrError(ValueError): |
| """Raised when querying a pull request fails.""" |
| |
| pass |
| |
| |
| class InvalidPrRefError(ValueError): |
| """Raised when a PR reference cannot be resolved.""" |
| |
| pass |
| |
| |
| class GitHubInterface(abc.ABC): |
| """Abstract interface for GitHub operations.""" |
| |
| repo: str |
| |
| @abc.abstractmethod |
| def post_issue_comment(self, issue_num: int, comment_body: str) -> None: |
| """Posts a comment on an issue or PR. |
| |
| Args: |
| issue_num: The issue or PR number. |
| comment_body: The body content of the comment. |
| """ |
| |
| @abc.abstractmethod |
| def add_comment_reaction(self, comment_id: int, reaction: str) -> None: |
| """Adds a reaction to an issue or PR comment. |
| |
| Args: |
| comment_id: The comment ID. |
| reaction: The reaction type (e.g., "+1", "-1", "rocket"). |
| """ |
| |
| @abc.abstractmethod |
| def enable_auto_merge(self, pr_num: int, method: str = "squash") -> None: |
| """Enables auto-merge for a PR. |
| |
| Args: |
| pr_num: The PR number. |
| method: The merge method ('squash', 'rebase', or 'merge'). |
| """ |
| |
| @abc.abstractmethod |
| def create_issue( |
| self, title: str, body: str, labels: list[str] | None = None |
| ) -> int: |
| """Creates an issue. |
| |
| Args: |
| title: Title of the issue. |
| body: Body text of the issue. |
| labels: Optional list of labels to add. |
| |
| Returns: |
| The created issue number. |
| """ |
| |
| @abc.abstractmethod |
| def create_release_tracking_issue(self, version: str, template_content: str) -> int: |
| """Creates a release tracking issue from a template. |
| |
| Args: |
| version: Release version string (e.g., "1.0.0"). |
| template_content: Content of the issue template markdown file. |
| |
| Returns: |
| The created issue number. |
| """ |
| |
| @abc.abstractmethod |
| def get_issue_body(self, issue_num: int) -> str: |
| """Gets the body content of an issue. |
| |
| Args: |
| issue_num: The issue number. |
| |
| Returns: |
| The body string of the issue. |
| """ |
| |
| @abc.abstractmethod |
| def get_issue_title(self, issue_num: int) -> str: |
| """Gets the title of an issue. |
| |
| Args: |
| issue_num: The issue number. |
| |
| Returns: |
| The title string of the issue. |
| """ |
| |
| @abc.abstractmethod |
| def update_issue_body(self, issue_num: int, body: str) -> None: |
| """Updates the body of an issue. |
| |
| Args: |
| issue_num: The issue number. |
| body: The new body content. |
| """ |
| |
| @abc.abstractmethod |
| def resolve_pr_number(self, pr_ref: str) -> int: |
| """Resolves a PR reference (number, #number, or GitHub URL) to a PR number. |
| |
| Args: |
| pr_ref: PR number string (e.g., "123", "#123") or URL. |
| |
| Returns: |
| The integer PR number. |
| |
| Raises: |
| InvalidPrRefError: If the PR reference cannot be resolved or is for |
| another repository. |
| """ |
| |
| @abc.abstractmethod |
| def get_release_tracking_issue(self, version: str) -> int: |
| """Finds the single open tracking issue for a given version. |
| |
| Args: |
| version: Version string (e.g., "1.0.0"). |
| |
| Returns: |
| The issue number. |
| |
| Raises: |
| NoTrackingIssueError: If no open tracking issue is found. |
| MultipleTrackingIssuesError: If multiple open tracking issues are |
| found. |
| """ |
| |
| @abc.abstractmethod |
| def create_pr( |
| self, |
| title: str, |
| body: str, |
| base: str = "main", |
| labels: list[str] | None = None, |
| ) -> str: |
| """Creates a pull request. |
| |
| Args: |
| title: Title of the PR. |
| body: Body of the PR. |
| base: Base branch to merge into (default: "main"). |
| labels: Optional list of labels to add. |
| |
| Returns: |
| The URL of the created PR. |
| """ |
| |
| @abc.abstractmethod |
| def get_open_pr(self, branch_name: str) -> PrDict | None: |
| """Finds an open PR for the given branch. |
| |
| Args: |
| branch_name: The head branch name to search for. |
| |
| Returns: |
| Dictionary containing PR details if open, else None. |
| """ |
| |
| @abc.abstractmethod |
| def get_open_tracking_issues(self, version: str | None = None) -> list[IssueDict]: |
| """Finds open tracking issues for release. |
| |
| Args: |
| version: Optional specific version to match (e.g., "1.0.0"). |
| |
| Returns: |
| List of matching open release tracking issue dictionaries. |
| """ |
| |
| @abc.abstractmethod |
| def get_pr_info(self, pr_num: int) -> PrDict: |
| """Gets info about a PR. |
| |
| Args: |
| pr_num: The PR number. |
| |
| Returns: |
| Dictionary containing PR fields (state, isDraft, mergeCommit, etc.). |
| |
| Raises: |
| GetPrError: If querying the PR fails. |
| """ |
| |
| @abc.abstractmethod |
| def get_pr_files(self, pr_num: int) -> list[str]: |
| """Gets the list of file paths touched by a PR. |
| |
| Args: |
| pr_num: The PR number. |
| |
| Returns: |
| A list of file paths. |
| |
| Raises: |
| GetPrError: If querying the PR fails. |
| """ |
| |
| @abc.abstractmethod |
| def get_pr_comments(self, pr_num: int) -> list[dict]: |
| """Gets all comments for a PR. |
| |
| Args: |
| pr_num: The PR number. |
| |
| Returns: |
| List of comment objects. |
| |
| Raises: |
| GetPrError: If querying the PR fails. |
| """ |
| |
| @abc.abstractmethod |
| def get_merge_commits_for_prs( |
| self, pending_items: list[BackportTask] |
| ) -> list[BackportTask]: |
| """Resolves PR references in pending backports to their merge commit SHAs. |
| |
| Args: |
| pending_items: A list of BackportTask items to resolve. |
| |
| Returns: |
| The list of resolved BackportTask items. |
| """ |
| |
| |
| class GitHub(GitHubInterface): |
| """GitHub CLI helper class for the release tool.""" |
| |
| def __init__(self, repo: str = "bazel-contrib/rules_python"): |
| """Initializes the GitHub helper. |
| |
| Args: |
| repo: The GitHub repository to operate on. |
| """ |
| self.repo = repo |
| |
| def _run_gh( |
| self, *args: str, check: bool = True, capture_output: bool = True |
| ) -> str | None: |
| """Runs a 'gh' command. |
| |
| Args: |
| *args: Arguments for 'gh' (excluding 'gh'). |
| check: If True, raises CalledProcessError on failure. |
| capture_output: If True, captures and returns stdout. |
| |
| Returns: |
| The stdout of the command, stripped, or None. |
| """ |
| return run_cmd("gh", *args, check=check, capture_output=capture_output) |
| |
| def _gh_issue( |
| self, *args: str, check: bool = True, capture_output: bool = True |
| ) -> str | None: |
| """Runs a 'gh issue' command.""" |
| return self._run_gh( |
| "issue", |
| *args, |
| f"--repo={self.repo}", |
| check=check, |
| capture_output=capture_output, |
| ) |
| |
| def _gh_pr( |
| self, *args: str, check: bool = True, capture_output: bool = True |
| ) -> str | None: |
| """Runs a 'gh pr' command.""" |
| return self._run_gh( |
| "pr", |
| *args, |
| f"--repo={self.repo}", |
| check=check, |
| capture_output=capture_output, |
| ) |
| |
| def list_issues( |
| self, |
| *, |
| fields: str, |
| label: str | None = None, |
| state: str | None = None, |
| search: str | None = None, |
| ) -> list[IssueDict]: |
| """Helper to list issues using gh CLI. |
| |
| Args: |
| fields: Comma-separated list of fields to return. |
| label: Filter by label. |
| state: Filter by state ('open', 'closed', 'all'). |
| search: Search query. |
| |
| Returns: |
| A list of issue dictionaries. |
| """ |
| cmd = ["list", f"--json={fields}"] |
| if label: |
| cmd.append(f"--label={label}") |
| if state: |
| cmd.append(f"--state={state}") |
| if search: |
| cmd.append(f"--search={search}") |
| |
| output = self._gh_issue(*cmd) |
| return json.loads(output) if output else [] |
| |
| @override |
| def get_open_tracking_issues(self, version: str | None = None) -> list[IssueDict]: |
| """Finds open tracking issues for release. |
| |
| Args: |
| version: Optional specific version to match (e.g., "1.0.0"). |
| |
| Returns: |
| List of matching open release tracking issue dictionaries. |
| """ |
| search = f"Release {version}" if version else None |
| return self.list_issues( |
| fields="number,title,url", |
| label=RELEASE_LABEL, |
| state="open", |
| search=search, |
| ) |
| |
| @override |
| def get_release_tracking_issue(self, version: str) -> int: |
| """Finds the single open tracking issue for a given version. |
| |
| Args: |
| version: Version string (e.g. "1.0.0"). |
| |
| Returns: |
| The issue number. |
| |
| Raises: |
| NoTrackingIssueError: If no open tracking issue is found. |
| MultipleTrackingIssuesError: If multiple open tracking issues are found. |
| """ |
| issues = self.get_open_tracking_issues(version) |
| matching = [i for i in issues if i["title"] == f"Release {version}"] |
| 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}: " |
| + ", ".join(str(i["number"]) for i in matching) |
| ) |
| return matching[0]["number"] |
| |
| @override |
| def create_issue( |
| self, title: str, body: str, labels: list[str] | None = None |
| ) -> int: |
| """Creates an issue using gh CLI. |
| |
| Args: |
| title: Title of the issue. |
| body: Body of the issue. |
| labels: List of labels to add. |
| |
| Returns: |
| The issue number. |
| """ |
| cmd = ["create", f"--title={title}", f"--body={body}"] |
| if labels: |
| for label in labels: |
| cmd.append(f"--label={label}") |
| |
| output = self._gh_issue(*cmd) |
| if not output: |
| raise RuntimeError("gh issue create returned no output") |
| # output is URL: https://github.com/owner/repo/issues/123 |
| return int(output.rstrip("/").split("/")[-1]) |
| |
| @override |
| def create_release_tracking_issue(self, version: str, template_content: str) -> int: |
| """Creates a release tracking issue from a template. |
| |
| Args: |
| version: Release version string (e.g., "1.0.0"). |
| template_content: Content of the issue template markdown file. |
| |
| Returns: |
| The created issue number. |
| """ |
| title = f"Release {version}" |
| # Strip YAML frontmatter if present |
| 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=title, body=issue_body, labels=[RELEASE_LABEL]) |
| |
| @override |
| def get_issue_body(self, issue_num: int) -> str: |
| """Gets the body content of an issue. |
| |
| Args: |
| issue_num: The issue number. |
| |
| Returns: |
| The body string of the issue. |
| """ |
| output = self._gh_issue("view", str(issue_num), "--json=body") |
| if not output: |
| return "" |
| data = json.loads(output) |
| return data.get("body", "") |
| |
| @override |
| def get_issue_title(self, issue_num: int) -> str: |
| """Gets the title of an issue. |
| |
| Args: |
| issue_num: The issue number. |
| |
| Returns: |
| The title string of the issue. |
| """ |
| output = self._gh_issue("view", str(issue_num), "--json=title") |
| if not output: |
| return "" |
| data = json.loads(output) |
| return data.get("title", "") |
| |
| @override |
| def update_issue_body(self, issue_num: int, body: str) -> None: |
| """Updates the body of an issue. |
| |
| Args: |
| issue_num: The issue number. |
| body: The new body content. |
| """ |
| with tempfile.NamedTemporaryFile(mode="w", delete=False, encoding="utf-8") as f: |
| f.write(body) |
| f.flush() |
| temp_path = f.name |
| |
| try: |
| self._gh_issue( |
| "edit", str(issue_num), f"--body-file={temp_path}", capture_output=False |
| ) |
| finally: |
| if os.path.exists(temp_path): |
| os.remove(temp_path) |
| |
| @override |
| def resolve_pr_number(self, pr_ref: str) -> int: |
| """Resolves a PR reference (number, #number, or GitHub URL) to a PR number. |
| |
| Args: |
| pr_ref: PR number string (e.g., "123", "#123") or URL. |
| |
| Returns: |
| The integer PR number. |
| |
| Raises: |
| InvalidPrRefError: If the PR reference cannot be resolved or is for another repo. |
| """ |
| 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 reference: {pr_ref}") |
| |
| def _gh_pr_view(self, pr_num: int, *fields: str) -> str: |
| """Helper to run `gh pr view` with specified JSON fields. |
| |
| Args: |
| pr_num: The PR number. |
| *fields: JSON fields to request (e.g., "state", "files"). |
| |
| Returns: |
| The raw JSON output string from gh. |
| |
| Raises: |
| GetPrError: If querying the PR fails. |
| """ |
| args = ["view", str(pr_num)] |
| if fields: |
| args.append(f"--json={','.join(fields)}") |
| try: |
| output = self._gh_pr(*args) |
| return output or "" |
| except subprocess.CalledProcessError as e: |
| raise GetPrError(f"Failed to get PR #{pr_num} on {self.repo}: {e}") from e |
| |
| @override |
| def get_pr_info(self, pr_num: int) -> PrDict: |
| """Gets info about a PR using gh CLI. |
| |
| Args: |
| pr_num: The PR number. |
| |
| Returns: |
| Dictionary containing PR fields (state, isDraft, mergeCommit, etc.). |
| """ |
| output = self._gh_pr_view(pr_num, "state", "isDraft", "mergeCommit") |
| return json.loads(output) if output else {} |
| |
| @override |
| def get_pr_files(self, pr_num: int) -> list[str]: |
| """Gets the list of file paths touched by a PR using gh CLI. |
| |
| Args: |
| pr_num: The PR number. |
| |
| Returns: |
| A list of file paths. |
| |
| Raises: |
| GetPrError: If querying the PR fails. |
| """ |
| output = self._gh_pr_view(pr_num, "files") |
| if not output: |
| return [] |
| data: PrDict = json.loads(output) |
| files = data.get("files", []) |
| return [f["path"] for f in files] |
| |
| @override |
| def get_pr_comments(self, pr_num: int) -> list[dict]: |
| """Gets all comments for a PR using gh CLI. |
| |
| Args: |
| pr_num: The PR number. |
| |
| Returns: |
| List of comment objects (with body, author, etc.). |
| """ |
| output = self._gh_pr_view(pr_num, "comments") |
| if not output: |
| return [] |
| data = json.loads(output) |
| return data.get("comments", []) |
| |
| @override |
| def create_pr( |
| self, |
| title: str, |
| body: str, |
| base: str = "main", |
| labels: list[str] | None = None, |
| ) -> str: |
| """Creates a pull request. |
| |
| Args: |
| title: Title of the PR. |
| body: Body of the PR. |
| base: Base branch to merge into (default: "main"). |
| labels: Optional list of labels to add. |
| |
| Returns: |
| The URL of the created PR. |
| |
| Raises: |
| CreatePrError: If creating the pull request fails. |
| """ |
| cmd = [ |
| "create", |
| f"--title={title}", |
| f"--body={body}", |
| f"--base={base}", |
| ] |
| if labels: |
| for label in labels: |
| cmd.append(f"--label={label}") |
| try: |
| output = self._gh_pr(*cmd) |
| except subprocess.CalledProcessError as e: |
| msg = f"Failed to create PR '{title}': {e}" |
| if e.stdout: |
| msg += ( |
| f"\n{'=' * 20} STDOUT BEGIN {'=' * 20}\n" |
| f"{e.stdout}\n" |
| f"{'=' * 20} STDOUT END {'=' * 20}" |
| ) |
| if e.stderr: |
| msg += ( |
| f"\n{'=' * 20} STDERR BEGIN {'=' * 20}\n" |
| f"{e.stderr}\n" |
| f"{'=' * 20} STDERR END {'=' * 20}" |
| ) |
| raise CreatePrError(msg) from e |
| except Exception as e: |
| raise CreatePrError(f"Failed to create PR '{title}': {e}") from e |
| if not output: |
| raise CreatePrError( |
| f"Failed to create PR '{title}': gh pr create returned no output" |
| ) |
| return output |
| |
| @override |
| def enable_auto_merge(self, pr_num: int, method: str = "squash") -> None: |
| """Enables auto-merge for a PR. |
| |
| Args: |
| pr_num: The PR number. |
| method: The merge method ('squash', 'rebase', or 'merge'). |
| """ |
| cmd = ["merge", str(pr_num), "--auto"] |
| if method == "squash": |
| cmd.append("--squash") |
| elif method == "rebase": |
| cmd.append("--rebase") |
| elif method == "merge": |
| cmd.append("--merge") |
| self._gh_pr(*cmd, capture_output=False) |
| |
| @override |
| def get_open_pr(self, branch_name: str) -> PrDict | None: |
| """Finds an open PR for the given branch. |
| |
| Args: |
| branch_name: The head branch name to search for. |
| |
| Returns: |
| Dictionary with 'number' and 'url' if an open PR exists, else None. |
| """ |
| cmd = [ |
| "list", |
| f"--head={branch_name}", |
| "--state=open", |
| "--json=number,url", |
| ] |
| output = self._gh_pr(*cmd) |
| prs = json.loads(output) if output else [] |
| return prs[0] if prs else None |
| |
| @override |
| def post_issue_comment(self, issue_num: int, comment_body: str) -> None: |
| """Posts a comment on an issue or PR. |
| |
| Args: |
| issue_num: The issue or PR number. |
| comment_body: The body content of the comment. |
| """ |
| self._gh_issue( |
| "comment", |
| str(issue_num), |
| f"--body={comment_body}", |
| capture_output=False, |
| ) |
| |
| @override |
| def add_comment_reaction(self, comment_id: int, reaction: str) -> None: |
| """Adds a reaction to an issue or PR comment. |
| |
| Args: |
| comment_id: The comment ID (note: gh api endpoint needed for comment reactions). |
| reaction: The reaction type (e.g., "+1", "-1", "rocket"). |
| """ |
| self._run_gh( |
| "api", |
| f"repos/{self.repo}/issues/comments/{comment_id}/reactions", |
| "-f", |
| f"content={reaction}", |
| capture_output=False, |
| ) |
| |
| @override |
| def get_merge_commits_for_prs( |
| self, pending_items: list[BackportTask] |
| ) -> list[BackportTask]: |
| """Resolves PR references in pending backports to their merge commit SHAs. |
| |
| Updates item.status based on PR state if it cannot be resolved. |
| |
| Args: |
| pending_items: A list of BackportTask items to resolve. |
| |
| Returns: |
| The list of resolved BackportTask items. |
| """ |
| return resolve_merge_commits_for_prs(self, pending_items) |
| |
| |
| def resolve_merge_commits_for_prs( |
| gh_client: GitHubInterface, pending_items: list[BackportTask] |
| ) -> list[BackportTask]: |
| """Resolves PR references in pending backports to their merge commit SHAs. |
| |
| Updates item.status based on PR state if it cannot be resolved. |
| |
| Args: |
| gh_client: The GitHub client. |
| pending_items: A list of BackportTask items to resolve. |
| |
| Returns: |
| The list of resolved BackportTask items. |
| """ |
| resolved_items = [] |
| for item in pending_items: |
| pr_num = int(item.pr_ref.lstrip("#")) |
| print(f"Resolving PR #{pr_num} to merge commit...") |
| try: |
| pr_info = gh_client.get_pr_info(pr_num) |
| if not pr_info: |
| print(f"PR #{pr_num} not found. Gating.") |
| item.status = BackportTaskStatus.ERROR_NOT_FOUND |
| else: |
| state = pr_info.get("state") |
| is_draft = pr_info.get("isDraft", False) |
| if state == "OPEN" or is_draft: |
| print( |
| f"PR #{pr_num} is open or draft (state: {state}," |
| f" draft: {is_draft}). Ignoring." |
| ) |
| item.status = ( |
| BackportTaskStatus.OPEN_PR |
| if not is_draft |
| else BackportTaskStatus.DRAFT_PR |
| ) |
| elif state == "CLOSED": |
| print(f"PR #{pr_num} is closed but not merged. Gating.") |
| item.status = BackportTaskStatus.ERROR_CLOSED_PR |
| elif state == "MERGED": |
| merge_commit = pr_info.get("mergeCommit") |
| if merge_commit and "oid" in merge_commit: |
| item.commit = merge_commit["oid"] |
| item.status = BackportTaskStatus.RESOLVED |
| else: |
| print(f"PR #{pr_num} has no merge commit SHA. Gating.") |
| item.status = BackportTaskStatus.ERROR_NO_MERGE_COMMIT |
| else: |
| print(f"PR #{pr_num} has unknown state: {state}. Gating.") |
| item.status = BackportTaskStatus.ERROR_UNKNOWN |
| except Exception as e: |
| print(f"Error resolving PR #{pr_num}: {e}. Gating.") |
| item.status = BackportTaskStatus.ERROR_RESOLUTION_FAILED |
| resolved_items.append(item) |
| return resolved_items |