@plainconceptsplatform/workflows 1.7.1 → 1.8.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/loops/actions/land-pull-request/land-pull-request.sh +2 -0
- package/loops/actions/merge-train/merge-train.sh +2 -2
- package/loops/actions/rebase-stale-patch/rebase-stale-patch.sh +1 -0
- package/loops/actions/validate-merge-gate-output/action.yml +24 -1
- package/loops/actions/validate-merge-gate-output/validate-merge-gate-output.sh +12 -5
- package/loops/actions/verify-route-matrix/verify-route-matrix.sh +18 -2
- package/loops/docs/diagrams.md +6 -1
- package/loops/workflows/agent-merge-gate.md +13 -0
- package/loops/workflows/work-router.yml +199 -2
- package/package.json +1 -1
|
@@ -27,6 +27,7 @@ head_ref=$(jq -r .headRefName <<<"$pr_json")
|
|
|
27
27
|
|
|
28
28
|
owner=${REPO%%/*}
|
|
29
29
|
name=${REPO#*/}
|
|
30
|
+
# shellcheck disable=SC2016
|
|
30
31
|
queue=$(gh api graphql -f query='query($o:String!,$n:String!,$b:String!){repository(owner:$o,name:$n){mergeQueue(branch:$b){id}}}' \
|
|
31
32
|
-f o="$owner" -f n="$name" -f b="$base" --jq '.data.repository.mergeQueue.id // ""' 2>/dev/null || true)
|
|
32
33
|
|
|
@@ -50,6 +51,7 @@ pr_id=$(gh pr view "$PR" --repo "$REPO" --json id --jq .id)
|
|
|
50
51
|
# GraphQL first: the asynchronous merge endpoint refuses a GitHub App with "Enqueuer is not
|
|
51
52
|
# authorized to merge" (Agentic-Workflow-Dogfood, 2026-10-20), while enqueuePullRequest takes the
|
|
52
53
|
# same token. A stacked pull request refuses GraphQL and only the asynchronous endpoint takes it.
|
|
54
|
+
# shellcheck disable=SC2016
|
|
53
55
|
enqueued=$(gh api graphql -f query='mutation($id:ID!,$sha:GitObjectID!){enqueuePullRequest(input:{pullRequestId:$id,expectedHeadOid:$sha}){mergeQueueEntry{state}}}' \
|
|
54
56
|
-f id="$pr_id" -f sha="$HEAD_SHA" 2>&1 || true)
|
|
55
57
|
if grep -q '"mergeQueueEntry":{' <<<"$enqueued"; then
|
|
@@ -219,8 +219,7 @@ deepen_ci() {
|
|
|
219
219
|
[ -n "$run" ] || return 1
|
|
220
220
|
mark_full_ci "$pr" || return 1
|
|
221
221
|
# A CI handover reaches the train from inside the run, and a run still going cannot be re-run.
|
|
222
|
-
|
|
223
|
-
for i in 1 2 3 4 5 6 7 8 9 10 11 12; do
|
|
222
|
+
for _ in 1 2 3 4 5 6 7 8 9 10 11 12; do
|
|
224
223
|
[ "$(gh api "repos/$REPO/actions/runs/$run" --jq .status 2>/dev/null)" = completed ] && break
|
|
225
224
|
sleep 10
|
|
226
225
|
done
|
|
@@ -231,6 +230,7 @@ deepen_ci() {
|
|
|
231
230
|
# UNMERGEABLE or MERGEABLE, or empty when it is not in one.
|
|
232
231
|
queue_state() {
|
|
233
232
|
local pr="$1"
|
|
233
|
+
# shellcheck disable=SC2016
|
|
234
234
|
gh api graphql -f query='query($o:String!,$n:String!,$p:Int!){repository(owner:$o,name:$n){pullRequest(number:$p){mergeQueueEntry{state}}}}' \
|
|
235
235
|
-f o="${REPO%%/*}" -f n="${REPO#*/}" -F p="$pr" \
|
|
236
236
|
--jq '.data.repository.pullRequest.mergeQueueEntry.state // ""' 2>/dev/null || true
|
|
@@ -76,6 +76,7 @@ fi
|
|
|
76
76
|
git push --quiet "$remote" "HEAD:refs/heads/${rescued_branch}"
|
|
77
77
|
|
|
78
78
|
grep -qiE "(closes|fixes|resolves) #${ISSUE}\b" "$work/body.md" || printf '\n\nCloses #%s\n' "$ISSUE" >> "$work/body.md"
|
|
79
|
+
# shellcheck disable=SC2016
|
|
79
80
|
printf '\n\nRebased onto `%s` by the workflow: the agent'"'"'s commits no longer applied after `%s` moved during the run.\n' \
|
|
80
81
|
"$base" "$base" >> "$work/body.md"
|
|
81
82
|
[[ "$title" == "[bot] "* ]] || title="[bot] $title"
|
|
@@ -31,7 +31,14 @@ inputs:
|
|
|
31
31
|
description: "check-diff result on the pull request: pass, fail or absent. Empty means it did not report."
|
|
32
32
|
required: false
|
|
33
33
|
default: absent
|
|
34
|
+
unattended:
|
|
35
|
+
description: "true lets a judgement call (low confidence, unmet acceptance criteria, hard to undo) merge instead of going to a person. Facts the workflow measured still hold the pull request."
|
|
36
|
+
required: false
|
|
37
|
+
default: 'false'
|
|
34
38
|
outputs:
|
|
39
|
+
flags:
|
|
40
|
+
description: "What an unattended merge accepted, on one line, for the pull request and the final report. Empty when nothing was."
|
|
41
|
+
value: ${{ steps.validate.outputs.flags }}
|
|
35
42
|
valid:
|
|
36
43
|
description: Whether the output produced a usable disposition.
|
|
37
44
|
value: ${{ steps.validate.outputs.valid }}
|
|
@@ -59,13 +66,29 @@ runs:
|
|
|
59
66
|
OWNER_HIT: ${{ inputs.owner-hit }}
|
|
60
67
|
CONFIDENCE_THRESHOLD: ${{ inputs.confidence-threshold }}
|
|
61
68
|
CODE_RULES: ${{ inputs.code-rules }}
|
|
69
|
+
UNATTENDED: ${{ inputs.unattended }}
|
|
62
70
|
run: |
|
|
63
71
|
set -euo pipefail
|
|
64
72
|
|
|
65
73
|
outcome="$(bash "${{ github.action_path }}/validate-merge-gate-output.sh" \
|
|
66
74
|
"$OUTPUT_FILE" "$ISSUE_NUMBER" "$CI_CONCLUSION" \
|
|
67
|
-
"$BLAST_LEVEL" "$PROTECTED_HIT" "$OWNER_HIT" "$CONFIDENCE_THRESHOLD" "$CODE_RULES")"
|
|
75
|
+
"$BLAST_LEVEL" "$PROTECTED_HIT" "$OWNER_HIT" "$CONFIDENCE_THRESHOLD" "$CODE_RULES" "$UNATTENDED")"
|
|
68
76
|
echo "outcome=$outcome" >> "$GITHUB_OUTPUT"
|
|
77
|
+
|
|
78
|
+
# What the unattended merge let through, named so the person reading the report later knows
|
|
79
|
+
# where to look. It is read from the same report the decision used, never from the prose.
|
|
80
|
+
flags=""
|
|
81
|
+
if [ "$UNATTENDED" = true ] && [ "$outcome" = auto-merge ]; then
|
|
82
|
+
flags=$(jq -r --arg threshold "$CONFIDENCE_THRESHOLD" --arg blast "$BLAST_LEVEL" '
|
|
83
|
+
[.items[]? | select(.type == "add_comment") | .body | strings
|
|
84
|
+
| [scan("```json\\s*(.*?)```"; "m") | .[0]] | last | select(. != null) | fromjson?] | last // {}
|
|
85
|
+
| [ (if (.confidence // 1) < ($threshold | tonumber) then "confidence \(.confidence)" else empty end),
|
|
86
|
+
(if .acceptanceCriteriaMet == false then "acceptance criteria reported unmet" else empty end),
|
|
87
|
+
(if ((.recoverability // "") | ascii_downcase) == "low" then "hard to undo" else empty end),
|
|
88
|
+
(if $blast == "medium" then "medium blast radius" else empty end) ]
|
|
89
|
+
| join("; ")' "$OUTPUT_FILE" 2>/dev/null || true)
|
|
90
|
+
fi
|
|
91
|
+
echo "flags=$flags" >> "$GITHUB_OUTPUT"
|
|
69
92
|
echo "valid=$([ "$outcome" != 'invalid' ] && echo true || echo false)" >> "$GITHUB_OUTPUT"
|
|
70
93
|
pushed=$(jq -r '[.items[]? | select(.type == "push_to_pull_request_branch")] | length > 0' "$OUTPUT_FILE" 2>/dev/null || echo false)
|
|
71
94
|
echo "pushed=$pushed" >> "$GITHUB_OUTPUT"
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
# Usage:
|
|
20
20
|
# validate-merge-gate-output.sh OUTPUT_FILE ISSUE CI_CONCLUSION \
|
|
21
21
|
# BLAST_LEVEL PROTECTED_HIT OWNER_HIT CONFIDENCE_THRESHOLD \
|
|
22
|
-
# CODE_RULES
|
|
22
|
+
# CODE_RULES [UNATTENDED]
|
|
23
23
|
|
|
24
24
|
set -euo pipefail
|
|
25
25
|
|
|
@@ -37,6 +37,12 @@ confidence_threshold="${7-0.8}"
|
|
|
37
37
|
# pass, fail or absent (the repository has no check-diff). An empty value is a job that did not
|
|
38
38
|
# report, and like every other unmeasured fact it goes to a person.
|
|
39
39
|
code_rules="${8-absent}"
|
|
40
|
+
# "true" lets a pull request whose only reason for a person is a judgement call merge anyway: low
|
|
41
|
+
# agent confidence, acceptance criteria reported unmet, or a change reported hard to undo. The
|
|
42
|
+
# facts that stop a merge stay: CI, verified critical or high findings, protected and owner paths,
|
|
43
|
+
# a high blast radius, a failing check-diff and anything the workflow could not measure. The
|
|
44
|
+
# caller records what was accepted so a person can read it afterwards.
|
|
45
|
+
unattended="${9-false}"
|
|
40
46
|
|
|
41
47
|
# An empty measured level is not a low-risk pull request, it is a job that did not report. The
|
|
42
48
|
# defaults above exist for a caller that genuinely has nothing to say; an empty string arriving
|
|
@@ -61,7 +67,7 @@ jq -r \
|
|
|
61
67
|
--arg protected "$protected_hit" \
|
|
62
68
|
--arg owner "$owner_hit" \
|
|
63
69
|
--arg threshold "$confidence_threshold" \
|
|
64
|
-
--arg code "$code_rules" '
|
|
70
|
+
--arg code "$code_rules" --arg unattended "$unattended" '
|
|
65
71
|
|
|
66
72
|
def level_rank: {"low": 0, "medium": 1, "high": 2}[.];
|
|
67
73
|
def is_bool: type == "boolean";
|
|
@@ -161,9 +167,10 @@ jq -r \
|
|
|
161
167
|
# pull request goes to a person rather than through on a default.
|
|
162
168
|
elif $protected != "false" or $owner != "false" then "human-review"
|
|
163
169
|
elif $code != "pass" and $code != "absent" then "human-review"
|
|
164
|
-
elif $level == 1 and $recoverability == "low"
|
|
165
|
-
|
|
166
|
-
|
|
170
|
+
elif ($level == 1 and $recoverability == "low")
|
|
171
|
+
or (($report | has("acceptanceCriteriaMet")) and $report.acceptanceCriteriaMet == false)
|
|
172
|
+
or $confidence < ($threshold | tonumber)
|
|
173
|
+
then (if $unattended == "true" then "auto-merge" else "human-review" end)
|
|
167
174
|
else "auto-merge"
|
|
168
175
|
end
|
|
169
176
|
end
|
|
@@ -1608,10 +1608,10 @@ if [ -f "$GATE_VALIDATOR" ] && worker_installed merge-gate; then
|
|
|
1608
1608
|
|
|
1609
1609
|
gate_case() {
|
|
1610
1610
|
local name="$1" want="$2" json="$3" conclusion="$4"
|
|
1611
|
-
local blast="${5:-low}" protected="${6:-false}" owner="${7:-false}" code="${8-pass}"
|
|
1611
|
+
local blast="${5:-low}" protected="${6:-false}" owner="${7:-false}" code="${8-pass}" unattended="${9:-false}"
|
|
1612
1612
|
printf '%s' "$json" > "$gate_fixture"
|
|
1613
1613
|
local got
|
|
1614
|
-
got=$(bash "$GATE_VALIDATOR" "$gate_fixture" 7 "$conclusion" "$blast" "$protected" "$owner" 0.8 "$code" 2>&1)
|
|
1614
|
+
got=$(bash "$GATE_VALIDATOR" "$gate_fixture" 7 "$conclusion" "$blast" "$protected" "$owner" 0.8 "$code" "$unattended" 2>&1)
|
|
1615
1615
|
if [ "$got" != "$want" ]; then
|
|
1616
1616
|
VALIDATOR_OK=0
|
|
1617
1617
|
echo "FAIL: the merge-gate validator called '${name}' ${got}, expected ${want}" >&2
|
|
@@ -1681,6 +1681,21 @@ if [ -f "$GATE_VALIDATOR" ] && worker_installed merge-gate; then
|
|
|
1681
1681
|
gate_case "confidence below the threshold needs a person" \
|
|
1682
1682
|
human-review "$(gate_items "$(gate_comment assessed "$gate_unsure")")" success low
|
|
1683
1683
|
|
|
1684
|
+
# Unattended, a judgement call merges and is reported; a measured fact still stops the merge.
|
|
1685
|
+
gate_case "unattended, low confidence merges" auto-merge "$(gate_items "$(gate_comment assessed "$gate_unsure")")" success low false false pass true
|
|
1686
|
+
gate_case "unattended, an unmet criterion merges" \
|
|
1687
|
+
auto-merge "$(gate_items "$(gate_comment assessed "$gate_unmet")")" success low false false pass true
|
|
1688
|
+
gate_case "unattended, medium risk that cannot be undone merges" \
|
|
1689
|
+
auto-merge "$(gate_items "$(gate_comment assessed "$gate_fragile")")" success medium false false pass true
|
|
1690
|
+
gate_case "unattended, a verified critical finding still blocks" \
|
|
1691
|
+
blocked "$(gate_items "$(gate_comment assessed "$gate_verified")")" success low false false pass true
|
|
1692
|
+
gate_case "unattended, a protected path still needs the owner" \
|
|
1693
|
+
owner-review "$(gate_items "$(gate_comment assessed "$gate_unsure")")" success low true false pass true
|
|
1694
|
+
gate_case "unattended, a high blast radius still needs the owner" \
|
|
1695
|
+
owner-review "$(gate_items "$(gate_comment assessed "$gate_unsure")")" success high false false pass true
|
|
1696
|
+
gate_case "unattended, a check-diff that did not report still needs a person" \
|
|
1697
|
+
human-review "$(gate_items "$(gate_comment assessed "$gate_unsure")")" success low false false '' true
|
|
1698
|
+
|
|
1684
1699
|
# The agent may raise the measured blast radius when it sees something the path rules could
|
|
1685
1700
|
# not. It may never lower it, which is the only direction that can turn a person's review into
|
|
1686
1701
|
# a machine merge.
|
|
@@ -2066,6 +2081,7 @@ if worker_installed merge-gate; then
|
|
|
2066
2081
|
assert "a conflict is still the gate's even when main is red" gate "$(move MERGEABLE=CONFLICTING CI_STATUS=completed CI_CONCLUSION=failure SAME_AS_MAIN=true)"
|
|
2067
2082
|
# The wait for a red base is bounded. Pliny-Bot's main was red from 19:30 and the train waited
|
|
2068
2083
|
# on it until morning, saying nothing, while five pull requests sat with their repairs unspent.
|
|
2084
|
+
# shellcheck disable=SC2015
|
|
2069
2085
|
grep -q 'MAIN_RED_WAIT_HOURS' "$TRAIN_SH" && grep -q 'red_for_hours' "$TRAIN_SH" &&
|
|
2070
2086
|
grep -q 'main-red-wait-hours' "${HERE}/../merge-train/action.yml" ||
|
|
2071
2087
|
{ FAIL=$((FAIL + 1)); echo "FAIL: the merge train waits on a red base branch with no time limit" >&2; }
|
package/loops/docs/diagrams.md
CHANGED
|
@@ -23,7 +23,9 @@ Every diagram uses the same shapes and the same palette, so reading one teaches
|
|
|
23
23
|
| Dotted arrow | the unhappy path |
|
|
24
24
|
|
|
25
25
|
Each worker diagram also names the rung each stage sits on, from the determinism ladder in
|
|
26
|
-
`skills/workflow-author`: the lower the rung, the cheaper and more reproducible the decision.
|
|
26
|
+
`skills/workflow-author`: the lower the rung, the cheaper and more reproducible the decision. A
|
|
27
|
+
rung is a step on that ladder, numbered 0 to 6 from the router's trigger to the safe outputs. The
|
|
28
|
+
term is ours, not gh-aw's, so it does not appear in the gh-aw documentation.
|
|
27
29
|
|
|
28
30
|
---
|
|
29
31
|
|
|
@@ -188,6 +190,9 @@ of them runs exactly the pipeline above and nothing else. The root is held while
|
|
|
188
190
|
Once every child has landed and the feature branch is green, the gate decides it like any pull
|
|
189
191
|
request and merges it, unless the blast radius, a protected path or a finding sends it to a person
|
|
190
192
|
(`STACK_ROOT_AUTOMERGE: "false"` restores a human merge for every root).
|
|
193
|
+
With `UNATTENDED: "true"` a feature blocked by another waits for that feature's root to merge;
|
|
194
|
+
the router's `stack-landed` job then brings the default branch into its branch, releases its
|
|
195
|
+
children and opens an Unattended report issue.
|
|
191
196
|
|
|
192
197
|
---
|
|
193
198
|
|
|
@@ -66,6 +66,14 @@ env:
|
|
|
66
66
|
# Agent confidence below which the pull request goes to a human. The agent reports the number;
|
|
67
67
|
# this decides what it means.
|
|
68
68
|
CONFIDENCE_THRESHOLD: "0.8"
|
|
69
|
+
# "true" is for a repository that is left to work alone, overnight or across a stack of features.
|
|
70
|
+
# A pull request whose only reason for a person is a judgement call (agent confidence under the
|
|
71
|
+
# threshold, acceptance criteria reported unmet, a change reported hard to undo) then merges, and
|
|
72
|
+
# the pull request and the stack's final report name what was accepted. What still stops a merge
|
|
73
|
+
# is what was measured, not judged: CI, a verified critical or high finding, a protected or owner
|
|
74
|
+
# path, a high blast radius, a failing check-diff and anything the workflow could not measure.
|
|
75
|
+
# "false" sends every one of them to a person.
|
|
76
|
+
UNATTENDED: "false"
|
|
69
77
|
# Replay the verification plan when the gate hands the pull request to a person, so the
|
|
70
78
|
# reviewer sees the screenshots before deciding. "false" leaves evidence to the merge.
|
|
71
79
|
EVIDENCE_BEFORE_REVIEW: "true"
|
|
@@ -501,6 +509,7 @@ jobs:
|
|
|
501
509
|
outcome: ${{ steps.validate.outputs.outcome }}
|
|
502
510
|
pushed: ${{ steps.validate.outputs.pushed }}
|
|
503
511
|
empty: ${{ steps.validate.outputs.empty }}
|
|
512
|
+
flags: ${{ steps.validate.outputs.flags }}
|
|
504
513
|
steps:
|
|
505
514
|
- name: Checkout workflow actions
|
|
506
515
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
@@ -525,6 +534,7 @@ jobs:
|
|
|
525
534
|
owner-hit: ${{ needs.protected_changes.outputs.owner_hit }}
|
|
526
535
|
confidence-threshold: ${{ env.CONFIDENCE_THRESHOLD }}
|
|
527
536
|
code-rules: ${{ needs.code_rules.outputs.result }}
|
|
537
|
+
unattended: ${{ env.UNATTENDED }}
|
|
528
538
|
conclude:
|
|
529
539
|
needs: [activation, subject, protected_changes, agent, safe_outputs, validate_output]
|
|
530
540
|
# `protected_changes.result == 'success'` is stated rather than relied on. GitHub skips a job
|
|
@@ -599,6 +609,9 @@ jobs:
|
|
|
599
609
|
| Files / lines | ${{ needs.protected_changes.outputs.files_changed }} / ${{ needs.protected_changes.outputs.lines_changed }} counted, ${{ needs.protected_changes.outputs.files_total }} / ${{ needs.protected_changes.outputs.lines_total }} in the diff |
|
|
600
610
|
| Protected paths | ${{ needs.protected_changes.outputs.requires_review == 'true' && 'yes' || 'no' }} |
|
|
601
611
|
| Owner paths | ${{ needs.protected_changes.outputs.owner_hit == 'true' && 'yes' || 'no' }} |
|
|
612
|
+
${{ needs.validate_output.outputs.flags != ''
|
|
613
|
+
&& format('| Accepted unattended | {0} |', needs.validate_output.outputs.flags)
|
|
614
|
+
|| '' }}
|
|
602
615
|
${{ needs.validate_output.outputs.outcome == 'owner-review'
|
|
603
616
|
&& format('| Required owners | `{0}` |', needs.protected_changes.outputs.required_owners != '' && needs.protected_changes.outputs.required_owners || 'none configured, see the matched paths above')
|
|
604
617
|
|| '' }}
|
|
@@ -161,6 +161,13 @@ env:
|
|
|
161
161
|
# same gate as any pull request: a change over the blast radius limit, a protected path or an
|
|
162
162
|
# unresolved finding still goes to a person. "false" keeps every root for a human.
|
|
163
163
|
MERGE_TRAIN_STACK_ROOT_AUTOMERGE: "true"
|
|
164
|
+
# "true" lets a chain of features run without a person between them. A feature whose issue is
|
|
165
|
+
# blocked by another (GitHub's "blocked by" relation, or a `Blocked by #N` line in its body) keeps
|
|
166
|
+
# its children held until that feature's stack root merges; the merge brings the default branch
|
|
167
|
+
# into the waiting feature's branch, releases its children, and leaves an "Unattended report"
|
|
168
|
+
# issue naming every pull request that merged on a judgement call. Set the merge gate's own
|
|
169
|
+
# UNATTENDED to the same value. "false" leaves features to be started and read by a person.
|
|
170
|
+
UNATTENDED: "false"
|
|
164
171
|
# "true" logs the move the train would make and makes none. For trying the train on a
|
|
165
172
|
# repository before letting it act.
|
|
166
173
|
MERGE_TRAIN_DRY_RUN: "false"
|
|
@@ -660,15 +667,30 @@ jobs:
|
|
|
660
667
|
[ "${landed:-0}" -gt 0 ] && continue
|
|
661
668
|
waiting="$waiting #$b"
|
|
662
669
|
done
|
|
670
|
+
# Unattended, a whole feature can wait on another one: its issue is blocked by a feature
|
|
671
|
+
# whose stack root has not merged into the default branch. Every child of it waits, whatever
|
|
672
|
+
# its own siblings say, and stack-landed releases them when that root merges.
|
|
673
|
+
if [ "${UNATTENDED:-false}" = true ]; then
|
|
674
|
+
feature=$(printf '%s' "$body" | grep -oE '<!-- split-parent: [0-9]+ -->' | head -1 | grep -oE '[0-9]+' || true)
|
|
675
|
+
if [ -n "$feature" ]; then
|
|
676
|
+
for b in $( { gh api "repos/$REPO/issues/$feature/dependencies/blocked_by" --jq '.[].number' 2>/dev/null || true
|
|
677
|
+
gh issue view "$feature" --repo "$REPO" --json body --jq '.body // ""' 2>/dev/null \
|
|
678
|
+
| grep -oiE 'blocked by #[0-9]+' | grep -oE '[0-9]+' || true
|
|
679
|
+
} | sort -u ); do
|
|
680
|
+
gh issue view "$b" --repo "$REPO" --json state --jq '.state' 2>/dev/null | grep -qix open || continue
|
|
681
|
+
waiting="$waiting #$b"
|
|
682
|
+
done
|
|
683
|
+
fi
|
|
684
|
+
fi
|
|
663
685
|
[ -n "$waiting" ] || exit 0
|
|
664
|
-
echo "::notice::#$ISSUE_NUMBER is blocked by$waiting, not yet landed
|
|
686
|
+
echo "::notice::#$ISSUE_NUMBER is blocked by$waiting, not yet landed; holding it."
|
|
665
687
|
echo "stack-blocked=true" >> "$GITHUB_OUTPUT"
|
|
666
688
|
gh issue edit "$ISSUE_NUMBER" --repo "$REPO" --remove-label bot-working >/dev/null 2>&1 || true
|
|
667
689
|
marker='<!-- stack-held -->'
|
|
668
690
|
if ! gh api "repos/$REPO/issues/$ISSUE_NUMBER/comments?per_page=100" --jq '.[].body' 2>/dev/null | grep -qF "$marker"; then
|
|
669
691
|
{
|
|
670
692
|
printf '%s\n' "$marker"
|
|
671
|
-
printf "Held until%s have landed
|
|
693
|
+
printf "Held until%s have landed: this child builds on them. It keeps \`implement\` and is released when they merge (a sibling into \`%s\`, a feature it waits on into the default branch).\n" "$waiting" "$STACK_BRANCH"
|
|
672
694
|
} > /tmp/held.md
|
|
673
695
|
gh issue comment "$ISSUE_NUMBER" --repo "$REPO" --body-file /tmp/held.md >/dev/null || true
|
|
674
696
|
fi
|
|
@@ -1292,6 +1314,181 @@ jobs:
|
|
|
1292
1314
|
fi
|
|
1293
1315
|
fi
|
|
1294
1316
|
|
|
1317
|
+
# A stack root merged into the default branch: the feature is in. When the repository works
|
|
1318
|
+
# unattended two things follow: the next feature in a chain starts, and a report is left for the
|
|
1319
|
+
# person who comes back. Event-gated, like land-stack-child, because a merge already routes to
|
|
1320
|
+
# evidence. It NEVER checks out or runs pull request code; it reads and writes through the API.
|
|
1321
|
+
stack-landed:
|
|
1322
|
+
needs: classify
|
|
1323
|
+
if: >
|
|
1324
|
+
github.event_name == 'pull_request_target' &&
|
|
1325
|
+
github.event.action == 'closed' &&
|
|
1326
|
+
github.event.pull_request.merged == true &&
|
|
1327
|
+
github.event.pull_request.base.ref == github.event.repository.default_branch
|
|
1328
|
+
runs-on: [agents-aw, "affinity-${{ github.repository_id }}-${{ github.run_id }}-${{ github.run_attempt }}"]
|
|
1329
|
+
timeout-minutes: 10
|
|
1330
|
+
permissions:
|
|
1331
|
+
contents: read
|
|
1332
|
+
pull-requests: read
|
|
1333
|
+
steps:
|
|
1334
|
+
- name: Checkout for App token action
|
|
1335
|
+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
1336
|
+
with:
|
|
1337
|
+
persist-credentials: false
|
|
1338
|
+
- name: Generate App token
|
|
1339
|
+
id: app-token
|
|
1340
|
+
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
|
1341
|
+
with:
|
|
1342
|
+
app-id: ${{ secrets.BOT_APP_ID }}
|
|
1343
|
+
private-key: ${{ secrets.BOT_PRIVATE_KEY }}
|
|
1344
|
+
- name: Start the next feature in the chain and write the report
|
|
1345
|
+
env:
|
|
1346
|
+
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
|
1347
|
+
REPO: ${{ github.repository }}
|
|
1348
|
+
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
|
1349
|
+
EVENT_PR: ${{ github.event.pull_request.number }}
|
|
1350
|
+
run: |
|
|
1351
|
+
set -euo pipefail
|
|
1352
|
+
if [ "${UNATTENDED:-false}" != true ]; then
|
|
1353
|
+
echo "UNATTENDED is not true: features are chained and reported by a person."
|
|
1354
|
+
exit 0
|
|
1355
|
+
fi
|
|
1356
|
+
|
|
1357
|
+
# The feature this root delivered: a closed issue that names this pull request as its
|
|
1358
|
+
# stack root. A merge that is not a stack root (every ordinary pull request) ends here.
|
|
1359
|
+
parent=""
|
|
1360
|
+
for issue in $(gh pr view "$EVENT_PR" --repo "$REPO" --json closingIssuesReferences \
|
|
1361
|
+
--jq '.closingIssuesReferences[].number' 2>/dev/null || true); do
|
|
1362
|
+
root=$(gh issue view "$issue" --repo "$REPO" --json body --jq '.body // ""' 2>/dev/null \
|
|
1363
|
+
| grep -oE '<!-- stack-root-pr: [0-9]+ -->' | head -1 | grep -oE '[0-9]+' || true)
|
|
1364
|
+
if [ "$root" = "$EVENT_PR" ]; then parent="$issue"; break; fi
|
|
1365
|
+
done
|
|
1366
|
+
if [ -z "$parent" ]; then
|
|
1367
|
+
echo "PR #$EVENT_PR is not a stack root; nothing to do."
|
|
1368
|
+
exit 0
|
|
1369
|
+
fi
|
|
1370
|
+
root_branch=$(gh pr view "$EVENT_PR" --repo "$REPO" --json headRefName --jq '.headRefName')
|
|
1371
|
+
echo "Stack #$parent landed on $DEFAULT_BRANCH through #$EVENT_PR ($root_branch)."
|
|
1372
|
+
|
|
1373
|
+
# What a feature waits on is read from the issue's native "blocked by" relation and from
|
|
1374
|
+
# `Blocked by #N` lines in its body, because refine rewrites a body and the relation
|
|
1375
|
+
# survives that. Open ones only; the feature that just landed counts as done even before
|
|
1376
|
+
# GitHub has closed its issue.
|
|
1377
|
+
blockers_of() {
|
|
1378
|
+
{
|
|
1379
|
+
gh api "repos/$REPO/issues/$1/dependencies/blocked_by" --jq '.[] | select(.state == "open") | .number' 2>/dev/null || true
|
|
1380
|
+
for b in $(gh issue view "$1" --repo "$REPO" --json body --jq '.body // ""' 2>/dev/null \
|
|
1381
|
+
| grep -oiE 'blocked by #[0-9]+' | grep -oE '[0-9]+' | sort -u || true); do
|
|
1382
|
+
gh issue view "$b" --repo "$REPO" --json state --jq '.state' 2>/dev/null | grep -qix open && echo "$b" || true
|
|
1383
|
+
done
|
|
1384
|
+
} | grep -vx "$parent" | sort -u || true
|
|
1385
|
+
}
|
|
1386
|
+
depends_on_parent() {
|
|
1387
|
+
{ gh api "repos/$REPO/issues/$1/dependencies/blocked_by" --jq '.[].number' 2>/dev/null || true
|
|
1388
|
+
gh issue view "$1" --repo "$REPO" --json body --jq '.body // ""' 2>/dev/null \
|
|
1389
|
+
| grep -oiE 'blocked by #[0-9]+' | grep -oE '[0-9]+' || true
|
|
1390
|
+
} | grep -qx "$parent"
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
# The held children of every feature that was waiting on this one.
|
|
1394
|
+
declare -A next_branch
|
|
1395
|
+
declare -A next_children
|
|
1396
|
+
candidates=$(gh issue list --repo "$REPO" --state open --label implement --limit 200 \
|
|
1397
|
+
--json number,labels,body \
|
|
1398
|
+
--jq '.[] | select([.labels[].name] | index("bot-working") | not)
|
|
1399
|
+
| select(.body | test("<!-- split-parent: [0-9]+ -->"))
|
|
1400
|
+
| [.number, (.body | capture("<!-- split-parent: (?<p>[0-9]+) -->").p)] | @tsv')
|
|
1401
|
+
while IFS=$'\t' read -r child feature; do
|
|
1402
|
+
[ -n "${child:-}" ] || continue
|
|
1403
|
+
depends_on_parent "$feature" || continue
|
|
1404
|
+
[ -z "$(blockers_of "$feature")" ] || { echo "Feature #$feature still waits on other work; leaving #$child held."; continue; }
|
|
1405
|
+
if [ -z "${next_branch[$feature]:-}" ]; then
|
|
1406
|
+
next_branch[$feature]=$(gh issue view "$feature" --repo "$REPO" --json body --jq '.body // ""' \
|
|
1407
|
+
| grep -oE '<!-- stack-root-branch: [^ ]+ -->' | head -1 | sed -E 's/<!-- stack-root-branch: ([^ ]+) -->/\1/' || true)
|
|
1408
|
+
fi
|
|
1409
|
+
next_children[$feature]="${next_children[$feature]:-} $child"
|
|
1410
|
+
done <<< "$candidates"
|
|
1411
|
+
|
|
1412
|
+
for feature in "${!next_children[@]}"; do
|
|
1413
|
+
branch="${next_branch[$feature]:-}"
|
|
1414
|
+
if ! printf '%s' "$branch" | grep -qE '^[A-Za-z0-9._/-]+$'; then
|
|
1415
|
+
echo "Feature #$feature has no readable stack branch; its children stay held."
|
|
1416
|
+
continue
|
|
1417
|
+
fi
|
|
1418
|
+
# The feature branch was cut from a default branch that did not have the feature that
|
|
1419
|
+
# just landed, and its children build on it. Bring the default branch in first. A
|
|
1420
|
+
# conflict is a person's.
|
|
1421
|
+
# 201 merged, 204 already up to date; both exit 0. A conflict (409) or a missing branch
|
|
1422
|
+
# exits non-zero with the reason on stderr.
|
|
1423
|
+
if ! answer=$(gh api -X POST "repos/$REPO/merges" -f base="$branch" -f head="$DEFAULT_BRANCH" \
|
|
1424
|
+
-f commit_message="Merge $DEFAULT_BRANCH into $branch: #$parent landed" 2>&1 >/dev/null); then
|
|
1425
|
+
answer=$(printf '%s' "$answer" | tr '\n' ' ' | cut -c1-200)
|
|
1426
|
+
echo "::warning::Merging $DEFAULT_BRANCH into $branch failed ($answer); feature #$feature stays held."
|
|
1427
|
+
gh label create needs-human --repo "$REPO" --color B60205 --description "A person has to look" >/dev/null 2>&1 || true
|
|
1428
|
+
gh issue edit "$feature" --repo "$REPO" --add-label needs-human >/dev/null 2>&1 || true
|
|
1429
|
+
gh issue comment "$feature" --repo "$REPO" --body "Feature #$parent landed on $DEFAULT_BRANCH, but \`$DEFAULT_BRANCH\` could not be merged into \`$branch\` ($answer). The children of this feature stay held until a person resolves the branch and re-adds \`implement\` to the first one." >/dev/null || true
|
|
1430
|
+
continue
|
|
1431
|
+
fi
|
|
1432
|
+
for child in ${next_children[$feature]}; do
|
|
1433
|
+
own=$(gh issue view "$child" --repo "$REPO" --json body --jq '.body // ""' 2>/dev/null \
|
|
1434
|
+
| grep -oiE 'blocked by #[0-9]+' | grep -oE '[0-9]+' | sort -u || true)
|
|
1435
|
+
waiting=""
|
|
1436
|
+
for b in $own; do
|
|
1437
|
+
gh issue view "$b" --repo "$REPO" --json state --jq '.state' 2>/dev/null | grep -qix open && waiting="$waiting #$b" || true
|
|
1438
|
+
done
|
|
1439
|
+
if [ -n "$waiting" ]; then
|
|
1440
|
+
echo "#$child still waits on$waiting inside its feature."
|
|
1441
|
+
continue
|
|
1442
|
+
fi
|
|
1443
|
+
gh issue edit "$child" --repo "$REPO" --remove-label implement >/dev/null 2>&1 || true
|
|
1444
|
+
gh issue edit "$child" --repo "$REPO" --add-label implement >/dev/null
|
|
1445
|
+
echo "Released #$child of feature #$feature."
|
|
1446
|
+
done
|
|
1447
|
+
done
|
|
1448
|
+
|
|
1449
|
+
# The report: what merged without a person, and what the gate noted on the way. One issue
|
|
1450
|
+
# per feature, written once; its marker makes a rerun a no-op.
|
|
1451
|
+
marker="<!-- unattended-report: $parent -->"
|
|
1452
|
+
if [ -n "$(gh issue list --repo "$REPO" --state all --search "\"$marker\" in:body" --json number --jq '.[].number')" ]; then
|
|
1453
|
+
echo "Report for #$parent already exists."
|
|
1454
|
+
exit 0
|
|
1455
|
+
fi
|
|
1456
|
+
title=$(gh issue view "$parent" --repo "$REPO" --json title --jq '.title')
|
|
1457
|
+
{
|
|
1458
|
+
echo "$marker"
|
|
1459
|
+
echo "Feature #$parent was delivered without a person: root pull request #$EVENT_PR is in \`$DEFAULT_BRANCH\`."
|
|
1460
|
+
echo
|
|
1461
|
+
echo "## Merged pull requests"
|
|
1462
|
+
echo
|
|
1463
|
+
} > /tmp/report.md
|
|
1464
|
+
flagged=0
|
|
1465
|
+
for pr in $(gh pr list --repo "$REPO" --state merged --base "$root_branch" --limit 100 --json number --jq '.[].number') "$EVENT_PR"; do
|
|
1466
|
+
line=$(gh pr view "$pr" --repo "$REPO" --json title,url --jq '"- [#\(.url | split("/") | last)](\(.url)) \(.title)"')
|
|
1467
|
+
notes=$(gh api "repos/$REPO/issues/$pr/comments?per_page=100" --paginate \
|
|
1468
|
+
--jq '.[].body | select(contains("Accepted unattended")) | capture("Accepted unattended \\| (?<f>[^|]+) \\|").f' 2>/dev/null | tail -1 || true)
|
|
1469
|
+
if [ -n "$notes" ]; then
|
|
1470
|
+
flagged=$((flagged + 1))
|
|
1471
|
+
printf '%s: **%s**\n' "$line" "$notes" >> /tmp/report.md
|
|
1472
|
+
else
|
|
1473
|
+
printf '%s\n' "$line" >> /tmp/report.md
|
|
1474
|
+
fi
|
|
1475
|
+
done
|
|
1476
|
+
{
|
|
1477
|
+
echo
|
|
1478
|
+
if [ "$flagged" -gt 0 ]; then
|
|
1479
|
+
echo "## Look at these"
|
|
1480
|
+
echo
|
|
1481
|
+
echo "$flagged pull request(s) above merged on a judgement call, not on a measured fact. Each was bold above with what the gate accepted. Nothing here was a verified critical or high finding, a protected path, a failing check or a high blast radius, because those still stop a merge. Read them when you can, and revert any you would not have merged."
|
|
1482
|
+
else
|
|
1483
|
+
echo "Nothing was merged on a judgement call. Every pull request passed the gate on measured facts."
|
|
1484
|
+
fi
|
|
1485
|
+
} >> /tmp/report.md
|
|
1486
|
+
label=unattended-report
|
|
1487
|
+
[ "$flagged" -gt 0 ] && label=needs-human
|
|
1488
|
+
gh label create needs-human --repo "$REPO" --color B60205 --description "A person has to look" >/dev/null 2>&1 || true
|
|
1489
|
+
gh label create unattended-report --repo "$REPO" --color 0E8A16 --description "What an unattended feature delivered" >/dev/null 2>&1 || true
|
|
1490
|
+
gh issue create --repo "$REPO" --title "Unattended report: $title" --label "$label" --body-file /tmp/report.md
|
|
1491
|
+
|
|
1295
1492
|
reconcile-bot-pr-runs:
|
|
1296
1493
|
needs: classify
|
|
1297
1494
|
if: needs.classify.outputs.route == 'reconcile-bot-pr-runs'
|