@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.
- package/README.md +105 -88
- package/dist/catalog-installation.d.ts +67 -0
- package/dist/catalog-installation.js +485 -0
- package/dist/catalog-listing.d.ts +13 -0
- package/dist/catalog-listing.js +70 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +273 -0
- package/dist/package-baseline.d.ts +25 -0
- package/dist/package-baseline.js +138 -0
- package/dist/repository-inspection.d.ts +23 -0
- package/dist/repository-inspection.js +113 -0
- package/dist/route-processing.d.ts +4 -0
- package/dist/route-processing.js +80 -0
- package/dist/stack-defaults.d.ts +11 -0
- package/dist/stack-defaults.js +110 -0
- package/dist/tui.d.ts +25 -0
- package/dist/tui.js +271 -0
- package/dist/worker-env.d.ts +46 -0
- package/dist/worker-env.js +179 -0
- package/dist/workflow-catalog.d.ts +27 -0
- package/dist/workflow-catalog.js +57 -0
- package/loops/actions/add-issue-labels/action.yml +22 -2
- package/loops/actions/apply-agent-bundle/apply-bundle.sh +14 -1
- package/loops/actions/audit-close/action.yml +180 -128
- package/loops/actions/classify-route/action.yml +7 -0
- package/loops/actions/classify-route/classify-route.sh +28 -7
- package/loops/actions/cleanup-artifacts/action.yml +38 -12
- package/loops/actions/housekeeping/action.yml +251 -0
- package/loops/actions/identify-gate-subject/action.yml +3 -1
- package/loops/actions/merge-agent-pr/action.yml +13 -0
- package/loops/actions/remove-issue-labels/action.yml +4 -2
- package/loops/actions/report-workflow-errors/action.yml +385 -0
- package/loops/actions/validate-merge-gate-output/validate-merge-gate-output.sh +13 -1
- package/loops/actions/validate-refine-output/validate-refine-output.sh +18 -2
- package/loops/actions/validate-triage-output/action.yml +1 -1
- package/loops/actions/validate-triage-output/validate-triage-output.sh +9 -5
- package/loops/actions/verify-composite-actions/verify-composite-actions.sh +53 -0
- package/loops/actions/verify-refine-output/verify-refine-output.sh +20 -0
- package/loops/actions/verify-route-matrix/verify-route-matrix.sh +1017 -56
- package/loops/scripts/compile-agent-workflows.mjs +96 -1
- package/loops/scripts/merge-changelog.mjs +76 -0
- package/loops/templates/agentics/actionlint.yaml +13 -0
- package/loops/templates/agentics/agentics-checks.yml +203 -86
- package/loops/templates/agentics/agentics-error-report.yml +97 -0
- package/loops/templates/ci/app-ci-dotnet-next.yml +64 -2
- package/loops/templates/ci/app-ci-node-monorepo.yml +52 -0
- package/loops/templates/opencode/opencode.ci.json +3 -3
- package/loops/templates/opencode/opencode.ci.json.md +4 -1
- package/loops/workflows/agent-apply-review.md +452 -465
- package/loops/workflows/agent-audit.md +201 -213
- package/loops/workflows/agent-implement.md +616 -538
- package/loops/workflows/agent-merge-gate.md +830 -730
- package/loops/workflows/agent-refine.md +599 -609
- package/loops/workflows/agent-release.md +244 -258
- package/loops/workflows/agent-triage.md +476 -447
- package/loops/workflows/authorize-bot-work.yml +26 -6
- package/loops/workflows/work-router.yml +1185 -862
- package/package.json +9 -8
- package/loops/actions/stale-recovery/action.yml +0 -288
- 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:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
.
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
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
|
-
|
|
104
|
-
|
|
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
|
|
148
|
-
# routing success here would double-fire the gate for every passing
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
64
|
-
|
|
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
|
}
|