Skip to content

Commit 2424283

Browse files
committed
Add comment driven PR triage commands
1 parent 76875bb commit 2424283

7 files changed

Lines changed: 325 additions & 25 deletions

File tree

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
#!/usr/bin/env python3
2+
"""Run PR triage slash commands from issue comments."""
3+
4+
from __future__ import annotations
5+
6+
import argparse
7+
import json
8+
import os
9+
import subprocess
10+
import sys
11+
import traceback
12+
from pathlib import Path
13+
from typing import Any
14+
from urllib.parse import quote
15+
16+
from common import REPO_ROOT, gh, gh_json, progress
17+
18+
19+
COMMANDS = {
20+
"/run-spotless": "run_spotless",
21+
"/fix-ci": "fix_ci",
22+
"/resolve-conflicts": "resolve_conflicts",
23+
"/review": "review",
24+
}
25+
OUTPUT_LIMIT = 6000
26+
27+
28+
def parse_args() -> argparse.Namespace:
29+
parser = argparse.ArgumentParser(description=__doc__)
30+
subparsers = parser.add_subparsers(dest="command", required=True)
31+
subparsers.add_parser("authorize", help="authorize the issue commenter and PR author")
32+
subparsers.add_parser("execute", help="run the selected PR triage command and comment with the result")
33+
return parser.parse_args()
34+
35+
36+
def event_payload() -> dict[str, Any]:
37+
path = os.environ.get("GITHUB_EVENT_PATH")
38+
if not path:
39+
raise RuntimeError("GITHUB_EVENT_PATH is not set")
40+
return json.loads(Path(path).read_text(encoding="utf-8"))
41+
42+
43+
def event_repo() -> str:
44+
return os.environ["GITHUB_REPOSITORY"]
45+
46+
47+
def pr_number() -> str:
48+
return os.environ["PR_NUMBER"]
49+
50+
51+
def parsed_command() -> tuple[str, str]:
52+
payload = event_payload()
53+
comment = payload.get("comment") or {}
54+
body = str(comment.get("body") or "").strip()
55+
first_line = body.splitlines()[0].strip() if body else ""
56+
requested = first_line.split(maxsplit=1)[0].lower() if first_line else ""
57+
command = COMMANDS.get(requested, "")
58+
return requested, command
59+
60+
61+
def gh_json_or_none(args: list[str]) -> object | None:
62+
result = gh(args, check=False)
63+
if result.returncode == 0:
64+
return json.loads(result.stdout or "null")
65+
if "HTTP 404" in result.stderr or "Not Found" in result.stderr:
66+
return None
67+
raise RuntimeError(result.stderr.strip() or result.stdout.strip() or "gh api failed")
68+
69+
70+
def has_repo_write(login: str) -> bool:
71+
encoded_login = quote(login, safe="")
72+
data = gh_json_or_none(["api", f"repos/{event_repo()}/collaborators/{encoded_login}/permission"])
73+
if not isinstance(data, dict):
74+
return False
75+
return data.get("permission") in {"admin", "maintain", "write"}
76+
77+
78+
def comment_on_pr(body: str) -> None:
79+
gh(["issue", "comment", pr_number(), "--repo", event_repo(), "--body", body])
80+
81+
82+
def authorization_reason() -> str | None:
83+
payload = event_payload()
84+
comment = payload.get("comment") or {}
85+
commenter = str((comment.get("user") or {}).get("login") or "")
86+
87+
if not has_repo_write(commenter):
88+
return (
89+
"for security reasons, PR triage commands can only be run by users "
90+
"who have write access to this repository"
91+
)
92+
93+
pr = gh_json(["api", f"repos/{event_repo()}/pulls/{pr_number()}"])
94+
author = pr["user"]["login"]
95+
if not has_repo_write(author):
96+
return (
97+
"for security reasons, PR triage commands are only supported on PRs "
98+
"from authors who already have write access to this repository"
99+
)
100+
return None
101+
102+
103+
def authorize_command() -> int:
104+
requested, command = parsed_command()
105+
if requested != "/help" and not command:
106+
# Not a recognized PR triage command: stay silent and skip.
107+
print("false")
108+
return 0
109+
reason = authorization_reason()
110+
if reason:
111+
comment_on_pr(f"I did not run `{requested}`: {reason}.")
112+
print("false")
113+
return 0
114+
print("true")
115+
return 0
116+
117+
118+
def output_file() -> Path:
119+
return Path(os.environ["RUNNER_TEMP"]) / "pr-triage-output.txt"
120+
121+
122+
def command_args(command: str) -> list[str]:
123+
if command == "run_spotless":
124+
return [sys.executable, ".github/scripts/pr-triage/run_spotless.py", pr_number()]
125+
if command == "fix_ci":
126+
return [sys.executable, ".github/scripts/pr-triage/fix_ci.py", pr_number()]
127+
if command == "resolve_conflicts":
128+
return [sys.executable, ".github/scripts/pr-triage/resolve_conflicts.py", pr_number()]
129+
if command == "review":
130+
return [sys.executable, ".github/scripts/pr-triage/review.py", pr_number()]
131+
raise RuntimeError(f"Unknown command: {command}")
132+
133+
134+
def run_command(command: str) -> int:
135+
cmd = command_args(command)
136+
progress("Running: " + " ".join(cmd))
137+
path = output_file()
138+
with path.open("w", encoding="utf-8", errors="replace") as output:
139+
proc = subprocess.Popen(
140+
cmd,
141+
cwd=REPO_ROOT,
142+
stdout=subprocess.PIPE,
143+
stderr=subprocess.STDOUT,
144+
text=True,
145+
encoding="utf-8",
146+
errors="replace",
147+
bufsize=1,
148+
)
149+
if proc.stdout is not None:
150+
for line in proc.stdout:
151+
print(line, end="", flush=True)
152+
output.write(line)
153+
status = proc.wait()
154+
return status
155+
156+
157+
def finish_command(requested: str, exit_code: int) -> int:
158+
if exit_code == 0:
159+
status = "completed successfully"
160+
else:
161+
status = "failed"
162+
163+
path = output_file()
164+
if path.exists():
165+
output = path.read_text(encoding="utf-8", errors="replace")
166+
if len(output) > OUTPUT_LIMIT:
167+
output = "...[output truncated]...\n" + output[-OUTPUT_LIMIT:]
168+
else:
169+
output = "No command output was captured."
170+
171+
comment_on_pr(f"`{requested}` {status}.\n\n```text\n{output}\n```")
172+
return 0
173+
174+
175+
def execute_command() -> int:
176+
requested, command = parsed_command()
177+
if requested == "/help":
178+
supported = ", ".join(f"`{command_name}`" for command_name in COMMANDS)
179+
comment_on_pr(f"Supported PR triage commands: {supported}.")
180+
return 0
181+
if not command:
182+
# authorize_command() already filtered unsupported/unauthorized cases.
183+
return 0
184+
185+
try:
186+
exit_code = run_command(command)
187+
except Exception:
188+
exit_code = 1
189+
output_file().write_text(traceback.format_exc(), encoding="utf-8", errors="replace")
190+
finish_command(requested, exit_code)
191+
raise
192+
193+
finish_command(requested, exit_code)
194+
return exit_code
195+
196+
197+
def main() -> int:
198+
args = parse_args()
199+
if args.command == "authorize":
200+
return authorize_command()
201+
if args.command == "execute":
202+
return execute_command()
203+
raise AssertionError(args.command)
204+
205+
206+
if __name__ == "__main__":
207+
sys.exit(main())

.github/scripts/pr-triage/common.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -238,10 +238,9 @@ def ensure_pr_push_allowed(pr: int, summary: Summary) -> dict[str, Any]:
238238
return metadata
239239

240240

241-
def checkout_pr(pr: int, summary: Summary) -> dict[str, Any]:
242-
progress(f"Checking out PR #{pr}")
243-
gh(["pr", "checkout", str(pr)], summary)
241+
def verify_pr_checkout(pr: int, summary: Summary) -> dict[str, Any]:
244242
summary.pr_branch = current_branch(summary)
243+
progress(f"Using checked out PR branch: {summary.pr_branch}")
245244
return ensure_pr_push_allowed(pr, summary)
246245

247246

.github/scripts/pr-triage/fix_ci.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@
1212
from common import (
1313
Summary,
1414
changed_files,
15-
checkout_pr,
1615
commit_all_tracked,
1716
current_branch,
1817
detect_repo,
@@ -31,6 +30,7 @@
3130
run,
3231
status_porcelain,
3332
untracked_files,
33+
verify_pr_checkout,
3434
write_json,
3535
)
3636

@@ -340,7 +340,7 @@ def main() -> int:
340340
try:
341341
require_clean_worktree(summary)
342342
summary.original_branch = current_branch(summary)
343-
checkout_pr(args.pr, summary)
343+
verify_pr_checkout(args.pr, summary)
344344

345345
checks = failed_checks(args.pr, summary)
346346
if not checks:

.github/scripts/pr-triage/resolve_conflicts.py

Lines changed: 28 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
#!/usr/bin/env python3
2-
"""Merge upstream/main into a PR branch, using Copilot only for conflicts."""
2+
"""Merge the PR base branch into a PR branch, using Copilot only for conflicts."""
33

44
from __future__ import annotations
55

@@ -9,10 +9,11 @@
99

1010
from common import (
1111
Summary,
12-
checkout_pr,
1312
current_branch,
13+
detect_repo,
1414
diff_check,
1515
git,
16+
gh_json,
1617
invoke_copilot,
1718
make_temp_dir,
1819
print_failure,
@@ -21,21 +22,39 @@
2122
require_clean_worktree,
2223
restore_original_branch,
2324
unmerged_paths,
25+
verify_pr_checkout,
2426
write_json,
2527
)
2628

2729

2830
def parse_args() -> argparse.Namespace:
2931
parser = argparse.ArgumentParser(description=__doc__)
3032
parser.add_argument("pr", type=int, help="pull request number")
31-
parser.add_argument("--upstream", default="upstream", help="upstream remote name")
3233
parser.add_argument("--json", action="store_true", help="print JSON summary")
3334
parser.add_argument("--no-push", action="store_true", help="commit but do not push")
3435
parser.add_argument("--keep-temp", action="store_true", help="reuse and retain the temp bundle directory")
3536
parser.add_argument("--skip-copilot", action="store_true", help="stop after collecting conflict context")
3637
return parser.parse_args()
3738

3839

40+
def fetch_pr_base(pr: int, summary: Summary) -> str:
41+
data = gh_json(["api", f"repos/{detect_repo(summary)}/pulls/{pr}"], summary)
42+
base = data.get("base") or {}
43+
base_ref = base.get("ref")
44+
base_repo = base.get("repo") or {}
45+
clone_url = base_repo.get("clone_url") if isinstance(base_repo, dict) else None
46+
if not isinstance(base_ref, str) or not base_ref:
47+
raise RuntimeError("could not determine PR base branch")
48+
if not isinstance(clone_url, str) or not clone_url:
49+
raise RuntimeError("could not determine PR base repository URL")
50+
51+
local_ref = f"refs/remotes/pr-triage-base/{base_ref}"
52+
progress(f"Fetching PR base {clone_url} {base_ref}")
53+
git(["fetch", clone_url, f"+refs/heads/{base_ref}:{local_ref}"], summary)
54+
git(["rev-parse", "--verify", local_ref], summary)
55+
return local_ref
56+
57+
3958
def write_conflict_bundle(directory: Path, summary: Summary) -> Path:
4059
progress(f"Preparing merge conflict bundle in {directory}")
4160
directory.mkdir(parents=True, exist_ok=True)
@@ -83,13 +102,11 @@ def main() -> int:
83102
try:
84103
require_clean_worktree(summary)
85104
summary.original_branch = current_branch(summary)
86-
checkout_pr(args.pr, summary)
105+
verify_pr_checkout(args.pr, summary)
87106

88-
progress(f"Fetching {args.upstream}")
89-
git(["fetch", args.upstream], summary)
90-
git(["rev-parse", "--verify", f"{args.upstream}/main"], summary)
91-
progress(f"Merging {args.upstream}/main")
92-
merge = git(["merge", "--no-edit", f"{args.upstream}/main"], summary, check=False)
107+
base_ref = fetch_pr_base(args.pr, summary)
108+
progress(f"Merging {base_ref}")
109+
merge = git(["merge", "--no-edit", base_ref], summary, check=False)
93110
if merge.returncode == 0:
94111
if "Already up to date" in merge.stdout:
95112
summary.outcome = "branch was already up to date"
@@ -99,7 +116,7 @@ def main() -> int:
99116
else:
100117
push(summary)
101118
summary.commits.append(git(["rev-parse", "--short", "HEAD"], summary).stdout.strip())
102-
summary.outcome = "merged upstream/main cleanly"
119+
summary.outcome = "merged PR base cleanly"
103120
return 0
104121

105122
conflicts = unmerged_paths(summary)
@@ -127,7 +144,7 @@ def main() -> int:
127144
summary.push_result = "not pushed (--no-push)"
128145
else:
129146
push(summary)
130-
summary.outcome = "resolved conflicts and merged upstream/main"
147+
summary.outcome = "resolved conflicts and merged PR base"
131148
return 0
132149
except Exception as e:
133150
summary.outcome = "failed"

.github/scripts/pr-triage/review.py

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -47,12 +47,6 @@ def pr_metadata(pr: int, summary: Summary) -> dict[str, Any]:
4747
return gh_json(["pr", "view", str(pr), "--json", fields], summary)
4848

4949

50-
def checkout_pr_for_review(pr: int, summary: Summary) -> None:
51-
progress(f"Checking out PR #{pr} for review")
52-
gh(["pr", "checkout", str(pr)], summary)
53-
summary.pr_branch = current_branch(summary)
54-
55-
5650
def parse_diff(diff: str) -> dict[str, dict[str, Any]]:
5751
files: dict[str, dict[str, Any]] = {}
5852
current_file: str | None = None
@@ -283,7 +277,8 @@ def main() -> int:
283277
try:
284278
require_clean_worktree(summary)
285279
summary.original_branch = current_branch(summary)
286-
checkout_pr_for_review(args.pr, summary)
280+
summary.pr_branch = summary.original_branch
281+
progress(f"Using checked out PR branch for review: {summary.pr_branch}")
287282

288283
metadata = pr_metadata(args.pr, summary)
289284
progress("Collecting PR diff")

.github/scripts/pr-triage/run_spotless.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@
99
from common import (
1010
Summary,
1111
changed_files,
12-
checkout_pr,
1312
commit_all_tracked,
1413
current_branch,
1514
diff_check,
@@ -22,6 +21,7 @@
2221
run,
2322
status_porcelain,
2423
untracked_files,
24+
verify_pr_checkout,
2525
)
2626

2727

@@ -39,7 +39,7 @@ def main() -> int:
3939
try:
4040
require_clean_worktree(summary)
4141
summary.original_branch = current_branch(summary)
42-
checkout_pr(args.pr, summary)
42+
verify_pr_checkout(args.pr, summary)
4343

4444
progress("Running Spotless")
4545
run(gradlew_cmd("spotless"), summary)

0 commit comments

Comments
 (0)