@plainconceptsplatform/workflows 1.3.1 → 1.4.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/worker-env.js +2 -3
- package/loops/actions/add-issue-labels/action.yml +50 -50
- package/loops/actions/agent-output.cjs +17 -17
- package/loops/actions/apply-agent-bundle/action.yml +24 -24
- package/loops/actions/apply-agent-comments/action.yml +42 -42
- package/loops/actions/apply-agent-labels/action.yml +55 -55
- package/loops/actions/apply-agent-output/action.yml +5 -0
- package/loops/actions/cleanup-artifacts/action.yml +91 -91
- package/loops/actions/close-agent-issues/action.yml +43 -43
- package/loops/actions/create-agent-issues/action.yml +64 -3
- package/loops/actions/create-issue-comment/action.yml +29 -29
- package/loops/actions/download-agent-output/action.yml +53 -53
- package/loops/actions/housekeeping/action.yml +95 -1
- package/loops/actions/link-pr-to-issue/action.yml +40 -40
- package/loops/actions/list-open-issues/action.yml +33 -33
- package/loops/actions/load-issue-context/action.yml +45 -45
- package/loops/actions/merge-agent-pr/action.yml +49 -49
- package/loops/actions/push-agent-branch/action.yml +45 -45
- package/loops/actions/remove-issue-labels/action.yml +37 -37
- package/loops/actions/update-agent-issues/action.yml +58 -58
- package/loops/actions/validate-refine-output/validate-refine-output.sh +30 -1
- package/loops/actions/validate-review-output/action.yml +35 -35
- package/loops/actions/validate-triage-output/action.yml +36 -36
- package/loops/actions/verify-composite-actions/action.yml +9 -9
- package/loops/actions/verify-refine-output/action.yml +9 -9
- package/loops/actions/verify-refine-output/verify-refine-output.sh +17 -0
- package/loops/actions/verify-route-matrix/action.yml +9 -9
- package/loops/actions/verify-route-matrix/verify-route-matrix.sh +214 -7
- package/loops/docs/diagrams.md +90 -12
- package/loops/scripts/compile-agent-workflows.mjs +76 -2
- package/loops/templates/agentics/agentics-maintenance.yml +121 -121
- package/loops/templates/issues/bug_report.yml +109 -109
- package/loops/templates/issues/feature_request.yml +75 -75
- package/loops/templates/release/github-release.yml +30 -30
- package/loops/workflows/agent-implement.md +37 -7
- package/loops/workflows/agent-merge-gate.md +74 -6
- package/loops/workflows/agent-refine.md +152 -5
- package/loops/workflows/authorize-bot-work.yml +105 -105
- package/loops/workflows/shared/platform-defaults.md +23 -23
- package/loops/workflows/work-router.yml +330 -2
- package/package.json +1 -1
|
@@ -1,105 +1,105 @@
|
|
|
1
|
-
# Managed by @plainconceptsplatform/workflows. Source: loops/workflows/authorize-bot-work.yml. Update with `workflows update --force`; consumer edits may be overwritten.
|
|
2
|
-
# Human adds implement/refine/direct/feature label → validates permission → bot adds bot-working
|
|
3
|
-
# This ensures the bot is the actor for all agentic workflows.
|
|
4
|
-
#
|
|
5
|
-
# IMPORTANT: Only triggers for HUMAN actors. When the bot transitions refine→implement,
|
|
6
|
-
# it adds bot-working itself, so authorize-bot-work must not fire again.
|
|
7
|
-
name: "Authorize Bot Work"
|
|
8
|
-
|
|
9
|
-
run-name: "Authorizing: ${{ github.event.issue.title }} (#${{ github.event.issue.number }})"
|
|
10
|
-
|
|
11
|
-
on:
|
|
12
|
-
issues:
|
|
13
|
-
types: [labeled]
|
|
14
|
-
|
|
15
|
-
permissions:
|
|
16
|
-
contents: read
|
|
17
|
-
|
|
18
|
-
jobs:
|
|
19
|
-
authorize:
|
|
20
|
-
# Only trigger for work labels from HUMANS (not bots), and only if another bot run does not
|
|
21
|
-
# already own the issue.
|
|
22
|
-
#
|
|
23
|
-
# `review` is deliberately NOT excluded. It used to be, and that made the label a one-way
|
|
24
|
-
# door: the classifier refuses to route while `review` is set, so a person adding `refine`
|
|
25
|
-
# to a parked issue got no run, no comment and no error anywhere. Triage's own
|
|
26
|
-
# needs-maintainer verdict tells the maintainer to add `refine`, which could not work.
|
|
27
|
-
#
|
|
28
|
-
# A person adding a work label IS the human review the label was waiting for, so this
|
|
29
|
-
# workflow clears it below before handing the issue to the bot. The classifier's own guard
|
|
30
|
-
# stays as it is: it exists to stop the *bot* re-triggering itself, and by the time
|
|
31
|
-
# bot-working is added `review` is already gone.
|
|
32
|
-
if: >
|
|
33
|
-
(github.event.label.name == 'implement' ||
|
|
34
|
-
github.event.label.name == 'refine') &&
|
|
35
|
-
!contains(github.event.issue.labels.*.name, 'bot-working') &&
|
|
36
|
-
!endsWith(github.actor, '[bot]')
|
|
37
|
-
runs-on: [agents-aw, "affinity-${{ github.repository_id }}-${{ github.run_id }}-${{ github.run_attempt }}"]
|
|
38
|
-
timeout-minutes: 5
|
|
39
|
-
concurrency:
|
|
40
|
-
group: authorize-${{ github.event.issue.number }}
|
|
41
|
-
cancel-in-progress: false
|
|
42
|
-
permissions:
|
|
43
|
-
contents: read
|
|
44
|
-
issues: write
|
|
45
|
-
steps:
|
|
46
|
-
- name: Check actor permission
|
|
47
|
-
id: check
|
|
48
|
-
env:
|
|
49
|
-
GH_TOKEN: ${{ github.token }}
|
|
50
|
-
ACTOR: ${{ github.actor }}
|
|
51
|
-
run: |
|
|
52
|
-
set -euo pipefail
|
|
53
|
-
|
|
54
|
-
permission=$(gh api "repos/$GITHUB_REPOSITORY/collaborators/$ACTOR/permission" \
|
|
55
|
-
--jq '.permission' 2>/dev/null || echo "none")
|
|
56
|
-
|
|
57
|
-
case "$permission" in
|
|
58
|
-
admin | maintain | write)
|
|
59
|
-
echo "authorized=true" >> "$GITHUB_OUTPUT"
|
|
60
|
-
echo "::notice::$ACTOR has $permission permission - authorized"
|
|
61
|
-
;;
|
|
62
|
-
*)
|
|
63
|
-
echo "authorized=false" >> "$GITHUB_OUTPUT"
|
|
64
|
-
echo "::warning::$ACTOR has '$permission' permission - not authorized"
|
|
65
|
-
;;
|
|
66
|
-
esac
|
|
67
|
-
|
|
68
|
-
- name: Checkout for App token action
|
|
69
|
-
if: steps.check.outputs.authorized == 'true'
|
|
70
|
-
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
71
|
-
with:
|
|
72
|
-
persist-credentials: false
|
|
73
|
-
|
|
74
|
-
- name: Generate App token
|
|
75
|
-
if: steps.check.outputs.authorized == 'true'
|
|
76
|
-
id: app-token
|
|
77
|
-
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
|
78
|
-
with:
|
|
79
|
-
app-id: ${{ secrets.BOT_APP_ID }}
|
|
80
|
-
private-key: ${{ secrets.BOT_PRIVATE_KEY }}
|
|
81
|
-
|
|
82
|
-
- name: Bot takes the issue over
|
|
83
|
-
if: steps.check.outputs.authorized == 'true'
|
|
84
|
-
env:
|
|
85
|
-
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
|
86
|
-
ISSUE_NUMBER: ${{ github.event.issue.number }}
|
|
87
|
-
LABEL: ${{ github.event.label.name }}
|
|
88
|
-
run: |
|
|
89
|
-
set -euo pipefail
|
|
90
|
-
|
|
91
|
-
# Clear review FIRST. A person adding a work label is the human review the label was
|
|
92
|
-
# waiting for, and the classifier refuses to route an issue that still carries it, so
|
|
93
|
-
# removing it here is what makes the hand-off work at all. It also has to happen
|
|
94
|
-
# before bot-working, because that is the event the classifier reads: adding
|
|
95
|
-
# bot-working first would raise an event whose payload still shows review.
|
|
96
|
-
# --remove-label on an absent label is a no-op, so this is safe on a fresh issue.
|
|
97
|
-
# `stalled` goes with it: a person taking the issue on restarts the work, so the
|
|
98
|
-
# marker the janitor retries on must not survive into the new run.
|
|
99
|
-
gh issue edit "$ISSUE_NUMBER" --remove-label "review" --remove-label "stalled" 2>/dev/null ||
|
|
100
|
-
echo "::notice::no review or stalled label to clear on #$ISSUE_NUMBER"
|
|
101
|
-
|
|
102
|
-
echo "Adding bot-working label to issue #$ISSUE_NUMBER (triggered by $LABEL)"
|
|
103
|
-
gh issue edit "$ISSUE_NUMBER" --add-label "bot-working"
|
|
104
|
-
|
|
105
|
-
echo "::notice::bot-working label added - bot will now own the $LABEL workflow"
|
|
1
|
+
# Managed by @plainconceptsplatform/workflows. Source: loops/workflows/authorize-bot-work.yml. Update with `workflows update --force`; consumer edits may be overwritten.
|
|
2
|
+
# Human adds implement/refine/direct/feature label → validates permission → bot adds bot-working
|
|
3
|
+
# This ensures the bot is the actor for all agentic workflows.
|
|
4
|
+
#
|
|
5
|
+
# IMPORTANT: Only triggers for HUMAN actors. When the bot transitions refine→implement,
|
|
6
|
+
# it adds bot-working itself, so authorize-bot-work must not fire again.
|
|
7
|
+
name: "Authorize Bot Work"
|
|
8
|
+
|
|
9
|
+
run-name: "Authorizing: ${{ github.event.issue.title }} (#${{ github.event.issue.number }})"
|
|
10
|
+
|
|
11
|
+
on:
|
|
12
|
+
issues:
|
|
13
|
+
types: [labeled]
|
|
14
|
+
|
|
15
|
+
permissions:
|
|
16
|
+
contents: read
|
|
17
|
+
|
|
18
|
+
jobs:
|
|
19
|
+
authorize:
|
|
20
|
+
# Only trigger for work labels from HUMANS (not bots), and only if another bot run does not
|
|
21
|
+
# already own the issue.
|
|
22
|
+
#
|
|
23
|
+
# `review` is deliberately NOT excluded. It used to be, and that made the label a one-way
|
|
24
|
+
# door: the classifier refuses to route while `review` is set, so a person adding `refine`
|
|
25
|
+
# to a parked issue got no run, no comment and no error anywhere. Triage's own
|
|
26
|
+
# needs-maintainer verdict tells the maintainer to add `refine`, which could not work.
|
|
27
|
+
#
|
|
28
|
+
# A person adding a work label IS the human review the label was waiting for, so this
|
|
29
|
+
# workflow clears it below before handing the issue to the bot. The classifier's own guard
|
|
30
|
+
# stays as it is: it exists to stop the *bot* re-triggering itself, and by the time
|
|
31
|
+
# bot-working is added `review` is already gone.
|
|
32
|
+
if: >
|
|
33
|
+
(github.event.label.name == 'implement' ||
|
|
34
|
+
github.event.label.name == 'refine') &&
|
|
35
|
+
!contains(github.event.issue.labels.*.name, 'bot-working') &&
|
|
36
|
+
!endsWith(github.actor, '[bot]')
|
|
37
|
+
runs-on: [agents-aw, "affinity-${{ github.repository_id }}-${{ github.run_id }}-${{ github.run_attempt }}"]
|
|
38
|
+
timeout-minutes: 5
|
|
39
|
+
concurrency:
|
|
40
|
+
group: authorize-${{ github.event.issue.number }}
|
|
41
|
+
cancel-in-progress: false
|
|
42
|
+
permissions:
|
|
43
|
+
contents: read
|
|
44
|
+
issues: write
|
|
45
|
+
steps:
|
|
46
|
+
- name: Check actor permission
|
|
47
|
+
id: check
|
|
48
|
+
env:
|
|
49
|
+
GH_TOKEN: ${{ github.token }}
|
|
50
|
+
ACTOR: ${{ github.actor }}
|
|
51
|
+
run: |
|
|
52
|
+
set -euo pipefail
|
|
53
|
+
|
|
54
|
+
permission=$(gh api "repos/$GITHUB_REPOSITORY/collaborators/$ACTOR/permission" \
|
|
55
|
+
--jq '.permission' 2>/dev/null || echo "none")
|
|
56
|
+
|
|
57
|
+
case "$permission" in
|
|
58
|
+
admin | maintain | write)
|
|
59
|
+
echo "authorized=true" >> "$GITHUB_OUTPUT"
|
|
60
|
+
echo "::notice::$ACTOR has $permission permission - authorized"
|
|
61
|
+
;;
|
|
62
|
+
*)
|
|
63
|
+
echo "authorized=false" >> "$GITHUB_OUTPUT"
|
|
64
|
+
echo "::warning::$ACTOR has '$permission' permission - not authorized"
|
|
65
|
+
;;
|
|
66
|
+
esac
|
|
67
|
+
|
|
68
|
+
- name: Checkout for App token action
|
|
69
|
+
if: steps.check.outputs.authorized == 'true'
|
|
70
|
+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
71
|
+
with:
|
|
72
|
+
persist-credentials: false
|
|
73
|
+
|
|
74
|
+
- name: Generate App token
|
|
75
|
+
if: steps.check.outputs.authorized == 'true'
|
|
76
|
+
id: app-token
|
|
77
|
+
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
|
78
|
+
with:
|
|
79
|
+
app-id: ${{ secrets.BOT_APP_ID }}
|
|
80
|
+
private-key: ${{ secrets.BOT_PRIVATE_KEY }}
|
|
81
|
+
|
|
82
|
+
- name: Bot takes the issue over
|
|
83
|
+
if: steps.check.outputs.authorized == 'true'
|
|
84
|
+
env:
|
|
85
|
+
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
|
86
|
+
ISSUE_NUMBER: ${{ github.event.issue.number }}
|
|
87
|
+
LABEL: ${{ github.event.label.name }}
|
|
88
|
+
run: |
|
|
89
|
+
set -euo pipefail
|
|
90
|
+
|
|
91
|
+
# Clear review FIRST. A person adding a work label is the human review the label was
|
|
92
|
+
# waiting for, and the classifier refuses to route an issue that still carries it, so
|
|
93
|
+
# removing it here is what makes the hand-off work at all. It also has to happen
|
|
94
|
+
# before bot-working, because that is the event the classifier reads: adding
|
|
95
|
+
# bot-working first would raise an event whose payload still shows review.
|
|
96
|
+
# --remove-label on an absent label is a no-op, so this is safe on a fresh issue.
|
|
97
|
+
# `stalled` goes with it: a person taking the issue on restarts the work, so the
|
|
98
|
+
# marker the janitor retries on must not survive into the new run.
|
|
99
|
+
gh issue edit "$ISSUE_NUMBER" --remove-label "review" --remove-label "stalled" 2>/dev/null ||
|
|
100
|
+
echo "::notice::no review or stalled label to clear on #$ISSUE_NUMBER"
|
|
101
|
+
|
|
102
|
+
echo "Adding bot-working label to issue #$ISSUE_NUMBER (triggered by $LABEL)"
|
|
103
|
+
gh issue edit "$ISSUE_NUMBER" --add-label "bot-working"
|
|
104
|
+
|
|
105
|
+
echo "::notice::bot-working label added - bot will now own the $LABEL workflow"
|
|
@@ -1,26 +1,26 @@
|
|
|
1
|
-
---
|
|
2
|
-
# Managed by @plainconceptsplatform/workflows. Source: loops/workflows/shared/platform-defaults.md. Update with `workflows update --force`; consumer edits may be overwritten.
|
|
3
|
-
description: Shared network and safe-output defaults for catalog agent workflows, plus the verification rules every worker that builds or pushes has to follow. The body below is runtime-imported into each worker's prompt ahead of its own steps.
|
|
4
|
-
|
|
5
|
-
network:
|
|
6
|
-
allowed:
|
|
7
|
-
- defaults
|
|
8
|
-
- forge.plainconcepts.com
|
|
9
|
-
# opencode fetches its model registry from models.opencode.ai at startup; without
|
|
10
|
-
# this host on the allowlist the fetch blocks bootstrap and the run stalls after
|
|
11
|
-
# "init" until the runner kills it. The apex "opencode.ai" is not enough.
|
|
12
|
-
- models.opencode.ai
|
|
13
|
-
- node
|
|
14
|
-
- github
|
|
15
|
-
- dotnet
|
|
16
|
-
- fonts
|
|
17
|
-
- login.microsoftonline.com
|
|
18
|
-
# centralised AgentMemory (App Service); the MCP shim needs it through the egress proxy
|
|
19
|
-
- agentmemory-pro-01.azurewebsites.net
|
|
20
|
-
|
|
21
|
-
safe-outputs:
|
|
22
|
-
threat-detection: false
|
|
23
|
-
---
|
|
1
|
+
---
|
|
2
|
+
# Managed by @plainconceptsplatform/workflows. Source: loops/workflows/shared/platform-defaults.md. Update with `workflows update --force`; consumer edits may be overwritten.
|
|
3
|
+
description: Shared network and safe-output defaults for catalog agent workflows, plus the verification rules every worker that builds or pushes has to follow. The body below is runtime-imported into each worker's prompt ahead of its own steps.
|
|
4
|
+
|
|
5
|
+
network:
|
|
6
|
+
allowed:
|
|
7
|
+
- defaults
|
|
8
|
+
- forge.plainconcepts.com
|
|
9
|
+
# opencode fetches its model registry from models.opencode.ai at startup; without
|
|
10
|
+
# this host on the allowlist the fetch blocks bootstrap and the run stalls after
|
|
11
|
+
# "init" until the runner kills it. The apex "opencode.ai" is not enough.
|
|
12
|
+
- models.opencode.ai
|
|
13
|
+
- node
|
|
14
|
+
- github
|
|
15
|
+
- dotnet
|
|
16
|
+
- fonts
|
|
17
|
+
- login.microsoftonline.com
|
|
18
|
+
# centralised AgentMemory (App Service); the MCP shim needs it through the egress proxy
|
|
19
|
+
- agentmemory-pro-01.azurewebsites.net
|
|
20
|
+
|
|
21
|
+
safe-outputs:
|
|
22
|
+
threat-detection: false
|
|
23
|
+
---
|
|
24
24
|
|
|
25
25
|
## Verification, for every worker that builds or pushes
|
|
26
26
|
|
|
@@ -461,9 +461,160 @@ jobs:
|
|
|
461
461
|
echo "has-open-pr=false" >> "$GITHUB_OUTPUT"
|
|
462
462
|
fi
|
|
463
463
|
|
|
464
|
+
# A stack child implements on the feature branch its parent issue names. The agent sandbox
|
|
465
|
+
# cannot resolve that branch itself -- agents have no git credentials and checkout a pinned
|
|
466
|
+
# default-branch SHA -- so the router reads the marker and resolves the dispatch ref before
|
|
467
|
+
# the worker starts. Refine stamps `<!-- stack-root-branch: feat/N -->` both on the PARENT
|
|
468
|
+
# feature issue and on every child body at hand-over, so the child is self-describing for
|
|
469
|
+
# the implement prompt, which reads only the child's context. The split-parent chain is the
|
|
470
|
+
# fallback when a child predates the stamp (retries of issues split by an earlier refine).
|
|
471
|
+
# Missing, empty or garbled markers fail closed: the child takes the flat call-implement
|
|
472
|
+
# path and lands on the default branch, exactly what a repo without stacks does today.
|
|
473
|
+
# The in-flight check exists because a dispatched agent-implement run is invisible to
|
|
474
|
+
# check-implement-pr's older-run dupe check: this router run completes as soon as
|
|
475
|
+
# `gh workflow run` returns (fire-and-forget), while a workflow_call caller stays
|
|
476
|
+
# in_progress -- and the dispatch run's title "Working (Implement)" and the App-token
|
|
477
|
+
# label events' actor "app/..." both miss the title conditions that check matches.
|
|
478
|
+
# head_branch is the discriminator: a dispatched implement's run records the dispatch ref,
|
|
479
|
+
# feat/N, while a workflow_call run's head_branch is the default branch. Filtering
|
|
480
|
+
# in_progress/queued agent-implement.lock.yml runs by head_branch therefore isolates stack
|
|
481
|
+
# implements with zero effect on flat runs.
|
|
482
|
+
check-stack-branch:
|
|
483
|
+
needs: [classify, authorize]
|
|
484
|
+
if: needs.classify.outputs.route == 'implement' && needs.authorize.outputs.trusted == 'true'
|
|
485
|
+
runs-on: agents-arc
|
|
486
|
+
timeout-minutes: 3
|
|
487
|
+
permissions:
|
|
488
|
+
contents: read
|
|
489
|
+
issues: write
|
|
490
|
+
actions: read
|
|
491
|
+
outputs:
|
|
492
|
+
is-stack: ${{ steps.stack.outputs.is-stack }}
|
|
493
|
+
stack-branch: ${{ steps.stack.outputs.stack-branch }}
|
|
494
|
+
stack-in-flight: ${{ steps.inflight.outputs.stack-in-flight }}
|
|
495
|
+
stack-blocked: ${{ steps.blocked.outputs.stack-blocked }}
|
|
496
|
+
steps:
|
|
497
|
+
- name: Resolve the feature branch from the stack markers
|
|
498
|
+
id: stack
|
|
499
|
+
env:
|
|
500
|
+
GH_TOKEN: ${{ github.token }}
|
|
501
|
+
REPO: ${{ github.repository }}
|
|
502
|
+
ISSUE_NUMBER: ${{ needs.classify.outputs.issue-number }}
|
|
503
|
+
run: |
|
|
504
|
+
set -euo pipefail
|
|
505
|
+
echo "is-stack=false" >> "$GITHUB_OUTPUT"
|
|
506
|
+
echo "stack-branch=" >> "$GITHUB_OUTPUT"
|
|
507
|
+
|
|
508
|
+
# Read failures degrade to the flat path, never to a failed job: call-implement
|
|
509
|
+
# names this job in its needs, so a failure here would block a perfectly ordinary
|
|
510
|
+
# flat implement run. A repo without stack markers must behave exactly as before.
|
|
511
|
+
body=$(gh issue view "$ISSUE_NUMBER" --repo "$REPO" --json body --jq '.body // ""' 2>/dev/null || echo '')
|
|
512
|
+
# Marker-first: the child may carry its own resolved marker from an earlier hop.
|
|
513
|
+
# `|| true` on both pipelines: under `set -o pipefail`, grep's exit 1 on no match
|
|
514
|
+
# (the common flat case) or head's SIGPIPE on a multi-marker body must not fail
|
|
515
|
+
# the job -- call-implement needs this job, so a failure here would block flat
|
|
516
|
+
# implements too.
|
|
517
|
+
branch=$(printf '%s' "$body" | grep -oE '<!-- stack-root-branch: [^ ]+ -->' | head -1 | sed -E 's/<!-- stack-root-branch: ([^ ]+) -->/\1/' || true)
|
|
518
|
+
|
|
519
|
+
if [ -z "$branch" ]; then
|
|
520
|
+
# No marker on the child: follow the split-parent chain to the feature issue,
|
|
521
|
+
# where refine stamps the stack root.
|
|
522
|
+
parent=$(printf '%s' "$body" \
|
|
523
|
+
| grep -oE '<!-- split-parent: [0-9]+ -->' | head -1 \
|
|
524
|
+
| grep -oE '[0-9]+' || true)
|
|
525
|
+
if [ -n "$parent" ]; then
|
|
526
|
+
parent_body=$(gh issue view "$parent" --repo "$REPO" --json body --jq '.body // ""' 2>/dev/null || echo '')
|
|
527
|
+
branch=$(printf '%s' "$parent_body" \
|
|
528
|
+
| grep -oE '<!-- stack-root-branch: [^ ]+ -->' | head -1 \
|
|
529
|
+
| sed -E 's/<!-- stack-root-branch: ([^ ]+) -->/\1/' || true)
|
|
530
|
+
fi
|
|
531
|
+
fi
|
|
532
|
+
|
|
533
|
+
# Fail closed on anything unreadable or unsafe: a missing marker, a garbled
|
|
534
|
+
# split parent, or a marker naming a deleted branch all take the flat path.
|
|
535
|
+
if ! printf '%s' "$branch" | grep -qE '^[A-Za-z0-9._/-]+$'; then
|
|
536
|
+
echo "::notice::Issue #$ISSUE_NUMBER carries no readable stack-root-branch marker; taking the flat path."
|
|
537
|
+
exit 0
|
|
538
|
+
fi
|
|
539
|
+
if ! gh api "repos/$REPO/branches/$(printf '%s' "$branch" | sed 's|/|%2F|g')" --jq '.name' >/dev/null 2>&1; then
|
|
540
|
+
echo "::notice::Stack root branch $branch no longer exists on the remote; taking the flat path."
|
|
541
|
+
echo "stack-branch=$branch" >> "$GITHUB_OUTPUT"
|
|
542
|
+
exit 0
|
|
543
|
+
fi
|
|
544
|
+
|
|
545
|
+
echo "is-stack=true" >> "$GITHUB_OUTPUT"
|
|
546
|
+
echo "stack-branch=$branch" >> "$GITHUB_OUTPUT"
|
|
547
|
+
|
|
548
|
+
# A child whose `Blocked by #N` sibling has not landed is held, not dispatched: dispatched now
|
|
549
|
+
# it would cut from a feature branch that lacks the sibling's work. Lyceum #112 (units,
|
|
550
|
+
# blocked by #111, modules) was dispatched alongside #111 and would have built units on a
|
|
551
|
+
# feat/45 with no modules. The in-flight check below cannot catch that: both routers ran
|
|
552
|
+
# before either dispatch existed, and serialising runs would still start #112 before #111's
|
|
553
|
+
# pull request merged. The landing of the blocker releases the child (the stack block in
|
|
554
|
+
# detect-pr-conflicts). Held, it keeps `implement` and loses `bot-working`, so it reads as
|
|
555
|
+
# waiting rather than stuck.
|
|
556
|
+
- name: Hold a child whose blockers have not landed
|
|
557
|
+
id: blocked
|
|
558
|
+
if: steps.stack.outputs.is-stack == 'true'
|
|
559
|
+
env:
|
|
560
|
+
GH_TOKEN: ${{ github.token }}
|
|
561
|
+
REPO: ${{ github.repository }}
|
|
562
|
+
ISSUE_NUMBER: ${{ needs.classify.outputs.issue-number }}
|
|
563
|
+
STACK_BRANCH: ${{ steps.stack.outputs.stack-branch }}
|
|
564
|
+
run: |
|
|
565
|
+
set -euo pipefail
|
|
566
|
+
echo "stack-blocked=false" >> "$GITHUB_OUTPUT"
|
|
567
|
+
body=$(gh issue view "$ISSUE_NUMBER" --repo "$REPO" --json body --jq '.body // ""' 2>/dev/null || echo '')
|
|
568
|
+
waiting=""
|
|
569
|
+
for b in $(printf '%s' "$body" | grep -oiE 'blocked by #[0-9]+' | grep -oE '[0-9]+' | sort -u || true); do
|
|
570
|
+
gh issue view "$b" --repo "$REPO" --json state --jq '.state' 2>/dev/null | grep -qix open || continue
|
|
571
|
+
# Open, but landed if a pull request that closes it has merged into the feature branch:
|
|
572
|
+
# the release fires on that merge and the gate closes the issue a moment later, or not
|
|
573
|
+
# at all when it fails. Reading the issue state alone would hold the child again with
|
|
574
|
+
# no later landing left to release it.
|
|
575
|
+
landed=$(gh pr list --repo "$REPO" --state merged --base "$STACK_BRANCH" --search "#$b in:body" --json body --jq "[.[] | select(.body | test(\"(?i)(close[sd]?|fix(e[sd])?|resolve[sd]?) #${b}([^0-9]|$)\"))] | length" 2>/dev/null || echo 0)
|
|
576
|
+
[ "${landed:-0}" -gt 0 ] && continue
|
|
577
|
+
waiting="$waiting #$b"
|
|
578
|
+
done
|
|
579
|
+
[ -n "$waiting" ] || exit 0
|
|
580
|
+
echo "::notice::#$ISSUE_NUMBER is blocked by$waiting, not yet landed on $STACK_BRANCH; holding it."
|
|
581
|
+
echo "stack-blocked=true" >> "$GITHUB_OUTPUT"
|
|
582
|
+
gh issue edit "$ISSUE_NUMBER" --repo "$REPO" --remove-label bot-working >/dev/null 2>&1 || true
|
|
583
|
+
marker='<!-- stack-held -->'
|
|
584
|
+
if ! gh api "repos/$REPO/issues/$ISSUE_NUMBER/comments?per_page=100" --jq '.[].body' 2>/dev/null | grep -qF "$marker"; then
|
|
585
|
+
{
|
|
586
|
+
printf '%s\n' "$marker"
|
|
587
|
+
printf "Held until%s have landed on \`%s\`: this child builds on them. It keeps \`implement\` and is released when they merge.\n" "$waiting" "$STACK_BRANCH"
|
|
588
|
+
} > /tmp/held.md
|
|
589
|
+
gh issue comment "$ISSUE_NUMBER" --repo "$REPO" --body-file /tmp/held.md >/dev/null || true
|
|
590
|
+
fi
|
|
591
|
+
|
|
592
|
+
- name: Detect an in-flight implement on the feature branch
|
|
593
|
+
id: inflight
|
|
594
|
+
if: steps.stack.outputs.is-stack == 'true'
|
|
595
|
+
env:
|
|
596
|
+
GH_TOKEN: ${{ github.token }}
|
|
597
|
+
REPO: ${{ github.repository }}
|
|
598
|
+
STACK_BRANCH: ${{ steps.stack.outputs.stack-branch }}
|
|
599
|
+
run: |
|
|
600
|
+
set -euo pipefail
|
|
601
|
+
echo "stack-in-flight=false" >> "$GITHUB_OUTPUT"
|
|
602
|
+
# Only dispatched stack implements run with head_branch = feat/N; a workflow_call
|
|
603
|
+
# (flat) implement run's head_branch is the default branch, so this filter never
|
|
604
|
+
# sees it. This closes the dupe-visibility gap left by fire-and-forget dispatch.
|
|
605
|
+
for status in in_progress queued; do
|
|
606
|
+
live=$(gh api "repos/$REPO/actions/workflows/agent-implement.lock.yml/runs?status=$status&per_page=100" \
|
|
607
|
+
--jq "[.workflow_runs[] | select(.head_branch == \"${STACK_BRANCH}\")] | length" 2>/dev/null || echo 0)
|
|
608
|
+
if [ "${live:-0}" -gt 0 ]; then
|
|
609
|
+
echo "::notice::An implement run is already in flight on $STACK_BRANCH; skipping dispatch."
|
|
610
|
+
echo "stack-in-flight=true" >> "$GITHUB_OUTPUT"
|
|
611
|
+
exit 0
|
|
612
|
+
fi
|
|
613
|
+
done
|
|
614
|
+
|
|
464
615
|
call-implement:
|
|
465
|
-
needs: [classify, authorize, check-implement-pr]
|
|
466
|
-
if: needs.classify.outputs.route == 'implement' && needs.authorize.outputs.trusted == 'true' && needs.check-implement-pr.outputs.has-open-pr != 'true'
|
|
616
|
+
needs: [classify, authorize, check-implement-pr, check-stack-branch]
|
|
617
|
+
if: needs.classify.outputs.route == 'implement' && needs.authorize.outputs.trusted == 'true' && needs.check-implement-pr.outputs.has-open-pr != 'true' && needs.check-stack-branch.outputs.is-stack != 'true'
|
|
467
618
|
uses: ./.github/workflows/agent-implement.lock.yml
|
|
468
619
|
concurrency:
|
|
469
620
|
group: implement-global
|
|
@@ -483,6 +634,50 @@ jobs:
|
|
|
483
634
|
BOT_PRIVATE_KEY: ${{ secrets.BOT_PRIVATE_KEY }}
|
|
484
635
|
AGENTMEMORY_SECRET: ${{ secrets.AGENTMEMORY_SECRET }}
|
|
485
636
|
|
|
637
|
+
# Stack children dispatch agent-implement by `gh workflow run --ref feat/N` instead of calling
|
|
638
|
+
# it: a workflow_call would pin the callee to THIS event's SHA, which for a label event is
|
|
639
|
+
# the default branch, not the feature branch. Dispatch on the feature ref makes the callee's
|
|
640
|
+
# GITHUB_SHA the feature tip, so the agent patches exactly the tree its PR will target, and
|
|
641
|
+
# the compile-time checkout ternary keys off `workflow_dispatch` to checkout github.ref_name.
|
|
642
|
+
# The dispatch itself carries no secrets (gh workflow run cannot forward them); the
|
|
643
|
+
# dispatched run reads REPO-LEVEL OPENAI_API_KEY / CODEX_API_KEY / COPILOT_GITHUB_TOKEN, the
|
|
644
|
+
# stack-activation prerequisite documented alongside the markers. BOT_APP_ID / BOT_PRIVATE_KEY
|
|
645
|
+
# are already repo-level. dispatch-triage proves the pattern: same app-token mint, same
|
|
646
|
+
# `actions: write` scope needed for `gh workflow run`.
|
|
647
|
+
dispatch-implement:
|
|
648
|
+
needs: [classify, authorize, check-implement-pr, check-stack-branch]
|
|
649
|
+
if: >
|
|
650
|
+
needs.classify.outputs.route == 'implement' &&
|
|
651
|
+
needs.authorize.outputs.trusted == 'true' &&
|
|
652
|
+
needs.check-implement-pr.outputs.has-open-pr != 'true' &&
|
|
653
|
+
needs.check-stack-branch.outputs.is-stack == 'true' &&
|
|
654
|
+
needs.check-stack-branch.outputs.stack-in-flight != 'true' &&
|
|
655
|
+
needs.check-stack-branch.outputs.stack-blocked != 'true'
|
|
656
|
+
runs-on: agents-arc
|
|
657
|
+
timeout-minutes: 5
|
|
658
|
+
permissions:
|
|
659
|
+
actions: write
|
|
660
|
+
contents: read
|
|
661
|
+
steps:
|
|
662
|
+
- name: Create bot token
|
|
663
|
+
id: app-token
|
|
664
|
+
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
|
665
|
+
with:
|
|
666
|
+
client-id: ${{ secrets.BOT_APP_ID }}
|
|
667
|
+
private-key: ${{ secrets.BOT_PRIVATE_KEY }}
|
|
668
|
+
- name: Dispatch stack implement on the feature branch
|
|
669
|
+
env:
|
|
670
|
+
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
|
671
|
+
REPO: ${{ github.repository }}
|
|
672
|
+
STACK_BRANCH: ${{ needs.check-stack-branch.outputs.stack-branch }}
|
|
673
|
+
ISSUE_NUMBER: ${{ needs.classify.outputs.issue-number }}
|
|
674
|
+
ATTEMPTS: ${{ needs.classify.outputs.attempts }}
|
|
675
|
+
run: |
|
|
676
|
+
set -euo pipefail
|
|
677
|
+
gh workflow run agent-implement.lock.yml --repo "$REPO" --ref "$STACK_BRANCH" \
|
|
678
|
+
-f issue-number="$ISSUE_NUMBER" \
|
|
679
|
+
-f attempts_so_far="$ATTEMPTS"
|
|
680
|
+
|
|
486
681
|
dispatch-triage:
|
|
487
682
|
needs: [classify, authorize]
|
|
488
683
|
if: needs.classify.outputs.route == 'triage' && needs.authorize.outputs.is_outside_collaborator == 'true'
|
|
@@ -973,6 +1168,139 @@ jobs:
|
|
|
973
1168
|
while IFS= read -r pr; do
|
|
974
1169
|
maybe_dispatch "$pr"
|
|
975
1170
|
done
|
|
1171
|
+
|
|
1172
|
+
# Stacked delivery: the merged pull request merged into a feature branch, not the
|
|
1173
|
+
# default branch, so this is a child landing. If the feature branch has no open
|
|
1174
|
+
# children left and its root pull request is green, the stack is complete and a
|
|
1175
|
+
# comment on the root pull request tells the human it is ready for the root merge.
|
|
1176
|
+
# Repos without stack markers never reach the interesting part: the event PR's base
|
|
1177
|
+
# was the default branch, so the guard below fails immediately. Fully idempotent:
|
|
1178
|
+
# the completion comment carries its own marker, and a run that finds it already
|
|
1179
|
+
# present exits without writing anything.
|
|
1180
|
+
event_base=$(gh api "repos/$REPO/pulls/$EVENT_PR" --jq '.base.ref // empty' 2>/dev/null || echo '')
|
|
1181
|
+
if [ -n "$event_base" ] && [ "$event_base" != "$DEFAULT_BRANCH" ]; then
|
|
1182
|
+
echo "PR #$EVENT_PR merged into a feature branch; checking the stack."
|
|
1183
|
+
# The implement worker's pull request closes exactly its own child issue, so
|
|
1184
|
+
# the closed issue comes from the linked closing references (the resolved form
|
|
1185
|
+
# GitHub computes whatever keyword the agent wrote), with the body text as
|
|
1186
|
+
# fallback for a PR created before that guarantee.
|
|
1187
|
+
closed_issue=$(gh pr view "$EVENT_PR" --repo "$REPO" --json closingIssuesReferences \
|
|
1188
|
+
--jq '.closingIssuesReferences[0].number // empty' 2>/dev/null || true)
|
|
1189
|
+
[ -n "$closed_issue" ] || closed_issue=$(gh api "repos/$REPO/pulls/$EVENT_PR" --jq '.body // ""' \
|
|
1190
|
+
| grep -oiE '(closes?|fixes?|resolves?) #[0-9]+' | head -1 | grep -oE '[0-9]+' || true)
|
|
1191
|
+
if [ -z "$closed_issue" ]; then
|
|
1192
|
+
echo "PR #$EVENT_PR closes no issue; not a stack child. Nothing to do."
|
|
1193
|
+
else
|
|
1194
|
+
# Refine stamps the split-parent marker on every child body it hands over, so
|
|
1195
|
+
# the child's own body names the feature issue. The reverse sub-issues
|
|
1196
|
+
# listing has no equivalent per-child lookup: the sub-issues API only walks
|
|
1197
|
+
# downward from the parent.
|
|
1198
|
+
parent=$(printf '%s' "$(gh issue view "$closed_issue" --repo "$REPO" --json body --jq '.body // ""' 2>/dev/null || echo '')" \
|
|
1199
|
+
| grep -oE '<!-- split-parent: [0-9]+ -->' | head -1 | grep -oE '[0-9]+' || true)
|
|
1200
|
+
if [ -z "$parent" ]; then
|
|
1201
|
+
echo "#$closed_issue is not a stack child issue (no parent found); nothing stack-related to do."
|
|
1202
|
+
else
|
|
1203
|
+
root_pr=$(printf '%s' "$(gh issue view "$parent" --repo "$REPO" --json body --jq '.body // ""' 2>/dev/null || echo '')" \
|
|
1204
|
+
| grep -oE '<!-- stack-root-pr: [0-9]+ -->' | head -1 | grep -oE '[0-9]+' || true)
|
|
1205
|
+
# Case-insensitive on purpose, here and in the child count below: `gh pr view` and
|
|
1206
|
+
# `gh issue view` read GraphQL and answer OPEN, where the REST API answers open. An
|
|
1207
|
+
# exact `open` never matched, so no root was ever open and no child was ever counted
|
|
1208
|
+
# open, and the stack-complete announcement could not fire (Lyceum #44, router run
|
|
1209
|
+
# 38752856518: "Stack #44 has no open root pull request" with #70 open).
|
|
1210
|
+
root_open=false
|
|
1211
|
+
[ -n "$root_pr" ] && root_open=$(gh pr view "$root_pr" --repo "$REPO" --json state --jq '.state' 2>/dev/null | grep -qix open && echo true || echo false)
|
|
1212
|
+
if [ "$root_open" != true ]; then
|
|
1213
|
+
echo "Stack #$parent has no open root pull request; nothing to announce."
|
|
1214
|
+
else
|
|
1215
|
+
# The same child listing refine uses to hand work out: the sub-issues
|
|
1216
|
+
# link safe_outputs writes, with the body-marker search as fallback. A
|
|
1217
|
+
# stack parent always has children, so a listing that finds none means
|
|
1218
|
+
# the lookups failed, and announcing completion of a stack we cannot
|
|
1219
|
+
# see would be wrong -- stay silent and let a rerun decide.
|
|
1220
|
+
children=$(gh api "repos/$REPO/issues/$parent/sub_issues" --jq '.[].number' 2>/dev/null || true)
|
|
1221
|
+
if [ -z "$children" ]; then
|
|
1222
|
+
children=$(gh issue list --repo "$REPO" --state open --limit 50 \
|
|
1223
|
+
--search "\"<!-- split-parent: ${parent} -->\" in:body" --json number --jq '.[].number' || true)
|
|
1224
|
+
fi
|
|
1225
|
+
if [ -z "$children" ]; then
|
|
1226
|
+
echo "Cannot list any child of stack #$parent; leaving completion undetected for a rerun."
|
|
1227
|
+
else
|
|
1228
|
+
# Release the children this landing unblocks. check-stack-branch holds a child
|
|
1229
|
+
# whose `Blocked by #N` sibling is still open, and nothing else re-dispatches it,
|
|
1230
|
+
# so the landing does: re-adding `implement` with the App token is a bot label
|
|
1231
|
+
# event, which the router routes straight to implement. The child that just
|
|
1232
|
+
# landed counts as done even before the gate closes its issue a moment later.
|
|
1233
|
+
for child in $children; do
|
|
1234
|
+
[ "$child" = "$closed_issue" ] && continue
|
|
1235
|
+
meta=$(gh issue view "$child" --repo "$REPO" --json state,labels,body 2>/dev/null || echo '{}')
|
|
1236
|
+
printf '%s' "$meta" | jq -e '(.state // "" | ascii_downcase) == "open"' >/dev/null || continue
|
|
1237
|
+
printf '%s' "$meta" | jq -e '[.labels[]?.name] | (index("implement") != null) and (index("bot-working") == null)' >/dev/null || continue
|
|
1238
|
+
blockers=$(printf '%s' "$meta" | jq -r '.body // ""' | grep -oiE 'blocked by #[0-9]+' | grep -oE '[0-9]+' | sort -u || true)
|
|
1239
|
+
[ -n "$blockers" ] || continue
|
|
1240
|
+
waiting=""
|
|
1241
|
+
for b in $blockers; do
|
|
1242
|
+
[ "$b" = "$closed_issue" ] && continue
|
|
1243
|
+
if gh issue view "$b" --repo "$REPO" --json state --jq '.state' 2>/dev/null | grep -qix open; then
|
|
1244
|
+
waiting="$waiting #$b"
|
|
1245
|
+
fi
|
|
1246
|
+
done
|
|
1247
|
+
if [ -n "$waiting" ]; then
|
|
1248
|
+
echo "#$child still waits on$waiting."
|
|
1249
|
+
continue
|
|
1250
|
+
fi
|
|
1251
|
+
gh issue edit "$child" --repo "$REPO" --remove-label implement >/dev/null 2>&1 || true
|
|
1252
|
+
gh issue edit "$child" --repo "$REPO" --add-label implement >/dev/null
|
|
1253
|
+
echo "Released #$child: everything it was blocked by has landed."
|
|
1254
|
+
done
|
|
1255
|
+
open_children=0
|
|
1256
|
+
for child in $children; do
|
|
1257
|
+
# Landed, though the gate may not have closed its issue yet.
|
|
1258
|
+
[ "$child" = "$closed_issue" ] && continue
|
|
1259
|
+
if gh issue view "$child" --repo "$REPO" --json state --jq '.state' 2>/dev/null | grep -qix open; then
|
|
1260
|
+
open_children=$((open_children + 1))
|
|
1261
|
+
fi
|
|
1262
|
+
done
|
|
1263
|
+
if [ "$open_children" -gt 0 ]; then
|
|
1264
|
+
echo "$open_children child issue(s) still open on stack #$parent; the stack continues."
|
|
1265
|
+
else
|
|
1266
|
+
# The root pull request must be green and mergeable. Green: a successful
|
|
1267
|
+
# CI run on its head SHA. Mergeable: the same on-demand computation
|
|
1268
|
+
# every gate dispatch already relies on; UNKNOWN settles in the
|
|
1269
|
+
# background, so ask until it does, like mergeable_state() does.
|
|
1270
|
+
root_state=UNKNOWN
|
|
1271
|
+
for _ in 1 2 3 4 5 6; do
|
|
1272
|
+
root_state=$(gh pr view "$root_pr" --repo "$REPO" --json mergeable --jq '.mergeable' 2>/dev/null || echo UNKNOWN)
|
|
1273
|
+
[ "$root_state" = "UNKNOWN" ] || break
|
|
1274
|
+
sleep 5
|
|
1275
|
+
done
|
|
1276
|
+
root_sha=$(gh api "repos/$REPO/pulls/$root_pr" --jq '.head.sha')
|
|
1277
|
+
root_ci=$(gh api "repos/$REPO/actions/runs?head_sha=$root_sha&per_page=100" \
|
|
1278
|
+
--jq '[.workflow_runs[]
|
|
1279
|
+
| select(.name == env.CI_WORKFLOW_NAME and .status == "completed"
|
|
1280
|
+
and .conclusion == "success")]
|
|
1281
|
+
| sort_by(.created_at) | last // empty')
|
|
1282
|
+
if [ -z "$root_ci" ] || [ "$root_state" != "MERGEABLE" ]; then
|
|
1283
|
+
echo "Root PR #$root_pr is not green/mergeable yet; the human will merge when it is."
|
|
1284
|
+
else
|
|
1285
|
+
completion_marker='<!-- stack-complete -->'
|
|
1286
|
+
# The announcement is a pull request comment, and pull request
|
|
1287
|
+
# comments live at the issue endpoint for the pull request number,
|
|
1288
|
+
# so the marker is looked for where the comment was posted.
|
|
1289
|
+
already=$(gh api "repos/$REPO/issues/$root_pr/comments?per_page=100" \
|
|
1290
|
+
--jq "[.[] | select(.body | contains(\"$completion_marker\"))] | length" 2>/dev/null || echo 0)
|
|
1291
|
+
if [ "${already:-0}" -gt 0 ]; then
|
|
1292
|
+
echo "Stack #$parent already has a completion announcement; staying silent."
|
|
1293
|
+
else
|
|
1294
|
+
gh pr comment "$root_pr" --repo "$REPO" --body "Stack complete: all children have merged into the feature branch and CI is green. This root pull request is ready for a human to review and merge into $DEFAULT_BRANCH. $completion_marker"
|
|
1295
|
+
echo "Stack #$parent complete: announced root PR #$root_pr."
|
|
1296
|
+
fi
|
|
1297
|
+
fi
|
|
1298
|
+
fi
|
|
1299
|
+
fi
|
|
1300
|
+
fi
|
|
1301
|
+
fi
|
|
1302
|
+
fi
|
|
1303
|
+
fi
|
|
976
1304
|
else
|
|
977
1305
|
maybe_dispatch "$EVENT_PR"
|
|
978
1306
|
fi
|