mirror of
https://github.com/github/awesome-copilot.git
synced 2026-07-17 11:23:27 +00:00
chore: publish from main
This commit is contained in:
@@ -0,0 +1,158 @@
|
||||
---
|
||||
name: copilot-pr-autopilot
|
||||
description: 'Copilot left 14 review comments on your PR — half are nits. Hours of fix → reply → resolve → re-request, and each round lands MORE comments. This skill runs loop engineering: auto-triggers Copilot Code Review via GraphQL (no @copilot mention), triages every open thread (Copilot, humans, advanced-security) with a fix / decline / escalate rubric, dispatches parallel fix sub-agents that obey the repo build/test/lint conventions, commits per iteration, replies+resolves citing the pushed SHA, then re-triggers until HEAD is reviewed with zero threads awaiting the agent''s reply (remaining open threads are explicit hand-offs to the human — escalated declines, design tradeoffs). You merge a clean PR; the bot runs it. Trigger phrases: "address copilot comments", "run a copilot review loop", "fix this PR", "iterate on copilot feedback". Repo-agnostic, gh CLI + PowerShell. Full autopilot needs repo Triage/Write; external PR authors get single-iteration mode plus manual re-trigger (UI 🔄 or substantive-commit push).'
|
||||
---
|
||||
|
||||
# Copilot PR Autopilot
|
||||
|
||||
Drive any GitHub pull request through repeated rounds of Copilot code
|
||||
review until the agent has done its job — every Copilot finding has
|
||||
a reply from the agent (fix-acknowledgement, decline-with-rationale,
|
||||
or explicit escalate-to-user hand-off). Remaining open threads, if
|
||||
any, are deliberate hand-offs to the human merge owner — they're
|
||||
not loop failures. Repository-agnostic — works on any repo that has
|
||||
Copilot Code Review enabled, run from a machine with `gh` CLI
|
||||
installed and authenticated (see Prerequisites).
|
||||
|
||||
## When to Use This Skill
|
||||
|
||||
- The user asks to "request Copilot review" or "run a Copilot review loop"
|
||||
on a PR.
|
||||
- A PR is functionally complete and the user wants a final correctness pass
|
||||
via repeated automated review rounds.
|
||||
- A previous Copilot review on the PR has left open threads that need
|
||||
triage, fixing, replying, and resolving.
|
||||
|
||||
## When NOT to Use This Skill
|
||||
|
||||
- The PR is still under active design — wait until the structure is stable;
|
||||
otherwise findings churn round-over-round.
|
||||
- The user wants human reviewer feedback, not Copilot's.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- `gh` CLI installed and authenticated against the target repository.
|
||||
- PowerShell on PATH — Windows PowerShell 5.1+ (`powershell.exe`) or
|
||||
PowerShell 7+ (`pwsh`). Both are tested.
|
||||
- Copilot Code Review is the primary use case (`01-request-review.ps1`
|
||||
uses GraphQL `requestReviewsByLogin` to trigger Copilot). It is
|
||||
**NOT a hard requirement** — if `01-request-review.ps1` fails
|
||||
because Copilot isn't enabled on the repo / account, the agent can
|
||||
still drive existing review threads (human, advanced-security, etc.)
|
||||
to completion by running steps 3–8 once as a single iteration; just
|
||||
skip the trigger + wait. There is no auto-detect for "Copilot
|
||||
unavailable" — the agent makes that decision after the trigger
|
||||
fails (the script can't reliably tell "Copilot disabled" from
|
||||
"Copilot enabled but not yet triggered" from API state alone).
|
||||
|
||||
### Permissions: who can run the full loop
|
||||
|
||||
The full multi-round autopilot (steps 1 → 9 → 1) needs **Triage or Write** permission on the target repo, because GitHub's only public API for adding the Copilot bot as a reviewer (`requestReviewsByLogin`) is gated on that permission. Verified against the public REST + GraphQL surface in this PR's commit history — there is no public-API path for bot reviewers without write permission.
|
||||
|
||||
| You are… | What works |
|
||||
|---|---|
|
||||
| **Repo collaborator with Triage / Write** | Full loop: `01` triggers Copilot, `02` waits, `04`–`08` triage / fix / reply, loop back to `01`. Hands-off. |
|
||||
| **External PR author (no write permission)** | `01` will throw a clear actionable error. Use `-SingleIteration` mode: address all current findings in one pass, then either click the UI 🔄 next to Copilot, **or** push a substantive commit (the `synchronize` event auto-triggers Copilot on most repos). Then re-run `02` to verify. |
|
||||
|
||||
In single-iteration mode the loop's convergence boolean is `Converged: true` iff `OpenThreadsAwaitingReply == 0` (the agent's side is done). The maintainer-side re-trigger then drives any additional rounds.
|
||||
|
||||
Every script dot-sources [scripts/_lib.ps1](scripts/_lib.ps1) which
|
||||
runs `Assert-GhReady` on load: if `gh` is missing OR `gh auth status`
|
||||
fails, the script halts **before any work** with a single actionable
|
||||
error message naming the install command and `gh auth login`. The
|
||||
agent should surface that message to the user verbatim and stop the
|
||||
loop — do not retry or work around it.
|
||||
|
||||
## Step-by-Step Workflow
|
||||
|
||||
> **The loop:** steps 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 → 9, then **back to step 1** if `Converged: false`. Repeat the 1→9 round until step 9 returns `Converged: true`; only then run step 10 once and call `task_complete`. **At every 10th round, the parent runs the [round-cap recap gate](references/09-convergence.md#round-cap--recap-gate-circuit-breaker) before looping back** — recap all prior rounds and stop if the loop has drifted out of the PR's original scope.
|
||||
|
||||
Each round runs steps 1–9; step 10 is a one-time cleanup after convergence. The parent agent coordinates; every sub-agent step runs in a fresh context with a bounded budget. Cross-cutting protocol (time-boxing, extension, single-iteration fallback): [orchestration.md](references/orchestration.md).
|
||||
|
||||
1. **Request review** _(parent)_ — see [01-request-review.md](references/01-request-review.md)
|
||||
2. **Wait for review** _(sub-agent, 20-min cap)_ — see [02-wait.md](references/02-wait.md)
|
||||
3. **List + categorize open threads** _(sub-agent, 5 min)_ — see [03-list-threads.md](references/03-list-threads.md)
|
||||
4. **Triage** _(sub-agent, 5 min per ≤5 threads)_ — see [04-triage.md](references/04-triage.md)
|
||||
5. **Fix** _(sub-agents, parallel max 5, 5 min each)_ — see [05-fix.md](references/05-fix.md)
|
||||
6. **Build + test per repo conventions** _(sub-agent, 10 min)_ — see [06-build-test.md](references/06-build-test.md)
|
||||
7. **Commit + push** _(parent)_ — see [07-commit-push.md](references/07-commit-push.md)
|
||||
8. **Reply (always) + resolve (conditional)** _(sub-agent drafts, parent posts)_ — see [08-reply-resolve.md](references/08-reply-resolve.md)
|
||||
9. **Convergence verify** _(sub-agent, 3 min)_ — see [09-convergence.md](references/09-convergence.md)
|
||||
- **`Converged: false` → loop back to step 1** for another round (re-trigger, wait, list, triage, fix, push, reply, re-check). Each round addresses Copilot's findings on the previous round's HEAD; the loop terminates as soon as Copilot has nothing new to say AND every open thread has a reply from the agent.
|
||||
- **`Converged: true` → exit the loop**, run step 10 once, call `task_complete` with the proof.
|
||||
- **Every 10th round (10, 20, 30…) → run the [round-cap recap gate](references/09-convergence.md#round-cap--recap-gate-circuit-breaker) before looping back.** Recap ALL prior rounds against the PR's original scope and pick a verdict: **CONTINUE**, **REVERT-AND-SHIP** (drop drifted commits, ship the in-scope ones), or **HAND-OFF** (escalate to the user). This is the circuit breaker that stops a runaway bot-review loop.
|
||||
10. **Cleanup outdated** _(parent, post-convergence, once)_ — see [10-cleanup.md](references/10-cleanup.md)
|
||||
|
||||
Convergence is computed by [scripts/02-check-review-status.ps1](scripts/02-check-review-status.ps1) as a single `Converged: true` boolean. Do **not** call `task_complete` until it returns true; print the proof (`HeadOid`, `LatestCopilotReview.commitOid`, `submittedAt`) in the completion message.
|
||||
|
||||
## Gotchas
|
||||
|
||||
The bundled scripts enforce the hard correctness invariants (trigger landing via `copilot_work_started` event id, `Converged` requiring HEAD-match + zero-awaiting + at-HEAD review, single-iteration fallback semantics, PR-state guard). Trust them — don't re-derive. The notes below cover decisions the scripts can't make for you:
|
||||
|
||||
- **Reply to every open thread; resolve only when the loop owns the disposition.** For `fix` and `decline` threads, reply + resolve. For `escalate-to-user` threads, reply with the analysis but leave the thread OPEN (`08-reply-and-resolve.ps1 -NoResolve`) so the human merge owner can act on it. See [08-reply-resolve.md](references/08-reply-resolve.md).
|
||||
- **Copilot threads are loop-owned; human / advanced-security / other-bot threads default to `escalate-to-user`.** Auto-resolving a human review thread can hide unaddressed concerns. See [04-triage.md](references/04-triage.md) for the rubric.
|
||||
- **One focused commit per round, not one per PR.** Bundling rounds destroys the audit trail of which finding drove which change and breaks `git bisect`. See [07-commit-push.md](references/07-commit-push.md).
|
||||
- **Build/test/lint with the repo's own commands** (per its `CONTRIBUTING` / `AGENTS` / `README` / `package.json` / `Makefile`) before pushing a fix. Discovery procedure: [06-build-test.md](references/06-build-test.md).
|
||||
- **Push back with written rationale** when a Copilot finding would over-engineer the design for a hypothetical edge case. Auto-accepting every suggestion erodes the design — see the `decline` path in [04-triage.md](references/04-triage.md).
|
||||
- **Scripting traps** (`gh api graphql -F` type-coercion, `git stash push -m` positional parsing, the three GraphQL traps for the reviewer mutation) are documented in [references/api-quirks.md](references/api-quirks.md). Read before modifying any script.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Issue | Solution |
|
||||
|-------|----------|
|
||||
| Script throws `prerequisite missing — gh CLI is not on PATH` | Install `gh` (`winget install GitHub.cli` on Windows; `brew install gh` on macOS; package manager on Linux; or download from https://cli.github.com). Then `gh auth login`. Surface the message to the user and STOP the loop — do not retry. |
|
||||
| Script throws `prerequisite missing — gh CLI is not authenticated` | Run `gh auth login`. STOP the loop until the user completes auth. |
|
||||
| Trigger fails or no `copilot_work_started` event lands | Push a substantive (non-whitespace) commit — auto-assign on `synchronize` is the most reliable trigger. Persistent failure indicates Copilot Code Review may not be enabled on the repo / account (check repo Settings → Code & automation → Copilot, or account-level Copilot Pro/Pro+). |
|
||||
| No new review after waiting ~10 min | Quiet-period after recent dismissal or trivial-diff suppression. Push a substantive commit and retry. Do not blindly re-run `01-request-review.ps1` — it reports `InFlight` while Copilot is still a requested reviewer. |
|
||||
| Outdated-but-unresolved threads in the open list | Expected: unresolved state is the source of truth. Reply + resolve them like any other open thread. `10-cleanup-outdated.ps1` is only a final safety net. |
|
||||
| Unsure whether to fix or decline a finding | See [references/04-triage.md](references/04-triage.md). |
|
||||
| Need a reply phrasing for "fixed", "declined", or "drift" | See the templates under [templates/](templates/) — [reply-fix.md](templates/reply-fix.md), [reply-decline.md](templates/reply-decline.md), [reply-drift.md](templates/reply-drift.md), [reply-partial.md](templates/reply-partial.md). |
|
||||
|
||||
## References
|
||||
|
||||
- [references/orchestration.md](references/orchestration.md) —
|
||||
cross-cutting loop control: time-boxing & extension protocol,
|
||||
sub-agent delegation map, single-iteration fallback, and loop-wide
|
||||
notes.
|
||||
- Per-step contracts (one `NN-*.md` per step):
|
||||
[references/01-request-review.md](references/01-request-review.md) _(parent)_,
|
||||
[references/02-wait.md](references/02-wait.md),
|
||||
[references/03-list-threads.md](references/03-list-threads.md),
|
||||
[references/04-triage.md](references/04-triage.md) (includes the
|
||||
fix-vs-decline rubric),
|
||||
[references/05-fix.md](references/05-fix.md),
|
||||
[references/06-build-test.md](references/06-build-test.md),
|
||||
[references/07-commit-push.md](references/07-commit-push.md) _(parent)_,
|
||||
[references/08-reply-resolve.md](references/08-reply-resolve.md),
|
||||
[references/09-convergence.md](references/09-convergence.md) (includes
|
||||
the round-cap recap gate),
|
||||
[references/10-cleanup.md](references/10-cleanup.md) _(parent)_.
|
||||
- [references/api-quirks.md](references/api-quirks.md) — verified
|
||||
GitHub API behavior, dead-ends, and the GraphQL traps for the
|
||||
reviewer mutation.
|
||||
- Templates (one per reply type):
|
||||
[templates/reply-fix.md](templates/reply-fix.md) — accepted-fix
|
||||
pattern; [templates/reply-decline.md](templates/reply-decline.md) —
|
||||
declined-with-rationale pattern;
|
||||
[templates/reply-drift.md](templates/reply-drift.md) —
|
||||
PR-description / comment / test-plan drift acknowledgement;
|
||||
[templates/reply-partial.md](templates/reply-partial.md) —
|
||||
partial fix with deferred follow-up. Cross-cutting reply guidance
|
||||
and anti-patterns live in
|
||||
[references/08-reply-resolve.md](references/08-reply-resolve.md#reply-guidance).
|
||||
- [scripts/_lib.ps1](scripts/_lib.ps1) — shared helpers (`Invoke-Gh`,
|
||||
`Invoke-GhGraphQL`, `Resolve-RepoCoords`); dot-sourced by every
|
||||
script.
|
||||
- [scripts/01-request-review.ps1](scripts/01-request-review.ps1) —
|
||||
trigger Copilot review and verify pickup via the
|
||||
`copilot_work_started` event.
|
||||
- [scripts/02-check-review-status.ps1](scripts/02-check-review-status.ps1) —
|
||||
single-shot snapshot of the PR's Copilot review state; emits
|
||||
`Converged: true` only when all three conditions hold.
|
||||
- [scripts/03-list-open-threads.ps1](scripts/03-list-open-threads.ps1) —
|
||||
every unresolved PR review thread from **all reviewers** (Copilot,
|
||||
humans, github-advanced-security, etc.).
|
||||
- [scripts/08-reply-and-resolve.ps1](scripts/08-reply-and-resolve.ps1) —
|
||||
post a reply and resolve in one call.
|
||||
- [scripts/10-cleanup-outdated.ps1](scripts/10-cleanup-outdated.ps1) —
|
||||
safety net for outdated Copilot threads.
|
||||
@@ -0,0 +1,44 @@
|
||||
# Step 1: Request review
|
||||
|
||||
Owner: **parent** (no sub-agent); budget: n/a.
|
||||
|
||||
## Inputs
|
||||
|
||||
- `PrNumber` for the target PR.
|
||||
|
||||
## Return contract
|
||||
|
||||
- Captured `baseline` = `LatestCopilotReview.submittedAt` string (or empty)
|
||||
to be passed to step 2.
|
||||
- Boolean `single_iteration_mode` — `true` if the trigger failed because
|
||||
Copilot isn't a valid reviewer; `false` otherwise.
|
||||
|
||||
## Procedure
|
||||
|
||||
1. Snapshot first to learn whether Copilot is already pending:
|
||||
|
||||
```pwsh
|
||||
$snap = pwsh ./scripts/02-check-review-status.ps1 -PrNumber <n>
|
||||
$baseline = if ($snap -match '"submittedAt":"([^"]+)"') { $Matches[1] } else { '' }
|
||||
$pending = ($snap -match '"CopilotPending":true')
|
||||
```
|
||||
|
||||
Regex on raw JSON keeps `submittedAt` a string across the
|
||||
parent → sub-agent boundary on any PS version (5.1 / 7.x), avoiding
|
||||
`[datetime]` rebinding.
|
||||
|
||||
2. **If `$pending`** — skip the trigger; jump to step 2 with `baseline`.
|
||||
|
||||
3. **Else** — fire the trigger:
|
||||
|
||||
```pwsh
|
||||
pwsh ./scripts/01-request-review.ps1 -PrNumber <n>
|
||||
```
|
||||
|
||||
The script keeps its own `InFlight` short-circuit as a safety net,
|
||||
but the canonical "is Copilot pending?" signal lives in
|
||||
`02-check-review-status.ps1` (above).
|
||||
|
||||
4. If `01-request-review.ps1` throws because Copilot isn't a valid
|
||||
reviewer (Copilot Code Review not enabled on the repo / account),
|
||||
take the [single-iteration fallback](orchestration.md#single-iteration-fallback).
|
||||
@@ -0,0 +1,49 @@
|
||||
# Step 2: Wait for review
|
||||
|
||||
Sub-agent type: `general-purpose`; budget: **20-minute hard cap** (one
|
||||
bounded sub-agent, NOT extension-driven).
|
||||
|
||||
**Skipped** when the loop is in [single-iteration
|
||||
mode](orchestration.md#single-iteration-fallback) — there's no Copilot
|
||||
review to wait for.
|
||||
|
||||
## Inputs
|
||||
|
||||
From step 1:
|
||||
- `PrNumber`.
|
||||
- `baseline` — the `LatestCopilotReview.submittedAt` string captured
|
||||
before the trigger fired (empty string if no prior Copilot review).
|
||||
|
||||
## Return contract
|
||||
|
||||
- `02-check-review-status.ps1` JSON snapshot.
|
||||
- `recommendation` ∈ {`ready`, `give-up-push-commit`}.
|
||||
- `ready` iff **both** `LatestCopilotReview.submittedAt > baseline`
|
||||
AND `ReviewAtHead: true`.
|
||||
|
||||
## Procedure
|
||||
|
||||
Poll `02-check-review-status.ps1` approximately every **3 minutes**
|
||||
until `ready` or the 20-minute cap is hit:
|
||||
|
||||
```pwsh
|
||||
pwsh ./scripts/02-check-review-status.ps1 -PrNumber <n>
|
||||
```
|
||||
|
||||
- Extract `submittedAt` and `ReviewAtHead` from the JSON each tick.
|
||||
- Stop and return `ready` on the first tick that satisfies both
|
||||
conditions vs. the captured `baseline`.
|
||||
- On cap reached without `ready`, return `give-up-push-commit`.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Don't poll faster than ~3 minutes.** There is no progress signal
|
||||
from the API; faster polling only burns budget.
|
||||
- **`give-up-push-commit` fallback is parent-driven.** When the
|
||||
sub-agent returns this recommendation, the **parent** pushes a
|
||||
substantive (non-whitespace) commit — auto-assign on `synchronize` is
|
||||
the most reliable trigger. Then the parent re-enters the loop at
|
||||
step 1 with a fresh `baseline`.
|
||||
- **Single bounded run, not extension-driven.** Do not request
|
||||
extensions on this step — if 20 min isn't enough, the right move is
|
||||
the `give-up-push-commit` fallback, not more polling.
|
||||
@@ -0,0 +1,53 @@
|
||||
# Step 3: List + categorize open threads
|
||||
|
||||
Sub-agent type: `explore`; budget: 5 min.
|
||||
|
||||
## Inputs
|
||||
|
||||
- `PrNumber`.
|
||||
|
||||
## Return contract
|
||||
|
||||
Table of rows, one per open thread:
|
||||
|
||||
```
|
||||
{ thread_id, file, line, author, author_class, severity, summary }
|
||||
```
|
||||
|
||||
Where `author_class` ∈ `copilot` | `human-or-bot`, derived from the
|
||||
raw `author.login` (see Gotchas).
|
||||
|
||||
## Procedure
|
||||
|
||||
Run the listing script:
|
||||
|
||||
```pwsh
|
||||
pwsh ./scripts/03-list-open-threads.ps1 -PrNumber <n>
|
||||
```
|
||||
|
||||
This returns every unresolved review thread from **all reviewers**
|
||||
(Copilot, humans, `github-advanced-security`, other bots). The script
|
||||
emits `Path` as `<file>:<line>` when the comment is anchored to a
|
||||
specific line (e.g. `src/foo.js:42`); when the comment has no line
|
||||
anchor (file-level / PR-level comments), `Path` is just `<file>` with
|
||||
no `:<line>` suffix. Callers should split on the last `:` **only when
|
||||
the suffix parses as an integer**, and treat `Path` as the file alone
|
||||
otherwise. For each row, classify the `author`:
|
||||
|
||||
- `copilot-pull-request-reviewer` or
|
||||
`copilot-pull-request-reviewer[bot]` → `author_class: copilot`
|
||||
- everything else → `author_class: human-or-bot`
|
||||
|
||||
Pass the classified table to step 4 — the triage rubric depends on it.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **The `[bot]` suffix appears on some surfaces.** Match BOTH
|
||||
`copilot-pull-request-reviewer` AND
|
||||
`copilot-pull-request-reviewer[bot]` — they're the same actor.
|
||||
- **Default human / advanced-security threads to `escalate-to-user` in
|
||||
step 4.** Classification here just flags them; triage applies the
|
||||
policy. See [04-triage.md](04-triage.md).
|
||||
- **Unresolved is the source of truth.** Outdated-but-unresolved
|
||||
threads still show up — that's correct. Don't filter them out;
|
||||
they're handled like any other open thread in step 8.
|
||||
@@ -0,0 +1,190 @@
|
||||
# Step 4: Triage
|
||||
|
||||
Sub-agent type: `general-purpose`; budget: **5 min per ≤5 threads**
|
||||
(parent batches in waves of ≤5 if step 3 returned more).
|
||||
|
||||
## Inputs
|
||||
|
||||
From step 3:
|
||||
- Classified thread table — `{ thread_id, file, line, author,
|
||||
author_class, severity, summary }` per open thread.
|
||||
- Original PR context (description, recent commits to the affected
|
||||
files) for re-deriving from principles in oscillation cases.
|
||||
|
||||
## Return contract
|
||||
|
||||
Table of rows, one per thread:
|
||||
|
||||
```
|
||||
{ thread_id, action, rationale }
|
||||
```
|
||||
|
||||
Where `action` ∈ `fix` | `decline` | `escalate-to-user` and
|
||||
`rationale` is a single line citing the rule from the rubric below
|
||||
that fired.
|
||||
|
||||
## Procedure
|
||||
|
||||
For each thread, apply the rubric in this file in order:
|
||||
|
||||
1. **Reviewer-type policy** — `human-or-bot` threads default to
|
||||
`escalate-to-user` unless the user explicitly scoped them in.
|
||||
2. **ROI vs Risk** — score and decide for `copilot` threads.
|
||||
3. **Fix / Decline rule lists** — match the finding pattern.
|
||||
4. **Project-specific policy hooks** — research repo conventions
|
||||
before applying a generic "fix".
|
||||
5. **Conflicting-comments resolution** — if the finding flips a
|
||||
prior round's edit, apply the oscillation rules; if the agent is
|
||||
about to re-do an edit it already reverted, hard-stop and escalate.
|
||||
6. **Escalation rules** — escalate on design-level tradeoffs,
|
||||
large/cross-cutting changes, or high-risk/irreversible actions.
|
||||
|
||||
Return `{ thread_id, action, rationale }` per thread to the parent;
|
||||
step 5 consumes only the `fix` rows, step 8 consumes the full table.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Batch in waves of ≤5 per sub-agent invocation.** Larger batches
|
||||
blur the rationale and overrun the 5-min budget.
|
||||
- **Human / advanced-security default is `escalate-to-user`.**
|
||||
Auto-replying or auto-resolving a human review thread hides
|
||||
unaddressed concerns and is socially wrong.
|
||||
- **Cite the rule that fired in `rationale`** — one line, concrete.
|
||||
"Style nit per rubric §Decline" is fine; "looks small" is not.
|
||||
- **The oscillation hard-stop is non-negotiable.** If the agent is
|
||||
about to make an edit it already reverted in a prior round, that's
|
||||
the unambiguous signature of an oscillation loop — escalate.
|
||||
|
||||
---
|
||||
|
||||
# Triage Rubric
|
||||
|
||||
Decision rubric for whether to fix or decline each Copilot finding.
|
||||
The goal is correctness, not appeasement — decline confidently when
|
||||
warranted.
|
||||
|
||||
## ROI vs Risk
|
||||
|
||||
Score every proposed change on two axes, then decide:
|
||||
|
||||
- **ROI** = value of the fix MINUS the cost to implement MINUS the
|
||||
user's cost to review.
|
||||
- **Risk** = blast radius, reversibility, effect on unrelated code.
|
||||
|
||||
| ROI | Risk | Action |
|
||||
|---|---|---|
|
||||
| Clear positive | Low | Fix unilaterally. |
|
||||
| Marginal | Low | Fix if cheap; otherwise decline with rationale. |
|
||||
| Clear positive | High / Irreversible | Propose to the user first. |
|
||||
| Marginal | High | Decline. |
|
||||
| Negative (over-engineering for hypothetical) | Any | Decline. |
|
||||
|
||||
## Reviewer-type policy (foundation, not a nit)
|
||||
|
||||
Each thread's `author` (returned by `03-list-open-threads.ps1`) drives
|
||||
who can decide it:
|
||||
|
||||
| Reviewer | Default action |
|
||||
|----------|----------------|
|
||||
| `copilot-pull-request-reviewer` / `copilot-pull-request-reviewer[bot]` | Loop-owned — triage with the rubric below. (`03-list-open-threads.ps1` reports the raw `author.login`, which may carry the `[bot]` suffix on some surfaces; match both forms.) |
|
||||
| Human reviewer | **Default `escalate-to-user`** unless the user explicitly scoped them into the loop. Auto-replying or auto-resolving a human thread can hide unaddressed concerns and is socially wrong. |
|
||||
| `github-advanced-security` / other automated bots | **Default `escalate-to-user`** unless the project has a documented suppression / fix convention you can follow. |
|
||||
|
||||
## Fix when the finding is...
|
||||
|
||||
- A **real correctness bug**: use-after-free / lifetime violation,
|
||||
race that drops user intent, gating logic that skips legitimate
|
||||
transitions, missing link dependency, off-by-one, null deref,
|
||||
unhandled error path.
|
||||
- A **cross-cutting concern with a clean local fix**: moving a mutex
|
||||
one scope up, pulling a duplicated check into a helper.
|
||||
- **Documentation / test-plan drift**: PR description claims behavior
|
||||
the code no longer matches; comment block describes the wrong
|
||||
thing; test-plan checkbox is inverted vs. the code.
|
||||
|
||||
## Decline when the finding is...
|
||||
|
||||
- A **purely hypothetical race** requiring cross-class plumbing,
|
||||
where actual exposure is negligible. Cite the interleaving you
|
||||
ruled out.
|
||||
- **Style, naming, or formatting**. Out of scope for the review loop.
|
||||
- **Suggestions to add abstractions** ("introduce a strategy pattern",
|
||||
"extract an interface") that don't pay for themselves at the
|
||||
current scale of the codebase.
|
||||
- **Suggestions that contradict an established project convention** —
|
||||
consistency with surrounding code is usually more valuable than the
|
||||
suggestion in isolation.
|
||||
- **Micro-optimizations** in code that is not on a hot path.
|
||||
|
||||
## Project-specific policy hooks
|
||||
|
||||
Some findings are decided by **project policy**, not by general
|
||||
correctness reasoning. Before applying a generic "fix", **research
|
||||
the repo's own conventions first** — `.github/instructions/*.md`
|
||||
files (often have an `applyTo` glob that pins them to the changed
|
||||
file's path), `.github/skills/`, `AGENTS.md`, `CONTRIBUTING.md`, CI
|
||||
config, and recent commits to similar files. Fan out multiple
|
||||
`explore` sub-agents when several axes need checking (lint, format,
|
||||
spell-check, license header, etc.) — don't invent answers. What
|
||||
looks like an obvious fix may violate a project rule:
|
||||
|
||||
- **Spell-check / dictionary findings.** If the project uses a
|
||||
spell-checker (`check-spelling`, `cspell`, `typos`, or similar),
|
||||
inspect its config and recent commits to learn the local
|
||||
convention (reword the document, add a pattern/regex, extend a
|
||||
dictionary/allowlist, use an inline ignore). Follow that
|
||||
convention; don't invent a new mechanism.
|
||||
- **Lint suppressions / inline ignore directives.** Most projects
|
||||
require an inline rationale comment. Bare suppressions get pushed
|
||||
back.
|
||||
- **License headers / file boilerplate.** Project-specific format,
|
||||
often CI-enforced — copy the existing header from a neighbor file.
|
||||
- **Test framework, mock library, formatter choices.** Follow the
|
||||
convention of the surrounding test files; never introduce a new
|
||||
framework because Copilot suggested one.
|
||||
|
||||
Cite the project's config file or convention in your reply.
|
||||
|
||||
## Conflicting comments — break oscillation early
|
||||
|
||||
Failure mode: round N "fixes" what comment A asked for; round N+1
|
||||
comment B objects and asks to revert it. Blindly flip-flopping
|
||||
ships oscillation and burns rounds.
|
||||
|
||||
Resolution rules — apply in order, stop at the first that fires:
|
||||
|
||||
1. **Re-derive from principles.** Pick the side that wins on the
|
||||
function's contract + surrounding patterns + user's stated
|
||||
preferences — not the latest comment.
|
||||
2. **Prefer the position with the explicit rationale.** Concrete
|
||||
failure modes (security, correctness, data loss, perf) win over
|
||||
stylistic positions.
|
||||
3. **Prefer human comments over bot comments** when they directly
|
||||
conflict.
|
||||
4. **If still ambiguous, escalate to the user.** Summarize both
|
||||
positions side-by-side with a recommendation; do not silently
|
||||
pick.
|
||||
|
||||
**Hard stop**: if you are about to make the same edit you
|
||||
*reverted* in an earlier round, stop and escalate. That is the
|
||||
unambiguous signature of an oscillation loop.
|
||||
|
||||
## Reply hygiene
|
||||
|
||||
Every reply — fix or decline — states the reasoning. This makes the
|
||||
PR self-documenting and gives the next review visible context.
|
||||
Declined findings cite the prior round and explain why the existing
|
||||
form is correct, so the next reviewer doesn't raise it again.
|
||||
|
||||
## Escalation rules
|
||||
|
||||
Stay in autopilot by default; escalate to the user when:
|
||||
|
||||
- The finding identifies a **design-level tradeoff** with multiple
|
||||
reasonable resolutions and no clear winner.
|
||||
- The fix would be **large or cross-cutting** (hundreds of lines,
|
||||
new architecture, refactor across modules).
|
||||
- The action is **high-risk or irreversible** (force-push, deletion,
|
||||
credentials, production).
|
||||
|
||||
Otherwise, decide and proceed.
|
||||
@@ -0,0 +1,59 @@
|
||||
# Step 5: Apply fixes
|
||||
|
||||
Sub-agent type: `general-purpose`, **one sub-agent per finding**,
|
||||
parallel **max 5 concurrent**; budget: 5 min each. If step 4 returned
|
||||
more than 5 `fix` rows, the parent runs step 5 in waves of ≤5.
|
||||
|
||||
## Inputs
|
||||
|
||||
Per sub-agent (one finding per invocation):
|
||||
- `thread_id`, `file`, `line` from step 3.
|
||||
- The finding `summary` and Copilot's suggested fix (if any).
|
||||
- The triage `rationale` from step 4 — the agent already decided this
|
||||
is a `fix`; this sub-agent only implements it.
|
||||
|
||||
## Return contract
|
||||
|
||||
```
|
||||
{ thread_id, files_touched, summary, status }
|
||||
```
|
||||
|
||||
Where `status` ∈ `complete` | `partial` | `blocked` and `summary` is
|
||||
a one-line description of the change.
|
||||
|
||||
## Procedure
|
||||
|
||||
1. **Discover repo conventions for the area being edited — first.**
|
||||
Before writing any code, read:
|
||||
- `.github/instructions/*.md` whose `applyTo` glob matches the
|
||||
file's path,
|
||||
- `.github/skills/`,
|
||||
- `AGENTS.md`,
|
||||
- `CONTRIBUTING.md`,
|
||||
- neighbor-file patterns in the same directory and recent commits
|
||||
touching similar files.
|
||||
2. Apply the fix in line with those conventions.
|
||||
3. Return `files_touched` + a one-line `summary` + `status`.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Max 5 concurrent fix sub-agents.** The cap prevents fix-fanout
|
||||
chaos; the parent merges results and reconciles file conflicts
|
||||
between waves before step 6.
|
||||
- **Never invent a generic answer that contradicts repo practice.**
|
||||
That's the "elephant in school" anti-pattern — a Copilot suggestion
|
||||
in isolation looks right but breaks the project's lint, format,
|
||||
spell-check, license-header, or framework conventions. The
|
||||
discovery step is mandatory, not optional.
|
||||
- **One finding per sub-agent.** Fixes that need to touch the same
|
||||
file get serialized by the parent between waves — don't merge
|
||||
multiple findings into one sub-agent invocation.
|
||||
- **Project policy beats Copilot suggestion.** If discovery surfaces
|
||||
a documented convention (spell-check allowlist mechanism, lint
|
||||
suppression style, etc.) that contradicts the suggested fix, follow
|
||||
the convention and reflect that in the reply body drafted in step 8
|
||||
(see [04-triage.md](04-triage.md#project-specific-policy-hooks)).
|
||||
- **Push back with written rationale** if implementing the fix would
|
||||
over-engineer the design for a hypothetical edge case — flip the
|
||||
triage to `decline` and return `status: blocked` with the rationale
|
||||
so step 8 drafts the right reply.
|
||||
@@ -0,0 +1,59 @@
|
||||
# Step 6: Build + test per repo conventions
|
||||
|
||||
Sub-agent type: `task` (may fan out to several `explore` sub-agents in
|
||||
parallel for discovery); budget: 10 min (extension cap up to 2× for
|
||||
slow suites).
|
||||
|
||||
## Inputs
|
||||
|
||||
- The set of files touched in step 5 (from each fix sub-agent's
|
||||
`files_touched`).
|
||||
- Whatever the parent has cached from prior rounds about the repo's
|
||||
build / test / lint command set.
|
||||
|
||||
## Return contract
|
||||
|
||||
```
|
||||
{ status, failures }
|
||||
```
|
||||
|
||||
Where `status` ∈ `pass` | `fail` and `failures` is the relevant
|
||||
excerpt from the failing tool's output (build errors, test failures,
|
||||
lint diagnostics) — enough for the parent to decide whether to loop
|
||||
back to step 5 for a follow-up fix or push as-is.
|
||||
|
||||
## Procedure
|
||||
|
||||
**Discovery first** — read and combine:
|
||||
|
||||
- `.github/instructions/*.md`,
|
||||
- `AGENTS.md`,
|
||||
- `CONTRIBUTING.md`,
|
||||
- `README.md`,
|
||||
- `package.json` scripts,
|
||||
- `Makefile`,
|
||||
- language-specific tooling configs,
|
||||
- AND recent CI workflow runs (`gh run list`, `gh run view`) to learn
|
||||
the *actual* command set in use.
|
||||
|
||||
THEN run those exact commands on the changed code. Independent
|
||||
discovery axes (build tool / test runner / lint / spelling / format)
|
||||
can be dispatched as separate `explore` sub-agents in parallel; cache
|
||||
the discovered commands per round so re-runs don't re-discover.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Never invent generic build commands.** A broken build wastes the
|
||||
next full review cycle (3–10 min). If discovery turns up nothing,
|
||||
surface the gap — don't guess.
|
||||
- **Respect repo-specific spell-check / lint / format policies.**
|
||||
Some repos prefer rewording over allowlist entries; some have a
|
||||
patterns/regex file; some accept inline-ignore directives. Inspect
|
||||
the repo's existing config and recent commits before applying a
|
||||
generic fix.
|
||||
- **Cache discovered commands per round, not per loop.** Repo configs
|
||||
can change between rounds (a fix may add a new lint), so re-discover
|
||||
at the start of each round, but reuse within the round.
|
||||
- **Failures route back to step 5.** When `status: fail`, the parent
|
||||
re-enters step 5 with the failure excerpts as a new finding — don't
|
||||
push a broken build to satisfy step 7.
|
||||
@@ -0,0 +1,22 @@
|
||||
# Step 7: Commit and push
|
||||
|
||||
Owner: **parent** (no sub-agent); budget: n/a.
|
||||
|
||||
## Inputs
|
||||
|
||||
- The fix results from step 5 and the green build from step 6.
|
||||
|
||||
## Return contract
|
||||
|
||||
- Pushed `HeadOid` (the new commit SHA), recorded for step 8 reply
|
||||
bodies and step 9 convergence proof.
|
||||
|
||||
## Procedure
|
||||
|
||||
- Parent runs `git commit` + `git push` directly. One focused commit
|
||||
per round — bundling rounds destroys the audit trail of which finding
|
||||
drove which change and breaks `git bisect`.
|
||||
- Include the trailer:
|
||||
`Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>`.
|
||||
- Record the pushed SHA so step 8 can cite it in every reply body and
|
||||
step 9 can compare it against `LatestCopilotReview.commitOid`.
|
||||
@@ -0,0 +1,103 @@
|
||||
# Step 8: Reply (always) + resolve (conditional)
|
||||
|
||||
Sub-agent type: `general-purpose` **drafts** the reply bodies; the
|
||||
**parent** posts them (mutations stay parent-owned). Budget for the
|
||||
drafting sub-agent: 5 min.
|
||||
|
||||
Runs AFTER step 7 (commit + push) so every reply can cite the
|
||||
**pushed commit SHA**.
|
||||
|
||||
## Inputs
|
||||
|
||||
- The full triage table from step 4 — `{ thread_id, action,
|
||||
rationale }` per open thread (including `escalate-to-user`).
|
||||
- The pushed `HeadOid` from step 7.
|
||||
- The per-thread fix `summary` and `files_touched` from step 5 (for
|
||||
`fix` rows).
|
||||
|
||||
## Return contract
|
||||
|
||||
One row per open thread:
|
||||
|
||||
```
|
||||
{ thread_id, action, reply_body }
|
||||
```
|
||||
|
||||
Where `action` ∈ `fix` | `decline` | `escalate-to-user`. The parent
|
||||
consumes this to drive the resolve/no-resolve decision (see
|
||||
Procedure).
|
||||
|
||||
## Procedure
|
||||
|
||||
1. **Drafting sub-agent** produces a `reply_body` per thread by
|
||||
selecting the appropriate template (see [#templates](#templates))
|
||||
based on the triage `action`. Cite the pushed SHA from step 7 in
|
||||
`fix` replies. For `escalate-to-user`, explain the disposition and
|
||||
the open question for the human merge owner; do not promise a
|
||||
resolve.
|
||||
2. **Parent posts each reply**, choosing whether to resolve:
|
||||
|
||||
```pwsh
|
||||
pwsh ./scripts/08-reply-and-resolve.ps1 -ThreadId <id> -Body <text>
|
||||
```
|
||||
|
||||
- `action ∈ { fix, decline }` → run as above (resolve happens).
|
||||
- `action == escalate-to-user` → **add `-NoResolve`** so the thread
|
||||
stays open for the human:
|
||||
|
||||
```pwsh
|
||||
pwsh ./scripts/08-reply-and-resolve.ps1 -ThreadId <id> -Body <text> -NoResolve
|
||||
```
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Reply to every open thread; resolve only when the loop owns the
|
||||
disposition** (`fix` or `decline`). Resolving without a reply
|
||||
leaves no record of why the issue was considered addressed.
|
||||
- **Escalated threads stay open *with our reply* explaining the
|
||||
disposition.** They're explicit hand-offs to the human merge
|
||||
owner, not loop failures — that's why convergence in step 9 can
|
||||
succeed with `OpenThreadCount > 0`.
|
||||
- **Mutations are parent-owned.** The sub-agent only drafts; it never
|
||||
posts. This keeps the audit trail of mutations on the parent and
|
||||
avoids double-post races between concurrent sub-agents.
|
||||
- **Cite the pushed SHA, not a local commit.** Step 7's recorded
|
||||
`HeadOid` is the only SHA reviewers can browse to.
|
||||
- **Reply hygiene matters for the next round.** Declines that don't
|
||||
cite reasoning get re-raised by the next Copilot review. See
|
||||
[04-triage.md](04-triage.md#reply-hygiene).
|
||||
|
||||
## Templates
|
||||
|
||||
Pick by triage action:
|
||||
|
||||
| Triage action | Template |
|
||||
|---------------|----------|
|
||||
| `fix` | [reply-fix.md](../templates/reply-fix.md) |
|
||||
| `decline` | [reply-decline.md](../templates/reply-decline.md) |
|
||||
| PR-description / comment drift acknowledgement | [reply-drift.md](../templates/reply-drift.md) |
|
||||
| Partial fix with deferred follow-up | [reply-partial.md](../templates/reply-partial.md) |
|
||||
|
||||
For `escalate-to-user`, there is no template — write a bespoke reply
|
||||
explaining the disposition and the open question, then post with
|
||||
`-NoResolve` so the thread stays open.
|
||||
|
||||
## Reply guidance
|
||||
|
||||
The reply has to do real work — it documents the decision for future
|
||||
maintainers and shapes what the next Copilot review will surface.
|
||||
|
||||
Be **concrete** (cite file paths, commit SHAs, function names),
|
||||
**direct** (no hedging when you have a position), and **brief** (2–4
|
||||
sentences is typical). Long replies usually mean the round should
|
||||
have been broken up.
|
||||
|
||||
### Anti-patterns — DO NOT use
|
||||
|
||||
- ❌ `"Thanks!"` / `"Good point."` with no substance.
|
||||
- ❌ `"Will fix later."` Either fix it now or decline with rationale;
|
||||
deferred fixes that aren't tracked anywhere get lost.
|
||||
- ❌ Resolve-without-reply. The next reviewer cannot reconstruct why
|
||||
the thread was closed.
|
||||
- ❌ `"I disagree."` with no reasoning. State the actual technical
|
||||
disagreement.
|
||||
@@ -0,0 +1,195 @@
|
||||
# Step 9: Convergence verify
|
||||
|
||||
Sub-agent type: `explore`; budget: 3 min.
|
||||
|
||||
## Inputs
|
||||
|
||||
- `PrNumber`.
|
||||
- The pushed `HeadOid` from step 7 (for the independent sanity check).
|
||||
- Whether the loop is in normal mode or [single-iteration
|
||||
mode](orchestration.md#single-iteration-fallback) (decided at step 1).
|
||||
|
||||
## Return contract
|
||||
|
||||
```
|
||||
{ converged, head_oid, latest_review_commit_oid, submitted_at,
|
||||
open_thread_count, open_threads_awaiting_reply, escalated_threads }
|
||||
```
|
||||
|
||||
`converged` is the single source-of-truth boolean — `Converged: true`
|
||||
returned by `02-check-review-status.ps1`.
|
||||
|
||||
## Procedure
|
||||
|
||||
Run the status check, passing `-SingleIteration` iff the loop took the
|
||||
fallback at step 1:
|
||||
|
||||
```pwsh
|
||||
pwsh ./scripts/02-check-review-status.ps1 -PrNumber <n>
|
||||
# single-iteration variant:
|
||||
pwsh ./scripts/02-check-review-status.ps1 -PrNumber <n> -SingleIteration
|
||||
```
|
||||
|
||||
Then run an **independent HEAD-vs-`LatestCopilotReview.commitOid`
|
||||
sanity check** — the parent's recorded `HeadOid` from step 7 should
|
||||
match `HEAD` and (in normal mode) match the latest review's
|
||||
`commitOid`.
|
||||
|
||||
## Decision: loop back or exit
|
||||
|
||||
After the status check, the parent agent **must** branch on `converged`:
|
||||
|
||||
```
|
||||
if converged == true:
|
||||
run step 10 once (cleanup outdated)
|
||||
call task_complete with proof (HeadOid, LatestCopilotReview.commitOid, submittedAt)
|
||||
DONE — exit the loop
|
||||
else:
|
||||
# non-converged = a fresh Copilot finding OR an unresolved human thread.
|
||||
# round = count of Copilot review submissions in the PR's history,
|
||||
# read deterministically from the API (NOT a mental tally):
|
||||
# pwsh ./scripts/09-review-round.ps1 -PrNumber <n> -> {Round, RecapDue}
|
||||
if RecapDue == true: # Round is 10, 20, 30, ...
|
||||
RUN THE RECAP GATE (see "Round cap & recap gate" below) BEFORE looping:
|
||||
recap ALL prior rounds, then pick CONTINUE / REVERT-AND-SHIP / HAND-OFF.
|
||||
CONTINUE -> fall through and start another round
|
||||
REVERT-AND-SHIP -> drop drifted commits, ship the in-scope result, exit
|
||||
HAND-OFF -> escalate to the user with the recap, exit
|
||||
GO BACK TO STEP 1 — start another round
|
||||
(re-trigger via 01-request-review.ps1, wait via 02-wait,
|
||||
list via 03-list-threads, triage, fix, push, reply+resolve,
|
||||
re-check via this step)
|
||||
```
|
||||
|
||||
A non-converged result is **never** terminal *on its own* — each round
|
||||
addresses the open review feedback on the previous round's HEAD,
|
||||
whether that's a **Copilot finding or a human review comment** (this
|
||||
skill handles both). The loop terminates only when there are **no new
|
||||
review comments from either source** AND **every open thread — Copilot
|
||||
or human — has a reply from the agent** (a thread the agent escalated
|
||||
to the user counts as replied; it stays open in `OpenThreadCount` as an
|
||||
explicit hand-off, not as loop work). But "never terminal" must not be
|
||||
read as "infinite": a bot-review loop has no guaranteed fixed point and
|
||||
can drift into over-engineering or oscillation. No script *enforces* a
|
||||
cap or stops the loop — capping is a reasoning decision the parent owns
|
||||
at the [round-cap recap gate](#round-cap--recap-gate-circuit-breaker)
|
||||
below. What *is* scripted is the round **count** itself
|
||||
([09-review-round.ps1](#round-cap--recap-gate-circuit-breaker)), so the
|
||||
gate's trigger is deterministic rather than a fallible mental tally
|
||||
(and oscillation — the same finding re-raised across rounds — is
|
||||
broken earlier per
|
||||
[04-triage.md](04-triage.md#conflicting-comments--break-oscillation-early)).
|
||||
|
||||
`-SingleIteration` mode is the **one** exception: by definition, it
|
||||
runs one round only (the trigger path is unavailable), and the
|
||||
`converged` result is taken as terminal whichever way it goes.
|
||||
|
||||
### Convergence semantics
|
||||
|
||||
`02-check-review-status.ps1` implements a PR-state guard plus three Converged branches (see the `Converged = if (...)` block near the end of that script for the canonical source):
|
||||
|
||||
- **PR State guard (overrides everything)** — if `State != 'OPEN'` (CLOSED / MERGED), `Converged: false` regardless of all other flags. The agent cannot push to a non-OPEN PR; surface the state change to the user and abort the loop rather than calling `task_complete`.
|
||||
- **Normal (Copilot-driven) mode** — a Copilot review exists OR `CopilotPending: true`:
|
||||
`Converged: true` iff
|
||||
`ReviewAtHead && NoNewComments && OpenThreadsAwaitingReply == 0`.
|
||||
- **Single-iteration mode** (`-SingleIteration` passed because the loop took the [fallback at step 1](orchestration.md#single-iteration-fallback)):
|
||||
`Converged: true` iff `OpenThreadsAwaitingReply == 0`. The stale-review checks can never advance without a new Copilot review, so they're omitted.
|
||||
- **No Copilot review ever observed AND not pending** (brand-new PRs with zero findings, or PRs where the trigger silently failed and the script wasn't called with `-SingleIteration`):
|
||||
`Converged: true` iff `OpenThreadsAwaitingReply == 0`. **Do NOT trust this as "loop done" before step 1 has fired** — it just means there's no human-thread work pending. The parent agent MUST run `01-request-review.ps1` first (per [step 1](01-request-review.md)) and re-check; treating brand-new-PR convergence as terminal will short-circuit the entire loop.
|
||||
|
||||
`OpenThreadCount` MAY be `> 0` when escalated-to-user threads stay
|
||||
open — that's an explicit human hand-off, not a loop failure. Return
|
||||
the list of escalated `thread_id`s so the parent can include them in
|
||||
the convergence proof.
|
||||
|
||||
## Round cap & recap gate (circuit breaker)
|
||||
|
||||
No script *enforces* a max-rounds cap or stops the loop — a hard number
|
||||
can't tell a *productive* round from a *drifting* one. Instead the parent
|
||||
agent runs a **recap gate** as reasoning: default **STOP at every 10th
|
||||
round** (10, 20, 30, …) **before** looping back to step 1, recap all
|
||||
prior rounds, and decide whether the loop is still serving the PR's
|
||||
original scope.
|
||||
|
||||
What *is* scripted is the **count**, so the gate's trigger is
|
||||
deterministic instead of a fallible mental tally. A **round** is **one
|
||||
execution of [step 1](01-request-review.md)** — one Copilot-review
|
||||
trigger at the top of the loop — which produces exactly one Copilot
|
||||
review submission. [`09-review-round.ps1`](../scripts/09-review-round.ps1)
|
||||
counts those submissions straight from the PR's API history and reports
|
||||
whether the cadence is hit:
|
||||
|
||||
```pwsh
|
||||
pwsh ./scripts/09-review-round.ps1 -PrNumber <n>
|
||||
# {"PrNumber":<n>,...,"Round":20,"RecapInterval":10,"RecapDue":true}
|
||||
```
|
||||
|
||||
Run it at the top of the non-converged branch and gate on `RecapDue`.
|
||||
Because the count is **derived from history, not remembered**, it can't
|
||||
drift even across a 100+ round run — the exact failure this gate exists
|
||||
to catch. The cap counts **review rounds** (Copilot review submissions),
|
||||
not sub-agent calls, tool calls, or individual fix edits — so a round
|
||||
that triages five threads still counts as one. The cadence is the
|
||||
`-RecapInterval` knob (default 10). The script reports the trigger only;
|
||||
it never decides the verdict.
|
||||
|
||||
This exists because an unbounded bot-review loop is the failure mode
|
||||
this skill was built to survive: a real run drifted for 156 rounds —
|
||||
later rounds "fixing" things the PR never set out to change, eventually
|
||||
reverting their own earlier fixes. The gate catches that class of drift
|
||||
early, every 10 rounds, instead of once at the end.
|
||||
|
||||
### What the recap reviews (ALL prior rounds, not just the last 10)
|
||||
|
||||
1. **Original PR scope** — the issue/PR title and the diff at the PR's
|
||||
base. This is the yardstick; everything else is measured against it.
|
||||
2. **Per-round ledger** — for each round so far: the Copilot finding,
|
||||
the disposition (fixed / declined / escalated), and the resulting
|
||||
change (files + intent in one line).
|
||||
3. **Drift signals** across the whole history:
|
||||
- **Out-of-scope** — a change that doesn't trace back to the
|
||||
original issue/PR goal (new feature, adjacent refactor, polish
|
||||
the PR never promised).
|
||||
- **Over-engineering** — defensive layers, abstractions, or config
|
||||
added solely to satisfy bot nits, not the PR's actual goal.
|
||||
- **Wrong-direction** — a fix that later rounds had to undo, work
|
||||
around, or re-fix (self-revert / oscillation across rounds).
|
||||
- **Belongs-in-separate-PR** — a legitimate improvement that is
|
||||
nonetheless unrelated to this PR's stated change.
|
||||
- **Scope/complexity growth** — diff size or file count climbing
|
||||
while the original goal was met rounds ago.
|
||||
|
||||
### Verdicts
|
||||
|
||||
| Verdict | When | Action |
|
||||
| --- | --- | --- |
|
||||
| **CONTINUE** | Every round so far traces to the original PR scope; no drift signals; Copilot is still surfacing in-scope findings. | Loop back to step 1 for the next 10-round block. |
|
||||
| **REVERT-AND-SHIP** | One or more rounds drifted (over-engineering / wrong-direction / oscillation) but the in-scope fixes are sound. | `git revert` (or drop) only the drifted commits, keep the in-scope ones, run step 6 build/test, then ship the clean result. Record which rounds were reverted in the convergence proof. |
|
||||
| **HAND-OFF** | Drift is entangled with in-scope work, the right fix is a redesign, or the change belongs in a separate PR. | Stop the loop, reply on the relevant threads, and escalate to the user with the recap and a recommendation (separate PR / redesign). Do **not** keep looping. |
|
||||
|
||||
The **trigger** is scripted but the **verdict** is agent reasoning —
|
||||
deliberately. [`09-review-round.ps1`](../scripts/09-review-round.ps1)
|
||||
makes the *count* deterministic (so the gate can't be missed), but
|
||||
*which verdict to pick* stays a judgment call: no number can tell a
|
||||
productive round from a drifting one. The recap is cheap (read the
|
||||
per-round commits + the PR base diff); the cost of *skipping* it is
|
||||
another runaway loop.
|
||||
|
||||
|
||||
|
||||
- **Trust `02-check-review-status.ps1`'s `Converged` flag, not your
|
||||
own re-derivation.** The script enforces all three conditions
|
||||
(normal mode) or the simplified condition (single-iteration) and
|
||||
is the canonical source.
|
||||
- **Don't call `task_complete` until `converged == true`.** Print
|
||||
the proof (`HeadOid`, `LatestCopilotReview.commitOid`,
|
||||
`submittedAt`, `OpenThreadsAwaitingReply: 0`, list of escalated
|
||||
threads if `OpenThreadCount > 0`) in the completion message.
|
||||
- **`-SingleIteration` is sticky to the fallback decision.** If
|
||||
step 1 took the fallback, every step 9 in this loop uses
|
||||
`-SingleIteration`; don't flip it mid-loop.
|
||||
- **PR State != OPEN aborts the loop.** If `State` is `CLOSED` or
|
||||
`MERGED`, `Converged` is forced `false` by the script's state
|
||||
guard. The parent agent cannot push to a non-OPEN PR — surface
|
||||
the state change to the user and stop the loop rather than
|
||||
retrying or calling `task_complete`.
|
||||
@@ -0,0 +1,25 @@
|
||||
# Step 10: Cleanup outdated
|
||||
|
||||
Owner: **parent** (no sub-agent); budget: n/a. Runs **once, after
|
||||
convergence** (step 9 returned `Converged: true`).
|
||||
|
||||
## Inputs
|
||||
|
||||
- `PrNumber` for the converged PR.
|
||||
|
||||
## Return contract
|
||||
|
||||
- None — step 10 is terminal. After it runs, the loop is complete and
|
||||
the parent calls `task_complete` with the convergence proof from
|
||||
step 9.
|
||||
|
||||
## Procedure
|
||||
|
||||
```pwsh
|
||||
pwsh ./scripts/10-cleanup-outdated.ps1 -PrNumber <n>
|
||||
```
|
||||
|
||||
Safety net only. Most loops converge with nothing to clean — outdated
|
||||
threads should already have been replied + resolved in step 8 like any
|
||||
other open thread. Unresolved state is the source of truth in the PR
|
||||
UI; `10-cleanup-outdated.ps1` only catches strays.
|
||||
@@ -0,0 +1,208 @@
|
||||
# GitHub API Quirks (Verified)
|
||||
|
||||
API behaviors that matter for the Copilot review loop. All verified
|
||||
against the current API surface — read this before reaching for an
|
||||
alternative API or modifying the bundled scripts.
|
||||
|
||||
## GraphQL trigger — `requestReviewsByLogin` is the supported path
|
||||
|
||||
```graphql
|
||||
mutation($p: ID!) {
|
||||
requestReviewsByLogin(input: {
|
||||
pullRequestId: $p,
|
||||
botLogins: ["copilot-pull-request-reviewer"]
|
||||
}) {
|
||||
pullRequest { number }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Verified empirically against personal repos without Copilot Pro AND
|
||||
org repos with Copilot Enterprise. Works for both initial-add and
|
||||
re-request (no special re-request mutation).
|
||||
|
||||
Three GraphQL traps:
|
||||
|
||||
1. Mutation is **`requestReviewsByLogin`**, NOT `requestReviews`.
|
||||
`RequestReviewsInput` (used by `requestReviews`) does not expose a
|
||||
`botLogins` field, so it can't request a bot reviewer at all —
|
||||
`botLogins` is the central field on `requestReviewsByLogin`.
|
||||
2. Field is **`botLogins`**, NOT `userLogins`. The latter returns
|
||||
`Could not resolve user with login 'Copilot'`.
|
||||
3. Slug is **`copilot-pull-request-reviewer`** (the App slug). The
|
||||
display login `Copilot` returns `Could not resolve bot with slug
|
||||
'Copilot'`.
|
||||
|
||||
Verify success via a new `copilot_work_started` event on the issue's
|
||||
events feed — `GET /repos/{o}/{r}/issues/{n}/events` (see SKILL.md
|
||||
Gotchas "HTTP 200 / exit 0 is NOT proof"). Empirically this event
|
||||
type IS exposed on the `/events` endpoint (verified across 20+
|
||||
trigger rounds on PR 236); it is not timeline-only.
|
||||
`01-request-review.ps1` enforces this by comparing the event `id`
|
||||
(monotonic) before and after the trigger.
|
||||
|
||||
### Other trigger paths — DO NOT USE
|
||||
|
||||
- **`requestReviews` with `botLogins`** → input type rejects the
|
||||
field. Don't try variants.
|
||||
- **REST `POST /pulls/<n>/requested_reviewers` with
|
||||
`reviewers[]=Copilot`** → can return HTTP 201 while silently
|
||||
dropping the bot. Not used by the script.
|
||||
- **`gh pr edit --add-reviewer Copilot`** → returns `'Copilot' not
|
||||
found` on current `gh`. Not used by the script.
|
||||
|
||||
## GraphQL `latestReviews` — stale cache, do NOT use
|
||||
|
||||
```graphql
|
||||
# DO NOT — stale projection:
|
||||
pullRequest(number:$pr){ latestReviews(first:50){ nodes{...} } }
|
||||
|
||||
# USE INSTEAD — always current:
|
||||
pullRequest(number:$pr){ reviews(last:100){ nodes{...} } }
|
||||
```
|
||||
|
||||
`latestReviews` is a "latest per user" projection with stale-cache
|
||||
behavior: a fresh Copilot review can be absent for several minutes
|
||||
after submission, while `reviews(last:100)` reflects it immediately.
|
||||
Using `latestReviews` for in-flight or convergence checks causes the
|
||||
script to operate on an obsolete commit OID — either falsely
|
||||
declaring convergence or timing out for a review that already
|
||||
exists.
|
||||
|
||||
`02-check-review-status.ps1` uses `reviews(last:100)` filtered
|
||||
client-side to the Copilot reviewer login. It also emits a stderr
|
||||
warning when the result is exactly 100 reviews, so the caller knows
|
||||
the boundary was hit and the latest Copilot review COULD be older
|
||||
than the window — practically only possible if 100+ non-Copilot
|
||||
reviews landed after the last Copilot review, which doesn't happen
|
||||
in normal use. If you ever see the warning and the loop misbehaves,
|
||||
fetch the full review list manually:
|
||||
|
||||
```bash
|
||||
gh pr view <n> --json reviews --jq '.reviews[] | select(.author.login | test("copilot-pull-request-reviewer"))'
|
||||
```
|
||||
|
||||
### Tie-break for multiple Copilot reviews
|
||||
|
||||
When more than one Copilot review shares the same `submittedAt`
|
||||
(rare server-side clock collision under burst re-triggers), the
|
||||
script first prefers the review whose `commit.oid == HEAD`, then
|
||||
falls back to a stable sort. The intent is "the review that
|
||||
matches the current code is the one the agent should reply to" —
|
||||
preventing a stale-OID review from winning the tie and falsely
|
||||
flipping `ReviewAtHead` to false.
|
||||
|
||||
## Reply + resolve mutations — both work
|
||||
|
||||
```graphql
|
||||
mutation($tid: ID!, $body: String!) {
|
||||
addPullRequestReviewThreadReply(input: {
|
||||
pullRequestReviewThreadId: $tid,
|
||||
body: $body
|
||||
}) { comment { id } }
|
||||
}
|
||||
|
||||
mutation($tid: ID!) {
|
||||
resolveReviewThread(input: { threadId: $tid }) {
|
||||
thread { isResolved }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## `isOutdated` ≠ `isResolved` — current unresolved state is truth
|
||||
|
||||
A thread can be `isOutdated: true` (Copilot's comment points at lines
|
||||
that have since changed) while still `isResolved: false`. These
|
||||
threads:
|
||||
|
||||
- Still need reply + resolve in the per-round loop. A thread can
|
||||
become outdated mid-round when your own fix shifts the cited
|
||||
lines. Filtering on `!isOutdated` would silently drop those
|
||||
threads, leaving the PR's open-conversations list non-empty even
|
||||
after the underlying code is fixed.
|
||||
- `03-list-open-threads.ps1` therefore lists every unresolved
|
||||
thread with no `isOutdated` filter.
|
||||
- `10-cleanup-outdated.ps1` is a safety net only — for the rare
|
||||
case where a thread becomes outdated AFTER your last per-round
|
||||
fetch.
|
||||
|
||||
## Review latency — don't poll faster than ~3 min
|
||||
|
||||
Copilot reviews typically post 3–6 minutes after the request,
|
||||
occasionally up to ~10 minutes. There is no progress signal;
|
||||
polling more often than every ~3 min wastes API budget without
|
||||
making the review arrive sooner.
|
||||
|
||||
## `gh api graphql -F` coerces strings — use `-f` for `String!`
|
||||
|
||||
The `gh` CLI distinguishes its two flag forms:
|
||||
|
||||
- `-F key=value` — type inference. Values parsing as int, bool, or
|
||||
null are sent as that JSON literal.
|
||||
- `-f key=value` — always sends as raw string.
|
||||
|
||||
For any GraphQL variable declared `String!` (e.g. `owner`, `repo`,
|
||||
`body`, `tid`, `after`), use **`-f`** at call sites. A reply body that
|
||||
happens to be `"true"`, `"null"`, or all digits would otherwise be
|
||||
coerced and the call fails with a type error. Keep `-F` only for
|
||||
genuinely numeric or boolean variables (e.g. `pr: Int!`).
|
||||
|
||||
> Note: the shared `Invoke-Gh` wrapper may internally rewrite
|
||||
> `-f field=<body>` into `-F field=@<tempfile>` when the body contains
|
||||
> embedded `"` (Windows PowerShell 5.1 native-arg quoting bug — see
|
||||
> below). Even via `@file`, `-F` still applies type inference to the
|
||||
> file content (gh's documented behaviour) — this rewrite is safe
|
||||
> only because the rewrite trigger ("body contains `"`") guarantees
|
||||
> the content is a string that no JSON literal (`123`, `true`,
|
||||
> `null`, etc.) would match. Treat this `-F ...=@file` usage as an
|
||||
> internal transport detail of the wrapper, not as permission to
|
||||
> use `-F ...=@file` for arbitrary strings at call sites.
|
||||
|
||||
```powershell
|
||||
# Wrong — body could be coerced AND, under Windows PowerShell 5.1,
|
||||
# any embedded `"` in $Body will be mis-split by the native-arg
|
||||
# passer (gh sees a truncated body or a "received N args" error).
|
||||
gh api graphql -f query=$q -F body=$Body
|
||||
|
||||
# Right — go through Invoke-Gh / Invoke-GhGraphQL. The shared helper
|
||||
# auto-rewrites `-f field=<body>` and `-F field=<body>` pairs whose
|
||||
# body contains `"` to `-F field=@<tempfile>` so the value is read
|
||||
# from disk and never appears on the command line. This works
|
||||
# identically on Windows PowerShell 5.1 and PowerShell 7+.
|
||||
Invoke-GhGraphQL -GhArgs @('-f',"query=$q",'-f',"body=$Body") -Context 'reply body'
|
||||
```
|
||||
|
||||
Calling `gh` directly (e.g. via `& gh ...` or raw `gh api graphql`)
|
||||
bypasses the cross-version tempfile rewrite — if your value contains
|
||||
`"` you'll re-introduce the PowerShell-5.1-only splitting bug. Always
|
||||
funnel `gh` calls through `Invoke-Gh` / `Invoke-GhGraphQL`.
|
||||
|
||||
## Native `gh` exit codes bypass `$ErrorActionPreference`
|
||||
|
||||
`gh` is a native executable, not a PowerShell cmdlet, so a non-zero
|
||||
exit does **not** throw even when `$ErrorActionPreference = 'Stop'`.
|
||||
Without an explicit check the script will print misleading success
|
||||
messages after a failed API call, and the loop will falsely declare
|
||||
convergence on auth issues, rate limits, or transient 5xx.
|
||||
|
||||
Additional trap: `gh api graphql` can exit 0 for an HTTP 200 whose
|
||||
JSON body carries a top-level `errors` array. Treat that as a failed
|
||||
call too.
|
||||
|
||||
The shared helpers in [scripts/_lib.ps1](../scripts/_lib.ps1)
|
||||
(`Invoke-Gh` and `Invoke-GhGraphQL`) run `gh` via `& gh @args`
|
||||
with stderr redirected to a temp file (`2>$errFile`), then read
|
||||
`$LASTEXITCODE` and return `{ExitCode, Stdout, Stderr}`.
|
||||
`Invoke-GhGraphQL` additionally parses the GraphQL `errors` array
|
||||
on the response body and throws on either failure mode. All
|
||||
bundled scripts dot-source `_lib.ps1` and use these wrappers — do
|
||||
the same in any new script.
|
||||
|
||||
## `git stash push` argument order
|
||||
|
||||
```bash
|
||||
git stash push -m "local-build" -- src/path/a src/path/b # correct
|
||||
git stash push -- src/path/a src/path/b -m "local-build" # SILENTLY drops -m
|
||||
```
|
||||
|
||||
The `-m` MUST come before the `--` path separator.
|
||||
@@ -0,0 +1,106 @@
|
||||
# Orchestration — Parent-Owned Loop Control
|
||||
|
||||
Cross-cutting protocol for the Copilot PR review loop: time-boxing,
|
||||
the sub-agent delegation map, the single-iteration fallback, and the
|
||||
loop-wide notes. Every step — including the parent-owned steps 1, 7,
|
||||
and 10 — has its own `NN-*.md` contract file alongside this one; this
|
||||
file holds only what spans the whole loop.
|
||||
|
||||
Build, test, and lint commands are NOT prescribed here. Every step
|
||||
that needs them defers to the target repo's own conventions
|
||||
(`CONTRIBUTING.md`, `AGENTS.md`, `README`, `package.json` /
|
||||
`Makefile` / language tooling, etc.). Discover and follow the repo's
|
||||
existing practice — never invent build commands.
|
||||
|
||||
## Time-boxing & extension protocol
|
||||
|
||||
| Concept | Rule |
|
||||
|---------|------|
|
||||
| Default budget | 5 minutes per sub-agent invocation |
|
||||
| Sub-agent must return | `status` ∈ {`complete`, `partial`, `blocked`} + `next_action` + `needs_extension_minutes` (0 if none). Always summarize progress before the budget expires — never silently overrun. |
|
||||
| Extension | parent only extends when `status: partial` AND `next_action` is concrete; sends `write_agent "continue for N min"` with `N = min(needs_extension_minutes, 10)` |
|
||||
| Extension cap (default) | 2 extensions per step; step 6 (build/test) up to 2× for slow suites. Step 2 (wait) is a single bounded sub-agent — see [02-wait.md](02-wait.md) — not extension-driven. |
|
||||
| Parent never blocks | step 1 (request), step 7 (commit + push), step 8 reply/resolve mutations, and the `task_complete` decision stay in the parent |
|
||||
|
||||
When the cap is reached and the work is still `partial`, the parent
|
||||
narrows the input (batch smaller in step 4 / split fix scope in step 5)
|
||||
or takes the step over itself.
|
||||
|
||||
## Sub-agent delegation map
|
||||
|
||||
> **The loop:** one **round** = steps 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 → 9. After step 9, if `Converged: false`, **go back to step 1** for another round. Repeat until step 9 returns `Converged: true`; then run step 10 once and exit. **At every 10th round, the parent runs the [round-cap recap gate](09-convergence.md#round-cap--recap-gate-circuit-breaker) before looping back** — the circuit breaker that recaps all prior rounds and stops the loop if it has drifted out of the PR's original scope. See [09-convergence.md](09-convergence.md) for the convergence definition, the loop-exit / loop-back decision, and the recap gate.
|
||||
|
||||
Canonical order per round: **request → wait → list → triage → fix →
|
||||
build → commit + push → reply + resolve (citing pushed SHA) →
|
||||
convergence check**. Reply/resolve runs AFTER push so replies can cite
|
||||
the pushed commit SHA.
|
||||
|
||||
| Step | Owner | Contract |
|
||||
|------|-------|----------|
|
||||
| 1 — Request review | parent | [01-request-review.md](01-request-review.md) |
|
||||
| 2 — Wait for review | sub-agent (`general-purpose`, 20 min) | [02-wait.md](02-wait.md) |
|
||||
| 3 — List + categorize open threads | sub-agent (`explore`, 5 min) | [03-list-threads.md](03-list-threads.md) |
|
||||
| 4 — Triage | sub-agent (`general-purpose`, 5 min/≤5 threads) | [04-triage.md](04-triage.md) |
|
||||
| 5 — Apply fixes | sub-agents (`general-purpose`, parallel max 5, 5 min each) | [05-fix.md](05-fix.md) |
|
||||
| 6 — Build + test per repo conventions | sub-agent (`task` + `explore`, 10 min) | [06-build-test.md](06-build-test.md) |
|
||||
| 7 — Commit + push | parent | [07-commit-push.md](07-commit-push.md) |
|
||||
| 8 — Reply (always) + resolve (conditional) | sub-agent drafts → parent posts | [08-reply-resolve.md](08-reply-resolve.md) |
|
||||
| 9 — Convergence verify | sub-agent (`explore`, 3 min) | [09-convergence.md](09-convergence.md) |
|
||||
| 10 — Cleanup outdated (post-convergence, once) | parent | [10-cleanup.md](10-cleanup.md) |
|
||||
|
||||
## Single-iteration fallback
|
||||
|
||||
When `01-request-review.ps1` throws because Copilot Code Review isn't
|
||||
enabled on the repo / account (the GraphQL mutation reports the bot
|
||||
isn't a valid reviewer), the agent falls back to **single-iteration
|
||||
mode**:
|
||||
|
||||
- Skip step 2 (no Copilot review to wait for).
|
||||
- Run steps 3 – 8 once against whatever review threads already exist
|
||||
(human, advanced-security, other bots).
|
||||
- At step 9, pass `-SingleIteration` to `02-check-review-status.ps1` so
|
||||
the convergence check ignores the stale-review checks that can never
|
||||
advance without a new Copilot review. `Converged: true` collapses to
|
||||
`OpenThreadsAwaitingReply == 0`.
|
||||
- Re-iteration happens only when a human posts new comments later —
|
||||
re-run the skill at that point.
|
||||
|
||||
Single-iteration mode is **the agent's decision after the trigger
|
||||
fails**, not an auto-detected state — the script can't reliably tell
|
||||
"Copilot disabled" from "Copilot enabled but not yet triggered" from
|
||||
API state alone.
|
||||
|
||||
## Convergence proof
|
||||
|
||||
Print the proof of convergence in the `task_complete` message — proof,
|
||||
not assertion:
|
||||
|
||||
- `HeadOid`
|
||||
- `LatestCopilotReview.commitOid`
|
||||
- `submittedAt`
|
||||
- `OpenThreadsAwaitingReply: 0`
|
||||
- The list of any open `escalate-to-user` threads if
|
||||
`OpenThreadCount > 0`.
|
||||
|
||||
## Notes
|
||||
|
||||
- **Re-request is first-class.** `01-request-review.ps1` does not
|
||||
silently skip when Copilot has already reviewed; it issues the
|
||||
same mutation and verifies via a new `copilot_work_started` event
|
||||
(the script enforces this — see [api-quirks.md](api-quirks.md) for
|
||||
the GraphQL surface and the silent-drop trap).
|
||||
- **Outdated threads still need reply + resolve.** They show up in
|
||||
the PR UI as unresolved until you explicitly close them; step 10
|
||||
is a safety net, not the primary mechanism.
|
||||
- **Reopened / revisit requests reset the thread to step 4.** If a
|
||||
declined finding is reopened by the user (or by a follow-up
|
||||
Copilot review), pull it back into triage with the prior rationale
|
||||
as input rather than re-running the whole loop.
|
||||
- **Resumability after interruption.** On restart, snapshot HEAD,
|
||||
the latest Copilot review's `commit.oid` + `submittedAt`, the
|
||||
open-threads list, and any uncommitted local changes. Discard
|
||||
cached triage / drafts if HEAD or the open-threads set changed.
|
||||
- **Local-build patches.** For projects with uncommitted local-build
|
||||
patches held out of the PR: `git stash push -m "local-build" --
|
||||
<paths>` before committing, `git stash pop` after. Note `-m` must
|
||||
come BEFORE `--` (see [api-quirks.md](api-quirks.md)).
|
||||
@@ -0,0 +1,316 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Request a Copilot review on a PR and verify the trigger landed.
|
||||
|
||||
.DESCRIPTION
|
||||
Single mechanism: GraphQL `requestReviewsByLogin` with
|
||||
`botLogins:["copilot-pull-request-reviewer"]`. See
|
||||
references/api-quirks.md for the GraphQL surface details.
|
||||
|
||||
Success contract (exit 0, single-line JSON):
|
||||
- Status="InFlight" — Copilot already a requested reviewer.
|
||||
- Status="TriggerLanded" — mutation submitted and verified via a
|
||||
new `copilot_work_started` event id.
|
||||
|
||||
Failure (throw, exit 1): mutation failed, or no new event landed
|
||||
within -VerifySeconds. Caller should push a substantive commit and
|
||||
retry (auto-assign on `synchronize` is the most reliable fallback).
|
||||
|
||||
.PARAMETER PrNumber PR number (required).
|
||||
.PARAMETER Owner
|
||||
Optional; auto-resolved from `gh repo view`.
|
||||
|
||||
.PARAMETER Repo
|
||||
Optional; auto-resolved from `gh repo view`.
|
||||
.PARAMETER VerifySeconds Verification poll window (1..600, default 45).
|
||||
|
||||
.EXAMPLE
|
||||
pwsh 01-request-review.ps1 -PrNumber 236
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[int]$PrNumber,
|
||||
|
||||
[string]$Owner,
|
||||
[string]$Repo,
|
||||
|
||||
[ValidateRange(1, 600)]
|
||||
[int]$VerifySeconds = 45
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
. "$PSScriptRoot/_lib.ps1"
|
||||
|
||||
function Get-LatestCopilotWorkStartedEvent {
|
||||
$eventsPath = "repos/$Owner/$Repo/issues/$PrNumber/events?per_page=100"
|
||||
$r = Invoke-Gh -GhArgs @('api','-i',$eventsPath)
|
||||
if ($r.ExitCode -ne 0) { throw "events query failed: $($r.Stderr)" }
|
||||
|
||||
$m = [regex]::Match($r.Stdout, '(?s)\A(?<headers>.*?)\r?\n\r?\n(?<body>.*)\z')
|
||||
if (-not $m.Success) { throw 'events query returned an unexpected header/body shape.' }
|
||||
$headers = $m.Groups['headers'].Value
|
||||
$body = $m.Groups['body'].Value
|
||||
|
||||
$lastPage = 1
|
||||
# Link header looks like: `<https://api.github.com/...?per_page=100&page=4>; rel="last"`
|
||||
# Param order is not guaranteed — `page=4` may appear before or after
|
||||
# other query params. Match `page=<n>` inside the URL (allowing `?`
|
||||
# or `&` separator) up to the closing angle bracket, then the
|
||||
# `rel="last"` marker.
|
||||
$lastMatch = [regex]::Match($headers, '<[^>]*[?&]page=(\d+)[^>]*>;\s*rel="last"')
|
||||
if ($lastMatch.Success) { $lastPage = [int]$lastMatch.Groups[1].Value }
|
||||
if ($lastPage -gt 1) {
|
||||
$r = Invoke-Gh -GhArgs @('api',"repos/$Owner/$Repo/issues/$PrNumber/events?per_page=100&page=$lastPage")
|
||||
if ($r.ExitCode -ne 0) { throw "events last-page query failed: $($r.Stderr)" }
|
||||
$body = $r.Stdout
|
||||
}
|
||||
|
||||
$events = @(ConvertFrom-GhJson -Stdout $body -Context "events page $lastPage")
|
||||
$latest = $events | Where-Object { $_.event -eq 'copilot_work_started' } | Sort-Object id | Select-Object -Last 1
|
||||
if (-not $latest) { return [pscustomobject]@{ Id = 0L; CreatedAt = '' } }
|
||||
$createdAt = Format-IsoUtcString $latest.created_at
|
||||
[pscustomobject]@{ Id = [long]$latest.id; CreatedAt = $createdAt }
|
||||
}
|
||||
|
||||
# ---------- repo resolve ----------
|
||||
|
||||
$coords = Resolve-RepoCoords -Owner $Owner -Repo $Repo
|
||||
$Owner = $coords.Owner
|
||||
$Repo = $coords.Repo
|
||||
|
||||
# ---------- state: is Copilot currently requested? ----------
|
||||
# Single GraphQL query: requested reviewers + head SHA, followed by
|
||||
# pagination for the full requested-reviewer set.
|
||||
|
||||
$stateQuery = @'
|
||||
query($o:String!,$r:String!,$n:Int!){
|
||||
viewer{login}
|
||||
repository(owner:$o,name:$r){
|
||||
pullRequest(number:$n){
|
||||
id
|
||||
headRefOid
|
||||
state
|
||||
author{login}
|
||||
reviews(last:50){nodes{author{login}}}
|
||||
reviewRequests(first:100){nodes{requestedReviewer{__typename ... on Bot{login} ... on User{login} ... on Mannequin{login}}} pageInfo{hasNextPage endCursor}}
|
||||
}
|
||||
}
|
||||
}
|
||||
'@
|
||||
$stateData = Invoke-GhGraphQL -GhArgs @('-f',"query=$stateQuery",'-f',"o=$Owner",'-f',"r=$Repo",'-F',"n=$PrNumber") -Context "state query for $Owner/$Repo PR #$PrNumber"
|
||||
$pr = $stateData.data.repository.pullRequest
|
||||
if (-not $pr) { throw "PR #$PrNumber not found in $Owner/$Repo." }
|
||||
if ($pr.state -ne 'OPEN') {
|
||||
throw "PR #$PrNumber is not OPEN (state=$($pr.state))."
|
||||
}
|
||||
|
||||
$viewerLogin = [string]$stateData.data.viewer.login
|
||||
$prAuthorLogin = if ($pr.author) { [string]$pr.author.login } else { '' }
|
||||
$viewerIsAuthor = ($viewerLogin -and $prAuthorLogin -and ($viewerLogin -eq $prAuthorLogin))
|
||||
$copilotHasReviewed = $false
|
||||
if ($pr.reviews -and $pr.reviews.nodes) {
|
||||
foreach ($rev in $pr.reviews.nodes) {
|
||||
if ($rev.author -and $rev.author.login -and ($rev.author.login -match $CopilotReviewerLoginRegex)) {
|
||||
$copilotHasReviewed = $true; break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$headOid = $pr.headRefOid
|
||||
$prNodeId = [string]$pr.id
|
||||
if ([string]::IsNullOrWhiteSpace($prNodeId)) {
|
||||
throw "Failed to resolve PR node id for $Owner/$Repo PR #$PrNumber from state query."
|
||||
}
|
||||
$reviewRequestsList = [System.Collections.Generic.List[object]]::new()
|
||||
foreach ($n in @($pr.reviewRequests.nodes)) { $reviewRequestsList.Add($n) }
|
||||
$hasNext = [bool]$pr.reviewRequests.pageInfo.hasNextPage
|
||||
$after = $pr.reviewRequests.pageInfo.endCursor
|
||||
while ($hasNext) {
|
||||
$pageQuery = @'
|
||||
query($o:String!,$r:String!,$n:Int!,$after:String!){
|
||||
repository(owner:$o,name:$r){
|
||||
pullRequest(number:$n){
|
||||
reviewRequests(first:100,after:$after){nodes{requestedReviewer{__typename ... on Bot{login} ... on User{login} ... on Mannequin{login}}} pageInfo{hasNextPage endCursor}}
|
||||
}
|
||||
}
|
||||
}
|
||||
'@
|
||||
$pageData = Invoke-GhGraphQL -GhArgs @('-f',"query=$pageQuery",'-f',"o=$Owner",'-f',"r=$Repo",'-F',"n=$PrNumber",'-f',"after=$after") -Context "reviewRequests page query for $Owner/$Repo PR #$PrNumber"
|
||||
$page = $pageData.data.repository.pullRequest.reviewRequests
|
||||
foreach ($n in $page.nodes) { $reviewRequestsList.Add($n) }
|
||||
$hasNext = [bool]$page.pageInfo.hasNextPage
|
||||
$after = $page.pageInfo.endCursor
|
||||
}
|
||||
$reviewRequests = $reviewRequestsList.ToArray()
|
||||
$copilotPendingRequests = @($reviewRequests | Where-Object {
|
||||
$_.requestedReviewer -and $_.requestedReviewer.login -and $_.requestedReviewer.login -match $CopilotReviewerLoginRegex
|
||||
})
|
||||
$copilotPending = $copilotPendingRequests.Count -gt 0
|
||||
|
||||
# If Copilot is currently in requested_reviewers, it's in-flight by definition.
|
||||
if ($copilotPending) {
|
||||
@{
|
||||
Status = 'InFlight'
|
||||
PrNumber = $PrNumber
|
||||
HeadOid = $headOid
|
||||
Detail = "Copilot is currently in requested_reviewers; review is in flight."
|
||||
} | ConvertTo-Json -Compress
|
||||
exit 0
|
||||
}
|
||||
|
||||
# We do NOT short-circuit on AlreadyReviewed — the user wants re-request
|
||||
# as a first-class flow. Re-trigger; the GraphQL mutation handles both
|
||||
# initial-add and re-request identically.
|
||||
|
||||
# ---------- snapshot copilot_work_started before triggering ----------
|
||||
|
||||
# Snapshot the latest copilot_work_started BEFORE triggering. Use the
|
||||
# event's numeric `id` (monotonic) — `created_at` is second-resolution
|
||||
# and would collide if a new event lands in the same second.
|
||||
$beforeEvent = Get-LatestCopilotWorkStartedEvent
|
||||
$beforeId = $beforeEvent.Id
|
||||
|
||||
# ---------- trigger via GraphQL requestReviewsByLogin ----------
|
||||
|
||||
$mut = 'mutation($p:ID!){requestReviewsByLogin(input:{pullRequestId:$p,botLogins:["copilot-pull-request-reviewer"]}){pullRequest{number}}}'
|
||||
# Why this path and not REST or `requestReviews`? Verified end-to-end:
|
||||
# - REST POST /pulls/{n}/requested_reviewers `reviewers:["Copilot"]`
|
||||
# (the bot's REST login per `GET user/175728472`) → 404. The REST
|
||||
# `reviewers` field accepts type=User only; bots are rejected even
|
||||
# when the login resolves to a Bot record.
|
||||
# - GraphQL `requestReviews` rejects bot node IDs ("Could not resolve
|
||||
# to User node with the global id of 'BOT_…'") at schema level.
|
||||
# - `requestReviewsByLogin.botLogins` is the ONLY public path for bot
|
||||
# reviewers; trade-off is that it requires repo Triage/Write.
|
||||
# - The UI 🔄 button uses a github.com Rails endpoint with a session
|
||||
# cookie + CSRF that gh's OAuth token cannot satisfy.
|
||||
# Catch the auth-gated case below and surface the two real workarounds.
|
||||
$r = Invoke-Gh -GhArgs @('api','graphql','-f',"query=$mut",'-f',"p=$prNodeId")
|
||||
|
||||
# Belt-and-suspenders permission-error detection. Empirically `gh api graphql`
|
||||
# exits non-zero AND puts the message in stderr for FORBIDDEN on
|
||||
# requestReviewsByLogin (verified: exit=1, stderr contains "does not have the
|
||||
# correct permissions"). But some GraphQL paths return exit=0 with a top-level
|
||||
# `errors[]` carrying type=FORBIDDEN, so check both surfaces and route both to
|
||||
# the same actionable-error formatter.
|
||||
$permErrInStderr = ($r.ExitCode -ne 0) -and ($r.Stderr -match '(?i)does not have (the )?correct permissions|forbidden|HTTP 403')
|
||||
$permErrInBody = $false
|
||||
$bodyErrors = $null
|
||||
if ($r.Stdout) {
|
||||
try {
|
||||
# Route through the shared ConvertFrom-GhJson helper so the
|
||||
# preview format / context conventions stay consistent. The
|
||||
# helper throws on parse failure; we catch and Write-Warning
|
||||
# (fall through to the authoritative stderr/exit-code path)
|
||||
# rather than abort — the warning makes the fall-through
|
||||
# observable in logs.
|
||||
$parsed = ConvertFrom-GhJson -Stdout $r.Stdout -Stderr $r.Stderr -Context 'requestReviewsByLogin' -PreviewChars 200
|
||||
if ($parsed.errors) {
|
||||
$bodyErrors = $parsed.errors
|
||||
$permErrInBody = [bool]($parsed.errors | Where-Object {
|
||||
($_.type -eq 'FORBIDDEN') -or ($_.message -match '(?i)does not have (the )?correct permissions|forbidden')
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
Write-Warning $_.Exception.Message
|
||||
}
|
||||
}
|
||||
|
||||
if ($permErrInStderr -or $permErrInBody) {
|
||||
$rawMsg = if ($permErrInStderr) { $r.Stderr } elseif ($bodyErrors) { ($bodyErrors | ForEach-Object { $_.message }) -join '; ' } else { '(no message)' }
|
||||
if ($viewerIsAuthor) {
|
||||
# External PR author scenario: GitHub's UI 🔄 button uses an internal
|
||||
# endpoint not exposed in the public GraphQL/REST schema. Verified via
|
||||
# schema enumeration: the only public bot-reviewer mutation is
|
||||
# requestReviewsByLogin, which requires Triage/Write on the repo.
|
||||
# PR authors without write permission cannot trigger via any public API.
|
||||
$scenario = if ($copilotHasReviewed) { 're-request' } else { 'initial add' }
|
||||
throw @"
|
||||
Cannot trigger Copilot via public API in this scenario ($scenario):
|
||||
- You are the PR author ($viewerLogin) on $Owner/$Repo PR #$PrNumber.
|
||||
- You lack repo Triage/Write permission, so requestReviewsByLogin returns FORBIDDEN.
|
||||
- GitHub's public GraphQL schema has no other bot-reviewer mutation
|
||||
(verified: requestReviews rejects bot node IDs; no REST ``bot_reviewers`` field).
|
||||
- The UI's '🔄 Re-request review' button uses an internal endpoint not in the public API.
|
||||
|
||||
Use one of these workarounds (both reliably drive Copilot to re-review):
|
||||
1. UI: open the PR in a browser → click 🔄 next to 'copilot-pull-request-reviewer'.
|
||||
2. CLI: push a substantive (non-whitespace) commit. The ``synchronize`` event
|
||||
auto-triggers Copilot with no API call and no permission required.
|
||||
|
||||
After triggering by either means, resume the loop with 02-check-review-status.ps1.
|
||||
|
||||
Raw error: $rawMsg
|
||||
"@
|
||||
}
|
||||
throw @"
|
||||
GraphQL requestReviewsByLogin failed with a permission error: $rawMsg
|
||||
|
||||
Most likely causes:
|
||||
* Authenticated user lacks Triage / Write permission on the repo
|
||||
(run ``gh api repos/$Owner/$Repo --jq .permissions`` to confirm; Read-only
|
||||
collaborators cannot request reviewers).
|
||||
* Copilot Code Review not enabled on the repo / account.
|
||||
"@
|
||||
}
|
||||
|
||||
if ($r.ExitCode -ne 0) {
|
||||
throw @"
|
||||
GraphQL requestReviewsByLogin failed: $($r.Stderr)
|
||||
|
||||
Most likely causes:
|
||||
* Quiet-period after a recent dismissal of Copilot — wait 5-10 min, or push a substantive commit.
|
||||
* Copilot Code Review not enabled on the repo / account.
|
||||
* PR in a state that blocks bot review (draft, conflict, branch protection).
|
||||
"@
|
||||
}
|
||||
|
||||
if ($bodyErrors) {
|
||||
# Non-FORBIDDEN errors[] from a successful exit — surface them directly.
|
||||
$msgs = ($bodyErrors | ForEach-Object { $_.message }) -join '; '
|
||||
throw "GraphQL requestReviewsByLogin returned errors: $msgs"
|
||||
}
|
||||
|
||||
# ---------- verify copilot_work_started event landed ----------
|
||||
|
||||
$deadline = (Get-Date).AddSeconds($VerifySeconds)
|
||||
$afterTs = ''
|
||||
$afterId = 0L
|
||||
$lastErr = ''
|
||||
do {
|
||||
try {
|
||||
$nowEvent = Get-LatestCopilotWorkStartedEvent
|
||||
$lastErr = ''
|
||||
if ($nowEvent.Id -gt $beforeId) {
|
||||
$afterId = $nowEvent.Id
|
||||
$afterTs = $nowEvent.CreatedAt
|
||||
break
|
||||
}
|
||||
} catch {
|
||||
$lastErr = $_.Exception.Message
|
||||
}
|
||||
if ((Get-Date) -ge $deadline) { break }
|
||||
$remaining = [int]($deadline - (Get-Date)).TotalSeconds
|
||||
Start-Sleep -Seconds ([Math]::Min(5, [Math]::Max(1, $remaining)))
|
||||
} while ((Get-Date) -lt $deadline)
|
||||
|
||||
if (-not $afterId) {
|
||||
$errTail = if ($lastErr) { "`n Last events-query error: $lastErr" } else { '' }
|
||||
throw @"
|
||||
GraphQL mutation returned success but no new copilot_work_started event landed within $VerifySeconds seconds. The server may have silently dropped the request, or the events query kept failing transiently.
|
||||
Latest copilot_work_started event id before trigger: $beforeId
|
||||
HEAD: $headOid$errTail
|
||||
|
||||
Push a substantive commit (auto-assign on synchronize is the most reliable trigger) and retry.
|
||||
"@
|
||||
}
|
||||
|
||||
@{
|
||||
Status = 'TriggerLanded'
|
||||
PrNumber = $PrNumber
|
||||
HeadOid = $headOid
|
||||
WorkStartedAt = $afterTs
|
||||
Detail = "Triggered via GraphQL requestReviewsByLogin; copilot_work_started at $afterTs."
|
||||
} | ConvertTo-Json -Compress
|
||||
exit 0
|
||||
@@ -0,0 +1,362 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Snapshot the current Copilot review state of a PR. Single-shot, no waiting.
|
||||
|
||||
.DESCRIPTION
|
||||
ONE job: return a JSON snapshot of the PR's current Copilot
|
||||
review state. The agent (caller) decides what to do with it —
|
||||
including how long to wait between snapshots when polling for a
|
||||
new review to land. THIS SCRIPT DOES NOT WAIT.
|
||||
|
||||
Output JSON fields:
|
||||
- PrNumber, Owner, Repo
|
||||
- HeadOid : current PR HEAD SHA
|
||||
- State : PR state (OPEN/CLOSED/MERGED)
|
||||
- LatestCopilotReview: {state, submittedAt, commitOid, bodyHead}
|
||||
or null if no Copilot review is present
|
||||
in the most recent 100 reviews (very long
|
||||
PRs may have an older Copilot review outside
|
||||
this window — treat null as "no recent
|
||||
review", not "never reviewed")
|
||||
- ReviewAtHead : true iff latest Copilot review's commit.oid == HeadOid
|
||||
- NoNewComments : true iff the latest review body matches
|
||||
"generated no new comments" / "generated 0 comments"
|
||||
- OpenThreadCount : number of unresolved review threads (from all
|
||||
reviewers); informational — convergence does
|
||||
NOT require this to be zero
|
||||
- OpenThreadsAwaitingReply: number of open threads where the
|
||||
LAST comment is NOT from the authenticated
|
||||
user (`gh api user`). "Ball-in-court"
|
||||
model: a Copilot/human comment with no
|
||||
reply from us OR a re-raise after our
|
||||
earlier reply both count as awaiting. A
|
||||
thread where WE are the latest commenter
|
||||
counts as "done from our side" (the human
|
||||
merge owner decides next).
|
||||
- CopilotPending : true iff the Copilot reviewer bot is currently
|
||||
listed in `requested_reviewers` on the PR (a
|
||||
review is in flight; the caller should wait
|
||||
rather than re-trigger)
|
||||
- Converged : true iff the agent has done its job.
|
||||
- When a Copilot review is at HEAD:
|
||||
ReviewAtHead && NoNewComments &&
|
||||
OpenThreadsAwaitingReply == 0.
|
||||
- When no Copilot review has been observed
|
||||
on this PR (LatestCopilotReview is null
|
||||
AND CopilotPending is false): just
|
||||
OpenThreadsAwaitingReply == 0. Note this
|
||||
ALSO fires for a brand-new PR with zero
|
||||
threads — meaning "nothing to do yet";
|
||||
the agent should still trigger a Copilot
|
||||
review via 01-request-review.ps1 if
|
||||
Copilot is enabled on the repo. Single-
|
||||
iteration mode (skip the trigger) is the
|
||||
agent's decision after 01 fails with a
|
||||
specific Copilot-disabled error, NOT an
|
||||
auto-detected state from this script.
|
||||
Open threads may remain in either case —
|
||||
those are explicit hand-offs to the human
|
||||
merge owner.
|
||||
|
||||
Canonical agent loop (see ../references/orchestration.md and the per-step files):
|
||||
1. Call this script → capture LatestCopilotReview.submittedAt as
|
||||
baseline AND read CopilotPending.
|
||||
2. If CopilotPending is true, skip the trigger step — Copilot is
|
||||
already reviewing. Otherwise, call 01-request-review.ps1.
|
||||
If 01 throws with a Copilot-disabled error (e.g. the bot
|
||||
isn't a valid reviewer on this repo), the agent may fall
|
||||
back to single-iteration mode: skip the wait, jump to
|
||||
03-list-open-threads.ps1, triage + reply to whatever exists,
|
||||
done.
|
||||
3. Wait sub-agent polls this script until either submittedAt
|
||||
advances past baseline AND ReviewAtHead is true, OR Converged.
|
||||
4. On convergence end the loop; otherwise fetch threads via
|
||||
03-list-open-threads.ps1, triage, fix, push, reply, repeat.
|
||||
|
||||
Parsing the JSON: timestamps are emitted as plain ISO-8601 UTC
|
||||
strings (e.g. `"2026-06-08T02:02:44Z"`). Extract via regex on the
|
||||
raw JSON to avoid PowerShell's auto re-binding of ISO strings to
|
||||
`[datetime]` (which renders local culture on string interpolation
|
||||
and silently breaks lexicographic baseline compares):
|
||||
|
||||
$snap = pwsh -NoProfile -File 02-check-review-status.ps1 -PrNumber <n>
|
||||
$baseline = if ($snap -match '"submittedAt":"([^"]+)"') { $Matches[1] } else { '' }
|
||||
$copilotPending = ($snap -match '"CopilotPending":true')
|
||||
$converged = ($snap -match '"Converged":true')
|
||||
|
||||
Works on any PowerShell version (5.1 + 7.x). No `[datetime]`
|
||||
rebinding, no version-specific parameters.
|
||||
|
||||
.PARAMETER PrNumber
|
||||
The pull request number. The only required parameter.
|
||||
|
||||
.PARAMETER Owner
|
||||
Repository owner. OPTIONAL — auto-resolved from `gh repo view`.
|
||||
|
||||
.PARAMETER Repo
|
||||
Repository name. OPTIONAL — auto-resolved from `gh repo view`.
|
||||
|
||||
.EXAMPLE
|
||||
pwsh 02-check-review-status.ps1 -PrNumber 236
|
||||
|
||||
# Output (converged):
|
||||
# {"HeadOid":"abc...","State":"OPEN","LatestCopilotReview":{...},"ReviewAtHead":true,"NoNewComments":true,"OpenThreadCount":0}
|
||||
|
||||
# Output (not converged — new findings):
|
||||
# {"HeadOid":"abc...","ReviewAtHead":true,"NoNewComments":false,"OpenThreadCount":3,...}
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[int]$PrNumber,
|
||||
|
||||
[string]$Owner,
|
||||
[string]$Repo,
|
||||
|
||||
# When set, the agent has decided to drive this PR as a single
|
||||
# iteration (typically because 01-request-review.ps1 failed with a
|
||||
# Copilot-disabled error). In this mode, convergence ignores the
|
||||
# stale-review checks (ReviewAtHead / NoNewComments) — those can
|
||||
# never become true when the trigger is intentionally skipped —
|
||||
# and depends solely on OpenThreadsAwaitingReply == 0.
|
||||
[switch]$SingleIteration
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
. "$PSScriptRoot/_lib.ps1"
|
||||
|
||||
$coords = Resolve-RepoCoords -Owner $Owner -Repo $Repo
|
||||
$Owner = $coords.Owner
|
||||
$Repo = $coords.Repo
|
||||
|
||||
# Identity of the currently-authenticated gh user. Used below to
|
||||
# detect "the agent has already replied to this thread" and therefore
|
||||
# count it as our work-completed (the thread may still be open
|
||||
# deliberately as a human hand-off).
|
||||
$meR = Invoke-Gh -GhArgs @('api','user','--jq','.login')
|
||||
if ($meR.ExitCode -ne 0) {
|
||||
throw "gh api user failed (exit $($meR.ExitCode)): $($meR.Stderr)"
|
||||
}
|
||||
$me = $meR.Stdout.Trim()
|
||||
|
||||
# Query A (once): PR head/state/reviews. Reviews are not paginated
|
||||
# here — `reviews(last:100)` is the most recent 100 reviews, sufficient
|
||||
# for finding the latest Copilot review.
|
||||
$qHead = @'
|
||||
query($o:String!,$r:String!,$n:Int!){
|
||||
repository(owner:$o,name:$r){
|
||||
pullRequest(number:$n){
|
||||
headRefOid
|
||||
state
|
||||
reviews(last:100){nodes{author{login} state submittedAt body commit{oid}}}
|
||||
}
|
||||
}
|
||||
}
|
||||
'@
|
||||
|
||||
$d = Invoke-GhGraphQL -GhArgs @('-f',"query=$qHead",'-f',"o=$Owner",'-f',"r=$Repo",'-F',"n=$PrNumber") -Context "head query for $Owner/$Repo PR #$PrNumber"
|
||||
$pr = $d.data.repository.pullRequest
|
||||
if (-not $pr) { throw "PR #$PrNumber not found in $Owner/$Repo." }
|
||||
|
||||
# Query B (paginated): reviewThreads — fetch isResolved AND the last
|
||||
# comment's author per thread so we can compute
|
||||
# "is this open thread awaiting our reply, or have we already handed
|
||||
# it off?" The loop converges when WE have nothing more to do, not
|
||||
# when the open-thread count drops to zero (some threads stay open
|
||||
# deliberately as human hand-offs / escalated declines).
|
||||
$qThreads = @'
|
||||
query($o:String!,$r:String!,$n:Int!,$after:String){
|
||||
repository(owner:$o,name:$r){
|
||||
pullRequest(number:$n){
|
||||
reviewThreads(first:100, after:$after){
|
||||
pageInfo{endCursor hasNextPage}
|
||||
nodes{
|
||||
isResolved
|
||||
comments(last:1){nodes{author{login}}}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
'@
|
||||
|
||||
$after = $null
|
||||
$allThreadsList = [System.Collections.Generic.List[object]]::new()
|
||||
do {
|
||||
$ghArgs = @('-f', "query=$qThreads", '-f', "o=$Owner", '-f', "r=$Repo", '-F', "n=$PrNumber")
|
||||
if ($after) { $ghArgs = $ghArgs + @('-f', "after=$after") }
|
||||
$threadResp = Invoke-GhGraphQL -GhArgs $ghArgs -Context "threads query for $Owner/$Repo PR #$PrNumber"
|
||||
$pagePr = $threadResp.data.repository.pullRequest
|
||||
if (-not $pagePr) { throw "PR #$PrNumber not found in $Owner/$Repo (threads page)." }
|
||||
foreach ($n in $pagePr.reviewThreads.nodes) { $allThreadsList.Add($n) }
|
||||
$after = $pagePr.reviewThreads.pageInfo.endCursor
|
||||
} while ($pagePr.reviewThreads.pageInfo.hasNextPage)
|
||||
$allThreads = $allThreadsList.ToArray()
|
||||
|
||||
# Query C (paginated): reviewRequests — typical PRs have <100 requested
|
||||
# reviewers, but pagination is required for correctness so we never
|
||||
# falsely report CopilotPending=false on a PR with >100 requested
|
||||
# reviewers (which would cause the wait sub-agent to re-trigger a
|
||||
# review that's actually already in flight).
|
||||
$qReviewRequests = @'
|
||||
query($o:String!,$r:String!,$n:Int!,$after:String){
|
||||
repository(owner:$o,name:$r){
|
||||
pullRequest(number:$n){
|
||||
reviewRequests(first:100, after:$after){
|
||||
pageInfo{endCursor hasNextPage}
|
||||
nodes{requestedReviewer{__typename ... on Bot{login} ... on User{login} ... on Mannequin{login}}}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
'@
|
||||
|
||||
$after = $null
|
||||
$allReviewRequestsList = [System.Collections.Generic.List[object]]::new()
|
||||
do {
|
||||
$ghArgs = @('-f', "query=$qReviewRequests", '-f', "o=$Owner", '-f', "r=$Repo", '-F', "n=$PrNumber")
|
||||
if ($after) { $ghArgs = $ghArgs + @('-f', "after=$after") }
|
||||
$rrResp = Invoke-GhGraphQL -GhArgs $ghArgs -Context "reviewRequests query for $Owner/$Repo PR #$PrNumber"
|
||||
$rrPagePr = $rrResp.data.repository.pullRequest
|
||||
if (-not $rrPagePr) { throw "PR #$PrNumber not found in $Owner/$Repo (reviewRequests page)." }
|
||||
foreach ($n in $rrPagePr.reviewRequests.nodes) { $allReviewRequestsList.Add($n) }
|
||||
$after = $rrPagePr.reviewRequests.pageInfo.endCursor
|
||||
} while ($rrPagePr.reviewRequests.pageInfo.hasNextPage)
|
||||
$allReviewRequests = $allReviewRequestsList.ToArray()
|
||||
|
||||
# M1 tie-break: when multiple Copilot reviews share the same
|
||||
# submittedAt (server-side clock collision is rare but possible
|
||||
# under burst re-triggers), pick the one whose commit.oid matches
|
||||
# HEAD if any; otherwise the original sort order is deterministic
|
||||
# enough (PowerShell Sort-Object is stable since 5.1).
|
||||
# M3 pagination: reviews(last:100) returns the MOST RECENT 100
|
||||
# reviews. If a PR has 100+ reviews more recent than the last
|
||||
# Copilot review (essentially impossible in normal use, but
|
||||
# theoretically possible on heavily-bot-reviewed PRs), the latest
|
||||
# Copilot review would be cut off. Emit a soft warning to stderr
|
||||
# when we hit the boundary so the caller knows to inspect manually.
|
||||
if ($pr.reviews.nodes.Count -ge 100) {
|
||||
[Console]::Error.WriteLine("WARNING: reviews(last:100) boundary hit on PR #$PrNumber — if there are 100+ non-Copilot reviews more recent than the latest Copilot review, LatestCopilotReview may be stale. Inspect via 'gh pr view $PrNumber --comments' if convergence behaves unexpectedly.")
|
||||
}
|
||||
$copilotReviews = @($pr.reviews.nodes | Where-Object {
|
||||
$_.author -and $_.author.login -and $_.author.login -match $CopilotReviewerLoginRegex
|
||||
})
|
||||
$latest = if ($copilotReviews.Count -gt 0) {
|
||||
$atHead = $copilotReviews | Where-Object { $_.commit -and $_.commit.oid -eq $pr.headRefOid } | Sort-Object submittedAt -Descending | Select-Object -First 1
|
||||
if ($atHead) { $atHead } else { $copilotReviews | Sort-Object submittedAt -Descending | Select-Object -First 1 }
|
||||
} else { $null }
|
||||
|
||||
$reviewAtHead = $false
|
||||
$noNewComments = $false
|
||||
$bodyHead = $null
|
||||
$latestCommitOid = $null
|
||||
if ($latest) {
|
||||
if ($latest.commit -and $latest.commit.oid) {
|
||||
$latestCommitOid = $latest.commit.oid
|
||||
$reviewAtHead = ($latestCommitOid -eq $pr.headRefOid)
|
||||
}
|
||||
$bodyText = if ($latest.body) { $latest.body } else { '' }
|
||||
# NoNewComments covers both successful zero-finding reviews AND the
|
||||
# "Copilot wasn't able to review any files in this pull request"
|
||||
# body that Copilot returns for empty / pure-whitespace / line-ending-
|
||||
# only diffs. Both are terminal for the loop: there is nothing for
|
||||
# the agent to address and re-triggering will produce the same body.
|
||||
# Matches Copilot's "no findings" terminal phrases. Anchored on
|
||||
# \b (word boundary, blocks "regenerated") and on a following
|
||||
# sentence-end (./!/EOL/EOS) so the regex does NOT false-positive
|
||||
# on substrings like "generated no comments yet but..." or
|
||||
# "with 0 comments outstanding". Tested against the 4 known
|
||||
# negative inputs and 6 known positive Copilot body templates.
|
||||
$noNewComments = ($bodyText -match '(?im)\b(?:generated|had|with)\s+(?:no|0|zero)\s+(?:new\s+)?comments\s*(?:[\.\!]|$)|wasn''t\s+able\s+to\s+review\s+any\s+files\s+in\s+this\s+pull\s+request|was\s+not\s+able\s+to\s+review\s+any\s+files\s+in\s+this\s+pull\s+request')
|
||||
$bodyHead = if ($bodyText.Length -gt 300) { $bodyText.Substring(0, 300) } else { $bodyText }
|
||||
}
|
||||
|
||||
$openThreads = @($allThreads | Where-Object { -not $_.isResolved })
|
||||
$openCount = $openThreads.Count
|
||||
|
||||
# OpenThreadsAwaitingReply: open threads where the LAST comment is
|
||||
# NOT from the authenticated user. "Ball is in our court" model:
|
||||
# - Copilot/human posts a finding → last=them → awaiting our reply.
|
||||
# - We reply → last=us → ball passes back → not awaiting.
|
||||
# - Copilot re-raises after our reply → last=them again → awaiting.
|
||||
# Using "last comment" (not "any comment by us in window") is what
|
||||
# correctly handles re-raised threads. Threads we've replied to but
|
||||
# the reviewer hasn't yet acted on count as "done from our side" —
|
||||
# the human merge owner decides what to do with them next.
|
||||
$awaitingReply = @($openThreads | Where-Object {
|
||||
$thread = $_
|
||||
$lastAuthor = $null
|
||||
if ($thread.comments -and $thread.comments.nodes -and $thread.comments.nodes.Count -gt 0) {
|
||||
$lastComment = $thread.comments.nodes[$thread.comments.nodes.Count - 1]
|
||||
if ($lastComment -and $lastComment.author -and $lastComment.author.login) {
|
||||
$lastAuthor = $lastComment.author.login
|
||||
}
|
||||
}
|
||||
$lastAuthor -ne $me
|
||||
})
|
||||
$awaitingCount = $awaitingReply.Count
|
||||
|
||||
# CopilotPending: is the Copilot reviewer bot currently in
|
||||
# `requested_reviewers`? Canonical signal for "review is in flight";
|
||||
# the wait sub-agent (workflow step 2) consults this so the trigger
|
||||
# step (01-request-review.ps1) can be skipped when already pending.
|
||||
$copilotPending = @($allReviewRequests | Where-Object {
|
||||
$_.requestedReviewer -and $_.requestedReviewer.login -and $_.requestedReviewer.login -match $CopilotReviewerLoginRegex
|
||||
}).Count -gt 0
|
||||
|
||||
# Force submittedAt to a stable ISO-8601 UTC string. ConvertFrom-Json
|
||||
# auto-converted the gh response's ISO string into [datetime], and
|
||||
# ConvertTo-Json would otherwise emit it with .NET's "o" format
|
||||
# (`2026-06-07T18:06:59.0000000Z`) — but more importantly, downstream
|
||||
# callers that pipe our JSON through `ConvertFrom-Json` again would
|
||||
# get another [datetime] which renders local culture on string
|
||||
# interpolation, silently breaking lexicographic baseline comparisons.
|
||||
# Emit a plain string so the round-trip is identity.
|
||||
$submittedAtIso = if ($latest -and $latest.submittedAt) { Format-IsoUtcString $latest.submittedAt } else { $null }
|
||||
|
||||
$result = [ordered]@{
|
||||
PrNumber = $PrNumber
|
||||
Owner = $Owner
|
||||
Repo = $Repo
|
||||
HeadOid = $pr.headRefOid
|
||||
State = $pr.state
|
||||
LatestCopilotReview = if ($latest) {
|
||||
[ordered]@{
|
||||
state = $latest.state
|
||||
submittedAt = $submittedAtIso
|
||||
commitOid = $latestCommitOid
|
||||
bodyHead = $bodyHead
|
||||
}
|
||||
} else { $null }
|
||||
ReviewAtHead = $reviewAtHead
|
||||
NoNewComments = $noNewComments
|
||||
OpenThreadCount = $openCount
|
||||
OpenThreadsAwaitingReply = $awaitingCount
|
||||
CopilotPending = $copilotPending
|
||||
# Converged = "the agent has nothing more to do".
|
||||
# PR State guard: a CLOSED / MERGED PR can never be the target of
|
||||
# a productive review loop — the agent cannot push, the loop
|
||||
# cannot iterate. Force Converged = false so the parent surfaces
|
||||
# the PR-state change to the user instead of silently calling
|
||||
# task_complete on a non-OPEN PR.
|
||||
# - SingleIteration (agent decision; Copilot unavailable or
|
||||
# trigger intentionally skipped): just OpenThreadsAwaitingReply
|
||||
# == 0. Ignores ReviewAtHead / NoNewComments because those will
|
||||
# never advance without a new Copilot review.
|
||||
# - Copilot review exists or pending: ReviewAtHead &&
|
||||
# NoNewComments && OpenThreadsAwaitingReply == 0.
|
||||
# - No Copilot review has ever been observed: just
|
||||
# OpenThreadsAwaitingReply == 0 (also fires for brand-new PRs
|
||||
# with zero findings; agent should still trigger via
|
||||
# 01-request-review.ps1 if Copilot is enabled).
|
||||
Converged = if ($pr.state -ne 'OPEN') {
|
||||
$false
|
||||
} elseif ($SingleIteration) {
|
||||
$awaitingCount -eq 0
|
||||
} elseif ($latest -or $copilotPending) {
|
||||
$reviewAtHead -and $noNewComments -and $awaitingCount -eq 0
|
||||
} else {
|
||||
$awaitingCount -eq 0
|
||||
}
|
||||
}
|
||||
$result | ConvertTo-Json -Depth 5 -Compress
|
||||
@@ -0,0 +1,126 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
List unresolved review threads on a pull request (all reviewers).
|
||||
|
||||
.DESCRIPTION
|
||||
Fetches review threads via GraphQL (paginated) and prints every
|
||||
thread that is still `isResolved: false`. Threads from all reviewers
|
||||
(Copilot, humans, other bots) are included; the triage step decides
|
||||
what to do with each.
|
||||
|
||||
Each thread's `comments(first:1)` is the originating review comment
|
||||
— that's where `path`, `line`, and `body` come from. Reply chains
|
||||
on the same thread are intentionally not surfaced here; this script
|
||||
is the input to triage, not to reading conversation history.
|
||||
|
||||
.PARAMETER Owner
|
||||
Optional; auto-resolved from `gh repo view`.
|
||||
|
||||
.PARAMETER Repo
|
||||
Optional; auto-resolved from `gh repo view`.
|
||||
.PARAMETER PrNumber The pull request number.
|
||||
|
||||
.EXAMPLE
|
||||
pwsh 03-list-open-threads.ps1 -PrNumber 122
|
||||
|
||||
.PARAMETER MaxBodyLength
|
||||
Cap the `Body` column at this many characters (default 500; pass 0 to
|
||||
disable). Long Copilot comments otherwise dominate stdout and slow
|
||||
triage; truncated bodies are suffixed with `…`.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$Owner,
|
||||
[string]$Repo,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[int]$PrNumber,
|
||||
|
||||
[ValidateRange(0, 100000)]
|
||||
[int]$MaxBodyLength = 500
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
. "$PSScriptRoot/_lib.ps1"
|
||||
|
||||
$coords = Resolve-RepoCoords -Owner $Owner -Repo $Repo
|
||||
$Owner = $coords.Owner
|
||||
$Repo = $coords.Repo
|
||||
|
||||
$query = @'
|
||||
query($owner: String!, $repo: String!, $pr: Int!, $after: String) {
|
||||
repository(owner: $owner, name: $repo) {
|
||||
pullRequest(number: $pr) {
|
||||
reviewThreads(first: 100, after: $after) {
|
||||
pageInfo {
|
||||
endCursor
|
||||
hasNextPage
|
||||
}
|
||||
nodes {
|
||||
id
|
||||
isResolved
|
||||
comments(first: 1) {
|
||||
nodes {
|
||||
author { login }
|
||||
body
|
||||
path
|
||||
line
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
'@
|
||||
|
||||
$all = [System.Collections.Generic.List[object]]::new()
|
||||
$after = $null
|
||||
do {
|
||||
$ghArgs = @('-f', "query=$query", '-f', "owner=$Owner", '-f', "repo=$Repo", '-F', "pr=$PrNumber")
|
||||
if ($after) { $ghArgs = $ghArgs + @('-f', "after=$after") }
|
||||
|
||||
$data = Invoke-GhGraphQL -GhArgs $ghArgs -Context "list threads for $Owner/$Repo PR #$PrNumber"
|
||||
$page = $data.data.repository.pullRequest.reviewThreads
|
||||
foreach ($n in $page.nodes) { $all.Add($n) }
|
||||
$after = $page.pageInfo.endCursor
|
||||
} while ($page.pageInfo.hasNextPage)
|
||||
|
||||
$threads = $all.ToArray()
|
||||
|
||||
$open = $threads | Where-Object { -not $_.isResolved }
|
||||
|
||||
$resultList = [System.Collections.Generic.List[object]]::new()
|
||||
foreach ($t in $open) {
|
||||
$c = if ($t.comments -and $t.comments.nodes -and $t.comments.nodes.Count -gt 0) { $t.comments.nodes[0] } else { $null }
|
||||
if (-not $c) { continue } # malformed thread (no originating comment) — skip rather than crash
|
||||
$body = ($c.body -replace "`r?`n", ' ')
|
||||
if ($MaxBodyLength -gt 0 -and $body.Length -gt $MaxBodyLength) {
|
||||
$body = $body.Substring(0, $MaxBodyLength) + '…'
|
||||
}
|
||||
$path = if ($null -ne $c.line) { "$($c.path):$($c.line)" } else { $c.path }
|
||||
$author = if ($c.author -and $c.author.login) { $c.author.login } else { '(deleted)' }
|
||||
# Normalise createdAt via the shared helper so the format stays
|
||||
# identical across 01/02/03 (Format-IsoUtcString in _lib.ps1).
|
||||
$createdAt = Format-IsoUtcString $c.createdAt
|
||||
$resultList.Add([pscustomobject]@{
|
||||
ThreadId = $t.id
|
||||
Author = $author
|
||||
Path = $path
|
||||
CreatedAt = $createdAt
|
||||
Body = $body
|
||||
})
|
||||
}
|
||||
$result = $resultList.ToArray()
|
||||
|
||||
# Single-line JSON output (matches the contract used by 01/02/08/10).
|
||||
# -Compress so callers can pipe to ConvertFrom-Json or jq directly without
|
||||
# stripping PowerShell's default Format-List rendering / ANSI escapes.
|
||||
[pscustomobject]@{
|
||||
PrNumber = $PrNumber
|
||||
Owner = $Owner
|
||||
Repo = $Repo
|
||||
OpenThreadCount = $result.Count
|
||||
Threads = $result
|
||||
} | ConvertTo-Json -Depth 6 -Compress
|
||||
@@ -0,0 +1,97 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Post a reply on a Copilot review thread and resolve it.
|
||||
|
||||
.DESCRIPTION
|
||||
Performs the two GraphQL mutations needed to address a Copilot finding:
|
||||
1. addPullRequestReviewThreadReply — appends a reply comment.
|
||||
2. resolveReviewThread — marks the thread resolved.
|
||||
|
||||
Use this for both accepted-and-fixed findings and for declined-with-
|
||||
rationale findings. See ../templates/reply-fix.md, reply-decline.md,
|
||||
reply-drift.md, and reply-partial.md for body patterns.
|
||||
|
||||
.PARAMETER ThreadId
|
||||
The GraphQL node ID of the review thread (e.g. PRRT_kw...).
|
||||
|
||||
.PARAMETER Body
|
||||
The reply body. Markdown is supported.
|
||||
|
||||
.PARAMETER NoResolve
|
||||
If set, posts the reply only and leaves the thread open. Useful when
|
||||
you want to start a back-and-forth discussion rather than close out the
|
||||
thread.
|
||||
|
||||
.EXAMPLE
|
||||
pwsh 08-reply-and-resolve.ps1 -ThreadId PRRT_kw... -Body "Fixed in abc1234."
|
||||
|
||||
.EXAMPLE
|
||||
# Decline with rationale, do not resolve yet
|
||||
pwsh 08-reply-and-resolve.ps1 -ThreadId PRRT_kw... -NoResolve `
|
||||
-Body "Declining: this would require cross-class plumbing for a hypothetical race."
|
||||
|
||||
.NOTES
|
||||
Reply + resolve are TWO separate GraphQL mutations and the script
|
||||
is NOT atomic: if the reply succeeds and the resolve then fails
|
||||
(transient 5xx, rate limit, thread disappeared mid-call), the reply
|
||||
is already posted. The script is also NOT idempotent on reply —
|
||||
re-running it with the same -ThreadId and -Body will post a
|
||||
duplicate reply because -Body is mandatory.
|
||||
|
||||
Recommended retry policy when a call fails:
|
||||
- If "Replied to thread X" did NOT print, the reply step failed;
|
||||
safe to re-run the whole command.
|
||||
- If "Replied to thread X" DID print but "Resolved thread X" did
|
||||
not, ONLY the resolve step failed. Do NOT re-run this script
|
||||
(it would post a duplicate reply). Instead resolve directly:
|
||||
|
||||
gh api graphql `
|
||||
-f query='mutation($t:ID!){resolveReviewThread(input:{threadId:$t}){thread{id}}}' `
|
||||
-f t=$ThreadId
|
||||
|
||||
This raw call bypasses the Invoke-Gh helper but is safe here
|
||||
because the mutation contains no embedded double-quote
|
||||
characters, so the PowerShell-5.1 native-arg splitting bug
|
||||
documented in references/api-quirks.md does not apply.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$ThreadId,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Body,
|
||||
|
||||
[switch]$NoResolve
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
. "$PSScriptRoot/_lib.ps1"
|
||||
|
||||
$replyMutation = @'
|
||||
mutation($tid: ID!, $body: String!) {
|
||||
addPullRequestReviewThreadReply(input: {
|
||||
pullRequestReviewThreadId: $tid,
|
||||
body: $body
|
||||
}) {
|
||||
comment { id }
|
||||
}
|
||||
}
|
||||
'@
|
||||
|
||||
$replyArgs = @('-f', "query=$replyMutation", '-f', "tid=$ThreadId", '-f', "body=$Body")
|
||||
Invoke-GhGraphQL -GhArgs $replyArgs -Context "reply to thread $ThreadId" | Out-Null
|
||||
Write-Output "Replied to thread $ThreadId"
|
||||
|
||||
if (-not $NoResolve) {
|
||||
$resolveMutation = @'
|
||||
mutation($tid: ID!) {
|
||||
resolveReviewThread(input: { threadId: $tid }) {
|
||||
thread { isResolved }
|
||||
}
|
||||
}
|
||||
'@
|
||||
$resolveArgs = @('-f', "query=$resolveMutation", '-f', "tid=$ThreadId")
|
||||
Invoke-GhGraphQL -GhArgs $resolveArgs -Context "resolve thread $ThreadId" | Out-Null
|
||||
Write-Output "Resolved thread $ThreadId"
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Deterministically count a PR's Copilot review rounds and flag whether
|
||||
the periodic recap gate is due this round. Single-shot, read-only.
|
||||
|
||||
.DESCRIPTION
|
||||
ONE job: compute the round number N for a PR from observable history —
|
||||
the count of review submissions authored by the Copilot Code Review
|
||||
bot — and report whether the recap circuit-breaker is due.
|
||||
|
||||
Each top-of-loop trigger (01-request-review.ps1) produces exactly one
|
||||
Copilot review submission, so the count of those submissions IS the
|
||||
round number. It is read from the GitHub API on every call, so it
|
||||
never depends on agent working memory or any on-disk state file. The
|
||||
original failure mode this skill was built to survive — an agent
|
||||
losing track of the count across a long run, drifting for 156 rounds —
|
||||
cannot happen when the count is derived, not remembered.
|
||||
|
||||
This script does NOT decide anything. It does not stop the loop, does
|
||||
not enforce a cap, and does not pick a verdict. It reports two facts;
|
||||
the parent agent still owns the recap reasoning and the
|
||||
CONTINUE / REVERT-AND-SHIP / HAND-OFF verdict (see
|
||||
../references/09-convergence.md). "No script decides the cap" stays
|
||||
true — this script only makes the *trigger* (Nth round) deterministic
|
||||
instead of a fallible mental tally.
|
||||
|
||||
Output JSON fields:
|
||||
- PrNumber, Owner, Repo
|
||||
- Round : count of Copilot Code Review submissions on the PR.
|
||||
Full pagination — every Copilot submission, NOT the
|
||||
most-recent-100 window 02-check-review-status.ps1
|
||||
uses to find the latest review. The count must stay
|
||||
correct on exactly the long loops the gate exists
|
||||
to catch (a 100-review window would silently
|
||||
undercount past round 100).
|
||||
- RecapInterval : the recap cadence (default 10; override with
|
||||
-RecapInterval).
|
||||
- RecapDue : true iff Round > 0 AND Round % RecapInterval == 0 —
|
||||
i.e. this is a 10th / 20th / 30th ... round and the
|
||||
parent should RUN THE RECAP GATE before looping back
|
||||
to step 1.
|
||||
|
||||
Parsing the JSON (any PowerShell version, 5.1 + 7.x):
|
||||
|
||||
$snap = pwsh -NoProfile -File 09-review-round.ps1 -PrNumber <n>
|
||||
$round = if ($snap -match '"Round":(\d+)') { [int]$Matches[1] } else { 0 }
|
||||
$recapDue = ($snap -match '"RecapDue":true')
|
||||
|
||||
.PARAMETER PrNumber
|
||||
The pull request number. The only required parameter.
|
||||
|
||||
.PARAMETER Owner
|
||||
Repository owner. OPTIONAL — auto-resolved from `gh repo view`.
|
||||
|
||||
.PARAMETER Repo
|
||||
Repository name. OPTIONAL — auto-resolved from `gh repo view`.
|
||||
|
||||
.PARAMETER RecapInterval
|
||||
Recap cadence in rounds. Default 10 (stop at 10, 20, 30, ...). Must be
|
||||
a positive integer. Exposed as a single named knob so the cadence
|
||||
isn't a magic number duplicated in prose, and so the gate boundary is
|
||||
testable on a real PR without fabricating review history.
|
||||
|
||||
.EXAMPLE
|
||||
pwsh ./scripts/09-review-round.ps1 -PrNumber 1944
|
||||
|
||||
# {"PrNumber":1944,"Owner":"github","Repo":"awesome-copilot","Round":154,"RecapInterval":10,"RecapDue":false}
|
||||
|
||||
.EXAMPLE
|
||||
pwsh ./scripts/09-review-round.ps1 -PrNumber 1944 -RecapInterval 7
|
||||
|
||||
# 154 % 7 == 0 -> {"...","Round":154,"RecapInterval":7,"RecapDue":true}
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[int]$PrNumber,
|
||||
|
||||
[string]$Owner,
|
||||
[string]$Repo,
|
||||
|
||||
[ValidateRange(1, [int]::MaxValue)]
|
||||
[int]$RecapInterval = 10
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
. "$PSScriptRoot/_lib.ps1"
|
||||
|
||||
$coords = Resolve-RepoCoords -Owner $Owner -Repo $Repo
|
||||
$Owner = $coords.Owner
|
||||
$Repo = $coords.Repo
|
||||
|
||||
# Walk ALL reviews (full pagination). 02-check-review-status.ps1 only needs
|
||||
# the most-recent-100 window to find the LATEST Copilot review; the round
|
||||
# COUNT must include EVERY Copilot submission, because the gate exists
|
||||
# precisely for long loops where the count can exceed 100. Counting is
|
||||
# order-independent, so forward pagination is sufficient.
|
||||
$qReviews = @'
|
||||
query($o:String!,$r:String!,$n:Int!,$after:String){
|
||||
repository(owner:$o,name:$r){
|
||||
pullRequest(number:$n){
|
||||
reviews(first:100, after:$after){
|
||||
pageInfo{endCursor hasNextPage}
|
||||
nodes{author{login}}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
'@
|
||||
|
||||
$after = $null
|
||||
$round = 0
|
||||
do {
|
||||
$ghArgs = @('-f', "query=$qReviews", '-f', "o=$Owner", '-f', "r=$Repo", '-F', "n=$PrNumber")
|
||||
if ($after) { $ghArgs = $ghArgs + @('-f', "after=$after") }
|
||||
$resp = Invoke-GhGraphQL -GhArgs $ghArgs -Context "reviews count for $Owner/$Repo PR #$PrNumber"
|
||||
$pagePr = $resp.data.repository.pullRequest
|
||||
if (-not $pagePr) { throw "PR #$PrNumber not found in $Owner/$Repo (reviews page)." }
|
||||
$round += @($pagePr.reviews.nodes | Where-Object {
|
||||
$_.author -and $_.author.login -and $_.author.login -match $CopilotReviewerLoginRegex
|
||||
}).Count
|
||||
$after = $pagePr.reviews.pageInfo.endCursor
|
||||
} while ($pagePr.reviews.pageInfo.hasNextPage)
|
||||
|
||||
# Gate TRIGGER only — not the verdict. RecapDue says "this is an Nth round,
|
||||
# run the recap"; the parent agent reads the recap and picks
|
||||
# CONTINUE / REVERT-AND-SHIP / HAND-OFF (09-convergence.md).
|
||||
$recapDue = ($round -gt 0) -and (($round % $RecapInterval) -eq 0)
|
||||
|
||||
$result = [ordered]@{
|
||||
PrNumber = $PrNumber
|
||||
Owner = $Owner
|
||||
Repo = $Repo
|
||||
Round = $round
|
||||
RecapInterval = $RecapInterval
|
||||
RecapDue = $recapDue
|
||||
}
|
||||
$result | ConvertTo-Json -Depth 3 -Compress
|
||||
@@ -0,0 +1,432 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Batch-resolve outdated Copilot review threads on a PR.
|
||||
|
||||
.DESCRIPTION
|
||||
After a review loop converges, the PR may still show old `isOutdated`
|
||||
Copilot threads listed as open. They were addressed by later commits
|
||||
but never explicitly resolved. This script finds them and resolves them
|
||||
in bulk.
|
||||
|
||||
Only acts on threads where:
|
||||
- isOutdated: true
|
||||
- isResolved: false
|
||||
- the first comment's author is copilot-pull-request-reviewer
|
||||
- the LAST comment's author is the authenticated user (i.e. we
|
||||
have already replied to the thread). This safety guard prevents
|
||||
the script from hiding actionable findings if invoked before
|
||||
the review loop has converged. Override with -Force.
|
||||
- NO comment in the thread is from a non-Copilot, non-authenticated-
|
||||
user author (the "human-in-thread" guard — if a human or another
|
||||
bot has chimed in anywhere in the thread, even past Copilot's
|
||||
opener, the thread is SKIPPED). This guard is NOT overridable
|
||||
by -Force; auto-resolving a thread carrying human signal would
|
||||
silently hide unaddressed concerns.
|
||||
|
||||
The doc claim "threads from human reviewers are never touched"
|
||||
therefore holds for both single-author human threads AND mixed-
|
||||
authorship threads where Copilot opened and a human replied.
|
||||
|
||||
.PARAMETER Owner
|
||||
Repository owner (org or user). Defaults to the current repo's owner
|
||||
(resolved via `gh repo view`).
|
||||
|
||||
.PARAMETER Repo
|
||||
Repository name. Defaults to the current repo's name.
|
||||
|
||||
.PARAMETER PrNumber
|
||||
The pull request number.
|
||||
|
||||
.EXAMPLE
|
||||
pwsh 10-cleanup-outdated.ps1 -PrNumber 122
|
||||
|
||||
.EXAMPLE
|
||||
pwsh 10-cleanup-outdated.ps1 -PrNumber 122 -DryRun
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$Owner,
|
||||
[string]$Repo,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[int]$PrNumber,
|
||||
|
||||
# Print what would be resolved without making any GraphQL
|
||||
# mutation. Uses an explicit switch (not the PowerShell
|
||||
# SupportsShouldProcess / -WhatIf machinery) so the helper's
|
||||
# internal `2>` redirect doesn't inherit a WhatIfPreference and
|
||||
# print cosmetic "Performing the operation Output to File" noise.
|
||||
[switch]$DryRun,
|
||||
|
||||
# Override the safety guard that requires the last commenter on a
|
||||
# thread to be the authenticated user. Without -Force, threads
|
||||
# where Copilot (or anyone other than us) had the last word are
|
||||
# SKIPPED — resolving them would hide an actionable finding the
|
||||
# agent hasn't replied to yet. Pass -Force only when you're
|
||||
# intentionally clearing stale outdated Copilot threads out of
|
||||
# band of the convergence loop.
|
||||
#
|
||||
# -Force does NOT override the human-in-thread guard: threads with
|
||||
# any non-Copilot, non-authenticated-user comment anywhere in the
|
||||
# thread are always skipped regardless of -Force.
|
||||
[switch]$Force
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
. "$PSScriptRoot/_lib.ps1"
|
||||
|
||||
$coords = Resolve-RepoCoords -Owner $Owner -Repo $Repo
|
||||
$Owner = $coords.Owner
|
||||
$Repo = $coords.Repo
|
||||
|
||||
# Authenticated-user login — needed (unless -Force) so we only resolve
|
||||
# threads where we have actually replied. Mirrors the pattern in
|
||||
# 02-check-review-status.ps1.
|
||||
$meR = Invoke-Gh -GhArgs @('api','user','--jq','.login')
|
||||
if ($meR.ExitCode -ne 0) {
|
||||
throw "gh api user failed (exit $($meR.ExitCode)): $($meR.Stderr)"
|
||||
}
|
||||
$me = $meR.Stdout.Trim()
|
||||
|
||||
$query = @'
|
||||
query($owner: String!, $repo: String!, $pr: Int!, $after: String) {
|
||||
repository(owner: $owner, name: $repo) {
|
||||
pullRequest(number: $pr) {
|
||||
reviewThreads(first: 100, after: $after) {
|
||||
pageInfo {
|
||||
endCursor
|
||||
hasNextPage
|
||||
}
|
||||
nodes {
|
||||
id
|
||||
isResolved
|
||||
isOutdated
|
||||
firstComment: comments(first: 1) {
|
||||
nodes { author { login } }
|
||||
}
|
||||
lastComment: comments(last: 1) {
|
||||
nodes { author { login } }
|
||||
}
|
||||
# First page of comment authors so we can reject threads where
|
||||
# any non-Copilot, non-$me participant has commented (a human
|
||||
# or different bot chimed in after Copilot's opener). The doc
|
||||
# promise "threads from human reviewers are never touched"
|
||||
# must hold even when the human comment is not the FIRST.
|
||||
# totalCount lets us detect threads that exceed the 100-node
|
||||
# window; for those we paginate the rest via the node(id:)
|
||||
# query below so authorship visibility is always complete.
|
||||
allComments: comments(first: 100) {
|
||||
totalCount
|
||||
pageInfo { endCursor hasNextPage }
|
||||
nodes { author { login } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
'@
|
||||
|
||||
$all = [System.Collections.Generic.List[object]]::new()
|
||||
$after = $null
|
||||
do {
|
||||
$ghArgs = @('-f', "query=$query", '-f', "owner=$Owner", '-f', "repo=$Repo", '-F', "pr=$PrNumber")
|
||||
if ($after) { $ghArgs = $ghArgs + @('-f', "after=$after") }
|
||||
|
||||
$data = Invoke-GhGraphQL -GhArgs $ghArgs -Context "list outdated threads for $Owner/$Repo PR #$PrNumber"
|
||||
$page = $data.data.repository.pullRequest.reviewThreads
|
||||
foreach ($n in $page.nodes) { $all.Add($n) }
|
||||
$after = $page.pageInfo.endCursor
|
||||
} while ($page.pageInfo.hasNextPage)
|
||||
|
||||
$threads = $all.ToArray()
|
||||
|
||||
# Comment-pagination helper: when a thread's first 100 comments don't
|
||||
# cover the full set (totalCount > nodes.Count), fetch the remaining
|
||||
# pages via node(id:) so authorship visibility is always complete.
|
||||
# Returns the full ordered list of author logins (may include $null
|
||||
# entries for ghost/deleted users — callers must tolerate $null).
|
||||
#
|
||||
# Guarantees:
|
||||
# * Uses a typed List[object] (preserves $null author entries for
|
||||
# ghost / deleted users) to avoid O(n^2) array growth via `+=`.
|
||||
# * Hard upper bound of `(MaxPages + 1) * 100` comments to prevent a
|
||||
# runaway loop if the server ever returns an invalid pageInfo
|
||||
# (cursor that never advances). The outer query is page 0 (first
|
||||
# 100 comments); MaxPages caps the paginated additional pages.
|
||||
# Default MaxPages=200 → up to 201 pages → 20,100 comments, three
|
||||
# orders of magnitude beyond any plausible PR thread.
|
||||
# * Throws with explicit context if the paginated `node(id:)` query
|
||||
# returns $null (e.g., thread deleted mid-pagination) so the failure
|
||||
# bubbles up rather than silently producing partial authorship.
|
||||
function Get-AllThreadAuthors {
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)] [string]$ThreadId,
|
||||
[Parameter(Mandatory)] $FirstPage, # the allComments object from the outer query
|
||||
[int]$MaxPages = 200
|
||||
)
|
||||
|
||||
# List[object] (not List[string]) so $null author entries — which
|
||||
# GitHub returns for deleted / ghost users — round-trip as $null
|
||||
# instead of being coerced to ''. Callers rely on `-not $login` to
|
||||
# skip both, but preserving the original shape keeps the contract
|
||||
# honest for any future caller.
|
||||
$authors = [System.Collections.Generic.List[object]]::new()
|
||||
|
||||
$firstNodes = @()
|
||||
if ($FirstPage -and $FirstPage.nodes) { $firstNodes = @($FirstPage.nodes) }
|
||||
foreach ($n in $firstNodes) {
|
||||
$login = $null
|
||||
if ($n -and $n.author) { $login = $n.author.login }
|
||||
$authors.Add($login)
|
||||
}
|
||||
|
||||
$hasNext = $false
|
||||
$after = $null
|
||||
if ($FirstPage -and $FirstPage.pageInfo) {
|
||||
$hasNext = [bool]$FirstPage.pageInfo.hasNextPage
|
||||
$after = $FirstPage.pageInfo.endCursor
|
||||
}
|
||||
|
||||
$pageQuery = @'
|
||||
query($id: ID!, $after: String) {
|
||||
node(id: $id) {
|
||||
... on PullRequestReviewThread {
|
||||
comments(first: 100, after: $after) {
|
||||
pageInfo { endCursor hasNextPage }
|
||||
nodes { author { login } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
'@
|
||||
|
||||
# First fetched page is labeled 1: $pageIndex initialised to 0 here,
|
||||
# incremented at loop entry. The outer (pre-loop) query is page 0;
|
||||
# this loop fetches additional pages, so MaxPages caps the in-loop
|
||||
# iterations, making the total bound (outer + paginated) = MaxPages + 1.
|
||||
$pageIndex = 0
|
||||
while ($hasNext) {
|
||||
$pageIndex++
|
||||
if ($pageIndex -gt $MaxPages) {
|
||||
throw "Get-AllThreadAuthors: exceeded MaxPages=$MaxPages for thread $ThreadId — likely a malformed server response (cursor not advancing)."
|
||||
}
|
||||
|
||||
$pageArgs = @('-f', "query=$pageQuery", '-f', "id=$ThreadId")
|
||||
if ($after) { $pageArgs = $pageArgs + @('-f', "after=$after") }
|
||||
$pageData = Invoke-GhGraphQL -GhArgs $pageArgs -Context "paginate comments for thread $ThreadId (page $pageIndex)"
|
||||
|
||||
$threadNode = $null
|
||||
if ($pageData -and $pageData.data) { $threadNode = $pageData.data.node }
|
||||
if (-not $threadNode) {
|
||||
throw "Get-AllThreadAuthors: node(id: '$ThreadId') returned null on page $pageIndex (thread deleted or inaccessible)."
|
||||
}
|
||||
$pageBody = $threadNode.comments
|
||||
if (-not $pageBody) {
|
||||
throw "Get-AllThreadAuthors: thread $ThreadId has no comments connection on page $pageIndex."
|
||||
}
|
||||
|
||||
$pageNodes = @()
|
||||
if ($pageBody.nodes) { $pageNodes = @($pageBody.nodes) }
|
||||
foreach ($n in $pageNodes) {
|
||||
$login = $null
|
||||
if ($n -and $n.author) { $login = $n.author.login }
|
||||
$authors.Add($login)
|
||||
}
|
||||
|
||||
$prevCursor = $after
|
||||
$hasNext = $false
|
||||
$after = $null
|
||||
if ($pageBody.pageInfo) {
|
||||
$hasNext = [bool]$pageBody.pageInfo.hasNextPage
|
||||
$after = $pageBody.pageInfo.endCursor
|
||||
}
|
||||
# Belt-and-suspenders: if the server claims more pages but the
|
||||
# cursor didn't advance, MaxPages would still catch it — but
|
||||
# bail explicitly with a clearer message.
|
||||
if ($hasNext -and $after -eq $prevCursor) {
|
||||
throw "Get-AllThreadAuthors: pagination cursor did not advance for thread $ThreadId on page $pageIndex (server returned same endCursor='$after')."
|
||||
}
|
||||
}
|
||||
|
||||
# Return as an array (comma-prefix prevents PowerShell from
|
||||
# unwrapping a single-element list at the call boundary).
|
||||
return ,$authors.ToArray()
|
||||
}
|
||||
|
||||
$copilotLoginRegex = $CopilotReviewerLoginRegex # canonical regex defined in _lib.ps1
|
||||
|
||||
# Build $targets via an explicit foreach instead of Where-Object {...}.
|
||||
# The earlier Where-Object predicate did real work (warnings, try/catch,
|
||||
# pagination calls, counter mutation) — Where-Object's script-block runs
|
||||
# in a child scope, which is why those counters had to be $script:. A
|
||||
# plain foreach keeps the predicate semantics readable, lets the
|
||||
# counters be normal locals, and makes error handling easier to follow.
|
||||
[int]$skippedAwaitingReply = 0
|
||||
[int]$skippedHumanInThread = 0
|
||||
[int]$skippedUnknownAuthorInThread = 0
|
||||
[int]$skippedPaginationError = 0
|
||||
$targets = New-Object System.Collections.Generic.List[object]
|
||||
|
||||
foreach ($thread in $threads) {
|
||||
if (-not $thread.isOutdated) { continue }
|
||||
if ($thread.isResolved) { continue }
|
||||
|
||||
# Defensive null guard around the GraphQL shape. The reviewer
|
||||
# login can appear as either `copilot-pull-request-reviewer` or
|
||||
# `copilot-pull-request-reviewer[bot]` depending on the GraphQL
|
||||
# surface; match both with the same regex used elsewhere.
|
||||
$firstAuthor = $null
|
||||
if ($thread.firstComment -and $thread.firstComment.nodes -and $thread.firstComment.nodes.Count -gt 0 -and $thread.firstComment.nodes[0].author) {
|
||||
$firstAuthor = $thread.firstComment.nodes[0].author.login
|
||||
}
|
||||
if (-not ($firstAuthor -and ($firstAuthor -match $copilotLoginRegex))) { continue }
|
||||
|
||||
# Human-in-thread guard: if ANY comment in the thread is from a
|
||||
# non-Copilot, non-$me author (i.e., a human or different bot chimed
|
||||
# in after Copilot's opener), refuse to auto-resolve even with -Force.
|
||||
# The doc claim "threads from human reviewers are never touched"
|
||||
# must hold regardless of *position* of the human comment — a thread
|
||||
# with mixed authorship still carries human signal that must not
|
||||
# silently disappear when the loop calls cleanup.
|
||||
#
|
||||
# The outer query fetches the first 100 comment authors; when the
|
||||
# connection's pageInfo.hasNextPage is true, Get-AllThreadAuthors
|
||||
# paginates the rest via node(id:) so authorship visibility is
|
||||
# always complete. hasNextPage is the canonical connection signal
|
||||
# for "more pages exist" — using it directly is more robust than
|
||||
# comparing totalCount vs nodes.Count (totalCount is kept on the
|
||||
# query for diagnostics but not used as the pagination trigger).
|
||||
$hasMore = $false
|
||||
if ($thread.allComments -and $thread.allComments.pageInfo) {
|
||||
$hasMore = [bool]$thread.allComments.pageInfo.hasNextPage
|
||||
}
|
||||
$allAuthors = $null
|
||||
if ($hasMore) {
|
||||
# Per-thread try/catch so a transient pagination failure on ONE
|
||||
# thread (cursor not advancing, node(id:) returning null mid-walk,
|
||||
# rate-limit etc.) doesn't abort the rest of the cleanup pass —
|
||||
# mirrors the per-thread isolation already used for the resolve
|
||||
# mutation below. On failure: fail-safe by SKIPPING the thread
|
||||
# (never resolve when authorship is unknown).
|
||||
try {
|
||||
$allAuthors = Get-AllThreadAuthors -ThreadId $thread.id -FirstPage $thread.allComments
|
||||
} catch {
|
||||
$skippedPaginationError++
|
||||
Write-Warning "Pagination failed for thread $($thread.id) — skipping (fail-safe): $($_.Exception.Message)"
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
$allAuthorsList = [System.Collections.Generic.List[object]]::new()
|
||||
if ($thread.allComments -and $thread.allComments.nodes) {
|
||||
foreach ($n in $thread.allComments.nodes) {
|
||||
$login = if ($n.author) { $n.author.login } else { $null }
|
||||
$allAuthorsList.Add($login)
|
||||
}
|
||||
}
|
||||
$allAuthors = $allAuthorsList.ToArray()
|
||||
}
|
||||
|
||||
$humanInThread = $false
|
||||
$unknownAuthorInThread = $false
|
||||
foreach ($login in $allAuthors) {
|
||||
if (-not $login) {
|
||||
# `$null` author = ghost / deleted user. Authorship is
|
||||
# genuinely unknown, so treat as unsafe (fail-safe): we'd
|
||||
# rather leak an outdated bot thread than auto-resolve a
|
||||
# thread that *might* contain human signal hidden behind
|
||||
# a deleted account. Surfaced separately from
|
||||
# $humanInThread so summaries can distinguish "human
|
||||
# touched this" from "we couldn't tell who touched this".
|
||||
$unknownAuthorInThread = $true
|
||||
continue
|
||||
}
|
||||
if ($login -eq $me) { continue }
|
||||
if ($login -match $copilotLoginRegex) { continue }
|
||||
$humanInThread = $true
|
||||
break
|
||||
}
|
||||
if ($humanInThread) {
|
||||
$skippedHumanInThread++
|
||||
continue
|
||||
}
|
||||
if ($unknownAuthorInThread) {
|
||||
$skippedUnknownAuthorInThread++
|
||||
continue
|
||||
}
|
||||
|
||||
# Safety guard: don't resolve threads where Copilot (or anyone
|
||||
# other than us) had the last word — we haven't replied yet, so
|
||||
# resolving would hide an actionable finding. Override with -Force.
|
||||
$lastAuthor = $null
|
||||
if ($thread.lastComment -and $thread.lastComment.nodes -and $thread.lastComment.nodes.Count -gt 0 -and $thread.lastComment.nodes[0].author) {
|
||||
$lastAuthor = $thread.lastComment.nodes[0].author.login
|
||||
}
|
||||
if (-not $Force -and $lastAuthor -ne $me) {
|
||||
$skippedAwaitingReply++
|
||||
continue
|
||||
}
|
||||
|
||||
$targets.Add($thread)
|
||||
}
|
||||
|
||||
if ($skippedHumanInThread -gt 0) {
|
||||
Write-Output "Skipped $skippedHumanInThread outdated Copilot thread(s) where a non-Copilot, non-'$me' commenter participated (-Force does NOT override this — human signal must not silently disappear)."
|
||||
}
|
||||
if ($skippedUnknownAuthorInThread -gt 0) {
|
||||
Write-Output "Skipped $skippedUnknownAuthorInThread outdated Copilot thread(s) with at least one ghost / deleted-user (null author) comment (-Force does NOT override this — authorship is unknown, fail-safe is to skip)."
|
||||
}
|
||||
if ($skippedAwaitingReply -gt 0) {
|
||||
Write-Output "Skipped $skippedAwaitingReply outdated Copilot thread(s) where the last comment is not from '$me' (pass -Force to override)."
|
||||
}
|
||||
if ($skippedPaginationError -gt 0) {
|
||||
Write-Output "Skipped $skippedPaginationError outdated Copilot thread(s) due to authorship-pagination errors (fail-safe: never resolve when authorship is unknown). See warnings above for per-thread detail."
|
||||
}
|
||||
|
||||
if ($targets.Count -eq 0) {
|
||||
Write-Output 'No outdated Copilot threads to clean up.'
|
||||
return
|
||||
}
|
||||
|
||||
Write-Output "Found $($targets.Count) outdated Copilot thread(s) to resolve."
|
||||
|
||||
$resolveMutation = @'
|
||||
mutation($tid: ID!) {
|
||||
resolveReviewThread(input: { threadId: $tid }) {
|
||||
thread { isResolved }
|
||||
}
|
||||
}
|
||||
'@
|
||||
|
||||
# Per-thread try/catch so a single mutation failure (rate-limit, transient
|
||||
# GraphQL error, thread-disappeared-mid-loop) does NOT abort the whole
|
||||
# cleanup pass and leave the remaining outdated threads unresolved. Track
|
||||
# successes and failures, then summarise at the end with a non-zero exit
|
||||
# code if any thread failed.
|
||||
$resolved = 0
|
||||
$failed = New-Object System.Collections.Generic.List[object]
|
||||
foreach ($t in $targets) {
|
||||
if ($DryRun) {
|
||||
Write-Output "Would resolve $($t.id) (DryRun)"
|
||||
continue
|
||||
}
|
||||
try {
|
||||
$resolveArgs = @('-f', "query=$resolveMutation", '-f', "tid=$($t.id)")
|
||||
Invoke-GhGraphQL -GhArgs $resolveArgs -Context "resolve outdated thread $($t.id)" | Out-Null
|
||||
Write-Output "Resolved $($t.id)"
|
||||
$resolved++
|
||||
} catch {
|
||||
$msg = $_.Exception.Message
|
||||
Write-Warning "Failed to resolve $($t.id): $msg"
|
||||
$failed.Add([pscustomobject]@{ ThreadId = $t.id; Error = $msg })
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $DryRun) {
|
||||
Write-Output "Cleanup summary: resolved=$resolved failed=$($failed.Count) skippedAwaitingReply=$skippedAwaitingReply skippedHumanInThread=$skippedHumanInThread skippedUnknownAuthorInThread=$skippedUnknownAuthorInThread skippedPaginationError=$skippedPaginationError"
|
||||
if ($failed.Count -gt 0) {
|
||||
Write-Output ("Failed threads: " + (($failed | ForEach-Object { "$($_.ThreadId) ($($_.Error))" }) -join '; '))
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
# Shared helpers for copilot-pr-autopilot scripts.
|
||||
# Dot-source with: `. "$PSScriptRoot/_lib.ps1"`
|
||||
#
|
||||
# Dot-sourcing runs the prerequisite check below; if `gh` is missing or
|
||||
# unauthenticated the script halts BEFORE doing any work, with a single
|
||||
# actionable error message the calling agent can pattern-match on.
|
||||
#
|
||||
# Compatibility: Windows PowerShell 5.1+ and PowerShell 7+. Uses only
|
||||
# `& gh @args 2>$tempFile` for stdout/stderr separation — avoids
|
||||
# `System.Diagnostics.ProcessStartInfo.ArgumentList` which is .NET
|
||||
# Core / .NET 5+ only and returns $null on .NET Framework.
|
||||
|
||||
# Canonical Copilot Code Review reviewer login regex.
|
||||
# GraphQL exposes the login as either `copilot-pull-request-reviewer` (when
|
||||
# referenced via `requestedReviewer.login`) or `copilot-pull-request-reviewer[bot]`
|
||||
# (when referenced via review `author.login`), so callers must accept both.
|
||||
# Centralised here so all step scripts (01 / 02 / 10) stay in sync — if the
|
||||
# canonical login ever changes, change it once.
|
||||
#
|
||||
# Namespaced (`CopilotPrAutopilot_` prefix) + read-only because `_lib.ps1` is
|
||||
# dot-sourced into the caller's scope; a bare name like `$CopilotLoginRegex`
|
||||
# would risk colliding with caller-side variables. `Set-Variable -Force` lets
|
||||
# us re-dot-source in the same session without erroring on the read-only flag.
|
||||
# A back-compat alias `$CopilotReviewerLoginRegex` is preserved so callers
|
||||
# don't have to type the prefix on every read site (and so older snapshots of
|
||||
# 01/02/10 keep working).
|
||||
Set-Variable -Name 'CopilotPrAutopilot_CopilotReviewerLoginRegex' `
|
||||
-Value '(?i)^copilot-pull-request-reviewer(\[bot\])?$' `
|
||||
-Option ReadOnly -Force -Scope Script
|
||||
Set-Variable -Name 'CopilotReviewerLoginRegex' `
|
||||
-Value $CopilotPrAutopilot_CopilotReviewerLoginRegex `
|
||||
-Option ReadOnly -Force -Scope Script
|
||||
|
||||
# Prerequisite check: gh CLI installed AND authenticated.
|
||||
# Fails fast with install/login instructions. Idempotent (once per
|
||||
# PowerShell session).
|
||||
function Assert-GhReady {
|
||||
if ($script:_GhReady) { return }
|
||||
|
||||
# 1. Installed?
|
||||
$cmd = Get-Command gh -ErrorAction SilentlyContinue
|
||||
if (-not $cmd) {
|
||||
throw @'
|
||||
copilot-pr-autopilot: prerequisite missing — `gh` CLI is not on PATH.
|
||||
|
||||
Install (one of):
|
||||
- winget install --id GitHub.cli (Windows)
|
||||
- brew install gh (macOS)
|
||||
- sudo apt install gh (Debian/Ubuntu — see https://cli.github.com for other distros)
|
||||
- https://cli.github.com/ (universal installer + download)
|
||||
|
||||
Then `gh auth login` and re-run this command.
|
||||
'@
|
||||
}
|
||||
|
||||
# 2. Authenticated? `gh auth status` exits non-zero when no account
|
||||
# is logged in. Capture stderr to a temp file via the `2>` redirect.
|
||||
$errFile = [IO.Path]::GetTempFileName()
|
||||
try {
|
||||
$null = & gh auth status 2>$errFile
|
||||
$ec = $LASTEXITCODE
|
||||
if ($ec -ne 0) {
|
||||
$err = ''
|
||||
if (Test-Path -LiteralPath $errFile) {
|
||||
$err = (Get-Content -Raw -LiteralPath $errFile -ErrorAction SilentlyContinue)
|
||||
if ($null -eq $err) { $err = '' }
|
||||
}
|
||||
throw @"
|
||||
copilot-pr-autopilot: prerequisite missing — ``gh`` CLI is not authenticated.
|
||||
|
||||
Run:
|
||||
gh auth login
|
||||
|
||||
Then re-run this command.
|
||||
|
||||
``gh auth status`` reported:
|
||||
$($err.Trim())
|
||||
"@
|
||||
}
|
||||
} finally {
|
||||
if (Test-Path -LiteralPath $errFile) {
|
||||
Remove-Item -LiteralPath $errFile -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
|
||||
$script:_GhReady = $true
|
||||
}
|
||||
|
||||
# Single-invocation gh wrapper. Captures stdout + stderr separately
|
||||
# via the `2>` redirect to a temp file. Returns ExitCode/Stdout/Stderr
|
||||
# so callers never have to re-invoke `gh` just to recover stderr, and
|
||||
# never feed stderr into `ConvertFrom-Json` on success.
|
||||
#
|
||||
# Note on -WhatIf: PowerShell's `2>` redirect goes through Out-File,
|
||||
# which respects $WhatIfPreference at the caller scope. The bundled
|
||||
# `10-cleanup-outdated.ps1` therefore uses an explicit `-DryRun`
|
||||
# switch instead of [CmdletBinding(SupportsShouldProcess)], so this
|
||||
# helper never sees a leaked WhatIfPreference and never prints
|
||||
# "Performing the operation Output to File" noise.
|
||||
function Invoke-Gh {
|
||||
param([Parameter(Mandatory)][string[]]$GhArgs)
|
||||
|
||||
# Cross-version safety: Windows PowerShell 5.1's native-command
|
||||
# argument passer mangles arguments that contain embedded double-quote
|
||||
# characters (long-standing bug, only fully fixed in PS 7.3+ via
|
||||
# $PSNativeCommandArgumentPassing). GraphQL queries/mutations routinely
|
||||
# embed quoted strings (comments, default values, enum-like literals
|
||||
# such as `["copilot-pull-request-reviewer"]`), so passing them as
|
||||
# command-line values (`-f field=<body>`) round-trips correctly in
|
||||
# pwsh 7 but silently mis-splits under 5.1 (e.g., gh CLI reports
|
||||
# "accepts 1 arg(s), received 7" or 'Expected type "number", but it
|
||||
# was malformed: "-pull"'). To work identically in both runtimes, any
|
||||
# `-f field=<body>` or `-F field=<body>` pair whose body contains `"`
|
||||
# is rewritten to `-F field=@<tempfile>` (the body is written to disk
|
||||
# first; `gh` reads it from the file and the value never appears on
|
||||
# the command line).
|
||||
#
|
||||
# IMPORTANT typing note (verified live with gh api graphql):
|
||||
# * `gh -F field=@<file>` reads the file content and applies type
|
||||
# inference (digit→Number, true/false→Boolean, null→null, else
|
||||
# String).
|
||||
# * `gh -f field=@<file>` does NOT expand `@<file>` — it sends the
|
||||
# literal string `@<file>` as the value (gh's `-f` skips the @
|
||||
# prefix entirely). So `-f` is NOT a viable tempfile carrier;
|
||||
# the rewrite MUST use `-F`.
|
||||
#
|
||||
# Safety of the unconditional rewrite-to-`-F`:
|
||||
# * Query bodies (large GraphQL strings) never look like Number /
|
||||
# Boolean / null after inference, so they round-trip as String.
|
||||
# * Reply bodies typed by humans (08-reply-and-resolve) almost
|
||||
# never look like exactly `"true"`, `"false"`, `"null"`, or a
|
||||
# bare digit run — and if they do AND they also contain `"`
|
||||
# (the rewrite trigger), the resulting coercion would be a
|
||||
# loud GraphQL `String!` type error, not silent data loss.
|
||||
# Tempfiles are cleaned up in `finally`.
|
||||
$rewritten = [System.Collections.Generic.List[string]]::new()
|
||||
$tempFiles = [System.Collections.Generic.List[string]]::new()
|
||||
for ($i = 0; $i -lt $GhArgs.Count; $i++) {
|
||||
$a = $GhArgs[$i]
|
||||
# Rewrite both `-f field=<body>` and `-F field=<body>` whose body
|
||||
# contains `"` — same PS 5.1 native-arg splitting bug applies to
|
||||
# both. The rewrite ALWAYS emits `-F` because `gh -f field=@file`
|
||||
# does not expand `@file` (only `-F` does — verified live). The
|
||||
# file content is then sent as a String GraphQL variable for any
|
||||
# body that doesn't look like a Number/Boolean/null (i.e., every
|
||||
# real-world query body and reply body in this skill).
|
||||
if (($a -eq '-f' -or $a -eq '-F') -and ($i + 1) -lt $GhArgs.Count) {
|
||||
$next = $GhArgs[$i + 1]
|
||||
$eqIdx = $next.IndexOf('=')
|
||||
if ($eqIdx -gt 0 -and $next.Substring($eqIdx + 1).Contains('"')) {
|
||||
$field = $next.Substring(0, $eqIdx)
|
||||
$body = $next.Substring($eqIdx + 1)
|
||||
$tf = [IO.Path]::GetTempFileName()
|
||||
[void]$tempFiles.Add($tf)
|
||||
# UTF-8 without BOM so `gh` reads the body verbatim
|
||||
[IO.File]::WriteAllText($tf, $body, [System.Text.UTF8Encoding]::new($false))
|
||||
[void]$rewritten.Add('-F')
|
||||
[void]$rewritten.Add("$field=@$tf")
|
||||
$i++
|
||||
continue
|
||||
}
|
||||
}
|
||||
[void]$rewritten.Add($a)
|
||||
}
|
||||
|
||||
$errFile = [IO.Path]::GetTempFileName()
|
||||
try {
|
||||
$finalArgs = $rewritten.ToArray()
|
||||
# Localise $ErrorActionPreference to 'Continue' around the native
|
||||
# `gh` call. Why: callers set `$ErrorActionPreference = 'Stop'` at
|
||||
# script scope, and under PowerShell 5.1 that combination converts
|
||||
# any line `gh` writes to stderr into a `NativeCommandError` that
|
||||
# aborts the script BEFORE we get to inspect `$LASTEXITCODE`. PS 7+
|
||||
# changed native-stderr handling and is unaffected. By keeping the
|
||||
# native call at 'Continue' we always return the
|
||||
# `@{ExitCode;Stdout;Stderr}` object on both runtimes, so callers
|
||||
# see the same structured error and can emit the same actionable
|
||||
# message (e.g. the "click UI 🔄" guidance in 01-request-review).
|
||||
$prevEAP = $ErrorActionPreference
|
||||
$ErrorActionPreference = 'Continue'
|
||||
try {
|
||||
$out = & gh @finalArgs 2>$errFile
|
||||
$ec = $LASTEXITCODE
|
||||
} finally {
|
||||
$ErrorActionPreference = $prevEAP
|
||||
}
|
||||
$err = ''
|
||||
if (Test-Path -LiteralPath $errFile) {
|
||||
$err = (Get-Content -Raw -LiteralPath $errFile -ErrorAction SilentlyContinue)
|
||||
if ($null -eq $err) { $err = '' }
|
||||
}
|
||||
# Preserve gh's stdout content without PowerShell formatting.
|
||||
# `Out-String` would append a trailing newline and apply console
|
||||
# formatting widths, which can subtly break callers that
|
||||
# regex/JSON-parse the result. `& gh` returns one array entry per
|
||||
# line (with the line terminator already stripped); we re-join with
|
||||
# "`n" and no trailing newline, so the result is content-preserving
|
||||
# but normalized to LF (not byte-identical to the original stream).
|
||||
# Callers add a trailing newline if they need one.
|
||||
$stdout = if ($null -eq $out) { '' }
|
||||
elseif ($out -is [string]) { $out }
|
||||
else { ($out | ForEach-Object { [string]$_ }) -join "`n" }
|
||||
[pscustomobject]@{ ExitCode = $ec; Stdout = $stdout; Stderr = $err }
|
||||
} finally {
|
||||
if (Test-Path -LiteralPath $errFile) {
|
||||
Remove-Item -LiteralPath $errFile -ErrorAction SilentlyContinue
|
||||
}
|
||||
foreach ($tf in $tempFiles) {
|
||||
if ($tf -and (Test-Path -LiteralPath $tf)) {
|
||||
Remove-Item -LiteralPath $tf -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Wrap ConvertFrom-Json so a non-JSON / empty stdout failure carries
|
||||
# the calling $Context plus trimmed stdout/stderr — without this
|
||||
# callers see a bare "Unexpected character encountered" exception
|
||||
# that doesn't say which gh command produced the bad output.
|
||||
# Centralised so the preview limits + format stay consistent across
|
||||
# Invoke-GhGraphQL, Resolve-RepoCoords, and any future call sites.
|
||||
function ConvertFrom-GhJson {
|
||||
param(
|
||||
[Parameter(Mandatory)][AllowEmptyString()][AllowNull()][string]$Stdout,
|
||||
[AllowEmptyString()][AllowNull()][string]$Stderr,
|
||||
[Parameter(Mandatory)][string]$Context,
|
||||
[int]$PreviewChars = 500
|
||||
)
|
||||
try {
|
||||
# Use -InputObject (not pipeline form `$Stdout | ConvertFrom-Json`):
|
||||
# on Windows PowerShell 5.1, returning the pipeline form from inside
|
||||
# a function preserves the parsed array as a single object rather
|
||||
# than unrolling it. Callers then see `.Count == 1` for a JSON
|
||||
# array of N items, and `$result[0]` is the inner array. The
|
||||
# parameter form returns the same parsed structure but PowerShell
|
||||
# 5.1 unrolls it correctly on function return.
|
||||
return (ConvertFrom-Json -InputObject $Stdout -ErrorAction Stop)
|
||||
} catch {
|
||||
$stdoutPreview = if ($Stdout) { $Stdout.Substring(0, [Math]::Min($PreviewChars, $Stdout.Length)) } else { '(empty)' }
|
||||
$stderrPreview = if ($Stderr) { $Stderr.Substring(0, [Math]::Min($PreviewChars, $Stderr.Length)) } else { '(empty)' }
|
||||
throw "$Context returned non-JSON: $($_.Exception.Message)`nstdout (<=${PreviewChars} chars): $stdoutPreview`nstderr (<=${PreviewChars} chars): $stderrPreview"
|
||||
}
|
||||
}
|
||||
|
||||
# Wrapper around Invoke-Gh for `gh api graphql` that throws on either
|
||||
# non-zero exit OR a GraphQL `errors` array in the response body.
|
||||
# Cross-version safety for embedded quotes in queries is handled by
|
||||
# Invoke-Gh's automatic `-f field=<body-with-quotes>` → tempfile rewrite.
|
||||
function Invoke-GhGraphQL {
|
||||
param(
|
||||
[Parameter(Mandatory)][string[]]$GhArgs,
|
||||
[Parameter(Mandatory)][string]$Context
|
||||
)
|
||||
$r = Invoke-Gh -GhArgs (@('api','graphql') + $GhArgs)
|
||||
if ($r.ExitCode -ne 0) {
|
||||
throw "gh api graphql failed (exit $($r.ExitCode)) [$Context]: $($r.Stderr)"
|
||||
}
|
||||
$data = ConvertFrom-GhJson -Stdout $r.Stdout -Stderr $r.Stderr -Context "gh api graphql [$Context]"
|
||||
if ($data.errors) {
|
||||
# Aggregate type + path + extensions.code alongside .message so
|
||||
# callers see actionable failures without re-running with extra
|
||||
# logging. GitHub commonly returns type=NOT_FOUND / FORBIDDEN /
|
||||
# RATE_LIMITED and extensions.code=undefinedField etc.; dropping
|
||||
# them turns a clear failure ("FORBIDDEN at /repository/pullRequest")
|
||||
# into an opaque message-only string.
|
||||
$msgs = ($data.errors | ForEach-Object {
|
||||
$parts = New-Object System.Collections.Generic.List[string]
|
||||
if ($_.type) { $parts.Add("type=$($_.type)") }
|
||||
if ($_.path) { $parts.Add("path=$(($_.path) -join '/')") }
|
||||
if ($_.extensions -and $_.extensions.code) { $parts.Add("code=$($_.extensions.code)") }
|
||||
$parts.Add("message=$($_.message)")
|
||||
($parts -join ' ')
|
||||
}) -join '; '
|
||||
throw "GraphQL errors [$Context]: $msgs"
|
||||
}
|
||||
$data
|
||||
}
|
||||
|
||||
# Auto-resolve owner/repo from gh's local context when caller didn't pass them.
|
||||
# Both-or-neither contract: passing exactly one of -Owner/-Repo is rejected,
|
||||
# because mixing a caller-supplied owner with a locally-detected repo (or vice
|
||||
# versa) silently constructs a non-existent or unintended `<Owner>/<Repo>` pair.
|
||||
function Resolve-RepoCoords {
|
||||
param([string]$Owner, [string]$Repo)
|
||||
if ([bool]$Owner -ne [bool]$Repo) {
|
||||
throw "Resolve-RepoCoords: pass both -Owner and -Repo, or neither (got Owner='$Owner' Repo='$Repo'). Partial override would silently mix caller and local repo coordinates."
|
||||
}
|
||||
if ($Owner -and $Repo) { return @{ Owner = $Owner; Repo = $Repo } }
|
||||
$r = Invoke-Gh -GhArgs @('repo','view','--json','owner,name')
|
||||
if ($r.ExitCode -ne 0) {
|
||||
throw "gh repo view failed (exit $($r.ExitCode)): $($r.Stderr). Pass -Owner and -Repo explicitly, or run from inside a gh-detected repo."
|
||||
}
|
||||
$info = ConvertFrom-GhJson -Stdout $r.Stdout -Stderr $r.Stderr -Context 'gh repo view'
|
||||
if (-not ($info -and $info.owner -and $info.owner.login -and $info.name)) {
|
||||
throw "gh repo view returned unexpected shape (missing owner.login or name); cannot auto-resolve repo coordinates. Pass -Owner and -Repo explicitly."
|
||||
}
|
||||
@{ Owner = $info.owner.login; Repo = $info.name }
|
||||
}
|
||||
|
||||
# Format-IsoUtcString — centralise the ISO-8601 UTC normalisation that
|
||||
# 01-request-review.ps1 (events.created_at), 02-check-review-status.ps1
|
||||
# (reviews.submittedAt), and 03-list-open-threads.ps1 (comments.createdAt)
|
||||
# all need to perform. `ConvertFrom-Json` auto-deserialises ISO timestamps
|
||||
# to `[datetime]`, whose default `.ToString()` is culture-dependent and
|
||||
# NOT round-trippable as ISO-8601. Calling `.ToUniversalTime().ToString(
|
||||
# 'yyyy-MM-ddTHH:mm:ssZ')` keeps the on-wire JSON contract identical to
|
||||
# the value GitHub originally sent. If the value is already a string
|
||||
# (e.g., gh returned a raw JSON string), we pass it through verbatim. If
|
||||
# it's null or empty, we return ''.
|
||||
function Format-IsoUtcString {
|
||||
param($Value)
|
||||
if ($null -eq $Value) { return '' }
|
||||
if ($Value -is [datetime]) { return $Value.ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') }
|
||||
return [string]$Value
|
||||
}
|
||||
|
||||
# Run the prerequisite check as a side-effect of dot-sourcing.
|
||||
Assert-GhReady
|
||||
@@ -0,0 +1,21 @@
|
||||
# Reply: declined with rationale
|
||||
|
||||
Use when triage decided `decline`. The reply must explain WHY
|
||||
declining is the right call — not just that you considered it. Always
|
||||
resolve the thread after replying; an open thread with no reply
|
||||
signals avoidance.
|
||||
|
||||
```
|
||||
Considered this, but declining: <concrete reason rooted in code or
|
||||
design>. <Optional: the interleaving / scenario you ruled out, or
|
||||
the alternative cost>. Happy to revisit if <specific trigger>.
|
||||
```
|
||||
|
||||
Example (domain-neutral):
|
||||
|
||||
> Considered extending the lock into the initialization path, but
|
||||
> declining: initialization runs to completion before any concurrent
|
||||
> caller can reach this code, so the race window only opens after
|
||||
> the init callback has returned. Sharing the lock across modules
|
||||
> costs more in coupling than the actual exposure justifies. Happy
|
||||
> to revisit if telemetry shows a real interleaving.
|
||||
@@ -0,0 +1,8 @@
|
||||
# Reply: documentation / test-plan drift
|
||||
|
||||
Use when Copilot points out the PR description, a comment, or the
|
||||
test plan no longer matches the code.
|
||||
|
||||
```
|
||||
Good catch — updated the <PR description | comment in `<file>` | test plan> to match the implemented behavior: <one-line summary of the now-correct statement>.
|
||||
```
|
||||
@@ -0,0 +1,25 @@
|
||||
# Reply: accepted fix
|
||||
|
||||
Use after the loop has committed and pushed a fix for the finding. Cite
|
||||
the pushed commit SHA from step 7.
|
||||
|
||||
```
|
||||
<one sentence acknowledging the finding>.
|
||||
<one or two sentences describing the fix>.
|
||||
Fixed in <commit-sha>.
|
||||
```
|
||||
|
||||
Example (language-neutral):
|
||||
|
||||
> The lock did not cover the install side of the path, so two
|
||||
> parallel writers could read the same baseline and clobber each
|
||||
> other. Promoted the per-instance lock to a process-wide
|
||||
> function-local static so all read-modify-write paths share it.
|
||||
> Fixed in abc1234.
|
||||
|
||||
When the fix is in a tested area, add a one-line test confirmation:
|
||||
|
||||
> Replaced the platform UUID dependency with a PID + monotonic-clock
|
||||
> + atomic counter so the test target no longer pulls in the
|
||||
> platform UUID library. All 42 tests in the affected suite still
|
||||
> pass. Fixed in abc1234.
|
||||
@@ -0,0 +1,9 @@
|
||||
# Reply: partial fix with deferred follow-up
|
||||
|
||||
Use when the finding has both an immediate fix and a deeper
|
||||
structural concern — address the immediate part now and acknowledge
|
||||
the rest.
|
||||
|
||||
```
|
||||
Fixed the immediate <X> in <commit-sha>. The broader <Y> would benefit from <larger change>, which I'd prefer to land separately because <reason>. Tracking as <issue-link-or-todo>.
|
||||
```
|
||||
Reference in New Issue
Block a user