@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.
- package/dist/catalog-installation.d.ts +2 -0
- package/dist/catalog-installation.js +44 -2
- package/dist/index.js +3 -0
- package/dist/route-processing.js +9 -2
- package/dist/stack-defaults.d.ts +1 -0
- package/dist/stack-defaults.js +13 -1
- 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/validate-merge-gate-output/action.yml +5 -0
- package/loops/actions/validate-merge-gate-output/validate-merge-gate-output.sh +8 -2
- package/loops/actions/verify-route-matrix/verify-route-matrix.sh +131 -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-implement.md +6 -1
- package/loops/workflows/agent-merge-gate.md +102 -56
- package/loops/workflows/work-router.yml +246 -464
- package/package.json +8 -9
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Managed by @plainconceptsplatform/workflows. Source: loops/actions/merge-train/merge-train.sh. Update with `workflows update --force`; consumer edits may be overwritten.
|
|
3
|
+
# Move the merge train one step: the oldest open pull request that can move, and only that one.
|
|
4
|
+
#
|
|
5
|
+
# The belt this replaces dispatched the gate for every eligible pull request at once. Each gate
|
|
6
|
+
# then resolved its branch against a default branch the gate ahead of it was about to move, so a
|
|
7
|
+
# night of ten pull requests became ten repairs, ten CI runs and ten more gates after every merge,
|
|
8
|
+
# and a verdict that parked a pull request held it however stale it went. The train keeps one
|
|
9
|
+
# pull request in flight, lowest number first. Everything else waits, untouched, until the one in
|
|
10
|
+
# front of it has merged or been parked for a person.
|
|
11
|
+
#
|
|
12
|
+
# One tick does at most one thing:
|
|
13
|
+
# wait the front pull request's CI is still running, or a gate is already on the belt
|
|
14
|
+
# update it is green but behind the default branch: merge the default branch in, CI reruns
|
|
15
|
+
# gate it is green and current, red, or conflicting: the merge gate repairs or merges it
|
|
16
|
+
# approve its CI is waiting for approval and a trusted bot opened it
|
|
17
|
+
# A pull request a person has to decide on, or that carries the hold label, is skipped, so it never
|
|
18
|
+
# blocks the line. Ticks come from every merge, every CI completion, every finished gate, and the
|
|
19
|
+
# hourly schedule as the fallback.
|
|
20
|
+
#
|
|
21
|
+
# `train_move` is pure, so the route matrix sources this file and exercises the same decision the
|
|
22
|
+
# train runs. Everything below it reads GitHub and acts.
|
|
23
|
+
|
|
24
|
+
set -euo pipefail
|
|
25
|
+
|
|
26
|
+
# Facts in, one move out. Every input is a plain string the I/O layer measured:
|
|
27
|
+
# HOLD true when the pull request carries the hold label
|
|
28
|
+
# REVIEW_DECISION GitHub's review decision (CHANGES_REQUESTED holds it for its author)
|
|
29
|
+
# PARK none, or held when a standing gate verdict covers this head
|
|
30
|
+
# ATTEMPTS failed gate attempts on this head; MAX_ATTEMPTS is the budget
|
|
31
|
+
# MERGEABLE MERGEABLE, CONFLICTING or UNKNOWN
|
|
32
|
+
# CI_STATUS none, or the latest CI run's status (queued, in_progress, completed, ...)
|
|
33
|
+
# CI_CONCLUSION that run's conclusion when it completed
|
|
34
|
+
# CI_STUCK true when that run has been pending longer than the train waits
|
|
35
|
+
# BEHIND_BY commits on the base branch the head does not have
|
|
36
|
+
# TRUSTED_AUTHOR true when a trusted bot opened it (only those runs are approved)
|
|
37
|
+
train_move() {
|
|
38
|
+
local move="" reason=""
|
|
39
|
+
|
|
40
|
+
if [ "${HOLD:-false}" = true ]; then
|
|
41
|
+
move=skip; reason="it carries the hold label"
|
|
42
|
+
elif [ "${REVIEW_DECISION:-}" = CHANGES_REQUESTED ]; then
|
|
43
|
+
move=skip; reason="a reviewer requested changes"
|
|
44
|
+
elif [ "${PARK:-none}" = held ]; then
|
|
45
|
+
move=skip; reason="a gate verdict on this head is waiting for a person"
|
|
46
|
+
elif [ "${ATTEMPTS:-0}" -ge "${MAX_ATTEMPTS:-6}" ]; then
|
|
47
|
+
move=skip; reason="its gate attempt budget on this head is spent"
|
|
48
|
+
elif [ "${MERGEABLE:-UNKNOWN}" = CONFLICTING ]; then
|
|
49
|
+
# A conflicting pull request has no merge ref, so CI can never run on it: waiting for a
|
|
50
|
+
# verdict would wait forever. Resolving the conflict is the gate's job.
|
|
51
|
+
move=gate; reason="it conflicts with its base branch"
|
|
52
|
+
else
|
|
53
|
+
case "${CI_STATUS:-none}" in
|
|
54
|
+
none)
|
|
55
|
+
move=skip; reason="no CI run on its head, nothing to gate on" ;;
|
|
56
|
+
completed)
|
|
57
|
+
case "${CI_CONCLUSION:-}" in
|
|
58
|
+
success)
|
|
59
|
+
if [ "${BEHIND_BY:-0}" -gt 0 ]; then
|
|
60
|
+
move=update; reason="green but ${BEHIND_BY} commit(s) behind its base branch"
|
|
61
|
+
else
|
|
62
|
+
move=gate; reason="green and up to date"
|
|
63
|
+
fi
|
|
64
|
+
;;
|
|
65
|
+
failure) move=gate; reason="CI failed" ;;
|
|
66
|
+
action_required)
|
|
67
|
+
if [ "${TRUSTED_AUTHOR:-false}" = true ]; then
|
|
68
|
+
move=approve; reason="CI is waiting for approval"
|
|
69
|
+
else
|
|
70
|
+
move=skip; reason="CI is waiting for a person to approve it"
|
|
71
|
+
fi
|
|
72
|
+
;;
|
|
73
|
+
*) move=skip; reason="CI concluded '${CI_CONCLUSION:-none}', which is no verdict" ;;
|
|
74
|
+
esac
|
|
75
|
+
;;
|
|
76
|
+
*)
|
|
77
|
+
if [ "${CI_STUCK:-false}" = true ]; then
|
|
78
|
+
move=skip; reason="CI has been '${CI_STATUS}' for longer than the train waits"
|
|
79
|
+
else
|
|
80
|
+
move="wait"; reason="CI is ${CI_STATUS}"
|
|
81
|
+
fi
|
|
82
|
+
;;
|
|
83
|
+
esac
|
|
84
|
+
fi
|
|
85
|
+
|
|
86
|
+
printf 'move=%s\nreason=%s\n' "$move" "$reason"
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
# A standing verdict holds the pull request when it is newer than the head commit. Two verdicts
|
|
90
|
+
# never hold: `remediated` is the gate saying it pushed and wants the next CI, and its comment is
|
|
91
|
+
# always newer than the commit it pushed. `blocked` and a merge that did not happen expire once
|
|
92
|
+
# the base branch moves, a bounded number of times: the failure may have been the base's, and a
|
|
93
|
+
# repair attempt on a newer base is cheap next to a weekend of waiting.
|
|
94
|
+
# VERDICT the latest gate verdict word, or empty
|
|
95
|
+
# VERDICT_NEWER true when that verdict is newer than the head commit
|
|
96
|
+
# BASE_MOVED true when the base branch has a commit newer than the verdict
|
|
97
|
+
# RETRIES blocked or failed-merge verdicts already given on this head
|
|
98
|
+
# MAX_RETRIES how many of those the train retries before leaving it to a person
|
|
99
|
+
# STACK_ROOT true for a stack's root pull request, which a person always merges
|
|
100
|
+
train_park() {
|
|
101
|
+
local park=none
|
|
102
|
+
if [ "${VERDICT_NEWER:-false}" = true ]; then
|
|
103
|
+
case "${VERDICT:-}" in
|
|
104
|
+
remediated | "") park=none ;;
|
|
105
|
+
blocked | auto-merge)
|
|
106
|
+
if [ "${STACK_ROOT:-false}" = true ]; then
|
|
107
|
+
park=held
|
|
108
|
+
elif [ "${BASE_MOVED:-false}" = true ] && [ "${RETRIES:-0}" -lt "${MAX_RETRIES:-3}" ]; then
|
|
109
|
+
park=none
|
|
110
|
+
else
|
|
111
|
+
park=held
|
|
112
|
+
fi
|
|
113
|
+
;;
|
|
114
|
+
*) park=held ;;
|
|
115
|
+
esac
|
|
116
|
+
fi
|
|
117
|
+
echo "$park"
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if [ "${BASH_SOURCE[0]}" != "$0" ]; then
|
|
121
|
+
return 0
|
|
122
|
+
fi
|
|
123
|
+
|
|
124
|
+
: "${REPO:?}" "${DEFAULT_BRANCH:?}" "${CI_WORKFLOW_NAME:?}"
|
|
125
|
+
TRUSTED_BOTS="${TRUSTED_BOTS:-}"
|
|
126
|
+
HOLD_LABEL="${HOLD_LABEL:-hold}"
|
|
127
|
+
MAX_ATTEMPTS="${MAX_ATTEMPTS:-6}"
|
|
128
|
+
MAX_RETRIES="${MAX_RETRIES:-3}"
|
|
129
|
+
STUCK_HOURS="${STUCK_HOURS:-3}"
|
|
130
|
+
DRY_RUN="${DRY_RUN:-false}"
|
|
131
|
+
SELF_RUN_ID="${GITHUB_RUN_ID:-0}"
|
|
132
|
+
GATE_TITLE_PREFIX="Agent: Merge Gate - PR #"
|
|
133
|
+
SUMMARY="${GITHUB_STEP_SUMMARY:-/dev/null}"
|
|
134
|
+
|
|
135
|
+
now_epoch=$(date -u +%s)
|
|
136
|
+
iso_epoch() { date -u -d "$1" +%s 2>/dev/null || echo 0; }
|
|
137
|
+
|
|
138
|
+
summary() { printf '%s\n' "$*" >>"$SUMMARY"; }
|
|
139
|
+
|
|
140
|
+
# Every write goes through this, so a dry run is honest: there is no second path that forgets.
|
|
141
|
+
act() {
|
|
142
|
+
local what="$1"
|
|
143
|
+
shift
|
|
144
|
+
if [ "$DRY_RUN" = true ]; then
|
|
145
|
+
echo "[dry-run] $what"
|
|
146
|
+
summary "**Next move (dry run):** $what"
|
|
147
|
+
return 0
|
|
148
|
+
fi
|
|
149
|
+
echo "$what"
|
|
150
|
+
summary "**Next move:** $what"
|
|
151
|
+
"$@"
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
is_trusted_bot() {
|
|
155
|
+
case " $TRUSTED_BOTS " in *" $1 "*) return 0 ;; esac
|
|
156
|
+
return 1
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
# Who may ride the train: a trusted bot, or a person with write access. Asked once per login.
|
|
160
|
+
declare -A WRITE_ACCESS=()
|
|
161
|
+
can_ride() {
|
|
162
|
+
local login="$1" permission
|
|
163
|
+
is_trusted_bot "$login" && return 0
|
|
164
|
+
if [ -z "${WRITE_ACCESS[$login]+set}" ]; then
|
|
165
|
+
permission=$(gh api "repos/$REPO/collaborators/$login/permission" --jq '.permission' 2>/dev/null || echo none)
|
|
166
|
+
case "$permission" in admin | maintain | write) WRITE_ACCESS[$login]=true ;; *) WRITE_ACCESS[$login]=false ;; esac
|
|
167
|
+
fi
|
|
168
|
+
[ "${WRITE_ACCESS[$login]}" = true ]
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
# One gate at a time across the repository. A gate dispatched by a tick, by a person or by a
|
|
172
|
+
# consumer CI is a router run titled by run-name; the run this tick belongs to is excluded,
|
|
173
|
+
# because the tick after a gate runs inside the gate's own router run.
|
|
174
|
+
live_gate() {
|
|
175
|
+
local status
|
|
176
|
+
for status in queued in_progress waiting pending requested; do
|
|
177
|
+
gh api "repos/$REPO/actions/workflows/work-router.yml/runs?status=$status&per_page=100" \
|
|
178
|
+
--jq ".workflow_runs[]
|
|
179
|
+
| select(.id != $SELF_RUN_ID)
|
|
180
|
+
| select(.display_title | startswith(\"$GATE_TITLE_PREFIX\"))
|
|
181
|
+
| .display_title" 2>/dev/null | head -1
|
|
182
|
+
done | head -1
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
# GitHub works mergeability out on demand: the first read of a pull request nobody has opened
|
|
186
|
+
# recently answers UNKNOWN while it computes. Ask until it settles.
|
|
187
|
+
mergeable_state() {
|
|
188
|
+
local pr="$1" state=UNKNOWN
|
|
189
|
+
for _ in 1 2 3 4 5 6; do
|
|
190
|
+
state=$(gh pr view "$pr" --repo "$REPO" --json mergeable --jq '.mergeable' 2>/dev/null || echo UNKNOWN)
|
|
191
|
+
[ "$state" = UNKNOWN ] || break
|
|
192
|
+
sleep 5
|
|
193
|
+
done
|
|
194
|
+
echo "$state"
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
# Gate comments carry the marker and a Verdict line, on the pull request or on its issue. The
|
|
198
|
+
# agent's own report says `assessed` and is always followed by the disposition the workflow
|
|
199
|
+
# computed from it, so it is not a verdict of its own.
|
|
200
|
+
verdict_comments() {
|
|
201
|
+
local number="$1"
|
|
202
|
+
gh api --paginate "repos/$REPO/issues/$number/comments?per_page=100" \
|
|
203
|
+
--jq '.[] | select((.body | contains("<!-- agent-merge-gate -->")) and (.body | test("Verdict:[*]+ [a-z-]+")))
|
|
204
|
+
| [.created_at, (.body | capture("Verdict:[*]+ (?<v>[a-z-]+)").v)]
|
|
205
|
+
| select(.[1] != "assessed") | @tsv' 2>/dev/null || true
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
# shellcheck disable=SC2317 # called through act
|
|
209
|
+
approve_runs() {
|
|
210
|
+
local branch="$1" id
|
|
211
|
+
gh api "repos/$REPO/actions/runs?status=action_required&branch=$branch" --jq '.workflow_runs[].id' |
|
|
212
|
+
while IFS= read -r id; do
|
|
213
|
+
gh api "repos/$REPO/actions/runs/$id/approve" --method POST >/dev/null
|
|
214
|
+
done
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
# shellcheck disable=SC2317 # called through act
|
|
218
|
+
update_branch() {
|
|
219
|
+
local pr="$1" head_sha="$2"
|
|
220
|
+
gh api "repos/$REPO/pulls/$pr/update-branch" --method PUT -f expected_head_sha="$head_sha" >/dev/null
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
attempt_count() {
|
|
224
|
+
local number="$1" since="$2"
|
|
225
|
+
gh api --paginate "repos/$REPO/issues/$number/comments?per_page=100" \
|
|
226
|
+
--jq "[.[] | select(.body | contains(\"<!-- agent-merge-gate-attempt -->\")) | select(.created_at > \"$since\")] | length" \
|
|
227
|
+
2>/dev/null | awk '{ n += $1 } END { print n + 0 }'
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
# shellcheck disable=SC2317 # called through act
|
|
231
|
+
dispatch_gate() {
|
|
232
|
+
local pr="$1" conclusion="$2" run_id="$3" attempts="$4" since
|
|
233
|
+
since=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
|
234
|
+
gh api "repos/$REPO/actions/workflows/work-router.yml/dispatches" --method POST \
|
|
235
|
+
-f ref="$DEFAULT_BRANCH" \
|
|
236
|
+
-f "inputs[operation]=merge-gate" \
|
|
237
|
+
-f "inputs[pr-number]=$pr" \
|
|
238
|
+
-f "inputs[ci-conclusion]=$conclusion" \
|
|
239
|
+
-f "inputs[ci-run-id]=$run_id" \
|
|
240
|
+
-f "inputs[attempts_so_far]=$attempts" >/dev/null
|
|
241
|
+
# The next tick waits behind this one and must see the gate it started, or it starts a second.
|
|
242
|
+
for _ in $(seq 1 12); do
|
|
243
|
+
if gh api "repos/$REPO/actions/workflows/work-router.yml/runs?event=workflow_dispatch&created=>=$since&per_page=20" \
|
|
244
|
+
--jq ".workflow_runs[] | select(.display_title == \"$GATE_TITLE_PREFIX$pr\") | .id" 2>/dev/null | grep -q .; then
|
|
245
|
+
return 0
|
|
246
|
+
fi
|
|
247
|
+
sleep 5
|
|
248
|
+
done
|
|
249
|
+
echo "::warning::Dispatched the gate for #$pr but its run did not appear within a minute."
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
summary "### Merge train"
|
|
253
|
+
summary ""
|
|
254
|
+
|
|
255
|
+
if gate=$(live_gate) && [ -n "$gate" ]; then
|
|
256
|
+
echo "A gate is already on the belt (${gate}); the train waits for it."
|
|
257
|
+
summary "Waiting: \`${gate}\` is on the belt."
|
|
258
|
+
exit 0
|
|
259
|
+
fi
|
|
260
|
+
|
|
261
|
+
base_head_date=""
|
|
262
|
+
base_head_date_for() {
|
|
263
|
+
local base="$1"
|
|
264
|
+
gh api "repos/$REPO/commits/$base" --jq '.commit.committer.date' 2>/dev/null || echo ""
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
summary "| PR | Author | Move | Why |"
|
|
268
|
+
summary "|---|---|---|---|"
|
|
269
|
+
|
|
270
|
+
mapfile -t candidates < <(
|
|
271
|
+
gh api --paginate "repos/$REPO/pulls?state=open&per_page=100" |
|
|
272
|
+
jq -r --arg repo "$REPO" --arg hold "$HOLD_LABEL" '
|
|
273
|
+
.[]
|
|
274
|
+
| select(.draft == false and (.head.repo.full_name // "") == $repo)
|
|
275
|
+
| [.number, .user.login, .head.sha, .head.ref, .base.ref,
|
|
276
|
+
([.labels[].name] | index($hold) != null)] | @tsv' |
|
|
277
|
+
sort -n
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
if [ "${#candidates[@]}" -eq 0 ]; then
|
|
281
|
+
echo "No open pull request on this repository; the train is empty."
|
|
282
|
+
summary "| - | - | empty | no open pull request |"
|
|
283
|
+
exit 0
|
|
284
|
+
fi
|
|
285
|
+
|
|
286
|
+
for line in "${candidates[@]}"; do
|
|
287
|
+
IFS=$'\t' read -r pr author head_sha head_ref base_ref hold <<<"$line"
|
|
288
|
+
|
|
289
|
+
if ! can_ride "$author"; then
|
|
290
|
+
echo "#$pr: opened by $author, who has no write access; not on the train."
|
|
291
|
+
continue
|
|
292
|
+
fi
|
|
293
|
+
|
|
294
|
+
meta=$(gh pr view "$pr" --repo "$REPO" --json reviewDecision,commits,closingIssuesReferences 2>/dev/null || echo '{}')
|
|
295
|
+
review_decision=$(jq -r '.reviewDecision // ""' <<<"$meta")
|
|
296
|
+
head_committed=$(jq -r '[.commits[]?.committedDate] | max // ""' <<<"$meta")
|
|
297
|
+
linked_issue=$(jq -r '.closingIssuesReferences[0].number // ""' <<<"$meta")
|
|
298
|
+
|
|
299
|
+
# The latest verdict on the pull request or its issue, and how many retries it has had.
|
|
300
|
+
verdicts=$(
|
|
301
|
+
{
|
|
302
|
+
verdict_comments "$pr"
|
|
303
|
+
[ -z "$linked_issue" ] || verdict_comments "$linked_issue"
|
|
304
|
+
} | sort
|
|
305
|
+
)
|
|
306
|
+
verdict_line=$(tail -1 <<<"$verdicts")
|
|
307
|
+
verdict_at=$(cut -f1 <<<"$verdict_line")
|
|
308
|
+
verdict=$(cut -f2 <<<"$verdict_line")
|
|
309
|
+
verdict_newer=false
|
|
310
|
+
if [ -n "$verdict_at" ] && { [ -z "$head_committed" ] || [ "$verdict_at" \> "$head_committed" ]; }; then
|
|
311
|
+
verdict_newer=true
|
|
312
|
+
fi
|
|
313
|
+
retries=$(awk -F'\t' -v since="$head_committed" '$1 > since && ($2 == "blocked" || $2 == "auto-merge")' <<<"$verdicts" | grep -c . || true)
|
|
314
|
+
|
|
315
|
+
base_moved=false
|
|
316
|
+
if [ "$verdict_newer" = true ]; then
|
|
317
|
+
base_head_date=$(base_head_date_for "$base_ref")
|
|
318
|
+
if [ -n "$base_head_date" ] && [ "$base_head_date" \> "$verdict_at" ]; then
|
|
319
|
+
base_moved=true
|
|
320
|
+
fi
|
|
321
|
+
fi
|
|
322
|
+
|
|
323
|
+
stack_root=false
|
|
324
|
+
if [ "$verdict_newer" = true ] && [ -n "$linked_issue" ]; then
|
|
325
|
+
if gh issue view "$linked_issue" --repo "$REPO" --json body --jq '.body // ""' 2>/dev/null |
|
|
326
|
+
grep -q "<!-- stack-root-pr: $pr -->"; then
|
|
327
|
+
stack_root=true
|
|
328
|
+
fi
|
|
329
|
+
fi
|
|
330
|
+
|
|
331
|
+
park=$(VERDICT="$verdict" VERDICT_NEWER="$verdict_newer" BASE_MOVED="$base_moved" \
|
|
332
|
+
RETRIES="$retries" MAX_RETRIES="$MAX_RETRIES" STACK_ROOT="$stack_root" train_park)
|
|
333
|
+
|
|
334
|
+
attempts=0
|
|
335
|
+
if [ "$park" = none ]; then
|
|
336
|
+
horizon="${head_committed:-1970-01-01T00:00:00Z}"
|
|
337
|
+
# A retried verdict starts a fresh budget from the verdict it retries, not from the head.
|
|
338
|
+
[ "$verdict_newer" = true ] && horizon="$verdict_at"
|
|
339
|
+
attempts=$(attempt_count "$pr" "$horizon")
|
|
340
|
+
if [ -n "$linked_issue" ]; then
|
|
341
|
+
attempts=$((attempts + $(attempt_count "$linked_issue" "$horizon")))
|
|
342
|
+
fi
|
|
343
|
+
fi
|
|
344
|
+
|
|
345
|
+
ci=$(gh api "repos/$REPO/actions/runs?head_sha=$head_sha&per_page=100" \
|
|
346
|
+
--jq '[.workflow_runs[] | select(.name == env.CI_WORKFLOW_NAME)] | sort_by(.created_at) | last // empty
|
|
347
|
+
| [.id, .status, (.conclusion // ""), .created_at] | @tsv' 2>/dev/null || true)
|
|
348
|
+
IFS=$'\t' read -r ci_run_id ci_status ci_conclusion ci_created <<<"${ci:-}"
|
|
349
|
+
ci_status="${ci_status:-none}"
|
|
350
|
+
# A CI run hands its verdict over from its last job, so the run itself is still in progress
|
|
351
|
+
# when this tick reads it. Its own word stands in for the conclusion GitHub has not written yet.
|
|
352
|
+
if [ -n "${HINT_RUN_ID:-}" ] && [ "${ci_run_id:-}" = "$HINT_RUN_ID" ] && [ "$ci_status" != completed ]; then
|
|
353
|
+
case "${HINT_CONCLUSION:-}" in
|
|
354
|
+
success | failure) ci_status=completed; ci_conclusion="$HINT_CONCLUSION" ;;
|
|
355
|
+
esac
|
|
356
|
+
fi
|
|
357
|
+
ci_stuck=false
|
|
358
|
+
if [ "$ci_status" != none ] && [ "$ci_status" != completed ] && [ -n "${ci_created:-}" ]; then
|
|
359
|
+
[ $((now_epoch - $(iso_epoch "$ci_created"))) -lt $((STUCK_HOURS * 3600)) ] || ci_stuck=true
|
|
360
|
+
fi
|
|
361
|
+
|
|
362
|
+
mergeable=UNKNOWN
|
|
363
|
+
behind_by=0
|
|
364
|
+
if [ "$park" = none ] && [ "$hold" != true ] && [ "$review_decision" != CHANGES_REQUESTED ]; then
|
|
365
|
+
mergeable=$(mergeable_state "$pr")
|
|
366
|
+
if [ "$ci_status" = completed ] && [ "$ci_conclusion" = success ]; then
|
|
367
|
+
behind_by=$(gh api "repos/$REPO/compare/$base_ref...$head_sha" --jq '.behind_by' 2>/dev/null || echo 0)
|
|
368
|
+
fi
|
|
369
|
+
fi
|
|
370
|
+
|
|
371
|
+
trusted_author=false
|
|
372
|
+
is_trusted_bot "$author" && trusted_author=true
|
|
373
|
+
|
|
374
|
+
decision=$(HOLD="$hold" REVIEW_DECISION="$review_decision" PARK="$park" \
|
|
375
|
+
ATTEMPTS="$attempts" MAX_ATTEMPTS="$MAX_ATTEMPTS" MERGEABLE="$mergeable" \
|
|
376
|
+
CI_STATUS="$ci_status" CI_CONCLUSION="$ci_conclusion" CI_STUCK="$ci_stuck" \
|
|
377
|
+
BEHIND_BY="${behind_by:-0}" TRUSTED_AUTHOR="$trusted_author" train_move)
|
|
378
|
+
move=$(sed -n 's/^move=//p' <<<"$decision")
|
|
379
|
+
reason=$(sed -n 's/^reason=//p' <<<"$decision")
|
|
380
|
+
|
|
381
|
+
echo "#$pr ($author): $move, $reason."
|
|
382
|
+
summary "| #$pr | $author | $move | $reason |"
|
|
383
|
+
|
|
384
|
+
case "$move" in
|
|
385
|
+
skip) continue ;;
|
|
386
|
+
wait) exit 0 ;;
|
|
387
|
+
approve)
|
|
388
|
+
act "Approving the CI runs waiting on #$pr." approve_runs "$head_ref"
|
|
389
|
+
exit 0
|
|
390
|
+
;;
|
|
391
|
+
update)
|
|
392
|
+
# Merge, never rebase: the gate's pushes are fast-forward only, and so are a person's.
|
|
393
|
+
if act "Merging $base_ref into #$pr so CI judges it against what it will land on." \
|
|
394
|
+
update_branch "$pr" "$head_sha"; then
|
|
395
|
+
exit 0
|
|
396
|
+
fi
|
|
397
|
+
echo "::warning::GitHub could not update #$pr from $base_ref; handing it to the gate."
|
|
398
|
+
act "Dispatching the merge gate for #$pr to bring it up to date." \
|
|
399
|
+
dispatch_gate "$pr" success "${ci_run_id:-}" "$attempts"
|
|
400
|
+
exit 0
|
|
401
|
+
;;
|
|
402
|
+
gate)
|
|
403
|
+
conclusion="${ci_conclusion:-}"
|
|
404
|
+
if [ "$ci_status" != completed ] || { [ "$conclusion" != success ] && [ "$conclusion" != failure ]; }; then
|
|
405
|
+
# Only a conflict gets here without a usable verdict on this head. The branch's last
|
|
406
|
+
# verdict tells the gate which path it is on; with none, the repair path resolves it.
|
|
407
|
+
last=$(gh api "repos/$REPO/actions/runs?branch=$head_ref&per_page=100" \
|
|
408
|
+
--jq '[.workflow_runs[] | select(.name == env.CI_WORKFLOW_NAME and .status == "completed"
|
|
409
|
+
and (.conclusion == "success" or .conclusion == "failure"))]
|
|
410
|
+
| sort_by(.created_at) | last // empty | [.id, .conclusion] | @tsv' 2>/dev/null || true)
|
|
411
|
+
ci_run_id=$(cut -f1 <<<"$last")
|
|
412
|
+
conclusion=$(cut -f2 <<<"$last")
|
|
413
|
+
[ -n "$conclusion" ] || conclusion=failure
|
|
414
|
+
fi
|
|
415
|
+
act "Dispatching the merge gate for #$pr (CI $conclusion, attempt $((attempts + 1)) of $MAX_ATTEMPTS)." \
|
|
416
|
+
dispatch_gate "$pr" "$conclusion" "${ci_run_id:-}" "$attempts"
|
|
417
|
+
exit 0
|
|
418
|
+
;;
|
|
419
|
+
esac
|
|
420
|
+
done
|
|
421
|
+
|
|
422
|
+
echo "Nothing on the train can move: every open pull request is parked, held or has no CI verdict."
|
|
423
|
+
summary ""
|
|
424
|
+
summary "Nothing can move: every open pull request is parked, held or has no CI verdict."
|
|
@@ -1,45 +1,60 @@
|
|
|
1
|
-
# Managed by @plainconceptsplatform/workflows. Source: loops/actions/push-agent-branch/action.yml. Update with workflows update --force; consumer edits may be overwritten.
|
|
2
|
-
name: Push agent branch
|
|
3
|
-
description: Read push_to_pull_request_branch from agent_output.json and fast-forward from its git bundle.
|
|
4
|
-
inputs:
|
|
5
|
-
output-file:
|
|
6
|
-
description: Path to agent_output.json.
|
|
7
|
-
required: true
|
|
8
|
-
bundle-file:
|
|
9
|
-
description: Path to the git bundle from the agent artifact.
|
|
10
|
-
required: false
|
|
11
|
-
default: ''
|
|
12
|
-
token:
|
|
13
|
-
description: GitHub token with contents:write.
|
|
14
|
-
required: true
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
1
|
+
# Managed by @plainconceptsplatform/workflows. Source: loops/actions/push-agent-branch/action.yml. Update with workflows update --force; consumer edits may be overwritten.
|
|
2
|
+
name: Push agent branch
|
|
3
|
+
description: Read push_to_pull_request_branch from agent_output.json and fast-forward from its git bundle.
|
|
4
|
+
inputs:
|
|
5
|
+
output-file:
|
|
6
|
+
description: Path to agent_output.json.
|
|
7
|
+
required: true
|
|
8
|
+
bundle-file:
|
|
9
|
+
description: Path to the git bundle from the agent artifact.
|
|
10
|
+
required: false
|
|
11
|
+
default: ''
|
|
12
|
+
token:
|
|
13
|
+
description: GitHub token with contents:write.
|
|
14
|
+
required: true
|
|
15
|
+
expected-branch:
|
|
16
|
+
description: The only branch the push may target. Empty accepts the branch the item names.
|
|
17
|
+
required: false
|
|
18
|
+
default: ''
|
|
19
|
+
runs:
|
|
20
|
+
using: composite
|
|
21
|
+
steps:
|
|
22
|
+
- name: Configure git credentials
|
|
23
|
+
shell: bash
|
|
24
|
+
env:
|
|
25
|
+
GH_TOKEN: ${{ inputs.token }}
|
|
26
|
+
run: |
|
|
27
|
+
git config --global url."https://x-access-token:${GH_TOKEN}@github.com/".insteadOf "https://github.com/"
|
|
28
|
+
- name: Read pull request branch
|
|
29
|
+
id: request
|
|
30
|
+
shell: bash
|
|
31
|
+
env:
|
|
32
|
+
GH_TOKEN: ${{ inputs.token }}
|
|
33
|
+
OUTPUT_FILE: ${{ inputs.output-file }}
|
|
34
|
+
BUNDLE_FILE: ${{ inputs.bundle-file }}
|
|
35
|
+
EXPECTED_BRANCH: ${{ inputs.expected-branch }}
|
|
36
|
+
run: |
|
|
37
|
+
set -euo pipefail
|
|
38
|
+
[ -f "$OUTPUT_FILE" ] || exit 0
|
|
39
|
+
|
|
40
|
+
ITEM=$(jq -c '.items[] | select(.type == "push_to_pull_request_branch")' "$OUTPUT_FILE")
|
|
41
|
+
[ -n "$ITEM" ] || exit 0
|
|
42
|
+
|
|
43
|
+
BRANCH=$(echo "$ITEM" | jq -r '.branch')
|
|
44
|
+
# The agent names the branch; the caller knows it. A push anywhere else is refused, not
|
|
45
|
+
# redirected: a model that named the wrong branch may have built on the wrong one too.
|
|
46
|
+
# An item that names none was built on the checkout, which is the expected branch.
|
|
47
|
+
if [ -n "$EXPECTED_BRANCH" ] && { [ -z "$BRANCH" ] || [ "$BRANCH" = null ]; }; then
|
|
48
|
+
BRANCH="$EXPECTED_BRANCH"
|
|
49
|
+
fi
|
|
50
|
+
if [ -n "$EXPECTED_BRANCH" ] && [ "$BRANCH" != "$EXPECTED_BRANCH" ]; then
|
|
51
|
+
echo "::error::The agent asked to push to '$BRANCH'; this run may only push to '$EXPECTED_BRANCH'."
|
|
52
|
+
exit 1
|
|
53
|
+
fi
|
|
54
|
+
echo "branch=$BRANCH" >> "$GITHUB_OUTPUT"
|
|
55
|
+
- name: Apply bundle to pull request branch
|
|
56
|
+
if: steps.request.outputs.branch != '' && inputs.bundle-file != ''
|
|
57
|
+
uses: ./.github/actions/apply-agent-bundle
|
|
58
|
+
with:
|
|
59
|
+
bundle-file: ${{ inputs.bundle-file }}
|
|
60
|
+
target-branch: ${{ steps.request.outputs.branch }}
|
|
@@ -38,6 +38,9 @@ outputs:
|
|
|
38
38
|
outcome:
|
|
39
39
|
description: "Disposition: auto-merge, human-review, owner-review, blocked, remediated, or invalid."
|
|
40
40
|
value: ${{ steps.validate.outputs.outcome }}
|
|
41
|
+
pushed:
|
|
42
|
+
description: Whether the agent proposed a push to the pull request branch, applied or not.
|
|
43
|
+
value: ${{ steps.validate.outputs.pushed }}
|
|
41
44
|
runs:
|
|
42
45
|
using: composite
|
|
43
46
|
steps:
|
|
@@ -61,6 +64,8 @@ runs:
|
|
|
61
64
|
"$BLAST_LEVEL" "$PROTECTED_HIT" "$OWNER_HIT" "$CONFIDENCE_THRESHOLD" "$CODE_RULES")"
|
|
62
65
|
echo "outcome=$outcome" >> "$GITHUB_OUTPUT"
|
|
63
66
|
echo "valid=$([ "$outcome" != 'invalid' ] && echo true || echo false)" >> "$GITHUB_OUTPUT"
|
|
67
|
+
pushed=$(jq -r '[.items[]? | select(.type == "push_to_pull_request_branch")] | length > 0' "$OUTPUT_FILE" 2>/dev/null || echo false)
|
|
68
|
+
echo "pushed=$pushed" >> "$GITHUB_OUTPUT"
|
|
64
69
|
|
|
65
70
|
if [ "$outcome" = 'invalid' ]; then
|
|
66
71
|
echo "::warning::Agent output has no usable merge-gate report. It will not be applied."
|
|
@@ -85,11 +85,17 @@ jq -r \
|
|
|
85
85
|
# Exactly one comment may carry a verdict. Taking the first of several let a second comment
|
|
86
86
|
# reporting a verified critical finding be discarded, and let a comment with no verdict at
|
|
87
87
|
# all supply the report for a verdict written in another.
|
|
88
|
-
|
|
88
|
+
#
|
|
89
|
+
# The line is matched by its shape, not its exact markdown. A model that wrote
|
|
90
|
+
# `**Verdict: remediated**` for `**Verdict:** remediated` had its pushed repair discarded
|
|
91
|
+
# twice on the same head (Pliny-Bot #639). The word has to open a line, so prose that
|
|
92
|
+
# mentions a verdict does not count.
|
|
93
|
+
| "(^|\\n)[^\\w\\n]*Verdict[^\\w\\n]{0,6}(?<v>assessed|remediated)\\b" as $verdict_line
|
|
94
|
+
| [$comments[] | select(.body | test($verdict_line; "i"))] as $verdicts
|
|
89
95
|
| if ($verdicts | length) != 1 then "invalid" else
|
|
90
96
|
|
|
91
97
|
($verdicts[0].body) as $body
|
|
92
|
-
| ($body | capture(
|
|
98
|
+
| ($body | capture($verdict_line; "i").v | ascii_downcase) as $verdict
|
|
93
99
|
| ([$items[] | select(.type == "push_to_pull_request_branch")] | length) as $pushes
|
|
94
100
|
|
|
95
101
|
# The LAST fenced json block in that comment, because the prompt puts the report last and
|