@plainconceptsplatform/workflows 0.5.1 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +105 -88
- package/dist/catalog-installation.d.ts +67 -0
- package/dist/catalog-installation.js +485 -0
- package/dist/catalog-listing.d.ts +13 -0
- package/dist/catalog-listing.js +70 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +273 -0
- package/dist/package-baseline.d.ts +25 -0
- package/dist/package-baseline.js +138 -0
- package/dist/repository-inspection.d.ts +23 -0
- package/dist/repository-inspection.js +113 -0
- package/dist/route-processing.d.ts +4 -0
- package/dist/route-processing.js +80 -0
- package/dist/stack-defaults.d.ts +11 -0
- package/dist/stack-defaults.js +110 -0
- package/dist/tui.d.ts +25 -0
- package/dist/tui.js +271 -0
- package/dist/worker-env.d.ts +46 -0
- package/dist/worker-env.js +179 -0
- package/dist/workflow-catalog.d.ts +27 -0
- package/dist/workflow-catalog.js +57 -0
- package/loops/actions/add-issue-labels/action.yml +22 -2
- package/loops/actions/apply-agent-bundle/apply-bundle.sh +14 -1
- package/loops/actions/audit-close/action.yml +180 -128
- package/loops/actions/classify-route/action.yml +7 -0
- package/loops/actions/classify-route/classify-route.sh +28 -7
- package/loops/actions/cleanup-artifacts/action.yml +38 -12
- package/loops/actions/housekeeping/action.yml +251 -0
- package/loops/actions/identify-gate-subject/action.yml +3 -1
- package/loops/actions/merge-agent-pr/action.yml +13 -0
- package/loops/actions/remove-issue-labels/action.yml +4 -2
- package/loops/actions/report-workflow-errors/action.yml +385 -0
- package/loops/actions/validate-merge-gate-output/validate-merge-gate-output.sh +13 -1
- package/loops/actions/validate-refine-output/validate-refine-output.sh +18 -2
- package/loops/actions/validate-triage-output/action.yml +1 -1
- package/loops/actions/validate-triage-output/validate-triage-output.sh +9 -5
- package/loops/actions/verify-composite-actions/verify-composite-actions.sh +53 -0
- package/loops/actions/verify-refine-output/verify-refine-output.sh +20 -0
- package/loops/actions/verify-route-matrix/verify-route-matrix.sh +1017 -56
- package/loops/scripts/compile-agent-workflows.mjs +96 -1
- package/loops/scripts/merge-changelog.mjs +76 -0
- package/loops/templates/agentics/actionlint.yaml +13 -0
- package/loops/templates/agentics/agentics-checks.yml +203 -86
- package/loops/templates/agentics/agentics-error-report.yml +97 -0
- package/loops/templates/ci/app-ci-dotnet-next.yml +64 -2
- package/loops/templates/ci/app-ci-node-monorepo.yml +52 -0
- package/loops/templates/opencode/opencode.ci.json +3 -3
- package/loops/templates/opencode/opencode.ci.json.md +4 -1
- package/loops/workflows/agent-apply-review.md +452 -465
- package/loops/workflows/agent-audit.md +201 -213
- package/loops/workflows/agent-implement.md +616 -538
- package/loops/workflows/agent-merge-gate.md +830 -730
- package/loops/workflows/agent-refine.md +599 -609
- package/loops/workflows/agent-release.md +244 -258
- package/loops/workflows/agent-triage.md +476 -447
- package/loops/workflows/authorize-bot-work.yml +26 -6
- package/loops/workflows/work-router.yml +1185 -862
- package/package.json +9 -8
- package/loops/actions/stale-recovery/action.yml +0 -288
- package/loops/actions/update-changelog/action.yml +0 -113
|
@@ -1,862 +1,1185 @@
|
|
|
1
|
-
# Managed by @plainconceptsplatform/workflows. Source: loops/workflows/work-router.yml. Update with `workflows update --force`; consumer edits may be overwritten.
|
|
2
|
-
name: "All Work Router"
|
|
3
|
-
|
|
4
|
-
# Owns every repository automation trigger. Classifies each event into exactly one route,
|
|
5
|
-
# then calls at most one worker or runs one deterministic job. No model runs here.
|
|
6
|
-
|
|
7
|
-
# GitHub titles an issue-triggered run with the issue title, so every run on one issue reads
|
|
8
|
-
# the same. Name it after the event instead. This is evaluated before any job, so it can
|
|
9
|
-
# describe the trigger but not the route the classifier picks.
|
|
10
|
-
run-name: >-
|
|
11
|
-
${{ github.event_name == 'issues'
|
|
12
|
-
&& github.event.action == 'opened'
|
|
13
|
-
&& format('Issue opened: {0} (#{1}) by {2}', github.event.issue.title, github.event.issue.number, github.actor)
|
|
14
|
-
|| github.event_name == 'issues'
|
|
15
|
-
&& github.event.label.name == 'bot-working'
|
|
16
|
-
&& contains(github.event.issue.labels.*.name, 'implement')
|
|
17
|
-
&& format('Working (Implement): {0} (#{1})', github.event.issue.title, github.event.issue.number)
|
|
18
|
-
|| github.event_name == 'issues'
|
|
19
|
-
&& github.event.label.name == 'bot-working'
|
|
20
|
-
&& contains(github.event.issue.labels.*.name, 'refine')
|
|
21
|
-
&& format('Working (Refine): {0} (#{1})', github.event.issue.title, github.event.issue.number)
|
|
22
|
-
|| github.event_name == 'issues'
|
|
23
|
-
&& github.event.label.name == 'bot-working'
|
|
24
|
-
&& contains(github.event.issue.labels.*.name, 'direct')
|
|
25
|
-
&& format('Working (Direct): {0} (#{1})', github.event.issue.title, github.event.issue.number)
|
|
26
|
-
|| github.event_name == 'issues'
|
|
27
|
-
&& github.event.label.name == 'bot-working'
|
|
28
|
-
&& contains(github.event.issue.labels.*.name, 'triage')
|
|
29
|
-
&& format('Working (Triage): {0} (#{1})', github.event.issue.title, github.event.issue.number)
|
|
30
|
-
|| github.event_name == 'issues'
|
|
31
|
-
&& github.event.label.name == 'bot-working'
|
|
32
|
-
&& format('Working: {0} (#{1})', github.event.issue.title, github.event.issue.number)
|
|
33
|
-
|| github.event_name == 'issues'
|
|
34
|
-
&& github.event.label.name == 'implement'
|
|
35
|
-
&& endsWith(github.actor, '[bot]')
|
|
36
|
-
&& format('Working (Implement): {0} (#{1})', github.event.issue.title, github.event.issue.number)
|
|
37
|
-
|| github.event_name == 'issues'
|
|
38
|
-
&& github.event.label.name == 'refine'
|
|
39
|
-
&& endsWith(github.actor, '[bot]')
|
|
40
|
-
&& format('Working (Refine): {0} (#{1})', github.event.issue.title, github.event.issue.number)
|
|
41
|
-
|| github.event_name == 'issues'
|
|
42
|
-
&& github.event.label.name == 'direct'
|
|
43
|
-
&& endsWith(github.actor, '[bot]')
|
|
44
|
-
&& format('Working (Direct): {0} (#{1})', github.event.issue.title, github.event.issue.number)
|
|
45
|
-
|| github.event_name == 'issues'
|
|
46
|
-
&& format('{0} label on #{1} by {2}', github.event.label.name, github.event.issue.number, github.actor)
|
|
47
|
-
|| github.event_name == 'issue_comment'
|
|
48
|
-
&& format('comment on #{0} by {1}', github.event.issue.number, github.actor)
|
|
49
|
-
|| github.event_name == 'pull_request_review'
|
|
50
|
-
&& format('review on PR #{0} by {1}', github.event.pull_request.number, github.actor)
|
|
51
|
-
|| github.event_name == 'pull_request_review_comment'
|
|
52
|
-
&& format('review comment on PR #{0} by {1}', github.event.pull_request.number, github.actor)
|
|
53
|
-
|| github.event_name == 'pull_request_target'
|
|
54
|
-
&& format('PR #{0} {1} by {2}', github.event.pull_request.number, github.event.action, github.actor)
|
|
55
|
-
|| github.event_name == 'workflow_run'
|
|
56
|
-
&& format('CI {0} on {1}', github.event.workflow_run.conclusion, github.event.workflow_run.head_branch)
|
|
57
|
-
|| github.event_name == 'workflow_dispatch'
|
|
58
|
-
&& inputs.operation == 'triage'
|
|
59
|
-
&& format('Working (Triage): #{0}', inputs.issue-number)
|
|
60
|
-
|| github.event_name == 'workflow_dispatch'
|
|
61
|
-
&& inputs.operation == 'refine'
|
|
62
|
-
&& format('Working (Refine): #{0}', inputs.issue-number)
|
|
63
|
-
|| github.event_name == 'workflow_dispatch'
|
|
64
|
-
&& inputs.operation == 'implement'
|
|
65
|
-
&& format('Working (Implement): #{0}', inputs.issue-number)
|
|
66
|
-
|| github.event_name == 'workflow_dispatch'
|
|
67
|
-
&& inputs.operation == 'merge-gate'
|
|
68
|
-
&& format('Working (Merge Gate): PR #{0}', inputs.pr-number)
|
|
69
|
-
|| github.event_name == 'workflow_dispatch'
|
|
70
|
-
&& inputs.operation == 'batch'
|
|
71
|
-
&& format('Working (Batch): #{0}', inputs.issue-number)
|
|
72
|
-
|| github.event_name == 'workflow_dispatch'
|
|
73
|
-
&& format('dispatch: {0}', inputs.operation)
|
|
74
|
-
|| github.event_name == 'schedule'
|
|
75
|
-
&& format('schedule {0}', github.event.schedule)
|
|
76
|
-
|| github.event_name }}
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
#
|
|
95
|
-
#
|
|
96
|
-
|
|
97
|
-
#
|
|
98
|
-
#
|
|
99
|
-
#
|
|
100
|
-
#
|
|
101
|
-
|
|
102
|
-
# may
|
|
103
|
-
#
|
|
104
|
-
#
|
|
105
|
-
#
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
description: "
|
|
170
|
-
required:
|
|
171
|
-
type: choice
|
|
172
|
-
options:
|
|
173
|
-
-
|
|
174
|
-
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
-
|
|
181
|
-
-
|
|
182
|
-
-
|
|
183
|
-
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
#
|
|
564
|
-
#
|
|
565
|
-
#
|
|
566
|
-
#
|
|
567
|
-
#
|
|
568
|
-
#
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
- name:
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
#
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
'
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
1
|
+
# Managed by @plainconceptsplatform/workflows. Source: loops/workflows/work-router.yml. Update with `workflows update --force`; consumer edits may be overwritten.
|
|
2
|
+
name: "All Work Router"
|
|
3
|
+
|
|
4
|
+
# Owns every repository automation trigger. Classifies each event into exactly one route,
|
|
5
|
+
# then calls at most one worker or runs one deterministic job. No model runs here.
|
|
6
|
+
|
|
7
|
+
# GitHub titles an issue-triggered run with the issue title, so every run on one issue reads
|
|
8
|
+
# the same. Name it after the event instead. This is evaluated before any job, so it can
|
|
9
|
+
# describe the trigger but not the route the classifier picks.
|
|
10
|
+
run-name: >-
|
|
11
|
+
${{ github.event_name == 'issues'
|
|
12
|
+
&& github.event.action == 'opened'
|
|
13
|
+
&& format('Issue opened: {0} (#{1}) by {2}', github.event.issue.title, github.event.issue.number, github.actor)
|
|
14
|
+
|| github.event_name == 'issues'
|
|
15
|
+
&& github.event.label.name == 'bot-working'
|
|
16
|
+
&& contains(github.event.issue.labels.*.name, 'implement')
|
|
17
|
+
&& format('Working (Implement): {0} (#{1})', github.event.issue.title, github.event.issue.number)
|
|
18
|
+
|| github.event_name == 'issues'
|
|
19
|
+
&& github.event.label.name == 'bot-working'
|
|
20
|
+
&& contains(github.event.issue.labels.*.name, 'refine')
|
|
21
|
+
&& format('Working (Refine): {0} (#{1})', github.event.issue.title, github.event.issue.number)
|
|
22
|
+
|| github.event_name == 'issues'
|
|
23
|
+
&& github.event.label.name == 'bot-working'
|
|
24
|
+
&& contains(github.event.issue.labels.*.name, 'direct')
|
|
25
|
+
&& format('Working (Direct): {0} (#{1})', github.event.issue.title, github.event.issue.number)
|
|
26
|
+
|| github.event_name == 'issues'
|
|
27
|
+
&& github.event.label.name == 'bot-working'
|
|
28
|
+
&& contains(github.event.issue.labels.*.name, 'triage')
|
|
29
|
+
&& format('Working (Triage): {0} (#{1})', github.event.issue.title, github.event.issue.number)
|
|
30
|
+
|| github.event_name == 'issues'
|
|
31
|
+
&& github.event.label.name == 'bot-working'
|
|
32
|
+
&& format('Working: {0} (#{1})', github.event.issue.title, github.event.issue.number)
|
|
33
|
+
|| github.event_name == 'issues'
|
|
34
|
+
&& github.event.label.name == 'implement'
|
|
35
|
+
&& endsWith(github.actor, '[bot]')
|
|
36
|
+
&& format('Working (Implement): {0} (#{1})', github.event.issue.title, github.event.issue.number)
|
|
37
|
+
|| github.event_name == 'issues'
|
|
38
|
+
&& github.event.label.name == 'refine'
|
|
39
|
+
&& endsWith(github.actor, '[bot]')
|
|
40
|
+
&& format('Working (Refine): {0} (#{1})', github.event.issue.title, github.event.issue.number)
|
|
41
|
+
|| github.event_name == 'issues'
|
|
42
|
+
&& github.event.label.name == 'direct'
|
|
43
|
+
&& endsWith(github.actor, '[bot]')
|
|
44
|
+
&& format('Working (Direct): {0} (#{1})', github.event.issue.title, github.event.issue.number)
|
|
45
|
+
|| github.event_name == 'issues'
|
|
46
|
+
&& format('{0} label on #{1} by {2}', github.event.label.name, github.event.issue.number, github.actor)
|
|
47
|
+
|| github.event_name == 'issue_comment'
|
|
48
|
+
&& format('comment on #{0} by {1}', github.event.issue.number, github.actor)
|
|
49
|
+
|| github.event_name == 'pull_request_review'
|
|
50
|
+
&& format('review on PR #{0} by {1}', github.event.pull_request.number, github.actor)
|
|
51
|
+
|| github.event_name == 'pull_request_review_comment'
|
|
52
|
+
&& format('review comment on PR #{0} by {1}', github.event.pull_request.number, github.actor)
|
|
53
|
+
|| github.event_name == 'pull_request_target'
|
|
54
|
+
&& format('PR #{0} {1} by {2}', github.event.pull_request.number, github.event.action, github.actor)
|
|
55
|
+
|| github.event_name == 'workflow_run'
|
|
56
|
+
&& format('CI {0} on {1}', github.event.workflow_run.conclusion, github.event.workflow_run.head_branch)
|
|
57
|
+
|| github.event_name == 'workflow_dispatch'
|
|
58
|
+
&& inputs.operation == 'triage'
|
|
59
|
+
&& format('Working (Triage): #{0}', inputs.issue-number)
|
|
60
|
+
|| github.event_name == 'workflow_dispatch'
|
|
61
|
+
&& inputs.operation == 'refine'
|
|
62
|
+
&& format('Working (Refine): #{0}', inputs.issue-number)
|
|
63
|
+
|| github.event_name == 'workflow_dispatch'
|
|
64
|
+
&& inputs.operation == 'implement'
|
|
65
|
+
&& format('Working (Implement): #{0}', inputs.issue-number)
|
|
66
|
+
|| github.event_name == 'workflow_dispatch'
|
|
67
|
+
&& inputs.operation == 'merge-gate'
|
|
68
|
+
&& format('Working (Merge Gate): PR #{0}', inputs.pr-number)
|
|
69
|
+
|| github.event_name == 'workflow_dispatch'
|
|
70
|
+
&& inputs.operation == 'batch'
|
|
71
|
+
&& format('Working (Batch): #{0}', inputs.issue-number)
|
|
72
|
+
|| github.event_name == 'workflow_dispatch'
|
|
73
|
+
&& format('dispatch: {0}', inputs.operation)
|
|
74
|
+
|| github.event_name == 'schedule'
|
|
75
|
+
&& format('schedule {0}', github.event.schedule)
|
|
76
|
+
|| github.event_name }}
|
|
77
|
+
|
|
78
|
+
# The two values a repository is expected to change, and the only part of this file that is
|
|
79
|
+
# not the package's. `workflows update` keeps whatever is here and puts the package's router
|
|
80
|
+
# around it, so change them here and nowhere else.
|
|
81
|
+
#
|
|
82
|
+
# Neither can be read where it is also needed: GitHub evaluates no expression in a
|
|
83
|
+
# `workflow_run.workflows:` list or in a `cron:`. The installer therefore mirrors both values
|
|
84
|
+
# into those two literal lines below, and the route matrix asserts the copies still agree.
|
|
85
|
+
# Everything that runs inside a job reads them from here, including the classifier, which
|
|
86
|
+
# takes AUDIT_CRON from this environment.
|
|
87
|
+
env:
|
|
88
|
+
# The CI workflow the merge belt reads its verdict from. A repository whose CI has another
|
|
89
|
+
# name gets no gate at all: the belt logs "no completed CI run" and the pull request waits
|
|
90
|
+
# forever, which is a silent failure rather than a red run.
|
|
91
|
+
CI_WORKFLOW_NAME: "App: CI"
|
|
92
|
+
# Slot 0. Every consumer installs this same router, and the agent fleet is one shared,
|
|
93
|
+
# two-VM scale set, so a second repository left on slot 0 audits at the same minute as the
|
|
94
|
+
# first and the two of them consume the whole fleet. Pick a free slot per repository: see
|
|
95
|
+
# "Scheduling across repositories" in the package README.
|
|
96
|
+
AUDIT_CRON: "17 1 * * 1"
|
|
97
|
+
# The bot identities whose label changes and pull requests this router trusts. Space
|
|
98
|
+
# separated, and every login the same account can present: GitHub reports an App as
|
|
99
|
+
# `name[bot]` on some endpoints and `app/name` on others, so both spellings belong here.
|
|
100
|
+
# Adding a third bot used to mean finding four hand-written comparisons across this file.
|
|
101
|
+
TRUSTED_BOTS: "github-actions[bot] app/github-actions platform-devbox[bot]"
|
|
102
|
+
# How long an issue may sit parked with `stalled` before the janitor retries it, and how many
|
|
103
|
+
# times it tries before leaving the issue to a person. Only machine failures are retried; a
|
|
104
|
+
# verdict the agent reached on purpose is reported and never re-run, because re-running a
|
|
105
|
+
# decision reproduces it.
|
|
106
|
+
HOUSEKEEPING_RETRY_AFTER_HOURS: "6"
|
|
107
|
+
HOUSEKEEPING_MAX_RETRIES: "3"
|
|
108
|
+
# A bot pull request older than this with no merge-gate verdict at all is reported, never
|
|
109
|
+
# closed: the janitor deletes branches and labels, never someone's work.
|
|
110
|
+
HOUSEKEEPING_STALE_PR_DAYS: "3"
|
|
111
|
+
# The single issue that lists what genuinely needs a person. Set it empty to turn the digest
|
|
112
|
+
# off and keep the rest of the janitor.
|
|
113
|
+
HOUSEKEEPING_DIGEST_TITLE: "Needs a human"
|
|
114
|
+
# How long an audit report may sit open before it is labelled `stale-audit`. The audit's own
|
|
115
|
+
# backpressure ignores a stale report, so three abandoned reports can no longer disable the
|
|
116
|
+
# weekly audit for good; the digest lists them instead. Two missed weekly cycles.
|
|
117
|
+
AUDIT_STALE_AFTER_DAYS: "14"
|
|
118
|
+
# How many failed merge-gate attempts one pull request head may consume before the belt
|
|
119
|
+
# parks it for a person. It was written as the literal 6 in four places here while the
|
|
120
|
+
# merge-gate worker carried its own MAX_ATTEMPTS; the route matrix now asserts the two
|
|
121
|
+
# agree, because a belt that gives up later than the worker says burns whole runs.
|
|
122
|
+
MAX_GATE_ATTEMPTS: "6"
|
|
123
|
+
|
|
124
|
+
on:
|
|
125
|
+
issues:
|
|
126
|
+
types: [opened, labeled]
|
|
127
|
+
issue_comment:
|
|
128
|
+
types: [created]
|
|
129
|
+
pull_request_review_comment:
|
|
130
|
+
types: [created]
|
|
131
|
+
pull_request_review:
|
|
132
|
+
types: [submitted]
|
|
133
|
+
|
|
134
|
+
pull_request_target:
|
|
135
|
+
# opened / reopened / synchronize feed conflict detection (detect-pr-conflicts) and
|
|
136
|
+
# bot-approve. closed feeds detect-pr-conflicts: when a PR merges, the target branch
|
|
137
|
+
# moved and previously-mergeable sibling bot PRs may now conflict.
|
|
138
|
+
types: [opened, synchronize, reopened, labeled, closed]
|
|
139
|
+
|
|
140
|
+
# CI completion on a pull request. The classifier admits only conclusion == failure with an
|
|
141
|
+
# attached pull request; a green run reaches the gate through the consumer CI's
|
|
142
|
+
# dispatch-merge-gate job and the reconcile belt, so routing success here would double-fire
|
|
143
|
+
# the gate on every passing PR.
|
|
144
|
+
#
|
|
145
|
+
# Delivery (verified 2026-09-06 in Pliny-Bot): GitHub delivers workflow_run only for CI runs
|
|
146
|
+
# whose actor is a human. A bot pull request is opened with GITHUB_TOKEN, the CI runs that
|
|
147
|
+
# follow have the bot as their actor, and no workflow_run arrives for any of them (four
|
|
148
|
+
# human-actor runs produced four router runs; three bot-actor runs produced none). For bot
|
|
149
|
+
# pull requests the consumer CI's dispatch-merge-gate job (templates/ci) and the hourly
|
|
150
|
+
# reconcile-bot-pr-runs belt are the paths. Security: workflow_run runs from the default
|
|
151
|
+
# branch with secrets, but the gate's subject job rejects any PR that is not open and
|
|
152
|
+
# bot-authored, so a fork PR's failing CI cannot start privileged work.
|
|
153
|
+
workflow_run:
|
|
154
|
+
# Mirrored from env.CI_WORKFLOW_NAME by the installer. A trigger evaluates no expression.
|
|
155
|
+
workflows: ["App: CI"]
|
|
156
|
+
types: [completed]
|
|
157
|
+
|
|
158
|
+
# Cron to route mapping lives in .github/actions/classify-route/classify-route.sh.
|
|
159
|
+
schedule:
|
|
160
|
+
- cron: "17 1 * * 1" # audit slot, mirrored from env.AUDIT_CRON by the installer
|
|
161
|
+
- cron: "43 3 * * *"
|
|
162
|
+
- cron: "0 6 * * *"
|
|
163
|
+
- cron: "17 * * * *"
|
|
164
|
+
- cron: "23 */6 * * *" # housekeeping: retry what stalled, tidy what it stranded
|
|
165
|
+
|
|
166
|
+
workflow_dispatch:
|
|
167
|
+
inputs:
|
|
168
|
+
operation:
|
|
169
|
+
description: "Operation to run"
|
|
170
|
+
required: true
|
|
171
|
+
type: choice
|
|
172
|
+
options:
|
|
173
|
+
- refine
|
|
174
|
+
- implement
|
|
175
|
+
- triage
|
|
176
|
+
- apply-review
|
|
177
|
+
- merge-gate
|
|
178
|
+
- audit
|
|
179
|
+
- audit-close
|
|
180
|
+
- cleanup-artifacts
|
|
181
|
+
- reconcile-bot-pr-runs
|
|
182
|
+
- housekeeping
|
|
183
|
+
- validate
|
|
184
|
+
- release
|
|
185
|
+
issue-number:
|
|
186
|
+
description: "Issue number (required for refine / implement / direct)"
|
|
187
|
+
required: false
|
|
188
|
+
type: string
|
|
189
|
+
pr-number:
|
|
190
|
+
description: "Pull request number (required for apply-review / merge-gate)"
|
|
191
|
+
required: false
|
|
192
|
+
type: string
|
|
193
|
+
mode:
|
|
194
|
+
description: "Refine mode: first or rerefine"
|
|
195
|
+
required: false
|
|
196
|
+
type: choice
|
|
197
|
+
options:
|
|
198
|
+
- first
|
|
199
|
+
- rerefine
|
|
200
|
+
ci-conclusion:
|
|
201
|
+
description: "CI conclusion for merge-gate dispatch"
|
|
202
|
+
required: false
|
|
203
|
+
type: choice
|
|
204
|
+
options:
|
|
205
|
+
- success
|
|
206
|
+
- failure
|
|
207
|
+
- action_required
|
|
208
|
+
ci-run-id:
|
|
209
|
+
description: "CI workflow run ID for merge-gate dispatch"
|
|
210
|
+
required: false
|
|
211
|
+
type: string
|
|
212
|
+
attempts_so_far:
|
|
213
|
+
description: "Failed merge-gate attempts already made against this CI verdict (filled by the router, not by humans)"
|
|
214
|
+
required: false
|
|
215
|
+
type: string
|
|
216
|
+
default: '0'
|
|
217
|
+
trigger-kind:
|
|
218
|
+
description: "Audit trigger: scheduled or manual"
|
|
219
|
+
required: false
|
|
220
|
+
type: choice
|
|
221
|
+
options:
|
|
222
|
+
- manual
|
|
223
|
+
- scheduled
|
|
224
|
+
dry-run:
|
|
225
|
+
description: "Housekeeping only: print every intended change and write nothing"
|
|
226
|
+
required: false
|
|
227
|
+
type: boolean
|
|
228
|
+
default: false
|
|
229
|
+
version-bump:
|
|
230
|
+
description: "Release version bump: patch, minor, major, or auto"
|
|
231
|
+
required: false
|
|
232
|
+
type: choice
|
|
233
|
+
options:
|
|
234
|
+
- auto
|
|
235
|
+
- patch
|
|
236
|
+
- minor
|
|
237
|
+
- major
|
|
238
|
+
|
|
239
|
+
permissions:
|
|
240
|
+
contents: read
|
|
241
|
+
|
|
242
|
+
jobs:
|
|
243
|
+
# Public repositories must check repository permission before an event can start a model.
|
|
244
|
+
authorize:
|
|
245
|
+
runs-on: ubuntu-latest
|
|
246
|
+
timeout-minutes: 5
|
|
247
|
+
permissions:
|
|
248
|
+
contents: read
|
|
249
|
+
outputs:
|
|
250
|
+
trusted: ${{ steps.check.outputs.trusted }}
|
|
251
|
+
is_outside_collaborator: ${{ steps.check.outputs.is_outside_collaborator }}
|
|
252
|
+
steps:
|
|
253
|
+
- name: Check the actor can start an agent
|
|
254
|
+
id: check
|
|
255
|
+
env:
|
|
256
|
+
GH_TOKEN: ${{ github.token }}
|
|
257
|
+
ACTOR: ${{ github.actor }}
|
|
258
|
+
EVENT: ${{ github.event_name }}
|
|
259
|
+
# Who GitHub says this person is relative to the repository, straight from the
|
|
260
|
+
# payload. OWNER and MEMBER are the organisation's own; COLLABORATOR is repository
|
|
261
|
+
# access without organisation membership, which is what triage is for. Read from
|
|
262
|
+
# here because a repository-scoped GITHUB_TOKEN cannot query org membership.
|
|
263
|
+
AUTHOR_ASSOCIATION: ${{ github.event.issue.author_association || github.event.comment.author_association || github.event.pull_request.author_association }}
|
|
264
|
+
run: |
|
|
265
|
+
set -euo pipefail
|
|
266
|
+
|
|
267
|
+
# Events with no human actor. `schedule` and `workflow_run` are the fleet triggering
|
|
268
|
+
# itself; `workflow_dispatch` is already restricted by GitHub to write and above.
|
|
269
|
+
case "$EVENT" in
|
|
270
|
+
schedule | workflow_run | workflow_dispatch)
|
|
271
|
+
echo "trusted=true" >> "$GITHUB_OUTPUT"
|
|
272
|
+
echo "is_outside_collaborator=false" >> "$GITHUB_OUTPUT"
|
|
273
|
+
echo "::notice::$EVENT has no untrusted actor."
|
|
274
|
+
exit 0
|
|
275
|
+
;;
|
|
276
|
+
esac
|
|
277
|
+
|
|
278
|
+
# Platform bot owns Safe Outputs. Its label changes must be able to
|
|
279
|
+
# start the follow-on worker, even though GitHub does not report it
|
|
280
|
+
# as a repository collaborator.
|
|
281
|
+
for bot in $TRUSTED_BOTS; do
|
|
282
|
+
if [ "$ACTOR" = "$bot" ]; then
|
|
283
|
+
echo "trusted=true" >> "$GITHUB_OUTPUT"
|
|
284
|
+
echo "is_outside_collaborator=false" >> "$GITHUB_OUTPUT"
|
|
285
|
+
exit 0
|
|
286
|
+
fi
|
|
287
|
+
done
|
|
288
|
+
|
|
289
|
+
permission=$(gh api "repos/$GITHUB_REPOSITORY/collaborators/$ACTOR/permission" \
|
|
290
|
+
--jq '.permission' 2>/dev/null || echo "none")
|
|
291
|
+
|
|
292
|
+
# Is this person one of the organisation's own, or somebody with repository access
|
|
293
|
+
# from outside it? Triage exists for the second kind.
|
|
294
|
+
#
|
|
295
|
+
# The payload answers it. The org API cannot: `orgs/{org}/members/{user}` needs an
|
|
296
|
+
# org-scoped token and GITHUB_TOKEN is scoped to this repository, so that call failed
|
|
297
|
+
# for everybody and every admin who opened an issue was declared an outside
|
|
298
|
+
# collaborator and triaged. It is kept only as a fallback for events that carry no
|
|
299
|
+
# association, and it can no longer decide "outside" on its own.
|
|
300
|
+
org=$(echo "$GITHUB_REPOSITORY" | cut -d/ -f1)
|
|
301
|
+
case "${AUTHOR_ASSOCIATION:-}" in
|
|
302
|
+
OWNER | MEMBER)
|
|
303
|
+
is_member=true
|
|
304
|
+
;;
|
|
305
|
+
COLLABORATOR)
|
|
306
|
+
is_member=false
|
|
307
|
+
;;
|
|
308
|
+
*)
|
|
309
|
+
# No association in this payload. Ask the org, and believe a yes rather than a
|
|
310
|
+
# no: a failed call here is indistinguishable from a real non-member.
|
|
311
|
+
if gh api "orgs/$org/members/$ACTOR" >/dev/null 2>&1; then
|
|
312
|
+
is_member=true
|
|
313
|
+
else
|
|
314
|
+
is_member=unknown
|
|
315
|
+
fi
|
|
316
|
+
;;
|
|
317
|
+
esac
|
|
318
|
+
|
|
319
|
+
case "$permission" in
|
|
320
|
+
admin | maintain | write)
|
|
321
|
+
# "unknown" counts as inside. Somebody with write or better whose membership
|
|
322
|
+
# cannot be established is far more likely to be a colleague than an outside
|
|
323
|
+
# collaborator, and treating them as outside starts a triage agent nobody asked
|
|
324
|
+
# for — which, with a four-hour timeout, holds a runner for the afternoon.
|
|
325
|
+
if [ "$is_member" != "false" ]; then
|
|
326
|
+
echo "trusted=true" >> "$GITHUB_OUTPUT"
|
|
327
|
+
echo "is_outside_collaborator=false" >> "$GITHUB_OUTPUT"
|
|
328
|
+
echo "::notice::$ACTOR has $permission permission and is an org member."
|
|
329
|
+
else
|
|
330
|
+
echo "trusted=true" >> "$GITHUB_OUTPUT"
|
|
331
|
+
echo "is_outside_collaborator=true" >> "$GITHUB_OUTPUT"
|
|
332
|
+
echo "::notice::$ACTOR has $permission permission but is NOT an org member (outside collaborator). Triage route eligible."
|
|
333
|
+
fi
|
|
334
|
+
;;
|
|
335
|
+
read)
|
|
336
|
+
echo "trusted=false" >> "$GITHUB_OUTPUT"
|
|
337
|
+
echo "is_outside_collaborator=true" >> "$GITHUB_OUTPUT"
|
|
338
|
+
echo "::notice::$ACTOR is an outside collaborator (read permission). Triage route eligible."
|
|
339
|
+
;;
|
|
340
|
+
*)
|
|
341
|
+
echo "trusted=false" >> "$GITHUB_OUTPUT"
|
|
342
|
+
echo "is_outside_collaborator=false" >> "$GITHUB_OUTPUT"
|
|
343
|
+
echo "::notice::$ACTOR has '$permission' permission on this repository. No agent will run."
|
|
344
|
+
;;
|
|
345
|
+
esac
|
|
346
|
+
|
|
347
|
+
classify:
|
|
348
|
+
runs-on: ubuntu-latest
|
|
349
|
+
timeout-minutes: 5
|
|
350
|
+
permissions:
|
|
351
|
+
contents: read
|
|
352
|
+
pull-requests: read
|
|
353
|
+
outputs:
|
|
354
|
+
route: ${{ steps.route.outputs.route }}
|
|
355
|
+
issue-number: ${{ steps.route.outputs.issue-number }}
|
|
356
|
+
pr-number: ${{ steps.route.outputs.pr-number }}
|
|
357
|
+
linked-issue: ${{ steps.route.outputs.linked-issue }}
|
|
358
|
+
ci-conclusion: ${{ steps.route.outputs.ci-conclusion }}
|
|
359
|
+
ci-run-id: ${{ steps.route.outputs.ci-run-id }}
|
|
360
|
+
merge-gate-attempts: ${{ steps.route.outputs.merge-gate-attempts }}
|
|
361
|
+
implement-attempts: ${{ steps.route.outputs.implement-attempts }}
|
|
362
|
+
refine-mode: ${{ steps.route.outputs.refine-mode }}
|
|
363
|
+
triage-mode: ${{ steps.route.outputs.triage-mode }}
|
|
364
|
+
trigger-kind: ${{ steps.route.outputs.trigger-kind }}
|
|
365
|
+
steps:
|
|
366
|
+
- name: Checkout workflow actions
|
|
367
|
+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
368
|
+
with:
|
|
369
|
+
persist-credentials: false
|
|
370
|
+
- name: Classify event into a single route
|
|
371
|
+
id: route
|
|
372
|
+
uses: ./.github/actions/classify-route
|
|
373
|
+
with:
|
|
374
|
+
token: ${{ github.token }}
|
|
375
|
+
|
|
376
|
+
call-refine:
|
|
377
|
+
needs: [classify, authorize]
|
|
378
|
+
if: needs.classify.outputs.route == 'refine' && needs.authorize.outputs.trusted == 'true'
|
|
379
|
+
uses: ./.github/workflows/agent-refine.lock.yml
|
|
380
|
+
concurrency:
|
|
381
|
+
group: refine-${{ needs.classify.outputs.issue-number }}
|
|
382
|
+
cancel-in-progress: false
|
|
383
|
+
# write-all, not a narrower map: every worker's agent job declares `permissions: read-all`,
|
|
384
|
+
# which requests read on every scope. A caller granting less cannot satisfy it, and GitHub
|
|
385
|
+
# rejects the call with a startup failure before any job is created. The real boundary is the
|
|
386
|
+
# worker's own read-all plus safe-outputs, not this grant.
|
|
387
|
+
permissions: write-all
|
|
388
|
+
with:
|
|
389
|
+
issue-number: ${{ needs.classify.outputs.issue-number }}
|
|
390
|
+
mode: ${{ needs.classify.outputs.refine-mode }}
|
|
391
|
+
secrets:
|
|
392
|
+
OPENAI_API_KEY: ${{ secrets.FORGE_API_KEY }}
|
|
393
|
+
CODEX_API_KEY: ${{ secrets.FORGE_API_KEY }}
|
|
394
|
+
COPILOT_GITHUB_TOKEN: ${{ secrets.FORGE_API_KEY }}
|
|
395
|
+
BOT_APP_ID: ${{ secrets.BOT_APP_ID }}
|
|
396
|
+
BOT_PRIVATE_KEY: ${{ secrets.BOT_PRIVATE_KEY }}
|
|
397
|
+
AGENTMEMORY_SECRET: ${{ secrets.AGENTMEMORY_SECRET }}
|
|
398
|
+
|
|
399
|
+
# Checks whether an open bot PR already exists for the issue. If it does, implement is
|
|
400
|
+
# skipped — the merge-gate will handle fixing the existing PR. This prevents duplicate
|
|
401
|
+
# PRs when retries are triggered after a merge-gate failure.
|
|
402
|
+
check-implement-pr:
|
|
403
|
+
needs: [classify, authorize]
|
|
404
|
+
if: needs.classify.outputs.route == 'implement' && needs.authorize.outputs.trusted == 'true'
|
|
405
|
+
runs-on: ubuntu-latest
|
|
406
|
+
timeout-minutes: 3
|
|
407
|
+
permissions:
|
|
408
|
+
contents: read
|
|
409
|
+
pull-requests: read
|
|
410
|
+
outputs:
|
|
411
|
+
has-open-pr: ${{ steps.check.outputs.has-open-pr }}
|
|
412
|
+
steps:
|
|
413
|
+
- name: Check for existing open bot PR
|
|
414
|
+
id: check
|
|
415
|
+
env:
|
|
416
|
+
GH_TOKEN: ${{ github.token }}
|
|
417
|
+
REPO: ${{ github.repository }}
|
|
418
|
+
ISSUE_NUMBER: ${{ needs.classify.outputs.issue-number }}
|
|
419
|
+
run: |
|
|
420
|
+
set -euo pipefail
|
|
421
|
+
# A refine-to-implement transition emits two label events (the implement label,
|
|
422
|
+
# then the worker re-adding bot-working) and each one classifies to implement.
|
|
423
|
+
# The older router run wins: a younger run that finds an older live implement
|
|
424
|
+
# route for the same issue skips instead of queueing a duplicate worker. Only
|
|
425
|
+
# bot-actor titles are counted, so a human's "waiting for bot-working" run can
|
|
426
|
+
# never block the real one.
|
|
427
|
+
for st in in_progress queued; do
|
|
428
|
+
dupes=$(gh api "repos/$REPO/actions/workflows/work-router.yml/runs?status=$st&per_page=100" --jq "[.workflow_runs[]
|
|
429
|
+
| select(.id < $GITHUB_RUN_ID)
|
|
430
|
+
| select(
|
|
431
|
+
((.display_title | startswith(\"Working (Implement)\")) and (.display_title | contains(\"(#${ISSUE_NUMBER})\")))
|
|
432
|
+
or
|
|
433
|
+
((.display_title | contains(\"implement label on #${ISSUE_NUMBER} by\")) and (.display_title | endswith(\"[bot]\")))
|
|
434
|
+
)
|
|
435
|
+
] | length" 2>/dev/null || echo 0)
|
|
436
|
+
if [ "${dupes:-0}" -gt 0 ]; then
|
|
437
|
+
echo "has-open-pr=true" >> "$GITHUB_OUTPUT"
|
|
438
|
+
echo "::notice::An older router run is already implementing #$ISSUE_NUMBER; skipping the duplicate."
|
|
439
|
+
exit 0
|
|
440
|
+
fi
|
|
441
|
+
done
|
|
442
|
+
|
|
443
|
+
# Marker-first: implement stamps <!-- implement-pr: N --> on the issue when its
|
|
444
|
+
# pull request is created, so the duplicate check is an exact lookup. Only an
|
|
445
|
+
# OPEN marker PR blocks; a merged or closed one means a re-implement is legitimate.
|
|
446
|
+
marker_pr=$(gh issue view "$ISSUE_NUMBER" --repo "$REPO" --json body --jq '.body // ""' \
|
|
447
|
+
| grep -oE '<!-- implement-pr: [0-9]+ -->' | head -1 | grep -oE '[0-9]+' || true)
|
|
448
|
+
if [ -n "$marker_pr" ]; then
|
|
449
|
+
state=$(gh pr view "$marker_pr" --repo "$REPO" --json state --jq '.state' 2>/dev/null || echo GONE)
|
|
450
|
+
if [ "$state" = "OPEN" ]; then
|
|
451
|
+
echo "has-open-pr=true" >> "$GITHUB_OUTPUT"
|
|
452
|
+
echo "::notice::Issue #$ISSUE_NUMBER is marked as implemented by open PR #$marker_pr. Skipping duplicate implement."
|
|
453
|
+
exit 0
|
|
454
|
+
fi
|
|
455
|
+
echo "Marker PR #$marker_pr is $state; a fresh implement is allowed."
|
|
456
|
+
fi
|
|
457
|
+
|
|
458
|
+
existing=$(gh pr list --repo "$REPO" --state open --json number,author,body \
|
|
459
|
+
--jq "[.[]
|
|
460
|
+
| select(.author.login | startswith(\"app/\") or endswith(\"[bot]\"))
|
|
461
|
+
| (.body | ascii_downcase) as \$body
|
|
462
|
+
| select(\$body | contains(\"close #${ISSUE_NUMBER}\") or contains(\"closes #${ISSUE_NUMBER}\") or contains(\"closed #${ISSUE_NUMBER}\") or contains(\"fix #${ISSUE_NUMBER}\") or contains(\"fixes #${ISSUE_NUMBER}\") or contains(\"fixed #${ISSUE_NUMBER}\") or contains(\"resolve #${ISSUE_NUMBER}\") or contains(\"resolves #${ISSUE_NUMBER}\") or contains(\"resolved #${ISSUE_NUMBER}\"))
|
|
463
|
+
] | length")
|
|
464
|
+
if [ "$existing" -gt 0 ]; then
|
|
465
|
+
echo "has-open-pr=true" >> "$GITHUB_OUTPUT"
|
|
466
|
+
echo "::notice::Issue #$ISSUE_NUMBER already has an open bot PR. Skipping implement to prevent duplicates."
|
|
467
|
+
else
|
|
468
|
+
echo "has-open-pr=false" >> "$GITHUB_OUTPUT"
|
|
469
|
+
fi
|
|
470
|
+
|
|
471
|
+
call-implement:
|
|
472
|
+
needs: [classify, authorize, check-implement-pr]
|
|
473
|
+
if: needs.classify.outputs.route == 'implement' && needs.authorize.outputs.trusted == 'true' && needs.check-implement-pr.outputs.has-open-pr != 'true'
|
|
474
|
+
uses: ./.github/workflows/agent-implement.lock.yml
|
|
475
|
+
concurrency:
|
|
476
|
+
group: implement-global
|
|
477
|
+
cancel-in-progress: false
|
|
478
|
+
# FIFO, up to 100 waiting. Without this the default `queue: single` cancels whatever
|
|
479
|
+
# was pending, so labelling six issues at once implements one and drops the rest.
|
|
480
|
+
queue: max
|
|
481
|
+
permissions: write-all
|
|
482
|
+
with:
|
|
483
|
+
issue-number: ${{ needs.classify.outputs.issue-number }}
|
|
484
|
+
attempts_so_far: ${{ needs.classify.outputs.implement-attempts }}
|
|
485
|
+
secrets:
|
|
486
|
+
OPENAI_API_KEY: ${{ secrets.FORGE_API_KEY }}
|
|
487
|
+
CODEX_API_KEY: ${{ secrets.FORGE_API_KEY }}
|
|
488
|
+
COPILOT_GITHUB_TOKEN: ${{ secrets.FORGE_API_KEY }}
|
|
489
|
+
BOT_APP_ID: ${{ secrets.BOT_APP_ID }}
|
|
490
|
+
BOT_PRIVATE_KEY: ${{ secrets.BOT_PRIVATE_KEY }}
|
|
491
|
+
AGENTMEMORY_SECRET: ${{ secrets.AGENTMEMORY_SECRET }}
|
|
492
|
+
|
|
493
|
+
dispatch-triage:
|
|
494
|
+
needs: [classify, authorize]
|
|
495
|
+
if: needs.classify.outputs.route == 'triage' && needs.authorize.outputs.is_outside_collaborator == 'true'
|
|
496
|
+
runs-on: ubuntu-latest
|
|
497
|
+
timeout-minutes: 5
|
|
498
|
+
permissions:
|
|
499
|
+
actions: write
|
|
500
|
+
contents: read
|
|
501
|
+
steps:
|
|
502
|
+
- name: Create bot token
|
|
503
|
+
id: app-token
|
|
504
|
+
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
|
505
|
+
with:
|
|
506
|
+
client-id: ${{ secrets.BOT_APP_ID }}
|
|
507
|
+
private-key: ${{ secrets.BOT_PRIVATE_KEY }}
|
|
508
|
+
- name: Dispatch trusted triage run
|
|
509
|
+
env:
|
|
510
|
+
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
|
511
|
+
REPO: ${{ github.repository }}
|
|
512
|
+
REF: ${{ github.event.repository.default_branch }}
|
|
513
|
+
ISSUE_NUMBER: ${{ needs.classify.outputs.issue-number }}
|
|
514
|
+
MODE: ${{ needs.classify.outputs.triage-mode }}
|
|
515
|
+
run: |
|
|
516
|
+
set -euo pipefail
|
|
517
|
+
gh workflow run work-router.yml --repo "$REPO" --ref "$REF" \
|
|
518
|
+
-f operation=triage \
|
|
519
|
+
-f issue-number="$ISSUE_NUMBER" \
|
|
520
|
+
-f mode="$MODE"
|
|
521
|
+
|
|
522
|
+
call-triage:
|
|
523
|
+
needs: [classify, authorize]
|
|
524
|
+
# Triage on issue open is for outside collaborators only (handled by dispatch-triage).
|
|
525
|
+
# A trusted actor who opens an issue should not start triage — they can add refine/implement/direct
|
|
526
|
+
# labels or manually dispatch triage. All other triage routes (labeled, comment, dispatch) are trusted.
|
|
527
|
+
if: needs.classify.outputs.route == 'triage' && needs.authorize.outputs.trusted == 'true' && !(github.event_name == 'issues' && github.event.action == 'opened')
|
|
528
|
+
uses: ./.github/workflows/agent-triage.lock.yml
|
|
529
|
+
concurrency:
|
|
530
|
+
group: triage-${{ needs.classify.outputs.issue-number }}
|
|
531
|
+
cancel-in-progress: false
|
|
532
|
+
permissions: write-all
|
|
533
|
+
with:
|
|
534
|
+
issue-number: ${{ needs.classify.outputs.issue-number }}
|
|
535
|
+
mode: ${{ needs.classify.outputs.triage-mode }}
|
|
536
|
+
secrets:
|
|
537
|
+
OPENAI_API_KEY: ${{ secrets.FORGE_API_KEY }}
|
|
538
|
+
CODEX_API_KEY: ${{ secrets.FORGE_API_KEY }}
|
|
539
|
+
COPILOT_GITHUB_TOKEN: ${{ secrets.FORGE_API_KEY }}
|
|
540
|
+
BOT_APP_ID: ${{ secrets.BOT_APP_ID }}
|
|
541
|
+
BOT_PRIVATE_KEY: ${{ secrets.BOT_PRIVATE_KEY }}
|
|
542
|
+
AGENTMEMORY_SECRET: ${{ secrets.AGENTMEMORY_SECRET }}
|
|
543
|
+
|
|
544
|
+
call-apply-review:
|
|
545
|
+
needs: [classify, authorize]
|
|
546
|
+
if: needs.classify.outputs.route == 'apply-review' && needs.authorize.outputs.trusted == 'true'
|
|
547
|
+
uses: ./.github/workflows/agent-apply-review.lock.yml
|
|
548
|
+
concurrency:
|
|
549
|
+
group: pr-feedback-${{ needs.classify.outputs.pr-number }}
|
|
550
|
+
cancel-in-progress: false
|
|
551
|
+
permissions: write-all
|
|
552
|
+
with:
|
|
553
|
+
pr-number: ${{ needs.classify.outputs.pr-number }}
|
|
554
|
+
linked-issue: ${{ needs.classify.outputs.linked-issue }}
|
|
555
|
+
secrets:
|
|
556
|
+
OPENAI_API_KEY: ${{ secrets.FORGE_API_KEY }}
|
|
557
|
+
CODEX_API_KEY: ${{ secrets.FORGE_API_KEY }}
|
|
558
|
+
COPILOT_GITHUB_TOKEN: ${{ secrets.FORGE_API_KEY }}
|
|
559
|
+
BOT_APP_ID: ${{ secrets.BOT_APP_ID }}
|
|
560
|
+
BOT_PRIVATE_KEY: ${{ secrets.BOT_PRIVATE_KEY }}
|
|
561
|
+
AGENTMEMORY_SECRET: ${{ secrets.AGENTMEMORY_SECRET }}
|
|
562
|
+
|
|
563
|
+
# The merge belt is one lock for the whole repository, not one group per pull request.
|
|
564
|
+
# A batch of six overnight pull requests means every merge moves the default branch
|
|
565
|
+
# under the other five; gates running at once would each rebase onto a base another
|
|
566
|
+
# gate is about to invalidate. Serialising the belt means each gate sees a main that
|
|
567
|
+
# only changes between gates, every merge re-arms the siblings through
|
|
568
|
+
# detect-pr-conflicts's rescan, and the queue drains one pull request at a time.
|
|
569
|
+
call-merge-gate:
|
|
570
|
+
needs: classify
|
|
571
|
+
if: needs.classify.outputs.route == 'merge-gate'
|
|
572
|
+
uses: ./.github/workflows/agent-merge-gate.lock.yml
|
|
573
|
+
concurrency:
|
|
574
|
+
group: merge-belt
|
|
575
|
+
cancel-in-progress: false
|
|
576
|
+
permissions: write-all
|
|
577
|
+
with:
|
|
578
|
+
pr-number: ${{ needs.classify.outputs.pr-number }}
|
|
579
|
+
linked-issue: ${{ needs.classify.outputs.linked-issue }}
|
|
580
|
+
ci-conclusion: ${{ needs.classify.outputs.ci-conclusion }}
|
|
581
|
+
ci-run-id: ${{ needs.classify.outputs.ci-run-id }}
|
|
582
|
+
attempts_so_far: ${{ needs.classify.outputs.merge-gate-attempts }}
|
|
583
|
+
secrets:
|
|
584
|
+
OPENAI_API_KEY: ${{ secrets.FORGE_API_KEY }}
|
|
585
|
+
CODEX_API_KEY: ${{ secrets.FORGE_API_KEY }}
|
|
586
|
+
COPILOT_GITHUB_TOKEN: ${{ secrets.FORGE_API_KEY }}
|
|
587
|
+
BOT_APP_ID: ${{ secrets.BOT_APP_ID }}
|
|
588
|
+
BOT_PRIVATE_KEY: ${{ secrets.BOT_PRIVATE_KEY }}
|
|
589
|
+
AGENTMEMORY_SECRET: ${{ secrets.AGENTMEMORY_SECRET }}
|
|
590
|
+
|
|
591
|
+
call-audit:
|
|
592
|
+
needs: classify
|
|
593
|
+
if: needs.classify.outputs.route == 'audit'
|
|
594
|
+
uses: ./.github/workflows/agent-audit.lock.yml
|
|
595
|
+
concurrency:
|
|
596
|
+
group: audit
|
|
597
|
+
cancel-in-progress: false
|
|
598
|
+
permissions: write-all
|
|
599
|
+
with:
|
|
600
|
+
trigger-kind: ${{ needs.classify.outputs.trigger-kind }}
|
|
601
|
+
secrets:
|
|
602
|
+
OPENAI_API_KEY: ${{ secrets.FORGE_API_KEY }}
|
|
603
|
+
CODEX_API_KEY: ${{ secrets.FORGE_API_KEY }}
|
|
604
|
+
COPILOT_GITHUB_TOKEN: ${{ secrets.FORGE_API_KEY }}
|
|
605
|
+
BOT_APP_ID: ${{ secrets.BOT_APP_ID }}
|
|
606
|
+
BOT_PRIVATE_KEY: ${{ secrets.BOT_PRIVATE_KEY }}
|
|
607
|
+
AGENTMEMORY_SECRET: ${{ secrets.AGENTMEMORY_SECRET }}
|
|
608
|
+
|
|
609
|
+
call-release:
|
|
610
|
+
needs: [classify, authorize]
|
|
611
|
+
if: needs.classify.outputs.route == 'release' && needs.authorize.outputs.trusted == 'true'
|
|
612
|
+
uses: ./.github/workflows/agent-release.lock.yml
|
|
613
|
+
permissions: write-all
|
|
614
|
+
with:
|
|
615
|
+
version-bump: ${{ github.event.inputs.version-bump || 'auto' }}
|
|
616
|
+
secrets:
|
|
617
|
+
OPENAI_API_KEY: ${{ secrets.FORGE_API_KEY }}
|
|
618
|
+
|
|
619
|
+
bot-approve:
|
|
620
|
+
needs: classify
|
|
621
|
+
if: >
|
|
622
|
+
needs.classify.outputs.route == 'bot-approve' &&
|
|
623
|
+
(github.actor == 'app/github-actions' || github.actor == 'platform-devbox[bot]')
|
|
624
|
+
runs-on: ubuntu-latest
|
|
625
|
+
timeout-minutes: 5
|
|
626
|
+
permissions:
|
|
627
|
+
actions: write
|
|
628
|
+
steps:
|
|
629
|
+
- name: Approve pending workflow runs
|
|
630
|
+
env:
|
|
631
|
+
GH_TOKEN: ${{ github.token }}
|
|
632
|
+
REPO: ${{ github.repository }}
|
|
633
|
+
BRANCH: ${{ github.head_ref }}
|
|
634
|
+
run: |
|
|
635
|
+
set -euo pipefail
|
|
636
|
+
|
|
637
|
+
mapfile -t run_ids < <(
|
|
638
|
+
gh api "repos/$REPO/actions/runs?status=action_required&branch=$BRANCH" \
|
|
639
|
+
--jq '.workflow_runs[].id'
|
|
640
|
+
)
|
|
641
|
+
|
|
642
|
+
if [ "${#run_ids[@]}" -eq 0 ]; then
|
|
643
|
+
echo "No runs awaiting approval on branch $BRANCH"
|
|
644
|
+
exit 0
|
|
645
|
+
fi
|
|
646
|
+
|
|
647
|
+
for run_id in "${run_ids[@]}"; do
|
|
648
|
+
echo "Approving run $run_id"
|
|
649
|
+
gh api "repos/$REPO/actions/runs/$run_id/approve" --method POST
|
|
650
|
+
done
|
|
651
|
+
|
|
652
|
+
# Conflict watcher for bot pull requests. Merge-conflict state is only available from the
|
|
653
|
+
# mergeability API, which the pure classifier cannot read, so this is an event-gated
|
|
654
|
+
# deterministic dispatcher rather than a classify route (it runs alongside bot-approve on
|
|
655
|
+
# the same pull_request_target event). It NEVER checks out or runs pull request code — it
|
|
656
|
+
# reads mergeability through the API and dispatches the Merge Gate — so it is safe on
|
|
657
|
+
# pull_request_target. Two entry points:
|
|
658
|
+
# opened / reopened / synchronize → check the pull request that changed. Gated to this
|
|
659
|
+
# repository's own branches so a fork PR never starts this App-token job.
|
|
660
|
+
# closed & merged → the target branch moved, so re-check every open bot
|
|
661
|
+
# pull request that may have just become conflicted (the "main changed after another PR
|
|
662
|
+
# merged" case). A merge into the default branch is an inherently trusted internal event.
|
|
663
|
+
# It dispatches only for bot-authored, open, non-draft pull requests whose head is this
|
|
664
|
+
# repository AND whose CI has already concluded (dispatching while CI is still pending would
|
|
665
|
+
# make the gate read a 'pending' verdict and prematurely ask for human review; that PR is
|
|
666
|
+
# covered by the CI-completion route instead). The gate's subject job re-validates ownership
|
|
667
|
+
# before any agent runs.
|
|
668
|
+
detect-pr-conflicts:
|
|
669
|
+
needs: classify
|
|
670
|
+
if: >
|
|
671
|
+
github.event_name == 'pull_request_target' &&
|
|
672
|
+
(
|
|
673
|
+
(github.event.action == 'closed' && github.event.pull_request.merged == true) ||
|
|
674
|
+
(
|
|
675
|
+
(github.event.action == 'opened' ||
|
|
676
|
+
github.event.action == 'reopened' ||
|
|
677
|
+
github.event.action == 'synchronize') &&
|
|
678
|
+
github.event.pull_request.head.repo.full_name == github.repository
|
|
679
|
+
)
|
|
680
|
+
)
|
|
681
|
+
runs-on: ubuntu-latest
|
|
682
|
+
timeout-minutes: 10
|
|
683
|
+
permissions:
|
|
684
|
+
contents: read
|
|
685
|
+
pull-requests: read
|
|
686
|
+
actions: write
|
|
687
|
+
steps:
|
|
688
|
+
- name: Checkout for App token action
|
|
689
|
+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
690
|
+
with:
|
|
691
|
+
persist-credentials: false
|
|
692
|
+
- name: Generate App token
|
|
693
|
+
id: app-token
|
|
694
|
+
uses: actions/create-github-app-token@df432ceedc7162793a195dd1713ff69aefc7379e # v2.0.6
|
|
695
|
+
with:
|
|
696
|
+
app-id: ${{ secrets.BOT_APP_ID }}
|
|
697
|
+
private-key: ${{ secrets.BOT_PRIVATE_KEY }}
|
|
698
|
+
- name: Dispatch Merge Gate for conflicting bot pull requests
|
|
699
|
+
env:
|
|
700
|
+
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
|
701
|
+
REPO: ${{ github.repository }}
|
|
702
|
+
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
|
703
|
+
ACTION: ${{ github.event.action }}
|
|
704
|
+
EVENT_PR: ${{ github.event.pull_request.number }}
|
|
705
|
+
run: |
|
|
706
|
+
set -euo pipefail
|
|
707
|
+
|
|
708
|
+
# Dispatch the Merge Gate for one pull request when it is a red-CI open, non-draft,
|
|
709
|
+
# bot-authored pull request on this repository whose CI has concluded — the
|
|
710
|
+
# conflict case is one arm of that, because a conflicting PR never gets a fresh
|
|
711
|
+
# CI run of its own; a merge conflict with red or concluded CI is the same
|
|
712
|
+
# "needs the gate" signal. A green non-conflicting PR is left for the merge belt
|
|
713
|
+
# to pick up when its checks finish (bot-approve / reconcile).
|
|
714
|
+
# GitHub computes mergeability on demand. The first read of a pull request nobody has
|
|
715
|
+
# opened recently answers UNKNOWN through the API and null through REST, while it works
|
|
716
|
+
# the answer out in the background. Reading once therefore reports "not conflicting"
|
|
717
|
+
# for precisely the stale conflicting pull requests that need the gate. Ask until it
|
|
718
|
+
# settles.
|
|
719
|
+
mergeable_state() {
|
|
720
|
+
local pr="$1" state=UNKNOWN
|
|
721
|
+
for _ in 1 2 3 4 5 6; do
|
|
722
|
+
state=$(gh pr view "$pr" --repo "$REPO" --json mergeable --jq '.mergeable' 2>/dev/null || echo UNKNOWN)
|
|
723
|
+
[ "$state" = "UNKNOWN" ] || break
|
|
724
|
+
sleep 5
|
|
725
|
+
done
|
|
726
|
+
echo "$state"
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
# A gate already queued or running for this pull request will read the same CI verdict;
|
|
730
|
+
# a second dispatch queues behind merge-belt and the single-slot queue cancels it. Same
|
|
731
|
+
# live-run test as the reconcile belt's stale-reservation sweep, keyed on the exact
|
|
732
|
+
# title run-name gives a gate dispatch.
|
|
733
|
+
gate_is_live() {
|
|
734
|
+
local pr="$1" status live
|
|
735
|
+
for status in queued in_progress waiting pending; do
|
|
736
|
+
live=$(gh api "repos/$REPO/actions/workflows/work-router.yml/runs?status=$status&per_page=100" \
|
|
737
|
+
--jq "[.workflow_runs[] | select(.display_title == \"Working (Merge Gate): PR #${pr}\")] | length" 2>/dev/null || echo 0)
|
|
738
|
+
[ "${live:-0}" -eq 0 ] || return 0
|
|
739
|
+
done
|
|
740
|
+
return 1
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
maybe_dispatch() {
|
|
744
|
+
pr="$1"
|
|
745
|
+
[ -n "$pr" ] || return 0
|
|
746
|
+
|
|
747
|
+
meta=$(gh api "repos/$REPO/pulls/$pr" \
|
|
748
|
+
--jq '{state:.state, draft:.draft, bot:.user.type, head:(.head.repo.full_name // "")}' \
|
|
749
|
+
2>/dev/null || echo '')
|
|
750
|
+
[ -n "$meta" ] || return 0
|
|
751
|
+
if [ "$(jq -r '.state' <<<"$meta")" != "open" ] \
|
|
752
|
+
|| [ "$(jq -r '.draft' <<<"$meta")" != "false" ] \
|
|
753
|
+
|| [ "$(jq -r '.bot' <<<"$meta")" != "Bot" ] \
|
|
754
|
+
|| [ "$(jq -r '.head' <<<"$meta")" != "$REPO" ]; then
|
|
755
|
+
return 0
|
|
756
|
+
fi
|
|
757
|
+
|
|
758
|
+
# Only dispatch when CI has already concluded. A conflict while CI is still
|
|
759
|
+
# running is handled by the CI-completion route; dispatching now would make the
|
|
760
|
+
# gate read a 'pending' conclusion and prematurely ask for human review.
|
|
761
|
+
head_sha=$(gh api "repos/$REPO/pulls/$pr" --jq '.head.sha')
|
|
762
|
+
ci=$(gh api "repos/$REPO/actions/runs?head_sha=$head_sha&per_page=100" \
|
|
763
|
+
--jq '[.workflow_runs[]
|
|
764
|
+
| select(.name == env.CI_WORKFLOW_NAME and .status == "completed"
|
|
765
|
+
and (.conclusion == "success" or .conclusion == "failure"))]
|
|
766
|
+
| sort_by(.created_at) | last // empty')
|
|
767
|
+
if [ -z "$ci" ]; then
|
|
768
|
+
# A conflicting pull request has no merge ref for GitHub to build, so a
|
|
769
|
+
# `pull_request` CI workflow can never run on that head: refs/pull/N/merge is a
|
|
770
|
+
# 404 for as long as the conflict lasts. Waiting for a verdict that cannot arrive
|
|
771
|
+
# deadlocks the belt, because resolving the conflict is the gate's own job. Fall
|
|
772
|
+
# back to the branch's last verdict so the gate runs; it stays on the remediation
|
|
773
|
+
# path and cannot merge on a stale conclusion.
|
|
774
|
+
head_ref=$(gh api "repos/$REPO/pulls/$pr" --jq '.head.ref')
|
|
775
|
+
mergeable_now=$(mergeable_state "$pr")
|
|
776
|
+
if [ "$mergeable_now" != "CONFLICTING" ]; then
|
|
777
|
+
echo "PR #$pr has no concluded CI run; deferring to the CI-completion route."
|
|
778
|
+
return 0
|
|
779
|
+
fi
|
|
780
|
+
ci=$(gh api "repos/$REPO/actions/runs?branch=$head_ref&per_page=100" \
|
|
781
|
+
--jq '[.workflow_runs[]
|
|
782
|
+
| select(.name == env.CI_WORKFLOW_NAME and .status == "completed"
|
|
783
|
+
and (.conclusion == "success" or .conclusion == "failure"))]
|
|
784
|
+
| sort_by(.created_at) | last // empty')
|
|
785
|
+
if [ -z "$ci" ]; then
|
|
786
|
+
echo "PR #$pr conflicts and has no CI verdict on any head; nothing to gate on yet."
|
|
787
|
+
return 0
|
|
788
|
+
fi
|
|
789
|
+
echo "PR #$pr conflicts, so CI cannot run on $head_sha at all; gating on the branch's last verdict."
|
|
790
|
+
fi
|
|
791
|
+
ci_conclusion=$(jq -r '.conclusion' <<<"$ci")
|
|
792
|
+
ci_finished=$(jq -r '.updated_at' <<<"$ci")
|
|
793
|
+
ci_run_id=$(jq -r '.id' <<<"$ci")
|
|
794
|
+
|
|
795
|
+
# GitHub computes mergeability asynchronously; poll until it is known (not null).
|
|
796
|
+
mergeable=""
|
|
797
|
+
for _ in 1 2 3 4 5 6; do
|
|
798
|
+
mergeable=$(gh api "repos/$REPO/pulls/$pr" --jq '.mergeable // "null"' 2>/dev/null || echo null)
|
|
799
|
+
[ "$mergeable" = "null" ] || break
|
|
800
|
+
sleep 5
|
|
801
|
+
done
|
|
802
|
+
|
|
803
|
+
if [ "$mergeable" != "false" ] && [ "$ci_conclusion" != "failure" ]; then
|
|
804
|
+
# Mergeable and green: the reconcile belt and the PR's own checks finish it.
|
|
805
|
+
return 0
|
|
806
|
+
fi
|
|
807
|
+
|
|
808
|
+
# A terminal verdict newer than this CI run means a gate already decided;
|
|
809
|
+
# failed attempts (the attempt marker) deliberately do not count.
|
|
810
|
+
verdict=$(gh api "repos/$REPO/issues/$pr/comments?per_page=100" \
|
|
811
|
+
--jq '[.[] | select((.body | contains("<!-- agent-merge-gate -->")) and (.body | contains("**Verdict:**"))) | .created_at] | max // empty')
|
|
812
|
+
linked_issue=$(gh pr view "$pr" --repo "$REPO" --json closingIssuesReferences --jq '.closingIssuesReferences[0].number // empty' 2>/dev/null || true)
|
|
813
|
+
if [ -n "$linked_issue" ]; then
|
|
814
|
+
issue_verdict=$(gh api "repos/$REPO/issues/$linked_issue/comments?per_page=100" \
|
|
815
|
+
--jq '[.[] | select((.body | contains("<!-- agent-merge-gate -->")) and (.body | contains("**Verdict:**"))) | .created_at] | max // empty')
|
|
816
|
+
if [ -n "$issue_verdict" ] && { [ -z "$verdict" ] || [ "$issue_verdict" \> "$verdict" ]; }; then
|
|
817
|
+
verdict="$issue_verdict"
|
|
818
|
+
fi
|
|
819
|
+
fi
|
|
820
|
+
# A verdict parks the CODE it was given on, not the CI run that prompted it.
|
|
821
|
+
# Comparing against the CI finish time meant any later run on the same commits
|
|
822
|
+
# reopened a park a human already owned. The newest commit on the branch is the
|
|
823
|
+
# right key: push, and the gate decides again; re-run CI, and the park holds.
|
|
824
|
+
head_committed=$(gh pr view "$pr" --repo "$REPO" \
|
|
825
|
+
--json commits --jq '[.commits[].committedDate] | max // empty' 2>/dev/null || true)
|
|
826
|
+
horizon="${head_committed:-$ci_finished}"
|
|
827
|
+
if [ -n "$verdict" ] && [ "$verdict" \> "$horizon" ]; then
|
|
828
|
+
echo "PR #$pr has a gate verdict newer than its head commit; the park holds, skipping."
|
|
829
|
+
return 0
|
|
830
|
+
fi
|
|
831
|
+
|
|
832
|
+
attempts=$(gh api "repos/$REPO/issues/$pr/comments?per_page=100" \
|
|
833
|
+
--jq '[.[] | select(.body | contains("<!-- agent-merge-gate-attempt -->")) | select(.created_at > "'"$horizon"'") ] | length')
|
|
834
|
+
if [ -n "$linked_issue" ]; then
|
|
835
|
+
issue_attempts=$(gh api "repos/$REPO/issues/$linked_issue/comments?per_page=100" \
|
|
836
|
+
--jq '[.[] | select(.body | contains("<!-- agent-merge-gate-attempt -->")) | select(.created_at > "'"$horizon"'") ] | length')
|
|
837
|
+
attempts=$((attempts + issue_attempts))
|
|
838
|
+
fi
|
|
839
|
+
if [ "$attempts" -ge "$MAX_GATE_ATTEMPTS" ]; then
|
|
840
|
+
echo "PR #$pr already consumed $attempts gate attempts on this head, parking for review."
|
|
841
|
+
return 0
|
|
842
|
+
fi
|
|
843
|
+
if gate_is_live "$pr"; then
|
|
844
|
+
echo "PR #$pr: a merge-gate run is already live; not dispatching another."
|
|
845
|
+
return 0
|
|
846
|
+
fi
|
|
847
|
+
|
|
848
|
+
echo "PR #$pr is $([ "$mergeable" = "false" ] && echo conflicting || echo red-CI) (CI $ci_conclusion); dispatching Merge Gate (attempt $((attempts + 1)) of $MAX_GATE_ATTEMPTS)."
|
|
849
|
+
gh api "repos/$REPO/actions/workflows/work-router.yml/dispatches" \
|
|
850
|
+
--method POST \
|
|
851
|
+
-f ref="$DEFAULT_BRANCH" \
|
|
852
|
+
-f "inputs[operation]=merge-gate" \
|
|
853
|
+
-f "inputs[pr-number]=$pr" \
|
|
854
|
+
-f "inputs[ci-conclusion]=$ci_conclusion" \
|
|
855
|
+
-f "inputs[ci-run-id]=$ci_run_id" \
|
|
856
|
+
-f "inputs[attempts_so_far]=$attempts"
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
if [ "$ACTION" = "closed" ]; then
|
|
860
|
+
# The target branch just moved. Any previously-mergeable bot PR may now conflict.
|
|
861
|
+
# The just-merged PR is now closed and is skipped by maybe_dispatch's state check.
|
|
862
|
+
gh api "repos/$REPO/pulls?state=open&per_page=100" \
|
|
863
|
+
--jq '.[] | select(.user.type == "Bot" and .draft == false) | .number' |
|
|
864
|
+
while IFS= read -r pr; do
|
|
865
|
+
maybe_dispatch "$pr"
|
|
866
|
+
done
|
|
867
|
+
else
|
|
868
|
+
maybe_dispatch "$EVENT_PR"
|
|
869
|
+
fi
|
|
870
|
+
|
|
871
|
+
reconcile-bot-pr-runs:
|
|
872
|
+
needs: classify
|
|
873
|
+
if: needs.classify.outputs.route == 'reconcile-bot-pr-runs'
|
|
874
|
+
runs-on: ubuntu-latest
|
|
875
|
+
timeout-minutes: 5
|
|
876
|
+
permissions:
|
|
877
|
+
contents: read
|
|
878
|
+
actions: write
|
|
879
|
+
pull-requests: read
|
|
880
|
+
# the stale reservation sweep clears bot-working from parked issues
|
|
881
|
+
issues: write
|
|
882
|
+
env:
|
|
883
|
+
BOT_APP_ID: ${{ secrets.BOT_APP_ID }}
|
|
884
|
+
BOT_PRIVATE_KEY: ${{ secrets.BOT_PRIVATE_KEY }}
|
|
885
|
+
steps:
|
|
886
|
+
- name: Checkout for App token action
|
|
887
|
+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
888
|
+
with:
|
|
889
|
+
persist-credentials: false
|
|
890
|
+
- name: Generate App token
|
|
891
|
+
id: app-token
|
|
892
|
+
uses: actions/create-github-app-token@df432ceedc7162793a195dd1713ff69aefc7379e # v2.0.6
|
|
893
|
+
with:
|
|
894
|
+
app-id: ${{ secrets.BOT_APP_ID }}
|
|
895
|
+
private-key: ${{ secrets.BOT_PRIVATE_KEY }}
|
|
896
|
+
# Runs only the default-branch workflow and never checks out pull-request code.
|
|
897
|
+
# External users cannot impersonate github-actions[bot].
|
|
898
|
+
- name: Approve checks and dispatch Merge Gate for trusted bot pull requests
|
|
899
|
+
env:
|
|
900
|
+
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
|
901
|
+
REPO: ${{ github.repository }}
|
|
902
|
+
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
|
903
|
+
run: |
|
|
904
|
+
set -euo pipefail
|
|
905
|
+
|
|
906
|
+
# GitHub computes mergeability on demand. The first read of a pull request nobody has
|
|
907
|
+
# opened recently answers UNKNOWN through the API and null through REST, while it works
|
|
908
|
+
# the answer out in the background. Reading once therefore reports "not conflicting"
|
|
909
|
+
# for precisely the stale conflicting pull requests that need the gate. Ask until it
|
|
910
|
+
# settles.
|
|
911
|
+
mergeable_state() {
|
|
912
|
+
local pr="$1" state=UNKNOWN
|
|
913
|
+
for _ in 1 2 3 4 5 6; do
|
|
914
|
+
state=$(gh pr view "$pr" --repo "$REPO" --json mergeable --jq '.mergeable' 2>/dev/null || echo UNKNOWN)
|
|
915
|
+
[ "$state" = "UNKNOWN" ] || break
|
|
916
|
+
sleep 5
|
|
917
|
+
done
|
|
918
|
+
echo "$state"
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
# A gate already queued or running for this pull request will read the same CI verdict;
|
|
922
|
+
# a second dispatch queues behind merge-belt and the single-slot queue cancels it.
|
|
923
|
+
gate_is_live() {
|
|
924
|
+
local pr="$1" status live
|
|
925
|
+
for status in queued in_progress waiting pending; do
|
|
926
|
+
live=$(gh api "repos/$REPO/actions/workflows/work-router.yml/runs?status=$status&per_page=100" \
|
|
927
|
+
--jq "[.workflow_runs[] | select(.display_title == \"Working (Merge Gate): PR #${pr}\")] | length" 2>/dev/null || echo 0)
|
|
928
|
+
[ "${live:-0}" -eq 0 ] || return 0
|
|
929
|
+
done
|
|
930
|
+
return 1
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
# Stale reservation sweep. Labelling ten issues at once labels all ten immediately,
|
|
934
|
+
# because authorize-bot-work is keyed per issue, while implement-global lets exactly
|
|
935
|
+
# one run at a time. A queued run that is cancelled therefore leaves its issue
|
|
936
|
+
# carrying bot-working with no run and no pull request: parked, invisible, and not
|
|
937
|
+
# re-triggerable, because classify refuses an issue that is already reserved.
|
|
938
|
+
gh issue list --repo "$REPO" --label bot-working --state open --limit 100 \
|
|
939
|
+
--json number --jq '.[].number' |
|
|
940
|
+
while IFS= read -r issue; do
|
|
941
|
+
[ -n "$issue" ] || continue
|
|
942
|
+
|
|
943
|
+
live=$(gh api "repos/$REPO/actions/runs?per_page=100" \
|
|
944
|
+
--jq "[.workflow_runs[]
|
|
945
|
+
| select(.status == \"queued\" or .status == \"in_progress\" or .status == \"pending\" or .status == \"waiting\")
|
|
946
|
+
| select(.display_title | test(\"#${issue}\\b\"))] | length")
|
|
947
|
+
[ "${live:-0}" -eq 0 ] || continue
|
|
948
|
+
|
|
949
|
+
has_pr=$(gh pr list --repo "$REPO" --state open --json body \
|
|
950
|
+
--jq "[.[] | select((.body // \"\") | ascii_downcase | test(\"clos(e|es|ed) #${issue}\\b|fix(es|ed)? #${issue}\\b|resolves? #${issue}\\b\"))] | length")
|
|
951
|
+
[ "${has_pr:-0}" -eq 0 ] || continue
|
|
952
|
+
|
|
953
|
+
echo "Clearing a stale bot-working reservation on #$issue: no run, no pull request."
|
|
954
|
+
gh issue edit "$issue" --repo "$REPO" --remove-label bot-working
|
|
955
|
+
done
|
|
956
|
+
|
|
957
|
+
gh api "repos/$REPO/pulls?state=open&per_page=100" |
|
|
958
|
+
jq -r --arg repo "$REPO" '
|
|
959
|
+
.[]
|
|
960
|
+
| select(
|
|
961
|
+
# Bind the login first. Inside index(...) the input is the array, so a bare
|
|
962
|
+
# .user.login there indexes the array and jq dies with "Cannot index array
|
|
963
|
+
# with string \"user\"", taking the whole hourly belt with it.
|
|
964
|
+
((.user.login) as $login | (env.TRUSTED_BOTS | split(" ")) | index($login) != null)
|
|
965
|
+
and .draft == false
|
|
966
|
+
and .head.repo.full_name == $repo
|
|
967
|
+
)
|
|
968
|
+
| [.number, .head.ref, .head.sha] | @tsv
|
|
969
|
+
' |
|
|
970
|
+
while IFS=$'\t' read -r pr_number branch head_sha; do
|
|
971
|
+
gh api "repos/$REPO/actions/runs?status=action_required&branch=$branch" \
|
|
972
|
+
--jq '.workflow_runs[]
|
|
973
|
+
| select(
|
|
974
|
+
.event == "pull_request"
|
|
975
|
+
and .name == env.CI_WORKFLOW_NAME
|
|
976
|
+
)
|
|
977
|
+
| .id' |
|
|
978
|
+
while IFS= read -r run_id; do
|
|
979
|
+
echo "Approving trusted bot run $run_id"
|
|
980
|
+
gh api "repos/$REPO/actions/runs/$run_id/approve" --method POST
|
|
981
|
+
done
|
|
982
|
+
|
|
983
|
+
ci=$(gh api "repos/$REPO/actions/runs?head_sha=$head_sha&per_page=100" \
|
|
984
|
+
--jq '[.workflow_runs[]
|
|
985
|
+
| select(
|
|
986
|
+
.name == env.CI_WORKFLOW_NAME
|
|
987
|
+
and .event == "pull_request"
|
|
988
|
+
and .status == "completed"
|
|
989
|
+
and (.conclusion == "success" or .conclusion == "failure")
|
|
990
|
+
)
|
|
991
|
+
] | sort_by(.created_at) | last // empty | [.id, .conclusion, .updated_at] | @tsv')
|
|
992
|
+
if [ -z "$ci" ]; then
|
|
993
|
+
# A conflicting pull request has no merge ref for GitHub to build, so a
|
|
994
|
+
# `pull_request` CI workflow can never run on that head: refs/pull/N/merge is a
|
|
995
|
+
# 404 for as long as the conflict lasts. Waiting for a verdict that cannot arrive
|
|
996
|
+
# deadlocks the belt, because resolving the conflict is the gate's own job. Fall
|
|
997
|
+
# back to the branch's last verdict so the gate runs; it stays on the remediation
|
|
998
|
+
# path and cannot merge on a stale conclusion.
|
|
999
|
+
mergeable_now=$(mergeable_state "$pr_number")
|
|
1000
|
+
if [ "$mergeable_now" = "CONFLICTING" ]; then
|
|
1001
|
+
ci=$(gh api "repos/$REPO/actions/runs?branch=$branch&per_page=100" \
|
|
1002
|
+
--jq '[.workflow_runs[]
|
|
1003
|
+
| select(
|
|
1004
|
+
.name == env.CI_WORKFLOW_NAME
|
|
1005
|
+
and .event == "pull_request"
|
|
1006
|
+
and .status == "completed"
|
|
1007
|
+
and (.conclusion == "success" or .conclusion == "failure")
|
|
1008
|
+
)
|
|
1009
|
+
] | sort_by(.created_at) | last // empty | [.id, .conclusion, .updated_at] | @tsv')
|
|
1010
|
+
fi
|
|
1011
|
+
if [ -z "$ci" ]; then
|
|
1012
|
+
echo "::warning::PR #$pr_number head $head_sha has no completed $CI_WORKFLOW_NAME run; the belt cannot gate it until CI runs."
|
|
1013
|
+
continue
|
|
1014
|
+
fi
|
|
1015
|
+
echo "PR #$pr_number conflicts, so CI cannot run on $head_sha at all; gating on the branch's last verdict."
|
|
1016
|
+
fi
|
|
1017
|
+
|
|
1018
|
+
IFS=$'\t' read -r ci_run_id ci_conclusion ci_finished <<<"$ci"
|
|
1019
|
+
|
|
1020
|
+
# A verdict is a comment carrying BOTH the gate marker and a `**Verdict:**`
|
|
1021
|
+
# line; progress comments and failed attempts deliberately carry neither
|
|
1022
|
+
# matching pair, so a crashed or OOM-killed gate never masks a PR from
|
|
1023
|
+
# later sweeps. A recorded verdict only counts when it is NEWER than the
|
|
1024
|
+
# latest CI run; the old existence check made the gate one-shot per PR.
|
|
1025
|
+
# Attempts are failed gates (the attempt marker) since this CI verdict.
|
|
1026
|
+
latest_verdict=$(gh api "repos/$REPO/issues/$pr_number/comments?per_page=100" \
|
|
1027
|
+
--jq '[.[] | select((.body | contains("<!-- agent-merge-gate -->")) and (.body | contains("**Verdict:**"))) | .created_at] | max // empty')
|
|
1028
|
+
linked_issue=$(gh pr view "$pr_number" --repo "$REPO" --json closingIssuesReferences --jq '.closingIssuesReferences[0].number // empty' 2>/dev/null || true)
|
|
1029
|
+
if [ -n "$linked_issue" ]; then
|
|
1030
|
+
issue_verdict=$(gh api "repos/$REPO/issues/$linked_issue/comments?per_page=100" \
|
|
1031
|
+
--jq '[.[] | select((.body | contains("<!-- agent-merge-gate -->")) and (.body | contains("**Verdict:**"))) | .created_at] | max // empty')
|
|
1032
|
+
if [ -n "$issue_verdict" ] && { [ -z "$latest_verdict" ] || [ "$issue_verdict" \> "$latest_verdict" ]; }; then
|
|
1033
|
+
latest_verdict="$issue_verdict"
|
|
1034
|
+
fi
|
|
1035
|
+
fi
|
|
1036
|
+
# A verdict parks the CODE it was given on, not the CI run that prompted it. This
|
|
1037
|
+
# used to compare against the CI finish time, which made the park worthless: any
|
|
1038
|
+
# later CI run on the same commits -- a re-run, a nightly, a rebuild triggered by
|
|
1039
|
+
# main moving -- was newer than the verdict, so the belt re-dispatched a pull
|
|
1040
|
+
# request a human had already been handed, and the attempt counter below reset to
|
|
1041
|
+
# zero at the same time and handed it a fresh budget of six full gate runs.
|
|
1042
|
+
# Lyceum PR #13 sat parked by a `review` verdict for six days while this happened.
|
|
1043
|
+
#
|
|
1044
|
+
# The newest commit on the branch is the right key. A gate verdict newer than it
|
|
1045
|
+
# applies to the current head, so the park holds however often CI runs. Push a
|
|
1046
|
+
# commit -- which is what apply-review and a `remediated` verdict both do -- and
|
|
1047
|
+
# the head moves past the verdict and the gate decides again, which is correct:
|
|
1048
|
+
# that is new code nobody has ruled on.
|
|
1049
|
+
head_committed=$(gh pr view "$pr_number" --repo "$REPO" \
|
|
1050
|
+
--json commits --jq '[.commits[].committedDate] | max // empty' 2>/dev/null || true)
|
|
1051
|
+
verdict_horizon="${head_committed:-$ci_finished}"
|
|
1052
|
+
if [ -n "$latest_verdict" ] && [ "$latest_verdict" \> "$verdict_horizon" ]; then
|
|
1053
|
+
echo "PR #$pr_number: gate verdict ($latest_verdict) is newer than its head commit ($verdict_horizon); the park holds, skipping"
|
|
1054
|
+
continue
|
|
1055
|
+
fi
|
|
1056
|
+
|
|
1057
|
+
attempts=$(gh api "repos/$REPO/issues/$pr_number/comments?per_page=100" \
|
|
1058
|
+
--jq '[.[] | select(.body | contains("<!-- agent-merge-gate-attempt -->")) | select(.created_at > "'"$verdict_horizon"'") ] | length')
|
|
1059
|
+
if [ -n "$linked_issue" ]; then
|
|
1060
|
+
issue_attempts=$(gh api "repos/$REPO/issues/$linked_issue/comments?per_page=100" \
|
|
1061
|
+
--jq '[.[] | select(.body | contains("<!-- agent-merge-gate-attempt -->")) | select(.created_at > "'"$verdict_horizon"'") ] | length')
|
|
1062
|
+
attempts=$((attempts + issue_attempts))
|
|
1063
|
+
fi
|
|
1064
|
+
if [ "$attempts" -ge "$MAX_GATE_ATTEMPTS" ]; then
|
|
1065
|
+
echo "PR #$pr_number already consumed $attempts gate attempts on this head, parking for review"
|
|
1066
|
+
continue
|
|
1067
|
+
fi
|
|
1068
|
+
if gate_is_live "$pr_number"; then
|
|
1069
|
+
echo "PR #$pr_number: a merge-gate run is already live; not dispatching another."
|
|
1070
|
+
continue
|
|
1071
|
+
fi
|
|
1072
|
+
|
|
1073
|
+
echo "Dispatching Merge Gate for PR #$pr_number ($ci_conclusion, run $ci_run_id, attempt $((attempts + 1)) of $MAX_GATE_ATTEMPTS)"
|
|
1074
|
+
gh api "repos/$REPO/actions/workflows/work-router.yml/dispatches" \
|
|
1075
|
+
--method POST \
|
|
1076
|
+
-f ref="$DEFAULT_BRANCH" \
|
|
1077
|
+
-f "inputs[operation]=merge-gate" \
|
|
1078
|
+
-f "inputs[pr-number]=$pr_number" \
|
|
1079
|
+
-f "inputs[ci-conclusion]=$ci_conclusion" \
|
|
1080
|
+
-f "inputs[ci-run-id]=$ci_run_id" \
|
|
1081
|
+
-f "inputs[attempts_so_far]=$attempts"
|
|
1082
|
+
done
|
|
1083
|
+
|
|
1084
|
+
audit-close:
|
|
1085
|
+
needs: classify
|
|
1086
|
+
if: needs.classify.outputs.route == 'audit-close'
|
|
1087
|
+
runs-on: ubuntu-latest
|
|
1088
|
+
timeout-minutes: 15
|
|
1089
|
+
concurrency:
|
|
1090
|
+
group: audit-close
|
|
1091
|
+
cancel-in-progress: false
|
|
1092
|
+
permissions:
|
|
1093
|
+
contents: read
|
|
1094
|
+
issues: write
|
|
1095
|
+
# It reads the open pull requests before closing a report, so a report whose
|
|
1096
|
+
# implement workers are still in review is left alone. Without this the list
|
|
1097
|
+
# call answers 403 and the nightly job fails having closed nothing.
|
|
1098
|
+
pull-requests: read
|
|
1099
|
+
steps:
|
|
1100
|
+
- name: Checkout workflow actions
|
|
1101
|
+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
1102
|
+
with:
|
|
1103
|
+
persist-credentials: false
|
|
1104
|
+
- uses: ./.github/actions/audit-close
|
|
1105
|
+
with:
|
|
1106
|
+
token: ${{ github.token }}
|
|
1107
|
+
stale-after-days: ${{ env.AUDIT_STALE_AFTER_DAYS }}
|
|
1108
|
+
|
|
1109
|
+
cleanup-artifacts:
|
|
1110
|
+
needs: classify
|
|
1111
|
+
if: needs.classify.outputs.route == 'cleanup-artifacts' && !github.event.repository.fork
|
|
1112
|
+
runs-on: ubuntu-latest
|
|
1113
|
+
timeout-minutes: 15
|
|
1114
|
+
concurrency:
|
|
1115
|
+
group: cleanup-artifacts
|
|
1116
|
+
cancel-in-progress: false
|
|
1117
|
+
permissions:
|
|
1118
|
+
contents: read
|
|
1119
|
+
actions: write
|
|
1120
|
+
steps:
|
|
1121
|
+
- name: Checkout workflow actions
|
|
1122
|
+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
1123
|
+
with:
|
|
1124
|
+
persist-credentials: false
|
|
1125
|
+
- uses: ./.github/actions/cleanup-artifacts
|
|
1126
|
+
with:
|
|
1127
|
+
token: ${{ github.token }}
|
|
1128
|
+
artifact-retention-days: ${{ vars.ARTIFACT_RETENTION_DAYS }}
|
|
1129
|
+
|
|
1130
|
+
# The janitor. Deterministic, no model: it retries work the machine stalled on, clears the state
|
|
1131
|
+
# a dead run stranded, deletes branches whose pull request is finished, and keeps one issue
|
|
1132
|
+
# listing what genuinely needs a person.
|
|
1133
|
+
housekeeping:
|
|
1134
|
+
needs: classify
|
|
1135
|
+
if: needs.classify.outputs.route == 'housekeeping' && !github.event.repository.fork
|
|
1136
|
+
runs-on: ubuntu-latest
|
|
1137
|
+
timeout-minutes: 15
|
|
1138
|
+
concurrency:
|
|
1139
|
+
group: housekeeping
|
|
1140
|
+
cancel-in-progress: false
|
|
1141
|
+
permissions:
|
|
1142
|
+
# contents: write deletes branches whose pull request is closed or merged.
|
|
1143
|
+
contents: write
|
|
1144
|
+
issues: write
|
|
1145
|
+
pull-requests: read
|
|
1146
|
+
env:
|
|
1147
|
+
BOT_APP_ID: ${{ secrets.BOT_APP_ID }}
|
|
1148
|
+
BOT_PRIVATE_KEY: ${{ secrets.BOT_PRIVATE_KEY }}
|
|
1149
|
+
steps:
|
|
1150
|
+
- name: Checkout workflow actions
|
|
1151
|
+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
1152
|
+
with:
|
|
1153
|
+
persist-credentials: false
|
|
1154
|
+
# A retry is a workflow_dispatch, and GitHub refuses to start a workflow run from an event
|
|
1155
|
+
# raised with GITHUB_TOKEN. With the default token every retry would report success and do
|
|
1156
|
+
# nothing, which is the exact failure mode this job exists to remove.
|
|
1157
|
+
- name: Generate App token
|
|
1158
|
+
id: app-token
|
|
1159
|
+
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
|
1160
|
+
with:
|
|
1161
|
+
app-id: ${{ secrets.BOT_APP_ID }}
|
|
1162
|
+
private-key: ${{ secrets.BOT_PRIVATE_KEY }}
|
|
1163
|
+
- uses: ./.github/actions/housekeeping
|
|
1164
|
+
with:
|
|
1165
|
+
token: ${{ steps.app-token.outputs.token }}
|
|
1166
|
+
retry-after-hours: ${{ env.HOUSEKEEPING_RETRY_AFTER_HOURS }}
|
|
1167
|
+
max-retries: ${{ env.HOUSEKEEPING_MAX_RETRIES }}
|
|
1168
|
+
stale-pr-days: ${{ env.HOUSEKEEPING_STALE_PR_DAYS }}
|
|
1169
|
+
digest-title: ${{ env.HOUSEKEEPING_DIGEST_TITLE }}
|
|
1170
|
+
dry-run: ${{ github.event.inputs.dry-run || 'false' }}
|
|
1171
|
+
|
|
1172
|
+
validate:
|
|
1173
|
+
needs: classify
|
|
1174
|
+
if: needs.classify.outputs.route == 'validate'
|
|
1175
|
+
runs-on: ubuntu-latest
|
|
1176
|
+
timeout-minutes: 10
|
|
1177
|
+
permissions:
|
|
1178
|
+
contents: read
|
|
1179
|
+
steps:
|
|
1180
|
+
- name: Checkout workflow actions
|
|
1181
|
+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
1182
|
+
with:
|
|
1183
|
+
persist-credentials: false
|
|
1184
|
+
- uses: ./.github/actions/verify-route-matrix
|
|
1185
|
+
- uses: ./.github/actions/verify-composite-actions
|