@plainconceptsplatform/workflows 0.17.0 → 0.20.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.
Files changed (60) hide show
  1. package/dist/catalog-installation.js +25 -13
  2. package/dist/stack-defaults.js +16 -16
  3. package/dist/worker-env.js +24 -21
  4. package/loops/actions/add-issue-labels/action.yml +50 -50
  5. package/loops/actions/agent-output.cjs +17 -17
  6. package/loops/actions/apply-agent-bundle/action.yml +24 -24
  7. package/loops/actions/apply-agent-comments/action.yml +42 -42
  8. package/loops/actions/apply-agent-labels/action.yml +55 -55
  9. package/loops/actions/apply-agent-output/action.yml +108 -108
  10. package/loops/actions/assess-blast-radius/action.yml +148 -0
  11. package/loops/actions/assess-blast-radius/assess-blast-radius.sh +138 -0
  12. package/loops/actions/classify-route/action.yml +100 -100
  13. package/loops/actions/cleanup-artifacts/action.yml +91 -91
  14. package/loops/actions/close-agent-issues/action.yml +43 -43
  15. package/loops/actions/create-agent-issues/action.yml +52 -52
  16. package/loops/actions/create-issue-comment/action.yml +29 -29
  17. package/loops/actions/download-agent-output/action.yml +53 -53
  18. package/loops/actions/housekeeping/action.yml +55 -2
  19. package/loops/actions/identify-gate-subject/action.yml +134 -122
  20. package/loops/actions/link-pr-to-issue/action.yml +40 -40
  21. package/loops/actions/list-open-issues/action.yml +33 -33
  22. package/loops/actions/load-issue-context/action.yml +45 -45
  23. package/loops/actions/merge-agent-pr/action.yml +49 -49
  24. package/loops/actions/push-agent-branch/action.yml +45 -45
  25. package/loops/actions/remove-issue-labels/action.yml +37 -37
  26. package/loops/actions/require-open-issue/action.yml +59 -0
  27. package/loops/actions/update-agent-issues/action.yml +58 -58
  28. package/loops/actions/validate-merge-gate-output/action.yml +62 -40
  29. package/loops/actions/validate-merge-gate-output/validate-merge-gate-output.sh +147 -31
  30. package/loops/actions/validate-refine-output/action.yml +48 -44
  31. package/loops/actions/validate-refine-output/validate-refine-output.sh +15 -4
  32. package/loops/actions/validate-review-output/action.yml +35 -35
  33. package/loops/actions/validate-triage-output/action.yml +36 -36
  34. package/loops/actions/verify-composite-actions/action.yml +9 -9
  35. package/loops/actions/verify-refine-output/action.yml +9 -9
  36. package/loops/actions/verify-refine-output/verify-refine-output.sh +6 -1
  37. package/loops/actions/verify-route-matrix/action.yml +9 -9
  38. package/loops/actions/verify-route-matrix/verify-gate-metrics.mjs +51 -0
  39. package/loops/actions/verify-route-matrix/verify-route-matrix.sh +630 -8
  40. package/loops/scripts/compile-agent-workflows.mjs +331 -331
  41. package/loops/templates/agentics/agentics-maintenance.yml +121 -121
  42. package/loops/templates/ci/app-ci-dotnet-next.yml +330 -330
  43. package/loops/templates/ci/app-ci-node-monorepo.yml +260 -260
  44. package/loops/templates/issues/bug_report.yml +109 -109
  45. package/loops/templates/issues/feature_request.yml +75 -75
  46. package/loops/templates/opencode/opencode.ci.json +55 -49
  47. package/loops/templates/opencode/opencode.ci.json.md +59 -49
  48. package/loops/templates/release/github-release.yml +30 -30
  49. package/loops/workflows/agent-apply-review.md +33 -3
  50. package/loops/workflows/agent-audit.md +36 -0
  51. package/loops/workflows/agent-implement.md +77 -5
  52. package/loops/workflows/agent-merge-gate.md +368 -148
  53. package/loops/workflows/agent-refine.md +93 -17
  54. package/loops/workflows/agent-release.md +4 -4
  55. package/loops/workflows/agent-triage.md +33 -1
  56. package/loops/workflows/authorize-bot-work.yml +105 -105
  57. package/loops/workflows/shared/opencode-ci.md +206 -206
  58. package/loops/workflows/shared/platform-defaults.md +19 -19
  59. package/loops/workflows/work-router.yml +25 -11
  60. package/package.json +2 -2
@@ -24,6 +24,7 @@ env:
24
24
  # re-running a decision produces the same decision. Created idempotently where it is applied.
25
25
  STALLED_LABEL: stalled
26
26
  REFINE_MARKER: "<!-- agent-refine -->"
27
+ DRAFT_MARKER: "<!-- agent-refine-draft -->"
27
28
  INITIAL_MODE: first
28
29
  RESPONSE_MODE: rerefine
29
30
  MAX_SELF_QUESTIONS: "5"
@@ -46,6 +47,10 @@ description: |
46
47
  Refines an issue into a user story, on a first pass or after the author has answered the
47
48
  bot's questions. Replaces .loops/recipes/refine-loop.yaml.
48
49
 
50
+ The refined story is wrapped in the repository's own issue template when one matches.
51
+ When questions remain, the worker leaves that template-wrapped temporal draft on the
52
+ issue and asks every remaining question in a single batched comment.
53
+
49
54
  Before writing the story, the agent explores the codebase per work unit (each bullet in a
50
55
  bullet-list issue is its own unit), answering its own questions where the code can and
51
56
  escalating only genuine business decisions to the author.
@@ -76,9 +81,41 @@ on:
76
81
  required: false
77
82
  type: string
78
83
  default: first
84
+ # The gate job that the top-level `if:` reads. gh-aw folds that `if:` into the generated
85
+ # activation job but gives activation no dependency on the job, so the reference resolves
86
+ # to '' and the clause is false -- the agent would never run. The package's own validator
87
+ # catches it after compilation; this is the line it asks for, the same one the merge gate
88
+ # uses for protected_changes.
89
+ needs: [still_open]
79
90
 
80
91
  jobs:
92
+ # A route dispatched while the issue was open must not execute after it has been closed. The
93
+ # classifier can only see `github.event.issue.state`, which is the state when the event fired
94
+ # and is absent on a workflow_dispatch, and this fleet queues for a runner for ten minutes and
95
+ # more. Numa #659 was closed one second after a comment dispatched refine; the run reached
96
+ # `reserve` thirteen minutes later and refined a closed issue to completion. Read now, once,
97
+ # and gate both the reservation and the agent on it.
98
+ still_open:
99
+ runs-on: agents-arc
100
+ permissions:
101
+ contents: read
102
+ issues: read
103
+ outputs:
104
+ open: ${{ steps.state.outputs.open }}
105
+ steps:
106
+ - name: Checkout workflow actions
107
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
108
+ with:
109
+ persist-credentials: false
110
+ - name: Read the issue state
111
+ id: state
112
+ uses: ./.github/actions/require-open-issue
113
+ with:
114
+ token: ${{ github.token }}
115
+ issue-number: ${{ inputs.issue-number }}
81
116
  reserve:
117
+ needs: [still_open]
118
+ if: needs.still_open.outputs.open == 'true'
82
119
  runs-on: agents-arc
83
120
  permissions:
84
121
  contents: read
@@ -141,6 +178,7 @@ jobs:
141
178
  with:
142
179
  output-file: ${{ steps.output.outputs.output-file }}
143
180
  marker: ${{ env.REFINE_MARKER }}
181
+ draft-marker: ${{ env.DRAFT_MARKER }}
144
182
  comment-prefix: ${{ env.SAFE_OUTPUT_COMMENT_PREFIX }}
145
183
  issue-number: ${{ inputs.issue-number }}
146
184
  conclude:
@@ -364,7 +402,7 @@ jobs:
364
402
  ${{ env.INCOMPLETE_COMMENT }}
365
403
  [View this workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})
366
404
 
367
- if: inputs.issue-number != ''
405
+ if: inputs.issue-number != '' && needs.still_open.outputs.open == 'true'
368
406
 
369
407
  runs-on: agents-arc
370
408
  runs-on-slim: agents-arc
@@ -440,7 +478,9 @@ timeout-minutes: 90
440
478
 
441
479
  - On a `${{ env.INITIAL_MODE }}` pass, refine from scratch.
442
480
  - On a `${{ env.RESPONSE_MODE }}` pass, incorporate only the supplied answers from the issue author or an
443
- assignee. Do not use answers from other commenters.
481
+ assignee. Do not use answers from other commenters. The body may already hold the
482
+ temporal draft from the earlier pass: reuse what still holds, and resolve its pending
483
+ marks with the author's answers.
444
484
 
445
485
  3. Explore before you write. Call skill("pc-plan-explore"); it owns the stance for this step.
446
486
 
@@ -484,8 +524,8 @@ timeout-minutes: 90
484
524
  No "As a / I want / so that" form. No Given/When/Then. No Mermaid. Just the marker,
485
525
  the summary, and the checklist.
486
526
 
487
- Load `@humanizer` and prepare the replacement issue body, then go directly to step 7
488
- (estimate). Skip steps 5 and 6.
527
+ Load `@humanizer` and prepare the replacement issue body, then go directly to step 8
528
+ (estimate). Skip steps 5-7.
489
529
 
490
530
  5. Before writing the story, verify coverage: list every work unit and confirm each one has
491
531
  exploration findings concrete enough for acceptance criteria. If any unit is missing, go back
@@ -499,9 +539,36 @@ timeout-minutes: 90
499
539
  Apply repository documentation and established conventions before finalizing the story.
500
540
  Adhere to ${{ env.REPO_RULES }}.
501
541
 
502
- 6. Load `@humanizer` and prepare the complete replacement issue body as valid Markdown.
542
+ 6. **Wrap the story in the repository's issue form.** The body people read must follow the
543
+ repository's own issue template when one exists; the story is the content, the template
544
+ is the shape.
545
+
546
+ Find the form first:
547
+
548
+ - List the YAML and Markdown forms under `.github/ISSUE_TEMPLATE/`, plus a legacy
549
+ `.github/issue_template.md` or a root `template.yml`. `config.yml` there only declares
550
+ contact links, which are not forms: ignore it.
551
+ - When a form filters by labels and the issue carries one of those labels, that form
552
+ wins. Otherwise use the repository's default form.
553
+ - When the repository has no form at all, keep the free-form story shape from step 5:
554
+ there is nothing to wrap around.
555
+
556
+ Then fill it:
503
557
 
504
- 7. **Estimate the story in points.** Use the Fibonacci scale, where one point is roughly one
558
+ - Draw every field's content from your exploration findings. Required fields always get
559
+ real content; optional fields only when you genuinely have something for them.
560
+ - The story narrative lands in the field that asks for it — proposal, description, or
561
+ what-happened, depending on the form.
562
+ - The Given/When/Then scenarios go into the form's acceptance-criteria field when it has
563
+ one; otherwise they stay a section of their own. The Mermaid diagram goes where it
564
+ reads best inside the filled form.
565
+ - The machine-readable lines the later steps add — split markers in step 9, estimate
566
+ lines in step 10 — always sit at the very top of the body, above the form's first
567
+ heading, so the workflow can read them whatever the form's shape.
568
+
569
+ 7. Load `@humanizer` and prepare the complete replacement issue body as valid Markdown.
570
+
571
+ 8. **Estimate the story in points.** Use the Fibonacci scale, where one point is roughly one
505
572
  human day of work for a developer who knows this codebase. Estimate the whole story: code,
506
573
  tests, and the edge cases the acceptance criteria imply.
507
574
 
@@ -514,7 +581,7 @@ timeout-minutes: 90
514
581
  Elapsed clock time is not evidence. A large change can land in minutes and a small one can
515
582
  wait days for a human, so never reason from how long anything took.
516
583
 
517
- 8. **Split when the estimate is ${{ env.SPLIT_THRESHOLD }} or more.** An oversized story is the
584
+ 9. **Split when the estimate is ${{ env.SPLIT_THRESHOLD }} or more.** An oversized story is the
518
585
  single best predictor of a pull request that never lands.
519
586
 
520
587
  First test whether it *can* split. A story splits when it contains slices that are each
@@ -523,7 +590,7 @@ timeout-minutes: 90
523
590
  on its own cannot be verified.
524
591
 
525
592
  **If it splits:** write between two and ${{ env.MAX_SPLIT_CHILDREN }} children. Each child is
526
- a complete refined story in the same format you would have written for the whole, with its own
593
+ a complete refined story wrapped in the same issue form, with its own
527
594
  acceptance criteria, its own tests section, and its own estimate of 5 or less. Never write a
528
595
  child estimated at 1: that is a fragment, so fold it into a sibling. Call `create_issue` once
529
596
  per child, and in each child body include:
@@ -539,7 +606,7 @@ timeout-minutes: 90
539
606
  single story and say so in one sentence in the body, under the estimate. An honest 8 is more
540
607
  useful than three fake threes that each break the build.
541
608
 
542
- 9. **Record the estimate in every body you write**, parent and children alike, immediately below
609
+ 10. **Record the estimate in every body you write**, parent and children alike, immediately below
543
610
  the title line, as exactly these two lines:
544
611
 
545
612
  ```
@@ -550,12 +617,22 @@ timeout-minutes: 90
550
617
  The visible line is for people and the marker is read by the workflow, which turns it into the
551
618
  `sp-N` label. A body without the marker gets no estimate label at all.
552
619
 
553
- 10. Decide exactly one outcome:
620
+ 11. Decide exactly one outcome:
554
621
 
555
622
  Labels are workflow-owned state. Do not call `add_labels` or `remove_labels`.
556
623
 
557
624
  **Questions remain.** You set aside one or more questions for the author that the codebase
558
- could not answer. Leave the body unchanged. Call `add_comment` once with:
625
+ could not answer. Leave the partial work visible: first call `update_issue` with a
626
+ temporal draft, then call `add_comment` once with the questions.
627
+
628
+ The temporal draft is the replacement body your path would have written — the filled
629
+ issue form from step 6 on the standard path, the marker, summary and checklist from
630
+ step 4a on the trivial path — holding everything you already established, with every
631
+ part the questions leave open marked `_pending — see questions below_`. Its very
632
+ first line is `${{ env.DRAFT_MARKER }}`; the next run replaces the draft wholesale
633
+ with the final body.
634
+
635
+ The comment carries:
559
636
  1. `${{ env.REFINE_MARKER }}`
560
637
  2. `${{ env.SAFE_OUTPUT_COMMENT_PREFIX }}`
561
638
  3. `I have some questions about this issue. Please reply in one comment and I'll process your answers.`
@@ -565,10 +642,9 @@ timeout-minutes: 90
565
642
  them is a domain expert, not an engineer.
566
643
 
567
644
  **The story is complete.** You answered every exploration question yourself and none remain
568
- for the author.
569
-
570
- Call `update_issue` first, with the replacement body, and wait for it to come back. Send
571
- the whole body in that one call: it is the only thing this step has to get right.
645
+ for the author. Call `update_issue` first, with the wrapped replacement body, and wait
646
+ for it to come back. Send the whole body in that one call: it is the only thing this
647
+ step has to get right.
572
648
 
573
649
  Only once that call has succeeded, call `add_comment`
574
650
  with `${{ env.REFINE_MARKER }}`, then `${{ env.SAFE_OUTPUT_COMMENT_PREFIX }}`,
@@ -586,5 +662,5 @@ timeout-minutes: 90
586
662
  seams. Call `create_issue` once per child, then `update_issue` on the parent with the
587
663
  summary and the checklist, then `add_comment` with `${{ env.REFINE_MARKER }}`, then
588
664
  `${{ env.SAFE_OUTPUT_COMMENT_PREFIX }}`, then one sentence naming the estimate you gave the
589
- whole and how many children you wrote. The children carry the work forward; the parent stays
590
- open as their tracker and is never implemented directly.
665
+ whole and how many children you wrote. The children carry the work forward; the parent stays
666
+ open as their tracker and is never implemented directly.
@@ -29,8 +29,8 @@ on:
29
29
  type: string
30
30
  default: "auto"
31
31
 
32
- runs-on: agents-arc
33
- runs-on-slim: agents-arc
32
+ runs-on: RunnerLandingZone
33
+ runs-on-slim: RunnerLandingZone
34
34
 
35
35
  secrets:
36
36
  OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
@@ -98,7 +98,7 @@ jobs:
98
98
  always() &&
99
99
  needs.agent.result == 'success' &&
100
100
  needs.safe_outputs.result == 'success'
101
- runs-on: agents-arc
101
+ runs-on: RunnerLandingZone
102
102
  permissions:
103
103
  contents: write
104
104
  steps:
@@ -205,7 +205,7 @@ jobs:
205
205
  if: >
206
206
  always() &&
207
207
  (needs.agent.result != 'success' || needs.safe_outputs.result != 'success')
208
- runs-on: agents-arc
208
+ runs-on: RunnerLandingZone
209
209
  permissions:
210
210
  contents: read
211
211
  steps:
@@ -70,9 +70,41 @@ on:
70
70
  required: false
71
71
  type: string
72
72
  default: first
73
+ # The gate job that the top-level `if:` reads. gh-aw folds that `if:` into the generated
74
+ # activation job but gives activation no dependency on the job, so the reference resolves
75
+ # to '' and the clause is false -- the agent would never run. The package's own validator
76
+ # catches it after compilation; this is the line it asks for, the same one the merge gate
77
+ # uses for protected_changes.
78
+ needs: [still_open]
73
79
 
74
80
  jobs:
81
+ # A route dispatched while the issue was open must not execute after it has been closed. The
82
+ # classifier can only see `github.event.issue.state`, which is the state when the event fired
83
+ # and is absent on a workflow_dispatch, and this fleet queues for a runner for ten minutes and
84
+ # more. Numa #659 was closed one second after a comment dispatched refine; the run reached
85
+ # `reserve` thirteen minutes later and refined a closed issue to completion. Read now, once,
86
+ # and gate both the reservation and the agent on it.
87
+ still_open:
88
+ runs-on: agents-arc
89
+ permissions:
90
+ contents: read
91
+ issues: read
92
+ outputs:
93
+ open: ${{ steps.state.outputs.open }}
94
+ steps:
95
+ - name: Checkout workflow actions
96
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
97
+ with:
98
+ persist-credentials: false
99
+ - name: Read the issue state
100
+ id: state
101
+ uses: ./.github/actions/require-open-issue
102
+ with:
103
+ token: ${{ github.token }}
104
+ issue-number: ${{ inputs.issue-number }}
75
105
  reserve:
106
+ needs: [still_open]
107
+ if: needs.still_open.outputs.open == 'true'
76
108
  runs-on: agents-arc
77
109
  permissions:
78
110
  contents: read
@@ -296,7 +328,7 @@ jobs:
296
328
  ${{ env.INCOMPLETE_COMMENT }}
297
329
  [View this workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})
298
330
 
299
- if: inputs.issue-number != ''
331
+ if: inputs.issue-number != '' && needs.still_open.outputs.open == 'true'
300
332
 
301
333
  runs-on: agents-arc
302
334
  runs-on-slim: agents-arc
@@ -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: ubuntu-latest
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: ubuntu-latest
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"