mirror of
https://github.com/github/awesome-copilot.git
synced 2026-07-15 02:21:04 +00:00
317 lines
13 KiB
PowerShell
317 lines
13 KiB
PowerShell
<#
|
|
.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
|