mirror of
https://github.com/github/awesome-copilot.git
synced 2026-07-18 11:50:00 +00:00
chore: publish from main
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
|
||||
Reference in New Issue
Block a user