@plainconceptsplatform/workflows 1.5.0 → 1.6.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/route-processing.js +9 -2
- package/dist/workflow-catalog.js +1 -1
- package/loops/actions/apply-agent-output/action.yml +5 -0
- package/loops/actions/classify-route/classify-route.sh +34 -13
- package/loops/actions/decide-agent-retry/action.yml +31 -3
- package/loops/actions/decide-agent-retry/classify-failure.sh +50 -0
- package/loops/actions/housekeeping/action.yml +20 -0
- package/loops/actions/identify-gate-subject/action.yml +52 -17
- package/loops/actions/merge-train/action.yml +71 -0
- package/loops/actions/merge-train/merge-train.sh +495 -0
- package/loops/actions/push-agent-branch/action.yml +73 -45
- package/loops/actions/verify-route-matrix/verify-route-matrix.sh +290 -84
- package/loops/docs/diagrams.md +55 -13
- package/loops/scripts/compile-agent-workflows.mjs +20 -3
- package/loops/templates/ci/app-ci-dotnet-next.yml +13 -14
- package/loops/templates/ci/app-ci-node-monorepo.yml +13 -14
- package/loops/workflows/agent-implement.md +77 -32
- package/loops/workflows/agent-merge-gate.md +90 -62
- package/loops/workflows/agent-refine.md +9 -0
- package/loops/workflows/work-router.yml +285 -471
- package/package.json +1 -1
|
@@ -0,0 +1,495 @@
|
|
|
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: look at every open pull request, oldest first, and do for each the one
|
|
4
|
+
# thing it needs, with merges one at a time and repairs in parallel.
|
|
5
|
+
#
|
|
6
|
+
# The belt this replaces dispatched the gate for every eligible pull request at once. Each gate
|
|
7
|
+
# then resolved its branch against a default branch the gate ahead of it was about to move, so a
|
|
8
|
+
# night of ten pull requests became ten repairs, ten CI runs and ten more gates after every merge,
|
|
9
|
+
# and a verdict that parked a pull request held it however stale it went.
|
|
10
|
+
#
|
|
11
|
+
# What races is a merge, because it moves the default branch under everyone else. A repair (a gate
|
|
12
|
+
# handed red CI) pushes to its own branch and can never merge, so it races nothing. So:
|
|
13
|
+
# merge one at a time, the oldest ready pull request first; nothing behind a pull request that
|
|
14
|
+
# is waiting on its CI, or behind a gate that may merge, merges or is brought up to date
|
|
15
|
+
# repair a red or conflicting pull request goes to the gate at once, up to MAX_REPAIRS together
|
|
16
|
+
# approve its CI is waiting for approval and a trusted bot opened it
|
|
17
|
+
# update it is green but behind the default branch: merge the default branch in, CI reruns
|
|
18
|
+
# A pull request a person has to decide on, or that carries the hold label, is skipped, so it never
|
|
19
|
+
# blocks the line. Ticks come from every merge, every CI completion, every finished gate, and the
|
|
20
|
+
# hourly schedule as the fallback.
|
|
21
|
+
#
|
|
22
|
+
# `train_move` is pure, so the route matrix sources this file and exercises the same decision the
|
|
23
|
+
# train runs. Everything below it reads GitHub and acts.
|
|
24
|
+
|
|
25
|
+
set -euo pipefail
|
|
26
|
+
|
|
27
|
+
# Facts in, one move out. Every input is a plain string the I/O layer measured:
|
|
28
|
+
# HOLD true when the pull request carries the hold label
|
|
29
|
+
# REVIEW_DECISION GitHub's review decision (CHANGES_REQUESTED holds it for its author)
|
|
30
|
+
# PARK none, or held when a standing gate verdict covers this head
|
|
31
|
+
# ATTEMPTS failed gate attempts on this head; MAX_ATTEMPTS is the budget. Counts the
|
|
32
|
+
# larger of the attempt comments and the gate runs, so a gate that fails
|
|
33
|
+
# without a comment still spends it
|
|
34
|
+
# SILENT_GATE true when a gate already ran on this head and left no verdict and no attempt
|
|
35
|
+
# comment: it declined, or died before it could say so. Handing it the same
|
|
36
|
+
# pull request again reproduces that, a hot loop with every tick
|
|
37
|
+
# MERGEABLE MERGEABLE, CONFLICTING or UNKNOWN
|
|
38
|
+
# CI_STATUS none, or the latest CI run's status (queued, in_progress, completed, ...)
|
|
39
|
+
# CI_CONCLUSION that run's conclusion when it completed
|
|
40
|
+
# CI_STUCK true when that run has been pending longer than the train waits
|
|
41
|
+
# BEHIND_BY commits on the base branch the head does not have
|
|
42
|
+
# TRUSTED_AUTHOR true when a trusted bot opened it (only those runs are approved)
|
|
43
|
+
train_move() {
|
|
44
|
+
local move="" reason=""
|
|
45
|
+
|
|
46
|
+
if [ "${HOLD:-false}" = true ]; then
|
|
47
|
+
move=skip; reason="it carries the hold label"
|
|
48
|
+
elif [ "${REVIEW_DECISION:-}" = CHANGES_REQUESTED ]; then
|
|
49
|
+
move=skip; reason="a reviewer requested changes"
|
|
50
|
+
elif [ "${PARK:-none}" = held ]; then
|
|
51
|
+
move=skip; reason="a gate verdict on this head is waiting for a person"
|
|
52
|
+
elif [ "${ATTEMPTS:-0}" -ge "${MAX_ATTEMPTS:-6}" ]; then
|
|
53
|
+
move=skip; reason="its gate attempt budget on this head is spent"
|
|
54
|
+
elif [ "${SILENT_GATE:-false}" = true ]; then
|
|
55
|
+
move=skip; reason="its gate already ran on this head and left no verdict or attempt"
|
|
56
|
+
elif [ "${MERGEABLE:-UNKNOWN}" = CONFLICTING ]; then
|
|
57
|
+
# A conflicting pull request has no merge ref, so CI can never run on it: waiting for a
|
|
58
|
+
# verdict would wait forever. Resolving the conflict is the gate's job.
|
|
59
|
+
move=gate; reason="it conflicts with its base branch"
|
|
60
|
+
else
|
|
61
|
+
case "${CI_STATUS:-none}" in
|
|
62
|
+
none)
|
|
63
|
+
move=skip; reason="no CI run on its head, nothing to gate on" ;;
|
|
64
|
+
completed)
|
|
65
|
+
case "${CI_CONCLUSION:-}" in
|
|
66
|
+
success)
|
|
67
|
+
if [ "${BEHIND_BY:-0}" -gt 0 ]; then
|
|
68
|
+
move=update; reason="green but ${BEHIND_BY} commit(s) behind its base branch"
|
|
69
|
+
else
|
|
70
|
+
move=gate; reason="green and up to date"
|
|
71
|
+
fi
|
|
72
|
+
;;
|
|
73
|
+
failure) move=gate; reason="CI failed" ;;
|
|
74
|
+
action_required)
|
|
75
|
+
if [ "${TRUSTED_AUTHOR:-false}" = true ]; then
|
|
76
|
+
move=approve; reason="CI is waiting for approval"
|
|
77
|
+
else
|
|
78
|
+
move=skip; reason="CI is waiting for a person to approve it"
|
|
79
|
+
fi
|
|
80
|
+
;;
|
|
81
|
+
*) move=skip; reason="CI concluded '${CI_CONCLUSION:-none}', which is no verdict" ;;
|
|
82
|
+
esac
|
|
83
|
+
;;
|
|
84
|
+
*)
|
|
85
|
+
if [ "${CI_STUCK:-false}" = true ]; then
|
|
86
|
+
move=skip; reason="CI has been '${CI_STATUS}' for longer than the train waits"
|
|
87
|
+
else
|
|
88
|
+
move="wait"; reason="CI is ${CI_STATUS}"
|
|
89
|
+
fi
|
|
90
|
+
;;
|
|
91
|
+
esac
|
|
92
|
+
fi
|
|
93
|
+
|
|
94
|
+
printf 'move=%s\nreason=%s\n' "$move" "$reason"
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
# A standing verdict holds the pull request when it is newer than the head commit. Two verdicts
|
|
98
|
+
# never hold: `remediated` is the gate saying it pushed and wants the next CI, and its comment is
|
|
99
|
+
# always newer than the commit it pushed. `blocked` and a merge that did not happen expire once
|
|
100
|
+
# the base branch moves, a bounded number of times: the failure may have been the base's, and a
|
|
101
|
+
# repair attempt on a newer base is cheap next to a weekend of waiting.
|
|
102
|
+
# VERDICT the latest gate verdict word, or empty
|
|
103
|
+
# VERDICT_NEWER true when that verdict is newer than the head commit
|
|
104
|
+
# BASE_MOVED true when the base branch has a commit newer than the verdict
|
|
105
|
+
# RETRIES blocked or failed-merge verdicts already given on this head
|
|
106
|
+
# MAX_RETRIES how many of those the train retries before leaving it to a person
|
|
107
|
+
# STACK_ROOT true for a stack's root pull request, which a person always merges
|
|
108
|
+
train_park() {
|
|
109
|
+
local park=none
|
|
110
|
+
if [ "${VERDICT_NEWER:-false}" = true ]; then
|
|
111
|
+
case "${VERDICT:-}" in
|
|
112
|
+
remediated | "") park=none ;;
|
|
113
|
+
blocked | auto-merge)
|
|
114
|
+
if [ "${STACK_ROOT:-false}" = true ]; then
|
|
115
|
+
park=held
|
|
116
|
+
elif [ "${BASE_MOVED:-false}" = true ] && [ "${RETRIES:-0}" -lt "${MAX_RETRIES:-3}" ]; then
|
|
117
|
+
park=none
|
|
118
|
+
else
|
|
119
|
+
park=held
|
|
120
|
+
fi
|
|
121
|
+
;;
|
|
122
|
+
*) park=held ;;
|
|
123
|
+
esac
|
|
124
|
+
fi
|
|
125
|
+
echo "$park"
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if [ "${BASH_SOURCE[0]}" != "$0" ]; then
|
|
129
|
+
return 0
|
|
130
|
+
fi
|
|
131
|
+
|
|
132
|
+
: "${REPO:?}" "${DEFAULT_BRANCH:?}" "${CI_WORKFLOW_NAME:?}"
|
|
133
|
+
TRUSTED_BOTS="${TRUSTED_BOTS:-}"
|
|
134
|
+
HOLD_LABEL="${HOLD_LABEL:-hold}"
|
|
135
|
+
MAX_ATTEMPTS="${MAX_ATTEMPTS:-6}"
|
|
136
|
+
MAX_RETRIES="${MAX_RETRIES:-3}"
|
|
137
|
+
MAX_REPAIRS="${MAX_REPAIRS:-1}"
|
|
138
|
+
case "$MAX_REPAIRS" in ''|*[!0-9]*|0) MAX_REPAIRS=1 ;; esac
|
|
139
|
+
STUCK_HOURS="${STUCK_HOURS:-3}"
|
|
140
|
+
DRY_RUN="${DRY_RUN:-false}"
|
|
141
|
+
SELF_RUN_ID="${GITHUB_RUN_ID:-0}"
|
|
142
|
+
GATE_TITLE_PREFIX="Agent: Merge Gate - PR #"
|
|
143
|
+
SUMMARY="${GITHUB_STEP_SUMMARY:-/dev/null}"
|
|
144
|
+
|
|
145
|
+
now_epoch=$(date -u +%s)
|
|
146
|
+
iso_epoch() { date -u -d "$1" +%s 2>/dev/null || echo 0; }
|
|
147
|
+
|
|
148
|
+
summary() { printf '%s\n' "$*" >>"$SUMMARY"; }
|
|
149
|
+
|
|
150
|
+
# Every write goes through this, so a dry run is honest: there is no second path that forgets.
|
|
151
|
+
act() {
|
|
152
|
+
local what="$1"
|
|
153
|
+
shift
|
|
154
|
+
if [ "$DRY_RUN" = true ]; then
|
|
155
|
+
echo "[dry-run] $what"
|
|
156
|
+
summary "**Next move (dry run):** $what"
|
|
157
|
+
return 0
|
|
158
|
+
fi
|
|
159
|
+
echo "$what"
|
|
160
|
+
summary "**Next move:** $what"
|
|
161
|
+
"$@"
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
is_trusted_bot() {
|
|
165
|
+
case " $TRUSTED_BOTS " in *" $1 "*) return 0 ;; esac
|
|
166
|
+
return 1
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
# Who may ride the train: a trusted bot, or a person with write access. Asked once per login.
|
|
170
|
+
declare -A WRITE_ACCESS=()
|
|
171
|
+
can_ride() {
|
|
172
|
+
local login="$1" permission
|
|
173
|
+
is_trusted_bot "$login" && return 0
|
|
174
|
+
if [ -z "${WRITE_ACCESS[$login]+set}" ]; then
|
|
175
|
+
permission=$(gh api "repos/$REPO/collaborators/$login/permission" --jq '.permission' 2>/dev/null || echo none)
|
|
176
|
+
case "$permission" in admin | maintain | write) WRITE_ACCESS[$login]=true ;; *) WRITE_ACCESS[$login]=false ;; esac
|
|
177
|
+
fi
|
|
178
|
+
[ "${WRITE_ACCESS[$login]}" = true ]
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
# The gates on the belt, one title per line. A gate handed red CI repairs and pushes and cannot
|
|
182
|
+
# merge, so run-name gives it a " (repair)" suffix and the router queues it per pull request;
|
|
183
|
+
# every other gate may merge, and there is one of those at a time across the repository. The run
|
|
184
|
+
# this tick belongs to is excluded: the tick after a gate runs inside the gate's own router run.
|
|
185
|
+
live_gates() {
|
|
186
|
+
local status
|
|
187
|
+
for status in queued in_progress waiting pending requested; do
|
|
188
|
+
gh api "repos/$REPO/actions/workflows/work-router.yml/runs?status=$status&per_page=100" --jq ".workflow_runs[]
|
|
189
|
+
| select(.id != $SELF_RUN_ID)
|
|
190
|
+
| select(.display_title | startswith(\"$GATE_TITLE_PREFIX\"))
|
|
191
|
+
| .display_title" 2>/dev/null
|
|
192
|
+
done | sort -u
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
# GitHub works mergeability out on demand: the first read of a pull request nobody has opened
|
|
196
|
+
# recently answers UNKNOWN while it computes. Ask until it settles.
|
|
197
|
+
mergeable_state() {
|
|
198
|
+
local pr="$1" state=UNKNOWN
|
|
199
|
+
for _ in 1 2 3 4 5 6; do
|
|
200
|
+
state=$(gh pr view "$pr" --repo "$REPO" --json mergeable --jq '.mergeable' 2>/dev/null || echo UNKNOWN)
|
|
201
|
+
[ "$state" = UNKNOWN ] || break
|
|
202
|
+
sleep 5
|
|
203
|
+
done
|
|
204
|
+
echo "$state"
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
# Gate comments carry the marker and a Verdict line, on the pull request or on its issue. The
|
|
208
|
+
# agent's own report says `assessed` and is always followed by the disposition the workflow
|
|
209
|
+
# computed from it, so it is not a verdict of its own.
|
|
210
|
+
verdict_comments() {
|
|
211
|
+
local number="$1"
|
|
212
|
+
gh api --paginate "repos/$REPO/issues/$number/comments?per_page=100" \
|
|
213
|
+
--jq '.[] | select((.body | contains("<!-- agent-merge-gate -->")) and (.body | test("Verdict:[*]+ [a-z-]+")))
|
|
214
|
+
| [.created_at, (.body | capture("Verdict:[*]+ (?<v>[a-z-]+)").v)]
|
|
215
|
+
| select(.[1] != "assessed") | @tsv' 2>/dev/null || true
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
# shellcheck disable=SC2317 # called through act
|
|
219
|
+
approve_runs() {
|
|
220
|
+
local branch="$1" id
|
|
221
|
+
gh api "repos/$REPO/actions/runs?status=action_required&branch=$branch" --jq '.workflow_runs[].id' |
|
|
222
|
+
while IFS= read -r id; do
|
|
223
|
+
gh api "repos/$REPO/actions/runs/$id/approve" --method POST >/dev/null
|
|
224
|
+
done
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
# shellcheck disable=SC2317 # called through act
|
|
228
|
+
update_branch() {
|
|
229
|
+
local pr="$1" head_sha="$2"
|
|
230
|
+
gh api "repos/$REPO/pulls/$pr/update-branch" --method PUT -f expected_head_sha="$head_sha" >/dev/null
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
# How many gate runs were started for a pull request since a moment, whatever became of them. A
|
|
234
|
+
# run is titled by the router's run-name; this is the one count that does not depend on the gate
|
|
235
|
+
# having written anything.
|
|
236
|
+
gate_run_count() {
|
|
237
|
+
local pr="$1" since="$2"
|
|
238
|
+
gh api --paginate "repos/$REPO/actions/workflows/work-router.yml/runs?event=workflow_dispatch&per_page=100" --jq "[.workflow_runs[] | select(.display_title == \"$GATE_TITLE_PREFIX$pr\") | select(.created_at > \"$since\")] | length" 2>/dev/null | awk '{ n += $1 } END { print n + 0 }'
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
attempt_count() {
|
|
242
|
+
local number="$1" since="$2"
|
|
243
|
+
gh api --paginate "repos/$REPO/issues/$number/comments?per_page=100" \
|
|
244
|
+
--jq "[.[] | select(.body | contains(\"<!-- agent-merge-gate-attempt -->\")) | select(.created_at > \"$since\")] | length" \
|
|
245
|
+
2>/dev/null | awk '{ n += $1 } END { print n + 0 }'
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
# shellcheck disable=SC2317 # called through act
|
|
249
|
+
dispatch_gate() {
|
|
250
|
+
local pr="$1" conclusion="$2" run_id="$3" attempts="$4" since
|
|
251
|
+
since=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
|
252
|
+
gh api "repos/$REPO/actions/workflows/work-router.yml/dispatches" --method POST \
|
|
253
|
+
-f ref="$DEFAULT_BRANCH" \
|
|
254
|
+
-f "inputs[operation]=merge-gate" \
|
|
255
|
+
-f "inputs[pr-number]=$pr" \
|
|
256
|
+
-f "inputs[ci-conclusion]=$conclusion" \
|
|
257
|
+
-f "inputs[ci-run-id]=$run_id" \
|
|
258
|
+
-f "inputs[attempts_so_far]=$attempts" >/dev/null
|
|
259
|
+
# The next tick waits behind this one and must see the gate it started, or it starts a second.
|
|
260
|
+
for _ in $(seq 1 12); do
|
|
261
|
+
if gh api "repos/$REPO/actions/workflows/work-router.yml/runs?event=workflow_dispatch&created=>=$since&per_page=20" \
|
|
262
|
+
--jq ".workflow_runs[] | select(.display_title | startswith(\"$GATE_TITLE_PREFIX$pr\")) | .id" 2>/dev/null | grep -q .; then
|
|
263
|
+
return 0
|
|
264
|
+
fi
|
|
265
|
+
sleep 5
|
|
266
|
+
done
|
|
267
|
+
echo "::warning::Dispatched the gate for #$pr but its run did not appear within a minute."
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
summary "### Merge train"
|
|
271
|
+
summary ""
|
|
272
|
+
|
|
273
|
+
live_titles="$(live_gates)"
|
|
274
|
+
live_repairs=$(printf '%s
|
|
275
|
+
' "$live_titles" | grep -c ' (repair)$' || true)
|
|
276
|
+
live_merge=$(printf '%s
|
|
277
|
+
' "$live_titles" | grep -v ' (repair)$' | grep -c . || true)
|
|
278
|
+
live_for_pr() { printf '%s
|
|
279
|
+
' "$live_titles" | grep -qE "PR #$1( \(repair\))?$"; }
|
|
280
|
+
echo "On the belt: ${live_merge} merging gate(s), ${live_repairs} repair(s). Up to ${MAX_REPAIRS} repair(s) at once."
|
|
281
|
+
summary "On the belt: ${live_merge} merging gate(s), ${live_repairs} repair(s); up to ${MAX_REPAIRS} repair(s) at once."
|
|
282
|
+
summary ""
|
|
283
|
+
|
|
284
|
+
# Once a pull request ahead is waiting on its CI, or a gate that may merge is on the belt, nothing
|
|
285
|
+
# behind it may merge or be brought up to date: the oldest merges first, and one at a time.
|
|
286
|
+
merge_blocked=false
|
|
287
|
+
[ "$live_merge" -eq 0 ] || merge_blocked=true
|
|
288
|
+
|
|
289
|
+
base_head_date=""
|
|
290
|
+
base_head_date_for() {
|
|
291
|
+
local base="$1"
|
|
292
|
+
gh api "repos/$REPO/commits/$base" --jq '.commit.committer.date' 2>/dev/null || echo ""
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
summary "| PR | Author | Move | Why |"
|
|
296
|
+
summary "|---|---|---|---|"
|
|
297
|
+
|
|
298
|
+
mapfile -t candidates < <(
|
|
299
|
+
gh api --paginate "repos/$REPO/pulls?state=open&per_page=100" |
|
|
300
|
+
jq -r --arg repo "$REPO" --arg hold "$HOLD_LABEL" '
|
|
301
|
+
.[]
|
|
302
|
+
| select(.draft == false and (.head.repo.full_name // "") == $repo)
|
|
303
|
+
| [.number, .user.login, .head.sha, .head.ref, .base.ref,
|
|
304
|
+
([.labels[].name] | index($hold) != null)] | @tsv' |
|
|
305
|
+
sort -n
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
if [ "${#candidates[@]}" -eq 0 ]; then
|
|
309
|
+
echo "No open pull request on this repository; the train is empty."
|
|
310
|
+
summary "| - | - | empty | no open pull request |"
|
|
311
|
+
exit 0
|
|
312
|
+
fi
|
|
313
|
+
|
|
314
|
+
for line in "${candidates[@]}"; do
|
|
315
|
+
IFS=$'\t' read -r pr author head_sha head_ref base_ref hold <<<"$line"
|
|
316
|
+
|
|
317
|
+
if ! can_ride "$author"; then
|
|
318
|
+
echo "#$pr: opened by $author, who has no write access; not on the train."
|
|
319
|
+
continue
|
|
320
|
+
fi
|
|
321
|
+
|
|
322
|
+
meta=$(gh pr view "$pr" --repo "$REPO" --json reviewDecision,commits,closingIssuesReferences 2>/dev/null || echo '{}')
|
|
323
|
+
review_decision=$(jq -r '.reviewDecision // ""' <<<"$meta")
|
|
324
|
+
head_committed=$(jq -r '[.commits[]?.committedDate] | max // ""' <<<"$meta")
|
|
325
|
+
linked_issue=$(jq -r '.closingIssuesReferences[0].number // ""' <<<"$meta")
|
|
326
|
+
|
|
327
|
+
# The latest verdict on the pull request or its issue, and how many retries it has had.
|
|
328
|
+
verdicts=$(
|
|
329
|
+
{
|
|
330
|
+
verdict_comments "$pr"
|
|
331
|
+
[ -z "$linked_issue" ] || verdict_comments "$linked_issue"
|
|
332
|
+
} | sort
|
|
333
|
+
)
|
|
334
|
+
verdict_line=$(tail -1 <<<"$verdicts")
|
|
335
|
+
verdict_at=$(cut -f1 <<<"$verdict_line")
|
|
336
|
+
verdict=$(cut -f2 <<<"$verdict_line")
|
|
337
|
+
verdict_newer=false
|
|
338
|
+
if [ -n "$verdict_at" ] && { [ -z "$head_committed" ] || [ "$verdict_at" \> "$head_committed" ]; }; then
|
|
339
|
+
verdict_newer=true
|
|
340
|
+
fi
|
|
341
|
+
retries=$(awk -F'\t' -v since="$head_committed" '$1 > since && ($2 == "blocked" || $2 == "auto-merge")' <<<"$verdicts" | grep -c . || true)
|
|
342
|
+
|
|
343
|
+
base_moved=false
|
|
344
|
+
if [ "$verdict_newer" = true ]; then
|
|
345
|
+
base_head_date=$(base_head_date_for "$base_ref")
|
|
346
|
+
if [ -n "$base_head_date" ] && [ "$base_head_date" \> "$verdict_at" ]; then
|
|
347
|
+
base_moved=true
|
|
348
|
+
fi
|
|
349
|
+
fi
|
|
350
|
+
|
|
351
|
+
stack_root=false
|
|
352
|
+
if [ "$verdict_newer" = true ] && [ -n "$linked_issue" ]; then
|
|
353
|
+
if gh issue view "$linked_issue" --repo "$REPO" --json body --jq '.body // ""' 2>/dev/null |
|
|
354
|
+
grep -q "<!-- stack-root-pr: $pr -->"; then
|
|
355
|
+
stack_root=true
|
|
356
|
+
fi
|
|
357
|
+
fi
|
|
358
|
+
|
|
359
|
+
park=$(VERDICT="$verdict" VERDICT_NEWER="$verdict_newer" BASE_MOVED="$base_moved" \
|
|
360
|
+
RETRIES="$retries" MAX_RETRIES="$MAX_RETRIES" STACK_ROOT="$stack_root" train_park)
|
|
361
|
+
|
|
362
|
+
attempts=0
|
|
363
|
+
silent_gate=false
|
|
364
|
+
if [ "$park" = none ]; then
|
|
365
|
+
horizon="${head_committed:-1970-01-01T00:00:00Z}"
|
|
366
|
+
# A retried verdict starts a fresh budget from the verdict it retries, not from the head.
|
|
367
|
+
[ "$verdict_newer" = true ] && horizon="$verdict_at"
|
|
368
|
+
attempts=$(attempt_count "$pr" "$horizon")
|
|
369
|
+
if [ -n "$linked_issue" ]; then
|
|
370
|
+
attempts=$((attempts + $(attempt_count "$linked_issue" "$horizon")))
|
|
371
|
+
fi
|
|
372
|
+
gate_runs=$(gate_run_count "$pr" "$horizon")
|
|
373
|
+
# Comments say what the gate decided. Runs say it was asked. A run on this head that left no
|
|
374
|
+
# attempt comment and no verdict is a gate that did nothing, and the next tick must not ask
|
|
375
|
+
# again: that is the loop.
|
|
376
|
+
if [ "$gate_runs" -gt 0 ] && [ "$attempts" -eq 0 ]; then silent_gate=true; fi
|
|
377
|
+
[ "$gate_runs" -le "$attempts" ] || attempts="$gate_runs"
|
|
378
|
+
fi
|
|
379
|
+
|
|
380
|
+
ci=$(gh api "repos/$REPO/actions/runs?head_sha=$head_sha&per_page=100" \
|
|
381
|
+
--jq '[.workflow_runs[] | select(.name == env.CI_WORKFLOW_NAME)] | sort_by(.created_at) | last // empty
|
|
382
|
+
| [.id, .status, (.conclusion // ""), .created_at] | @tsv' 2>/dev/null || true)
|
|
383
|
+
IFS=$'\t' read -r ci_run_id ci_status ci_conclusion ci_created <<<"${ci:-}"
|
|
384
|
+
ci_status="${ci_status:-none}"
|
|
385
|
+
# A CI run hands its verdict over from its last job, so the run itself is still in progress
|
|
386
|
+
# when this tick reads it. Its own word stands in for the conclusion GitHub has not written yet.
|
|
387
|
+
if [ -n "${HINT_RUN_ID:-}" ] && [ "${ci_run_id:-}" = "$HINT_RUN_ID" ] && [ "$ci_status" != completed ]; then
|
|
388
|
+
case "${HINT_CONCLUSION:-}" in
|
|
389
|
+
success | failure) ci_status=completed; ci_conclusion="$HINT_CONCLUSION" ;;
|
|
390
|
+
esac
|
|
391
|
+
fi
|
|
392
|
+
ci_stuck=false
|
|
393
|
+
if [ "$ci_status" != none ] && [ "$ci_status" != completed ] && [ -n "${ci_created:-}" ]; then
|
|
394
|
+
[ $((now_epoch - $(iso_epoch "$ci_created"))) -lt $((STUCK_HOURS * 3600)) ] || ci_stuck=true
|
|
395
|
+
fi
|
|
396
|
+
|
|
397
|
+
mergeable=UNKNOWN
|
|
398
|
+
behind_by=0
|
|
399
|
+
if [ "$park" = none ] && [ "$hold" != true ] && [ "$review_decision" != CHANGES_REQUESTED ]; then
|
|
400
|
+
mergeable=$(mergeable_state "$pr")
|
|
401
|
+
if [ "$ci_status" = completed ] && [ "$ci_conclusion" = success ]; then
|
|
402
|
+
behind_by=$(gh api "repos/$REPO/compare/$base_ref...$head_sha" --jq '.behind_by' 2>/dev/null || echo 0)
|
|
403
|
+
fi
|
|
404
|
+
fi
|
|
405
|
+
|
|
406
|
+
trusted_author=false
|
|
407
|
+
is_trusted_bot "$author" && trusted_author=true
|
|
408
|
+
|
|
409
|
+
decision=$(HOLD="$hold" REVIEW_DECISION="$review_decision" PARK="$park" \
|
|
410
|
+
ATTEMPTS="$attempts" MAX_ATTEMPTS="$MAX_ATTEMPTS" SILENT_GATE="$silent_gate" MERGEABLE="$mergeable" \
|
|
411
|
+
CI_STATUS="$ci_status" CI_CONCLUSION="$ci_conclusion" CI_STUCK="$ci_stuck" \
|
|
412
|
+
BEHIND_BY="${behind_by:-0}" TRUSTED_AUTHOR="$trusted_author" train_move)
|
|
413
|
+
move=$(sed -n 's/^move=//p' <<<"$decision")
|
|
414
|
+
reason=$(sed -n 's/^reason=//p' <<<"$decision")
|
|
415
|
+
|
|
416
|
+
# A gate is already on this pull request, whatever kind: offering it another would run the same
|
|
417
|
+
# work twice. It does not hold the line up for the repair kind; a merging gate already does.
|
|
418
|
+
if [ "$move" != skip ] && live_for_pr "$pr"; then
|
|
419
|
+
move=skip
|
|
420
|
+
reason="a gate is already on it"
|
|
421
|
+
fi
|
|
422
|
+
|
|
423
|
+
echo "#$pr ($author): $move, $reason."
|
|
424
|
+
summary "| #$pr | $author | $move | $reason |"
|
|
425
|
+
|
|
426
|
+
case "$move" in
|
|
427
|
+
skip) continue ;;
|
|
428
|
+
wait)
|
|
429
|
+
# Its CI is running: nothing behind it may merge or be brought up to date first, but the
|
|
430
|
+
# pull requests behind it that need repair do not wait for anyone.
|
|
431
|
+
merge_blocked=true
|
|
432
|
+
continue
|
|
433
|
+
;;
|
|
434
|
+
approve)
|
|
435
|
+
act "Approving the CI runs waiting on #$pr." approve_runs "$head_ref"
|
|
436
|
+
merge_blocked=true
|
|
437
|
+
continue
|
|
438
|
+
;;
|
|
439
|
+
update)
|
|
440
|
+
if [ "$merge_blocked" = true ]; then
|
|
441
|
+
echo " #$pr waits for the pull request ahead of it before it is brought up to date."
|
|
442
|
+
continue
|
|
443
|
+
fi
|
|
444
|
+
# Merge, never rebase: the gate's pushes are fast-forward only, and so are a person's.
|
|
445
|
+
if act "Merging $base_ref into #$pr so CI judges it against what it will land on." \
|
|
446
|
+
update_branch "$pr" "$head_sha"; then
|
|
447
|
+
merge_blocked=true
|
|
448
|
+
continue
|
|
449
|
+
fi
|
|
450
|
+
echo "::warning::GitHub could not update #$pr from $base_ref; handing it to the gate."
|
|
451
|
+
act "Dispatching the merge gate for #$pr to bring it up to date." \
|
|
452
|
+
dispatch_gate "$pr" success "${ci_run_id:-}" "$attempts"
|
|
453
|
+
merge_blocked=true
|
|
454
|
+
continue
|
|
455
|
+
;;
|
|
456
|
+
gate)
|
|
457
|
+
conclusion="${ci_conclusion:-}"
|
|
458
|
+
if [ "$ci_status" != completed ] || { [ "$conclusion" != success ] && [ "$conclusion" != failure ]; }; then
|
|
459
|
+
# Only a conflict gets here without a usable verdict on this head. The branch's last
|
|
460
|
+
# verdict tells the gate which path it is on; with none, the repair path resolves it.
|
|
461
|
+
last=$(gh api "repos/$REPO/actions/runs?branch=$head_ref&per_page=100" \
|
|
462
|
+
--jq '[.workflow_runs[] | select(.name == env.CI_WORKFLOW_NAME and .status == "completed"
|
|
463
|
+
and (.conclusion == "success" or .conclusion == "failure"))]
|
|
464
|
+
| sort_by(.created_at) | last // empty | [.id, .conclusion] | @tsv' 2>/dev/null || true)
|
|
465
|
+
ci_run_id=$(cut -f1 <<<"$last")
|
|
466
|
+
conclusion=$(cut -f2 <<<"$last")
|
|
467
|
+
[ -n "$conclusion" ] || conclusion=failure
|
|
468
|
+
fi
|
|
469
|
+
if [ "$conclusion" = failure ]; then
|
|
470
|
+
# A repair: it pushes and can never merge, so it races nothing. Up to MAX_REPAIRS at once.
|
|
471
|
+
if [ "$live_repairs" -ge "$MAX_REPAIRS" ]; then
|
|
472
|
+
echo " #$pr needs a repair; ${live_repairs} already in progress, the limit is ${MAX_REPAIRS}."
|
|
473
|
+
continue
|
|
474
|
+
fi
|
|
475
|
+
act "Dispatching a repair gate for #$pr (CI failed, attempt $((attempts + 1)) of $MAX_ATTEMPTS)." \
|
|
476
|
+
dispatch_gate "$pr" failure "${ci_run_id:-}" "$attempts"
|
|
477
|
+
live_repairs=$((live_repairs + 1))
|
|
478
|
+
continue
|
|
479
|
+
fi
|
|
480
|
+
# CI is green on the verdict: this gate may merge, so it goes only when nothing is ahead of it.
|
|
481
|
+
if [ "$merge_blocked" = true ]; then
|
|
482
|
+
echo " #$pr is ready for the gate; it waits for the pull request ahead of it."
|
|
483
|
+
continue
|
|
484
|
+
fi
|
|
485
|
+
act "Dispatching the merge gate for #$pr (CI $conclusion, attempt $((attempts + 1)) of $MAX_ATTEMPTS)." \
|
|
486
|
+
dispatch_gate "$pr" "$conclusion" "${ci_run_id:-}" "$attempts"
|
|
487
|
+
merge_blocked=true
|
|
488
|
+
continue
|
|
489
|
+
;;
|
|
490
|
+
esac
|
|
491
|
+
done
|
|
492
|
+
|
|
493
|
+
echo "That is every open pull request this tick could move."
|
|
494
|
+
summary ""
|
|
495
|
+
summary "Every open pull request was looked at; those that could move have."
|
|
@@ -1,45 +1,73 @@
|
|
|
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
|
+
set -euo pipefail
|
|
28
|
+
# The runners here outlive their jobs, and `git config --global` outlives the job with
|
|
29
|
+
# them. This step used to add one `url.<token>.insteadOf` rule per run and never remove it,
|
|
30
|
+
# so a runner that had pushed before held several rules for the same prefix, each carrying
|
|
31
|
+
# a token revoked since, and git picks among equal-length matches arbitrarily. The same
|
|
32
|
+
# gate pushed for one pull request and was refused for the next: "Invalid username or
|
|
33
|
+
# token", then "Target branch does not exist" because ls-remote exited 128 (Pliny-Bot #716
|
|
34
|
+
# failed twice that way while #717 pushed in between). Drop every such rule wherever it
|
|
35
|
+
# lives, and set this run's rule in the checkout only.
|
|
36
|
+
for scope in --global --local; do
|
|
37
|
+
git config "$scope" --get-regexp '^url\..*x-access-token.*\.insteadof$' 2>/dev/null |
|
|
38
|
+
while read -r key _; do git config "$scope" --unset-all "$key" || true; done || true
|
|
39
|
+
done
|
|
40
|
+
git config --local url."https://x-access-token:${GH_TOKEN}@github.com/".insteadOf "https://github.com/"
|
|
41
|
+
- name: Read pull request branch
|
|
42
|
+
id: request
|
|
43
|
+
shell: bash
|
|
44
|
+
env:
|
|
45
|
+
GH_TOKEN: ${{ inputs.token }}
|
|
46
|
+
OUTPUT_FILE: ${{ inputs.output-file }}
|
|
47
|
+
BUNDLE_FILE: ${{ inputs.bundle-file }}
|
|
48
|
+
EXPECTED_BRANCH: ${{ inputs.expected-branch }}
|
|
49
|
+
run: |
|
|
50
|
+
set -euo pipefail
|
|
51
|
+
[ -f "$OUTPUT_FILE" ] || exit 0
|
|
52
|
+
|
|
53
|
+
ITEM=$(jq -c '.items[] | select(.type == "push_to_pull_request_branch")' "$OUTPUT_FILE")
|
|
54
|
+
[ -n "$ITEM" ] || exit 0
|
|
55
|
+
|
|
56
|
+
BRANCH=$(echo "$ITEM" | jq -r '.branch')
|
|
57
|
+
# The agent names the branch; the caller knows it. A push anywhere else is refused, not
|
|
58
|
+
# redirected: a model that named the wrong branch may have built on the wrong one too.
|
|
59
|
+
# An item that names none was built on the checkout, which is the expected branch.
|
|
60
|
+
if [ -n "$EXPECTED_BRANCH" ] && { [ -z "$BRANCH" ] || [ "$BRANCH" = null ]; }; then
|
|
61
|
+
BRANCH="$EXPECTED_BRANCH"
|
|
62
|
+
fi
|
|
63
|
+
if [ -n "$EXPECTED_BRANCH" ] && [ "$BRANCH" != "$EXPECTED_BRANCH" ]; then
|
|
64
|
+
echo "::error::The agent asked to push to '$BRANCH'; this run may only push to '$EXPECTED_BRANCH'."
|
|
65
|
+
exit 1
|
|
66
|
+
fi
|
|
67
|
+
echo "branch=$BRANCH" >> "$GITHUB_OUTPUT"
|
|
68
|
+
- name: Apply bundle to pull request branch
|
|
69
|
+
if: steps.request.outputs.branch != '' && inputs.bundle-file != ''
|
|
70
|
+
uses: ./.github/actions/apply-agent-bundle
|
|
71
|
+
with:
|
|
72
|
+
bundle-file: ${{ inputs.bundle-file }}
|
|
73
|
+
target-branch: ${{ steps.request.outputs.branch }}
|