@plainconceptsplatform/workflows 1.5.0 → 1.6.10
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/dist/route-processing.js +9 -2
- package/dist/workflow-catalog.js +1 -1
- package/loops/actions/apply-agent-output/action.yml +5 -0
- package/loops/actions/classify-route/classify-route.sh +34 -13
- package/loops/actions/decide-agent-retry/action.yml +31 -3
- package/loops/actions/decide-agent-retry/classify-failure.sh +50 -0
- package/loops/actions/housekeeping/action.yml +20 -0
- package/loops/actions/identify-gate-subject/action.yml +52 -17
- package/loops/actions/merge-train/action.yml +71 -0
- package/loops/actions/merge-train/merge-train.sh +495 -0
- package/loops/actions/push-agent-branch/action.yml +73 -45
- package/loops/actions/verify-route-matrix/verify-route-matrix.sh +290 -84
- package/loops/docs/diagrams.md +55 -13
- package/loops/scripts/compile-agent-workflows.mjs +20 -3
- package/loops/templates/ci/app-ci-dotnet-next.yml +13 -14
- package/loops/templates/ci/app-ci-node-monorepo.yml +13 -14
- package/loops/workflows/agent-implement.md +77 -32
- package/loops/workflows/agent-merge-gate.md +90 -62
- package/loops/workflows/agent-refine.md +9 -0
- package/loops/workflows/work-router.yml +285 -471
- package/package.json +1 -1
|
@@ -65,7 +65,14 @@ run-name: >-
|
|
|
65
65
|
&& format('Agent: Implement - #{0}', inputs.issue-number)
|
|
66
66
|
|| github.event_name == 'workflow_dispatch'
|
|
67
67
|
&& inputs.operation == 'merge-gate'
|
|
68
|
-
&&
|
|
68
|
+
&& github.actor == 'github-actions[bot]'
|
|
69
|
+
&& format('Agentics: Merge Train - CI on PR #{0}', inputs.pr-number)
|
|
70
|
+
|| github.event_name == 'workflow_dispatch'
|
|
71
|
+
&& inputs.operation == 'merge-gate'
|
|
72
|
+
&& format('Agent: Merge Gate - PR #{0}{1}', inputs.pr-number, inputs.ci-conclusion == 'failure' && ' (repair)' || '')
|
|
73
|
+
|| github.event_name == 'workflow_dispatch'
|
|
74
|
+
&& inputs.operation == 'merge-train'
|
|
75
|
+
&& format('Agentics: Merge Train{0}', '')
|
|
69
76
|
|| github.event_name == 'workflow_dispatch'
|
|
70
77
|
&& inputs.operation == 'evidence'
|
|
71
78
|
&& format('Agentics: Evidence - PR #{0}', inputs.pr-number)
|
|
@@ -144,6 +151,24 @@ env:
|
|
|
144
151
|
# merge-gate worker carried its own MAX_ATTEMPTS; the route matrix now asserts the two
|
|
145
152
|
# agree, because a belt that gives up later than the worker says burns whole runs.
|
|
146
153
|
MAX_GATE_ATTEMPTS: "6"
|
|
154
|
+
# The merge train takes the oldest open pull request, bot or person, and moves it one step at a
|
|
155
|
+
# time until it merges or a person has to decide. A pull request with this label stays off it.
|
|
156
|
+
MERGE_TRAIN_HOLD_LABEL: "hold"
|
|
157
|
+
# How many times a `blocked` verdict, or a merge that did not happen, is retried once the base
|
|
158
|
+
# branch has moved. After that the pull request waits for a person or a new commit.
|
|
159
|
+
MERGE_TRAIN_MAX_RETRIES: "3"
|
|
160
|
+
# "true" logs the move the train would make and makes none. For trying the train on a
|
|
161
|
+
# repository before letting it act.
|
|
162
|
+
MERGE_TRAIN_DRY_RUN: "false"
|
|
163
|
+
# How many pull requests the merge train may have in repair at once. A repair moves nothing on
|
|
164
|
+
# the default branch, so repairs of different pull requests do not race; only merges are serial.
|
|
165
|
+
# 1 repairs one pull request at a time.
|
|
166
|
+
MERGE_TRAIN_REPAIRS: "1"
|
|
167
|
+
# How many implement runs may work at once. Each queue is FIFO and serial. An issue joins the
|
|
168
|
+
# queue of its split parent when it has one, so the children of one refine never run side by
|
|
169
|
+
# side on the code they share; independent issues spread across the queues. 1 is one queue
|
|
170
|
+
# for everything, as before this existed. Raise it only with a fleet that has the VMs.
|
|
171
|
+
IMPLEMENT_PARALLELISM: "1"
|
|
147
172
|
# Whether a merged pull request's verification plan is replayed and its screenshots archived on
|
|
148
173
|
# the pull request (evidence.yml). It needs nothing configured: the app starts with the root
|
|
149
174
|
# `pnpm run dev`, and a pull request with no plan costs one API call. Set it to "false" for a
|
|
@@ -161,25 +186,22 @@ on:
|
|
|
161
186
|
types: [submitted]
|
|
162
187
|
|
|
163
188
|
pull_request_target:
|
|
164
|
-
# opened / reopened / synchronize feed
|
|
165
|
-
#
|
|
166
|
-
#
|
|
167
|
-
#
|
|
189
|
+
# opened / reopened / synchronize feed bot-approve. closed feeds the merge train: a merge
|
|
190
|
+
# moved the base branch, so the next pull request in line can move. A merge into a feature
|
|
191
|
+
# branch also lands a stack child (land-stack-child), and every merge routes to evidence,
|
|
192
|
+
# which replays the pull request's verification plan.
|
|
168
193
|
types: [opened, synchronize, reopened, labeled, closed]
|
|
169
194
|
|
|
170
|
-
# CI completion on a pull request
|
|
171
|
-
#
|
|
172
|
-
# dispatch-merge-gate job and the reconcile belt, so routing success here would double-fire
|
|
173
|
-
# the gate on every passing PR.
|
|
195
|
+
# CI completion on a pull request moves the merge train, green or red: the train decides
|
|
196
|
+
# whether that pull request is the one in front, and what it needs.
|
|
174
197
|
#
|
|
175
198
|
# Delivery (verified 2026-09-06 in Pliny-Bot): GitHub delivers workflow_run only for CI runs
|
|
176
|
-
# whose actor is a human. A
|
|
177
|
-
#
|
|
178
|
-
#
|
|
179
|
-
#
|
|
180
|
-
#
|
|
181
|
-
#
|
|
182
|
-
# bot-authored, so a fork PR's failing CI cannot start privileged work.
|
|
199
|
+
# whose actor is a human. A pull request opened or updated with a bot token has the bot as the
|
|
200
|
+
# actor of every CI run that follows, and no workflow_run arrives for any of them. For those
|
|
201
|
+
# the consumer CI's dispatch job (templates/ci) and the hourly schedule are the paths.
|
|
202
|
+
# Security: workflow_run runs from the default branch with secrets, but the train only ever
|
|
203
|
+
# moves pull requests from this repository opened by a trusted bot or someone with write
|
|
204
|
+
# access, and the gate's subject job checks the same again.
|
|
183
205
|
workflow_run:
|
|
184
206
|
# Mirrored from env.CI_WORKFLOW_NAME by the installer. A trigger evaluates no expression.
|
|
185
207
|
workflows: ["App: CI"]
|
|
@@ -205,6 +227,7 @@ on:
|
|
|
205
227
|
- triage
|
|
206
228
|
- apply-review
|
|
207
229
|
- merge-gate
|
|
230
|
+
- merge-train
|
|
208
231
|
- evidence
|
|
209
232
|
- audit
|
|
210
233
|
- audit-close
|
|
@@ -508,7 +531,29 @@ jobs:
|
|
|
508
531
|
stack-branch: ${{ steps.stack.outputs.stack-branch }}
|
|
509
532
|
stack-in-flight: ${{ steps.inflight.outputs.stack-in-flight }}
|
|
510
533
|
stack-blocked: ${{ steps.blocked.outputs.stack-blocked }}
|
|
534
|
+
implement-queue: ${{ steps.queue.outputs.queue }}
|
|
511
535
|
steps:
|
|
536
|
+
# Which implement queue this issue waits in. The children of one refine share a queue,
|
|
537
|
+
# keyed on their split parent, because they build on each other's code and two of them at
|
|
538
|
+
# once would each cut from a main the other is about to change. Anything else is keyed on
|
|
539
|
+
# its own number. A body that cannot be read keys on the issue: a wrong queue only costs
|
|
540
|
+
# parallelism, never order within a feature.
|
|
541
|
+
- name: Pick the implement queue
|
|
542
|
+
id: queue
|
|
543
|
+
env:
|
|
544
|
+
GH_TOKEN: ${{ github.token }}
|
|
545
|
+
REPO: ${{ github.repository }}
|
|
546
|
+
ISSUE_NUMBER: ${{ needs.classify.outputs.issue-number }}
|
|
547
|
+
run: |
|
|
548
|
+
set -euo pipefail
|
|
549
|
+
queues="${IMPLEMENT_PARALLELISM:-1}"
|
|
550
|
+
case "$queues" in ''|*[!0-9]*|0) queues=1 ;; esac
|
|
551
|
+
body=$(gh issue view "$ISSUE_NUMBER" --repo "$REPO" --json body --jq '.body // ""' 2>/dev/null || echo '')
|
|
552
|
+
parent=$(printf '%s' "$body" | grep -oE '<!-- split-parent: [0-9]+ -->' | head -1 | grep -oE '[0-9]+' || true)
|
|
553
|
+
key="${parent:-$ISSUE_NUMBER}"
|
|
554
|
+
queue=$(( key % queues ))
|
|
555
|
+
echo "queue=$queue" >> "$GITHUB_OUTPUT"
|
|
556
|
+
echo "#$ISSUE_NUMBER waits in implement queue $queue of $queues (keyed on #$key)."
|
|
512
557
|
- name: Resolve the feature branch from the stack markers
|
|
513
558
|
id: stack
|
|
514
559
|
env:
|
|
@@ -565,8 +610,8 @@ jobs:
|
|
|
565
610
|
# blocked by #111, modules) was dispatched alongside #111 and would have built units on a
|
|
566
611
|
# feat/45 with no modules. The in-flight check below cannot catch that: both routers ran
|
|
567
612
|
# before either dispatch existed, and serialising runs would still start #112 before #111's
|
|
568
|
-
# pull request merged. The landing of the blocker releases the child (
|
|
569
|
-
#
|
|
613
|
+
# pull request merged. The landing of the blocker releases the child (land-stack-child).
|
|
614
|
+
# Held, it keeps `implement` and loses `bot-working`, so it reads as
|
|
570
615
|
# waiting rather than stuck.
|
|
571
616
|
- name: Hold a child whose blockers have not landed
|
|
572
617
|
id: blocked
|
|
@@ -631,8 +676,10 @@ jobs:
|
|
|
631
676
|
needs: [classify, authorize, check-implement-pr, check-stack-branch]
|
|
632
677
|
if: needs.classify.outputs.route == 'implement' && needs.authorize.outputs.trusted == 'true' && needs.check-implement-pr.outputs.has-open-pr != 'true' && needs.check-stack-branch.outputs.is-stack != 'true'
|
|
633
678
|
uses: ./.github/workflows/agent-implement.lock.yml
|
|
679
|
+
# One FIFO queue per IMPLEMENT_PARALLELISM slot, chosen by check-stack-branch: the children of
|
|
680
|
+
# one refine always share a queue, independent issues spread across them.
|
|
634
681
|
concurrency:
|
|
635
|
-
group: implement-
|
|
682
|
+
group: implement-queue-${{ needs.check-stack-branch.outputs.implement-queue || '0' }}
|
|
636
683
|
cancel-in-progress: false
|
|
637
684
|
# FIFO, up to 100 waiting. Without this the default `queue: single` cancels whatever
|
|
638
685
|
# was pending, so labelling six issues at once implements one and drops the rest.
|
|
@@ -768,9 +815,9 @@ jobs:
|
|
|
768
815
|
# The merge belt is one lock for the whole repository, not one group per pull request.
|
|
769
816
|
# A batch of six overnight pull requests means every merge moves the default branch
|
|
770
817
|
# under the other five; gates running at once would each rebase onto a base another
|
|
771
|
-
# gate is about to invalidate.
|
|
772
|
-
#
|
|
773
|
-
#
|
|
818
|
+
# gate is about to invalidate. The merge train dispatches one gate at a time, oldest pull
|
|
819
|
+
# request first; the lock is what still holds when a person or a consumer CI dispatches one
|
|
820
|
+
# beside it.
|
|
774
821
|
#
|
|
775
822
|
# `queue: max` keeps up to 100 gates waiting in order. Without it GitHub keeps one pending run
|
|
776
823
|
# per group and silently cancels the previous one: in one week Numa cancelled 12 gates and
|
|
@@ -779,8 +826,12 @@ jobs:
|
|
|
779
826
|
needs: classify
|
|
780
827
|
if: needs.classify.outputs.route == 'merge-gate'
|
|
781
828
|
uses: ./.github/workflows/agent-merge-gate.lock.yml
|
|
829
|
+
# A gate that was handed red CI repairs and pushes: it can never merge (the validator blocks a
|
|
830
|
+
# non-success conclusion), so it moves nothing on the default branch and cannot race another
|
|
831
|
+
# gate. Those run in a group of their own per pull request, in parallel, while everything that
|
|
832
|
+
# may merge still waits its turn on the one belt.
|
|
782
833
|
concurrency:
|
|
783
|
-
group: merge-belt
|
|
834
|
+
group: ${{ needs.classify.outputs.ci-conclusion == 'failure' && format('merge-repair-{0}', needs.classify.outputs.pr-number) || 'merge-belt' }}
|
|
784
835
|
cancel-in-progress: false
|
|
785
836
|
queue: max
|
|
786
837
|
permissions: write-all
|
|
@@ -798,7 +849,65 @@ jobs:
|
|
|
798
849
|
BOT_PRIVATE_KEY: ${{ secrets.BOT_PRIVATE_KEY }}
|
|
799
850
|
AGENTMEMORY_SECRET: ${{ secrets.AGENTMEMORY_SECRET }}
|
|
800
851
|
|
|
801
|
-
# The
|
|
852
|
+
# The merge train: one step for the oldest open pull request that can move, bot or person.
|
|
853
|
+
# It merges the base branch into a green pull request that fell behind, hands a red,
|
|
854
|
+
# conflicting or green-and-current one to the gate, and skips one a person has to decide on,
|
|
855
|
+
# so a parked pull request never blocks the line. One gate in flight at a time; everything
|
|
856
|
+
# else waits untouched. It ticks on every merge, every CI completion, after every gate (this
|
|
857
|
+
# job waits for call-merge-gate in the same run), and hourly as the fallback. Deterministic:
|
|
858
|
+
# it reads GitHub and dispatches, and never checks out or runs pull request code.
|
|
859
|
+
#
|
|
860
|
+
# One tick at a time. A pending tick replaced by a newer one loses nothing: each reads the
|
|
861
|
+
# whole repository afresh.
|
|
862
|
+
merge-train:
|
|
863
|
+
needs: [classify, call-merge-gate]
|
|
864
|
+
if: >
|
|
865
|
+
always() &&
|
|
866
|
+
needs.classify.result == 'success' &&
|
|
867
|
+
!github.event.repository.fork &&
|
|
868
|
+
(
|
|
869
|
+
needs.classify.outputs.route == 'merge-train' ||
|
|
870
|
+
needs.classify.outputs.route == 'merge-gate' ||
|
|
871
|
+
needs.classify.outputs.route == 'reconcile-bot-pr-runs' ||
|
|
872
|
+
(github.event_name == 'pull_request_target' &&
|
|
873
|
+
github.event.action == 'closed' &&
|
|
874
|
+
github.event.pull_request.merged == true)
|
|
875
|
+
)
|
|
876
|
+
runs-on: [agents-aw, "affinity-${{ github.repository_id }}-${{ github.run_id }}-${{ github.run_attempt }}"]
|
|
877
|
+
timeout-minutes: 15
|
|
878
|
+
concurrency:
|
|
879
|
+
group: merge-train
|
|
880
|
+
cancel-in-progress: false
|
|
881
|
+
permissions:
|
|
882
|
+
contents: read
|
|
883
|
+
pull-requests: read
|
|
884
|
+
steps:
|
|
885
|
+
- name: Checkout workflow actions
|
|
886
|
+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
887
|
+
with:
|
|
888
|
+
persist-credentials: false
|
|
889
|
+
# The App token, not GITHUB_TOKEN: a gate dispatch and a branch update made with
|
|
890
|
+
# GITHUB_TOKEN start no workflow, so the gate would never run and CI never rerun.
|
|
891
|
+
- name: Generate App token
|
|
892
|
+
id: app-token
|
|
893
|
+
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
|
894
|
+
with:
|
|
895
|
+
app-id: ${{ secrets.BOT_APP_ID }}
|
|
896
|
+
private-key: ${{ secrets.BOT_PRIVATE_KEY }}
|
|
897
|
+
- uses: ./.github/actions/merge-train
|
|
898
|
+
with:
|
|
899
|
+
token: ${{ steps.app-token.outputs.token }}
|
|
900
|
+
ci-workflow-name: ${{ env.CI_WORKFLOW_NAME }}
|
|
901
|
+
trusted-bots: ${{ env.TRUSTED_BOTS }}
|
|
902
|
+
hold-label: ${{ env.MERGE_TRAIN_HOLD_LABEL }}
|
|
903
|
+
max-gate-attempts: ${{ env.MAX_GATE_ATTEMPTS }}
|
|
904
|
+
max-retries: ${{ env.MERGE_TRAIN_MAX_RETRIES }}
|
|
905
|
+
max-repairs: ${{ env.MERGE_TRAIN_REPAIRS }}
|
|
906
|
+
dry-run: ${{ env.MERGE_TRAIN_DRY_RUN }}
|
|
907
|
+
ci-run-id: ${{ needs.classify.outputs.ci-run-id }}
|
|
908
|
+
ci-conclusion: ${{ needs.classify.outputs.ci-conclusion }}
|
|
909
|
+
|
|
910
|
+
# The decision the gate parked for: a person with write access approves a pull request the
|
|
802
911
|
# gate handed to them (human-review or owner-review), and the bot merges the commit they
|
|
803
912
|
# approved. Re-running the gate would only reproduce the same parked verdict.
|
|
804
913
|
merge-approved:
|
|
@@ -841,7 +950,15 @@ jobs:
|
|
|
841
950
|
|
|
842
951
|
read -r state author head_sha < <(gh api "repos/$REPO/pulls/$PR" --jq '[.state, .user.login, .head.sha] | @tsv')
|
|
843
952
|
[ "$state" = open ] || skip "it is $state"
|
|
844
|
-
|
|
953
|
+
# The gate parks pull requests by people too. Its author needs write access, like
|
|
954
|
+
# everyone the merge train carries; a bot needs to be one this router trusts.
|
|
955
|
+
case " $TRUSTED_BOTS " in
|
|
956
|
+
*" $author "*) ;;
|
|
957
|
+
*)
|
|
958
|
+
author_permission=$(gh api "repos/$REPO/collaborators/$author/permission" --jq '.permission' 2>/dev/null || echo none)
|
|
959
|
+
case "$author_permission" in admin|maintain|write) ;; *) skip "it was opened by $author, who has $author_permission access" ;; esac
|
|
960
|
+
;;
|
|
961
|
+
esac
|
|
845
962
|
[ "$head_sha" = "$REVIEW_COMMIT" ] || skip "$REVIEWER approved ${REVIEW_COMMIT:0:7}, the head is now ${head_sha:0:7}"
|
|
846
963
|
|
|
847
964
|
# Another reviewer's change request still stands.
|
|
@@ -969,41 +1086,23 @@ jobs:
|
|
|
969
1086
|
gh api "repos/$REPO/actions/runs/$run_id/approve" --method POST
|
|
970
1087
|
done
|
|
971
1088
|
|
|
972
|
-
#
|
|
973
|
-
#
|
|
974
|
-
#
|
|
975
|
-
#
|
|
976
|
-
# reads
|
|
977
|
-
|
|
978
|
-
# opened / reopened / synchronize → check the pull request that changed. Gated to this
|
|
979
|
-
# repository's own branches so a fork PR never starts this App-token job.
|
|
980
|
-
# closed & merged → the target branch moved, so re-check every open
|
|
981
|
-
# pull request that may have just become conflicted (the "main changed after another PR
|
|
982
|
-
# merged" case). A merge into the default branch is an inherently trusted internal event.
|
|
983
|
-
# It dispatches only for open, non-draft pull requests whose head is this
|
|
984
|
-
# repository AND whose CI has already concluded (dispatching while CI is still pending would
|
|
985
|
-
# make the gate read a 'pending' verdict and prematurely ask for human review; that PR is
|
|
986
|
-
# covered by the CI-completion route instead). The gate's subject job re-validates ownership
|
|
987
|
-
# before any agent runs.
|
|
988
|
-
detect-pr-conflicts:
|
|
1089
|
+
# A stack child landing. A pull request merged into a feature branch rather than the default
|
|
1090
|
+
# branch is one child of a stack: it may unblock siblings that waited on it, and it may be the
|
|
1091
|
+
# last one, which makes the root pull request ready for a person. Event-gated rather than a
|
|
1092
|
+
# route because a merge already routes to evidence. It NEVER checks out or runs pull request
|
|
1093
|
+
# code; it reads issues and pull requests through the API and writes labels and one comment.
|
|
1094
|
+
land-stack-child:
|
|
989
1095
|
needs: classify
|
|
990
1096
|
if: >
|
|
991
1097
|
github.event_name == 'pull_request_target' &&
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
(github.event.action == 'opened' ||
|
|
996
|
-
github.event.action == 'reopened' ||
|
|
997
|
-
github.event.action == 'synchronize') &&
|
|
998
|
-
github.event.pull_request.head.repo.full_name == github.repository
|
|
999
|
-
)
|
|
1000
|
-
)
|
|
1098
|
+
github.event.action == 'closed' &&
|
|
1099
|
+
github.event.pull_request.merged == true &&
|
|
1100
|
+
github.event.pull_request.base.ref != github.event.repository.default_branch
|
|
1001
1101
|
runs-on: [agents-aw, "affinity-${{ github.repository_id }}-${{ github.run_id }}-${{ github.run_attempt }}"]
|
|
1002
1102
|
timeout-minutes: 10
|
|
1003
1103
|
permissions:
|
|
1004
1104
|
contents: read
|
|
1005
1105
|
pull-requests: read
|
|
1006
|
-
actions: write
|
|
1007
1106
|
steps:
|
|
1008
1107
|
- name: Checkout for App token action
|
|
1009
1108
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
@@ -1015,309 +1114,146 @@ jobs:
|
|
|
1015
1114
|
with:
|
|
1016
1115
|
app-id: ${{ secrets.BOT_APP_ID }}
|
|
1017
1116
|
private-key: ${{ secrets.BOT_PRIVATE_KEY }}
|
|
1018
|
-
- name:
|
|
1117
|
+
- name: Release unblocked children and announce a complete stack
|
|
1019
1118
|
env:
|
|
1020
1119
|
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
|
1021
1120
|
REPO: ${{ github.repository }}
|
|
1022
1121
|
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
|
1023
|
-
ACTION: ${{ github.event.action }}
|
|
1024
1122
|
EVENT_PR: ${{ github.event.pull_request.number }}
|
|
1025
1123
|
run: |
|
|
1026
1124
|
set -euo pipefail
|
|
1027
1125
|
|
|
1028
|
-
#
|
|
1029
|
-
#
|
|
1030
|
-
#
|
|
1031
|
-
#
|
|
1032
|
-
#
|
|
1033
|
-
#
|
|
1034
|
-
#
|
|
1035
|
-
#
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
[ "${live:-0}" -eq 0 ] || return 0
|
|
1059
|
-
done
|
|
1060
|
-
return 1
|
|
1061
|
-
}
|
|
1062
|
-
|
|
1063
|
-
maybe_dispatch() {
|
|
1064
|
-
pr="$1"
|
|
1065
|
-
[ -n "$pr" ] || return 0
|
|
1066
|
-
|
|
1067
|
-
meta=$(gh api "repos/$REPO/pulls/$pr" \
|
|
1068
|
-
--jq '{state:.state, draft:.draft, bot:.user.type, head:(.head.repo.full_name // "")}' \
|
|
1069
|
-
2>/dev/null || echo '')
|
|
1070
|
-
[ -n "$meta" ] || return 0
|
|
1071
|
-
if [ "$(jq -r '.state' <<<"$meta")" != "open" ] \
|
|
1072
|
-
|| [ "$(jq -r '.draft' <<<"$meta")" != "false" ] \
|
|
1073
|
-
|| [ "$(jq -r '.head' <<<"$meta")" != "$REPO" ]; then
|
|
1074
|
-
return 0
|
|
1075
|
-
fi
|
|
1076
|
-
|
|
1077
|
-
# Only dispatch when CI has already concluded. A conflict while CI is still
|
|
1078
|
-
# running is handled by the CI-completion route; dispatching now would make the
|
|
1079
|
-
# gate read a 'pending' conclusion and prematurely ask for human review.
|
|
1080
|
-
head_sha=$(gh api "repos/$REPO/pulls/$pr" --jq '.head.sha')
|
|
1081
|
-
ci=$(gh api "repos/$REPO/actions/runs?head_sha=$head_sha&per_page=100" \
|
|
1082
|
-
--jq '[.workflow_runs[]
|
|
1083
|
-
| select(.name == env.CI_WORKFLOW_NAME and .status == "completed"
|
|
1084
|
-
and (.conclusion == "success" or .conclusion == "failure"))]
|
|
1085
|
-
| sort_by(.created_at) | last // empty')
|
|
1086
|
-
if [ -z "$ci" ]; then
|
|
1087
|
-
# A conflicting pull request has no merge ref for GitHub to build, so a
|
|
1088
|
-
# `pull_request` CI workflow can never run on that head: refs/pull/N/merge is a
|
|
1089
|
-
# 404 for as long as the conflict lasts. Waiting for a verdict that cannot arrive
|
|
1090
|
-
# deadlocks the belt, because resolving the conflict is the gate's own job. Fall
|
|
1091
|
-
# back to the branch's last verdict so the gate runs; it stays on the remediation
|
|
1092
|
-
# path and cannot merge on a stale conclusion.
|
|
1093
|
-
head_ref=$(gh api "repos/$REPO/pulls/$pr" --jq '.head.ref')
|
|
1094
|
-
mergeable_now=$(mergeable_state "$pr")
|
|
1095
|
-
if [ "$mergeable_now" != "CONFLICTING" ]; then
|
|
1096
|
-
echo "PR #$pr has no concluded CI run; deferring to the CI-completion route."
|
|
1097
|
-
return 0
|
|
1098
|
-
fi
|
|
1099
|
-
ci=$(gh api "repos/$REPO/actions/runs?branch=$head_ref&per_page=100" \
|
|
1100
|
-
--jq '[.workflow_runs[]
|
|
1101
|
-
| select(.name == env.CI_WORKFLOW_NAME and .status == "completed"
|
|
1102
|
-
and (.conclusion == "success" or .conclusion == "failure"))]
|
|
1103
|
-
| sort_by(.created_at) | last // empty')
|
|
1104
|
-
if [ -z "$ci" ]; then
|
|
1105
|
-
echo "PR #$pr conflicts and has no CI verdict on any head; nothing to gate on yet."
|
|
1106
|
-
return 0
|
|
1107
|
-
fi
|
|
1108
|
-
echo "PR #$pr conflicts, so CI cannot run on $head_sha at all; gating on the branch's last verdict."
|
|
1109
|
-
fi
|
|
1110
|
-
ci_conclusion=$(jq -r '.conclusion' <<<"$ci")
|
|
1111
|
-
ci_finished=$(jq -r '.updated_at' <<<"$ci")
|
|
1112
|
-
ci_run_id=$(jq -r '.id' <<<"$ci")
|
|
1113
|
-
|
|
1114
|
-
# GitHub computes mergeability asynchronously; poll until it is known (not null).
|
|
1115
|
-
mergeable=""
|
|
1116
|
-
for _ in 1 2 3 4 5 6; do
|
|
1117
|
-
mergeable=$(gh api "repos/$REPO/pulls/$pr" --jq '.mergeable // "null"' 2>/dev/null || echo null)
|
|
1118
|
-
[ "$mergeable" = "null" ] || break
|
|
1119
|
-
sleep 5
|
|
1120
|
-
done
|
|
1121
|
-
|
|
1122
|
-
if [ "$mergeable" != "false" ] && [ "$ci_conclusion" != "failure" ]; then
|
|
1123
|
-
# Mergeable and green: the reconcile belt and the PR's own checks finish it.
|
|
1124
|
-
return 0
|
|
1125
|
-
fi
|
|
1126
|
-
|
|
1127
|
-
# A terminal verdict newer than this CI run means a gate already decided;
|
|
1128
|
-
# failed attempts (the attempt marker) deliberately do not count.
|
|
1129
|
-
verdict=$(gh api "repos/$REPO/issues/$pr/comments?per_page=100" \
|
|
1130
|
-
--jq '[.[] | select((.body | contains("<!-- agent-merge-gate -->")) and (.body | contains("**Verdict:**"))) | .created_at] | max // empty')
|
|
1131
|
-
linked_issue=$(gh pr view "$pr" --repo "$REPO" --json closingIssuesReferences --jq '.closingIssuesReferences[0].number // empty' 2>/dev/null || true)
|
|
1132
|
-
if [ -n "$linked_issue" ]; then
|
|
1133
|
-
issue_verdict=$(gh api "repos/$REPO/issues/$linked_issue/comments?per_page=100" \
|
|
1134
|
-
--jq '[.[] | select((.body | contains("<!-- agent-merge-gate -->")) and (.body | contains("**Verdict:**"))) | .created_at] | max // empty')
|
|
1135
|
-
if [ -n "$issue_verdict" ] && { [ -z "$verdict" ] || [ "$issue_verdict" \> "$verdict" ]; }; then
|
|
1136
|
-
verdict="$issue_verdict"
|
|
1137
|
-
fi
|
|
1138
|
-
fi
|
|
1139
|
-
# A verdict parks the CODE it was given on, not the CI run that prompted it.
|
|
1140
|
-
# Comparing against the CI finish time meant any later run on the same commits
|
|
1141
|
-
# reopened a park a human already owned. The newest commit on the branch is the
|
|
1142
|
-
# right key: push, and the gate decides again; re-run CI, and the park holds.
|
|
1143
|
-
head_committed=$(gh pr view "$pr" --repo "$REPO" \
|
|
1144
|
-
--json commits --jq '[.commits[].committedDate] | max // empty' 2>/dev/null || true)
|
|
1145
|
-
horizon="${head_committed:-$ci_finished}"
|
|
1146
|
-
if [ -n "$verdict" ] && [ "$verdict" \> "$horizon" ]; then
|
|
1147
|
-
echo "PR #$pr has a gate verdict newer than its head commit; the park holds, skipping."
|
|
1148
|
-
return 0
|
|
1149
|
-
fi
|
|
1150
|
-
|
|
1151
|
-
attempts=$(gh api "repos/$REPO/issues/$pr/comments?per_page=100" \
|
|
1152
|
-
--jq '[.[] | select(.body | contains("<!-- agent-merge-gate-attempt -->")) | select(.created_at > "'"$horizon"'") ] | length')
|
|
1153
|
-
if [ -n "$linked_issue" ]; then
|
|
1154
|
-
issue_attempts=$(gh api "repos/$REPO/issues/$linked_issue/comments?per_page=100" \
|
|
1155
|
-
--jq '[.[] | select(.body | contains("<!-- agent-merge-gate-attempt -->")) | select(.created_at > "'"$horizon"'") ] | length')
|
|
1156
|
-
attempts=$((attempts + issue_attempts))
|
|
1157
|
-
fi
|
|
1158
|
-
if [ "$attempts" -ge "$MAX_GATE_ATTEMPTS" ]; then
|
|
1159
|
-
echo "PR #$pr already consumed $attempts gate attempts on this head, parking for review."
|
|
1160
|
-
return 0
|
|
1161
|
-
fi
|
|
1162
|
-
if gate_is_live "$pr"; then
|
|
1163
|
-
echo "PR #$pr: a merge-gate run is already live; not dispatching another."
|
|
1164
|
-
return 0
|
|
1165
|
-
fi
|
|
1166
|
-
|
|
1167
|
-
echo "PR #$pr is $([ "$mergeable" = "false" ] && echo conflicting || echo red-CI) (CI $ci_conclusion); dispatching Merge Gate (attempt $((attempts + 1)) of $MAX_GATE_ATTEMPTS)."
|
|
1168
|
-
gh api "repos/$REPO/actions/workflows/work-router.yml/dispatches" \
|
|
1169
|
-
--method POST \
|
|
1170
|
-
-f ref="$DEFAULT_BRANCH" \
|
|
1171
|
-
-f "inputs[operation]=merge-gate" \
|
|
1172
|
-
-f "inputs[pr-number]=$pr" \
|
|
1173
|
-
-f "inputs[ci-conclusion]=$ci_conclusion" \
|
|
1174
|
-
-f "inputs[ci-run-id]=$ci_run_id" \
|
|
1175
|
-
-f "inputs[attempts_so_far]=$attempts"
|
|
1176
|
-
}
|
|
1177
|
-
|
|
1178
|
-
if [ "$ACTION" = "closed" ]; then
|
|
1179
|
-
# The target branch just moved. Any previously-mergeable bot PR may now conflict.
|
|
1180
|
-
# The just-merged PR is now closed and is skipped by maybe_dispatch's state check.
|
|
1181
|
-
gh api "repos/$REPO/pulls?state=open&per_page=100" \
|
|
1182
|
-
--jq '.[] | select(.user.type == "Bot" and .draft == false) | .number' |
|
|
1183
|
-
while IFS= read -r pr; do
|
|
1184
|
-
maybe_dispatch "$pr"
|
|
1185
|
-
done
|
|
1186
|
-
|
|
1187
|
-
# Stacked delivery: the merged pull request merged into a feature branch, not the
|
|
1188
|
-
# default branch, so this is a child landing. If the feature branch has no open
|
|
1189
|
-
# children left and its root pull request is green, the stack is complete and a
|
|
1190
|
-
# comment on the root pull request tells the human it is ready for the root merge.
|
|
1191
|
-
# Repos without stack markers never reach the interesting part: the event PR's base
|
|
1192
|
-
# was the default branch, so the guard below fails immediately. Fully idempotent:
|
|
1193
|
-
# the completion comment carries its own marker, and a run that finds it already
|
|
1194
|
-
# present exits without writing anything.
|
|
1195
|
-
event_base=$(gh api "repos/$REPO/pulls/$EVENT_PR" --jq '.base.ref // empty' 2>/dev/null || echo '')
|
|
1196
|
-
if [ -n "$event_base" ] && [ "$event_base" != "$DEFAULT_BRANCH" ]; then
|
|
1197
|
-
echo "PR #$EVENT_PR merged into a feature branch; checking the stack."
|
|
1198
|
-
# The implement worker's pull request closes exactly its own child issue, so
|
|
1199
|
-
# the closed issue comes from the linked closing references (the resolved form
|
|
1200
|
-
# GitHub computes whatever keyword the agent wrote), with the body text as
|
|
1201
|
-
# fallback for a PR created before that guarantee.
|
|
1202
|
-
closed_issue=$(gh pr view "$EVENT_PR" --repo "$REPO" --json closingIssuesReferences \
|
|
1203
|
-
--jq '.closingIssuesReferences[0].number // empty' 2>/dev/null || true)
|
|
1204
|
-
[ -n "$closed_issue" ] || closed_issue=$(gh api "repos/$REPO/pulls/$EVENT_PR" --jq '.body // ""' \
|
|
1205
|
-
| grep -oiE '(closes?|fixes?|resolves?) #[0-9]+' | head -1 | grep -oE '[0-9]+' || true)
|
|
1206
|
-
if [ -z "$closed_issue" ]; then
|
|
1207
|
-
echo "PR #$EVENT_PR closes no issue; not a stack child. Nothing to do."
|
|
1126
|
+
# Stacked delivery: the merged pull request merged into a feature branch, not the
|
|
1127
|
+
# default branch, so this is a child landing. If the feature branch has no open
|
|
1128
|
+
# children left and its root pull request is green, the stack is complete and a
|
|
1129
|
+
# comment on the root pull request tells the human it is ready for the root merge.
|
|
1130
|
+
# Repos without stack markers never reach the interesting part: the event PR's base
|
|
1131
|
+
# was the default branch, so the guard below fails immediately. Fully idempotent:
|
|
1132
|
+
# the completion comment carries its own marker, and a run that finds it already
|
|
1133
|
+
# present exits without writing anything.
|
|
1134
|
+
event_base=$(gh api "repos/$REPO/pulls/$EVENT_PR" --jq '.base.ref // empty' 2>/dev/null || echo '')
|
|
1135
|
+
if [ -n "$event_base" ] && [ "$event_base" != "$DEFAULT_BRANCH" ]; then
|
|
1136
|
+
echo "PR #$EVENT_PR merged into a feature branch; checking the stack."
|
|
1137
|
+
# The implement worker's pull request closes exactly its own child issue, so
|
|
1138
|
+
# the closed issue comes from the linked closing references (the resolved form
|
|
1139
|
+
# GitHub computes whatever keyword the agent wrote), with the body text as
|
|
1140
|
+
# fallback for a PR created before that guarantee.
|
|
1141
|
+
closed_issue=$(gh pr view "$EVENT_PR" --repo "$REPO" --json closingIssuesReferences \
|
|
1142
|
+
--jq '.closingIssuesReferences[0].number // empty' 2>/dev/null || true)
|
|
1143
|
+
[ -n "$closed_issue" ] || closed_issue=$(gh api "repos/$REPO/pulls/$EVENT_PR" --jq '.body // ""' \
|
|
1144
|
+
| grep -oiE '(closes?|fixes?|resolves?) #[0-9]+' | head -1 | grep -oE '[0-9]+' || true)
|
|
1145
|
+
if [ -z "$closed_issue" ]; then
|
|
1146
|
+
echo "PR #$EVENT_PR closes no issue; not a stack child. Nothing to do."
|
|
1147
|
+
else
|
|
1148
|
+
# Refine stamps the split-parent marker on every child body it hands over, so
|
|
1149
|
+
# the child's own body names the feature issue. The reverse sub-issues
|
|
1150
|
+
# listing has no equivalent per-child lookup: the sub-issues API only walks
|
|
1151
|
+
# downward from the parent.
|
|
1152
|
+
parent=$(printf '%s' "$(gh issue view "$closed_issue" --repo "$REPO" --json body --jq '.body // ""' 2>/dev/null || echo '')" \
|
|
1153
|
+
| grep -oE '<!-- split-parent: [0-9]+ -->' | head -1 | grep -oE '[0-9]+' || true)
|
|
1154
|
+
if [ -z "$parent" ]; then
|
|
1155
|
+
echo "#$closed_issue is not a stack child issue (no parent found); nothing stack-related to do."
|
|
1208
1156
|
else
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
#
|
|
1212
|
-
#
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1157
|
+
root_pr=$(printf '%s' "$(gh issue view "$parent" --repo "$REPO" --json body --jq '.body // ""' 2>/dev/null || echo '')" \
|
|
1158
|
+
| grep -oE '<!-- stack-root-pr: [0-9]+ -->' | head -1 | grep -oE '[0-9]+' || true)
|
|
1159
|
+
# Case-insensitive on purpose, here and in the child count below: `gh pr view` and
|
|
1160
|
+
# `gh issue view` read GraphQL and answer OPEN, where the REST API answers open. An
|
|
1161
|
+
# exact `open` never matched, so no root was ever open and no child was ever counted
|
|
1162
|
+
# open, and the stack-complete announcement could not fire (Lyceum #44, router run
|
|
1163
|
+
# 38752856518: "Stack #44 has no open root pull request" with #70 open).
|
|
1164
|
+
root_open=false
|
|
1165
|
+
[ -n "$root_pr" ] && root_open=$(gh pr view "$root_pr" --repo "$REPO" --json state --jq '.state' 2>/dev/null | grep -qix open && echo true || echo false)
|
|
1166
|
+
if [ "$root_open" != true ]; then
|
|
1167
|
+
echo "Stack #$parent has no open root pull request; nothing to announce."
|
|
1217
1168
|
else
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
#
|
|
1221
|
-
#
|
|
1222
|
-
#
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1169
|
+
# The same child listing refine uses to hand work out: the sub-issues
|
|
1170
|
+
# link safe_outputs writes, with the body-marker search as fallback. A
|
|
1171
|
+
# stack parent always has children, so a listing that finds none means
|
|
1172
|
+
# the lookups failed, and announcing completion of a stack we cannot
|
|
1173
|
+
# see would be wrong -- stay silent and let a rerun decide.
|
|
1174
|
+
children=$(gh api "repos/$REPO/issues/$parent/sub_issues" --jq '.[].number' 2>/dev/null || true)
|
|
1175
|
+
if [ -z "$children" ]; then
|
|
1176
|
+
children=$(gh issue list --repo "$REPO" --state open --limit 50 \
|
|
1177
|
+
--search "\"<!-- split-parent: ${parent} -->\" in:body" --json number --jq '.[].number' || true)
|
|
1178
|
+
fi
|
|
1179
|
+
if [ -z "$children" ]; then
|
|
1180
|
+
echo "Cannot list any child of stack #$parent; leaving completion undetected for a rerun."
|
|
1229
1181
|
else
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
# landed counts as done even before the gate closes its issue a moment later.
|
|
1248
|
-
for child in $children; do
|
|
1249
|
-
[ "$child" = "$closed_issue" ] && continue
|
|
1250
|
-
meta=$(gh issue view "$child" --repo "$REPO" --json state,labels,body 2>/dev/null || echo '{}')
|
|
1251
|
-
printf '%s' "$meta" | jq -e '(.state // "" | ascii_downcase) == "open"' >/dev/null || continue
|
|
1252
|
-
printf '%s' "$meta" | jq -e '[.labels[]?.name] | (index("implement") != null) and (index("bot-working") == null)' >/dev/null || continue
|
|
1253
|
-
blockers=$(printf '%s' "$meta" | jq -r '.body // ""' | grep -oiE 'blocked by #[0-9]+' | grep -oE '[0-9]+' | sort -u || true)
|
|
1254
|
-
[ -n "$blockers" ] || continue
|
|
1255
|
-
waiting=""
|
|
1256
|
-
for b in $blockers; do
|
|
1257
|
-
[ "$b" = "$closed_issue" ] && continue
|
|
1258
|
-
if gh issue view "$b" --repo "$REPO" --json state --jq '.state' 2>/dev/null | grep -qix open; then
|
|
1259
|
-
waiting="$waiting #$b"
|
|
1260
|
-
fi
|
|
1261
|
-
done
|
|
1262
|
-
if [ -n "$waiting" ]; then
|
|
1263
|
-
echo "#$child still waits on$waiting."
|
|
1264
|
-
continue
|
|
1182
|
+
# Release the children this landing unblocks. check-stack-branch holds a child
|
|
1183
|
+
# whose `Blocked by #N` sibling is still open, and nothing else re-dispatches it,
|
|
1184
|
+
# so the landing does: re-adding `implement` with the App token is a bot label
|
|
1185
|
+
# event, which the router routes straight to implement. The child that just
|
|
1186
|
+
# landed counts as done even before the gate closes its issue a moment later.
|
|
1187
|
+
for child in $children; do
|
|
1188
|
+
[ "$child" = "$closed_issue" ] && continue
|
|
1189
|
+
meta=$(gh issue view "$child" --repo "$REPO" --json state,labels,body 2>/dev/null || echo '{}')
|
|
1190
|
+
printf '%s' "$meta" | jq -e '(.state // "" | ascii_downcase) == "open"' >/dev/null || continue
|
|
1191
|
+
printf '%s' "$meta" | jq -e '[.labels[]?.name] | (index("implement") != null) and (index("bot-working") == null)' >/dev/null || continue
|
|
1192
|
+
blockers=$(printf '%s' "$meta" | jq -r '.body // ""' | grep -oiE 'blocked by #[0-9]+' | grep -oE '[0-9]+' | sort -u || true)
|
|
1193
|
+
[ -n "$blockers" ] || continue
|
|
1194
|
+
waiting=""
|
|
1195
|
+
for b in $blockers; do
|
|
1196
|
+
[ "$b" = "$closed_issue" ] && continue
|
|
1197
|
+
if gh issue view "$b" --repo "$REPO" --json state --jq '.state' 2>/dev/null | grep -qix open; then
|
|
1198
|
+
waiting="$waiting #$b"
|
|
1265
1199
|
fi
|
|
1266
|
-
gh issue edit "$child" --repo "$REPO" --remove-label implement >/dev/null 2>&1 || true
|
|
1267
|
-
gh issue edit "$child" --repo "$REPO" --add-label implement >/dev/null
|
|
1268
|
-
echo "Released #$child: everything it was blocked by has landed."
|
|
1269
1200
|
done
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1201
|
+
if [ -n "$waiting" ]; then
|
|
1202
|
+
echo "#$child still waits on$waiting."
|
|
1203
|
+
continue
|
|
1204
|
+
fi
|
|
1205
|
+
gh issue edit "$child" --repo "$REPO" --remove-label implement >/dev/null 2>&1 || true
|
|
1206
|
+
gh issue edit "$child" --repo "$REPO" --add-label implement >/dev/null
|
|
1207
|
+
echo "Released #$child: everything it was blocked by has landed."
|
|
1208
|
+
done
|
|
1209
|
+
open_children=0
|
|
1210
|
+
for child in $children; do
|
|
1211
|
+
# Landed, though the gate may not have closed its issue yet.
|
|
1212
|
+
[ "$child" = "$closed_issue" ] && continue
|
|
1213
|
+
if gh issue view "$child" --repo "$REPO" --json state --jq '.state' 2>/dev/null | grep -qix open; then
|
|
1214
|
+
open_children=$((open_children + 1))
|
|
1215
|
+
fi
|
|
1216
|
+
done
|
|
1217
|
+
if [ "$open_children" -gt 0 ]; then
|
|
1218
|
+
echo "$open_children child issue(s) still open on stack #$parent; the stack continues."
|
|
1219
|
+
else
|
|
1220
|
+
# The root pull request must be green and mergeable. Green: a successful
|
|
1221
|
+
# CI run on its head SHA. Mergeable: the same on-demand computation
|
|
1222
|
+
# every gate dispatch already relies on; UNKNOWN settles in the
|
|
1223
|
+
# background, so ask until it does, like mergeable_state() does.
|
|
1224
|
+
root_state=UNKNOWN
|
|
1225
|
+
for _ in 1 2 3 4 5 6; do
|
|
1226
|
+
root_state=$(gh pr view "$root_pr" --repo "$REPO" --json mergeable --jq '.mergeable' 2>/dev/null || echo UNKNOWN)
|
|
1227
|
+
[ "$root_state" = "UNKNOWN" ] || break
|
|
1228
|
+
sleep 5
|
|
1277
1229
|
done
|
|
1278
|
-
|
|
1279
|
-
|
|
1230
|
+
root_sha=$(gh api "repos/$REPO/pulls/$root_pr" --jq '.head.sha')
|
|
1231
|
+
root_ci=$(gh api "repos/$REPO/actions/runs?head_sha=$root_sha&per_page=100" \
|
|
1232
|
+
--jq '[.workflow_runs[]
|
|
1233
|
+
| select(.name == env.CI_WORKFLOW_NAME and .status == "completed"
|
|
1234
|
+
and .conclusion == "success")]
|
|
1235
|
+
| sort_by(.created_at) | last // empty')
|
|
1236
|
+
if [ -z "$root_ci" ] || [ "$root_state" != "MERGEABLE" ]; then
|
|
1237
|
+
echo "Root PR #$root_pr is not green/mergeable yet; the human will merge when it is."
|
|
1280
1238
|
else
|
|
1281
|
-
|
|
1282
|
-
#
|
|
1283
|
-
#
|
|
1284
|
-
#
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
sleep 5
|
|
1290
|
-
done
|
|
1291
|
-
root_sha=$(gh api "repos/$REPO/pulls/$root_pr" --jq '.head.sha')
|
|
1292
|
-
root_ci=$(gh api "repos/$REPO/actions/runs?head_sha=$root_sha&per_page=100" \
|
|
1293
|
-
--jq '[.workflow_runs[]
|
|
1294
|
-
| select(.name == env.CI_WORKFLOW_NAME and .status == "completed"
|
|
1295
|
-
and .conclusion == "success")]
|
|
1296
|
-
| sort_by(.created_at) | last // empty')
|
|
1297
|
-
if [ -z "$root_ci" ] || [ "$root_state" != "MERGEABLE" ]; then
|
|
1298
|
-
echo "Root PR #$root_pr is not green/mergeable yet; the human will merge when it is."
|
|
1239
|
+
completion_marker='<!-- stack-complete -->'
|
|
1240
|
+
# The announcement is a pull request comment, and pull request
|
|
1241
|
+
# comments live at the issue endpoint for the pull request number,
|
|
1242
|
+
# so the marker is looked for where the comment was posted.
|
|
1243
|
+
already=$(gh api "repos/$REPO/issues/$root_pr/comments?per_page=100" \
|
|
1244
|
+
--jq "[.[] | select(.body | contains(\"$completion_marker\"))] | length" 2>/dev/null || echo 0)
|
|
1245
|
+
if [ "${already:-0}" -gt 0 ]; then
|
|
1246
|
+
echo "Stack #$parent already has a completion announcement; staying silent."
|
|
1299
1247
|
else
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
# comments live at the issue endpoint for the pull request number,
|
|
1303
|
-
# so the marker is looked for where the comment was posted.
|
|
1304
|
-
already=$(gh api "repos/$REPO/issues/$root_pr/comments?per_page=100" \
|
|
1305
|
-
--jq "[.[] | select(.body | contains(\"$completion_marker\"))] | length" 2>/dev/null || echo 0)
|
|
1306
|
-
if [ "${already:-0}" -gt 0 ]; then
|
|
1307
|
-
echo "Stack #$parent already has a completion announcement; staying silent."
|
|
1308
|
-
else
|
|
1309
|
-
gh pr comment "$root_pr" --repo "$REPO" --body "Stack complete: all children have merged into the feature branch and CI is green. This root pull request is ready for a human to review and merge into $DEFAULT_BRANCH. $completion_marker"
|
|
1310
|
-
echo "Stack #$parent complete: announced root PR #$root_pr."
|
|
1311
|
-
fi
|
|
1248
|
+
gh pr comment "$root_pr" --repo "$REPO" --body "Stack complete: all children have merged into the feature branch and CI is green. This root pull request is ready for a human to review and merge into $DEFAULT_BRANCH. $completion_marker"
|
|
1249
|
+
echo "Stack #$parent complete: announced root PR #$root_pr."
|
|
1312
1250
|
fi
|
|
1313
1251
|
fi
|
|
1314
|
-
|
|
1252
|
+
fi
|
|
1315
1253
|
fi
|
|
1316
1254
|
fi
|
|
1317
1255
|
fi
|
|
1318
1256
|
fi
|
|
1319
|
-
else
|
|
1320
|
-
maybe_dispatch "$EVENT_PR"
|
|
1321
1257
|
fi
|
|
1322
1258
|
|
|
1323
1259
|
reconcile-bot-pr-runs:
|
|
@@ -1347,55 +1283,37 @@ jobs:
|
|
|
1347
1283
|
private-key: ${{ secrets.BOT_PRIVATE_KEY }}
|
|
1348
1284
|
# Runs only the default-branch workflow and never checks out pull-request code.
|
|
1349
1285
|
# External users cannot impersonate github-actions[bot].
|
|
1350
|
-
|
|
1286
|
+
# Moving pull requests towards a merge is the merge train's, which this schedule also ticks.
|
|
1287
|
+
- name: Approve trusted bot CI and clear stale reservations
|
|
1351
1288
|
env:
|
|
1352
1289
|
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
|
1353
1290
|
REPO: ${{ github.repository }}
|
|
1354
|
-
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
|
1355
1291
|
run: |
|
|
1356
1292
|
set -euo pipefail
|
|
1357
1293
|
|
|
1358
|
-
# GitHub computes mergeability on demand. The first read of a pull request nobody has
|
|
1359
|
-
# opened recently answers UNKNOWN through the API and null through REST, while it works
|
|
1360
|
-
# the answer out in the background. Reading once therefore reports "not conflicting"
|
|
1361
|
-
# for precisely the stale conflicting pull requests that need the gate. Ask until it
|
|
1362
|
-
# settles.
|
|
1363
|
-
mergeable_state() {
|
|
1364
|
-
local pr="$1" state=UNKNOWN
|
|
1365
|
-
for _ in 1 2 3 4 5 6; do
|
|
1366
|
-
state=$(gh pr view "$pr" --repo "$REPO" --json mergeable --jq '.mergeable' 2>/dev/null || echo UNKNOWN)
|
|
1367
|
-
[ "$state" = "UNKNOWN" ] || break
|
|
1368
|
-
sleep 5
|
|
1369
|
-
done
|
|
1370
|
-
echo "$state"
|
|
1371
|
-
}
|
|
1372
|
-
|
|
1373
|
-
# A gate already queued or running for this pull request will read the same CI verdict;
|
|
1374
|
-
# a second dispatch would wait in the belt's queue and run the same gate twice.
|
|
1375
|
-
gate_is_live() {
|
|
1376
|
-
local pr="$1" status live
|
|
1377
|
-
for status in queued in_progress waiting pending; do
|
|
1378
|
-
live=$(gh api "repos/$REPO/actions/workflows/work-router.yml/runs?status=$status&per_page=100" \
|
|
1379
|
-
--jq "[.workflow_runs[] | select(.display_title == \"Working (Merge Gate): PR #${pr}\")] | length" 2>/dev/null || echo 0)
|
|
1380
|
-
[ "${live:-0}" -eq 0 ] || return 0
|
|
1381
|
-
done
|
|
1382
|
-
return 1
|
|
1383
|
-
}
|
|
1384
|
-
|
|
1385
1294
|
# Stale reservation sweep. Labelling ten issues at once labels all ten immediately,
|
|
1386
|
-
# because authorize-bot-work is keyed per issue, while implement
|
|
1295
|
+
# because authorize-bot-work is keyed per issue, while each implement queue lets exactly
|
|
1387
1296
|
# one run at a time. A queued run that is cancelled therefore leaves its issue
|
|
1388
1297
|
# carrying bot-working with no run and no pull request: parked, invisible, and not
|
|
1389
1298
|
# re-triggerable, because classify refuses an issue that is already reserved.
|
|
1299
|
+
#
|
|
1300
|
+
# The live runs are read by status, not as the repository's latest hundred runs. Every
|
|
1301
|
+
# comment and label is a router run, so a busy repository pushes an implement that has
|
|
1302
|
+
# waited two hours in the queue out of the latest hundred, and the sweep then cleared
|
|
1303
|
+
# bot-working from issues whose runs were alive and about to start (Pliny-Bot #693 and
|
|
1304
|
+
# #707, both still queued from 07:32). Runs that are not finished are few, whatever
|
|
1305
|
+
# their age.
|
|
1306
|
+
active_runs=$(for status in queued in_progress pending waiting requested; do
|
|
1307
|
+
gh api "repos/$REPO/actions/runs?status=$status&per_page=100" --jq '.workflow_runs[]' 2>/dev/null || true
|
|
1308
|
+
done | jq -s '{workflow_runs: .}')
|
|
1390
1309
|
gh issue list --repo "$REPO" --label bot-working --state open --limit 100 \
|
|
1391
1310
|
--json number --jq '.[].number' |
|
|
1392
1311
|
while IFS= read -r issue; do
|
|
1393
1312
|
[ -n "$issue" ] || continue
|
|
1394
1313
|
|
|
1395
|
-
live=$(
|
|
1396
|
-
--jq "[.workflow_runs[]
|
|
1314
|
+
live=$(jq "[.workflow_runs[]
|
|
1397
1315
|
| select(.status == \"queued\" or .status == \"in_progress\" or .status == \"pending\" or .status == \"waiting\")
|
|
1398
|
-
| select((.display_title + \" \") | test(\"#${issue}[^0-9]\"))] | length")
|
|
1316
|
+
| select((.display_title + \" \") | test(\"#${issue}[^0-9]\"))] | length" <<<"$active_runs")
|
|
1399
1317
|
[ "${live:-0}" -eq 0 ] || continue
|
|
1400
1318
|
|
|
1401
1319
|
has_pr=$(gh pr list --repo "$REPO" --state open --json body \
|
|
@@ -1419,7 +1337,7 @@ jobs:
|
|
|
1419
1337
|
)
|
|
1420
1338
|
| [.number, .head.ref, .head.sha] | @tsv
|
|
1421
1339
|
' |
|
|
1422
|
-
while IFS=$'\t' read -r
|
|
1340
|
+
while IFS=$'\t' read -r _ branch _; do
|
|
1423
1341
|
gh api "repos/$REPO/actions/runs?status=action_required&branch=$branch" \
|
|
1424
1342
|
--jq '.workflow_runs[]
|
|
1425
1343
|
| select(
|
|
@@ -1431,110 +1349,6 @@ jobs:
|
|
|
1431
1349
|
echo "Approving trusted bot run $run_id"
|
|
1432
1350
|
gh api "repos/$REPO/actions/runs/$run_id/approve" --method POST
|
|
1433
1351
|
done
|
|
1434
|
-
|
|
1435
|
-
ci=$(gh api "repos/$REPO/actions/runs?head_sha=$head_sha&per_page=100" \
|
|
1436
|
-
--jq '[.workflow_runs[]
|
|
1437
|
-
| select(
|
|
1438
|
-
.name == env.CI_WORKFLOW_NAME
|
|
1439
|
-
and .event == "pull_request"
|
|
1440
|
-
and .status == "completed"
|
|
1441
|
-
and (.conclusion == "success" or .conclusion == "failure")
|
|
1442
|
-
)
|
|
1443
|
-
] | sort_by(.created_at) | last // empty | [.id, .conclusion, .updated_at] | @tsv')
|
|
1444
|
-
if [ -z "$ci" ]; then
|
|
1445
|
-
# A conflicting pull request has no merge ref for GitHub to build, so a
|
|
1446
|
-
# `pull_request` CI workflow can never run on that head: refs/pull/N/merge is a
|
|
1447
|
-
# 404 for as long as the conflict lasts. Waiting for a verdict that cannot arrive
|
|
1448
|
-
# deadlocks the belt, because resolving the conflict is the gate's own job. Fall
|
|
1449
|
-
# back to the branch's last verdict so the gate runs; it stays on the remediation
|
|
1450
|
-
# path and cannot merge on a stale conclusion.
|
|
1451
|
-
mergeable_now=$(mergeable_state "$pr_number")
|
|
1452
|
-
if [ "$mergeable_now" = "CONFLICTING" ]; then
|
|
1453
|
-
ci=$(gh api "repos/$REPO/actions/runs?branch=$branch&per_page=100" \
|
|
1454
|
-
--jq '[.workflow_runs[]
|
|
1455
|
-
| select(
|
|
1456
|
-
.name == env.CI_WORKFLOW_NAME
|
|
1457
|
-
and .event == "pull_request"
|
|
1458
|
-
and .status == "completed"
|
|
1459
|
-
and (.conclusion == "success" or .conclusion == "failure")
|
|
1460
|
-
)
|
|
1461
|
-
] | sort_by(.created_at) | last // empty | [.id, .conclusion, .updated_at] | @tsv')
|
|
1462
|
-
fi
|
|
1463
|
-
if [ -z "$ci" ]; then
|
|
1464
|
-
echo "::warning::PR #$pr_number head $head_sha has no completed $CI_WORKFLOW_NAME run; the belt cannot gate it until CI runs."
|
|
1465
|
-
continue
|
|
1466
|
-
fi
|
|
1467
|
-
echo "PR #$pr_number conflicts, so CI cannot run on $head_sha at all; gating on the branch's last verdict."
|
|
1468
|
-
fi
|
|
1469
|
-
|
|
1470
|
-
IFS=$'\t' read -r ci_run_id ci_conclusion ci_finished <<<"$ci"
|
|
1471
|
-
|
|
1472
|
-
# A verdict is a comment carrying BOTH the gate marker and a `**Verdict:**`
|
|
1473
|
-
# line; progress comments and failed attempts deliberately carry neither
|
|
1474
|
-
# matching pair, so a crashed or OOM-killed gate never masks a PR from
|
|
1475
|
-
# later sweeps. A recorded verdict only counts when it is NEWER than the
|
|
1476
|
-
# latest CI run; the old existence check made the gate one-shot per PR.
|
|
1477
|
-
# Attempts are failed gates (the attempt marker) since this CI verdict.
|
|
1478
|
-
latest_verdict=$(gh api "repos/$REPO/issues/$pr_number/comments?per_page=100" \
|
|
1479
|
-
--jq '[.[] | select((.body | contains("<!-- agent-merge-gate -->")) and (.body | contains("**Verdict:**"))) | .created_at] | max // empty')
|
|
1480
|
-
linked_issue=$(gh pr view "$pr_number" --repo "$REPO" --json closingIssuesReferences --jq '.closingIssuesReferences[0].number // empty' 2>/dev/null || true)
|
|
1481
|
-
if [ -n "$linked_issue" ]; then
|
|
1482
|
-
issue_verdict=$(gh api "repos/$REPO/issues/$linked_issue/comments?per_page=100" \
|
|
1483
|
-
--jq '[.[] | select((.body | contains("<!-- agent-merge-gate -->")) and (.body | contains("**Verdict:**"))) | .created_at] | max // empty')
|
|
1484
|
-
if [ -n "$issue_verdict" ] && { [ -z "$latest_verdict" ] || [ "$issue_verdict" \> "$latest_verdict" ]; }; then
|
|
1485
|
-
latest_verdict="$issue_verdict"
|
|
1486
|
-
fi
|
|
1487
|
-
fi
|
|
1488
|
-
# A verdict parks the CODE it was given on, not the CI run that prompted it. This
|
|
1489
|
-
# used to compare against the CI finish time, which made the park worthless: any
|
|
1490
|
-
# later CI run on the same commits -- a re-run, a nightly, a rebuild triggered by
|
|
1491
|
-
# main moving -- was newer than the verdict, so the belt re-dispatched a pull
|
|
1492
|
-
# request a human had already been handed, and the attempt counter below reset to
|
|
1493
|
-
# zero at the same time and handed it a fresh budget of six full gate runs.
|
|
1494
|
-
# Lyceum PR #13 sat parked by a `review` verdict for six days while this happened.
|
|
1495
|
-
#
|
|
1496
|
-
# The newest commit on the branch is the right key. A gate verdict newer than it
|
|
1497
|
-
# applies to the current head, so the park holds however often CI runs. Push a
|
|
1498
|
-
# commit -- which is what apply-review and a `remediated` verdict both do -- and
|
|
1499
|
-
# the head moves past the verdict and the gate decides again, which is correct:
|
|
1500
|
-
# that is new code nobody has ruled on.
|
|
1501
|
-
head_committed=$(gh pr view "$pr_number" --repo "$REPO" \
|
|
1502
|
-
--json commits --jq '[.commits[].committedDate] | max // empty' 2>/dev/null || true)
|
|
1503
|
-
verdict_horizon="${head_committed:-$ci_finished}"
|
|
1504
|
-
if [ -n "$latest_verdict" ] && [ "$latest_verdict" \> "$verdict_horizon" ]; then
|
|
1505
|
-
echo "PR #$pr_number: gate verdict ($latest_verdict) is newer than its head commit ($verdict_horizon); the park holds, skipping"
|
|
1506
|
-
continue
|
|
1507
|
-
fi
|
|
1508
|
-
|
|
1509
|
-
attempts=$(gh api "repos/$REPO/issues/$pr_number/comments?per_page=100" \
|
|
1510
|
-
--jq '[.[] | select(.body | contains("<!-- agent-merge-gate-attempt -->")) | select(.created_at > "'"$verdict_horizon"'") ] | length')
|
|
1511
|
-
if [ -n "$linked_issue" ]; then
|
|
1512
|
-
issue_attempts=$(gh api "repos/$REPO/issues/$linked_issue/comments?per_page=100" \
|
|
1513
|
-
--jq '[.[] | select(.body | contains("<!-- agent-merge-gate-attempt -->")) | select(.created_at > "'"$verdict_horizon"'") ] | length')
|
|
1514
|
-
attempts=$((attempts + issue_attempts))
|
|
1515
|
-
fi
|
|
1516
|
-
if [ "$attempts" -ge "$MAX_GATE_ATTEMPTS" ]; then
|
|
1517
|
-
echo "PR #$pr_number already consumed $attempts gate attempts on this head, parking for review"
|
|
1518
|
-
continue
|
|
1519
|
-
fi
|
|
1520
|
-
if gate_is_live "$pr_number"; then
|
|
1521
|
-
echo "PR #$pr_number: a merge-gate run is already live; not dispatching another."
|
|
1522
|
-
continue
|
|
1523
|
-
fi
|
|
1524
|
-
|
|
1525
|
-
echo "Dispatching Merge Gate for PR #$pr_number ($ci_conclusion, run $ci_run_id, attempt $((attempts + 1)) of $MAX_GATE_ATTEMPTS)"
|
|
1526
|
-
gh api "repos/$REPO/actions/workflows/work-router.yml/dispatches" \
|
|
1527
|
-
--method POST \
|
|
1528
|
-
-f ref="$DEFAULT_BRANCH" \
|
|
1529
|
-
-f "inputs[operation]=merge-gate" \
|
|
1530
|
-
-f "inputs[pr-number]=$pr_number" \
|
|
1531
|
-
-f "inputs[ci-conclusion]=$ci_conclusion" \
|
|
1532
|
-
-f "inputs[ci-run-id]=$ci_run_id" \
|
|
1533
|
-
-f "inputs[attempts_so_far]=$attempts"
|
|
1534
|
-
|
|
1535
|
-
# Every eligible pull request in one tick. The belt queues them first in, first out
|
|
1536
|
-
# (`queue: max` on call-merge-gate); before it did, each dispatch cancelled the one
|
|
1537
|
-
# pending ahead of it, so this loop stopped after one and the rest waited an hour.
|
|
1538
1352
|
done
|
|
1539
1353
|
|
|
1540
1354
|
audit-close:
|