8.4 KiB
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
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:
- Mutation is
requestReviewsByLogin, NOTrequestReviews.RequestReviewsInput(used byrequestReviews) does not expose abotLoginsfield, so it can't request a bot reviewer at all —botLoginsis the central field onrequestReviewsByLogin. - Field is
botLogins, NOTuserLogins. The latter returnsCould not resolve user with login 'Copilot'. - Slug is
copilot-pull-request-reviewer(the App slug). The display loginCopilotreturnsCould 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
requestReviewswithbotLogins→ input type rejects the field. Don't try variants.- REST
POST /pulls/<n>/requested_reviewerswithreviewers[]=Copilot→ can return HTTP 201 while silently dropping the bot. Not used by the script. gh pr edit --add-reviewer Copilot→ returns'Copilot' not foundon currentgh. Not used by the script.
GraphQL latestReviews — stale cache, do NOT use
# 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"))'
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
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
!isOutdatedwould silently drop those threads, leaving the PR's open-conversations list non-empty even after the underlying code is fixed. 03-list-open-threads.ps1therefore lists every unresolved thread with noisOutdatedfilter.10-cleanup-outdated.ps1is 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-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.
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
(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
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.