@plainconceptsplatform/workflows 0.5.1 → 0.16.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/README.md +105 -88
  2. package/dist/catalog-installation.d.ts +67 -0
  3. package/dist/catalog-installation.js +485 -0
  4. package/dist/catalog-listing.d.ts +13 -0
  5. package/dist/catalog-listing.js +70 -0
  6. package/dist/index.d.ts +2 -0
  7. package/dist/index.js +273 -0
  8. package/dist/package-baseline.d.ts +25 -0
  9. package/dist/package-baseline.js +138 -0
  10. package/dist/repository-inspection.d.ts +23 -0
  11. package/dist/repository-inspection.js +113 -0
  12. package/dist/route-processing.d.ts +4 -0
  13. package/dist/route-processing.js +80 -0
  14. package/dist/stack-defaults.d.ts +11 -0
  15. package/dist/stack-defaults.js +110 -0
  16. package/dist/tui.d.ts +25 -0
  17. package/dist/tui.js +271 -0
  18. package/dist/worker-env.d.ts +46 -0
  19. package/dist/worker-env.js +179 -0
  20. package/dist/workflow-catalog.d.ts +27 -0
  21. package/dist/workflow-catalog.js +57 -0
  22. package/loops/actions/add-issue-labels/action.yml +22 -2
  23. package/loops/actions/apply-agent-bundle/apply-bundle.sh +14 -1
  24. package/loops/actions/audit-close/action.yml +180 -128
  25. package/loops/actions/classify-route/action.yml +7 -0
  26. package/loops/actions/classify-route/classify-route.sh +28 -7
  27. package/loops/actions/cleanup-artifacts/action.yml +38 -12
  28. package/loops/actions/housekeeping/action.yml +251 -0
  29. package/loops/actions/identify-gate-subject/action.yml +3 -1
  30. package/loops/actions/merge-agent-pr/action.yml +13 -0
  31. package/loops/actions/remove-issue-labels/action.yml +4 -2
  32. package/loops/actions/report-workflow-errors/action.yml +385 -0
  33. package/loops/actions/validate-merge-gate-output/validate-merge-gate-output.sh +13 -1
  34. package/loops/actions/validate-refine-output/validate-refine-output.sh +18 -2
  35. package/loops/actions/validate-triage-output/action.yml +1 -1
  36. package/loops/actions/validate-triage-output/validate-triage-output.sh +9 -5
  37. package/loops/actions/verify-composite-actions/verify-composite-actions.sh +53 -0
  38. package/loops/actions/verify-refine-output/verify-refine-output.sh +20 -0
  39. package/loops/actions/verify-route-matrix/verify-route-matrix.sh +1017 -56
  40. package/loops/scripts/compile-agent-workflows.mjs +96 -1
  41. package/loops/scripts/merge-changelog.mjs +76 -0
  42. package/loops/templates/agentics/actionlint.yaml +13 -0
  43. package/loops/templates/agentics/agentics-checks.yml +203 -86
  44. package/loops/templates/agentics/agentics-error-report.yml +97 -0
  45. package/loops/templates/ci/app-ci-dotnet-next.yml +64 -2
  46. package/loops/templates/ci/app-ci-node-monorepo.yml +52 -0
  47. package/loops/templates/opencode/opencode.ci.json +3 -3
  48. package/loops/templates/opencode/opencode.ci.json.md +4 -1
  49. package/loops/workflows/agent-apply-review.md +452 -465
  50. package/loops/workflows/agent-audit.md +201 -213
  51. package/loops/workflows/agent-implement.md +616 -538
  52. package/loops/workflows/agent-merge-gate.md +830 -730
  53. package/loops/workflows/agent-refine.md +599 -609
  54. package/loops/workflows/agent-release.md +244 -258
  55. package/loops/workflows/agent-triage.md +476 -447
  56. package/loops/workflows/authorize-bot-work.yml +26 -6
  57. package/loops/workflows/work-router.yml +1185 -862
  58. package/package.json +9 -8
  59. package/loops/actions/stale-recovery/action.yml +0 -288
  60. package/loops/actions/update-changelog/action.yml +0 -113
@@ -0,0 +1,57 @@
1
+ export const routeNames = [
2
+ "refine",
3
+ "implement",
4
+ "triage",
5
+ "apply-review",
6
+ "merge-gate",
7
+ "audit",
8
+ "release",
9
+ ];
10
+ export const workflowRoutes = [
11
+ { name: "refine", worker: "agent-refine.md", description: "Refines an issue into a user story, on a first pass or after the author has answered the bot's questions.", defaultEnabled: true },
12
+ { name: "implement", worker: "agent-implement.md", description: "Implements an issue and opens a pull request. Stops there: the merge decision belongs to the merge gate.", defaultEnabled: true },
13
+ { name: "triage", worker: "agent-triage.md", description: "Triages issues opened from outside the organisation: runs 10 checks (template, security, size, danger, duplicates, clarity, reproducibility, acceptance, cross-cutting, product scope). Loops up to 3 rounds. Passes to refine, asks the author for more, parks out-of-scope work open for a maintainer, or closes what is genuinely rejected.", defaultEnabled: true },
14
+ { name: "apply-review", worker: "agent-apply-review.md", description: "Applies reviewer feedback to an open pull request the bot authored, then pushes the fixes to the same branch.", defaultEnabled: true },
15
+ { name: "merge-gate", worker: "agent-merge-gate.md", description: "Decides what happens to a bot-authored pull request once CI has reported: merge, hand to a human, or fix CI.", defaultEnabled: true },
16
+ { name: "audit", worker: "agent-audit.md", description: "Read-only repository audit. Finds 5-7 problems, scores each 1-10, and files a single issue that Refine then sizes and splits.", defaultEnabled: true },
17
+ { name: "release", worker: "agent-release.md", description: "Writes release notes from the commit log, then a deterministic job bumps the version, tags, and publishes a GitHub Release. Manual dispatch only (operation=release).", defaultEnabled: true },
18
+ ];
19
+ export const packageOwnedTargets = [
20
+ ".github/actions",
21
+ ".github/workflows/agent-*.md",
22
+ ".github/workflows/shared/platform-defaults.md",
23
+ ".github/workflows/shared/opencode-ci.md",
24
+ ".github/workflows/work-router.yml",
25
+ "scripts/compile-agent-workflows.mjs",
26
+ "opencode.ci.json",
27
+ ];
28
+ export const mandatoryFiles = [
29
+ { source: "templates/opencode/opencode.ci.json", target: "opencode.ci.json" },
30
+ { source: "scripts/compile-agent-workflows.mjs", target: "scripts/compile-agent-workflows.mjs" },
31
+ ];
32
+ export const generatedConsumerTargets = [
33
+ ".github/workflows/agent-*.lock.yml",
34
+ ".github/aw/actions-lock.json",
35
+ ];
36
+ export const templateNames = [
37
+ "agentics-checks",
38
+ "agentics-error-report",
39
+ "agentics-maintenance",
40
+ "app-ci-dotnet-next",
41
+ "app-ci-node-monorepo",
42
+ "bug-report",
43
+ "feature-request",
44
+ "github-release",
45
+ "opencode.ci.json",
46
+ ];
47
+ export const catalogTemplates = [
48
+ { name: "agentics-checks", file: "agentics-checks.yml", description: "Agentics checks: verifies generated agent lockfiles, actionlint, and compile on PRs touching workflow files." },
49
+ { name: "agentics-error-report", file: "agentics-error-report.yml", description: "Daily error report: classifies the last day of failures in the workflows this package ships and files them upstream so the package gets fixed. Deterministic, no model; sends only package-owned names, conclusions and counts, and refuses to file anything a leak scanner flags." },
50
+ { name: "agentics-maintenance", file: "agentics-maintenance.yml", description: "Agentic maintenance: scheduled daily maintenance workflow for keeping workflows and actions up to date." },
51
+ { name: "app-ci-dotnet-next", file: "app-ci-dotnet-next.yml", description: "App CI pipeline for a .NET + Next.js monorepo: build, test, and lint on PRs and schedule." },
52
+ { name: "app-ci-node-monorepo", file: "app-ci-node-monorepo.yml", description: "App CI pipeline for a Node monorepo: build, test, and lint on PRs and schedule." },
53
+ { name: "bug-report", file: "bug_report.yml", description: "Bug report issue template: what happened, repro steps, expected behavior, acceptance criteria, environment, logs.", target: ".github/ISSUE_TEMPLATE/bug_report.yml" },
54
+ { name: "feature-request", file: "feature_request.yml", description: "Feature request issue template scoped to small, well-scoped improvements (Small/Medium only; large work belongs in a planning issue).", target: ".github/ISSUE_TEMPLATE/feature_request.yml" },
55
+ { name: "github-release", file: "github-release.yml", description: "Publishes or updates a GitHub Release with generated notes whenever a v* tag is pushed." },
56
+ { name: "opencode.ci.json", file: "opencode.ci.json", description: "Standalone OpenCode CI config: plainconcepts provider, GLM model registration, ci-workflow-agent, and LSP defaults for consumer repositories." },
57
+ ];
@@ -9,7 +9,7 @@ inputs:
9
9
  description: Issue or pull request number.
10
10
  required: true
11
11
  labels:
12
- description: Newline-delimited labels to add.
12
+ description: Labels to add, one per line. A comma-separated list is accepted as well.
13
13
  required: true
14
14
  runs:
15
15
  using: composite
@@ -22,7 +22,27 @@ runs:
22
22
  with:
23
23
  github-token: ${{ inputs.token }}
24
24
  script: |
25
- const labels = process.env.LABELS.split(/\r?\n/).filter(Boolean);
25
+ const labels = process.env.LABELS.split(/\r?\n|,/).map((label) => label.trim()).filter(Boolean);
26
+ if (labels.length === 0) {
27
+ core.notice('No labels to add.');
28
+ return;
29
+ }
30
+
31
+ // Create what does not exist yet, so shipping a new label in the package does not
32
+ // require a person to go and make it in every repository first. Its sibling
33
+ // remove-issue-labels has swallowed 404 for a long time; this one had no guard at all,
34
+ // so a label the package introduced would fail the step in every consumer until
35
+ // somebody created it by hand. 422 is "already exists", which is the happy path on
36
+ // every run after the first.
37
+ for (const name of labels) {
38
+ try {
39
+ await github.rest.issues.createLabel({ ...context.repo, name, color: 'ededed' });
40
+ core.notice(`Created the ${name} label.`);
41
+ } catch (error) {
42
+ if (error.status !== 422) throw error;
43
+ }
44
+ }
45
+
26
46
  await github.rest.issues.addLabels({
27
47
  ...context.repo,
28
48
  issue_number: Number(process.env.ISSUE_NUMBER),
@@ -40,7 +40,20 @@ git fetch --no-tags "$BUNDLE_FILE" "$BUNDLE_REF"
40
40
  [ "$(git cat-file -t "$BUNDLE_TIP")" = commit ] || fail "Git bundle ref must point directly to a commit"
41
41
  [ "$(git rev-parse FETCH_HEAD)" = "$BUNDLE_TIP" ] || fail "Fetched bundle commit did not match listed bundle ref"
42
42
 
43
- git merge-base --is-ancestor "$TARGET_TIP" "$BUNDLE_TIP" || fail "Git bundle cannot fast-forward $TARGET_BRANCH"
43
+ # Fast-forward only, deliberately: a push that is not one would discard somebody's commits.
44
+ #
45
+ # "Cannot fast-forward" said nothing about why, and there are two reasons: the agent
46
+ # rewrote history (a rebase, an amend, a reset), or somebody pushed to the branch while it
47
+ # was working. Both mean the same thing to git, that the branch carries commits the bundle
48
+ # does not, so the applier cannot tell them apart and does not try. It prints the commits
49
+ # that are missing instead: their authors and dates answer the question immediately.
50
+ if ! git merge-base --is-ancestor "$TARGET_TIP" "$BUNDLE_TIP"; then
51
+ echo "::error::Nothing was applied. $TARGET_BRANCH is at $TARGET_TIP and the bundle is at $BUNDLE_TIP, which does not contain it, so pushing would drop the commits below. Either the agent rewrote history, which this push refuses, or the branch moved while it was working, in which case nothing is lost and the gate only needs running again on the current head."
52
+ echo "On $TARGET_BRANCH but missing from the bundle:"
53
+ git --no-pager log --format=" %h %ad %an %s" --date=short "$BUNDLE_TIP..$TARGET_TIP" |
54
+ head -20 || true
55
+ exit 1
56
+ fi
44
57
  git switch --detach "$TARGET_TIP"
45
58
  git merge --ff-only "$BUNDLE_TIP"
46
59
  git push origin "HEAD:refs/heads/$TARGET_BRANCH"
@@ -1,128 +1,180 @@
1
- # Managed by @plainconceptsplatform/workflows. Source: loops/actions/audit-close/action.yml. Update with workflows update --force; consumer edits may be overwritten.
2
- name: Close audit reports
3
- description: Close open audit reports once every issue they reference is closed.
4
- inputs:
5
- token:
6
- description: GitHub token with issues write access.
7
- required: true
8
- runs:
9
- using: composite
10
- steps:
11
- - name: Close every report whose referenced issues are all closed
12
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
13
- with:
14
- github-token: ${{ inputs.token }}
15
- script: |
16
- const reports = await github.paginate(github.rest.issues.listForRepo, {
17
- ...context.repo,
18
- state: 'open',
19
- labels: 'audit',
20
- per_page: 100,
21
- });
22
-
23
- if (reports.length === 0) {
24
- core.info('No open audit reports.');
25
- return;
26
- }
27
-
28
- // Skip `owner/repo#12` and `abc#12`: only a bare `#12` references this repository.
29
- const referencedIssues = (body) => {
30
- const matches = (body ?? '').matchAll(/(?:^|[^A-Za-z0-9_/])#(\d+)/g);
31
- return [...new Set([...matches].map((match) => Number(match[1])))];
32
- };
33
-
34
- let closed = 0;
35
- let keptOpen = 0;
36
-
37
- for (const report of reports.sort((a, b) => a.number - b.number)) {
38
- const refs = referencedIssues(report.body).filter((n) => n !== report.number);
39
-
40
- const stillOpen = [];
41
- const unknown = [];
42
- let closedRefs = 0;
43
- let resolved = 0;
44
-
45
- for (const ref of refs) {
46
- let state;
47
-
48
- try {
49
- ({ data: { state } } = await github.rest.issues.get({
50
- ...context.repo,
51
- issue_number: ref,
52
- }));
53
- } catch (error) {
54
- if (error.status === 404) {
55
- core.info(`#${report.number}: ignoring #${ref}, not an issue in this repository`);
56
- continue;
57
- }
58
- unknown.push(ref);
59
- resolved += 1;
60
- continue;
61
- }
62
-
63
- resolved += 1;
64
- if (state === 'closed') closedRefs += 1;
65
- else stillOpen.push(ref);
66
- }
67
-
68
- if (stillOpen.length > 0) {
69
- core.info(`#${report.number}: kept open, still-open references: ${stillOpen.join(', ')}`);
70
- keptOpen += 1;
71
- continue;
72
- }
73
-
74
- if (unknown.length > 0) {
75
- core.info(`#${report.number}: kept open, undetermined references: ${unknown.join(', ')}`);
76
- keptOpen += 1;
77
- continue;
78
- }
79
-
80
- const reason = resolved === 0
81
- ? 'This report references no issues.'
82
- : `All ${closedRefs} referenced issue(s) are closed.`;
83
-
84
- // Do not close if there are open PRs that close this issue.
85
- // The audit report may have spawned implement workers whose PRs
86
- // are still under review — closing the report prematurely is misleading.
87
- const openPRs = await github.paginate(github.rest.pulls.list, {
88
- ...context.repo,
89
- state: 'open',
90
- per_page: 100,
91
- });
92
-
93
- const closesThisReport = openPRs.some((pr) => {
94
- const body = pr.body ?? '';
95
- const patterns = [
96
- new RegExp(`(?:^|[^A-Za-z0-9_/])#(?:closes?|fixes?|resolves?|closed?)\\s+#${report.number}\\b`, 'i'),
97
- new RegExp(`(?:^|[^A-Za-z0-9_/])#${report.number}\\b`),
98
- ];
99
- return patterns.some((p) => p.test(body));
100
- });
101
-
102
- if (closesThisReport) {
103
- core.info(`#${report.number}: kept open, has open PR(s) that close it.`);
104
- keptOpen += 1;
105
- continue;
106
- }
107
-
108
- await github.rest.issues.createComment({
109
- ...context.repo,
110
- issue_number: report.number,
111
- body: `Closed automatically by \`Work Router: audit-close\`. ${reason}`,
112
- });
113
-
114
- await github.rest.issues.update({
115
- ...context.repo,
116
- issue_number: report.number,
117
- state: 'closed',
118
- state_reason: 'completed',
119
- });
120
-
121
- core.info(`#${report.number}: closed. ${reason}`);
122
- closed += 1;
123
- }
124
-
125
- await core.summary
126
- .addHeading('Audit close', 2)
127
- .addList([`Reports closed: ${closed}`, `Reports left open: ${keptOpen}`])
128
- .write();
1
+ # Managed by @plainconceptsplatform/workflows. Source: loops/actions/audit-close/action.yml. Update with workflows update --force; consumer edits may be overwritten.
2
+ name: Close audit reports
3
+ description: Close open audit reports once every issue they reference is closed.
4
+ inputs:
5
+ token:
6
+ description: GitHub token with issues write access.
7
+ required: true
8
+ stale-after-days:
9
+ description: >-
10
+ How long a report may sit open before it is marked stale-audit. A stale report stops
11
+ counting against the audit's backpressure and is listed for a person instead.
12
+ required: false
13
+ default: '14'
14
+ runs:
15
+ using: composite
16
+ steps:
17
+ - name: Close every report whose referenced issues are all closed
18
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
19
+ env:
20
+ STALE_AFTER_DAYS: ${{ inputs.stale-after-days }}
21
+ with:
22
+ github-token: ${{ inputs.token }}
23
+ script: |
24
+ const reports = await github.paginate(github.rest.issues.listForRepo, {
25
+ ...context.repo,
26
+ state: 'open',
27
+ labels: 'audit',
28
+ per_page: 100,
29
+ });
30
+
31
+ if (reports.length === 0) {
32
+ core.info('No open audit reports.');
33
+ return;
34
+ }
35
+
36
+ const STALE_LABEL = 'stale-audit';
37
+ const staleAfterMs = Number(process.env.STALE_AFTER_DAYS) * 86400 * 1000;
38
+
39
+ // One list for every report. This used to be fetched inside the loop, so a repository
40
+ // with three open reports paid for three full pull-request listings each night.
41
+ const openPRs = await github.paginate(github.rest.pulls.list, {
42
+ ...context.repo,
43
+ state: 'open',
44
+ per_page: 100,
45
+ });
46
+
47
+ // Skip `owner/repo#12` and `abc#12`: only a bare `#12` references this repository.
48
+ const referencedIssues = (body) => {
49
+ const matches = (body ?? '').matchAll(/(?:^|[^A-Za-z0-9_/])#(\d+)/g);
50
+ return [...new Set([...matches].map((match) => Number(match[1])))];
51
+ };
52
+
53
+ // Only a real closing keyword counts. There were two patterns here and both were
54
+ // wrong in opposite directions: the first required the literal text `#closes #12`,
55
+ // which nothing has ever written, so it matched nothing; the second matched a bare
56
+ // `#12` anywhere in any open pull request body, so one passing mention of the report
57
+ // pinned it open for as long as that pull request stayed open. A report referenced
58
+ // by a merged-and-closed chain then never closed, and the three of them silently
59
+ // used up the audit backpressure.
60
+ const closesReport = (number) => {
61
+ const pattern = new RegExp(`(?:clos(?:e|es|ed)|fix(?:es|ed)?|resolves?)\\s+#${number}\\b`, 'i');
62
+ return openPRs.some((pr) => pattern.test(pr.body ?? ''));
63
+ };
64
+
65
+ let closed = 0;
66
+ let keptOpen = 0;
67
+ let stale = 0;
68
+
69
+ const markStale = async (report, why) => {
70
+ if (report.labels.some((l) => (typeof l === 'string' ? l : l.name) === STALE_LABEL)) return;
71
+ if (Date.now() - new Date(report.created_at).getTime() < staleAfterMs) return;
72
+ // Creating the label is idempotent; 422 means it already exists.
73
+ await github.rest.issues.createLabel({
74
+ ...context.repo,
75
+ name: STALE_LABEL,
76
+ color: 'd4c5f9',
77
+ description: 'Audit report nobody actioned; no longer blocks the next audit',
78
+ }).catch((error) => { if (error.status !== 422) throw error; });
79
+ await github.rest.issues.addLabels({
80
+ ...context.repo, issue_number: report.number, labels: [STALE_LABEL],
81
+ });
82
+ await github.rest.issues.createComment({
83
+ ...context.repo,
84
+ issue_number: report.number,
85
+ body: `Labelled \`${STALE_LABEL}\` after ${process.env.STALE_AFTER_DAYS} days: ${why} This report no longer counts against the audit backpressure, so the next audit can run, and it is listed in the housekeeping digest for a person to pick up or close.`,
86
+ });
87
+ core.info(`#${report.number}: marked ${STALE_LABEL}. ${why}`);
88
+ stale += 1;
89
+ };
90
+
91
+ for (const report of reports.sort((a, b) => a.number - b.number)) {
92
+ const refs = referencedIssues(report.body).filter((n) => n !== report.number);
93
+
94
+ const stillOpen = [];
95
+ const unknown = [];
96
+ let closedRefs = 0;
97
+ let resolved = 0;
98
+
99
+ for (const ref of refs) {
100
+ let state;
101
+
102
+ try {
103
+ ({ data: { state } } = await github.rest.issues.get({
104
+ ...context.repo,
105
+ issue_number: ref,
106
+ }));
107
+ } catch (error) {
108
+ if (error.status === 404) {
109
+ core.info(`#${report.number}: ignoring #${ref}, not an issue in this repository`);
110
+ continue;
111
+ }
112
+ unknown.push(ref);
113
+ resolved += 1;
114
+ continue;
115
+ }
116
+
117
+ resolved += 1;
118
+ if (state === 'closed') closedRefs += 1;
119
+ else stillOpen.push(ref);
120
+ }
121
+
122
+ // A report referencing nothing is a report nothing was done about. Refine turns a
123
+ // report into children and writes their numbers into it; a body with no bare `#N`
124
+ // means refine never ran, or ran and produced nothing. Closing that as `completed`
125
+ // used to throw the finding away silently and was the single most common way an
126
+ // audit ended: every finding closed, nothing implemented. Leave it open, and once
127
+ // it is past its budget mark it stale so it stops blocking the next audit.
128
+ if (resolved === 0) {
129
+ core.info(`#${report.number}: kept open, references no issues, so no work was done on it`);
130
+ keptOpen += 1;
131
+ await markStale(report, 'it references no issues, so nothing was ever implemented from it.');
132
+ continue;
133
+ }
134
+
135
+ if (stillOpen.length > 0) {
136
+ core.info(`#${report.number}: kept open, still-open references: ${stillOpen.join(', ')}`);
137
+ keptOpen += 1;
138
+ await markStale(report, `its children are still open (${stillOpen.map((n) => `#${n}`).join(', ')}).`);
139
+ continue;
140
+ }
141
+
142
+ if (unknown.length > 0) {
143
+ core.info(`#${report.number}: kept open, undetermined references: ${unknown.join(', ')}`);
144
+ keptOpen += 1;
145
+ continue;
146
+ }
147
+
148
+ // The report may have spawned implement workers whose pull requests are still under
149
+ // review; closing it while they are open would be misleading.
150
+ if (closesReport(report.number)) {
151
+ core.info(`#${report.number}: kept open, an open pull request closes it`);
152
+ keptOpen += 1;
153
+ continue;
154
+ }
155
+
156
+ await github.rest.issues.createComment({
157
+ ...context.repo,
158
+ issue_number: report.number,
159
+ body: `Closed automatically by \`Work Router: audit-close\`. All ${closedRefs} referenced issue(s) are closed.`,
160
+ });
161
+
162
+ await github.rest.issues.update({
163
+ ...context.repo,
164
+ issue_number: report.number,
165
+ state: 'closed',
166
+ state_reason: 'completed',
167
+ });
168
+
169
+ core.info(`#${report.number}: closed. All ${closedRefs} referenced issue(s) are closed.`);
170
+ closed += 1;
171
+ }
172
+
173
+ await core.summary
174
+ .addHeading('Audit close', 2)
175
+ .addList([
176
+ `Reports closed: ${closed}`,
177
+ `Reports left open: ${keptOpen}`,
178
+ `Reports marked stale: ${stale}`,
179
+ ])
180
+ .write();
@@ -24,6 +24,12 @@ outputs:
24
24
  ci-run-id:
25
25
  description: Workflow run ID of that CI run.
26
26
  value: ${{ steps.classify.outputs.ci-run-id }}
27
+ merge-gate-attempts:
28
+ description: Failed merge-gate attempts already made against this CI verdict.
29
+ value: ${{ steps.classify.outputs.merge-gate-attempts }}
30
+ implement-attempts:
31
+ description: Implement runs already made for this issue that died before producing an answer.
32
+ value: ${{ steps.classify.outputs.implement-attempts }}
27
33
  refine-mode:
28
34
  description: first or rerefine.
29
35
  value: ${{ steps.classify.outputs.refine-mode }}
@@ -60,6 +66,7 @@ runs:
60
66
  INPUT_MODE: ${{ github.event.inputs.mode }}
61
67
  INPUT_CI_CONCLUSION: ${{ github.event.inputs.ci-conclusion }}
62
68
  INPUT_CI_RUN_ID: ${{ github.event.inputs.ci-run-id }}
69
+ INPUT_ATTEMPTS_SO_FAR: ${{ github.event.inputs.attempts_so_far }}
63
70
  INPUT_TRIGGER_KIND: ${{ github.event.inputs.trigger-kind }}
64
71
  run: |
65
72
  set -euo pipefail
@@ -8,10 +8,15 @@
8
8
 
9
9
  set -euo pipefail
10
10
 
11
- readonly AUDIT_CRON="17 1 * * 1"
11
+ # The audit slot is per repository, so it comes from the router's own env: block rather than
12
+ # from here. The fallback is the package default and matches the cron the router ships; a run
13
+ # fired on a cron this file does not know classifies to no route and dies silently, so the
14
+ # route matrix asserts the two still agree.
15
+ readonly AUDIT_CRON="${AUDIT_CRON:-17 1 * * 1}"
12
16
  readonly AUDIT_CLOSE_CRON="43 3 * * *"
13
17
  readonly CLEANUP_ARTIFACTS_CRON="0 6 * * *"
14
18
  readonly RECONCILE_BOT_PR_RUNS_CRON="17 * * * *"
19
+ readonly HOUSEKEEPING_CRON="23 */6 * * *"
15
20
 
16
21
  has_label() {
17
22
  jq -e --arg name "$1" 'index($name)' >/dev/null 2>&1 <<<"${ISSUE_LABELS:-[]}"
@@ -23,7 +28,7 @@ is_issue_number() {
23
28
 
24
29
  classify_route() {
25
30
  local route="none" error=""
26
- local issue_number="" pr_number="" ci_conclusion="" ci_run_id=""
31
+ local issue_number="" pr_number="" ci_conclusion="" ci_run_id="" merge_gate_attempts="0" implement_attempts="0"
27
32
  local refine_mode="" triage_mode="" trigger_kind=""
28
33
 
29
34
  case "${EVENT:-}" in
@@ -100,8 +105,14 @@ classify_route() {
100
105
 
101
106
  issue_comment)
102
107
  if [ "${COMMENT_ON_PR:-false}" = "true" ]; then
103
- route="apply-review"
104
- pr_number="${EVENT_ISSUE_NUMBER:-}"
108
+ if [ "${COMMENT_SENDER_TYPE:-}" = "Bot" ]; then
109
+ # Every App-token comment on a pull request is an issue_comment event, and the
110
+ # workers comment on pull requests they own. None of that is reviewer feedback.
111
+ error="comment authored by a bot"
112
+ else
113
+ route="apply-review"
114
+ pr_number="${EVENT_ISSUE_NUMBER:-}"
115
+ fi
105
116
  elif [ "${ISSUE_STATE:-}" = "closed" ]; then
106
117
  # A closing comment on a refine-labelled issue used to start a full re-refine, which
107
118
  # held a runner for 35 minutes and filed split children under an already-shut parent.
@@ -144,8 +155,10 @@ classify_route() {
144
155
 
145
156
  workflow_run)
146
157
  # Only a FAILED CI run on an attached pull request auto-dispatches the gate. A green
147
- # run reaches merge through app-ci's dispatch-merge-gate and the reconcile belt, so
148
- # routing success here would double-fire the gate for every passing pull request.
158
+ # run reaches the gate through the consumer CI's dispatch-merge-gate job and the
159
+ # reconcile belt, so routing success here would double-fire the gate for every passing
160
+ # pull request. GitHub delivers workflow_run only for CI runs whose actor is a human;
161
+ # a bot pull request's CI never arrives here at all, and the same two paths cover it.
149
162
  if [ "${RUN_CONCLUSION:-}" != "failure" ]; then
150
163
  error="CI concluded '${RUN_CONCLUSION:-}'; the gate auto-triggers only on failure"
151
164
  elif is_issue_number "${RUN_PR_NUMBER:-}"; then
@@ -165,6 +178,7 @@ classify_route() {
165
178
  "$AUDIT_CLOSE_CRON") route="audit-close" ;;
166
179
  "$CLEANUP_ARTIFACTS_CRON") route="cleanup-artifacts" ;;
167
180
  "$RECONCILE_BOT_PR_RUNS_CRON") route="reconcile-bot-pr-runs" ;;
181
+ "$HOUSEKEEPING_CRON") route="housekeeping" ;;
168
182
  *) error="no route for cron '${SCHEDULE:-}'" ;;
169
183
  esac
170
184
  ;;
@@ -178,6 +192,10 @@ classify_route() {
178
192
  issue_number="${INPUT_ISSUE_NUMBER}"
179
193
  if [ "$OPERATION" = "refine" ]; then
180
194
  refine_mode="${INPUT_MODE:-first}"
195
+ else
196
+ # The implement worker re-dispatches itself when a run dies before doing any work,
197
+ # and carries the count so the budget is bounded.
198
+ implement_attempts="${INPUT_ATTEMPTS_SO_FAR:-0}"
181
199
  fi
182
200
  else
183
201
  error="operation '${OPERATION}' needs a positive issue-number, got '${INPUT_ISSUE_NUMBER:-}'"
@@ -206,6 +224,7 @@ classify_route() {
206
224
  pr_number="${INPUT_PR_NUMBER}"
207
225
  ci_conclusion="${INPUT_CI_CONCLUSION:-}"
208
226
  ci_run_id="${INPUT_CI_RUN_ID:-}"
227
+ merge_gate_attempts="${INPUT_ATTEMPTS_SO_FAR:-0}"
209
228
  else
210
229
  error="operation 'merge-gate' needs a positive pr-number, got '${INPUT_PR_NUMBER:-}'"
211
230
  fi
@@ -214,7 +233,7 @@ classify_route() {
214
233
  route="${OPERATION}"
215
234
  trigger_kind="${INPUT_TRIGGER_KIND:-manual}"
216
235
  ;;
217
- audit-close | cleanup-artifacts | reconcile-bot-pr-runs | validate)
236
+ audit-close | cleanup-artifacts | reconcile-bot-pr-runs | housekeeping | validate)
218
237
  route="${OPERATION}"
219
238
  ;;
220
239
  release)
@@ -237,6 +256,8 @@ issue-number=${issue_number}
237
256
  pr-number=${pr_number}
238
257
  ci-conclusion=${ci_conclusion}
239
258
  ci-run-id=${ci_run_id}
259
+ merge-gate-attempts=${merge_gate_attempts}
260
+ implement-attempts=${implement_attempts}
240
261
  refine-mode=${refine_mode}
241
262
  triage-mode=${triage_mode}
242
263
  trigger-kind=${trigger_kind}
@@ -36,17 +36,32 @@ runs:
36
36
  const failed = [];
37
37
 
38
38
  for (const artifact of expired) {
39
- try {
40
- await github.rest.actions.deleteArtifact({
41
- ...context.repo,
42
- artifact_id: artifact.id,
43
- });
39
+ // GitHub answers a delete with a 5xx now and then. Three tries, because the
40
+ // alternative is a red daily run over one artifact that tomorrow's run would
41
+ // have removed anyway.
42
+ let lastError;
43
+ for (let attempt = 1; attempt <= 3; attempt += 1) {
44
+ try {
45
+ await github.rest.actions.deleteArtifact({
46
+ ...context.repo,
47
+ artifact_id: artifact.id,
48
+ });
44
49
 
45
- core.info(`Deleted ${artifact.name} (created ${artifact.created_at})`);
46
- deleted += 1;
47
- } catch (error) {
48
- core.warning(`Could not delete ${artifact.name} (${artifact.id}): ${error.message}`);
49
- failed.push(artifact.name);
50
+ core.info(`Deleted ${artifact.name} (created ${artifact.created_at})`);
51
+ deleted += 1;
52
+ lastError = undefined;
53
+ break;
54
+ } catch (error) {
55
+ lastError = error;
56
+ if (attempt < 3) await new Promise((wait) => setTimeout(wait, attempt * 2000));
57
+ }
58
+ }
59
+
60
+ if (lastError) {
61
+ core.warning(
62
+ `Could not delete ${artifact.name} (${artifact.id}) after 3 tries: ${lastError.message}`,
63
+ );
64
+ failed.push({ name: artifact.name, status: lastError.status });
50
65
  }
51
66
  }
52
67
 
@@ -60,6 +75,17 @@ runs:
60
75
  ])
61
76
  .write();
62
77
 
63
- if (failed.length > 0) {
64
- core.setFailed(`${failed.length} artifact(s) could not be deleted: ${failed.join(', ')}`);
78
+ // Deleting nothing at all while expired artifacts exist is a permission or
79
+ // configuration problem, and has to be loud. One stubborn artifact among many is
80
+ // the API having a bad minute: tomorrow's run picks it up, and failing here only
81
+ // teaches people to ignore a red cleanup. A 4xx is never transient, so it fails
82
+ // whatever else succeeded.
83
+ const names = failed.map((entry) => entry.name).join(', ');
84
+ const structural = failed.some((entry) => entry.status && entry.status < 500);
85
+ if (failed.length > 0 && (deleted === 0 || structural)) {
86
+ core.setFailed(`${failed.length} artifact(s) could not be deleted: ${names}`);
87
+ } else if (failed.length > 0) {
88
+ core.warning(
89
+ `${failed.length} artifact(s) survived this run and will be retried tomorrow: ${names}`,
90
+ );
65
91
  }