chore: publish from main

This commit is contained in:
github-actions[bot]
2026-07-01 01:10:23 +00:00
parent fa007c62f7
commit 5075e262b5
25 changed files with 3124 additions and 0 deletions
@@ -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