@plainconceptsplatform/workflows 1.6.11 → 1.6.21
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/loops/actions/housekeeping/action.yml +15 -0
- package/loops/actions/merge-train/action.yml +7 -0
- package/loops/actions/merge-train/merge-train.sh +29 -1
- package/loops/actions/report-workflow-errors/action.yml +92 -23
- package/loops/actions/report-workflow-errors/report-policy.cjs +110 -0
- package/loops/actions/verify-route-matrix/verify-route-matrix.sh +198 -0
- package/loops/docs/diagrams.md +8 -4
- package/loops/templates/agentics/agentics-error-report.yml +2 -0
- package/loops/templates/opencode/opencode.ci.json +118 -1
- package/loops/workflows/agent-apply-review.md +12 -1
- package/loops/workflows/agent-audit.md +6 -1
- package/loops/workflows/agent-implement.md +109 -13
- package/loops/workflows/agent-merge-gate.md +6 -1
- package/loops/workflows/agent-refine.md +10 -1
- package/loops/workflows/agent-release.md +6 -1
- package/loops/workflows/agent-triage.md +10 -1
- package/loops/workflows/shared/opencode-ci.md +7 -1
- package/loops/workflows/work-router.yml +22 -2
- package/package.json +1 -1
|
@@ -499,6 +499,21 @@ runs:
|
|
|
499
499
|
}
|
|
500
500
|
}
|
|
501
501
|
|
|
502
|
+
// The merge train wakes when a CI run finishes. When a gate it was waiting on ends without
|
|
503
|
+
// one, or is cancelled, nothing calls it again and every pull request sits until the next
|
|
504
|
+
// CI event: Pliny-Bot had four green pull requests waiting on that for the better part of an
|
|
505
|
+
// hour. The hourly sweep is the heartbeat. A dispatch of the router by the workflow token is
|
|
506
|
+
// allowed to start a run, and a tick with nothing to do costs one short job.
|
|
507
|
+
const openPulls = pulls.filter((pr) => !pr.draft);
|
|
508
|
+
if (openPulls.length > 0) {
|
|
509
|
+
await act(`dispatching a merge train tick: ${openPulls.length} pull request(s) are open`, () =>
|
|
510
|
+
github.rest.actions.createWorkflowDispatch({
|
|
511
|
+
...context.repo, workflow_id: 'work-router.yml',
|
|
512
|
+
ref: context.payload.repository?.default_branch ?? 'main',
|
|
513
|
+
inputs: { operation: 'merge-train' },
|
|
514
|
+
}));
|
|
515
|
+
}
|
|
516
|
+
|
|
502
517
|
await core.summary
|
|
503
518
|
.addHeading('Housekeeping', 2)
|
|
504
519
|
.addList([
|
|
@@ -32,6 +32,12 @@ inputs:
|
|
|
32
32
|
description: How many pull requests may be in repair at once. Merges stay one at a time.
|
|
33
33
|
required: false
|
|
34
34
|
default: '1'
|
|
35
|
+
main-red-wait-hours:
|
|
36
|
+
description: >-
|
|
37
|
+
How long the train waits for a base branch whose checks fail the same way as a pull request's
|
|
38
|
+
before it stops waiting and repairs the pull request anyway.
|
|
39
|
+
required: false
|
|
40
|
+
default: '2'
|
|
35
41
|
stuck-hours:
|
|
36
42
|
description: How long the train waits behind a CI run that never finishes before it passes that pull request by.
|
|
37
43
|
required: false
|
|
@@ -64,6 +70,7 @@ runs:
|
|
|
64
70
|
MAX_ATTEMPTS: ${{ inputs.max-gate-attempts }}
|
|
65
71
|
MAX_RETRIES: ${{ inputs.max-retries }}
|
|
66
72
|
STUCK_HOURS: ${{ inputs.stuck-hours }}
|
|
73
|
+
MAIN_RED_WAIT_HOURS: ${{ inputs.main-red-wait-hours }}
|
|
67
74
|
MAX_REPAIRS: ${{ inputs.max-repairs }}
|
|
68
75
|
DRY_RUN: ${{ inputs.dry-run }}
|
|
69
76
|
HINT_RUN_ID: ${{ inputs.ci-run-id }}
|
|
@@ -146,6 +146,7 @@ MAX_ATTEMPTS="${MAX_ATTEMPTS:-6}"
|
|
|
146
146
|
MAX_RETRIES="${MAX_RETRIES:-3}"
|
|
147
147
|
MAX_REPAIRS="${MAX_REPAIRS:-1}"
|
|
148
148
|
case "$MAX_REPAIRS" in ''|*[!0-9]*|0) MAX_REPAIRS=1 ;; esac
|
|
149
|
+
MAIN_RED_WAIT_HOURS="${MAIN_RED_WAIT_HOURS:-2}"
|
|
149
150
|
STUCK_HOURS="${STUCK_HOURS:-3}"
|
|
150
151
|
DRY_RUN="${DRY_RUN:-false}"
|
|
151
152
|
SELF_RUN_ID="${GITHUB_RUN_ID:-0}"
|
|
@@ -423,9 +424,22 @@ for line in "${candidates[@]}"; do
|
|
|
423
424
|
--jq '[.check_runs[] | select(.conclusion == "failure") | (.name | sub("^.* / "; ""))] | unique | .[]' 2>/dev/null || true)
|
|
424
425
|
failed_base=$(gh api "repos/$REPO/commits/$base_ref/check-runs?per_page=100" \
|
|
425
426
|
--jq '[.check_runs[] | select(.conclusion == "failure") | (.name | sub("^.* / "; ""))] | unique | .[]' 2>/dev/null || true)
|
|
427
|
+
# How long the base has been red: the oldest failing check on its tip. The wait is bounded,
|
|
428
|
+
# because waiting for a base nobody fixes starves every pull request behind it. Pliny-Bot's
|
|
429
|
+
# main was red from 19:30 and the train waited until morning, ten hours, saying nothing.
|
|
430
|
+
red_since=$(gh api "repos/$REPO/commits/$base_ref/check-runs?per_page=100" \
|
|
431
|
+
--jq '[.check_runs[] | select(.conclusion == "failure") | .completed_at] | min // empty' 2>/dev/null || true)
|
|
432
|
+
red_for_hours=0
|
|
433
|
+
if [ -n "$red_since" ]; then
|
|
434
|
+
red_for_hours=$(( (now_epoch - $(iso_epoch "$red_since")) / 3600 ))
|
|
435
|
+
fi
|
|
426
436
|
if [ -n "$failed_here" ] && [ -n "$failed_base" ] &&
|
|
427
437
|
[ -z "$(comm -23 <(sort <<<"$failed_here") <(sort <<<"$failed_base"))" ]; then
|
|
428
|
-
|
|
438
|
+
if [ "$red_for_hours" -lt "${MAIN_RED_WAIT_HOURS:-2}" ]; then
|
|
439
|
+
same_as_main=true
|
|
440
|
+
else
|
|
441
|
+
echo "::warning::$base_ref has been red for ${red_for_hours}h on the same checks as #$pr; the train no longer waits for it and repairs #$pr."
|
|
442
|
+
fi
|
|
429
443
|
fi
|
|
430
444
|
fi
|
|
431
445
|
|
|
@@ -439,6 +453,20 @@ for line in "${candidates[@]}"; do
|
|
|
439
453
|
move=$(sed -n 's/^move=//p' <<<"$decision")
|
|
440
454
|
reason=$(sed -n 's/^reason=//p' <<<"$decision")
|
|
441
455
|
|
|
456
|
+
# A repair gate dispatched for red CI is moot once CI on the head is green: its job was to make
|
|
457
|
+
# that run pass. Left alone it keeps the pull request marked as having a gate, so the train
|
|
458
|
+
# skipped four green Pliny-Bot pull requests with "a gate is already on it" until it was cancelled
|
|
459
|
+
# by hand. Cancel it and let the pull request move.
|
|
460
|
+
if { [ "$move" = gate ] || [ "$move" = update ]; } && [ "$ci_status" = completed ] && [ "$ci_conclusion" = success ] &&
|
|
461
|
+
printf '%s\n' "$live_titles" | grep -qE "PR #${pr} \(repair\)$"; then
|
|
462
|
+
for run_id in $(gh api "repos/$REPO/actions/workflows/work-router.yml/runs?per_page=100" \
|
|
463
|
+
--jq ".workflow_runs[] | select(.status != \"completed\" and (.display_title | endswith(\"PR #${pr} (repair)\"))) | .id" 2>/dev/null); do
|
|
464
|
+
act "Cancelling the repair gate $run_id on #$pr: its CI is green." \
|
|
465
|
+
gh api -X POST "repos/$REPO/actions/runs/$run_id/cancel" --silent
|
|
466
|
+
done
|
|
467
|
+
live_titles=$(printf '%s\n' "$live_titles" | grep -vE "PR #${pr} \(repair\)$" || true)
|
|
468
|
+
fi
|
|
469
|
+
|
|
442
470
|
# A gate is already on this pull request, whatever kind: offering it another would run the same
|
|
443
471
|
# work twice. It does not hold the line up for the repair kind; a merging gate already does.
|
|
444
472
|
if [ "$move" != skip ] && live_for_pr "$pr"; then
|
|
@@ -23,6 +23,20 @@ inputs:
|
|
|
23
23
|
description: How far back to look for failures.
|
|
24
24
|
required: false
|
|
25
25
|
default: '24'
|
|
26
|
+
min-occurrences:
|
|
27
|
+
description: >-
|
|
28
|
+
A signature seen fewer times than this inside the window does not open a new issue. One
|
|
29
|
+
occurrence is a blip; an issue that is already open is always appended to.
|
|
30
|
+
required: false
|
|
31
|
+
default: '2'
|
|
32
|
+
max-findings:
|
|
33
|
+
description: The most issues one run opens, the most frequent first. The rest stay in the job summary.
|
|
34
|
+
required: false
|
|
35
|
+
default: '5'
|
|
36
|
+
quiet-days:
|
|
37
|
+
description: A signature whose issue was closed within this many days stays quiet, whatever the reason it was closed.
|
|
38
|
+
required: false
|
|
39
|
+
default: '30'
|
|
26
40
|
dry-run:
|
|
27
41
|
description: Compute and print the report, file nothing.
|
|
28
42
|
required: false
|
|
@@ -43,6 +57,10 @@ runs:
|
|
|
43
57
|
UPSTREAM_TOKEN: ${{ inputs.upstream-token }}
|
|
44
58
|
UPSTREAM_REPO: ${{ inputs.upstream-repo }}
|
|
45
59
|
LOOKBACK_HOURS: ${{ inputs.lookback-hours }}
|
|
60
|
+
MIN_OCCURRENCES: ${{ inputs.min-occurrences }}
|
|
61
|
+
MAX_FINDINGS: ${{ inputs.max-findings }}
|
|
62
|
+
QUIET_DAYS: ${{ inputs.quiet-days }}
|
|
63
|
+
POLICY_PATH: ${{ github.action_path }}/report-policy.cjs
|
|
46
64
|
DRY_RUN: ${{ inputs.dry-run }}
|
|
47
65
|
with:
|
|
48
66
|
github-token: ${{ inputs.token }}
|
|
@@ -56,6 +74,10 @@ runs:
|
|
|
56
74
|
// is checked against a leak scanner and the job fails rather than files if the
|
|
57
75
|
// scanner trips.
|
|
58
76
|
|
|
77
|
+
// What to file and what to leave as a number lives in report-policy.cjs, apart from
|
|
78
|
+
// what this script reads and writes, so it can be run on its own.
|
|
79
|
+
const policy = require(process.env.POLICY_PATH);
|
|
80
|
+
|
|
59
81
|
const dryRun = process.env.DRY_RUN === 'true';
|
|
60
82
|
const upstreamToken = process.env.UPSTREAM_TOKEN;
|
|
61
83
|
const [upstreamOwner, upstreamName] = process.env.UPSTREAM_REPO.split('/');
|
|
@@ -140,7 +162,7 @@ runs:
|
|
|
140
162
|
const header = require('node:fs')
|
|
141
163
|
.readFileSync('.github/workflows/work-router.yml', 'utf8')
|
|
142
164
|
.split('\n')[0];
|
|
143
|
-
packageVersion =
|
|
165
|
+
packageVersion = policy.parseVersion(header);
|
|
144
166
|
} catch {
|
|
145
167
|
core.info('could not read the installed package version from the router header');
|
|
146
168
|
}
|
|
@@ -153,15 +175,19 @@ runs:
|
|
|
153
175
|
// upstream, and every part of it is package-owned or an enumerated value.
|
|
154
176
|
//
|
|
155
177
|
// THE FINDING SHAPE IS THE PRIVACY BOUNDARY. bodyFor() renders a finding, so a field
|
|
156
|
-
// added here is a field sent to another repository. These
|
|
178
|
+
// added here is a field sent to another repository. These ten are all
|
|
157
179
|
// package-owned names or enumerated values; nothing derived from a log, a branch, a
|
|
158
180
|
// path or an issue belongs on this object. The route matrix asserts this exact list,
|
|
159
181
|
// so adding a ninth field fails the build rather than leaking on the next cron.
|
|
160
|
-
const FINDING_FIELDS = ['workflow', 'job', 'step', 'conclusion', 'patternId', 'count', 'runnerLabels', 'attempts'];
|
|
182
|
+
const FINDING_FIELDS = ['workflow', 'job', 'step', 'conclusion', 'patternId', 'count', 'runnerLabels', 'attempts', 'severity', 'variants'];
|
|
161
183
|
const findings = new Map();
|
|
162
184
|
let ownedRuns = 0;
|
|
163
185
|
let ownedFailures = 0;
|
|
164
186
|
let skippedForeign = 0;
|
|
187
|
+
// Things that happened and are worth a number in the job summary, but that nobody can
|
|
188
|
+
// fix by editing a workflow, so they never open an issue: a cancelled job with no step
|
|
189
|
+
// that failed, a log that could not be read, and the fleet's own transient causes.
|
|
190
|
+
const quiet = { cancelledNoStep: 0, unreadable: 0, infra: new Map() };
|
|
165
191
|
let queueWaitTotal = 0;
|
|
166
192
|
let queueWaitCount = 0;
|
|
167
193
|
let queueWaitWorst = 0;
|
|
@@ -180,15 +206,7 @@ runs:
|
|
|
180
206
|
if (['queued', 'waiting', 'pending'].includes(run.status) &&
|
|
181
207
|
Date.now() - new Date(run.created_at).getTime() > STUCK_AFTER_MS) {
|
|
182
208
|
stuckInQueue += 1;
|
|
183
|
-
|
|
184
|
-
const seen = findings.get(signature) ?? {
|
|
185
|
-
workflow: file, job: '(whole run)', step: '(never started)',
|
|
186
|
-
conclusion: 'queued', patternId: 'runner-never-assigned', count: 0,
|
|
187
|
-
runnerLabels: new Set(), attempts: new Set(),
|
|
188
|
-
};
|
|
189
|
-
seen.count += 1;
|
|
190
|
-
seen.attempts.add(run.run_attempt ?? 1);
|
|
191
|
-
findings.set(signature, seen);
|
|
209
|
+
quiet.infra.set('runner-never-assigned', (quiet.infra.get('runner-never-assigned') ?? 0) + 1);
|
|
192
210
|
continue;
|
|
193
211
|
}
|
|
194
212
|
|
|
@@ -217,7 +235,24 @@ runs:
|
|
|
217
235
|
// package's own words. A `run-name` is not: it interpolates issue titles.
|
|
218
236
|
const stepName = step?.name ?? '(no failing step recorded)';
|
|
219
237
|
|
|
238
|
+
// A cancelled job that never reached a failing step is somebody or something
|
|
239
|
+
// stopping work, not a defect: a superseded run, a job cancelled behind a failed
|
|
240
|
+
// one. It was 189 of 320 reports and every one said nothing.
|
|
241
|
+
if (job.conclusion === 'cancelled' && !step) { quiet.cancelledNoStep += 1; continue; }
|
|
242
|
+
|
|
243
|
+
// GitHub writes why a runner went away into the job's annotations ("The runner has
|
|
244
|
+
// received a shutdown signal", exit code 143), not into the log, so they are read
|
|
245
|
+
// with it. Only the id of a catalogue entry that matches ever leaves.
|
|
246
|
+
let annotationText = '';
|
|
247
|
+
try {
|
|
248
|
+
const notes = await github.paginate(github.rest.checks.listAnnotations, {
|
|
249
|
+
...context.repo, check_run_id: job.id, per_page: 50,
|
|
250
|
+
});
|
|
251
|
+
annotationText = notes.map((note) => note.message ?? '').join('\n');
|
|
252
|
+
} catch { /* annotations are an extra; the log can still classify */ }
|
|
253
|
+
|
|
220
254
|
let patternId = 'unrecognised';
|
|
255
|
+
let logRead = true;
|
|
221
256
|
try {
|
|
222
257
|
const { data: log } = await github.rest.actions.downloadJobLogsForWorkflowRun({
|
|
223
258
|
...context.repo, job_id: job.id,
|
|
@@ -225,19 +260,32 @@ runs:
|
|
|
225
260
|
const text = typeof log === 'string' ? log : Buffer.from(log).toString('utf8');
|
|
226
261
|
// The tail is where the failure is, and reading less of the log is less to get
|
|
227
262
|
// wrong. Only the matched entry's id crosses into the report.
|
|
228
|
-
const tail = text.slice(-20000)
|
|
263
|
+
const tail = `${annotationText}\n${text.slice(-20000)}`;
|
|
229
264
|
patternId = CATALOGUE.find((entry) => entry.test.test(tail))?.id ?? 'unrecognised';
|
|
230
265
|
} catch {
|
|
231
|
-
|
|
266
|
+
logRead = false;
|
|
267
|
+
patternId = CATALOGUE.find((entry) => entry.test.test(annotationText))?.id ?? 'log-unavailable';
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// A log nobody could read says nothing, so it is counted and not filed.
|
|
271
|
+
if (patternId === 'log-unavailable') { quiet.unreadable += 1; continue; }
|
|
272
|
+
if (!logRead && policy.severityOf(patternId) === 'infra') {
|
|
273
|
+
quiet.infra.set(patternId, (quiet.infra.get(patternId) ?? 0) + 1);
|
|
274
|
+
continue;
|
|
232
275
|
}
|
|
233
276
|
|
|
234
|
-
|
|
277
|
+
// One cause is one finding, whichever job tripped on it.
|
|
278
|
+
const signature = policy.signatureOf({
|
|
279
|
+
workflow: file, step: stepName, conclusion: job.conclusion, patternId,
|
|
280
|
+
});
|
|
235
281
|
const seen = findings.get(signature) ?? {
|
|
236
282
|
workflow: file, job: job.name, step: stepName,
|
|
237
|
-
conclusion: job.conclusion, patternId, count: 0,
|
|
283
|
+
conclusion: job.conclusion, patternId: patternId, count: 0,
|
|
238
284
|
runnerLabels: new Set(), attempts: new Set(),
|
|
285
|
+
severity: policy.severityOf(patternId), variants: new Set(),
|
|
239
286
|
};
|
|
240
287
|
seen.count += 1;
|
|
288
|
+
seen.variants.add(`${job.name}|${stepName}`);
|
|
241
289
|
seen.attempts.add(run.run_attempt ?? 1);
|
|
242
290
|
// A pool label is the one value on a finding that the CONSUMER writes, and the
|
|
243
291
|
// installer deliberately preserves theirs across updates (preserveRunnerPool in
|
|
@@ -261,6 +309,13 @@ runs:
|
|
|
261
309
|
const ranked = [...findings.values()].sort((a, b) => b.count - a.count);
|
|
262
310
|
const hours = process.env.LOOKBACK_HOURS;
|
|
263
311
|
|
|
312
|
+
// What becomes an issue. Infrastructure causes are a rate, a blip is not worth a
|
|
313
|
+
// person's time, and a bad night opens a handful of issues rather than forty.
|
|
314
|
+
const selection = policy.select(ranked, {
|
|
315
|
+
minOccurrences: Number(process.env.MIN_OCCURRENCES),
|
|
316
|
+
maxFindings: Number(process.env.MAX_FINDINGS),
|
|
317
|
+
});
|
|
318
|
+
|
|
264
319
|
// Enforce the boundary at run time as well as in the build. A field that reached a
|
|
265
320
|
// finding by some path the static check did not see stops the job here rather than
|
|
266
321
|
// being rendered into an issue in another repository.
|
|
@@ -273,7 +328,7 @@ runs:
|
|
|
273
328
|
}
|
|
274
329
|
|
|
275
330
|
const bodyFor = (finding) => [
|
|
276
|
-
`<!-- pcp-error-report: ${
|
|
331
|
+
`<!-- pcp-error-report: ${policy.markerKey(finding)} -->`,
|
|
277
332
|
'',
|
|
278
333
|
'Filed automatically by the `report-workflow-errors` job in a repository that installs',
|
|
279
334
|
'this package. It carries no information about that repository: only the names this',
|
|
@@ -286,6 +341,8 @@ runs:
|
|
|
286
341
|
`| Failing step | \`${finding.step}\` |`,
|
|
287
342
|
`| Conclusion | \`${finding.conclusion}\` |`,
|
|
288
343
|
`| Classification | \`${finding.patternId}\` |`,
|
|
344
|
+
`| Severity | \`${finding.severity}\` |`,
|
|
345
|
+
`| Job and step combinations with this cause | ${finding.variants.size} |`,
|
|
289
346
|
`| Runner labels | ${[...finding.runnerLabels].map((l) => `\`${l}\``).join(', ') || 'not reported'} |`,
|
|
290
347
|
`| Occurrences in the last ${hours}h | ${finding.count} |`,
|
|
291
348
|
`| Re-run attempts involved | ${[...finding.attempts].sort().join(', ')} |`,
|
|
@@ -298,8 +355,9 @@ runs:
|
|
|
298
355
|
`- ${new Date().toISOString().slice(0, 10)}: ${finding.count} in ${hours}h, on \`${packageVersion}\``,
|
|
299
356
|
].join('\n');
|
|
300
357
|
|
|
301
|
-
const titleFor = (finding) =>
|
|
302
|
-
|
|
358
|
+
const titleFor = (finding) => (finding.patternId === 'unrecognised'
|
|
359
|
+
? `[${finding.severity}] ${finding.workflow}: ${finding.patternId} in "${finding.step}"`
|
|
360
|
+
: `[${finding.severity}] ${finding.workflow}: ${finding.patternId}`);
|
|
303
361
|
|
|
304
362
|
// The scanner. A body that trips it is never filed, and the job goes red so the
|
|
305
363
|
// pattern that produced it gets fixed rather than quietly dropped every night.
|
|
@@ -325,6 +383,10 @@ runs:
|
|
|
325
383
|
'This report does not cover itself: a failure in this job shows up in this repository only.',
|
|
326
384
|
`Runs still queued after 45 minutes, so no runner ever took them: ${stuckInQueue}`,
|
|
327
385
|
`Distinct failure signatures: ${ranked.length}`,
|
|
386
|
+
`Filed or appended: ${selection.file.length}; held back: ${selection.held.length}`,
|
|
387
|
+
`Cancelled jobs with no failing step, not reported: ${quiet.cancelledNoStep}`,
|
|
388
|
+
`Jobs whose log could not be read, not reported: ${quiet.unreadable}`,
|
|
389
|
+
`Infrastructure causes, counted and not reported: ${[...quiet.infra].map(([id, n]) => `${id} ${n}`).join(', ') || 'none'}`,
|
|
328
390
|
queueWaitCount > 0
|
|
329
391
|
? `Runner queue wait: ${Math.round(queueWaitTotal / queueWaitCount)}s mean, ${queueWaitWorst}s worst`
|
|
330
392
|
: 'Runner queue wait: no jobs started in the window',
|
|
@@ -345,7 +407,7 @@ runs:
|
|
|
345
407
|
// full picture rather than one night at a time.
|
|
346
408
|
const reports = [];
|
|
347
409
|
let leaked = 0;
|
|
348
|
-
for (const finding of
|
|
410
|
+
for (const finding of selection.file) {
|
|
349
411
|
const title = titleFor(finding);
|
|
350
412
|
const body = bodyFor(finding);
|
|
351
413
|
if (!scan(`${title}\n${body}`, `The report for ${finding.patternId}`)) { leaked += 1; continue; }
|
|
@@ -373,10 +435,17 @@ runs:
|
|
|
373
435
|
let existing;
|
|
374
436
|
try {
|
|
375
437
|
const { data } = await upstream.rest.search.issuesAndPullRequests({
|
|
376
|
-
q: `repo:${upstreamOwner}/${upstreamName} is:issue
|
|
377
|
-
per_page:
|
|
438
|
+
q: `repo:${upstreamOwner}/${upstreamName} is:issue in:body "${marker}"`,
|
|
439
|
+
per_page: 10,
|
|
378
440
|
});
|
|
379
|
-
|
|
441
|
+
const matches = data.items.filter((item) => (item.body ?? '').includes(marker));
|
|
442
|
+
existing = matches.find((item) => item.state === 'open');
|
|
443
|
+
// Closing an issue is the answer to it. Without this every bulk close reopened the
|
|
444
|
+
// whole set the next morning: 150 of 320 reports re-filed a signature seen before.
|
|
445
|
+
if (!existing && matches.some((item) => policy.isQuiet(item.closed_at, Date.now(), Number(process.env.QUIET_DAYS)))) {
|
|
446
|
+
core.info(`left alone, an issue for it was closed within ${process.env.QUIET_DAYS} days: ${title}`);
|
|
447
|
+
continue;
|
|
448
|
+
}
|
|
380
449
|
} catch (error) {
|
|
381
450
|
core.warning(`could not search upstream for an existing report: HTTP ${error.status ?? '?'}`);
|
|
382
451
|
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// Managed by @plainconceptsplatform/workflows. Source: loops/actions/report-workflow-errors/report-policy.cjs. Update with workflows update --force; consumer edits may be overwritten.
|
|
2
|
+
//
|
|
3
|
+
// What the error report decides, kept apart from what it reads and files so it can be run on its
|
|
4
|
+
// own. Pure: no network, no clock, nothing read from the repository. Everything it sees is a
|
|
5
|
+
// finding the report already built from package-owned names and enumerated values, so nothing
|
|
6
|
+
// here can widen what leaves a private repository.
|
|
7
|
+
//
|
|
8
|
+
// The report used to open one issue per job, step and classification, for every failure it saw.
|
|
9
|
+
// Read back over a month, 320 issues: 59% were cancelled jobs, 80% carried no diagnosis, 14% were
|
|
10
|
+
// a runner shut down under a job, 47% re-filed a signature seen before, every one was closed as
|
|
11
|
+
// not planned and none had a comment. What follows is the filter that would have kept about a
|
|
12
|
+
// hundred of them out of that pile and left the rest as a count in the job summary.
|
|
13
|
+
|
|
14
|
+
'use strict';
|
|
15
|
+
|
|
16
|
+
// Causes that say something about the fleet, not about this package. They are counted in the job
|
|
17
|
+
// summary, where a trend is visible, and never become an issue: nobody can fix a runner that was
|
|
18
|
+
// taken away mid-job by editing a workflow. A job that hit its timeout is NOT here, because that
|
|
19
|
+
// can be the package's own work taking too long.
|
|
20
|
+
const INFRA = new Set([
|
|
21
|
+
'runner-never-assigned',
|
|
22
|
+
'runner-lost-mid-job',
|
|
23
|
+
'gh-api-rate-limit',
|
|
24
|
+
'model-quota',
|
|
25
|
+
'model-gateway-unreachable',
|
|
26
|
+
'out-of-disk',
|
|
27
|
+
'out-of-memory',
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
// Causes that are a defect in something this package ships.
|
|
31
|
+
const PACKAGE_BUG = new Set([
|
|
32
|
+
'jq-error',
|
|
33
|
+
'yaml-or-expression-invalid',
|
|
34
|
+
'lock-out-of-date',
|
|
35
|
+
'action-not-found',
|
|
36
|
+
'agent-produced-no-output',
|
|
37
|
+
'gh-api-403',
|
|
38
|
+
'gh-api-404',
|
|
39
|
+
'gh-api-409-conflict',
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
/** infra, package-bug, or unknown (a cause nobody classified yet, worth a look when it repeats). */
|
|
43
|
+
function severityOf(patternId) {
|
|
44
|
+
if (INFRA.has(patternId)) return 'infra';
|
|
45
|
+
if (PACKAGE_BUG.has(patternId)) return 'package-bug';
|
|
46
|
+
return 'unknown';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* One cause is one issue. A specific cause is the same defect whichever job of the router tripped
|
|
51
|
+
* on it, so the job and the step are not part of its identity: conclude, safe_outputs and reserve
|
|
52
|
+
* failing on one 403 are one finding, not three. A cause nobody could name is told apart by the
|
|
53
|
+
* step it failed in, because that is all there is to go on.
|
|
54
|
+
*/
|
|
55
|
+
function signatureOf({ workflow, step, conclusion, patternId }) {
|
|
56
|
+
if (patternId === 'unrecognised') return [workflow, step, conclusion, patternId].join('|');
|
|
57
|
+
return [workflow, conclusion, patternId].join('|');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The text the upstream search matches on, so a repeat lands on the issue that already exists. */
|
|
61
|
+
function markerKey({ workflow, step, conclusion, patternId }) {
|
|
62
|
+
return signatureOf({ workflow, step, conclusion, patternId });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Which findings become issues, and why the others do not.
|
|
67
|
+
* - infra causes are held: they are a rate, not a bug.
|
|
68
|
+
* - a finding seen fewer than `minOccurrences` times in the window is held: once is a blip.
|
|
69
|
+
* - at most `maxFindings` are filed per run, the most frequent first, so a bad night opens a
|
|
70
|
+
* handful of issues and not forty.
|
|
71
|
+
*/
|
|
72
|
+
function select(findings, { minOccurrences = 2, maxFindings = 5 } = {}) {
|
|
73
|
+
const ranked = [...findings].sort((a, b) => b.count - a.count);
|
|
74
|
+
const file = [];
|
|
75
|
+
const held = [];
|
|
76
|
+
for (const finding of ranked) {
|
|
77
|
+
if (finding.severity === 'infra') {
|
|
78
|
+
held.push({ finding, reason: 'infrastructure cause, counted not filed' });
|
|
79
|
+
} else if (finding.count < minOccurrences) {
|
|
80
|
+
held.push({ finding, reason: `seen ${finding.count} time(s), under the ${minOccurrences} needed to file` });
|
|
81
|
+
} else if (file.length >= maxFindings) {
|
|
82
|
+
held.push({ finding, reason: `over the ${maxFindings} filed per run` });
|
|
83
|
+
} else {
|
|
84
|
+
file.push(finding);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return { file, held };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* A signature closed recently stays quiet. Closing an issue is the answer to it; the report used
|
|
92
|
+
* to search open issues only, so every bulk close reopened the whole set the next morning.
|
|
93
|
+
*/
|
|
94
|
+
function isQuiet(closedAt, now, quietDays = 30) {
|
|
95
|
+
if (!closedAt) return false;
|
|
96
|
+
const closed = new Date(closedAt).getTime();
|
|
97
|
+
if (Number.isNaN(closed)) return false;
|
|
98
|
+
return now - closed < quietDays * 24 * 3600 * 1000;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The package version out of the ownership header. The character class used to swallow the
|
|
103
|
+
* full stop that ends the sentence, so every report said `1.2.3.`.
|
|
104
|
+
*/
|
|
105
|
+
function parseVersion(header) {
|
|
106
|
+
const match = /@plainconceptsplatform\/workflows@([0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.]*[0-9A-Za-z])?)/.exec(header ?? '');
|
|
107
|
+
return match ? match[1] : 'unknown';
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
module.exports = { INFRA, PACKAGE_BUG, severityOf, signatureOf, markerKey, select, isQuiet, parseVersion };
|