mirror of
https://github.com/github/awesome-copilot.git
synced 2026-07-18 03:40:02 +00:00
Add copilot-pr-autopilot skill (#1944)
* Add copilot-pr-autopilot skill Skill that drives any GitHub pull request through repeated rounds of Copilot Code Review until the agent has either resolved every thread or explicitly escalated it to the human. Triggered via GraphQL (no @copilot mention needed), triages every open thread with a fix / decline / escalate rubric, replies and resolves each thread citing the pushed SHA, then re-triggers until HEAD is reviewed with zero threads awaiting the agent's reply. Includes step scripts (01 request-review, 02 check-review-status, 03 list-open-threads, 08 reply-and-resolve, 10 cleanup-outdated), shared library (_lib.ps1) with gh-CLI wrappers (Invoke-Gh, Invoke-GhGraphQL, ConvertFrom-GhJson, Assert-GhReady), reply templates, and reference docs for each step. Repo-agnostic. Requires gh CLI on PATH and repo Triage/Write for full autopilot; external PR authors get single-iteration mode with manual re-trigger via the UI re-request button or a substantive push. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address review: split per-step references + add recap-gate circuit breaker - Fix #1: give steps 1/7/10 their own reference files (01-request-review.md, 07-commit-push.md, 10-cleanup.md); trim the inline bodies out of orchestration.md so it stays cross-cutting only. - Fix #3: add a recurring round-cap & recap gate to 09-convergence.md — default STOP every 10th round, recap all prior rounds, detect drift (out-of-scope / over-engineering / wrong-direction / belongs-in-separate-PR) with CONTINUE / REVERT-AND-SHIP / HAND-OFF verdicts. Agent reasoning, no new script. - Surface the gate from SKILL.md and orchestration.md; regenerate docs/README.skills.md. Markdown-only change; scripts unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs(copilot-pr-autopilot): surface recap gate in decision pseudo-code; clarify Copilot+human convergence and round definition - Inject the round-cap recap gate into the '## Decision: loop back or exit' pseudo-code else-branch so an agent following the code block (not just the prose) runs the STOP-every-10th-round check before looping. - Broaden the 'never terminal' paragraph: non-convergence is driven by a Copilot finding OR a human review comment (this skill handles both); the loop ends only when there are no new comments from either source AND every open thread (Copilot or human) has an agent reply/escalation. - Define a 'round' explicitly as one execution of step 1 (01-request-review), i.e. one Copilot-review trigger — the cap counts review rounds, not tool calls or fix edits. Markdown-only; no script changes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * copilot-pr-autopilot: make recap-gate round count deterministic Add 09-review-round.ps1: counts Copilot Code Review submissions straight from the PR's API history (full GraphQL pagination), so the recap-gate trigger is a derived number, not a fallible agent mental tally. This removes the exact failure mode the skill exists to survive — a count drifting across a long run (the real 156-round case). The script reports Round + RecapDue (Round % RecapInterval == 0) only; it never stops the loop or picks the verdict. CONTINUE / REVERT-AND-SHIP / HAND-OFF stays agent reasoning. 09-convergence.md updated to reference the deterministic count while preserving 'no script stops the loop' and 'non-convergence = Copilot finding OR human comment'. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Regenerate docs/README.skills.md for copilot-pr-autopilot (add 09-review-round.ps1) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user