@plainconceptsplatform/workflows 0.1.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/catalog-installation.d.ts +12 -0
- package/dist/catalog-installation.js +81 -0
- package/dist/catalog-installation.test.d.ts +1 -0
- package/dist/catalog-installation.test.js +85 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +57 -0
- package/dist/repository-inspection.d.ts +23 -0
- package/dist/repository-inspection.js +91 -0
- package/dist/repository-state.d.ts +18 -0
- package/dist/repository-state.js +77 -0
- package/dist/repository-state.test.d.ts +1 -0
- package/dist/repository-state.test.js +96 -0
- package/dist/workflow-catalog.d.ts +17 -0
- package/dist/workflow-catalog.js +39 -0
- package/dist/workflow-catalog.test.d.ts +1 -0
- package/dist/workflow-catalog.test.js +14 -0
- package/loops/actions/add-issue-labels/action.yml +29 -0
- package/loops/actions/agent-output.cjs +16 -0
- package/loops/actions/apply-agent-bundle/action.yml +23 -0
- package/loops/actions/apply-agent-bundle/apply-bundle.sh +45 -0
- package/loops/actions/apply-agent-comments/action.yml +41 -0
- package/loops/actions/apply-agent-labels/action.yml +54 -0
- package/loops/actions/apply-agent-output/action.yml +107 -0
- package/loops/actions/audit-close/action.yml +103 -0
- package/loops/actions/classify-route/action.yml +90 -0
- package/loops/actions/classify-route/classify-route.sh +172 -0
- package/loops/actions/cleanup-artifacts/action.yml +64 -0
- package/loops/actions/close-agent-issues/action.yml +42 -0
- package/loops/actions/create-agent-issues/action.yml +51 -0
- package/loops/actions/create-issue-comment/action.yml +28 -0
- package/loops/actions/download-agent-output/action.yml +52 -0
- package/loops/actions/identify-gate-subject/action.yml +96 -0
- package/loops/actions/link-pr-to-issue/action.yml +39 -0
- package/loops/actions/list-open-issues/action.yml +32 -0
- package/loops/actions/load-issue-context/action.yml +42 -0
- package/loops/actions/merge-agent-pr/action.yml +35 -0
- package/loops/actions/push-agent-branch/action.yml +44 -0
- package/loops/actions/remove-issue-labels/action.yml +34 -0
- package/loops/actions/stale-recovery/action.yml +287 -0
- package/loops/actions/update-agent-issues/action.yml +57 -0
- package/loops/actions/validate-refine-output/action.yml +43 -0
- package/loops/actions/validate-refine-output/validate-refine-output.sh +46 -0
- package/loops/actions/verify-composite-actions/action.yml +8 -0
- package/loops/actions/verify-composite-actions/verify-composite-actions.sh +50 -0
- package/loops/actions/verify-refine-output/action.yml +8 -0
- package/loops/actions/verify-refine-output/verify-refine-output.sh +65 -0
- package/loops/actions/verify-route-matrix/action.yml +8 -0
- package/loops/actions/verify-route-matrix/verify-route-matrix.sh +212 -0
- package/loops/aw/repo-config.md +12 -0
- package/loops/scripts/compile-agent-workflows.mjs +13 -0
- package/loops/workflows/agent-apply-review.md +372 -0
- package/loops/workflows/agent-audit.md +197 -0
- package/loops/workflows/agent-direct.md +362 -0
- package/loops/workflows/agent-implement.md +329 -0
- package/loops/workflows/agent-merge-gate.md +470 -0
- package/loops/workflows/agent-propose.md +342 -0
- package/loops/workflows/agent-refine.md +338 -0
- package/loops/workflows/shared/opencode-ci.md +26 -0
- package/loops/workflows/shared/platform-defaults.md +14 -0
- package/loops/workflows/work-router.yml +391 -0
- package/package.json +28 -0
|
@@ -0,0 +1,287 @@
|
|
|
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();
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
name: Update agent issues
|
|
2
|
+
description: Apply every update_issue item from agent_output.json.
|
|
3
|
+
inputs:
|
|
4
|
+
output-file:
|
|
5
|
+
description: Path to agent_output.json.
|
|
6
|
+
required: true
|
|
7
|
+
token:
|
|
8
|
+
description: GitHub token with issues:write.
|
|
9
|
+
required: true
|
|
10
|
+
fallback-issue-number:
|
|
11
|
+
description: Issue an item targets when it names none.
|
|
12
|
+
required: false
|
|
13
|
+
default: ''
|
|
14
|
+
runs:
|
|
15
|
+
using: composite
|
|
16
|
+
steps:
|
|
17
|
+
- name: Update issues
|
|
18
|
+
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
|
19
|
+
env:
|
|
20
|
+
AGENT_OUTPUT_LIB: ${{ github.action_path }}/../agent-output.cjs
|
|
21
|
+
OUTPUT_FILE: ${{ inputs.output-file }}
|
|
22
|
+
FALLBACK_ISSUE_NUMBER: ${{ inputs.fallback-issue-number }}
|
|
23
|
+
with:
|
|
24
|
+
github-token: ${{ inputs.token }}
|
|
25
|
+
script: |
|
|
26
|
+
const { readAgentItems } = require(process.env.AGENT_OUTPUT_LIB);
|
|
27
|
+
const items = readAgentItems(process.env.OUTPUT_FILE, 'update_issue');
|
|
28
|
+
|
|
29
|
+
let updated = 0;
|
|
30
|
+
|
|
31
|
+
for (const item of items) {
|
|
32
|
+
const target = item.item_number ?? process.env.FALLBACK_ISSUE_NUMBER;
|
|
33
|
+
|
|
34
|
+
if (!target) {
|
|
35
|
+
core.warning(`update_issue item has no item_number and no fallback, skipping: ${JSON.stringify(item)}`);
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const changes = {};
|
|
40
|
+
if (item.title) changes.title = item.title;
|
|
41
|
+
if (item.body) changes.body = item.body;
|
|
42
|
+
|
|
43
|
+
if (Object.keys(changes).length === 0) {
|
|
44
|
+
core.warning(`update_issue item changes nothing, skipping: ${JSON.stringify(item)}`);
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
await github.rest.issues.update({
|
|
49
|
+
...context.repo,
|
|
50
|
+
issue_number: Number(target),
|
|
51
|
+
...changes,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
updated += 1;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
core.info(`Updated ${updated} issue(s).`);
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
name: Validate refine output
|
|
2
|
+
description: Verify agent_output.json contains a usable refinement outcome before any GitHub writes.
|
|
3
|
+
inputs:
|
|
4
|
+
output-file:
|
|
5
|
+
description: Path to agent_output.json.
|
|
6
|
+
required: true
|
|
7
|
+
marker:
|
|
8
|
+
description: Comment marker removed before evaluating clarification content.
|
|
9
|
+
required: true
|
|
10
|
+
comment-prefix:
|
|
11
|
+
description: Comment prefix removed before evaluating clarification content.
|
|
12
|
+
required: true
|
|
13
|
+
issue-number:
|
|
14
|
+
description: Issue number that every refinement outcome must target.
|
|
15
|
+
required: true
|
|
16
|
+
outputs:
|
|
17
|
+
valid:
|
|
18
|
+
description: Whether the output contains a usable replacement body or clarification comment.
|
|
19
|
+
value: ${{ steps.validate.outputs.valid }}
|
|
20
|
+
outcome:
|
|
21
|
+
description: "Deterministic refinement outcome: complete, questions, or invalid."
|
|
22
|
+
value: ${{ steps.validate.outputs.outcome }}
|
|
23
|
+
runs:
|
|
24
|
+
using: composite
|
|
25
|
+
steps:
|
|
26
|
+
- name: Validate refinement outcome
|
|
27
|
+
id: validate
|
|
28
|
+
shell: bash
|
|
29
|
+
env:
|
|
30
|
+
COMMENT_PREFIX: ${{ inputs.comment-prefix }}
|
|
31
|
+
ISSUE_NUMBER: ${{ inputs.issue-number }}
|
|
32
|
+
MARKER: ${{ inputs.marker }}
|
|
33
|
+
OUTPUT_FILE: ${{ inputs.output-file }}
|
|
34
|
+
run: |
|
|
35
|
+
set -euo pipefail
|
|
36
|
+
|
|
37
|
+
outcome="$(bash "${{ github.action_path }}/validate-refine-output.sh" "$OUTPUT_FILE" "$MARKER" "$COMMENT_PREFIX" "$ISSUE_NUMBER")"
|
|
38
|
+
echo "outcome=$outcome" >> "$GITHUB_OUTPUT"
|
|
39
|
+
echo "valid=$([ "$outcome" != 'invalid' ] && echo true || echo false)" >> "$GITHUB_OUTPUT"
|
|
40
|
+
|
|
41
|
+
if [ "$outcome" = 'invalid' ]; then
|
|
42
|
+
echo "::warning::Agent output has no usable refinement outcome. It will not be applied."
|
|
43
|
+
fi
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Print the deterministic Refine outcome: complete, questions, or invalid.
|
|
3
|
+
|
|
4
|
+
set -euo pipefail
|
|
5
|
+
|
|
6
|
+
output_file="$1"
|
|
7
|
+
marker="$2"
|
|
8
|
+
comment_prefix="$3"
|
|
9
|
+
issue_number="$4"
|
|
10
|
+
|
|
11
|
+
if [ ! -f "$output_file" ] || ! jq -e '.items | arrays' "$output_file" >/dev/null 2>&1; then
|
|
12
|
+
echo invalid
|
|
13
|
+
exit 0
|
|
14
|
+
fi
|
|
15
|
+
|
|
16
|
+
jq -r --arg marker "$marker" --arg prefix "$comment_prefix" --arg issue "$issue_number" '
|
|
17
|
+
def has_replacement_body:
|
|
18
|
+
any(.items[]; .type == "update_issue" and
|
|
19
|
+
(.item_number == null or (.item_number | tostring) == $issue) and
|
|
20
|
+
(.body | type == "string") and (.body | test("[^[:space:]]")));
|
|
21
|
+
|
|
22
|
+
def has_update:
|
|
23
|
+
any(.items[]; .type == "update_issue");
|
|
24
|
+
|
|
25
|
+
def has_only_source_items:
|
|
26
|
+
all(.items[];
|
|
27
|
+
(
|
|
28
|
+
.type == "update_issue" and
|
|
29
|
+
(.item_number == null or (.item_number | tostring) == $issue)
|
|
30
|
+
) or
|
|
31
|
+
(
|
|
32
|
+
.type == "add_comment" and
|
|
33
|
+
(.item_number | tostring) == $issue
|
|
34
|
+
));
|
|
35
|
+
|
|
36
|
+
def has_clarification:
|
|
37
|
+
any(.items[]; .type == "add_comment" and (.item_number | tostring) == $issue and
|
|
38
|
+
(.body | type == "string") and
|
|
39
|
+
(.body | split($marker) | join("") | split($prefix) | join("") |
|
|
40
|
+
gsub("<[^>]*>"; "") | test("[[:alnum:]]")));
|
|
41
|
+
|
|
42
|
+
if has_replacement_body and has_only_source_items then "complete"
|
|
43
|
+
elif has_clarification and (has_update | not) and has_only_source_items then "questions"
|
|
44
|
+
else "invalid"
|
|
45
|
+
end
|
|
46
|
+
' "$output_file"
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
name: Verify composite actions
|
|
2
|
+
description: Check every local composite action manifest parses and uses only contexts a composite action actually has.
|
|
3
|
+
runs:
|
|
4
|
+
using: composite
|
|
5
|
+
steps:
|
|
6
|
+
- name: Validate composite action manifests
|
|
7
|
+
shell: bash
|
|
8
|
+
run: bash "${GITHUB_ACTION_PATH}/verify-composite-actions.sh"
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Validate every local composite action manifest.
|
|
3
|
+
#
|
|
4
|
+
# The runner evaluates ${{ }} everywhere in an action.yml, including inside `description:`,
|
|
5
|
+
# and a composite action has no `needs`, `jobs` or `secrets` context. Referencing one, even as
|
|
6
|
+
# documentation, fails the action at load time with "Unrecognized named-value", which surfaces
|
|
7
|
+
# as a one-second job failure with no other clue.
|
|
8
|
+
#
|
|
9
|
+
# `gh aw compile` does not read these files and actionlint does not lint them, so this is the
|
|
10
|
+
# only thing between a typo here and a runtime failure.
|
|
11
|
+
|
|
12
|
+
set -euo pipefail
|
|
13
|
+
|
|
14
|
+
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
15
|
+
ACTIONS_DIR="${1:-$(cd "${HERE}/.." && pwd)}"
|
|
16
|
+
|
|
17
|
+
PASS=0
|
|
18
|
+
FAIL=0
|
|
19
|
+
|
|
20
|
+
while IFS= read -r manifest; do
|
|
21
|
+
rel="${manifest#"${ACTIONS_DIR}/"}"
|
|
22
|
+
|
|
23
|
+
if ! python3 -c "import sys, yaml; yaml.safe_load(open(sys.argv[1], encoding='utf-8'))" "$manifest" 2>/dev/null; then
|
|
24
|
+
FAIL=$((FAIL + 1))
|
|
25
|
+
echo "FAIL: ${rel} is not valid YAML" >&2
|
|
26
|
+
continue
|
|
27
|
+
fi
|
|
28
|
+
|
|
29
|
+
# Only what is inside an expression matters; the same word in prose is fine.
|
|
30
|
+
offenders="$(grep -oE '\$\{\{[^}]*\}\}' "$manifest" |
|
|
31
|
+
grep -E '\b(needs|jobs|secrets)\.' || true)"
|
|
32
|
+
|
|
33
|
+
if [ -n "$offenders" ]; then
|
|
34
|
+
FAIL=$((FAIL + 1))
|
|
35
|
+
echo "FAIL: ${rel} uses a context a composite action does not have:" >&2
|
|
36
|
+
printf ' %s\n' "$offenders" >&2
|
|
37
|
+
continue
|
|
38
|
+
fi
|
|
39
|
+
|
|
40
|
+
PASS=$((PASS + 1))
|
|
41
|
+
done < <(find "$ACTIONS_DIR" -name 'action.yml' | sort)
|
|
42
|
+
|
|
43
|
+
echo
|
|
44
|
+
if [ "$FAIL" -eq 0 ]; then
|
|
45
|
+
echo "Composite action manifests: ${PASS} valid"
|
|
46
|
+
else
|
|
47
|
+
echo "Composite action manifests: ${PASS} valid, ${FAIL} INVALID" >&2
|
|
48
|
+
fi
|
|
49
|
+
|
|
50
|
+
exit $((FAIL > 0))
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Exercise real validation script so incomplete agent output cannot be applied.
|
|
3
|
+
|
|
4
|
+
set -euo pipefail
|
|
5
|
+
|
|
6
|
+
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
7
|
+
VALIDATOR="${HERE}/../validate-refine-output/validate-refine-output.sh"
|
|
8
|
+
MARKER='<!-- agent-refine -->'
|
|
9
|
+
PREFIX='Refinement update'
|
|
10
|
+
TEMP_DIR="$(mktemp -d)"
|
|
11
|
+
|
|
12
|
+
trap 'rm -rf "$TEMP_DIR"' EXIT
|
|
13
|
+
|
|
14
|
+
PASS=0
|
|
15
|
+
FAIL=0
|
|
16
|
+
|
|
17
|
+
assert_output() {
|
|
18
|
+
local label="$1"
|
|
19
|
+
local expected="$2"
|
|
20
|
+
local payload="$3"
|
|
21
|
+
local output_file="${TEMP_DIR}/agent_output.json"
|
|
22
|
+
local actual
|
|
23
|
+
|
|
24
|
+
printf '%s' "$payload" > "$output_file"
|
|
25
|
+
actual="$(bash "$VALIDATOR" "$output_file" "$MARKER" "$PREFIX" 42)"
|
|
26
|
+
|
|
27
|
+
if [ "$actual" = "$expected" ]; then
|
|
28
|
+
PASS=$((PASS + 1))
|
|
29
|
+
else
|
|
30
|
+
FAIL=$((FAIL + 1))
|
|
31
|
+
printf 'FAIL: %s\n expected: %s\n actual: %s\n' "$label" "$expected" "$actual" >&2
|
|
32
|
+
fi
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
assert_output 'complete refinement is classified without labels' complete \
|
|
36
|
+
'{"items":[{"type":"update_issue","item_number":42,"body":"# User story"}]}'
|
|
37
|
+
assert_output 'fallback-targeted refinement is complete' complete \
|
|
38
|
+
'{"items":[{"type":"update_issue","body":"# User story"}]}'
|
|
39
|
+
assert_output 'clarification is classified without labels' questions \
|
|
40
|
+
'{"items":[{"type":"add_comment","item_number":42,"body":"<!-- agent-refine -->\nRefinement update\nWhich users need this feature?"}]}'
|
|
41
|
+
assert_output 'agent label actions are invalid' invalid \
|
|
42
|
+
'{"items":[{"type":"update_issue","item_number":42,"body":"# User story"},{"type":"add_labels","item_number":42,"labels":[{"name":"review"}]}]}'
|
|
43
|
+
assert_output 'single hyphen comment is invalid' invalid \
|
|
44
|
+
'{"items":[{"type":"add_comment","body":"-"}]}'
|
|
45
|
+
assert_output 'marker and prefix alone are invalid' invalid \
|
|
46
|
+
'{"items":[{"type":"add_comment","body":"<!-- agent-refine -->\nRefinement update\n---"}]}'
|
|
47
|
+
assert_output 'blank issue body is invalid' invalid \
|
|
48
|
+
'{"items":[{"type":"update_issue","item_number":42,"body":" \n\t "}]}'
|
|
49
|
+
assert_output 'question with an update is complete' complete \
|
|
50
|
+
'{"items":[{"type":"update_issue","item_number":42,"body":"# User story"},{"type":"add_comment","item_number":42,"body":"Which users need this feature?"}]}'
|
|
51
|
+
assert_output 'question with a blank update is invalid' invalid \
|
|
52
|
+
'{"items":[{"type":"update_issue","item_number":42,"body":" "},{"type":"add_comment","item_number":42,"body":"Which users need this feature?"}]}'
|
|
53
|
+
assert_output 'wrong issue output is invalid' invalid \
|
|
54
|
+
'{"items":[{"type":"add_comment","item_number":7,"body":"Which users need this feature?"}]}'
|
|
55
|
+
assert_output 'complete output cannot update another issue' invalid \
|
|
56
|
+
'{"items":[{"type":"update_issue","item_number":42,"body":"# User story"},{"type":"update_issue","item_number":7,"body":"# Other story"}]}'
|
|
57
|
+
|
|
58
|
+
echo
|
|
59
|
+
if [ "$FAIL" -eq 0 ]; then
|
|
60
|
+
echo "Refine output validation: ${PASS} passed"
|
|
61
|
+
else
|
|
62
|
+
echo "Refine output validation: ${PASS} passed, ${FAIL} FAILED" >&2
|
|
63
|
+
fi
|
|
64
|
+
|
|
65
|
+
exit $((FAIL > 0))
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
name: Verify route matrix
|
|
2
|
+
description: Exercise the router's classifier against every supported event, and check that each route and dispatch operation has a job in work-router.yml.
|
|
3
|
+
runs:
|
|
4
|
+
using: composite
|
|
5
|
+
steps:
|
|
6
|
+
- name: Run route matrix verification
|
|
7
|
+
shell: bash
|
|
8
|
+
run: bash "${GITHUB_ACTION_PATH}/verify-route-matrix.sh"
|