Setting the file. One moment.
01 Request Review · Copilot PR Autopilot · github/awesome-copilot · Skills Docs
ContentsBack to the top of the page 94.10
10 Cleanup
(opens in a new tab)
scripts/ 01-request-review.ps1
PowerShell · 316 lines · 13 KB
16 within -VerifySeconds. Caller should push a substantive commit and
17 retry (auto-assign on `synchronize` is the most reliable fallback).
18
19 . PARAMETER PrNumber PR number (required).
20 . PARAMETER Owner
21 Optional; auto-resolved from `gh repo view`.
22
23 . PARAMETER Repo
24 Optional; auto-resolved from `gh repo view`.
25 . PARAMETER VerifySeconds Verification poll window (1..600, default 45).
26
27 . EXAMPLE
28 pwsh 01-request-review.ps1 -PrNumber 236
29 #>
30 [ CmdletBinding ()]
31 param (
32 [ Parameter ( Mandatory = $true )]
33 [ int ]$PrNumber ,
34
35 [ string ]$Owner ,
36 [ string ]$Repo ,
37
38 [ ValidateRange ( 1 , 600 )]
39 [ int ]$VerifySeconds = 45
40 )
41
42 $ErrorActionPreference = 'Stop'
43 . " $PSScriptRoot /_lib.ps1"
44
45 function Get-LatestCopilotWorkStartedEvent {
46 $eventsPath = "repos/ $Owner / $Repo /issues/ $PrNumber /events?per_page=100"
47 $r = Invoke-Gh - GhArgs @ ( 'api' , '-i' , $eventsPath)
48 if ($r.ExitCode -ne 0 ) { throw "events query failed: $( $r.Stderr ) " }
49
50 $m = [ regex ]::Match($r.Stdout , '(?s)\A(?<headers>.*?)\r?\n\r?\n(?<body>.*)\z' )
51 if ( -not $m.Success) { throw 'events query returned an unexpected header/body shape.' }
52 $headers = $m.Groups[ 'headers' ].Value
53 $body = $m.Groups[ 'body' ].Value
54
55 $lastPage = 1
56 # Link header looks like: `<https://api.github.com/...?per_page=100&page=4>; rel="last"`
57 # Param order is not guaranteed — `page=4` may appear before or after
58 # other query params. Match `page=<n>` inside the URL (allowing `?`
59 # or `&` separator) up to the closing angle bracket, then the
60 # `rel="last"` marker.
61 $lastMatch = [ regex ]::Match($headers , '<[^>]*[?&]page=(\d+)[^>]*>;\s*rel="last"' )
62 if ($lastMatch.Success) { $lastPage = [ int ]$lastMatch.Groups[ 1 ].Value }
63 if ($lastPage -gt 1 ) {
64 $r = Invoke-Gh - GhArgs @ ( 'api' , "repos/ $Owner / $Repo /issues/ $PrNumber /events?per_page=100&page= $lastPage " )
65 if ($r.ExitCode -ne 0 ) { throw "events last-page query failed: $( $r.Stderr ) " }
66 $body = $r.Stdout
67 }
68
69 $events = @ ( ConvertFrom-GhJson - Stdout $body - Context "events page $lastPage " )
70 $latest = $events | Where-Object { $_ .event -eq 'copilot_work_started' } | Sort-Object id | Select-Object - Last 1
71 if ( -not $latest) { return [ pscustomobject ] @ { Id = 0L ; CreatedAt = '' } }
72 $createdAt = Format-IsoUtcString $latest.created_at
73 [ pscustomobject ] @ { Id = [ long ]$latest.id; CreatedAt = $createdAt }
74 }
75
76 # ---------- repo resolve ----------
77
78 $coords = Resolve-RepoCoords - Owner $Owner - Repo $Repo
79 $Owner = $coords.Owner
80 $Repo = $coords.Repo
81
82 # ---------- state: is Copilot currently requested? ----------
83 # Single GraphQL query: requested reviewers + head SHA, followed by
84 # pagination for the full requested-reviewer set.
85
86 $stateQuery = @'
87 query($o:String!,$r:String!,$n:Int!){
88 viewer{login}
89 repository(owner:$o,name:$r){
90 pullRequest(number:$n){
91 id
92 headRefOid
93 state
94 author{login}
95 reviews(last:50){nodes{author{login}}}
96 reviewRequests(first:100){nodes{requestedReviewer{__typename ... on Bot{login} ... on User{login} ... on Mannequin{login}}} pageInfo{hasNextPage endCursor}}
97 }
98 }
99 }
100 '@
101 $stateData = Invoke-GhGraphQL - GhArgs @ ( '-f' , "query= $stateQuery " , '-f' , "o= $Owner " , '-f' , "r= $Repo " , '-F' , "n= $PrNumber " ) - Context "state query for $Owner / $Repo PR # $PrNumber "
102 $pr = $stateData.data.repository.pullRequest
103 if ( -not $pr) { throw "PR # $PrNumber not found in $Owner / $Repo ." }
104 if ($pr.state -ne 'OPEN' ) {
105 throw "PR # $PrNumber is not OPEN (state= $( $pr.state ) )."
106 }
107
108 $viewerLogin = [ string ]$stateData.data.viewer.login
109 $prAuthorLogin = if ($pr.author) { [ string ]$pr.author.login } else { '' }
110 $viewerIsAuthor = ($viewerLogin -and $prAuthorLogin -and ($viewerLogin -eq $prAuthorLogin))
111 $copilotHasReviewed = $false
112 if ($pr.reviews -and $pr.reviews.nodes) {
113 foreach ($rev in $pr.reviews.nodes) {
114 if ($rev.author -and $rev.author.login -and ($rev.author.login -match $CopilotReviewerLoginRegex)) {
115 $copilotHasReviewed = $true ; break
116 }
117 }
118 }
119
120 $headOid = $pr.headRefOid
121 $prNodeId = [ string ]$pr.id
122 if ([ string ]::IsNullOrWhiteSpace($prNodeId)) {
123 throw "Failed to resolve PR node id for $Owner / $Repo PR # $PrNumber from state query."
124 }
125 $reviewRequestsList = [ System.Collections.Generic.List [ object ]]::new()
126 foreach ($n in @ ($pr.reviewRequests.nodes)) { $reviewRequestsList.Add($n) }
127 $hasNext = [ bool ]$pr.reviewRequests.pageInfo.hasNextPage
128 $after = $pr.reviewRequests.pageInfo.endCursor
129 while ($hasNext) {
130 $pageQuery = @'
131 query($o:String!,$r:String!,$n:Int!,$after:String!){
132 repository(owner:$o,name:$r){
133 pullRequest(number:$n){
134 reviewRequests(first:100,after:$after){nodes{requestedReviewer{__typename ... on Bot{login} ... on User{login} ... on Mannequin{login}}} pageInfo{hasNextPage endCursor}}
135 }
136 }
137 }
138 '@
139 $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 "
140 $page = $pageData.data.repository.pullRequest.reviewRequests
141 foreach ($n in $page.nodes) { $reviewRequestsList.Add($n) }
142 $hasNext = [ bool ]$page.pageInfo.hasNextPage
143 $after = $page.pageInfo.endCursor
144 }
145 $reviewRequests = $reviewRequestsList.ToArray()
146 $copilotPendingRequests = @ ($reviewRequests | Where-Object {
147 $_ .requestedReviewer -and $_ .requestedReviewer.login -and $_ .requestedReviewer.login -match $CopilotReviewerLoginRegex
148 })
149 $copilotPending = $copilotPendingRequests.Count -gt 0
150
151 # If Copilot is currently in requested_reviewers, it's in-flight by definition.
152 if ($copilotPending) {
153 @ {
154 Status = 'InFlight'
155 PrNumber = $PrNumber
156 HeadOid = $headOid
157 Detail = "Copilot is currently in requested_reviewers; review is in flight."
158 } | ConvertTo-Json - Compress
159 exit 0
160 }
161
162 # We do NOT short-circuit on AlreadyReviewed — the user wants re-request
163 # as a first-class flow. Re-trigger; the GraphQL mutation handles both
164 # initial-add and re-request identically.
165
166 # ---------- snapshot copilot_work_started before triggering ----------
167
168 # Snapshot the latest copilot_work_started BEFORE triggering. Use the
169 # event's numeric `id` (monotonic) — `created_at` is second-resolution
170 # and would collide if a new event lands in the same second.
171 $beforeEvent = Get-LatestCopilotWorkStartedEvent
172 $beforeId = $beforeEvent.Id
173
174 # ---------- trigger via GraphQL requestReviewsByLogin ----------
175
176 $mut = 'mutation($p:ID!){requestReviewsByLogin(input:{pullRequestId:$p,botLogins:["copilot-pull-request-reviewer"]}){pullRequest{number}}}'
177 # Why this path and not REST or `requestReviews`? Verified end-to-end:
178 # - REST POST /pulls/{n}/requested_reviewers `reviewers:["Copilot"]`
179 # (the bot's REST login per `GET user/175728472`) → 404. The REST
180 # `reviewers` field accepts type=User only; bots are rejected even
181 # when the login resolves to a Bot record.
182 # - GraphQL `requestReviews` rejects bot node IDs ("Could not resolve
183 # to User node with the global id of 'BOT_…'") at schema level.
184 # - `requestReviewsByLogin.botLogins` is the ONLY public path for bot
185 # reviewers; trade-off is that it requires repo Triage/Write.
186 # - The UI 🔄 button uses a github.com Rails endpoint with a session
187 # cookie + CSRF that gh's OAuth token cannot satisfy.
188 # Catch the auth-gated case below and surface the two real workarounds.
189 $r = Invoke-Gh - GhArgs @ ( 'api' , 'graphql' , '-f' , "query= $mut " , '-f' , "p= $prNodeId " )
190
191 # Belt-and-suspenders permission-error detection. Empirically `gh api graphql`
192 # exits non-zero AND puts the message in stderr for FORBIDDEN on
193 # requestReviewsByLogin (verified: exit=1, stderr contains "does not have the
194 # correct permissions"). But some GraphQL paths return exit=0 with a top-level
195 # `errors[]` carrying type=FORBIDDEN, so check both surfaces and route both to
196 # the same actionable-error formatter.
197 $permErrInStderr = ($r.ExitCode -ne 0 ) -and ($r.Stderr -match '(?i)does not have (the )?correct permissions|forbidden|HTTP 403' )
198 $permErrInBody = $false
199 $bodyErrors = $null
200 if ($r.Stdout) {
201 try {
202 # Route through the shared ConvertFrom-GhJson helper so the
203 # preview format / context conventions stay consistent. The
204 # helper throws on parse failure; we catch and Write-Warning
205 # (fall through to the authoritative stderr/exit-code path)
206 # rather than abort — the warning makes the fall-through
207 # observable in logs.
208 $parsed = ConvertFrom-GhJson - Stdout $r.Stdout - Stderr $r.Stderr - Context 'requestReviewsByLogin' - PreviewChars 200
209 if ($parsed.errors) {
210 $bodyErrors = $parsed.errors
211 $permErrInBody = [ bool ]($parsed.errors | Where-Object {
212 ( $_ .type -eq 'FORBIDDEN' ) -or ( $_ .message -match '(?i)does not have (the )?correct permissions|forbidden' )
213 })
214 }
215 } catch {
216 Write-Warning $_ .Exception.Message
217 }
218 }
219
220 if ($permErrInStderr -or $permErrInBody) {
221 $rawMsg = if ($permErrInStderr) { $r.Stderr } elseif ($bodyErrors) { ($bodyErrors | ForEach-Object { $_ .message }) -join '; ' } else { '(no message)' }
222 if ($viewerIsAuthor) {
223 # External PR author scenario: GitHub's UI 🔄 button uses an internal
224 # endpoint not exposed in the public GraphQL/REST schema. Verified via
225 # schema enumeration: the only public bot-reviewer mutation is
226 # requestReviewsByLogin, which requires Triage/Write on the repo.
227 # PR authors without write permission cannot trigger via any public API.
228 $scenario = if ($copilotHasReviewed) { 're-request' } else { 'initial add' }
229 throw @"
230 Cannot trigger Copilot via public API in this scenario ( $scenario ):
231 - You are the PR author ( $viewerLogin ) on $Owner / $Repo PR # $PrNumber .
232 - You lack repo Triage/Write permission, so requestReviewsByLogin returns FORBIDDEN.
233 - GitHub's public GraphQL schema has no other bot-reviewer mutation
234 (verified: requestReviews rejects bot node IDs; no REST `` bot_reviewers `` field).
235 - The UI's '🔄 Re-request review' button uses an internal endpoint not in the public API.
236
237 Use one of these workarounds (both reliably drive Copilot to re-review):
238 1. UI: open the PR in a browser → click 🔄 next to 'copilot-pull-request-reviewer'.
239 2. CLI: push a substantive (non-whitespace) commit. The `` synchronize `` event
240 auto-triggers Copilot with no API call and no permission required.
241
242 After triggering by either means, resume the loop with 02-check-review-status.ps1.
243
244 Raw error: $rawMsg
245 "@
246 }
247 throw @"
248 GraphQL requestReviewsByLogin failed with a permission error: $rawMsg
249
250 Most likely causes:
251 * Authenticated user lacks Triage / Write permission on the repo
252 (run `` gh api repos/ $Owner / $Repo --jq .permissions `` to confirm; Read-only
253 collaborators cannot request reviewers).
254 * Copilot Code Review not enabled on the repo / account.
255 "@
256 }
257
258 if ($r.ExitCode -ne 0 ) {
259 throw @"
260 GraphQL requestReviewsByLogin failed: $( $r.Stderr )
261
262 Most likely causes:
263 * Quiet-period after a recent dismissal of Copilot — wait 5-10 min, or push a substantive commit.
264 * Copilot Code Review not enabled on the repo / account.
265 * PR in a state that blocks bot review (draft, conflict, branch protection).
266 "@
267 }
268
269 if ($bodyErrors) {
270 # Non-FORBIDDEN errors[] from a successful exit — surface them directly.
271 $msgs = ($bodyErrors | ForEach-Object { $_ .message }) -join '; '
272 throw "GraphQL requestReviewsByLogin returned errors: $msgs "
273 }
274
275 # ---------- verify copilot_work_started event landed ----------
276
277 $deadline = ( Get-Date ).AddSeconds($VerifySeconds)
278 $afterTs = ''
279 $afterId = 0L
280 $lastErr = ''
281 do {
282 try {
283 $nowEvent = Get-LatestCopilotWorkStartedEvent
284 $lastErr = ''
285 if ($nowEvent.Id -gt $beforeId) {
286 $afterId = $nowEvent.Id
287 $afterTs = $nowEvent.CreatedAt
288 break
289 }
290 } catch {
291 $lastErr = $_ .Exception.Message
292 }
293 if (( Get-Date ) -ge $deadline) { break }
294 $remaining = [ int ]($deadline - ( Get-Date )).TotalSeconds
295 Start-Sleep - Seconds ([ Math ]::Min( 5 , [ Math ]::Max( 1 , $remaining)))
296 } while (( Get-Date ) -lt $deadline)
297
298 if ( -not $afterId) {
299 $errTail = if ($lastErr) { " `n Last events-query error: $lastErr " } else { '' }
300 throw @"
301 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.
302 Latest copilot_work_started event id before trigger: $beforeId
303 HEAD: $headOid$errTail
304
305 Push a substantive commit (auto-assign on synchronize is the most reliable trigger) and retry.
306 "@
307 }
308
309 @ {
310 Status = 'TriggerLanded'
311 PrNumber = $PrNumber
312 HeadOid = $headOid
313 WorkStartedAt = $afterTs
314 Detail = "Triggered via GraphQL requestReviewsByLogin; copilot_work_started at $afterTs ."
315 } | ConvertTo-Json - Compress
316 exit 0