Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions scripts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,9 @@ python3 scripts/pr_review.py reply 452 --repo ptr727/ProjectTemplate \

`--repo` is required and carries no default. A default names one repository, and a run from anywhere else resolves its number there instead: the digest renders, every field is well-formed, and nothing in the output disagrees. Two runs read this repository's pull requests while their own was the subject, each caught by the maintainer rather than by the run. The digest leads with `repo=OWNER/NAME` for the same reason, since a number alone reads as correct in any repository. A value that is not `OWNER/NAME` is rejected by name rather than raised as an unpacking traceback, that being the near-miss a required argument still admits.

`wait` exits `30` when the review is still pending at the timeout, which is pending rather than failed. Its failure mode is a wrong answer rather than a crash, so the cases feed crafted GraphQL payloads: a review attributed to the wrong login, a review counted against a stale head, a maintainer's own thread read as a finding, and a wait that returns success while nothing landed. One case reads the reviewer login out of the runbook rather than restating it, since GraphQL drops the `[bot]` suffix REST carries, and another holds the script to exactly the two mutations `reply` owns, so a third arriving is a write nobody reviewed as one.
`wait` exits `30` when the review is still pending at the timeout, which is pending rather than failed. Its failure mode is a wrong answer rather than a crash, so the cases feed crafted GraphQL payloads: a review attributed to the wrong login, a review counted against a stale head, a maintainer's own thread read as a finding, and a wait that returns success while nothing landed. One case reads the reviewer login out of the runbook rather than restating it, since GraphQL drops the `[bot]` suffix REST carries, and another holds the script to exactly the four mutation documents named `addComment`, `requestReviews`, `addPullRequestReviewThreadReply`, and `resolveReviewThread`, so a fifth arriving is a write nobody reviewed as one.

`wait` exits `64` when the target is out of scope, the same refusal `comment` and `reply` raise, checked before either the auto-request or the backoff loop reaches GitHub. The `reply` paragraph below states what that refusal covers.

`wait` exits `40` when Copilot answers the request with a plain comment rather than a review, meaning a comment of its own that postdates its newest review on the pull request. The test is the **shape** of that answer and not its cause, which the script reads nothing of: a comment carries no commit, so it satisfies no coverage check whatever it says, and a wait reading formal reviews alone treats it as an unmet condition and then polls out its whole timeout against an answer that already arrived. A refusal is the case that makes this worth catching, a quota or rate-limit message among them, and `40` neither asserts nor detects one. The comment prints whole because its wording is the only thing separating a refusal, which is terminal since no review follows it and re-requesting does not clear it, from an ordinary remark that is not, so `40` ends the wait and hands the text to the reader who can tell them apart. A comment **older** than the newest review is spent rather than terminal, because the review it preceded did land. Every connection reads the newest `WINDOW` nodes rather than the reviewer's own, since GraphQL offers no author filter, so ordinary traffic is what pushes theirs out of reach. `window_blind` is the one guard over both sides, and each side fails differently. Blind on **comments** means an answer could be back there unseen, which reads as `answered_outside_review=unknown` rather than `no`. Blind on **reviews** is worse, because the newest review in view is then not the newest there is, and an empty baseline dates every comment as newer so each one reads as an answer: a false `40` that stops the loop on a pull request whose review actually landed. That case reports nothing and lets the wait keep polling, since a wait that runs on is visible where a wrong terminal is not.

Expand Down Expand Up @@ -209,7 +211,7 @@ The digest also reports the **suppressed findings** a review body collapses into

`reply` posts one answer and resolves one thread, and it exists because the hand-run form keeps failing the same way rather than because a wrapper is tidier. Three instances are on record, each an agent that had read the rule against hand-typing a node id and reached for the literal regardless, the last of them refused by the `gh-write-guard` hook an hour after quoting that same rule in a pull request body. A shape that fails while the reader knows the rule is a shape to remove rather than a rule to restate, so the selector is the finding's own words and there is deliberately no argument a `PRRT_...` id fits in: the id is read from the query in the same run and passed straight to the mutation, and a case asserts the id the mutation carries is the one that run just read. The words are matched against the thread's opening comment rather than against a line number, because a fix push moves the line and every lookup keyed to one then misses, which is how three replies posted against nothing while the resolves still succeeded and closed the threads carrying no answer. Matching is case-insensitive, since the text is quoted back out of a digest by a reader.

Every failure is a stop rather than a fallback, because each alternative closes a finding while leaving it unanswered, which is the state a reviewer reads as addressed. No match exits `60` and prints the open threads, since a no-match and an already-answered thread read identically from here. Two matches exit `61` and print both candidates rather than taking the first, `head -n 1` being how a reply lands on the wrong finding. A reply whose response carries no `url`, or a body that came back empty, exits `62` **without** resolving. A resolve that does not report `isResolved` exits `63`, with the reply already posted, so the thread is open behind an answer rather than silently assumed closed. `--resolve` is opt-in rather than the default, because a decline is resolved only once its evidence is in the thread. Exit `64` is the scope refusal, and `comment` raises it on the same terms `reply` does. It covers two states rather than one: a target under an owner other than the one `git remote get-url origin` names when run against the directory this script sits in, and an owner that run could not determine at all. Both refuse before reading anything from GitHub. That owner is read from the script's own directory rather than from the working directory, since the helper is reached from a hub checkout while the repository being answered is named on the command line, so both refusals name the directory they probed rather than leaving a reader to take it as the one they are standing in. That refusal takes no environment-variable escape: a grant this process can be handed is one the caller sets on the command that runs it, and a grant the caller writes for itself is not a grant, so the cross-owner case goes through the runbook's explicit `gh` path where the hook reads the maintainer's grant from the session instead.
Every failure is a stop rather than a fallback, because each alternative closes a finding while leaving it unanswered, which is the state a reviewer reads as addressed. No match exits `60` and prints the open threads, since a no-match and an already-answered thread read identically from here. Two matches exit `61` and print both candidates rather than taking the first, `head -n 1` being how a reply lands on the wrong finding. A reply whose response carries no `url`, or a body that came back empty, exits `62` **without** resolving. A resolve that does not report `isResolved` exits `63`, with the reply already posted, so the thread is open behind an answer rather than silently assumed closed. `--resolve` is opt-in rather than the default, because a decline is resolved only once its evidence is in the thread. Exit `64` is the scope refusal, and `comment` and `wait` both raise it on the same terms `reply` does, `wait` ahead of both the auto-request and the backoff loop that follows it, since the check runs before either does. It covers two states rather than one: a target under an owner other than the one `git remote get-url origin` names when run against the directory this script sits in, and an owner that run could not determine at all. Both refuse before reading anything from GitHub. That owner is read from the script's own directory rather than from the working directory, since the helper is reached from a hub checkout while the repository being answered is named on the command line, so both refusals name the directory they probed rather than leaving a reader to take it as the one they are standing in. That refusal takes no environment-variable escape: a grant this process can be handed is one the caller sets on the command that runs it, and a grant the caller writes for itself is not a grant, so the cross-owner case goes through the runbook's explicit `gh` path where the hook reads the maintainer's grant from the session instead.

What this trades away is stated rather than glossed. A mutation spelled as a `gh` command in a shell is read by the `gh-write-guard` hook and one this script performs is not. The script removes that failure at its source instead: every node ID comes from a live query in the same run. The owner check is also enforced in-process. The whole-source test guards against every other state-changing call and counts each reviewed mutation document.

Expand Down
22 changes: 22 additions & 0 deletions scripts/pr_review.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,8 @@
A pending request remains pending until a review, an answer, or the timeout. GitHub's
effort-labeled review lifecycle does not always emit `copilot_work_started`, so that
event is not evidence that distinguishes queued work from abandoned work.
64 = the write scope could not be established or excludes the target, checked before
the auto-request or any poll, so a cross-owner target reads and writes nothing here.

Reading is the bulk of this and the writing commands are a trade rather than a free win. A
mutation spelled as a `gh` command in a shell is read by the gh-write-guard PreToolUse hook, and
Expand All @@ -148,6 +150,7 @@
import argparse
import io
import json
import os
import re
import subprocess
import sys
Expand Down Expand Up @@ -1995,6 +1998,17 @@ def origin_owner() -> str | None:
reached from a hub checkout while the repository being answered is named on the command line,
so the working directory says nothing about who owns either.
"""
# An inherited GIT_DIR or GIT_COMMON_DIR overrides the `-C` argument and points the probe at another repository's config.
# An inherited GIT_CONFIG* channel injects config into this call, and `remote get-url` applies `insteadOf` rewriting, so an injected rewrite would make it name an owner this checkout does not have.
# This strip closes the channels an inherited tooling environment sets: the four discovery names it drops, and any name starting with GIT_CONFIG that injects config into this one call.
# It does not close, and cannot close, an inherited HOME, XDG_CONFIG_HOME, or PATH, because a caller who controls any of those already controls the process that would perform the write, leaving nothing here for this check to defend.
# The environment is stripped rather than cleared, since git still needs PATH to be found at all, and HOME so the checkout's own global config and `safe.directory` still apply.
env = {
k: v
for k, v in os.environ.items()
if k not in ("GIT_DIR", "GIT_WORK_TREE", "GIT_COMMON_DIR", "GIT_OBJECT_DIRECTORY")
and not k.startswith("GIT_CONFIG")
}
try:
url = subprocess.run(
["git", "-C", str(HERE), "remote", "get-url", "origin"],
Expand All @@ -2003,6 +2017,7 @@ def origin_owner() -> str | None:
encoding="utf-8",
timeout=5,
check=False,
env=env,
).stdout.strip()
# A missing git, a timeout, or any other failure all mean the owner cannot be read.
except Exception: # noqa: BLE001
Expand Down Expand Up @@ -2497,6 +2512,13 @@ def main(argv: list[str] | None = None) -> int:
if a.cmd == "reply":
return reply_to_thread(owner, repo, a.number, a.match, a.body, a.path, a.resolve)

# `wait` mutates through the auto-request below, so it refuses a cross-owner target the way `comment` and `reply` do.
# The policy is that a cross-owner target is not touched at all, so the refusal precedes the reading half too.
ok, why = in_scope(owner)
if not ok:
print(f"status=OUT_OF_SCOPE nothing was written: {why}")
return 64

# In-process backoff, so the whole wait costs one agent turn.
delays = [15, 20, 30, 45, 60, 120]
start = time.monotonic()
Expand Down
Loading