@plainconceptsplatform/workflows 1.5.0 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +0 -0
- 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/identify-gate-subject/action.yml +50 -7
- package/loops/actions/merge-train/action.yml +66 -0
- package/loops/actions/merge-train/merge-train.sh +424 -0
- package/loops/actions/push-agent-branch/action.yml +60 -45
- package/loops/actions/verify-route-matrix/verify-route-matrix.sh +124 -80
- package/loops/docs/diagrams.md +55 -13
- 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-merge-gate.md +75 -57
- package/loops/workflows/work-router.yml +231 -464
- package/package.json +8 -9
|
@@ -65,8 +65,15 @@ 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
|
+
&& 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'
|
|
68
72
|
&& format('Agent: Merge Gate - PR #{0}', inputs.pr-number)
|
|
69
73
|
|| github.event_name == 'workflow_dispatch'
|
|
74
|
+
&& inputs.operation == 'merge-train'
|
|
75
|
+
&& format('Agentics: Merge Train{0}', '')
|
|
76
|
+
|| github.event_name == 'workflow_dispatch'
|
|
70
77
|
&& inputs.operation == 'evidence'
|
|
71
78
|
&& format('Agentics: Evidence - PR #{0}', inputs.pr-number)
|
|
72
79
|
|| github.event_name == 'workflow_dispatch'
|
|
@@ -144,6 +151,15 @@ 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"
|
|
147
163
|
# Whether a merged pull request's verification plan is replayed and its screenshots archived on
|
|
148
164
|
# the pull request (evidence.yml). It needs nothing configured: the app starts with the root
|
|
149
165
|
# `pnpm run dev`, and a pull request with no plan costs one API call. Set it to "false" for a
|
|
@@ -161,25 +177,22 @@ on:
|
|
|
161
177
|
types: [submitted]
|
|
162
178
|
|
|
163
179
|
pull_request_target:
|
|
164
|
-
# opened / reopened / synchronize feed
|
|
165
|
-
#
|
|
166
|
-
#
|
|
167
|
-
#
|
|
180
|
+
# opened / reopened / synchronize feed bot-approve. closed feeds the merge train: a merge
|
|
181
|
+
# moved the base branch, so the next pull request in line can move. A merge into a feature
|
|
182
|
+
# branch also lands a stack child (land-stack-child), and every merge routes to evidence,
|
|
183
|
+
# which replays the pull request's verification plan.
|
|
168
184
|
types: [opened, synchronize, reopened, labeled, closed]
|
|
169
185
|
|
|
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.
|
|
186
|
+
# CI completion on a pull request moves the merge train, green or red: the train decides
|
|
187
|
+
# whether that pull request is the one in front, and what it needs.
|
|
174
188
|
#
|
|
175
189
|
# 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.
|
|
190
|
+
# whose actor is a human. A pull request opened or updated with a bot token has the bot as the
|
|
191
|
+
# actor of every CI run that follows, and no workflow_run arrives for any of them. For those
|
|
192
|
+
# the consumer CI's dispatch job (templates/ci) and the hourly schedule are the paths.
|
|
193
|
+
# Security: workflow_run runs from the default branch with secrets, but the train only ever
|
|
194
|
+
# moves pull requests from this repository opened by a trusted bot or someone with write
|
|
195
|
+
# access, and the gate's subject job checks the same again.
|
|
183
196
|
workflow_run:
|
|
184
197
|
# Mirrored from env.CI_WORKFLOW_NAME by the installer. A trigger evaluates no expression.
|
|
185
198
|
workflows: ["App: CI"]
|
|
@@ -205,6 +218,7 @@ on:
|
|
|
205
218
|
- triage
|
|
206
219
|
- apply-review
|
|
207
220
|
- merge-gate
|
|
221
|
+
- merge-train
|
|
208
222
|
- evidence
|
|
209
223
|
- audit
|
|
210
224
|
- audit-close
|
|
@@ -565,8 +579,8 @@ jobs:
|
|
|
565
579
|
# blocked by #111, modules) was dispatched alongside #111 and would have built units on a
|
|
566
580
|
# feat/45 with no modules. The in-flight check below cannot catch that: both routers ran
|
|
567
581
|
# 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
|
-
#
|
|
582
|
+
# pull request merged. The landing of the blocker releases the child (land-stack-child).
|
|
583
|
+
# Held, it keeps `implement` and loses `bot-working`, so it reads as
|
|
570
584
|
# waiting rather than stuck.
|
|
571
585
|
- name: Hold a child whose blockers have not landed
|
|
572
586
|
id: blocked
|
|
@@ -768,9 +782,9 @@ jobs:
|
|
|
768
782
|
# The merge belt is one lock for the whole repository, not one group per pull request.
|
|
769
783
|
# A batch of six overnight pull requests means every merge moves the default branch
|
|
770
784
|
# under the other five; gates running at once would each rebase onto a base another
|
|
771
|
-
# gate is about to invalidate.
|
|
772
|
-
#
|
|
773
|
-
#
|
|
785
|
+
# gate is about to invalidate. The merge train dispatches one gate at a time, oldest pull
|
|
786
|
+
# request first; the lock is what still holds when a person or a consumer CI dispatches one
|
|
787
|
+
# beside it.
|
|
774
788
|
#
|
|
775
789
|
# `queue: max` keeps up to 100 gates waiting in order. Without it GitHub keeps one pending run
|
|
776
790
|
# per group and silently cancels the previous one: in one week Numa cancelled 12 gates and
|
|
@@ -798,7 +812,64 @@ jobs:
|
|
|
798
812
|
BOT_PRIVATE_KEY: ${{ secrets.BOT_PRIVATE_KEY }}
|
|
799
813
|
AGENTMEMORY_SECRET: ${{ secrets.AGENTMEMORY_SECRET }}
|
|
800
814
|
|
|
801
|
-
# The
|
|
815
|
+
# The merge train: one step for the oldest open pull request that can move, bot or person.
|
|
816
|
+
# It merges the base branch into a green pull request that fell behind, hands a red,
|
|
817
|
+
# conflicting or green-and-current one to the gate, and skips one a person has to decide on,
|
|
818
|
+
# so a parked pull request never blocks the line. One gate in flight at a time; everything
|
|
819
|
+
# else waits untouched. It ticks on every merge, every CI completion, after every gate (this
|
|
820
|
+
# job waits for call-merge-gate in the same run), and hourly as the fallback. Deterministic:
|
|
821
|
+
# it reads GitHub and dispatches, and never checks out or runs pull request code.
|
|
822
|
+
#
|
|
823
|
+
# One tick at a time. A pending tick replaced by a newer one loses nothing: each reads the
|
|
824
|
+
# whole repository afresh.
|
|
825
|
+
merge-train:
|
|
826
|
+
needs: [classify, call-merge-gate]
|
|
827
|
+
if: >
|
|
828
|
+
always() &&
|
|
829
|
+
needs.classify.result == 'success' &&
|
|
830
|
+
!github.event.repository.fork &&
|
|
831
|
+
(
|
|
832
|
+
needs.classify.outputs.route == 'merge-train' ||
|
|
833
|
+
needs.classify.outputs.route == 'merge-gate' ||
|
|
834
|
+
needs.classify.outputs.route == 'reconcile-bot-pr-runs' ||
|
|
835
|
+
(github.event_name == 'pull_request_target' &&
|
|
836
|
+
github.event.action == 'closed' &&
|
|
837
|
+
github.event.pull_request.merged == true)
|
|
838
|
+
)
|
|
839
|
+
runs-on: [agents-aw, "affinity-${{ github.repository_id }}-${{ github.run_id }}-${{ github.run_attempt }}"]
|
|
840
|
+
timeout-minutes: 15
|
|
841
|
+
concurrency:
|
|
842
|
+
group: merge-train
|
|
843
|
+
cancel-in-progress: false
|
|
844
|
+
permissions:
|
|
845
|
+
contents: read
|
|
846
|
+
pull-requests: read
|
|
847
|
+
steps:
|
|
848
|
+
- name: Checkout workflow actions
|
|
849
|
+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
850
|
+
with:
|
|
851
|
+
persist-credentials: false
|
|
852
|
+
# The App token, not GITHUB_TOKEN: a gate dispatch and a branch update made with
|
|
853
|
+
# GITHUB_TOKEN start no workflow, so the gate would never run and CI never rerun.
|
|
854
|
+
- name: Generate App token
|
|
855
|
+
id: app-token
|
|
856
|
+
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
|
857
|
+
with:
|
|
858
|
+
app-id: ${{ secrets.BOT_APP_ID }}
|
|
859
|
+
private-key: ${{ secrets.BOT_PRIVATE_KEY }}
|
|
860
|
+
- uses: ./.github/actions/merge-train
|
|
861
|
+
with:
|
|
862
|
+
token: ${{ steps.app-token.outputs.token }}
|
|
863
|
+
ci-workflow-name: ${{ env.CI_WORKFLOW_NAME }}
|
|
864
|
+
trusted-bots: ${{ env.TRUSTED_BOTS }}
|
|
865
|
+
hold-label: ${{ env.MERGE_TRAIN_HOLD_LABEL }}
|
|
866
|
+
max-gate-attempts: ${{ env.MAX_GATE_ATTEMPTS }}
|
|
867
|
+
max-retries: ${{ env.MERGE_TRAIN_MAX_RETRIES }}
|
|
868
|
+
dry-run: ${{ env.MERGE_TRAIN_DRY_RUN }}
|
|
869
|
+
ci-run-id: ${{ needs.classify.outputs.ci-run-id }}
|
|
870
|
+
ci-conclusion: ${{ needs.classify.outputs.ci-conclusion }}
|
|
871
|
+
|
|
872
|
+
# The decision the gate parked for: a person with write access approves a pull request the
|
|
802
873
|
# gate handed to them (human-review or owner-review), and the bot merges the commit they
|
|
803
874
|
# approved. Re-running the gate would only reproduce the same parked verdict.
|
|
804
875
|
merge-approved:
|
|
@@ -841,7 +912,15 @@ jobs:
|
|
|
841
912
|
|
|
842
913
|
read -r state author head_sha < <(gh api "repos/$REPO/pulls/$PR" --jq '[.state, .user.login, .head.sha] | @tsv')
|
|
843
914
|
[ "$state" = open ] || skip "it is $state"
|
|
844
|
-
|
|
915
|
+
# The gate parks pull requests by people too. Its author needs write access, like
|
|
916
|
+
# everyone the merge train carries; a bot needs to be one this router trusts.
|
|
917
|
+
case " $TRUSTED_BOTS " in
|
|
918
|
+
*" $author "*) ;;
|
|
919
|
+
*)
|
|
920
|
+
author_permission=$(gh api "repos/$REPO/collaborators/$author/permission" --jq '.permission' 2>/dev/null || echo none)
|
|
921
|
+
case "$author_permission" in admin|maintain|write) ;; *) skip "it was opened by $author, who has $author_permission access" ;; esac
|
|
922
|
+
;;
|
|
923
|
+
esac
|
|
845
924
|
[ "$head_sha" = "$REVIEW_COMMIT" ] || skip "$REVIEWER approved ${REVIEW_COMMIT:0:7}, the head is now ${head_sha:0:7}"
|
|
846
925
|
|
|
847
926
|
# Another reviewer's change request still stands.
|
|
@@ -969,41 +1048,23 @@ jobs:
|
|
|
969
1048
|
gh api "repos/$REPO/actions/runs/$run_id/approve" --method POST
|
|
970
1049
|
done
|
|
971
1050
|
|
|
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:
|
|
1051
|
+
# A stack child landing. A pull request merged into a feature branch rather than the default
|
|
1052
|
+
# branch is one child of a stack: it may unblock siblings that waited on it, and it may be the
|
|
1053
|
+
# last one, which makes the root pull request ready for a person. Event-gated rather than a
|
|
1054
|
+
# route because a merge already routes to evidence. It NEVER checks out or runs pull request
|
|
1055
|
+
# code; it reads issues and pull requests through the API and writes labels and one comment.
|
|
1056
|
+
land-stack-child:
|
|
989
1057
|
needs: classify
|
|
990
1058
|
if: >
|
|
991
1059
|
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
|
-
)
|
|
1060
|
+
github.event.action == 'closed' &&
|
|
1061
|
+
github.event.pull_request.merged == true &&
|
|
1062
|
+
github.event.pull_request.base.ref != github.event.repository.default_branch
|
|
1001
1063
|
runs-on: [agents-aw, "affinity-${{ github.repository_id }}-${{ github.run_id }}-${{ github.run_attempt }}"]
|
|
1002
1064
|
timeout-minutes: 10
|
|
1003
1065
|
permissions:
|
|
1004
1066
|
contents: read
|
|
1005
1067
|
pull-requests: read
|
|
1006
|
-
actions: write
|
|
1007
1068
|
steps:
|
|
1008
1069
|
- name: Checkout for App token action
|
|
1009
1070
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
@@ -1015,309 +1076,146 @@ jobs:
|
|
|
1015
1076
|
with:
|
|
1016
1077
|
app-id: ${{ secrets.BOT_APP_ID }}
|
|
1017
1078
|
private-key: ${{ secrets.BOT_PRIVATE_KEY }}
|
|
1018
|
-
- name:
|
|
1079
|
+
- name: Release unblocked children and announce a complete stack
|
|
1019
1080
|
env:
|
|
1020
1081
|
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
|
1021
1082
|
REPO: ${{ github.repository }}
|
|
1022
1083
|
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
|
1023
|
-
ACTION: ${{ github.event.action }}
|
|
1024
1084
|
EVENT_PR: ${{ github.event.pull_request.number }}
|
|
1025
1085
|
run: |
|
|
1026
1086
|
set -euo pipefail
|
|
1027
1087
|
|
|
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."
|
|
1088
|
+
# Stacked delivery: the merged pull request merged into a feature branch, not the
|
|
1089
|
+
# default branch, so this is a child landing. If the feature branch has no open
|
|
1090
|
+
# children left and its root pull request is green, the stack is complete and a
|
|
1091
|
+
# comment on the root pull request tells the human it is ready for the root merge.
|
|
1092
|
+
# Repos without stack markers never reach the interesting part: the event PR's base
|
|
1093
|
+
# was the default branch, so the guard below fails immediately. Fully idempotent:
|
|
1094
|
+
# the completion comment carries its own marker, and a run that finds it already
|
|
1095
|
+
# present exits without writing anything.
|
|
1096
|
+
event_base=$(gh api "repos/$REPO/pulls/$EVENT_PR" --jq '.base.ref // empty' 2>/dev/null || echo '')
|
|
1097
|
+
if [ -n "$event_base" ] && [ "$event_base" != "$DEFAULT_BRANCH" ]; then
|
|
1098
|
+
echo "PR #$EVENT_PR merged into a feature branch; checking the stack."
|
|
1099
|
+
# The implement worker's pull request closes exactly its own child issue, so
|
|
1100
|
+
# the closed issue comes from the linked closing references (the resolved form
|
|
1101
|
+
# GitHub computes whatever keyword the agent wrote), with the body text as
|
|
1102
|
+
# fallback for a PR created before that guarantee.
|
|
1103
|
+
closed_issue=$(gh pr view "$EVENT_PR" --repo "$REPO" --json closingIssuesReferences \
|
|
1104
|
+
--jq '.closingIssuesReferences[0].number // empty' 2>/dev/null || true)
|
|
1105
|
+
[ -n "$closed_issue" ] || closed_issue=$(gh api "repos/$REPO/pulls/$EVENT_PR" --jq '.body // ""' \
|
|
1106
|
+
| grep -oiE '(closes?|fixes?|resolves?) #[0-9]+' | head -1 | grep -oE '[0-9]+' || true)
|
|
1107
|
+
if [ -z "$closed_issue" ]; then
|
|
1108
|
+
echo "PR #$EVENT_PR closes no issue; not a stack child. Nothing to do."
|
|
1109
|
+
else
|
|
1110
|
+
# Refine stamps the split-parent marker on every child body it hands over, so
|
|
1111
|
+
# the child's own body names the feature issue. The reverse sub-issues
|
|
1112
|
+
# listing has no equivalent per-child lookup: the sub-issues API only walks
|
|
1113
|
+
# downward from the parent.
|
|
1114
|
+
parent=$(printf '%s' "$(gh issue view "$closed_issue" --repo "$REPO" --json body --jq '.body // ""' 2>/dev/null || echo '')" \
|
|
1115
|
+
| grep -oE '<!-- split-parent: [0-9]+ -->' | head -1 | grep -oE '[0-9]+' || true)
|
|
1116
|
+
if [ -z "$parent" ]; then
|
|
1117
|
+
echo "#$closed_issue is not a stack child issue (no parent found); nothing stack-related to do."
|
|
1208
1118
|
else
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
#
|
|
1212
|
-
#
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1119
|
+
root_pr=$(printf '%s' "$(gh issue view "$parent" --repo "$REPO" --json body --jq '.body // ""' 2>/dev/null || echo '')" \
|
|
1120
|
+
| grep -oE '<!-- stack-root-pr: [0-9]+ -->' | head -1 | grep -oE '[0-9]+' || true)
|
|
1121
|
+
# Case-insensitive on purpose, here and in the child count below: `gh pr view` and
|
|
1122
|
+
# `gh issue view` read GraphQL and answer OPEN, where the REST API answers open. An
|
|
1123
|
+
# exact `open` never matched, so no root was ever open and no child was ever counted
|
|
1124
|
+
# open, and the stack-complete announcement could not fire (Lyceum #44, router run
|
|
1125
|
+
# 38752856518: "Stack #44 has no open root pull request" with #70 open).
|
|
1126
|
+
root_open=false
|
|
1127
|
+
[ -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)
|
|
1128
|
+
if [ "$root_open" != true ]; then
|
|
1129
|
+
echo "Stack #$parent has no open root pull request; nothing to announce."
|
|
1217
1130
|
else
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
#
|
|
1221
|
-
#
|
|
1222
|
-
#
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1131
|
+
# The same child listing refine uses to hand work out: the sub-issues
|
|
1132
|
+
# link safe_outputs writes, with the body-marker search as fallback. A
|
|
1133
|
+
# stack parent always has children, so a listing that finds none means
|
|
1134
|
+
# the lookups failed, and announcing completion of a stack we cannot
|
|
1135
|
+
# see would be wrong -- stay silent and let a rerun decide.
|
|
1136
|
+
children=$(gh api "repos/$REPO/issues/$parent/sub_issues" --jq '.[].number' 2>/dev/null || true)
|
|
1137
|
+
if [ -z "$children" ]; then
|
|
1138
|
+
children=$(gh issue list --repo "$REPO" --state open --limit 50 \
|
|
1139
|
+
--search "\"<!-- split-parent: ${parent} -->\" in:body" --json number --jq '.[].number' || true)
|
|
1140
|
+
fi
|
|
1141
|
+
if [ -z "$children" ]; then
|
|
1142
|
+
echo "Cannot list any child of stack #$parent; leaving completion undetected for a rerun."
|
|
1229
1143
|
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
|
|
1144
|
+
# Release the children this landing unblocks. check-stack-branch holds a child
|
|
1145
|
+
# whose `Blocked by #N` sibling is still open, and nothing else re-dispatches it,
|
|
1146
|
+
# so the landing does: re-adding `implement` with the App token is a bot label
|
|
1147
|
+
# event, which the router routes straight to implement. The child that just
|
|
1148
|
+
# landed counts as done even before the gate closes its issue a moment later.
|
|
1149
|
+
for child in $children; do
|
|
1150
|
+
[ "$child" = "$closed_issue" ] && continue
|
|
1151
|
+
meta=$(gh issue view "$child" --repo "$REPO" --json state,labels,body 2>/dev/null || echo '{}')
|
|
1152
|
+
printf '%s' "$meta" | jq -e '(.state // "" | ascii_downcase) == "open"' >/dev/null || continue
|
|
1153
|
+
printf '%s' "$meta" | jq -e '[.labels[]?.name] | (index("implement") != null) and (index("bot-working") == null)' >/dev/null || continue
|
|
1154
|
+
blockers=$(printf '%s' "$meta" | jq -r '.body // ""' | grep -oiE 'blocked by #[0-9]+' | grep -oE '[0-9]+' | sort -u || true)
|
|
1155
|
+
[ -n "$blockers" ] || continue
|
|
1156
|
+
waiting=""
|
|
1157
|
+
for b in $blockers; do
|
|
1158
|
+
[ "$b" = "$closed_issue" ] && continue
|
|
1159
|
+
if gh issue view "$b" --repo "$REPO" --json state --jq '.state' 2>/dev/null | grep -qix open; then
|
|
1160
|
+
waiting="$waiting #$b"
|
|
1265
1161
|
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
1162
|
done
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1163
|
+
if [ -n "$waiting" ]; then
|
|
1164
|
+
echo "#$child still waits on$waiting."
|
|
1165
|
+
continue
|
|
1166
|
+
fi
|
|
1167
|
+
gh issue edit "$child" --repo "$REPO" --remove-label implement >/dev/null 2>&1 || true
|
|
1168
|
+
gh issue edit "$child" --repo "$REPO" --add-label implement >/dev/null
|
|
1169
|
+
echo "Released #$child: everything it was blocked by has landed."
|
|
1170
|
+
done
|
|
1171
|
+
open_children=0
|
|
1172
|
+
for child in $children; do
|
|
1173
|
+
# Landed, though the gate may not have closed its issue yet.
|
|
1174
|
+
[ "$child" = "$closed_issue" ] && continue
|
|
1175
|
+
if gh issue view "$child" --repo "$REPO" --json state --jq '.state' 2>/dev/null | grep -qix open; then
|
|
1176
|
+
open_children=$((open_children + 1))
|
|
1177
|
+
fi
|
|
1178
|
+
done
|
|
1179
|
+
if [ "$open_children" -gt 0 ]; then
|
|
1180
|
+
echo "$open_children child issue(s) still open on stack #$parent; the stack continues."
|
|
1181
|
+
else
|
|
1182
|
+
# The root pull request must be green and mergeable. Green: a successful
|
|
1183
|
+
# CI run on its head SHA. Mergeable: the same on-demand computation
|
|
1184
|
+
# every gate dispatch already relies on; UNKNOWN settles in the
|
|
1185
|
+
# background, so ask until it does, like mergeable_state() does.
|
|
1186
|
+
root_state=UNKNOWN
|
|
1187
|
+
for _ in 1 2 3 4 5 6; do
|
|
1188
|
+
root_state=$(gh pr view "$root_pr" --repo "$REPO" --json mergeable --jq '.mergeable' 2>/dev/null || echo UNKNOWN)
|
|
1189
|
+
[ "$root_state" = "UNKNOWN" ] || break
|
|
1190
|
+
sleep 5
|
|
1277
1191
|
done
|
|
1278
|
-
|
|
1279
|
-
|
|
1192
|
+
root_sha=$(gh api "repos/$REPO/pulls/$root_pr" --jq '.head.sha')
|
|
1193
|
+
root_ci=$(gh api "repos/$REPO/actions/runs?head_sha=$root_sha&per_page=100" \
|
|
1194
|
+
--jq '[.workflow_runs[]
|
|
1195
|
+
| select(.name == env.CI_WORKFLOW_NAME and .status == "completed"
|
|
1196
|
+
and .conclusion == "success")]
|
|
1197
|
+
| sort_by(.created_at) | last // empty')
|
|
1198
|
+
if [ -z "$root_ci" ] || [ "$root_state" != "MERGEABLE" ]; then
|
|
1199
|
+
echo "Root PR #$root_pr is not green/mergeable yet; the human will merge when it is."
|
|
1280
1200
|
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."
|
|
1201
|
+
completion_marker='<!-- stack-complete -->'
|
|
1202
|
+
# The announcement is a pull request comment, and pull request
|
|
1203
|
+
# comments live at the issue endpoint for the pull request number,
|
|
1204
|
+
# so the marker is looked for where the comment was posted.
|
|
1205
|
+
already=$(gh api "repos/$REPO/issues/$root_pr/comments?per_page=100" \
|
|
1206
|
+
--jq "[.[] | select(.body | contains(\"$completion_marker\"))] | length" 2>/dev/null || echo 0)
|
|
1207
|
+
if [ "${already:-0}" -gt 0 ]; then
|
|
1208
|
+
echo "Stack #$parent already has a completion announcement; staying silent."
|
|
1299
1209
|
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
|
|
1210
|
+
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"
|
|
1211
|
+
echo "Stack #$parent complete: announced root PR #$root_pr."
|
|
1312
1212
|
fi
|
|
1313
1213
|
fi
|
|
1314
|
-
|
|
1214
|
+
fi
|
|
1315
1215
|
fi
|
|
1316
1216
|
fi
|
|
1317
1217
|
fi
|
|
1318
1218
|
fi
|
|
1319
|
-
else
|
|
1320
|
-
maybe_dispatch "$EVENT_PR"
|
|
1321
1219
|
fi
|
|
1322
1220
|
|
|
1323
1221
|
reconcile-bot-pr-runs:
|
|
@@ -1347,41 +1245,14 @@ jobs:
|
|
|
1347
1245
|
private-key: ${{ secrets.BOT_PRIVATE_KEY }}
|
|
1348
1246
|
# Runs only the default-branch workflow and never checks out pull-request code.
|
|
1349
1247
|
# External users cannot impersonate github-actions[bot].
|
|
1350
|
-
|
|
1248
|
+
# Moving pull requests towards a merge is the merge train's, which this schedule also ticks.
|
|
1249
|
+
- name: Approve trusted bot CI and clear stale reservations
|
|
1351
1250
|
env:
|
|
1352
1251
|
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
|
1353
1252
|
REPO: ${{ github.repository }}
|
|
1354
|
-
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
|
1355
1253
|
run: |
|
|
1356
1254
|
set -euo pipefail
|
|
1357
1255
|
|
|
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
1256
|
# Stale reservation sweep. Labelling ten issues at once labels all ten immediately,
|
|
1386
1257
|
# because authorize-bot-work is keyed per issue, while implement-global lets exactly
|
|
1387
1258
|
# one run at a time. A queued run that is cancelled therefore leaves its issue
|
|
@@ -1419,7 +1290,7 @@ jobs:
|
|
|
1419
1290
|
)
|
|
1420
1291
|
| [.number, .head.ref, .head.sha] | @tsv
|
|
1421
1292
|
' |
|
|
1422
|
-
while IFS=$'\t' read -r
|
|
1293
|
+
while IFS=$'\t' read -r _ branch _; do
|
|
1423
1294
|
gh api "repos/$REPO/actions/runs?status=action_required&branch=$branch" \
|
|
1424
1295
|
--jq '.workflow_runs[]
|
|
1425
1296
|
| select(
|
|
@@ -1431,110 +1302,6 @@ jobs:
|
|
|
1431
1302
|
echo "Approving trusted bot run $run_id"
|
|
1432
1303
|
gh api "repos/$REPO/actions/runs/$run_id/approve" --method POST
|
|
1433
1304
|
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
1305
|
done
|
|
1539
1306
|
|
|
1540
1307
|
audit-close:
|