@plainconceptsplatform/workflows 0.1.2 → 0.1.5

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 (67) hide show
  1. package/README.md +28 -10
  2. package/dist/catalog-installation.d.ts +3 -2
  3. package/dist/catalog-installation.js +26 -6
  4. package/dist/catalog-installation.test.js +104 -10
  5. package/dist/catalog-listing.d.ts +13 -0
  6. package/dist/catalog-listing.js +68 -0
  7. package/dist/catalog-listing.test.d.ts +1 -0
  8. package/dist/catalog-listing.test.js +137 -0
  9. package/dist/index.js +62 -11
  10. package/dist/index.test.d.ts +1 -0
  11. package/dist/index.test.js +112 -0
  12. package/dist/repository-inspection.test.d.ts +1 -0
  13. package/dist/repository-inspection.test.js +77 -0
  14. package/dist/workflow-catalog.d.ts +9 -7
  15. package/dist/workflow-catalog.js +21 -16
  16. package/dist/workflow-catalog.test.js +18 -3
  17. package/loops/actions/add-issue-labels/action.yml +30 -29
  18. package/loops/actions/agent-output.cjs +1 -0
  19. package/loops/actions/apply-agent-bundle/action.yml +24 -23
  20. package/loops/actions/apply-agent-bundle/apply-bundle.sh +1 -0
  21. package/loops/actions/apply-agent-comments/action.yml +42 -41
  22. package/loops/actions/apply-agent-labels/action.yml +55 -54
  23. package/loops/actions/apply-agent-output/action.yml +108 -107
  24. package/loops/actions/audit-close/action.yml +104 -103
  25. package/loops/actions/classify-route/action.yml +91 -90
  26. package/loops/actions/classify-route/classify-route.sh +1 -0
  27. package/loops/actions/cleanup-artifacts/action.yml +65 -64
  28. package/loops/actions/close-agent-issues/action.yml +43 -42
  29. package/loops/actions/create-agent-issues/action.yml +52 -51
  30. package/loops/actions/create-issue-comment/action.yml +29 -28
  31. package/loops/actions/download-agent-output/action.yml +53 -52
  32. package/loops/actions/identify-gate-subject/action.yml +97 -96
  33. package/loops/actions/link-pr-to-issue/action.yml +40 -39
  34. package/loops/actions/list-open-issues/action.yml +33 -32
  35. package/loops/actions/load-issue-context/action.yml +43 -42
  36. package/loops/actions/merge-agent-pr/action.yml +36 -35
  37. package/loops/actions/push-agent-branch/action.yml +45 -44
  38. package/loops/actions/remove-issue-labels/action.yml +35 -34
  39. package/loops/actions/stale-recovery/action.yml +288 -287
  40. package/loops/actions/update-agent-issues/action.yml +58 -57
  41. package/loops/actions/validate-refine-output/action.yml +44 -43
  42. package/loops/actions/validate-refine-output/validate-refine-output.sh +1 -0
  43. package/loops/actions/verify-composite-actions/action.yml +9 -8
  44. package/loops/actions/verify-composite-actions/verify-composite-actions.sh +1 -0
  45. package/loops/actions/verify-refine-output/action.yml +9 -8
  46. package/loops/actions/verify-refine-output/verify-refine-output.sh +1 -0
  47. package/loops/actions/verify-route-matrix/action.yml +9 -8
  48. package/loops/actions/verify-route-matrix/verify-route-matrix.sh +1 -0
  49. package/loops/scripts/compile-agent-workflows.mjs +18 -2
  50. package/loops/templates/agentics/agentics-checks.yml +86 -0
  51. package/loops/templates/agentics/agentics-maintenance.yml +121 -0
  52. package/loops/templates/ci/app-ci-dotnet-next.yml +167 -0
  53. package/loops/templates/ci/app-ci-node-monorepo.yml +178 -0
  54. package/loops/templates/opencode/opencode.ci.json +46 -0
  55. package/loops/templates/opencode/opencode.ci.json.md +41 -0
  56. package/loops/workflows/agent-apply-review.md +9 -10
  57. package/loops/workflows/agent-audit.md +6 -7
  58. package/loops/workflows/agent-direct.md +7 -8
  59. package/loops/workflows/agent-implement.md +7 -8
  60. package/loops/workflows/agent-merge-gate.md +9 -10
  61. package/loops/workflows/agent-propose.md +7 -8
  62. package/loops/workflows/agent-refine.md +5 -7
  63. package/loops/workflows/shared/opencode-ci.md +1 -0
  64. package/loops/workflows/shared/platform-defaults.md +1 -0
  65. package/loops/workflows/work-router.yml +1 -0
  66. package/package.json +2 -2
  67. package/loops/aw/repo-config.md +0 -12
@@ -1,287 +1,288 @@
1
- name: Recover stale agent work
2
- description: Reconcile Refine, Implement, and Merge Gate claims whose bot-working label has outlived the staleness threshold, and dispatch merge-gate for bot PRs with failed CI.
3
- inputs:
4
- token:
5
- description: GitHub token with issues write and pull-requests read access.
6
- required: true
7
- stale-threshold-hours:
8
- description: Hours after which a bot-working claim is stale. Invalid or empty values use 2.
9
- required: false
10
- default: ''
11
- refine-marker:
12
- description: Marker identifying the comment a Refine run posts when it asks the author a question.
13
- required: false
14
- default: '<!-- agent-refine -->'
15
- working-label:
16
- description: Label marking an issue the bot currently owns.
17
- required: false
18
- default: bot-working
19
- refine-label:
20
- description: Label marking an issue queued for refinement.
21
- required: false
22
- default: refine
23
- direct-label:
24
- description: Label marking an issue with a free-form direct instruction.
25
- required: false
26
- default: direct
27
- implement-label:
28
- description: Label marking an issue queued for implementation.
29
- required: false
30
- default: implement
31
- review-label:
32
- description: Label marking an issue that needs a human.
33
- required: false
34
- default: review
35
- ci-workflow-name:
36
- description: Name of the CI workflow whose failure should trigger a merge-gate dispatch.
37
- required: false
38
- default: 'App: CI'
39
- router-workflow-name:
40
- description: Name of the work router workflow to dispatch.
41
- required: false
42
- default: 'All Work Router'
43
- runs:
44
- using: composite
45
- steps:
46
- - name: Reconcile stale claims
47
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
48
- env:
49
- STALE_THRESHOLD_HOURS: ${{ inputs.stale-threshold-hours }}
50
- REFINE_MARKER: ${{ inputs.refine-marker }}
51
- WORKING_LABEL: ${{ inputs.working-label }}
52
- REFINE_LABEL: ${{ inputs.refine-label }}
53
- DIRECT_LABEL: ${{ inputs.direct-label }}
54
- IMPLEMENT_LABEL: ${{ inputs.implement-label }}
55
- REVIEW_LABEL: ${{ inputs.review-label }}
56
- CI_WORKFLOW_NAME: ${{ inputs.ci-workflow-name }}
57
- ROUTER_WORKFLOW_NAME: ${{ inputs.router-workflow-name }}
58
- with:
59
- github-token: ${{ inputs.token }}
60
- script: |
61
- const WORKING = process.env.WORKING_LABEL;
62
- const REVIEW = process.env.REVIEW_LABEL;
63
- const MARKER = process.env.REFINE_MARKER;
64
- const CI_WORKFLOW_NAME = process.env.CI_WORKFLOW_NAME;
65
- const ROUTER_WORKFLOW_NAME = process.env.ROUTER_WORKFLOW_NAME;
66
-
67
- const parsedHours = Number.parseInt(process.env.STALE_THRESHOLD_HOURS, 10);
68
- const hours = Number.isInteger(parsedHours) && parsedHours > 0 ? parsedHours : 2;
69
- const cutoff = new Date(Date.now() - hours * 60 * 60 * 1000);
70
-
71
- core.info(`Stale threshold: ${hours}h (cutoff ${cutoff.toISOString()})`);
72
-
73
- const staleIssuesLabelled = async (label) => {
74
- const issues = await github.paginate(github.rest.issues.listForRepo, {
75
- ...context.repo,
76
- state: 'open',
77
- labels: `${WORKING},${label}`,
78
- per_page: 100,
79
- });
80
-
81
- return issues.filter((issue) => new Date(issue.updated_at) < cutoff);
82
- };
83
-
84
- const removeWorking = async (issueNumber) => {
85
- try {
86
- await github.rest.issues.removeLabel({
87
- ...context.repo,
88
- issue_number: issueNumber,
89
- name: WORKING,
90
- });
91
- } catch (error) {
92
- if (error.status !== 404) throw error;
93
- }
94
- };
95
-
96
- // Open bot pull requests keep work in flight. GraphQL exposes authorship and closing
97
- // references together, so use `__typename` instead of matching a login.
98
- const openPullRequests = [];
99
-
100
- for (let cursor = null; ; ) {
101
- const page = await github.graphql(
102
- `query($owner: String!, $name: String!, $cursor: String) {
103
- repository(owner: $owner, name: $name) {
104
- pullRequests(states: OPEN, first: 100, after: $cursor) {
105
- pageInfo { hasNextPage endCursor }
106
- nodes {
107
- number
108
- updatedAt
109
- author { __typename }
110
- closingIssuesReferences(first: 10) {
111
- nodes { number labels(first: 20) { nodes { name } } }
112
- }
113
- }
114
- }
115
- }
116
- }`,
117
- { owner: context.repo.owner, name: context.repo.repo, cursor },
118
- );
119
-
120
- const { nodes, pageInfo } = page.repository.pullRequests;
121
- openPullRequests.push(...nodes);
122
-
123
- if (!pageInfo.hasNextPage) break;
124
- cursor = pageInfo.endCursor;
125
- }
126
-
127
- const botPullRequests = openPullRequests.filter(
128
- (pr) => pr.author?.__typename === 'Bot',
129
- );
130
-
131
- const issuesWithOpenBotPr = new Set(
132
- botPullRequests.flatMap((pr) => pr.closingIssuesReferences.nodes.map((n) => n.number)),
133
- );
134
-
135
- const summary = {
136
- refineEscalated: 0,
137
- refineAwaitingAuthor: 0,
138
- implementReleased: 0,
139
- directReleased: 0,
140
- mergeGateReleased: 0,
141
- mergeGateDispatched: 0,
142
- };
143
-
144
- for (const issue of await staleIssuesLabelled(process.env.REFINE_LABEL)) {
145
- const comments = await github.paginate(github.rest.issues.listComments, {
146
- ...context.repo,
147
- issue_number: issue.number,
148
- per_page: 100,
149
- });
150
-
151
- const lastMarker = comments.filter((c) => (c.body ?? '').includes(MARKER)).pop();
152
- const lastHuman = comments.filter((c) => c.user?.type !== 'Bot').pop();
153
-
154
- // The bot asked a question nobody has answered yet. Escalating would drop the
155
- // question on the floor, so leave the claim in place.
156
- if (lastMarker && !(lastHuman && new Date(lastHuman.created_at) > new Date(lastMarker.created_at))) {
157
- core.info(`#${issue.number}: awaiting author reply, left alone`);
158
- summary.refineAwaitingAuthor += 1;
159
- continue;
160
- }
161
-
162
- await removeWorking(issue.number);
163
- await github.rest.issues.addLabels({
164
- ...context.repo,
165
- issue_number: issue.number,
166
- labels: [REVIEW],
167
- });
168
-
169
- core.info(`#${issue.number}: escalated to ${REVIEW}`);
170
- summary.refineEscalated += 1;
171
- }
172
-
173
- for (const issue of await staleIssuesLabelled(process.env.IMPLEMENT_LABEL)) {
174
- if (issuesWithOpenBotPr.has(issue.number)) {
175
- core.info(`#${issue.number}: open bot pull request still in flight, left alone`);
176
- continue;
177
- }
178
-
179
- await removeWorking(issue.number);
180
- core.info(`#${issue.number}: released`);
181
- summary.implementReleased += 1;
182
- }
183
-
184
- for (const issue of await staleIssuesLabelled(process.env.DIRECT_LABEL)) {
185
- if (issuesWithOpenBotPr.has(issue.number)) {
186
- core.info(`#${issue.number}: open bot pull request still in flight, left alone`);
187
- continue;
188
- }
189
-
190
- await removeWorking(issue.number);
191
- await github.rest.issues.addLabels({
192
- ...context.repo,
193
- issue_number: issue.number,
194
- labels: [REVIEW],
195
- });
196
- core.info(`#${issue.number}: direct released, escalated to ${REVIEW}`);
197
- summary.directReleased += 1;
198
- }
199
-
200
- for (const pr of botPullRequests.filter((p) => new Date(p.updatedAt) < cutoff)) {
201
- for (const issue of pr.closingIssuesReferences.nodes) {
202
- if (!issue.labels.nodes.some((label) => label.name === WORKING)) {
203
- continue;
204
- }
205
-
206
- await removeWorking(issue.number);
207
- core.info(`PR #${pr.number} / issue #${issue.number}: released`);
208
- summary.mergeGateReleased += 1;
209
- }
210
- }
211
-
212
- // The workflow_run trigger does not fire for PR-triggered CI completions on
213
- // feature branches. This polls every stale-recovery cycle (2h) for bot PRs
214
- // whose CI concluded failure and dispatches the merge-gate, which is the same
215
- // thing the workflow_run trigger would have done. A run already in progress
216
- // for the same PR is skipped by the router's concurrency group.
217
- const ciWorkflow = await github.rest.actions.getRepoWorkflow({
218
- ...context.repo,
219
- workflow_id: CI_WORKFLOW_NAME,
220
- }).catch(() => null);
221
-
222
- if (ciWorkflow) {
223
- for (const pr of botPullRequests) {
224
- const runs = await github.rest.actions.listWorkflowRuns({
225
- ...context.repo,
226
- workflow_id: CI_WORKFLOW_NAME,
227
- branch: pr.number.toString(),
228
- status: 'completed',
229
- per_page: 1,
230
- }).catch(() => ({ data: { workflow_runs: [] } }));
231
-
232
- const latestRun = runs.data.workflow_runs[0];
233
- if (!latestRun || latestRun.conclusion !== 'failure') continue;
234
-
235
- // Skip if the issue already carries review (the agent already gave up).
236
- const issueNumbers = pr.closingIssuesReferences.nodes.map((n) => n.number);
237
- const hasReview = await Promise.all(
238
- issueNumbers.map(async (n) => {
239
- const labels = await github.rest.issues.listLabelsOnIssue({
240
- ...context.repo,
241
- issue_number: n,
242
- }).catch(() => ({ data: [] }));
243
- return labels.data.some((l) => l.name === REVIEW);
244
- }),
245
- );
246
- if (hasReview.some(Boolean)) continue;
247
-
248
- const routerWorkflow = await github.rest.actions.getRepoWorkflow({
249
- ...context.repo,
250
- workflow_id: ROUTER_WORKFLOW_NAME,
251
- }).catch(() => null);
252
-
253
- if (routerWorkflow) {
254
- // Resolve the linked issue number for the dispatch.
255
- const linkedIssue = issueNumbers[0];
256
- if (!linkedIssue) continue;
257
-
258
- await github.rest.actions.createWorkflowDispatch({
259
- ...context.repo,
260
- workflow_id: ROUTER_WORKFLOW_NAME,
261
- ref: 'main',
262
- inputs: {
263
- operation: 'merge-gate',
264
- 'pr-number': pr.number.toString(),
265
- 'ci-conclusion': 'failure',
266
- 'ci-run-id': latestRun.id.toString(),
267
- },
268
- });
269
-
270
- core.info(`PR #${pr.number}: dispatched merge-gate for failed CI (run ${latestRun.id})`);
271
- summary.mergeGateDispatched += 1;
272
- }
273
- }
274
- }
275
-
276
- await core.summary
277
- .addHeading('Stale recovery', 2)
278
- .addList([
279
- `Threshold: ${hours} hours (cutoff ${cutoff.toISOString()})`,
280
- `Refine escalated to ${REVIEW}: ${summary.refineEscalated}`,
281
- `Refine awaiting author: ${summary.refineAwaitingAuthor}`,
282
- `Implement released: ${summary.implementReleased}`,
283
- `Direct released: ${summary.directReleased}`,
284
- `Merge Gate released: ${summary.mergeGateReleased}`,
285
- `Merge Gate dispatched for failed CI: ${summary.mergeGateDispatched}`,
286
- ])
287
- .write();
1
+ # Managed by @plainconceptsplatform/workflows. Source: loops/actions/stale-recovery/action.yml. Update with workflows update --force; consumer edits may be overwritten.
2
+ name: Recover stale agent work
3
+ description: Reconcile Refine, Implement, and Merge Gate claims whose bot-working label has outlived the staleness threshold, and dispatch merge-gate for bot PRs with failed CI.
4
+ inputs:
5
+ token:
6
+ description: GitHub token with issues write and pull-requests read access.
7
+ required: true
8
+ stale-threshold-hours:
9
+ description: Hours after which a bot-working claim is stale. Invalid or empty values use 2.
10
+ required: false
11
+ default: ''
12
+ refine-marker:
13
+ description: Marker identifying the comment a Refine run posts when it asks the author a question.
14
+ required: false
15
+ default: '<!-- agent-refine -->'
16
+ working-label:
17
+ description: Label marking an issue the bot currently owns.
18
+ required: false
19
+ default: bot-working
20
+ refine-label:
21
+ description: Label marking an issue queued for refinement.
22
+ required: false
23
+ default: refine
24
+ direct-label:
25
+ description: Label marking an issue with a free-form direct instruction.
26
+ required: false
27
+ default: direct
28
+ implement-label:
29
+ description: Label marking an issue queued for implementation.
30
+ required: false
31
+ default: implement
32
+ review-label:
33
+ description: Label marking an issue that needs a human.
34
+ required: false
35
+ default: review
36
+ ci-workflow-name:
37
+ description: Name of the CI workflow whose failure should trigger a merge-gate dispatch.
38
+ required: false
39
+ default: 'App: CI'
40
+ router-workflow-name:
41
+ description: Name of the work router workflow to dispatch.
42
+ required: false
43
+ default: 'All Work Router'
44
+ runs:
45
+ using: composite
46
+ steps:
47
+ - name: Reconcile stale claims
48
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
49
+ env:
50
+ STALE_THRESHOLD_HOURS: ${{ inputs.stale-threshold-hours }}
51
+ REFINE_MARKER: ${{ inputs.refine-marker }}
52
+ WORKING_LABEL: ${{ inputs.working-label }}
53
+ REFINE_LABEL: ${{ inputs.refine-label }}
54
+ DIRECT_LABEL: ${{ inputs.direct-label }}
55
+ IMPLEMENT_LABEL: ${{ inputs.implement-label }}
56
+ REVIEW_LABEL: ${{ inputs.review-label }}
57
+ CI_WORKFLOW_NAME: ${{ inputs.ci-workflow-name }}
58
+ ROUTER_WORKFLOW_NAME: ${{ inputs.router-workflow-name }}
59
+ with:
60
+ github-token: ${{ inputs.token }}
61
+ script: |
62
+ const WORKING = process.env.WORKING_LABEL;
63
+ const REVIEW = process.env.REVIEW_LABEL;
64
+ const MARKER = process.env.REFINE_MARKER;
65
+ const CI_WORKFLOW_NAME = process.env.CI_WORKFLOW_NAME;
66
+ const ROUTER_WORKFLOW_NAME = process.env.ROUTER_WORKFLOW_NAME;
67
+
68
+ const parsedHours = Number.parseInt(process.env.STALE_THRESHOLD_HOURS, 10);
69
+ const hours = Number.isInteger(parsedHours) && parsedHours > 0 ? parsedHours : 2;
70
+ const cutoff = new Date(Date.now() - hours * 60 * 60 * 1000);
71
+
72
+ core.info(`Stale threshold: ${hours}h (cutoff ${cutoff.toISOString()})`);
73
+
74
+ const staleIssuesLabelled = async (label) => {
75
+ const issues = await github.paginate(github.rest.issues.listForRepo, {
76
+ ...context.repo,
77
+ state: 'open',
78
+ labels: `${WORKING},${label}`,
79
+ per_page: 100,
80
+ });
81
+
82
+ return issues.filter((issue) => new Date(issue.updated_at) < cutoff);
83
+ };
84
+
85
+ const removeWorking = async (issueNumber) => {
86
+ try {
87
+ await github.rest.issues.removeLabel({
88
+ ...context.repo,
89
+ issue_number: issueNumber,
90
+ name: WORKING,
91
+ });
92
+ } catch (error) {
93
+ if (error.status !== 404) throw error;
94
+ }
95
+ };
96
+
97
+ // Open bot pull requests keep work in flight. GraphQL exposes authorship and closing
98
+ // references together, so use `__typename` instead of matching a login.
99
+ const openPullRequests = [];
100
+
101
+ for (let cursor = null; ; ) {
102
+ const page = await github.graphql(
103
+ `query($owner: String!, $name: String!, $cursor: String) {
104
+ repository(owner: $owner, name: $name) {
105
+ pullRequests(states: OPEN, first: 100, after: $cursor) {
106
+ pageInfo { hasNextPage endCursor }
107
+ nodes {
108
+ number
109
+ updatedAt
110
+ author { __typename }
111
+ closingIssuesReferences(first: 10) {
112
+ nodes { number labels(first: 20) { nodes { name } } }
113
+ }
114
+ }
115
+ }
116
+ }
117
+ }`,
118
+ { owner: context.repo.owner, name: context.repo.repo, cursor },
119
+ );
120
+
121
+ const { nodes, pageInfo } = page.repository.pullRequests;
122
+ openPullRequests.push(...nodes);
123
+
124
+ if (!pageInfo.hasNextPage) break;
125
+ cursor = pageInfo.endCursor;
126
+ }
127
+
128
+ const botPullRequests = openPullRequests.filter(
129
+ (pr) => pr.author?.__typename === 'Bot',
130
+ );
131
+
132
+ const issuesWithOpenBotPr = new Set(
133
+ botPullRequests.flatMap((pr) => pr.closingIssuesReferences.nodes.map((n) => n.number)),
134
+ );
135
+
136
+ const summary = {
137
+ refineEscalated: 0,
138
+ refineAwaitingAuthor: 0,
139
+ implementReleased: 0,
140
+ directReleased: 0,
141
+ mergeGateReleased: 0,
142
+ mergeGateDispatched: 0,
143
+ };
144
+
145
+ for (const issue of await staleIssuesLabelled(process.env.REFINE_LABEL)) {
146
+ const comments = await github.paginate(github.rest.issues.listComments, {
147
+ ...context.repo,
148
+ issue_number: issue.number,
149
+ per_page: 100,
150
+ });
151
+
152
+ const lastMarker = comments.filter((c) => (c.body ?? '').includes(MARKER)).pop();
153
+ const lastHuman = comments.filter((c) => c.user?.type !== 'Bot').pop();
154
+
155
+ // The bot asked a question nobody has answered yet. Escalating would drop the
156
+ // question on the floor, so leave the claim in place.
157
+ if (lastMarker && !(lastHuman && new Date(lastHuman.created_at) > new Date(lastMarker.created_at))) {
158
+ core.info(`#${issue.number}: awaiting author reply, left alone`);
159
+ summary.refineAwaitingAuthor += 1;
160
+ continue;
161
+ }
162
+
163
+ await removeWorking(issue.number);
164
+ await github.rest.issues.addLabels({
165
+ ...context.repo,
166
+ issue_number: issue.number,
167
+ labels: [REVIEW],
168
+ });
169
+
170
+ core.info(`#${issue.number}: escalated to ${REVIEW}`);
171
+ summary.refineEscalated += 1;
172
+ }
173
+
174
+ for (const issue of await staleIssuesLabelled(process.env.IMPLEMENT_LABEL)) {
175
+ if (issuesWithOpenBotPr.has(issue.number)) {
176
+ core.info(`#${issue.number}: open bot pull request still in flight, left alone`);
177
+ continue;
178
+ }
179
+
180
+ await removeWorking(issue.number);
181
+ core.info(`#${issue.number}: released`);
182
+ summary.implementReleased += 1;
183
+ }
184
+
185
+ for (const issue of await staleIssuesLabelled(process.env.DIRECT_LABEL)) {
186
+ if (issuesWithOpenBotPr.has(issue.number)) {
187
+ core.info(`#${issue.number}: open bot pull request still in flight, left alone`);
188
+ continue;
189
+ }
190
+
191
+ await removeWorking(issue.number);
192
+ await github.rest.issues.addLabels({
193
+ ...context.repo,
194
+ issue_number: issue.number,
195
+ labels: [REVIEW],
196
+ });
197
+ core.info(`#${issue.number}: direct released, escalated to ${REVIEW}`);
198
+ summary.directReleased += 1;
199
+ }
200
+
201
+ for (const pr of botPullRequests.filter((p) => new Date(p.updatedAt) < cutoff)) {
202
+ for (const issue of pr.closingIssuesReferences.nodes) {
203
+ if (!issue.labels.nodes.some((label) => label.name === WORKING)) {
204
+ continue;
205
+ }
206
+
207
+ await removeWorking(issue.number);
208
+ core.info(`PR #${pr.number} / issue #${issue.number}: released`);
209
+ summary.mergeGateReleased += 1;
210
+ }
211
+ }
212
+
213
+ // The workflow_run trigger does not fire for PR-triggered CI completions on
214
+ // feature branches. This polls every stale-recovery cycle (2h) for bot PRs
215
+ // whose CI concluded failure and dispatches the merge-gate, which is the same
216
+ // thing the workflow_run trigger would have done. A run already in progress
217
+ // for the same PR is skipped by the router's concurrency group.
218
+ const ciWorkflow = await github.rest.actions.getWorkflow({
219
+ ...context.repo,
220
+ workflow_id: CI_WORKFLOW_NAME,
221
+ }).catch(() => null);
222
+
223
+ if (ciWorkflow) {
224
+ for (const pr of botPullRequests) {
225
+ const runs = await github.rest.actions.listWorkflowRuns({
226
+ ...context.repo,
227
+ workflow_id: CI_WORKFLOW_NAME,
228
+ branch: pr.number.toString(),
229
+ status: 'completed',
230
+ per_page: 1,
231
+ }).catch(() => ({ data: { workflow_runs: [] } }));
232
+
233
+ const latestRun = runs.data.workflow_runs[0];
234
+ if (!latestRun || latestRun.conclusion !== 'failure') continue;
235
+
236
+ // Skip if the issue already carries review (the agent already gave up).
237
+ const issueNumbers = pr.closingIssuesReferences.nodes.map((n) => n.number);
238
+ const hasReview = await Promise.all(
239
+ issueNumbers.map(async (n) => {
240
+ const labels = await github.rest.issues.listLabelsOnIssue({
241
+ ...context.repo,
242
+ issue_number: n,
243
+ }).catch(() => ({ data: [] }));
244
+ return labels.data.some((l) => l.name === REVIEW);
245
+ }),
246
+ );
247
+ if (hasReview.some(Boolean)) continue;
248
+
249
+ const routerWorkflow = await github.rest.actions.getWorkflow({
250
+ ...context.repo,
251
+ workflow_id: ROUTER_WORKFLOW_NAME,
252
+ }).catch(() => null);
253
+
254
+ if (routerWorkflow) {
255
+ // Resolve the linked issue number for the dispatch.
256
+ const linkedIssue = issueNumbers[0];
257
+ if (!linkedIssue) continue;
258
+
259
+ await github.rest.actions.createWorkflowDispatch({
260
+ ...context.repo,
261
+ workflow_id: ROUTER_WORKFLOW_NAME,
262
+ ref: 'main',
263
+ inputs: {
264
+ operation: 'merge-gate',
265
+ 'pr-number': pr.number.toString(),
266
+ 'ci-conclusion': 'failure',
267
+ 'ci-run-id': latestRun.id.toString(),
268
+ },
269
+ });
270
+
271
+ core.info(`PR #${pr.number}: dispatched merge-gate for failed CI (run ${latestRun.id})`);
272
+ summary.mergeGateDispatched += 1;
273
+ }
274
+ }
275
+ }
276
+
277
+ await core.summary
278
+ .addHeading('Stale recovery', 2)
279
+ .addList([
280
+ `Threshold: ${hours} hours (cutoff ${cutoff.toISOString()})`,
281
+ `Refine escalated to ${REVIEW}: ${summary.refineEscalated}`,
282
+ `Refine awaiting author: ${summary.refineAwaitingAuthor}`,
283
+ `Implement released: ${summary.implementReleased}`,
284
+ `Direct released: ${summary.directReleased}`,
285
+ `Merge Gate released: ${summary.mergeGateReleased}`,
286
+ `Merge Gate dispatched for failed CI: ${summary.mergeGateDispatched}`,
287
+ ])
288
+ .write();