Skill 94 · Copilot PR Autopilot
Subchapter 94.11
references/api-quirks.mdMarkdown8 KBView on GitHub
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.
Scripts
LibrequestReviewsByLogin is the supported pathmutation($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:
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.botLogins, NOT userLogins. The latter returns
Could not resolve user with login 'Copilot'.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.
requestReviews with botLogins → input type rejects the
field. Don’t try variants.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.# 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:
gh pr view <n> --json reviews --jq '.reviews[] | select(.author.login | test("copilot-pull-request-reviewer"))'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.
mutation($tid: ID!, $body: String!) {
addPullRequestReviewThreadReply(input: {
pullRequestReviewThreadId: $tid,
body: $body
}) { comment { id } }
}
mutation($tid: ID!) {
resolveReviewThread(input: { threadId: $tid }) {
thread { isResolved }
}
}A thread can be isOutdated: true (Copilot’s comment points at lines
that have since changed) while still isResolved: false. These
threads:
!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.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.
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-Ghwrapper 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,-Fstill 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 ...=@fileusage as an internal transport detail of the wrapper, not as permission to use-F ...=@filefor arbitrary strings at call sites.
# 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.
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
(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 -m "local-build" -- src/path/a src/path/b # correct
git stash push -- src/path/a src/path/b -m "local-build" # SILENTLY drops -mThe -m MUST come before the -- path separator.