From 4f4ac1828838fe91bfbd6af69bfa2c83cda50c5b Mon Sep 17 00:00:00 2001 From: masquare <11543761+masquare@users.noreply.github.com> Date: Tue, 10 Nov 2026 10:23:54 +0100 Subject: [PATCH 1/6] ci: add PR-Agent stack review workflow --- .github/scripts/stack_review.py | 196 ++++++++++++++++++++++++++ .github/workflows/pr-agent-stack.yaml | 57 ++++++++ 2 files changed, 253 insertions(+) create mode 100644 .github/scripts/stack_review.py create mode 100644 .github/workflows/pr-agent-stack.yaml diff --git a/.github/scripts/stack_review.py b/.github/scripts/stack_review.py new file mode 100644 index 000000000..2c54ef0c7 --- /dev/null +++ b/.github/scripts/stack_review.py @@ -0,0 +1,196 @@ +# /// script +# requires-python = ">=3.11" +# dependencies = [] +# /// +"""Review a whole stack of pull requests with PR-Agent's local provider. + +Called by ``.github/workflows/pr-agent-stack.yaml``. Starting from one PR it walks down to the +PR whose base is the target branch, and up through every open PR built on top of it. For each +path from the bottom PR to a top PR it checks out the top branch, points a local base branch at +the bottom PR's target, and runs ``pr-agent review`` on that combined diff. The result becomes one +comment per path on the triggering PR, found again by a marker so reruns edit it in place. + +The script is trusted code. The workflow runs it from the default branch, never from the PR +under review, and the PR-Agent configuration is built here for the same reason. + +Run it the way CI does:: + + uv run --no-project .github/scripts/stack_review.py +""" + +from collections import defaultdict +import json +import os +from pathlib import Path +import subprocess # ruff: ignore[suspicious-subprocess-import] - fixed argument vectors, no shell +import sys +from typing import Any + + +PR_AGENT = "git+https://github.com/The-PR-Agent/pr-agent.git@v0.50.0" +MODEL = "openrouter/deepseek/deepseek-v4.1-flash-1017" +BASE_BRANCH = "stack-base" +MARKER = "" + +INSTRUCTIONS = ( + "Review the combined stack as one change. Trace lower-level contracts through all supplied " + "upper-level consumers. Report concrete failures that emerge across those layers, such as a " + "design decision in a lower PR that only breaks once a later PR builds on it. Name the " + "foundational code and its downstream usage, and say which PR owns each finding. Follow " + "AGENTS.md. Check TimeF invariants, explicit TimeNet exceptions on data issues (no silent " + "repair of data), data types, missing values, lazy loading and bounded memory. Report only " + "defects with material impact. " +) + +CONFIG = """\ +[config] +git_provider = "local" +model = "{model}" +fallback_models = [] +max_model_tokens = 120000 +custom_model_max_tokens = 120000 +ai_timeout = 900 +reasoning_effort = "low" +repo_context_files = ["AGENTS.md"] +output_run_details = true + +[local] +review_path = "{review_path}" + +[pr_reviewer] +enable_review_labels_security = false +enable_review_labels_effort = false +extra_instructions = {instructions} + +[openrouter] +max_tokens = 16000 +""" + + +def run(*cmd: str, cwd: Path | None = None, env: dict[str, str] | None = None) -> str: + """Run a command and return its stdout, exiting if it fails. + + Returns: + The standard output of the command. + """ + # ruff: ignore[subprocess-without-shell-equals-true] + done = subprocess.run(cmd, cwd=cwd, env=env, capture_output=True, text=True, check=False) + if done.returncode: + sys.exit(f"{' '.join(cmd[:3])} failed:\n{done.stdout}\n{done.stderr}") + return done.stdout + + +def stack_paths(prs: list[dict[str, Any]], number: int) -> list[list[dict[str, Any]]]: + """Return every bottom-to-top path of open PRs that contains ``number``.""" + by_number = {pr["number"]: pr for pr in prs} + by_head = {pr["headRefName"]: pr for pr in prs} + children: dict[str, list[dict[str, Any]]] = defaultdict(list) + for pr in prs: + children[pr["baseRefName"]].append(pr) + + bottom = [by_number[number]] + while bottom[0]["baseRefName"] in by_head: + parent = by_head[bottom[0]["baseRefName"]] + if parent in bottom: + sys.exit("Stack has a base cycle.") + bottom.insert(0, parent) + + def up(path: list[dict[str, Any]]) -> list[list[dict[str, Any]]]: + kids = children.get(path[-1]["headRefName"], []) + if not kids: + return [path] + return [p for kid in kids for p in up([*path, kid])] + + return up(bottom) + + +def review(repo_dir: Path, path: list[dict[str, Any]], tmp: Path) -> str: + """Run PR-Agent on the combined diff of ``path``. + + Returns: + The review Markdown written by PR-Agent. + """ + root, leaf = path[0], path[-1] + run("git", "checkout", "--detach", f"origin/{leaf['headRefName']}", cwd=repo_dir) + run("git", "branch", "-f", BASE_BRANCH, f"origin/{root['baseRefName']}", cwd=repo_dir) + + stack = "; ".join(f"#{pr['number']} {pr['title']}" for pr in path) + review_path = tmp / "review.md" + review_path.unlink(missing_ok=True) + config = tmp / "stack-review.toml" + config.write_text( + CONFIG.format( + model=MODEL, + review_path=review_path, + instructions=json.dumps(f"{INSTRUCTIONS}The stack, bottom to top, is: {stack}."), + ) + ) + run( + "uvx", + "--from", + PR_AGENT, + "pr-agent", + "--pr_url", + BASE_BRANCH, + "--extra_config_url", + str(config), + "review", + cwd=repo_dir, + ) + return review_path.read_text() + + +def upsert_comment(repo: str, number: int, marker: str, body: str, tmp: Path) -> None: + """Create the PR comment holding ``marker``, or edit it in place.""" + body_file = tmp / "comment.md" + body_file.write_text(f"{marker}\n{body}") + found = run( + "gh", + "api", + "--paginate", + f"repos/{repo}/issues/{number}/comments", + "--jq", + f'.[] | select(.body | contains("{marker}")) | .id', + ).split() + if found: + run("gh", "api", "-X", "PATCH", f"repos/{repo}/issues/comments/{found[0]}", "-F", f"body=@{body_file}") + else: + run("gh", "api", f"repos/{repo}/issues/{number}/comments", "-F", f"body=@{body_file}") + + +def main() -> None: + """Review every stack path through the PR given on the command line.""" + repo_dir, number = Path(sys.argv[1]).resolve(), int(sys.argv[2]) + repo = os.environ["GITHUB_REPOSITORY"] + tmp = Path(os.environ["RUNNER_TEMP"]) + + prs = json.loads( + run( + "gh", + "pr", + "list", + "--repo", + repo, + "--state", + "open", + "--limit", + "200", + "--json", + "number,title,headRefName,baseRefName", + ) + ) + paths = stack_paths(prs, number) + if all(len(path) == 1 for path in paths): + print(f"#{number} is not part of a stack; the per-PR review covers it.") + return + + for path in paths: + leaf = path[-1]["number"] + members = " ← ".join(f"#{pr['number']}" for pr in path) + text = review(repo_dir, path, tmp) + header = f"## Stack review: {members}\nThe whole stack was reviewed as one change.\n\n" + upsert_comment(repo, number, MARKER.format(leaf=leaf), header + text, tmp) + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/pr-agent-stack.yaml b/.github/workflows/pr-agent-stack.yaml new file mode 100644 index 000000000..9bb20fbe1 --- /dev/null +++ b/.github/workflows/pr-agent-stack.yaml @@ -0,0 +1,57 @@ +name: PR-Agent stack review + +# Reviews a PR together with every PR below and above it in its stack, as one combined diff. +# The per-PR workflow (pr-agent.yaml) still posts the inline suggestions. +on: + pull_request: + types: [opened, reopened, ready_for_review] + workflow_dispatch: + inputs: + pr_number: + description: Pull request to review together with its stack + required: true + +permissions: + contents: read + +# One review per PR at a time. A newer run replaces an older one. +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || inputs.pr_number }} + cancel-in-progress: true + +jobs: + stack_review: + # Same-repo branches only: fork PRs get no secrets. Drafts wait for ready_for_review. + if: >- + github.event_name == 'workflow_dispatch' || + (github.event.sender.type != 'Bot' && + github.event.pull_request.draft != true && + github.event.pull_request.head.repo.full_name == github.repository) + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + issues: write + pull-requests: write + steps: + # The script and its config come from the default branch, never from the PR under review. + - name: Checkout trusted script + uses: actions/checkout@v5 + with: + ref: ${{ github.event.repository.default_branch }} + path: trusted + - name: Checkout full history + uses: actions/checkout@v5 + with: + fetch-depth: 0 + path: repo + - name: Set up uv + uses: astral-sh/setup-uv@v8.2.0 + with: + enable-cache: false + - name: Review the stack + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + OPENROUTER__KEY: ${{ secrets.OPENROUTER_API_KEY }} + PR_NUMBER: ${{ github.event.pull_request.number || inputs.pr_number }} + run: uv run --no-project trusted/.github/scripts/stack_review.py repo "$PR_NUMBER" From 158ff1e10e4f4a2c57bb8b7158ac79505b34f806 Mon Sep 17 00:00:00 2001 From: masquare <11543761+masquare@users.noreply.github.com> Date: Tue, 10 Nov 2026 10:34:19 +0100 Subject: [PATCH 2/6] ci: use kimi-k3.1-pro on Nebius and chunk large stacks --- .github/scripts/stack_review.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/scripts/stack_review.py b/.github/scripts/stack_review.py index 2c54ef0c7..62607da79 100644 --- a/.github/scripts/stack_review.py +++ b/.github/scripts/stack_review.py @@ -28,7 +28,7 @@ PR_AGENT = "git+https://github.com/The-PR-Agent/pr-agent.git@v0.50.0" -MODEL = "openrouter/deepseek/deepseek-v4.1-flash-1017" +MODEL = "openrouter/moonshotai/kimi-k3.1-pro" BASE_BRANCH = "stack-base" MARKER = "" @@ -48,7 +48,7 @@ model = "{model}" fallback_models = [] max_model_tokens = 120000 -custom_model_max_tokens = 120000 +custom_model_max_tokens = 128000 ai_timeout = 900 reasoning_effort = "low" repo_context_files = ["AGENTS.md"] @@ -58,12 +58,16 @@ review_path = "{review_path}" [pr_reviewer] +enable_large_pr_chunking = true +max_number_of_calls = 6 enable_review_labels_security = false enable_review_labels_effort = false extra_instructions = {instructions} [openrouter] max_tokens = 16000 +provider_only = ["nebius"] +allow_fallbacks = false """ From 5bde1cf41a8a415fcf824709a7dd1a6d088f8dfb Mon Sep 17 00:00:00 2001 From: masquare <11543761+masquare@users.noreply.github.com> Date: Tue, 10 Nov 2026 11:06:53 +0100 Subject: [PATCH 3/6] ci: post one stack review on the top PR and skip unchanged stacks --- .github/scripts/stack_review.py | 54 ++++++++++++++++++--------- .github/workflows/pr-agent-stack.yaml | 5 ++- 2 files changed, 39 insertions(+), 20 deletions(-) diff --git a/.github/scripts/stack_review.py b/.github/scripts/stack_review.py index 62607da79..89028e530 100644 --- a/.github/scripts/stack_review.py +++ b/.github/scripts/stack_review.py @@ -8,7 +8,8 @@ PR whose base is the target branch, and up through every open PR built on top of it. For each path from the bottom PR to a top PR it checks out the top branch, points a local base branch at the bottom PR's target, and runs ``pr-agent review`` on that combined diff. The result becomes one -comment per path on the triggering PR, found again by a marker so reruns edit it in place. +comment per path, on the top PR, found again by a marker so reruns edit it in place. A path is +skipped when no PR head in it changed since the comment was written. The script is trusted code. The workflow runs it from the default branch, never from the PR under review, and the PR-Agent configuration is built here for the same reason. @@ -19,6 +20,7 @@ """ from collections import defaultdict +import hashlib import json import os from pathlib import Path @@ -144,26 +146,35 @@ def review(repo_dir: Path, path: list[dict[str, Any]], tmp: Path) -> str: return review_path.read_text() -def upsert_comment(repo: str, number: int, marker: str, body: str, tmp: Path) -> None: - """Create the PR comment holding ``marker``, or edit it in place.""" - body_file = tmp / "comment.md" - body_file.write_text(f"{marker}\n{body}") - found = run( +def find_comment(repo: str, number: int, marker: str) -> dict[str, Any] | None: + """Return the PR comment holding ``marker``, or ``None``.""" + lines = run( "gh", "api", "--paginate", f"repos/{repo}/issues/{number}/comments", "--jq", - f'.[] | select(.body | contains("{marker}")) | .id', - ).split() - if found: - run("gh", "api", "-X", "PATCH", f"repos/{repo}/issues/comments/{found[0]}", "-F", f"body=@{body_file}") + f'.[] | select(.body | contains("{marker}")) | {{id, body}}', + ).splitlines() + return json.loads(lines[0]) if lines else None + + +def write_comment(repo: str, number: int, existing: dict[str, Any] | None, body: str, tmp: Path) -> None: + """Edit the ``existing`` PR comment in place, or create one.""" + body_file = tmp / "comment.md" + body_file.write_text(body) + if existing: + run("gh", "api", "-X", "PATCH", f"repos/{repo}/issues/comments/{existing['id']}", "-F", f"body=@{body_file}") else: run("gh", "api", f"repos/{repo}/issues/{number}/comments", "-F", f"body=@{body_file}") def main() -> None: - """Review every stack path through the PR given on the command line.""" + """Review every stack path through the PR given on the command line. + + Each path gets one comment, on its top PR. A path whose PR head commits have not changed since + the last review is skipped. + """ repo_dir, number = Path(sys.argv[1]).resolve(), int(sys.argv[2]) repo = os.environ["GITHUB_REPOSITORY"] tmp = Path(os.environ["RUNNER_TEMP"]) @@ -180,20 +191,27 @@ def main() -> None: "--limit", "200", "--json", - "number,title,headRefName,baseRefName", + "number,title,headRefName,headRefOid,baseRefName", ) ) - paths = stack_paths(prs, number) - if all(len(path) == 1 for path in paths): + paths = [path for path in stack_paths(prs, number) if len(path) > 1] + if not paths: print(f"#{number} is not part of a stack; the per-PR review covers it.") return for path in paths: - leaf = path[-1]["number"] - members = " ← ".join(f"#{pr['number']}" for pr in path) + top = path[-1]["number"] + marker = MARKER.format(leaf=top) + commits = "\n".join(f"{pr['number']}:{pr['headRefOid']}" for pr in path) + stamp = f"" + existing = find_comment(repo, top, marker) + if existing and stamp in existing["body"]: + print(f"Stack ending at #{top} is unchanged since its last review.") + continue + members = " \u2190 ".join(f"#{pr['number']} (`{pr['headRefOid'][:7]}`)" for pr in path) + header = f"## Stack review\nReviewed as one change, bottom to top: {members}\n\n" text = review(repo_dir, path, tmp) - header = f"## Stack review: {members}\nThe whole stack was reviewed as one change.\n\n" - upsert_comment(repo, number, MARKER.format(leaf=leaf), header + text, tmp) + write_comment(repo, top, existing, f"{marker}\n{stamp}\n{header}{text}", tmp) if __name__ == "__main__": diff --git a/.github/workflows/pr-agent-stack.yaml b/.github/workflows/pr-agent-stack.yaml index 9bb20fbe1..acf53f0aa 100644 --- a/.github/workflows/pr-agent-stack.yaml +++ b/.github/workflows/pr-agent-stack.yaml @@ -1,10 +1,11 @@ name: PR-Agent stack review -# Reviews a PR together with every PR below and above it in its stack, as one combined diff. +# Reviews a PR together with every PR below and above it in its stack, as one combined diff. One +# comment per stack, on its top PR. A push to any PR in the stack reruns it. # The per-PR workflow (pr-agent.yaml) still posts the inline suggestions. on: pull_request: - types: [opened, reopened, ready_for_review] + types: [opened, reopened, ready_for_review, synchronize] workflow_dispatch: inputs: pr_number: From dc2fa823154038e622088d7a9f85c88288b461ba Mon Sep 17 00:00:00 2001 From: masquare <11543761+masquare@users.noreply.github.com> Date: Tue, 10 Nov 2026 11:10:38 +0100 Subject: [PATCH 4/6] ci: read the stack review prompt from the shared markdown file --- .github/scripts/stack_review.py | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/.github/scripts/stack_review.py b/.github/scripts/stack_review.py index 89028e530..a21caa1bf 100644 --- a/.github/scripts/stack_review.py +++ b/.github/scripts/stack_review.py @@ -12,7 +12,8 @@ skipped when no PR head in it changed since the comment was written. The script is trusted code. The workflow runs it from the default branch, never from the PR -under review, and the PR-Agent configuration is built here for the same reason. +under review, and the PR-Agent configuration is built here for the same reason. The review prompt +is ``.github/pr-review-instructions.md`` next to it. Run it the way CI does:: @@ -34,15 +35,7 @@ BASE_BRANCH = "stack-base" MARKER = "" -INSTRUCTIONS = ( - "Review the combined stack as one change. Trace lower-level contracts through all supplied " - "upper-level consumers. Report concrete failures that emerge across those layers, such as a " - "design decision in a lower PR that only breaks once a later PR builds on it. Name the " - "foundational code and its downstream usage, and say which PR owns each finding. Follow " - "AGENTS.md. Check TimeF invariants, explicit TimeNet exceptions on data issues (no silent " - "repair of data), data types, missing values, lazy loading and bounded memory. Report only " - "defects with material impact. " -) +INSTRUCTIONS = Path(__file__).parent.parent / "pr-review-instructions.md" CONFIG = """\ [config] @@ -128,7 +121,7 @@ def review(repo_dir: Path, path: list[dict[str, Any]], tmp: Path) -> str: CONFIG.format( model=MODEL, review_path=review_path, - instructions=json.dumps(f"{INSTRUCTIONS}The stack, bottom to top, is: {stack}."), + instructions=json.dumps(f"{INSTRUCTIONS.read_text()}\nThe stack, bottom to top, is: {stack}."), ) ) run( From ce409b01b453a98e26f97c4397cddea04ef2fd1b Mon Sep 17 00:00:00 2001 From: masquare <11543761+masquare@users.noreply.github.com> Date: Tue, 10 Nov 2026 11:18:54 +0100 Subject: [PATCH 5/6] ci: keep one stack review comment when runs overlap --- .github/scripts/stack_review.py | 30 +++++++++++++++++++----------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/.github/scripts/stack_review.py b/.github/scripts/stack_review.py index a21caa1bf..6bef1d257 100644 --- a/.github/scripts/stack_review.py +++ b/.github/scripts/stack_review.py @@ -139,8 +139,8 @@ def review(repo_dir: Path, path: list[dict[str, Any]], tmp: Path) -> str: return review_path.read_text() -def find_comment(repo: str, number: int, marker: str) -> dict[str, Any] | None: - """Return the PR comment holding ``marker``, or ``None``.""" +def find_comments(repo: str, number: int, marker: str) -> list[dict[str, Any]]: + """Return the PR comments holding ``marker``, oldest first.""" lines = run( "gh", "api", @@ -149,17 +149,25 @@ def find_comment(repo: str, number: int, marker: str) -> dict[str, Any] | None: "--jq", f'.[] | select(.body | contains("{marker}")) | {{id, body}}', ).splitlines() - return json.loads(lines[0]) if lines else None + return [json.loads(line) for line in lines] -def write_comment(repo: str, number: int, existing: dict[str, Any] | None, body: str, tmp: Path) -> None: - """Edit the ``existing`` PR comment in place, or create one.""" +def write_comment(repo: str, number: int, marker: str, body: str, tmp: Path) -> None: + """Edit the oldest PR comment holding ``marker`` in place and delete any copies. + + Looks again right before writing, so two runs that reviewed the same stack at the same time + end up with one comment. + """ body_file = tmp / "comment.md" body_file.write_text(body) - if existing: - run("gh", "api", "-X", "PATCH", f"repos/{repo}/issues/comments/{existing['id']}", "-F", f"body=@{body_file}") - else: + comments = find_comments(repo, number, marker) + if not comments: run("gh", "api", f"repos/{repo}/issues/{number}/comments", "-F", f"body=@{body_file}") + return + first, *copies = comments + run("gh", "api", "-X", "PATCH", f"repos/{repo}/issues/comments/{first['id']}", "-F", f"body=@{body_file}") + for copy in copies: + run("gh", "api", "-X", "DELETE", f"repos/{repo}/issues/comments/{copy['id']}") def main() -> None: @@ -197,14 +205,14 @@ def main() -> None: marker = MARKER.format(leaf=top) commits = "\n".join(f"{pr['number']}:{pr['headRefOid']}" for pr in path) stamp = f"" - existing = find_comment(repo, top, marker) - if existing and stamp in existing["body"]: + existing = find_comments(repo, top, marker) + if any(stamp in comment["body"] for comment in existing): print(f"Stack ending at #{top} is unchanged since its last review.") continue members = " \u2190 ".join(f"#{pr['number']} (`{pr['headRefOid'][:7]}`)" for pr in path) header = f"## Stack review\nReviewed as one change, bottom to top: {members}\n\n" text = review(repo_dir, path, tmp) - write_comment(repo, top, existing, f"{marker}\n{stamp}\n{header}{text}", tmp) + write_comment(repo, top, marker, f"{marker}\n{stamp}\n{header}{text}", tmp) if __name__ == "__main__": From a1a9bb2d8b9b8211def4a5d72255fe794e4bc453 Mon Sep 17 00:00:00 2001 From: masquare <11543761+masquare@users.noreply.github.com> Date: Tue, 10 Nov 2026 11:40:28 +0100 Subject: [PATCH 6/6] ci: use PyGithub in the stack review and drop the duplicate comment code --- .github/scripts/stack_review.py | 177 ++++++++++++-------------- .github/workflows/pr-agent-stack.yaml | 1 + 2 files changed, 80 insertions(+), 98 deletions(-) diff --git a/.github/scripts/stack_review.py b/.github/scripts/stack_review.py index 6bef1d257..6f057bdb8 100644 --- a/.github/scripts/stack_review.py +++ b/.github/scripts/stack_review.py @@ -1,6 +1,9 @@ # /// script # requires-python = ">=3.11" -# dependencies = [] +# dependencies = ["PyGithub>=2.9"] +# +# [tool.ty.rules] +# unresolved-import = "ignore" # /// """Review a whole stack of pull requests with PR-Agent's local provider. @@ -29,18 +32,21 @@ import sys from typing import Any +from github import Auth, Github +from github.PullRequest import PullRequest +from github.Repository import Repository + PR_AGENT = "git+https://github.com/The-PR-Agent/pr-agent.git@v0.50.0" -MODEL = "openrouter/moonshotai/kimi-k3.1-pro" BASE_BRANCH = "stack-base" -MARKER = "" +MARKER = "" INSTRUCTIONS = Path(__file__).parent.parent / "pr-review-instructions.md" CONFIG = """\ [config] git_provider = "local" -model = "{model}" +model = "openrouter/moonshotai/kimi-k3.1-pro" fallback_models = [] max_model_tokens = 120000 custom_model_max_tokens = 128000 @@ -65,37 +71,35 @@ allow_fallbacks = false """ +PR = dict[str, Any] +"""An open PR as ``number``, ``title``, ``head`` and ``base`` branch names, and head ``sha``.""" -def run(*cmd: str, cwd: Path | None = None, env: dict[str, str] | None = None) -> str: - """Run a command and return its stdout, exiting if it fails. - Returns: - The standard output of the command. - """ +def run(*cmd: str, cwd: Path) -> None: + """Run a command, exiting with its output if it fails.""" # ruff: ignore[subprocess-without-shell-equals-true] - done = subprocess.run(cmd, cwd=cwd, env=env, capture_output=True, text=True, check=False) + done = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, check=False) if done.returncode: - sys.exit(f"{' '.join(cmd[:3])} failed:\n{done.stdout}\n{done.stderr}") - return done.stdout + sys.exit(f"{' '.join(cmd)} failed:\n{done.stdout}\n{done.stderr}") -def stack_paths(prs: list[dict[str, Any]], number: int) -> list[list[dict[str, Any]]]: +def stack_paths(prs: list[PR], number: int) -> list[list[PR]]: """Return every bottom-to-top path of open PRs that contains ``number``.""" by_number = {pr["number"]: pr for pr in prs} - by_head = {pr["headRefName"]: pr for pr in prs} - children: dict[str, list[dict[str, Any]]] = defaultdict(list) + by_head = {pr["head"]: pr for pr in prs} + children: dict[str, list[PR]] = defaultdict(list) for pr in prs: - children[pr["baseRefName"]].append(pr) + children[pr["base"]].append(pr) bottom = [by_number[number]] - while bottom[0]["baseRefName"] in by_head: - parent = by_head[bottom[0]["baseRefName"]] + while bottom[0]["base"] in by_head: + parent = by_head[bottom[0]["base"]] if parent in bottom: sys.exit("Stack has a base cycle.") bottom.insert(0, parent) - def up(path: list[dict[str, Any]]) -> list[list[dict[str, Any]]]: - kids = children.get(path[-1]["headRefName"], []) + def up(path: list[PR]) -> list[list[PR]]: + kids = children.get(path[-1]["head"], []) if not kids: return [path] return [p for kid in kids for p in up([*path, kid])] @@ -103,116 +107,93 @@ def up(path: list[dict[str, Any]]) -> list[list[dict[str, Any]]]: return up(bottom) -def review(repo_dir: Path, path: list[dict[str, Any]], tmp: Path) -> str: +def review(repo_dir: Path, path: list[PR], tmp: Path) -> str: """Run PR-Agent on the combined diff of ``path``. Returns: The review Markdown written by PR-Agent. """ root, leaf = path[0], path[-1] - run("git", "checkout", "--detach", f"origin/{leaf['headRefName']}", cwd=repo_dir) - run("git", "branch", "-f", BASE_BRANCH, f"origin/{root['baseRefName']}", cwd=repo_dir) + run("git", "checkout", "--detach", f"origin/{leaf['head']}", cwd=repo_dir) + run("git", "branch", "-f", BASE_BRANCH, f"origin/{root['base']}", cwd=repo_dir) stack = "; ".join(f"#{pr['number']} {pr['title']}" for pr in path) review_path = tmp / "review.md" review_path.unlink(missing_ok=True) config = tmp / "stack-review.toml" + prompt = f"{INSTRUCTIONS.read_text()}\nThe stack, bottom to top, is: {stack}." config.write_text( - CONFIG.format( - model=MODEL, - review_path=review_path, - instructions=json.dumps(f"{INSTRUCTIONS.read_text()}\nThe stack, bottom to top, is: {stack}."), - ) + CONFIG.format(review_path=review_path, instructions=json.dumps(prompt, ensure_ascii=False)), + encoding="utf-8", ) run( - "uvx", - "--from", - PR_AGENT, - "pr-agent", - "--pr_url", - BASE_BRANCH, - "--extra_config_url", - str(config), - "review", + "uvx", "--from", PR_AGENT, "pr-agent", + "--pr_url", BASE_BRANCH, "--extra_config_url", str(config), "review", cwd=repo_dir, - ) + ) # fmt: skip + if not review_path.exists(): + sys.exit("PR-Agent finished without writing a review.") return review_path.read_text() -def find_comments(repo: str, number: int, marker: str) -> list[dict[str, Any]]: - """Return the PR comments holding ``marker``, oldest first.""" - lines = run( - "gh", - "api", - "--paginate", - f"repos/{repo}/issues/{number}/comments", - "--jq", - f'.[] | select(.body | contains("{marker}")) | {{id, body}}', - ).splitlines() - return [json.loads(line) for line in lines] +def stamped_comments(pull: PullRequest, marker: str) -> list[Any]: + """Return the comments on ``pull`` that hold ``marker``, oldest first.""" + return [c for c in pull.get_issue_comments() if marker in c.body] + + +def open_prs(repo: Repository) -> list[PR]: + """Return the open PRs from branches of ``repo``. + + A fork PR can reuse a branch name such as `main`, so forks are left out. + """ + return [ + {"number": p.number, "title": p.title, "head": p.head.ref, "base": p.base.ref, "sha": p.head.sha} + for p in repo.get_pulls(state="open") + if p.head.repo and p.head.repo.full_name == repo.full_name + ] -def write_comment(repo: str, number: int, marker: str, body: str, tmp: Path) -> None: - """Edit the oldest PR comment holding ``marker`` in place and delete any copies. +def review_path_once(pull: PullRequest, path: list[PR], repo_dir: Path, tmp: Path) -> None: + """Review ``path`` and write the result as the one stack comment on ``pull``. - Looks again right before writing, so two runs that reviewed the same stack at the same time - end up with one comment. + Skips the review when no PR head in the path changed since the comment was written. Two runs + that finish together still leave one comment: it is looked up again right before writing, and + any extra copy is deleted. """ - body_file = tmp / "comment.md" - body_file.write_text(body) - comments = find_comments(repo, number, marker) + marker = MARKER.format(top=pull.number) + commits = "\n".join(f"{pr['number']}:{pr['sha']}" for pr in path) + stamp = f"" + if any(stamp in c.body for c in stamped_comments(pull, marker)): + print(f"Stack ending at #{pull.number} is unchanged since its last review.") + return + members = " \u2190 ".join(f"#{pr['number']} (`{pr['sha'][:7]}`)" for pr in path) + header = f"## Stack review\nReviewed as one change, bottom to top: {members}\n\n" + body = f"{marker}\n{stamp}\n{header}{review(repo_dir, path, tmp)}" + comments = stamped_comments(pull, marker) if not comments: - run("gh", "api", f"repos/{repo}/issues/{number}/comments", "-F", f"body=@{body_file}") + pull.create_issue_comment(body) return first, *copies = comments - run("gh", "api", "-X", "PATCH", f"repos/{repo}/issues/comments/{first['id']}", "-F", f"body=@{body_file}") + first.edit(body) for copy in copies: - run("gh", "api", "-X", "DELETE", f"repos/{repo}/issues/comments/{copy['id']}") + copy.delete() def main() -> None: - """Review every stack path through the PR given on the command line. - - Each path gets one comment, on its top PR. A path whose PR head commits have not changed since - the last review is skipped. - """ + """Review every stack path through the PR given on the command line.""" repo_dir, number = Path(sys.argv[1]).resolve(), int(sys.argv[2]) - repo = os.environ["GITHUB_REPOSITORY"] tmp = Path(os.environ["RUNNER_TEMP"]) - prs = json.loads( - run( - "gh", - "pr", - "list", - "--repo", - repo, - "--state", - "open", - "--limit", - "200", - "--json", - "number,title,headRefName,headRefOid,baseRefName", - ) - ) - paths = [path for path in stack_paths(prs, number) if len(path) > 1] - if not paths: - print(f"#{number} is not part of a stack; the per-PR review covers it.") - return - - for path in paths: - top = path[-1]["number"] - marker = MARKER.format(leaf=top) - commits = "\n".join(f"{pr['number']}:{pr['headRefOid']}" for pr in path) - stamp = f"" - existing = find_comments(repo, top, marker) - if any(stamp in comment["body"] for comment in existing): - print(f"Stack ending at #{top} is unchanged since its last review.") - continue - members = " \u2190 ".join(f"#{pr['number']} (`{pr['headRefOid'][:7]}`)" for pr in path) - header = f"## Stack review\nReviewed as one change, bottom to top: {members}\n\n" - text = review(repo_dir, path, tmp) - write_comment(repo, top, marker, f"{marker}\n{stamp}\n{header}{text}", tmp) + with Github(auth=Auth.Token(os.environ["GH_TOKEN"])) as github: + repo = github.get_repo(os.environ["GITHUB_REPOSITORY"]) + prs = open_prs(repo) + if number not in {pr["number"] for pr in prs}: + sys.exit(f"#{number} is not an open pull request from a branch of this repository.") + paths = [path for path in stack_paths(prs, number) if len(path) > 1] + if not paths: + print(f"#{number} is not part of a stack; the per-PR review covers it.") + for path in paths: + review_path_once(repo.get_pull(path[-1]["number"]), path, repo_dir, tmp) if __name__ == "__main__": diff --git a/.github/workflows/pr-agent-stack.yaml b/.github/workflows/pr-agent-stack.yaml index acf53f0aa..99b2da060 100644 --- a/.github/workflows/pr-agent-stack.yaml +++ b/.github/workflows/pr-agent-stack.yaml @@ -40,6 +40,7 @@ jobs: uses: actions/checkout@v5 with: ref: ${{ github.event.repository.default_branch }} + sparse-checkout: .github path: trusted - name: Checkout full history uses: actions/checkout@v5