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