@plainconceptsplatform/workflows 1.9.1 → 1.10.1

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 (36) hide show
  1. package/dist/package-baseline.js +4 -1
  2. package/loops/actions/add-issue-labels/action.yml +50 -50
  3. package/loops/actions/agent-output.cjs +17 -17
  4. package/loops/actions/apply-agent-bundle/action.yml +24 -24
  5. package/loops/actions/apply-agent-comments/action.yml +42 -42
  6. package/loops/actions/apply-agent-labels/action.yml +55 -55
  7. package/loops/actions/cleanup-artifacts/action.yml +91 -91
  8. package/loops/actions/close-agent-issues/action.yml +43 -43
  9. package/loops/actions/collect-doc-drift/collect-doc-drift.mjs +7 -3
  10. package/loops/actions/create-issue-comment/action.yml +29 -29
  11. package/loops/actions/download-agent-output/action.yml +53 -53
  12. package/loops/actions/identify-gate-subject/action.yml +4 -0
  13. package/loops/actions/link-pr-to-issue/action.yml +40 -40
  14. package/loops/actions/list-open-issues/action.yml +33 -33
  15. package/loops/actions/load-issue-context/action.yml +45 -45
  16. package/loops/actions/merge-agent-pr/action.yml +49 -49
  17. package/loops/actions/remove-issue-labels/action.yml +37 -37
  18. package/loops/actions/report-workflow-errors/action.yml +63 -16
  19. package/loops/actions/report-workflow-errors/report-policy.cjs +89 -5
  20. package/loops/actions/update-agent-issues/action.yml +58 -58
  21. package/loops/actions/validate-refine-output/action.yml +6 -1
  22. package/loops/actions/validate-refine-output/validate-refine-output.sh +20 -2
  23. package/loops/actions/validate-review-output/action.yml +35 -35
  24. package/loops/actions/validate-triage-output/action.yml +36 -36
  25. package/loops/actions/verify-composite-actions/action.yml +9 -9
  26. package/loops/actions/verify-refine-output/action.yml +9 -9
  27. package/loops/actions/verify-route-matrix/action.yml +9 -9
  28. package/loops/actions/verify-route-matrix/verify-route-matrix.sh +20 -0
  29. package/loops/templates/agentics/agentics-maintenance.yml +121 -121
  30. package/loops/templates/issues/bug_report.yml +109 -109
  31. package/loops/templates/issues/feature_request.yml +75 -75
  32. package/loops/workflows/agent-merge-gate.md +11 -7
  33. package/loops/workflows/agent-refine.md +61 -11
  34. package/loops/workflows/shared/platform-defaults.md +53 -53
  35. package/loops/workflows/work-router.yml +77 -1
  36. package/package.json +1 -1
@@ -1,37 +1,37 @@
1
- # Managed by @plainconceptsplatform/workflows. Source: loops/actions/remove-issue-labels/action.yml. Update with workflows update --force; consumer edits may be overwritten.
2
- name: Remove issue labels
3
- description: Remove one or more labels from an issue or pull request.
4
- inputs:
5
- token:
6
- description: GitHub token used by github-script.
7
- required: true
8
- issue-number:
9
- description: Issue or pull request number.
10
- required: true
11
- labels:
12
- description: Labels to remove, one per line. A comma-separated list is accepted as well.
13
- required: true
14
- runs:
15
- using: composite
16
- steps:
17
- - name: Remove labels
18
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
19
- env:
20
- ISSUE_NUMBER: ${{ inputs.issue-number }}
21
- LABELS: ${{ inputs.labels }}
22
- with:
23
- github-token: ${{ inputs.token }}
24
- script: |
25
- // Callers wrote `labels: a,b` while this split on newlines, so one label named "a,b"
26
- // was removed: a 404, swallowed below, and neither label ever came off.
27
- for (const label of process.env.LABELS.split(/\r?\n|,/).map((label) => label.trim()).filter(Boolean)) {
28
- try {
29
- await github.rest.issues.removeLabel({
30
- ...context.repo,
31
- issue_number: Number(process.env.ISSUE_NUMBER),
32
- name: label,
33
- });
34
- } catch (error) {
35
- if (error.status !== 404) throw error;
36
- }
37
- }
1
+ # Managed by @plainconceptsplatform/workflows. Source: loops/actions/remove-issue-labels/action.yml. Update with workflows update --force; consumer edits may be overwritten.
2
+ name: Remove issue labels
3
+ description: Remove one or more labels from an issue or pull request.
4
+ inputs:
5
+ token:
6
+ description: GitHub token used by github-script.
7
+ required: true
8
+ issue-number:
9
+ description: Issue or pull request number.
10
+ required: true
11
+ labels:
12
+ description: Labels to remove, one per line. A comma-separated list is accepted as well.
13
+ required: true
14
+ runs:
15
+ using: composite
16
+ steps:
17
+ - name: Remove labels
18
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
19
+ env:
20
+ ISSUE_NUMBER: ${{ inputs.issue-number }}
21
+ LABELS: ${{ inputs.labels }}
22
+ with:
23
+ github-token: ${{ inputs.token }}
24
+ script: |
25
+ // Callers wrote `labels: a,b` while this split on newlines, so one label named "a,b"
26
+ // was removed: a 404, swallowed below, and neither label ever came off.
27
+ for (const label of process.env.LABELS.split(/\r?\n|,/).map((label) => label.trim()).filter(Boolean)) {
28
+ try {
29
+ await github.rest.issues.removeLabel({
30
+ ...context.repo,
31
+ issue_number: Number(process.env.ISSUE_NUMBER),
32
+ name: label,
33
+ });
34
+ } catch (error) {
35
+ if (error.status !== 404) throw error;
36
+ }
37
+ }
@@ -29,6 +29,12 @@ inputs:
29
29
  occurrence is a blip; an issue that is already open is always appended to.
30
30
  required: false
31
31
  default: '2'
32
+ min-occurrences-unknown:
33
+ description: >-
34
+ The same, for a failure the catalogue could not classify. With no diagnosis behind it, a
35
+ cause has to repeat more before it is worth a person's time.
36
+ required: false
37
+ default: '3'
32
38
  max-findings:
33
39
  description: The most issues one run opens, the most frequent first. The rest stay in the job summary.
34
40
  required: false
@@ -58,6 +64,7 @@ runs:
58
64
  UPSTREAM_REPO: ${{ inputs.upstream-repo }}
59
65
  LOOKBACK_HOURS: ${{ inputs.lookback-hours }}
60
66
  MIN_OCCURRENCES: ${{ inputs.min-occurrences }}
67
+ MIN_UNKNOWN: ${{ inputs.min-occurrences-unknown }}
61
68
  MAX_FINDINGS: ${{ inputs.max-findings }}
62
69
  QUIET_DAYS: ${{ inputs.quiet-days }}
63
70
  POLICY_PATH: ${{ github.action_path }}/report-policy.cjs
@@ -118,7 +125,7 @@ runs:
118
125
  // line carries repository paths, branch names and issue titles.
119
126
  const CATALOGUE = [
120
127
  { id: 'runner-never-assigned', test: /waiting for a runner|no runner matching|requested labels/i, note: 'A job waited for a self-hosted runner that never arrived.' },
121
- { id: 'runner-lost-mid-job', test: /lost communication with the server|runner has received a shutdown signal|The self-hosted runner:.*lost/i, note: 'The runner disappeared while a job was running.' },
128
+ { id: 'runner-lost-mid-job', test: /lost communication with the server|runner has received a shutdown signal|The self-hosted runner:.*lost|exit code 143/i, note: 'The runner disappeared while a job was running.' },
122
129
  { id: 'job-cancelled-by-timeout', test: /The job running on runner .* has exceeded the maximum execution time|timeout-minutes/i, note: 'A job hit its timeout instead of finishing.' },
123
130
  { id: 'out-of-disk', test: /no space left on device|You are running out of disk space/i, note: 'The runner ran out of disk.' },
124
131
  { id: 'out-of-memory', test: /JavaScript heap out of memory|Killed\s+process|OOMKilled/i, note: 'A step was killed for memory.' },
@@ -187,7 +194,7 @@ runs:
187
194
  // Things that happened and are worth a number in the job summary, but that nobody can
188
195
  // fix by editing a workflow, so they never open an issue: a cancelled job with no step
189
196
  // that failed, a log that could not be read, and the fleet's own transient causes.
190
- const quiet = { cancelledNoStep: 0, unreadable: 0, infra: new Map() };
197
+ const quiet = { cancelledNoStep: 0, unreadable: 0, retried: 0, reviewChecks: 0, infra: new Map() };
191
198
  let queueWaitTotal = 0;
192
199
  let queueWaitCount = 0;
193
200
  let queueWaitWorst = 0;
@@ -213,6 +220,9 @@ runs:
213
220
  if (!['failure', 'cancelled', 'timed_out', 'startup_failure'].includes(run.conclusion)) continue;
214
221
  ownedFailures += 1;
215
222
 
223
+ // A lint check failing on a pull request has done its job on somebody's change.
224
+ if (policy.isReviewCheck({ workflow: file, event: run.event })) { quiet.reviewChecks += 1; continue; }
225
+
216
226
  let jobs = [];
217
227
  try {
218
228
  jobs = await github.paginate(github.rest.actions.listJobsForWorkflowRunAttempt, {
@@ -220,6 +230,10 @@ runs:
220
230
  });
221
231
  } catch { continue; }
222
232
 
233
+ // The worker already caught this one and sent the work back through the router; the
234
+ // attempt that parks does not retry, so a failure no retry got past is still read.
235
+ if (policy.wasRetried(jobs)) { quiet.retried += 1; continue; }
236
+
223
237
  for (const job of jobs) {
224
238
  if (job.started_at && job.created_at) {
225
239
  const wait = (new Date(job.started_at) - new Date(job.created_at)) / 1000;
@@ -251,20 +265,24 @@ runs:
251
265
  annotationText = notes.map((note) => note.message ?? '').join('\n');
252
266
  } catch { /* annotations are an extra; the log can still classify */ }
253
267
 
254
- let patternId = 'unrecognised';
268
+ // A step whose name already says what failed is not read further: a missing secret
269
+ // is the repository's to set, whatever the log says around it.
270
+ let patternId = policy.causeFromStep(step?.name) ?? 'unrecognised';
255
271
  let logRead = true;
256
- try {
257
- const { data: log } = await github.rest.actions.downloadJobLogsForWorkflowRun({
258
- ...context.repo, job_id: job.id,
259
- });
260
- const text = typeof log === 'string' ? log : Buffer.from(log).toString('utf8');
261
- // The tail is where the failure is, and reading less of the log is less to get
262
- // wrong. Only the matched entry's id crosses into the report.
263
- const tail = `${annotationText}\n${text.slice(-20000)}`;
264
- patternId = CATALOGUE.find((entry) => entry.test.test(tail))?.id ?? 'unrecognised';
265
- } catch {
266
- logRead = false;
267
- patternId = CATALOGUE.find((entry) => entry.test.test(annotationText))?.id ?? 'log-unavailable';
272
+ if (patternId === 'unrecognised') {
273
+ try {
274
+ const { data: log } = await github.rest.actions.downloadJobLogsForWorkflowRun({
275
+ ...context.repo, job_id: job.id,
276
+ });
277
+ const text = typeof log === 'string' ? log : Buffer.from(log).toString('utf8');
278
+ // The tail is where the failure is, and reading less of the log is less to get
279
+ // wrong. Only the matched entry's id crosses into the report.
280
+ const tail = `${annotationText}\n${text.slice(-20000)}`;
281
+ patternId = CATALOGUE.find((entry) => entry.test.test(tail))?.id ?? 'unrecognised';
282
+ } catch {
283
+ logRead = false;
284
+ patternId = CATALOGUE.find((entry) => entry.test.test(annotationText))?.id ?? 'log-unavailable';
285
+ }
268
286
  }
269
287
 
270
288
  // A log nobody could read says nothing, so it is counted and not filed.
@@ -303,7 +321,8 @@ runs:
303
321
  }
304
322
 
305
323
  const catalogueNote = (id) => CATALOGUE.find((entry) => entry.id === id)?.note
306
- ?? (id === 'log-unavailable' ? 'The job log could not be read, so the failure could not be classified.'
324
+ ?? (id === 'missing-secret' ? 'A workflow stopped at a check for a secret this repository has not set.'
325
+ : id === 'log-unavailable' ? 'The job log could not be read, so the failure could not be classified.'
307
326
  : 'The log matched no known failure shape. Worth adding one to the catalogue.');
308
327
 
309
328
  const ranked = [...findings.values()].sort((a, b) => b.count - a.count);
@@ -313,6 +332,7 @@ runs:
313
332
  // person's time, and a bad night opens a handful of issues rather than forty.
314
333
  const selection = policy.select(ranked, {
315
334
  minOccurrences: Number(process.env.MIN_OCCURRENCES),
335
+ minUnknown: Number(process.env.MIN_UNKNOWN),
316
336
  maxFindings: Number(process.env.MAX_FINDINGS),
317
337
  });
318
338
 
@@ -385,6 +405,8 @@ runs:
385
405
  `Distinct failure signatures: ${ranked.length}`,
386
406
  `Filed or appended: ${selection.file.length}; held back: ${selection.held.length}`,
387
407
  `Cancelled jobs with no failing step, not reported: ${quiet.cancelledNoStep}`,
408
+ `Runs whose failure the worker retried through the router, not reported: ${quiet.retried}`,
409
+ `Checks that failed on a pull request, not reported: ${quiet.reviewChecks}`,
388
410
  `Jobs whose log could not be read, not reported: ${quiet.unreadable}`,
389
411
  `Infrastructure causes, counted and not reported: ${[...quiet.infra].map(([id, n]) => `${id} ${n}`).join(', ') || 'none'}`,
390
412
  queueWaitCount > 0
@@ -397,6 +419,12 @@ runs:
397
419
  ])
398
420
  .write();
399
421
 
422
+ // The repository's own configuration is said here, to the people who can change it, and
423
+ // nowhere else. The step name is the package's (or gh-aw's) own words.
424
+ for (const { finding } of selection.held.filter(({ finding }) => finding.severity === 'consumer')) {
425
+ core.warning(`${finding.count} job(s) of ${finding.workflow} stopped at "${finding.step}": set the secret it checks for, or the work it guards never runs.`);
426
+ }
427
+
400
428
  if (ranked.length === 0) {
401
429
  core.info('No failures in the window for any workflow this package ships.');
402
430
  core.setOutput('filed', '');
@@ -427,6 +455,18 @@ runs:
427
455
  const upstream = getOctokit(upstreamToken);
428
456
  const filed = [];
429
457
 
458
+ // The latest release, to tell a failure a release may already have fixed from one that
459
+ // is still live. Best effort: a token that cannot read tags files as before.
460
+ let latestRelease = '';
461
+ try {
462
+ const { data: tags } = await upstream.rest.repos.listTags({
463
+ owner: upstreamOwner, repo: upstreamName, per_page: 100,
464
+ });
465
+ latestRelease = policy.latestVersion(tags.map((tag) => tag.name));
466
+ } catch (error) {
467
+ core.info(`could not read the upstream tags: HTTP ${error.status ?? '?'}`);
468
+ }
469
+
430
470
  for (const { finding, title, body } of reports) {
431
471
  const marker = body.split('\n')[0];
432
472
  // One issue per signature, updated rather than repeated. Searching by the marker
@@ -450,6 +490,13 @@ runs:
450
490
  core.warning(`could not search upstream for an existing report: HTTP ${error.status ?? '?'}`);
451
491
  }
452
492
 
493
+ // An install a minor or more behind does not open a new issue: update it first. It
494
+ // still appends to one that is open, because then the bug is live on newer versions too.
495
+ if (!existing && policy.isOutdated(packageVersion, latestRelease)) {
496
+ core.info(`left alone, ${packageVersion} is behind ${latestRelease}; update first: ${title}`);
497
+ continue;
498
+ }
499
+
453
500
  if (dryRun) {
454
501
  core.info(`[dry-run] would ${existing ? `append to #${existing.number}` : 'open an issue'}: ${title}`);
455
502
  continue;
@@ -10,6 +10,11 @@
10
10
  // a runner shut down under a job, 47% re-filed a signature seen before, every one was closed as
11
11
  // not planned and none had a comment. What follows is the filter that would have kept about a
12
12
  // hundred of them out of that pile and left the rest as a count in the job summary.
13
+ //
14
+ // The 11 that still got through in the week to 2026-11-03 were the next layer: 39 jobs a day failing
15
+ // gh-aw's check for an engine secret the repository never set, agent failures the worker had
16
+ // already sent back through the router and recovered, a shellcheck step failing on a pull request
17
+ // it was right to fail, and installs several minors behind reporting what a release had fixed.
13
18
 
14
19
  'use strict';
15
20
 
@@ -27,6 +32,13 @@ const INFRA = new Set([
27
32
  'out-of-memory',
28
33
  ]);
29
34
 
35
+ // Causes that belong to the repository that installed the package: a secret it never set. They are
36
+ // warned about in that repository's own job summary, where the person who can set it will look,
37
+ // and never filed upstream, where nobody can.
38
+ const CONSUMER = new Set([
39
+ 'missing-secret',
40
+ ]);
41
+
30
42
  // Causes that are a defect in something this package ships.
31
43
  const PACKAGE_BUG = new Set([
32
44
  'jq-error',
@@ -39,13 +51,75 @@ const PACKAGE_BUG = new Set([
39
51
  'gh-api-409-conflict',
40
52
  ]);
41
53
 
42
- /** infra, package-bug, or unknown (a cause nobody classified yet, worth a look when it repeats). */
54
+ /** infra, consumer, package-bug, or unknown (a cause nobody classified yet, worth a look when it repeats). */
43
55
  function severityOf(patternId) {
44
56
  if (INFRA.has(patternId)) return 'infra';
57
+ if (CONSUMER.has(patternId)) return 'consumer';
45
58
  if (PACKAGE_BUG.has(patternId)) return 'package-bug';
46
59
  return 'unknown';
47
60
  }
48
61
 
62
+ /**
63
+ * Some steps say what failed by their name alone, and their log says it in words the catalogue
64
+ * cannot safely match: gh-aw's secret check names the variable, and a pattern for that name would
65
+ * put an engine name in a file that asserts it never reaches for a model. Step names are the
66
+ * package's own (or gh-aw's, compiled into the package's lock files), so they are safe to read.
67
+ */
68
+ function causeFromStep(step) {
69
+ if (/^Validate .* secrets?$/i.test(step ?? '')) return 'missing-secret';
70
+ if (/^Check workflow lock file$/i.test(step ?? '')) return 'lock-out-of-date';
71
+ return null;
72
+ }
73
+
74
+ /**
75
+ * A worker that hits a short run, a runner shutdown or a cut-off sends the work back through the
76
+ * router (decide-agent-retry) and the next attempt usually lands. The failed agent job stays red in
77
+ * the run that retried it, and used to be filed as if nothing had caught it. A run whose retry step
78
+ * succeeded has had its failure answered; the attempt that finally parks does not retry, so a
79
+ * failure the retries never got past is still counted.
80
+ */
81
+ function wasRetried(jobs) {
82
+ return (jobs ?? []).some((job) => (job.steps ?? []).some((step) =>
83
+ /^Send the (work|issue) back through the router$/.test(step.name ?? '') && step.conclusion === 'success'));
84
+ }
85
+
86
+ /**
87
+ * agentics-checks lints the repository's workflows and actions. On a pull request a red check is
88
+ * the check doing its job on somebody's change; only on the default branch, where the package's
89
+ * own files are what is there, can its failure be the package's.
90
+ */
91
+ function isReviewCheck({ workflow, event }) {
92
+ return workflow === 'agentics-checks.yml' && (event === 'pull_request' || event === 'pull_request_target');
93
+ }
94
+
95
+ /**
96
+ * Whether the reporting install is a minor or more behind the latest release. Its failure may be
97
+ * one the release already fixed, so it does not open an issue; it still appends to one that is
98
+ * open. Patch versions are left alone: the fleet is rolled out the same day and a patch behind is
99
+ * the normal state for hours after every release.
100
+ */
101
+ function isOutdated(reporting, latest) {
102
+ const parse = (v) => /^v?(\d+)\.(\d+)\./.exec(v ?? '')?.slice(1, 3).map(Number);
103
+ const a = parse(reporting);
104
+ const b = parse(latest);
105
+ if (!a || !b) return false;
106
+ return a[0] < b[0] || (a[0] === b[0] && a[1] < b[1]);
107
+ }
108
+
109
+ /**
110
+ * The newest release among the upstream tags. Tags, not GitHub releases or the npm dist-tag: the
111
+ * fleet is rolled out from tags, and both of the others have lagged them by several minors.
112
+ */
113
+ function latestVersion(tags) {
114
+ let best = '';
115
+ for (const tag of tags ?? []) {
116
+ if (!/^v?\d+\.\d+\.\d+$/.test(tag)) continue;
117
+ const [a, b] = [tag, best].map((v) => v.replace(/^v/, '').split('.').map(Number));
118
+ if (!best || a[0] > b[0] || (a[0] === b[0] && (a[1] > b[1] || (a[1] === b[1] && a[2] > b[2])))) best = tag;
119
+ }
120
+ return best;
121
+ }
122
+
49
123
  /**
50
124
  * One cause is one issue. A specific cause is the same defect whichever job of the router tripped
51
125
  * on it, so the job and the step are not part of its identity: conclude, safe_outputs and reserve
@@ -65,19 +139,26 @@ function markerKey({ workflow, step, conclusion, patternId }) {
65
139
  /**
66
140
  * Which findings become issues, and why the others do not.
67
141
  * - infra causes are held: they are a rate, not a bug.
142
+ * - consumer causes are held: the repository that has them is the one that can fix them.
68
143
  * - a finding seen fewer than `minOccurrences` times in the window is held: once is a blip.
144
+ * - an unrecognised cause needs `minUnknown`: with no diagnosis, twice in a day was mostly a
145
+ * provider hiccup nobody could act on.
69
146
  * - at most `maxFindings` are filed per run, the most frequent first, so a bad night opens a
70
147
  * handful of issues and not forty.
71
148
  */
72
- function select(findings, { minOccurrences = 2, maxFindings = 5 } = {}) {
149
+ function select(findings, { minOccurrences = 2, minUnknown = 3, maxFindings = 5 } = {}) {
73
150
  const ranked = [...findings].sort((a, b) => b.count - a.count);
74
151
  const file = [];
75
152
  const held = [];
153
+ const neededFor = (finding) => (finding.severity === 'unknown' ? Math.max(minOccurrences, minUnknown) : minOccurrences);
76
154
  for (const finding of ranked) {
77
155
  if (finding.severity === 'infra') {
78
156
  held.push({ finding, reason: 'infrastructure cause, counted not filed' });
79
- } else if (finding.count < minOccurrences) {
80
- held.push({ finding, reason: `seen ${finding.count} time(s), under the ${minOccurrences} needed to file` });
157
+ } else if (finding.severity === 'consumer') {
158
+ held.push({ finding, reason: "this repository's own configuration, warned here not filed" });
159
+ } else if (finding.count < neededFor(finding)) {
160
+ const needed = neededFor(finding);
161
+ held.push({ finding, reason: `seen ${finding.count} time(s), under the ${needed} needed to file` });
81
162
  } else if (file.length >= maxFindings) {
82
163
  held.push({ finding, reason: `over the ${maxFindings} filed per run` });
83
164
  } else {
@@ -107,4 +188,7 @@ function parseVersion(header) {
107
188
  return match ? match[1] : 'unknown';
108
189
  }
109
190
 
110
- module.exports = { INFRA, PACKAGE_BUG, severityOf, signatureOf, markerKey, select, isQuiet, parseVersion };
191
+ module.exports = {
192
+ INFRA, CONSUMER, PACKAGE_BUG, severityOf, causeFromStep, wasRetried, isReviewCheck, isOutdated, latestVersion,
193
+ signatureOf, markerKey, select, isQuiet, parseVersion,
194
+ };
@@ -1,58 +1,58 @@
1
- # Managed by @plainconceptsplatform/workflows. Source: loops/actions/update-agent-issues/action.yml. Update with workflows update --force; consumer edits may be overwritten.
2
- name: Update agent issues
3
- description: Apply every update_issue item from agent_output.json.
4
- inputs:
5
- output-file:
6
- description: Path to agent_output.json.
7
- required: true
8
- token:
9
- description: GitHub token with issues:write.
10
- required: true
11
- fallback-issue-number:
12
- description: Issue an item targets when it names none.
13
- required: false
14
- default: ''
15
- runs:
16
- using: composite
17
- steps:
18
- - name: Update issues
19
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
20
- env:
21
- AGENT_OUTPUT_LIB: ${{ github.action_path }}/../agent-output.cjs
22
- OUTPUT_FILE: ${{ inputs.output-file }}
23
- FALLBACK_ISSUE_NUMBER: ${{ inputs.fallback-issue-number }}
24
- with:
25
- github-token: ${{ inputs.token }}
26
- script: |
27
- const { readAgentItems } = require(process.env.AGENT_OUTPUT_LIB);
28
- const items = readAgentItems(process.env.OUTPUT_FILE, 'update_issue');
29
-
30
- let updated = 0;
31
-
32
- for (const item of items) {
33
- const target = item.item_number ?? process.env.FALLBACK_ISSUE_NUMBER;
34
-
35
- if (!target) {
36
- core.warning(`update_issue item has no item_number and no fallback, skipping: ${JSON.stringify(item)}`);
37
- continue;
38
- }
39
-
40
- const changes = {};
41
- if (item.title) changes.title = item.title;
42
- if (item.body) changes.body = item.body;
43
-
44
- if (Object.keys(changes).length === 0) {
45
- core.warning(`update_issue item changes nothing, skipping: ${JSON.stringify(item)}`);
46
- continue;
47
- }
48
-
49
- await github.rest.issues.update({
50
- ...context.repo,
51
- issue_number: Number(target),
52
- ...changes,
53
- });
54
-
55
- updated += 1;
56
- }
57
-
58
- core.info(`Updated ${updated} issue(s).`);
1
+ # Managed by @plainconceptsplatform/workflows. Source: loops/actions/update-agent-issues/action.yml. Update with workflows update --force; consumer edits may be overwritten.
2
+ name: Update agent issues
3
+ description: Apply every update_issue item from agent_output.json.
4
+ inputs:
5
+ output-file:
6
+ description: Path to agent_output.json.
7
+ required: true
8
+ token:
9
+ description: GitHub token with issues:write.
10
+ required: true
11
+ fallback-issue-number:
12
+ description: Issue an item targets when it names none.
13
+ required: false
14
+ default: ''
15
+ runs:
16
+ using: composite
17
+ steps:
18
+ - name: Update issues
19
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
20
+ env:
21
+ AGENT_OUTPUT_LIB: ${{ github.action_path }}/../agent-output.cjs
22
+ OUTPUT_FILE: ${{ inputs.output-file }}
23
+ FALLBACK_ISSUE_NUMBER: ${{ inputs.fallback-issue-number }}
24
+ with:
25
+ github-token: ${{ inputs.token }}
26
+ script: |
27
+ const { readAgentItems } = require(process.env.AGENT_OUTPUT_LIB);
28
+ const items = readAgentItems(process.env.OUTPUT_FILE, 'update_issue');
29
+
30
+ let updated = 0;
31
+
32
+ for (const item of items) {
33
+ const target = item.item_number ?? process.env.FALLBACK_ISSUE_NUMBER;
34
+
35
+ if (!target) {
36
+ core.warning(`update_issue item has no item_number and no fallback, skipping: ${JSON.stringify(item)}`);
37
+ continue;
38
+ }
39
+
40
+ const changes = {};
41
+ if (item.title) changes.title = item.title;
42
+ if (item.body) changes.body = item.body;
43
+
44
+ if (Object.keys(changes).length === 0) {
45
+ core.warning(`update_issue item changes nothing, skipping: ${JSON.stringify(item)}`);
46
+ continue;
47
+ }
48
+
49
+ await github.rest.issues.update({
50
+ ...context.repo,
51
+ issue_number: Number(target),
52
+ ...changes,
53
+ });
54
+
55
+ updated += 1;
56
+ }
57
+
58
+ core.info(`Updated ${updated} issue(s).`);
@@ -17,6 +17,10 @@ inputs:
17
17
  issue-number:
18
18
  description: Issue number that every refinement outcome must target.
19
19
  required: true
20
+ questions-line:
21
+ description: The sentence that opens the batched questions comment. A comment carrying it makes the outcome questions.
22
+ required: false
23
+ default: I have some questions about this issue
20
24
  outputs:
21
25
  valid:
22
26
  description: Whether the output contains a usable replacement body or clarification comment.
@@ -36,10 +40,11 @@ runs:
36
40
  ISSUE_NUMBER: ${{ inputs.issue-number }}
37
41
  MARKER: ${{ inputs.marker }}
38
42
  OUTPUT_FILE: ${{ inputs.output-file }}
43
+ QUESTIONS_LINE: ${{ inputs.questions-line }}
39
44
  run: |
40
45
  set -euo pipefail
41
46
 
42
- outcome="$(bash "${{ github.action_path }}/validate-refine-output.sh" "$OUTPUT_FILE" "$MARKER" "$COMMENT_PREFIX" "$ISSUE_NUMBER" "$DRAFT_MARKER")"
47
+ outcome="$(bash "${{ github.action_path }}/validate-refine-output.sh" "$OUTPUT_FILE" "$MARKER" "$COMMENT_PREFIX" "$ISSUE_NUMBER" "$DRAFT_MARKER" "$QUESTIONS_LINE")"
43
48
  echo "outcome=$outcome" >> "$GITHUB_OUTPUT"
44
49
  echo "valid=$([ "$outcome" != 'invalid' ] && echo true || echo false)" >> "$GITHUB_OUTPUT"
45
50
 
@@ -9,13 +9,17 @@ marker="$2"
9
9
  comment_prefix="$3"
10
10
  issue_number="$4"
11
11
  draft_marker="$5"
12
+ # The sentence the prompt opens every batch of questions with, and the mark it leaves in the draft
13
+ # wherever a question is still open.
14
+ questions_line="${6:-I have some questions about this issue}"
15
+ pending_mark="${7:-_pending see questions below_}"
12
16
 
13
17
  if [ ! -f "$output_file" ] || ! jq -e '.items | arrays' "$output_file" >/dev/null 2>&1; then
14
18
  echo invalid
15
19
  exit 0
16
20
  fi
17
21
 
18
- jq -r --arg marker "$marker" --arg prefix "$comment_prefix" --arg issue "$issue_number" --arg draft_marker "$draft_marker" '
22
+ jq -r --arg marker "$marker" --arg prefix "$comment_prefix" --arg issue "$issue_number" --arg draft_marker "$draft_marker" --arg questions_line "$questions_line" --arg pending_mark "$pending_mark" '
19
23
  def has_replacement_body:
20
24
  any(.items[]; .type == "update_issue" and
21
25
  (.item_number == null or (.item_number | tostring) == $issue) and
@@ -32,6 +36,18 @@ jq -r --arg marker "$marker" --arg prefix "$comment_prefix" --arg issue "$issue_
32
36
  (.body | type == "string") and
33
37
  (.body | contains($draft_marker)));
34
38
 
39
+ # The batched questions, by the sentence that opens them. It decides the outcome on its own:
40
+ # Bots #561, #871 and Numa #821 asked, left a draft without the draft marker, were read as
41
+ # complete, and implement started two seconds later on a story nobody had answered.
42
+ def asks_questions:
43
+ any(.items[]; .type == "add_comment" and (.item_number | tostring) == $issue and
44
+ (.body | type == "string") and (.body | contains($questions_line)));
45
+
46
+ def has_pending_marks:
47
+ any(.items[]; .type == "update_issue" and
48
+ (.item_number == null or (.item_number | tostring) == $issue) and
49
+ (.body | type == "string") and (.body | contains($pending_mark)));
50
+
35
51
  # A split writes children, which are the only items allowed to target something other than
36
52
  # the source issue: they do not exist yet, so they carry no number at all.
37
53
  def child_count:
@@ -108,7 +124,9 @@ jq -r --arg marker "$marker" --arg prefix "$comment_prefix" --arg issue "$issue_
108
124
  # demanding a draft from both.
109
125
  if child_count >= 2 and has_replacement_body and has_only_split_items and root_pr_count <= 1 then "split"
110
126
  elif child_count > 0 then "invalid"
111
- elif has_replacement_body and (has_draft_body | not) and has_only_source_items then "complete"
127
+ elif asks_questions and ((has_update | not) or has_replacement_body) and has_only_source_items
128
+ and (reported_incomplete | not) then "questions"
129
+ elif has_replacement_body and (has_draft_body | not) and (has_pending_marks | not) and has_only_source_items then "complete"
112
130
  elif has_clarification and ((has_update | not) or has_draft_body) and has_only_source_items
113
131
  and (reported_incomplete | not) then "questions"
114
132
  else "invalid"
@@ -1,35 +1,35 @@
1
- # Managed by @plainconceptsplatform/workflows. Source: loops/actions/validate-review-output/action.yml. Update with workflows update --force; consumer edits may be overwritten.
2
- name: Validate review output
3
- description: Verify an apply-review outcome accounts for every unresolved review thread.
4
- inputs:
5
- output-file:
6
- description: Path to agent_output.json.
7
- required: true
8
- pr-number:
9
- description: Pull request receiving the review feedback.
10
- required: true
11
- review-threads-file:
12
- description: JSON file containing unresolved review threads.
13
- required: true
14
- outputs:
15
- valid:
16
- description: Whether the agent output is a valid review outcome.
17
- value: ${{ steps.validate.outputs.valid }}
18
- outcome:
19
- description: "Deterministic review outcome: implemented, already-satisfied, needs-human, or invalid."
20
- value: ${{ steps.validate.outputs.outcome }}
21
- runs:
22
- using: composite
23
- steps:
24
- - name: Validate review outcome
25
- id: validate
26
- shell: bash
27
- env:
28
- OUTPUT_FILE: ${{ inputs.output-file }}
29
- PR_NUMBER: ${{ inputs.pr-number }}
30
- REVIEW_THREADS_FILE: ${{ inputs.review-threads-file }}
31
- run: |
32
- set -euo pipefail
33
- outcome="$(bash "${{ github.action_path }}/validate-review-output.sh" "$OUTPUT_FILE" "$PR_NUMBER" "$REVIEW_THREADS_FILE")"
34
- echo "outcome=$outcome" >> "$GITHUB_OUTPUT"
35
- echo "valid=$([ "$outcome" != 'invalid' ] && echo true || echo false)" >> "$GITHUB_OUTPUT"
1
+ # Managed by @plainconceptsplatform/workflows. Source: loops/actions/validate-review-output/action.yml. Update with workflows update --force; consumer edits may be overwritten.
2
+ name: Validate review output
3
+ description: Verify an apply-review outcome accounts for every unresolved review thread.
4
+ inputs:
5
+ output-file:
6
+ description: Path to agent_output.json.
7
+ required: true
8
+ pr-number:
9
+ description: Pull request receiving the review feedback.
10
+ required: true
11
+ review-threads-file:
12
+ description: JSON file containing unresolved review threads.
13
+ required: true
14
+ outputs:
15
+ valid:
16
+ description: Whether the agent output is a valid review outcome.
17
+ value: ${{ steps.validate.outputs.valid }}
18
+ outcome:
19
+ description: "Deterministic review outcome: implemented, already-satisfied, needs-human, or invalid."
20
+ value: ${{ steps.validate.outputs.outcome }}
21
+ runs:
22
+ using: composite
23
+ steps:
24
+ - name: Validate review outcome
25
+ id: validate
26
+ shell: bash
27
+ env:
28
+ OUTPUT_FILE: ${{ inputs.output-file }}
29
+ PR_NUMBER: ${{ inputs.pr-number }}
30
+ REVIEW_THREADS_FILE: ${{ inputs.review-threads-file }}
31
+ run: |
32
+ set -euo pipefail
33
+ outcome="$(bash "${{ github.action_path }}/validate-review-output.sh" "$OUTPUT_FILE" "$PR_NUMBER" "$REVIEW_THREADS_FILE")"
34
+ echo "outcome=$outcome" >> "$GITHUB_OUTPUT"
35
+ echo "valid=$([ "$outcome" != 'invalid' ] && echo true || echo false)" >> "$GITHUB_OUTPUT"