From 5b53d4b083c68accdc72e3bc9edd645a3775334e Mon Sep 17 00:00:00 2001 From: An Long Date: Sun, 8 Nov 2026 16:13:19 +0900 Subject: [PATCH] Create PR via gh when GH_AUTH is not set --- README.md | 5 ++- cherry_picker/cherry_picker.py | 68 ++++++++++++++++++++++++++--- cherry_picker/test_cherry_picker.py | 22 ++++++++++ 3 files changed, 86 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 06988df..40915af 100644 --- a/README.md +++ b/README.md @@ -33,8 +33,9 @@ options: --auto-pr, --no-auto-pr If auto PR is enabled, cherry-picker will automatically open a PR through API if - GH_AUTH env var is set, or automatically open - the PR creation page in the web browser otherwise. + GH_AUTH env var is set, through the GitHub CLI + if it is logged in, or automatically open the PR + creation page in the web browser otherwise. --config-path CONFIG-PATH Path to config file, .cherry_picker.toml from project root by default. You can prepend a colon- diff --git a/cherry_picker/cherry_picker.py b/cherry_picker/cherry_picker.py index 5d17035..15cd9df 100755 --- a/cherry_picker/cherry_picker.py +++ b/cherry_picker/cherry_picker.py @@ -8,6 +8,7 @@ import functools import os import re +import shutil import subprocess import sys import tomllib @@ -448,6 +449,15 @@ def push_to_remote(self, base_branch, head_branch, commit_message=""): except GitHubException: set_state(WORKFLOW_STATES.PR_CREATING_FAILED) raise + elif self.gh_cli_available(): + set_state(WORKFLOW_STATES.PR_CREATING) + try: + self.create_pr_with_gh_cli( + base_branch, head_branch, commit_message=commit_message + ) + except GitHubException: + set_state(WORKFLOW_STATES.PR_CREATING_FAILED) + raise else: set_state(WORKFLOW_STATES.PR_OPENING) self.open_pr(self.get_pr_url(base_branch, head_branch)) @@ -463,10 +473,7 @@ def create_gh_pr(self, base_branch, head_branch, *, commit_message, gh_auth): "Authorization": f"token {gh_auth}", "X-GitHub-Api-Version": "2022-11-28", } - title, body = normalize_commit_message(commit_message) - if not self.prefix_commit: - title = remove_commit_prefix(title) - title = f"[{base_branch}] {title}" + title, body = self.get_pr_title_and_body(base_branch, commit_message) data = { "title": title, "body": body, @@ -493,6 +500,52 @@ def create_gh_pr(self, base_branch, head_branch, *, commit_message, gh_auth): print(f"Backport PR created at {response_data['html_url']}") self.pr_number = response_data["number"] + def get_pr_title_and_body( + self, base_branch: str, commit_message: str + ) -> tuple[str, str]: + title, body = normalize_commit_message(commit_message) + if not self.prefix_commit: + title = remove_commit_prefix(title) + title = f"[{base_branch}] {title}" + return title, body + + def gh_cli_available(self) -> bool: + if shutil.which("gh") is None: + return False + cmd = ["gh", "auth", "status", "--hostname", "github.com"] + return subprocess.run(cmd, capture_output=True).returncode == 0 + + @stamina.retry(on=GitHubException, timeout=120) + def create_pr_with_gh_cli( + self, base_branch: str, head_branch: str, *, commit_message: str + ) -> None: + title, body = self.get_pr_title_and_body(base_branch, commit_message) + cmd = [ + "gh", + "pr", + "create", + "--repo", + f"{self.config['team']}/{self.config['repo']}", + "--base", + base_branch, + "--head", + f"{self.username}:{head_branch}", + "--title", + title, + "--body", + body, + ] + if self.config["draft_pr"]: + cmd.append("--draft") + try: + output = self.run_cmd(cmd) + except subprocess.CalledProcessError as exc: + raise GitHubException(f"Creating PR with gh failed: {_decode(exc.output)}") + if output: + url = output.strip().splitlines()[-1] + print(f"Backport PR created at {url}") + self.pr_number = int(url.rsplit("/", 1)[-1]) + def open_pr(self, url): """ open url in the web browser @@ -664,7 +717,7 @@ def continue_cherry_pick(self): self.run_cmd(cmd) if self.push: - self.push_to_remote(base, cherry_pick_branch) + self.push_to_remote(base, cherry_pick_branch, commit_message) if not self.is_mirror(): self.cleanup_branch(cherry_pick_branch) @@ -797,8 +850,9 @@ def get_parser(): default=True, help=( "If auto PR is enabled, cherry-picker will automatically open a PR " - "through API if `GH_AUTH` env var is set, or automatically open the PR " - "creation page in the web browser otherwise." + "through API if `GH_AUTH` env var is set, through the GitHub CLI if it " + "is logged in, or automatically open the PR creation page in the web " + "browser otherwise." ), ) parser.add_argument( diff --git a/cherry_picker/test_cherry_picker.py b/cherry_picker/test_cherry_picker.py index 7548015..7c34d8c 100644 --- a/cherry_picker/test_cherry_picker.py +++ b/cherry_picker/test_cherry_picker.py @@ -972,6 +972,7 @@ def test_push_to_remote_interactive(tmp_git_repo_dir): with ( mock.patch.object(cherry_picker, "run_cmd"), + mock.patch.object(cherry_picker, "gh_cli_available", return_value=False), mock.patch.object(cherry_picker, "open_pr"), mock.patch.object(cherry_picker, "get_pr_url", return_value="https://pr_url"), ): @@ -979,6 +980,27 @@ def test_push_to_remote_interactive(tmp_git_repo_dir): assert get_state() == WORKFLOW_STATES.PR_OPENING +@mock.patch.object(CherryPicker, "username", new_callable=mock.PropertyMock) +def test_push_to_remote_gh_cli(mock_username, tmp_git_repo_dir, monkeypatch): + monkeypatch.delenv("GH_AUTH", raising=False) + mock_username.return_value = "username" + with mock.patch("cherry_picker.cherry_picker.validate_sha", return_value=True): + cherry_picker = CherryPicker("origin", "xxx", []) + + with ( + mock.patch.object(cherry_picker, "gh_cli_available", return_value=True), + mock.patch.object( + cherry_picker, + "run_cmd", + return_value="https://github.com/python/cpython/pull/1347\n", + ) as run_cmd, + ): + cherry_picker.push_to_remote("3.14", "backport-branch-test", "Title") + + assert run_cmd.call_args.args[0][:3] == ["gh", "pr", "create"] + assert cherry_picker.pr_number == 1347 + + def test_push_to_remote_botflow(tmp_git_repo_dir, monkeypatch): monkeypatch.setenv("GH_AUTH", "True") with mock.patch("cherry_picker.cherry_picker.validate_sha", return_value=True):