@plainconceptsplatform/workflows 1.4.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.
@@ -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 conflict detection (detect-pr-conflicts) and
165
- # bot-approve. closed feeds detect-pr-conflicts: when a PR merges, the target branch
166
- # moved and previously-mergeable sibling bot PRs may now conflict. A merge also routes to
167
- # evidence, which replays the pull request's verification plan.
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. The classifier admits only conclusion == failure with an
171
- # attached pull request; a green run reaches the gate through the consumer CI's
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 bot pull request is opened with GITHUB_TOKEN, the CI runs that
177
- # follow have the bot as their actor, and no workflow_run arrives for any of them (four
178
- # human-actor runs produced four router runs; three bot-actor runs produced none). For bot
179
- # pull requests the consumer CI's dispatch-merge-gate job (templates/ci) and the hourly
180
- # reconcile-bot-pr-runs belt are the paths. Security: workflow_run runs from the default
181
- # branch with secrets, but the gate's subject job rejects any PR that is not open and
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
@@ -457,6 +471,21 @@ jobs:
457
471
  if [ "$existing" -gt 0 ]; then
458
472
  echo "has-open-pr=true" >> "$GITHUB_OUTPUT"
459
473
  echo "::notice::Issue #$ISSUE_NUMBER already has an open bot PR. Skipping implement to prevent duplicates."
474
+ exit 0
475
+ fi
476
+
477
+ # A person working on the issue rarely writes a closing keyword: Pliny-Bot #633 said
478
+ # "Implements #627", and an hour later the bot opened #639 for the same issue. Any open
479
+ # pull request by a person that names the issue in its title, body or branch holds it.
480
+ human=$(gh pr list --repo "$REPO" --state open --json number,author,title,body,headRefName \
481
+ --jq "[.[]
482
+ | select((.author.login | startswith(\"app/\") or endswith(\"[bot]\")) | not)
483
+ | select(((.title + \"\n\" + (.body // \"\")) | test(\"#${ISSUE_NUMBER}([^0-9]|$)\"))
484
+ or (.headRefName | test(\"(issue|gh)[-_/]?${ISSUE_NUMBER}([^0-9]|$)\"; \"i\")))
485
+ | .number] | first // empty")
486
+ if [ -n "$human" ]; then
487
+ echo "has-open-pr=true" >> "$GITHUB_OUTPUT"
488
+ echo "::notice::Open PR #$human by a person names #$ISSUE_NUMBER. Skipping implement so the two do not collide."
460
489
  else
461
490
  echo "has-open-pr=false" >> "$GITHUB_OUTPUT"
462
491
  fi
@@ -550,8 +579,8 @@ jobs:
550
579
  # blocked by #111, modules) was dispatched alongside #111 and would have built units on a
551
580
  # feat/45 with no modules. The in-flight check below cannot catch that: both routers ran
552
581
  # before either dispatch existed, and serialising runs would still start #112 before #111's
553
- # pull request merged. The landing of the blocker releases the child (the stack block in
554
- # detect-pr-conflicts). Held, it keeps `implement` and loses `bot-working`, so it reads as
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
555
584
  # waiting rather than stuck.
556
585
  - name: Hold a child whose blockers have not landed
557
586
  id: blocked
@@ -753,9 +782,9 @@ jobs:
753
782
  # The merge belt is one lock for the whole repository, not one group per pull request.
754
783
  # A batch of six overnight pull requests means every merge moves the default branch
755
784
  # under the other five; gates running at once would each rebase onto a base another
756
- # gate is about to invalidate. Serialising the belt means each gate sees a main that
757
- # only changes between gates, every merge re-arms the siblings through
758
- # detect-pr-conflicts's rescan, and the queue drains one pull request at a time.
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.
759
788
  #
760
789
  # `queue: max` keeps up to 100 gates waiting in order. Without it GitHub keeps one pending run
761
790
  # per group and silently cancels the previous one: in one week Numa cancelled 12 gates and
@@ -783,7 +812,64 @@ jobs:
783
812
  BOT_PRIVATE_KEY: ${{ secrets.BOT_PRIVATE_KEY }}
784
813
  AGENTMEMORY_SECRET: ${{ secrets.AGENTMEMORY_SECRET }}
785
814
 
786
- # The decision the gate parked for: a person with write access approves a bot pull request 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
787
873
  # gate handed to them (human-review or owner-review), and the bot merges the commit they
788
874
  # approved. Re-running the gate would only reproduce the same parked verdict.
789
875
  merge-approved:
@@ -826,7 +912,15 @@ jobs:
826
912
 
827
913
  read -r state author head_sha < <(gh api "repos/$REPO/pulls/$PR" --jq '[.state, .user.login, .head.sha] | @tsv')
828
914
  [ "$state" = open ] || skip "it is $state"
829
- case " $TRUSTED_BOTS " in *" $author "*) ;; *) skip "it was opened by $author, not the bot" ;; esac
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
830
924
  [ "$head_sha" = "$REVIEW_COMMIT" ] || skip "$REVIEWER approved ${REVIEW_COMMIT:0:7}, the head is now ${head_sha:0:7}"
831
925
 
832
926
  # Another reviewer's change request still stands.
@@ -954,41 +1048,23 @@ jobs:
954
1048
  gh api "repos/$REPO/actions/runs/$run_id/approve" --method POST
955
1049
  done
956
1050
 
957
- # Conflict watcher for bot pull requests. Merge-conflict state is only available from the
958
- # mergeability API, which the pure classifier cannot read, so this is an event-gated
959
- # deterministic dispatcher rather than a classify route (it runs alongside bot-approve on
960
- # the same pull_request_target event). It NEVER checks out or runs pull request code - it
961
- # reads mergeability through the API and dispatches the Merge Gate - so it is safe on
962
- # pull_request_target. Two entry points:
963
- # opened / reopened / synchronize → check the pull request that changed. Gated to this
964
- # repository's own branches so a fork PR never starts this App-token job.
965
- # closed & merged → the target branch moved, so re-check every open
966
- # pull request that may have just become conflicted (the "main changed after another PR
967
- # merged" case). A merge into the default branch is an inherently trusted internal event.
968
- # It dispatches only for open, non-draft pull requests whose head is this
969
- # repository AND whose CI has already concluded (dispatching while CI is still pending would
970
- # make the gate read a 'pending' verdict and prematurely ask for human review; that PR is
971
- # covered by the CI-completion route instead). The gate's subject job re-validates ownership
972
- # before any agent runs.
973
- 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:
974
1057
  needs: classify
975
1058
  if: >
976
1059
  github.event_name == 'pull_request_target' &&
977
- (
978
- (github.event.action == 'closed' && github.event.pull_request.merged == true) ||
979
- (
980
- (github.event.action == 'opened' ||
981
- github.event.action == 'reopened' ||
982
- github.event.action == 'synchronize') &&
983
- github.event.pull_request.head.repo.full_name == github.repository
984
- )
985
- )
1060
+ github.event.action == 'closed' &&
1061
+ github.event.pull_request.merged == true &&
1062
+ github.event.pull_request.base.ref != github.event.repository.default_branch
986
1063
  runs-on: [agents-aw, "affinity-${{ github.repository_id }}-${{ github.run_id }}-${{ github.run_attempt }}"]
987
1064
  timeout-minutes: 10
988
1065
  permissions:
989
1066
  contents: read
990
1067
  pull-requests: read
991
- actions: write
992
1068
  steps:
993
1069
  - name: Checkout for App token action
994
1070
  uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -1000,309 +1076,146 @@ jobs:
1000
1076
  with:
1001
1077
  app-id: ${{ secrets.BOT_APP_ID }}
1002
1078
  private-key: ${{ secrets.BOT_PRIVATE_KEY }}
1003
- - name: Dispatch Merge Gate for conflicting bot pull requests
1079
+ - name: Release unblocked children and announce a complete stack
1004
1080
  env:
1005
1081
  GH_TOKEN: ${{ steps.app-token.outputs.token }}
1006
1082
  REPO: ${{ github.repository }}
1007
1083
  DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
1008
- ACTION: ${{ github.event.action }}
1009
1084
  EVENT_PR: ${{ github.event.pull_request.number }}
1010
1085
  run: |
1011
1086
  set -euo pipefail
1012
1087
 
1013
- # Dispatch the Merge Gate for one pull request when it is a red-CI open, non-draft
1014
- # pull request on this repository whose CI has concluded - the
1015
- # conflict case is one arm of that, because a conflicting PR never gets a fresh
1016
- # CI run of its own; a merge conflict with red or concluded CI is the same
1017
- # "needs the gate" signal. A green non-conflicting PR is left for the merge belt
1018
- # to pick up when its checks finish (bot-approve / reconcile).
1019
- # GitHub computes mergeability on demand. The first read of a pull request nobody has
1020
- # opened recently answers UNKNOWN through the API and null through REST, while it works
1021
- # the answer out in the background. Reading once therefore reports "not conflicting"
1022
- # for precisely the stale conflicting pull requests that need the gate. Ask until it
1023
- # settles.
1024
- mergeable_state() {
1025
- local pr="$1" state=UNKNOWN
1026
- for _ in 1 2 3 4 5 6; do
1027
- state=$(gh pr view "$pr" --repo "$REPO" --json mergeable --jq '.mergeable' 2>/dev/null || echo UNKNOWN)
1028
- [ "$state" = "UNKNOWN" ] || break
1029
- sleep 5
1030
- done
1031
- echo "$state"
1032
- }
1033
-
1034
- # A gate already queued or running for this pull request will read the same CI verdict;
1035
- # a second dispatch would wait in the belt's queue and run the same gate twice. Same
1036
- # live-run test as the reconcile belt's stale-reservation sweep, keyed on the exact
1037
- # title run-name gives a gate dispatch.
1038
- gate_is_live() {
1039
- local pr="$1" status live
1040
- for status in queued in_progress waiting pending; do
1041
- live=$(gh api "repos/$REPO/actions/workflows/work-router.yml/runs?status=$status&per_page=100" \
1042
- --jq "[.workflow_runs[] | select(.display_title == \"Working (Merge Gate): PR #${pr}\")] | length" 2>/dev/null || echo 0)
1043
- [ "${live:-0}" -eq 0 ] || return 0
1044
- done
1045
- return 1
1046
- }
1047
-
1048
- maybe_dispatch() {
1049
- pr="$1"
1050
- [ -n "$pr" ] || return 0
1051
-
1052
- meta=$(gh api "repos/$REPO/pulls/$pr" \
1053
- --jq '{state:.state, draft:.draft, bot:.user.type, head:(.head.repo.full_name // "")}' \
1054
- 2>/dev/null || echo '')
1055
- [ -n "$meta" ] || return 0
1056
- if [ "$(jq -r '.state' <<<"$meta")" != "open" ] \
1057
- || [ "$(jq -r '.draft' <<<"$meta")" != "false" ] \
1058
- || [ "$(jq -r '.head' <<<"$meta")" != "$REPO" ]; then
1059
- return 0
1060
- fi
1061
-
1062
- # Only dispatch when CI has already concluded. A conflict while CI is still
1063
- # running is handled by the CI-completion route; dispatching now would make the
1064
- # gate read a 'pending' conclusion and prematurely ask for human review.
1065
- head_sha=$(gh api "repos/$REPO/pulls/$pr" --jq '.head.sha')
1066
- ci=$(gh api "repos/$REPO/actions/runs?head_sha=$head_sha&per_page=100" \
1067
- --jq '[.workflow_runs[]
1068
- | select(.name == env.CI_WORKFLOW_NAME and .status == "completed"
1069
- and (.conclusion == "success" or .conclusion == "failure"))]
1070
- | sort_by(.created_at) | last // empty')
1071
- if [ -z "$ci" ]; then
1072
- # A conflicting pull request has no merge ref for GitHub to build, so a
1073
- # `pull_request` CI workflow can never run on that head: refs/pull/N/merge is a
1074
- # 404 for as long as the conflict lasts. Waiting for a verdict that cannot arrive
1075
- # deadlocks the belt, because resolving the conflict is the gate's own job. Fall
1076
- # back to the branch's last verdict so the gate runs; it stays on the remediation
1077
- # path and cannot merge on a stale conclusion.
1078
- head_ref=$(gh api "repos/$REPO/pulls/$pr" --jq '.head.ref')
1079
- mergeable_now=$(mergeable_state "$pr")
1080
- if [ "$mergeable_now" != "CONFLICTING" ]; then
1081
- echo "PR #$pr has no concluded CI run; deferring to the CI-completion route."
1082
- return 0
1083
- fi
1084
- ci=$(gh api "repos/$REPO/actions/runs?branch=$head_ref&per_page=100" \
1085
- --jq '[.workflow_runs[]
1086
- | select(.name == env.CI_WORKFLOW_NAME and .status == "completed"
1087
- and (.conclusion == "success" or .conclusion == "failure"))]
1088
- | sort_by(.created_at) | last // empty')
1089
- if [ -z "$ci" ]; then
1090
- echo "PR #$pr conflicts and has no CI verdict on any head; nothing to gate on yet."
1091
- return 0
1092
- fi
1093
- echo "PR #$pr conflicts, so CI cannot run on $head_sha at all; gating on the branch's last verdict."
1094
- fi
1095
- ci_conclusion=$(jq -r '.conclusion' <<<"$ci")
1096
- ci_finished=$(jq -r '.updated_at' <<<"$ci")
1097
- ci_run_id=$(jq -r '.id' <<<"$ci")
1098
-
1099
- # GitHub computes mergeability asynchronously; poll until it is known (not null).
1100
- mergeable=""
1101
- for _ in 1 2 3 4 5 6; do
1102
- mergeable=$(gh api "repos/$REPO/pulls/$pr" --jq '.mergeable // "null"' 2>/dev/null || echo null)
1103
- [ "$mergeable" = "null" ] || break
1104
- sleep 5
1105
- done
1106
-
1107
- if [ "$mergeable" != "false" ] && [ "$ci_conclusion" != "failure" ]; then
1108
- # Mergeable and green: the reconcile belt and the PR's own checks finish it.
1109
- return 0
1110
- fi
1111
-
1112
- # A terminal verdict newer than this CI run means a gate already decided;
1113
- # failed attempts (the attempt marker) deliberately do not count.
1114
- verdict=$(gh api "repos/$REPO/issues/$pr/comments?per_page=100" \
1115
- --jq '[.[] | select((.body | contains("<!-- agent-merge-gate -->")) and (.body | contains("**Verdict:**"))) | .created_at] | max // empty')
1116
- linked_issue=$(gh pr view "$pr" --repo "$REPO" --json closingIssuesReferences --jq '.closingIssuesReferences[0].number // empty' 2>/dev/null || true)
1117
- if [ -n "$linked_issue" ]; then
1118
- issue_verdict=$(gh api "repos/$REPO/issues/$linked_issue/comments?per_page=100" \
1119
- --jq '[.[] | select((.body | contains("<!-- agent-merge-gate -->")) and (.body | contains("**Verdict:**"))) | .created_at] | max // empty')
1120
- if [ -n "$issue_verdict" ] && { [ -z "$verdict" ] || [ "$issue_verdict" \> "$verdict" ]; }; then
1121
- verdict="$issue_verdict"
1122
- fi
1123
- fi
1124
- # A verdict parks the CODE it was given on, not the CI run that prompted it.
1125
- # Comparing against the CI finish time meant any later run on the same commits
1126
- # reopened a park a human already owned. The newest commit on the branch is the
1127
- # right key: push, and the gate decides again; re-run CI, and the park holds.
1128
- head_committed=$(gh pr view "$pr" --repo "$REPO" \
1129
- --json commits --jq '[.commits[].committedDate] | max // empty' 2>/dev/null || true)
1130
- horizon="${head_committed:-$ci_finished}"
1131
- if [ -n "$verdict" ] && [ "$verdict" \> "$horizon" ]; then
1132
- echo "PR #$pr has a gate verdict newer than its head commit; the park holds, skipping."
1133
- return 0
1134
- fi
1135
-
1136
- attempts=$(gh api "repos/$REPO/issues/$pr/comments?per_page=100" \
1137
- --jq '[.[] | select(.body | contains("<!-- agent-merge-gate-attempt -->")) | select(.created_at > "'"$horizon"'") ] | length')
1138
- if [ -n "$linked_issue" ]; then
1139
- issue_attempts=$(gh api "repos/$REPO/issues/$linked_issue/comments?per_page=100" \
1140
- --jq '[.[] | select(.body | contains("<!-- agent-merge-gate-attempt -->")) | select(.created_at > "'"$horizon"'") ] | length')
1141
- attempts=$((attempts + issue_attempts))
1142
- fi
1143
- if [ "$attempts" -ge "$MAX_GATE_ATTEMPTS" ]; then
1144
- echo "PR #$pr already consumed $attempts gate attempts on this head, parking for review."
1145
- return 0
1146
- fi
1147
- if gate_is_live "$pr"; then
1148
- echo "PR #$pr: a merge-gate run is already live; not dispatching another."
1149
- return 0
1150
- fi
1151
-
1152
- echo "PR #$pr is $([ "$mergeable" = "false" ] && echo conflicting || echo red-CI) (CI $ci_conclusion); dispatching Merge Gate (attempt $((attempts + 1)) of $MAX_GATE_ATTEMPTS)."
1153
- gh api "repos/$REPO/actions/workflows/work-router.yml/dispatches" \
1154
- --method POST \
1155
- -f ref="$DEFAULT_BRANCH" \
1156
- -f "inputs[operation]=merge-gate" \
1157
- -f "inputs[pr-number]=$pr" \
1158
- -f "inputs[ci-conclusion]=$ci_conclusion" \
1159
- -f "inputs[ci-run-id]=$ci_run_id" \
1160
- -f "inputs[attempts_so_far]=$attempts"
1161
- }
1162
-
1163
- if [ "$ACTION" = "closed" ]; then
1164
- # The target branch just moved. Any previously-mergeable bot PR may now conflict.
1165
- # The just-merged PR is now closed and is skipped by maybe_dispatch's state check.
1166
- gh api "repos/$REPO/pulls?state=open&per_page=100" \
1167
- --jq '.[] | select(.user.type == "Bot" and .draft == false) | .number' |
1168
- while IFS= read -r pr; do
1169
- maybe_dispatch "$pr"
1170
- done
1171
-
1172
- # Stacked delivery: the merged pull request merged into a feature branch, not the
1173
- # default branch, so this is a child landing. If the feature branch has no open
1174
- # children left and its root pull request is green, the stack is complete and a
1175
- # comment on the root pull request tells the human it is ready for the root merge.
1176
- # Repos without stack markers never reach the interesting part: the event PR's base
1177
- # was the default branch, so the guard below fails immediately. Fully idempotent:
1178
- # the completion comment carries its own marker, and a run that finds it already
1179
- # present exits without writing anything.
1180
- event_base=$(gh api "repos/$REPO/pulls/$EVENT_PR" --jq '.base.ref // empty' 2>/dev/null || echo '')
1181
- if [ -n "$event_base" ] && [ "$event_base" != "$DEFAULT_BRANCH" ]; then
1182
- echo "PR #$EVENT_PR merged into a feature branch; checking the stack."
1183
- # The implement worker's pull request closes exactly its own child issue, so
1184
- # the closed issue comes from the linked closing references (the resolved form
1185
- # GitHub computes whatever keyword the agent wrote), with the body text as
1186
- # fallback for a PR created before that guarantee.
1187
- closed_issue=$(gh pr view "$EVENT_PR" --repo "$REPO" --json closingIssuesReferences \
1188
- --jq '.closingIssuesReferences[0].number // empty' 2>/dev/null || true)
1189
- [ -n "$closed_issue" ] || closed_issue=$(gh api "repos/$REPO/pulls/$EVENT_PR" --jq '.body // ""' \
1190
- | grep -oiE '(closes?|fixes?|resolves?) #[0-9]+' | head -1 | grep -oE '[0-9]+' || true)
1191
- if [ -z "$closed_issue" ]; then
1192
- 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."
1193
1118
  else
1194
- # Refine stamps the split-parent marker on every child body it hands over, so
1195
- # the child's own body names the feature issue. The reverse sub-issues
1196
- # listing has no equivalent per-child lookup: the sub-issues API only walks
1197
- # downward from the parent.
1198
- parent=$(printf '%s' "$(gh issue view "$closed_issue" --repo "$REPO" --json body --jq '.body // ""' 2>/dev/null || echo '')" \
1199
- | grep -oE '<!-- split-parent: [0-9]+ -->' | head -1 | grep -oE '[0-9]+' || true)
1200
- if [ -z "$parent" ]; then
1201
- echo "#$closed_issue is not a stack child issue (no parent found); nothing stack-related to do."
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."
1202
1130
  else
1203
- root_pr=$(printf '%s' "$(gh issue view "$parent" --repo "$REPO" --json body --jq '.body // ""' 2>/dev/null || echo '')" \
1204
- | grep -oE '<!-- stack-root-pr: [0-9]+ -->' | head -1 | grep -oE '[0-9]+' || true)
1205
- # Case-insensitive on purpose, here and in the child count below: `gh pr view` and
1206
- # `gh issue view` read GraphQL and answer OPEN, where the REST API answers open. An
1207
- # exact `open` never matched, so no root was ever open and no child was ever counted
1208
- # open, and the stack-complete announcement could not fire (Lyceum #44, router run
1209
- # 38752856518: "Stack #44 has no open root pull request" with #70 open).
1210
- root_open=false
1211
- [ -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)
1212
- if [ "$root_open" != true ]; then
1213
- echo "Stack #$parent has no open root pull request; nothing to announce."
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."
1214
1143
  else
1215
- # The same child listing refine uses to hand work out: the sub-issues
1216
- # link safe_outputs writes, with the body-marker search as fallback. A
1217
- # stack parent always has children, so a listing that finds none means
1218
- # the lookups failed, and announcing completion of a stack we cannot
1219
- # see would be wrong -- stay silent and let a rerun decide.
1220
- children=$(gh api "repos/$REPO/issues/$parent/sub_issues" --jq '.[].number' 2>/dev/null || true)
1221
- if [ -z "$children" ]; then
1222
- children=$(gh issue list --repo "$REPO" --state open --limit 50 \
1223
- --search "\"<!-- split-parent: ${parent} -->\" in:body" --json number --jq '.[].number' || true)
1224
- fi
1225
- if [ -z "$children" ]; then
1226
- echo "Cannot list any child of stack #$parent; leaving completion undetected for a rerun."
1227
- else
1228
- # Release the children this landing unblocks. check-stack-branch holds a child
1229
- # whose `Blocked by #N` sibling is still open, and nothing else re-dispatches it,
1230
- # so the landing does: re-adding `implement` with the App token is a bot label
1231
- # event, which the router routes straight to implement. The child that just
1232
- # landed counts as done even before the gate closes its issue a moment later.
1233
- for child in $children; do
1234
- [ "$child" = "$closed_issue" ] && continue
1235
- meta=$(gh issue view "$child" --repo "$REPO" --json state,labels,body 2>/dev/null || echo '{}')
1236
- printf '%s' "$meta" | jq -e '(.state // "" | ascii_downcase) == "open"' >/dev/null || continue
1237
- printf '%s' "$meta" | jq -e '[.labels[]?.name] | (index("implement") != null) and (index("bot-working") == null)' >/dev/null || continue
1238
- blockers=$(printf '%s' "$meta" | jq -r '.body // ""' | grep -oiE 'blocked by #[0-9]+' | grep -oE '[0-9]+' | sort -u || true)
1239
- [ -n "$blockers" ] || continue
1240
- waiting=""
1241
- for b in $blockers; do
1242
- [ "$b" = "$closed_issue" ] && continue
1243
- if gh issue view "$b" --repo "$REPO" --json state --jq '.state' 2>/dev/null | grep -qix open; then
1244
- waiting="$waiting #$b"
1245
- fi
1246
- done
1247
- if [ -n "$waiting" ]; then
1248
- echo "#$child still waits on$waiting."
1249
- 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"
1250
1161
  fi
1251
- gh issue edit "$child" --repo "$REPO" --remove-label implement >/dev/null 2>&1 || true
1252
- gh issue edit "$child" --repo "$REPO" --add-label implement >/dev/null
1253
- echo "Released #$child: everything it was blocked by has landed."
1254
1162
  done
1255
- open_children=0
1256
- for child in $children; do
1257
- # Landed, though the gate may not have closed its issue yet.
1258
- [ "$child" = "$closed_issue" ] && continue
1259
- if gh issue view "$child" --repo "$REPO" --json state --jq '.state' 2>/dev/null | grep -qix open; then
1260
- open_children=$((open_children + 1))
1261
- fi
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
1262
1191
  done
1263
- if [ "$open_children" -gt 0 ]; then
1264
- echo "$open_children child issue(s) still open on stack #$parent; the stack continues."
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."
1265
1200
  else
1266
- # The root pull request must be green and mergeable. Green: a successful
1267
- # CI run on its head SHA. Mergeable: the same on-demand computation
1268
- # every gate dispatch already relies on; UNKNOWN settles in the
1269
- # background, so ask until it does, like mergeable_state() does.
1270
- root_state=UNKNOWN
1271
- for _ in 1 2 3 4 5 6; do
1272
- root_state=$(gh pr view "$root_pr" --repo "$REPO" --json mergeable --jq '.mergeable' 2>/dev/null || echo UNKNOWN)
1273
- [ "$root_state" = "UNKNOWN" ] || break
1274
- sleep 5
1275
- done
1276
- root_sha=$(gh api "repos/$REPO/pulls/$root_pr" --jq '.head.sha')
1277
- root_ci=$(gh api "repos/$REPO/actions/runs?head_sha=$root_sha&per_page=100" \
1278
- --jq '[.workflow_runs[]
1279
- | select(.name == env.CI_WORKFLOW_NAME and .status == "completed"
1280
- and .conclusion == "success")]
1281
- | sort_by(.created_at) | last // empty')
1282
- if [ -z "$root_ci" ] || [ "$root_state" != "MERGEABLE" ]; then
1283
- 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."
1284
1209
  else
1285
- completion_marker='<!-- stack-complete -->'
1286
- # The announcement is a pull request comment, and pull request
1287
- # comments live at the issue endpoint for the pull request number,
1288
- # so the marker is looked for where the comment was posted.
1289
- already=$(gh api "repos/$REPO/issues/$root_pr/comments?per_page=100" \
1290
- --jq "[.[] | select(.body | contains(\"$completion_marker\"))] | length" 2>/dev/null || echo 0)
1291
- if [ "${already:-0}" -gt 0 ]; then
1292
- echo "Stack #$parent already has a completion announcement; staying silent."
1293
- else
1294
- 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"
1295
- echo "Stack #$parent complete: announced root PR #$root_pr."
1296
- 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."
1297
1212
  fi
1298
1213
  fi
1299
- fi
1214
+ fi
1300
1215
  fi
1301
1216
  fi
1302
1217
  fi
1303
1218
  fi
1304
- else
1305
- maybe_dispatch "$EVENT_PR"
1306
1219
  fi
1307
1220
 
1308
1221
  reconcile-bot-pr-runs:
@@ -1332,41 +1245,14 @@ jobs:
1332
1245
  private-key: ${{ secrets.BOT_PRIVATE_KEY }}
1333
1246
  # Runs only the default-branch workflow and never checks out pull-request code.
1334
1247
  # External users cannot impersonate github-actions[bot].
1335
- - name: Approve checks and dispatch Merge Gate for trusted bot pull requests
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
1336
1250
  env:
1337
1251
  GH_TOKEN: ${{ steps.app-token.outputs.token }}
1338
1252
  REPO: ${{ github.repository }}
1339
- DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
1340
1253
  run: |
1341
1254
  set -euo pipefail
1342
1255
 
1343
- # GitHub computes mergeability on demand. The first read of a pull request nobody has
1344
- # opened recently answers UNKNOWN through the API and null through REST, while it works
1345
- # the answer out in the background. Reading once therefore reports "not conflicting"
1346
- # for precisely the stale conflicting pull requests that need the gate. Ask until it
1347
- # settles.
1348
- mergeable_state() {
1349
- local pr="$1" state=UNKNOWN
1350
- for _ in 1 2 3 4 5 6; do
1351
- state=$(gh pr view "$pr" --repo "$REPO" --json mergeable --jq '.mergeable' 2>/dev/null || echo UNKNOWN)
1352
- [ "$state" = "UNKNOWN" ] || break
1353
- sleep 5
1354
- done
1355
- echo "$state"
1356
- }
1357
-
1358
- # A gate already queued or running for this pull request will read the same CI verdict;
1359
- # a second dispatch would wait in the belt's queue and run the same gate twice.
1360
- gate_is_live() {
1361
- local pr="$1" status live
1362
- for status in queued in_progress waiting pending; do
1363
- live=$(gh api "repos/$REPO/actions/workflows/work-router.yml/runs?status=$status&per_page=100" \
1364
- --jq "[.workflow_runs[] | select(.display_title == \"Working (Merge Gate): PR #${pr}\")] | length" 2>/dev/null || echo 0)
1365
- [ "${live:-0}" -eq 0 ] || return 0
1366
- done
1367
- return 1
1368
- }
1369
-
1370
1256
  # Stale reservation sweep. Labelling ten issues at once labels all ten immediately,
1371
1257
  # because authorize-bot-work is keyed per issue, while implement-global lets exactly
1372
1258
  # one run at a time. A queued run that is cancelled therefore leaves its issue
@@ -1404,7 +1290,7 @@ jobs:
1404
1290
  )
1405
1291
  | [.number, .head.ref, .head.sha] | @tsv
1406
1292
  ' |
1407
- while IFS=$'\t' read -r pr_number branch head_sha; do
1293
+ while IFS=$'\t' read -r _ branch _; do
1408
1294
  gh api "repos/$REPO/actions/runs?status=action_required&branch=$branch" \
1409
1295
  --jq '.workflow_runs[]
1410
1296
  | select(
@@ -1416,110 +1302,6 @@ jobs:
1416
1302
  echo "Approving trusted bot run $run_id"
1417
1303
  gh api "repos/$REPO/actions/runs/$run_id/approve" --method POST
1418
1304
  done
1419
-
1420
- ci=$(gh api "repos/$REPO/actions/runs?head_sha=$head_sha&per_page=100" \
1421
- --jq '[.workflow_runs[]
1422
- | select(
1423
- .name == env.CI_WORKFLOW_NAME
1424
- and .event == "pull_request"
1425
- and .status == "completed"
1426
- and (.conclusion == "success" or .conclusion == "failure")
1427
- )
1428
- ] | sort_by(.created_at) | last // empty | [.id, .conclusion, .updated_at] | @tsv')
1429
- if [ -z "$ci" ]; then
1430
- # A conflicting pull request has no merge ref for GitHub to build, so a
1431
- # `pull_request` CI workflow can never run on that head: refs/pull/N/merge is a
1432
- # 404 for as long as the conflict lasts. Waiting for a verdict that cannot arrive
1433
- # deadlocks the belt, because resolving the conflict is the gate's own job. Fall
1434
- # back to the branch's last verdict so the gate runs; it stays on the remediation
1435
- # path and cannot merge on a stale conclusion.
1436
- mergeable_now=$(mergeable_state "$pr_number")
1437
- if [ "$mergeable_now" = "CONFLICTING" ]; then
1438
- ci=$(gh api "repos/$REPO/actions/runs?branch=$branch&per_page=100" \
1439
- --jq '[.workflow_runs[]
1440
- | select(
1441
- .name == env.CI_WORKFLOW_NAME
1442
- and .event == "pull_request"
1443
- and .status == "completed"
1444
- and (.conclusion == "success" or .conclusion == "failure")
1445
- )
1446
- ] | sort_by(.created_at) | last // empty | [.id, .conclusion, .updated_at] | @tsv')
1447
- fi
1448
- if [ -z "$ci" ]; then
1449
- echo "::warning::PR #$pr_number head $head_sha has no completed $CI_WORKFLOW_NAME run; the belt cannot gate it until CI runs."
1450
- continue
1451
- fi
1452
- echo "PR #$pr_number conflicts, so CI cannot run on $head_sha at all; gating on the branch's last verdict."
1453
- fi
1454
-
1455
- IFS=$'\t' read -r ci_run_id ci_conclusion ci_finished <<<"$ci"
1456
-
1457
- # A verdict is a comment carrying BOTH the gate marker and a `**Verdict:**`
1458
- # line; progress comments and failed attempts deliberately carry neither
1459
- # matching pair, so a crashed or OOM-killed gate never masks a PR from
1460
- # later sweeps. A recorded verdict only counts when it is NEWER than the
1461
- # latest CI run; the old existence check made the gate one-shot per PR.
1462
- # Attempts are failed gates (the attempt marker) since this CI verdict.
1463
- latest_verdict=$(gh api "repos/$REPO/issues/$pr_number/comments?per_page=100" \
1464
- --jq '[.[] | select((.body | contains("<!-- agent-merge-gate -->")) and (.body | contains("**Verdict:**"))) | .created_at] | max // empty')
1465
- linked_issue=$(gh pr view "$pr_number" --repo "$REPO" --json closingIssuesReferences --jq '.closingIssuesReferences[0].number // empty' 2>/dev/null || true)
1466
- if [ -n "$linked_issue" ]; then
1467
- issue_verdict=$(gh api "repos/$REPO/issues/$linked_issue/comments?per_page=100" \
1468
- --jq '[.[] | select((.body | contains("<!-- agent-merge-gate -->")) and (.body | contains("**Verdict:**"))) | .created_at] | max // empty')
1469
- if [ -n "$issue_verdict" ] && { [ -z "$latest_verdict" ] || [ "$issue_verdict" \> "$latest_verdict" ]; }; then
1470
- latest_verdict="$issue_verdict"
1471
- fi
1472
- fi
1473
- # A verdict parks the CODE it was given on, not the CI run that prompted it. This
1474
- # used to compare against the CI finish time, which made the park worthless: any
1475
- # later CI run on the same commits -- a re-run, a nightly, a rebuild triggered by
1476
- # main moving -- was newer than the verdict, so the belt re-dispatched a pull
1477
- # request a human had already been handed, and the attempt counter below reset to
1478
- # zero at the same time and handed it a fresh budget of six full gate runs.
1479
- # Lyceum PR #13 sat parked by a `review` verdict for six days while this happened.
1480
- #
1481
- # The newest commit on the branch is the right key. A gate verdict newer than it
1482
- # applies to the current head, so the park holds however often CI runs. Push a
1483
- # commit -- which is what apply-review and a `remediated` verdict both do -- and
1484
- # the head moves past the verdict and the gate decides again, which is correct:
1485
- # that is new code nobody has ruled on.
1486
- head_committed=$(gh pr view "$pr_number" --repo "$REPO" \
1487
- --json commits --jq '[.commits[].committedDate] | max // empty' 2>/dev/null || true)
1488
- verdict_horizon="${head_committed:-$ci_finished}"
1489
- if [ -n "$latest_verdict" ] && [ "$latest_verdict" \> "$verdict_horizon" ]; then
1490
- echo "PR #$pr_number: gate verdict ($latest_verdict) is newer than its head commit ($verdict_horizon); the park holds, skipping"
1491
- continue
1492
- fi
1493
-
1494
- attempts=$(gh api "repos/$REPO/issues/$pr_number/comments?per_page=100" \
1495
- --jq '[.[] | select(.body | contains("<!-- agent-merge-gate-attempt -->")) | select(.created_at > "'"$verdict_horizon"'") ] | length')
1496
- if [ -n "$linked_issue" ]; then
1497
- issue_attempts=$(gh api "repos/$REPO/issues/$linked_issue/comments?per_page=100" \
1498
- --jq '[.[] | select(.body | contains("<!-- agent-merge-gate-attempt -->")) | select(.created_at > "'"$verdict_horizon"'") ] | length')
1499
- attempts=$((attempts + issue_attempts))
1500
- fi
1501
- if [ "$attempts" -ge "$MAX_GATE_ATTEMPTS" ]; then
1502
- echo "PR #$pr_number already consumed $attempts gate attempts on this head, parking for review"
1503
- continue
1504
- fi
1505
- if gate_is_live "$pr_number"; then
1506
- echo "PR #$pr_number: a merge-gate run is already live; not dispatching another."
1507
- continue
1508
- fi
1509
-
1510
- echo "Dispatching Merge Gate for PR #$pr_number ($ci_conclusion, run $ci_run_id, attempt $((attempts + 1)) of $MAX_GATE_ATTEMPTS)"
1511
- gh api "repos/$REPO/actions/workflows/work-router.yml/dispatches" \
1512
- --method POST \
1513
- -f ref="$DEFAULT_BRANCH" \
1514
- -f "inputs[operation]=merge-gate" \
1515
- -f "inputs[pr-number]=$pr_number" \
1516
- -f "inputs[ci-conclusion]=$ci_conclusion" \
1517
- -f "inputs[ci-run-id]=$ci_run_id" \
1518
- -f "inputs[attempts_so_far]=$attempts"
1519
-
1520
- # Every eligible pull request in one tick. The belt queues them first in, first out
1521
- # (`queue: max` on call-merge-gate); before it did, each dispatch cancelled the one
1522
- # pending ahead of it, so this loop stopped after one and the rest waited an hour.
1523
1305
  done
1524
1306
 
1525
1307
  audit-close: