@plainconceptsplatform/workflows 0.6.1 → 0.16.2
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 +107 -88
- package/dist/catalog-installation.d.ts +39 -2
- package/dist/catalog-installation.js +172 -109
- package/dist/index.js +128 -73
- package/dist/package-baseline.d.ts +25 -0
- package/dist/package-baseline.js +138 -0
- package/dist/route-processing.d.ts +0 -2
- package/dist/route-processing.js +22 -91
- package/dist/stack-defaults.js +18 -12
- package/dist/tui.js +27 -43
- package/dist/worker-env.d.ts +46 -0
- package/dist/worker-env.js +179 -0
- package/dist/workflow-catalog.d.ts +4 -2
- package/dist/workflow-catalog.js +5 -3
- package/loops/actions/add-issue-labels/action.yml +20 -0
- package/loops/actions/audit-close/action.yml +180 -128
- package/loops/actions/classify-route/classify-route.sh +8 -2
- package/loops/actions/housekeeping/action.yml +251 -0
- package/loops/actions/merge-agent-pr/action.yml +13 -0
- package/loops/actions/report-workflow-errors/action.yml +414 -0
- package/loops/actions/validate-merge-gate-output/validate-merge-gate-output.sh +13 -1
- 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-route-matrix/verify-route-matrix.sh +911 -170
- package/loops/templates/agentics/agentics-error-report.yml +98 -0
- package/loops/templates/opencode/opencode.ci.json +1 -1
- package/loops/workflows/agent-apply-review.md +438 -469
- package/loops/workflows/agent-audit.md +200 -213
- package/loops/workflows/agent-implement.md +591 -640
- package/loops/workflows/agent-merge-gate.md +826 -844
- package/loops/workflows/agent-refine.md +590 -633
- 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/shared/platform-defaults.md +18 -1
- package/loops/workflows/work-router.yml +1185 -1038
- package/package.json +9 -8
- package/dist/action-validation.test.d.ts +0 -1
- package/dist/action-validation.test.js +0 -87
- package/dist/catalog-installation.test.d.ts +0 -1
- package/dist/catalog-installation.test.js +0 -485
- package/dist/catalog-listing.test.d.ts +0 -1
- package/dist/catalog-listing.test.js +0 -150
- package/dist/index.test.d.ts +0 -1
- package/dist/index.test.js +0 -273
- package/dist/repository-inspection.test.d.ts +0 -1
- package/dist/repository-inspection.test.js +0 -77
- package/dist/route-processing.test.d.ts +0 -1
- package/dist/route-processing.test.js +0 -283
- package/dist/stack-defaults.test.d.ts +0 -1
- package/dist/stack-defaults.test.js +0 -266
- package/dist/tui.test.d.ts +0 -1
- package/dist/tui.test.js +0 -249
- package/dist/workflow-catalog.test.d.ts +0 -1
- package/dist/workflow-catalog.test.js +0 -29
- package/loops/actions/stale-recovery/action.yml +0 -288
- package/loops/actions/update-changelog/action.yml +0 -113
|
@@ -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();
|
|
@@ -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:-[]}"
|
|
@@ -173,6 +178,7 @@ classify_route() {
|
|
|
173
178
|
"$AUDIT_CLOSE_CRON") route="audit-close" ;;
|
|
174
179
|
"$CLEANUP_ARTIFACTS_CRON") route="cleanup-artifacts" ;;
|
|
175
180
|
"$RECONCILE_BOT_PR_RUNS_CRON") route="reconcile-bot-pr-runs" ;;
|
|
181
|
+
"$HOUSEKEEPING_CRON") route="housekeeping" ;;
|
|
176
182
|
*) error="no route for cron '${SCHEDULE:-}'" ;;
|
|
177
183
|
esac
|
|
178
184
|
;;
|
|
@@ -227,7 +233,7 @@ classify_route() {
|
|
|
227
233
|
route="${OPERATION}"
|
|
228
234
|
trigger_kind="${INPUT_TRIGGER_KIND:-manual}"
|
|
229
235
|
;;
|
|
230
|
-
audit-close | cleanup-artifacts | reconcile-bot-pr-runs | validate)
|
|
236
|
+
audit-close | cleanup-artifacts | reconcile-bot-pr-runs | housekeeping | validate)
|
|
231
237
|
route="${OPERATION}"
|
|
232
238
|
;;
|
|
233
239
|
release)
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
# Managed by @plainconceptsplatform/workflows. Source: loops/actions/housekeeping/action.yml. Update with workflows update --force; consumer edits may be overwritten.
|
|
2
|
+
name: Housekeeping
|
|
3
|
+
description: >-
|
|
4
|
+
Retry work the machine stalled on, tidy the state it strands behind, and report what genuinely
|
|
5
|
+
still needs a person. Deterministic: no model runs here.
|
|
6
|
+
|
|
7
|
+
inputs:
|
|
8
|
+
token:
|
|
9
|
+
description: Token with issues, pull-requests and contents write access.
|
|
10
|
+
required: true
|
|
11
|
+
retry-after-hours:
|
|
12
|
+
description: How long an issue may sit stalled before it is retried.
|
|
13
|
+
required: false
|
|
14
|
+
default: '6'
|
|
15
|
+
max-retries:
|
|
16
|
+
description: How many times the janitor retries one issue before leaving it to a person.
|
|
17
|
+
required: false
|
|
18
|
+
default: '3'
|
|
19
|
+
stale-pr-days:
|
|
20
|
+
description: A bot pull request older than this with no CI verdict is reported, never closed.
|
|
21
|
+
required: false
|
|
22
|
+
default: '3'
|
|
23
|
+
digest-title:
|
|
24
|
+
description: Title of the single issue that reports what needs a human. Empty disables it.
|
|
25
|
+
required: false
|
|
26
|
+
default: 'Needs a human'
|
|
27
|
+
dry-run:
|
|
28
|
+
description: Compute everything and write nothing. Every intended change is printed.
|
|
29
|
+
required: false
|
|
30
|
+
default: 'false'
|
|
31
|
+
|
|
32
|
+
runs:
|
|
33
|
+
using: composite
|
|
34
|
+
steps:
|
|
35
|
+
- name: Sweep
|
|
36
|
+
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
|
37
|
+
env:
|
|
38
|
+
RETRY_AFTER_HOURS: ${{ inputs.retry-after-hours }}
|
|
39
|
+
MAX_RETRIES: ${{ inputs.max-retries }}
|
|
40
|
+
STALE_PR_DAYS: ${{ inputs.stale-pr-days }}
|
|
41
|
+
DIGEST_TITLE: ${{ inputs.digest-title }}
|
|
42
|
+
DRY_RUN: ${{ inputs.dry-run }}
|
|
43
|
+
with:
|
|
44
|
+
github-token: ${{ inputs.token }}
|
|
45
|
+
script: |
|
|
46
|
+
const dryRun = process.env.DRY_RUN === 'true';
|
|
47
|
+
const retryAfterMs = Number(process.env.RETRY_AFTER_HOURS) * 3600 * 1000;
|
|
48
|
+
const maxRetries = Number(process.env.MAX_RETRIES);
|
|
49
|
+
const stalePrMs = Number(process.env.STALE_PR_DAYS) * 86400 * 1000;
|
|
50
|
+
const digestTitle = process.env.DIGEST_TITLE;
|
|
51
|
+
|
|
52
|
+
// Every write goes through this, so --dry-run is honest rather than hopeful: there is
|
|
53
|
+
// no second path that forgets to check it.
|
|
54
|
+
const planned = [];
|
|
55
|
+
const act = async (what, run) => {
|
|
56
|
+
planned.push(what);
|
|
57
|
+
if (dryRun) { core.info(`[dry-run] ${what}`); return; }
|
|
58
|
+
core.info(what);
|
|
59
|
+
await run();
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const RETRY_MARKER = '<!-- housekeeping-retry -->';
|
|
63
|
+
const DIGEST_MARKER = '<!-- housekeeping-digest -->';
|
|
64
|
+
const WORK_LABELS = ['implement', 'refine', 'triage'];
|
|
65
|
+
const isBot = (login) => login.endsWith('[bot]') || login.startsWith('app/');
|
|
66
|
+
const age = (iso) => Date.now() - new Date(iso).getTime();
|
|
67
|
+
const days = (ms) => Math.floor(ms / 86400000);
|
|
68
|
+
|
|
69
|
+
const issues = await github.paginate(github.rest.issues.listForRepo, {
|
|
70
|
+
...context.repo, state: 'open', per_page: 100,
|
|
71
|
+
});
|
|
72
|
+
const pulls = await github.paginate(github.rest.pulls.list, {
|
|
73
|
+
...context.repo, state: 'open', per_page: 100,
|
|
74
|
+
});
|
|
75
|
+
const names = (issue) => issue.labels.map((l) => (typeof l === 'string' ? l : l.name));
|
|
76
|
+
|
|
77
|
+
const needsHuman = [];
|
|
78
|
+
let retried = 0;
|
|
79
|
+
|
|
80
|
+
// 1. Retry what the machine stalled on. `stalled` marks a park caused by a crash, a
|
|
81
|
+
// timeout or an empty output; `review` alone is a decision the agent made, and
|
|
82
|
+
// re-running a decision only reproduces it, so those are reported and never retried.
|
|
83
|
+
for (const issue of issues) {
|
|
84
|
+
if (issue.pull_request) continue;
|
|
85
|
+
const labels = names(issue);
|
|
86
|
+
if (!labels.includes('review')) continue;
|
|
87
|
+
|
|
88
|
+
const comments = await github.paginate(github.rest.issues.listComments, {
|
|
89
|
+
...context.repo, issue_number: issue.number, per_page: 100,
|
|
90
|
+
});
|
|
91
|
+
const attempts = comments.filter((c) => (c.body ?? '').includes(RETRY_MARKER)).length;
|
|
92
|
+
const work = WORK_LABELS.find((l) => labels.includes(l));
|
|
93
|
+
const waited = age(issue.updated_at);
|
|
94
|
+
|
|
95
|
+
if (!labels.includes('stalled')) {
|
|
96
|
+
needsHuman.push({ kind: 'decided', issue, labels, waited });
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (waited < retryAfterMs) continue;
|
|
100
|
+
if (attempts >= maxRetries || !work) {
|
|
101
|
+
needsHuman.push({ kind: attempts >= maxRetries ? 'retries-exhausted' : 'no-work-label', issue, labels, waited, attempts });
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
await act(`#${issue.number}: retrying ${work} (attempt ${attempts + 1} of ${maxRetries}, stalled ${Math.floor(waited / 3600000)}h)`, async () => {
|
|
106
|
+
await github.rest.issues.createComment({
|
|
107
|
+
...context.repo, issue_number: issue.number,
|
|
108
|
+
body: `${RETRY_MARKER}\nThis issue stalled rather than reaching a decision, so it is being retried automatically: attempt ${attempts + 1} of ${maxRetries}. If it stalls ${maxRetries} times it stays parked for a person.`,
|
|
109
|
+
});
|
|
110
|
+
// Removing the labels is what lets it route again; the work label is already there.
|
|
111
|
+
for (const label of ['review', 'stalled']) {
|
|
112
|
+
await github.rest.issues.removeLabel({ ...context.repo, issue_number: issue.number, name: label })
|
|
113
|
+
.catch((error) => { if (error.status !== 404) throw error; });
|
|
114
|
+
}
|
|
115
|
+
await github.rest.actions.createWorkflowDispatch({
|
|
116
|
+
...context.repo, workflow_id: 'work-router.yml',
|
|
117
|
+
ref: context.payload.repository.default_branch,
|
|
118
|
+
inputs: { operation: work, 'issue-number': String(issue.number) },
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
retried += 1;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// 2. Strands. pr-pending says a pull request is open and waiting; when that pull request
|
|
125
|
+
// closed without merging, nothing ever took the label off.
|
|
126
|
+
for (const issue of issues) {
|
|
127
|
+
if (issue.pull_request || !names(issue).includes('pr-pending')) continue;
|
|
128
|
+
const linked = pulls.some((p) => new RegExp(`(clos(e|es|ed)|fix(es|ed)?|resolves?)\\s+#${issue.number}\\b`, 'i').test(p.body ?? ''));
|
|
129
|
+
if (linked) continue;
|
|
130
|
+
await act(`#${issue.number}: removing pr-pending, no open pull request closes it`, () =>
|
|
131
|
+
github.rest.issues.removeLabel({ ...context.repo, issue_number: issue.number, name: 'pr-pending' })
|
|
132
|
+
.catch((error) => { if (error.status !== 404) throw error; }));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// 3. Split parents. Refine leaves the parent open as a tracker and nothing has ever
|
|
136
|
+
// closed one: once every child is done the parent sits there for ever with an unticked
|
|
137
|
+
// checklist. Children carry `<!-- split-parent: N -->`, which is the only machine-
|
|
138
|
+
// readable link between them.
|
|
139
|
+
const childrenOf = new Map();
|
|
140
|
+
for (const issue of await github.paginate(github.rest.issues.listForRepo, { ...context.repo, state: 'all', per_page: 100 })) {
|
|
141
|
+
const match = /<!-- split-parent: (\d+) -->/.exec(issue.body ?? '');
|
|
142
|
+
if (!match || issue.pull_request) continue;
|
|
143
|
+
const parent = Number(match[1]);
|
|
144
|
+
if (!childrenOf.has(parent)) childrenOf.set(parent, []);
|
|
145
|
+
childrenOf.get(parent).push(issue);
|
|
146
|
+
}
|
|
147
|
+
for (const [parent, children] of childrenOf) {
|
|
148
|
+
const open = children.filter((c) => c.state === 'open');
|
|
149
|
+
if (open.length > 0 || !issues.some((i) => i.number === parent)) continue;
|
|
150
|
+
await act(`#${parent}: closing split parent, all ${children.length} children are done`, async () => {
|
|
151
|
+
await github.rest.issues.createComment({
|
|
152
|
+
...context.repo, issue_number: parent,
|
|
153
|
+
body: `${DIGEST_MARKER}\nClosed automatically: every child of this split is closed (${children.map((c) => `#${c.number}`).join(', ')}).`,
|
|
154
|
+
});
|
|
155
|
+
await github.rest.issues.update({ ...context.repo, issue_number: parent, state: 'closed', state_reason: 'completed' });
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// 4. Branches. Nothing in the fleet has ever deleted one, so they accumulate for ever
|
|
160
|
+
// after a merge and after a close alike. Only ever touch a branch whose pull request is
|
|
161
|
+
// finished; never the default branch, and never one with a pull request still open.
|
|
162
|
+
//
|
|
163
|
+
// Deleting the branch of a pull request that closed WITHOUT merging does not throw the
|
|
164
|
+
// work away: GitHub keeps `refs/pull/<n>/head` for the life of the pull request, which
|
|
165
|
+
// is what its own "Restore branch" button restores from. Verified on this fleet --
|
|
166
|
+
// after the first sweep removed six branches, every one of their pull requests still
|
|
167
|
+
// resolved through that ref. So `git fetch origin pull/<n>/head` brings any of this
|
|
168
|
+
// back, and the pull request page still shows the diff.
|
|
169
|
+
const branches = await github.paginate(github.rest.repos.listBranches, { ...context.repo, per_page: 100 });
|
|
170
|
+
const defaultBranch = context.payload.repository.default_branch;
|
|
171
|
+
const allPulls = await github.paginate(github.rest.pulls.list, { ...context.repo, state: 'all', per_page: 100 });
|
|
172
|
+
const byHead = new Map();
|
|
173
|
+
for (const pull of allPulls) {
|
|
174
|
+
if (!byHead.has(pull.head.ref)) byHead.set(pull.head.ref, []);
|
|
175
|
+
byHead.get(pull.head.ref).push(pull);
|
|
176
|
+
}
|
|
177
|
+
for (const branch of branches) {
|
|
178
|
+
if (branch.name === defaultBranch) continue;
|
|
179
|
+
const forBranch = byHead.get(branch.name) ?? [];
|
|
180
|
+
if (forBranch.length === 0) continue; // never opened a PR: not ours to judge
|
|
181
|
+
if (forBranch.some((p) => p.state === 'open')) continue; // still in use
|
|
182
|
+
if (!forBranch.every((p) => isBot(p.user?.login ?? ''))) continue;
|
|
183
|
+
const merged = forBranch.some((p) => p.merged_at);
|
|
184
|
+
await act(`branch ${branch.name}: deleting (${merged ? 'merged' : 'closed unmerged'})`, () =>
|
|
185
|
+
github.rest.git.deleteRef({ ...context.repo, ref: `heads/${branch.name}` })
|
|
186
|
+
.catch((error) => { if (error.status !== 404 && error.status !== 422) throw error; }));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// 5. Pull requests that need a person: parked by a gate verdict, or old with no CI.
|
|
190
|
+
for (const pull of pulls) {
|
|
191
|
+
if (!isBot(pull.user?.login ?? '')) continue;
|
|
192
|
+
const comments = await github.paginate(github.rest.issues.listComments, {
|
|
193
|
+
...context.repo, issue_number: pull.number, per_page: 100,
|
|
194
|
+
});
|
|
195
|
+
const verdict = [...comments].reverse().find((c) => (c.body ?? '').includes('<!-- agent-merge-gate -->') && (c.body ?? '').includes('**Verdict:**'));
|
|
196
|
+
const parsed = verdict ? /\*\*Verdict:\*\*\s*(\w+)/.exec(verdict.body)?.[1] : undefined;
|
|
197
|
+
if (parsed && parsed !== 'merge') {
|
|
198
|
+
needsHuman.push({ kind: 'pr-parked', pull, verdict: parsed, waited: age(verdict.created_at) });
|
|
199
|
+
} else if (!verdict && age(pull.created_at) > stalePrMs) {
|
|
200
|
+
needsHuman.push({ kind: 'pr-stale', pull, waited: age(pull.created_at) });
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// 6. One digest. Everything above either fixed itself or is listed here, so there is a
|
|
205
|
+
// single place to look instead of four repositories and three label queries.
|
|
206
|
+
const line = (entry) => {
|
|
207
|
+
if (entry.kind === 'pr-parked') return `- PR #${entry.pull.number} · parked ${days(entry.waited)}d · gate verdict \`${entry.verdict}\` · ${entry.pull.title}`;
|
|
208
|
+
if (entry.kind === 'pr-stale') return `- PR #${entry.pull.number} · ${days(entry.waited)}d old · no gate verdict yet · ${entry.pull.title}`;
|
|
209
|
+
if (entry.kind === 'retries-exhausted') return `- #${entry.issue.number} · stalled ${days(entry.waited)}d · retried ${entry.attempts}× and still stalling · ${entry.issue.title}`;
|
|
210
|
+
if (entry.kind === 'no-work-label') return `- #${entry.issue.number} · stalled ${days(entry.waited)}d · no work label to retry with · ${entry.issue.title}`;
|
|
211
|
+
return `- #${entry.issue.number} · waiting ${days(entry.waited)}d · ${entry.labels.filter((l) => l !== 'review').join(', ') || 'no other labels'} · ${entry.issue.title}`;
|
|
212
|
+
};
|
|
213
|
+
const section = (heading, kinds) => {
|
|
214
|
+
const rows = needsHuman.filter((e) => kinds.includes(e.kind));
|
|
215
|
+
return rows.length === 0 ? '' : `\n### ${heading} (${rows.length})\n\n${rows.map(line).join('\n')}\n`;
|
|
216
|
+
};
|
|
217
|
+
const body = [
|
|
218
|
+
DIGEST_MARKER,
|
|
219
|
+
`_Updated ${new Date().toISOString().slice(0, 16).replace('T', ' ')} UTC. This issue is rewritten by the housekeeping job; edits to it are lost._`,
|
|
220
|
+
section('Waiting on a decision', ['decided']),
|
|
221
|
+
section('Pull requests parked by the gate', ['pr-parked', 'pr-stale']),
|
|
222
|
+
section('Stalled beyond the retry budget', ['retries-exhausted', 'no-work-label']),
|
|
223
|
+
needsHuman.length === 0 ? '\nNothing is waiting on a person right now.\n' : '',
|
|
224
|
+
`\n---\n\nThis run retried ${retried} stalled issue(s) and made ${planned.length} change(s) in total.`,
|
|
225
|
+
].filter(Boolean).join('\n');
|
|
226
|
+
|
|
227
|
+
if (digestTitle) {
|
|
228
|
+
const existing = issues.find((i) => !i.pull_request && i.title === digestTitle && (i.body ?? '').includes(DIGEST_MARKER));
|
|
229
|
+
if (existing) {
|
|
230
|
+
if (needsHuman.length === 0) {
|
|
231
|
+
await act(`closing the digest #${existing.number}: nothing is waiting`, () =>
|
|
232
|
+
github.rest.issues.update({ ...context.repo, issue_number: existing.number, body, state: 'closed', state_reason: 'completed' }));
|
|
233
|
+
} else {
|
|
234
|
+
await act(`updating the digest #${existing.number}: ${needsHuman.length} item(s)`, () =>
|
|
235
|
+
github.rest.issues.update({ ...context.repo, issue_number: existing.number, body }));
|
|
236
|
+
}
|
|
237
|
+
} else if (needsHuman.length > 0) {
|
|
238
|
+
await act(`opening the digest: ${needsHuman.length} item(s)`, () =>
|
|
239
|
+
github.rest.issues.create({ ...context.repo, title: digestTitle, body }));
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
await core.summary
|
|
244
|
+
.addHeading('Housekeeping', 2)
|
|
245
|
+
.addList([
|
|
246
|
+
`Retried: ${retried}`,
|
|
247
|
+
`Waiting on a person: ${needsHuman.length}`,
|
|
248
|
+
`Changes ${dryRun ? 'planned' : 'made'}: ${planned.length}`,
|
|
249
|
+
])
|
|
250
|
+
.addRaw(planned.length ? `\n\n${planned.map((p) => `- ${p}`).join('\n')}` : '')
|
|
251
|
+
.write();
|
|
@@ -32,5 +32,18 @@ runs:
|
|
|
32
32
|
HEAD_SHA=$(gh pr view "$PR_NUM" --repo "$REPO" --json headRefOid --jq '.headRefOid')
|
|
33
33
|
[[ "$HEAD_SHA" =~ ^[0-9a-fA-F]{40}$ ]] || { echo "::error::Invalid pull request head SHA for #$PR_NUM"; exit 1; }
|
|
34
34
|
|
|
35
|
+
HEAD_REF=$(gh pr view "$PR_NUM" --repo "$REPO" --json headRefName --jq '.headRefName')
|
|
36
|
+
|
|
35
37
|
gh pr merge "$PR_NUM" --repo "$REPO" --squash --match-head-commit "$HEAD_SHA"
|
|
38
|
+
|
|
39
|
+
# Delete the branch, because nothing else ever has: four repositories had accumulated
|
|
40
|
+
# 47 non-main branches, every one the head of a pull request merged or closed months
|
|
41
|
+
# ago. Done after the merge and with its failure swallowed rather than through
|
|
42
|
+
# `gh pr merge --delete-branch`, which also tries to delete the local branch and can
|
|
43
|
+
# turn a completed merge into a red step. `delete_branch_on_merge` on the repository
|
|
44
|
+
# does the same job, but only where someone remembered to switch it on.
|
|
45
|
+
if [ -n "$HEAD_REF" ] && [ "$HEAD_REF" != "null" ]; then
|
|
46
|
+
gh api -X DELETE "repos/$REPO/git/refs/heads/$HEAD_REF" >/dev/null 2>&1 ||
|
|
47
|
+
echo "::notice::branch $HEAD_REF was already gone or is protected"
|
|
48
|
+
fi
|
|
36
49
|
done
|