@jaybeeuu/agent-cortex 2.7.0 → 2.9.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/CHANGELOG.md +412 -0
- package/README.md +87 -8
- package/agents/README.md +4 -4
- package/agents/guppi/agent.md +162 -0
- package/agents/guppi/claude/frontmatter.json +6 -0
- package/agents/guppi/pi/frontmatter.json +6 -0
- package/agents/plan/agent.md +6 -0
- package/agents/ralph/agent.md +17 -16
- package/agents/ralph/claude/wake.md +4 -4
- package/agents/ralph/pi/wake.md +15 -20
- package/agents/ralph-plan/agent.md +6 -0
- package/bin/agent-cortex.mjs +20 -1
- package/bin/installers/claude.mjs +1 -20
- package/bin/installers/pi.mjs +8 -2
- package/extensions/interrupt-bookmark/README.md +129 -0
- package/extensions/interrupt-bookmark/index.test.ts +717 -0
- package/extensions/interrupt-bookmark/index.ts +354 -0
- package/extensions/interrupt-bookmark/package.json +20 -0
- package/extensions/post-merge-watch/README.md +133 -0
- package/extensions/post-merge-watch/index.test.ts +247 -0
- package/extensions/post-merge-watch/index.ts +123 -0
- package/extensions/post-merge-watch/package.json +16 -0
- package/extensions/post-merge-watch/watch.test.ts +469 -0
- package/extensions/post-merge-watch/watch.ts +431 -0
- package/extensions/session-clear/README.md +74 -0
- package/extensions/session-clear/index.test.ts +524 -0
- package/extensions/session-clear/index.ts +159 -0
- package/extensions/session-clear/package.json +16 -0
- package/extensions/skill-stats/README.md +84 -3
- package/extensions/skill-stats/index.ts +62 -3
- package/extensions/skill-stats/package.json +1 -1
- package/extensions/skill-stats/rollup.test.ts +643 -0
- package/extensions/skill-stats/rollup.ts +415 -0
- package/extensions/skill-stats/timeline.ts +101 -0
- package/lib/cli.mjs +42 -1
- package/lib/self-update.mjs +553 -0
- package/package.json +1 -1
- package/scripts/lib/manifest.mjs +29 -0
- package/skills/engineering/implement-task/SKILL.md +124 -0
- package/skills/engineering/tdd/SKILL.md +2 -0
- package/skills/planning/create-task/REFERENCE.md +15 -6
- package/skills/planning/create-task/SKILL.md +1 -1
- package/skills/planning/create-task/pipeline.json +8 -0
- package/skills/planning/create-task/scripts/create-chores.mjs +27 -18
- package/skills/planning/create-task/templates/code.md +8 -3
- package/skills/planning/create-task/templates/document.md +8 -3
- package/skills/planning/create-task/templates/fix.md +8 -3
- package/skills/planning/create-task/templates/housekeeping.md +8 -4
- package/skills/planning/create-task/templates/post-merge.md +67 -0
- package/skills/planning/create-task/templates/review.md +8 -3
- package/skills/planning/create-task/templates/verify.md +8 -3
- package/skills/productivity/bd-tool/SKILL.md +55 -0
- package/skills/review/review-defects/EXAMPLES.md +198 -0
- package/skills/review/review-defects/SKILL.md +121 -0
- package/skills/review/review-report/EXAMPLES.md +114 -0
- package/skills/review/review-report/SKILL.md +138 -0
- package/skills/review/review-report/scripts/report.mjs +1032 -0
- package/skills/style/style-tests/EXAMPLES.md +30 -0
- package/skills/style/style-tests/SKILL.md +10 -6
- package/skills/workflow/hitl-queue/SKILL.md +134 -0
- package/skills/workflow/hitl-queue/scripts/queue.mjs +626 -0
- package/skills/workflow/plan/SKILL.md +27 -3
- package/skills/workflow/prioritise-work/CHALLENGE.md +139 -0
- package/skills/workflow/prioritise-work/SKILL.md +150 -0
- package/skills/workflow/prioritise-work/scripts/challenge.mjs +505 -0
- package/skills/workflow/prioritise-work/scripts/strategy.mjs +566 -0
- package/skills/workflow/prioritise-work/templates/strategy.md +61 -0
- package/skills/workflow/ralph/REFERENCE.md +124 -27
- package/skills/workflow/ralph/SKILL.md +24 -15
- package/skills/workflow/ralph/scripts/post-merge-check.mjs +651 -0
- package/skills/workflow/run-pipeline-stage/SKILL.md +37 -22
- package/skills/workflow/run-pipeline-stage/playbooks/code.md +14 -8
- package/skills/workflow/run-pipeline-stage/playbooks/document.md +2 -2
- package/skills/workflow/run-pipeline-stage/playbooks/fix.md +2 -2
- package/skills/workflow/run-pipeline-stage/playbooks/housekeeping.md +1 -1
- package/skills/workflow/run-pipeline-stage/playbooks/post-merge.md +54 -0
- package/skills/workflow/run-pipeline-stage/playbooks/review.md +37 -3
- package/skills/workflow/run-pipeline-stage/playbooks/verify.md +2 -2
- package/skills/workflow/run-pipeline-stage/prompts/stage-runner.md +55 -11
- package/skills/workflow/run-pipeline-stage/scripts/recover-brief.mjs +805 -0
- package/skills/workflow/run-pipeline-stage/scripts/trail-entry.mjs +702 -0
- package/skills/workflow/score-bead/FORMAT.md +73 -0
- package/skills/workflow/score-bead/SKILL.md +112 -0
- package/skills/workflow/score-bead/scripts/rice.mjs +664 -0
- package/skills/workflow/using-agent-skills/SKILL.md +21 -8
- package/token-map.README.md +27 -0
- package/token-map.json +16 -4
|
@@ -0,0 +1,651 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// Post-merge check: did the CI run for the merge commit pass?
|
|
3
|
+
//
|
|
4
|
+
// Ralph's post-merge stage runs after a human merges the feature PR and closes the
|
|
5
|
+
// `lifecycle:feature-pr` gate. This script reads `gh run list` for the base branch's
|
|
6
|
+
// tip commit and answers one question with an exit code:
|
|
7
|
+
//
|
|
8
|
+
// 0 passed every run for the commit concluded successfully
|
|
9
|
+
// 1 failed a run concluded in failure; one P1 escalation bead is filed
|
|
10
|
+
// carrying the run URL and the failing job (a re-run files nothing)
|
|
11
|
+
// 2 inconclusive no run for the commit on the base branch, a run still on the wait
|
|
12
|
+
// window, a conclusion that is neither pass nor failure, or a
|
|
13
|
+
// repository `gh` cannot see as GitHub. Nothing is filed.
|
|
14
|
+
//
|
|
15
|
+
// The check never retries a run, never re-runs a workflow, and never touches the
|
|
16
|
+
// parent bead: a failure is reported to a human, not repaired by the pipeline.
|
|
17
|
+
//
|
|
18
|
+
// GitHub-only in v1: a remote `gh` cannot resolve to a GitHub repository is
|
|
19
|
+
// inconclusive. The repository slug and base branch come from `gh repo view`, never
|
|
20
|
+
// from a hardcoded constant, because this ships to arbitrary consuming repos.
|
|
21
|
+
//
|
|
22
|
+
// Zero dependencies: run with `node post-merge-check.mjs`.
|
|
23
|
+
|
|
24
|
+
import { execFile } from 'node:child_process';
|
|
25
|
+
import { promisify } from 'node:util';
|
|
26
|
+
import { pathToFileURL } from 'node:url';
|
|
27
|
+
|
|
28
|
+
const execFileAsync = promisify(execFile);
|
|
29
|
+
|
|
30
|
+
// GH_PATH / BD_PATH env overrides let machines where a binary is not on PATH pin a
|
|
31
|
+
// location. git lives beside them conceptually, so GIT_PATH follows the same shape.
|
|
32
|
+
const GH_PATH = process.env.GH_PATH || 'gh';
|
|
33
|
+
const BD_PATH = process.env.BD_PATH || 'bd';
|
|
34
|
+
const GIT_PATH = process.env.GIT_PATH || 'git';
|
|
35
|
+
|
|
36
|
+
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* A workflow run as `gh run list --json` reports it.
|
|
40
|
+
* @typedef {object} WorkflowRun
|
|
41
|
+
* @property {number} databaseId
|
|
42
|
+
* @property {string} url
|
|
43
|
+
* @property {string} workflowName
|
|
44
|
+
* @property {string} headBranch
|
|
45
|
+
* @property {string} headSha
|
|
46
|
+
* @property {string} status `completed` once the run settled
|
|
47
|
+
* @property {string | null} conclusion
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* A job that concluded in failure, as reported in the escalation bead.
|
|
52
|
+
* @typedef {object} FailingJob
|
|
53
|
+
* @property {string} name
|
|
54
|
+
* @property {string} url
|
|
55
|
+
*/
|
|
56
|
+
|
|
57
|
+
/** @typedef {'passed' | 'failed' | 'inconclusive'} Outcome */
|
|
58
|
+
|
|
59
|
+
/** A child-process call that returns stdout. @typedef {(args: string[]) => Promise<string>} Runner */
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The one escalation bead a failing run files, and whether this run filed it.
|
|
63
|
+
* @typedef {object} Escalation
|
|
64
|
+
* @property {boolean} filed
|
|
65
|
+
* @property {string} [beadId] Set when this run created the bead.
|
|
66
|
+
* @property {string} [existing] Set when a bead for this run was already filed.
|
|
67
|
+
* @property {string} [detail] Set when the bead could not be filed.
|
|
68
|
+
*/
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The machine-readable result, printed by the CLI and returned to tests.
|
|
72
|
+
* @typedef {object} CheckReport
|
|
73
|
+
* @property {Outcome} outcome
|
|
74
|
+
* @property {number} exitCode
|
|
75
|
+
* @property {string} reason
|
|
76
|
+
* @property {string} base
|
|
77
|
+
* @property {string | null} repository
|
|
78
|
+
* @property {string | null} commit
|
|
79
|
+
* @property {WorkflowRun[]} runs Runs for the commit on the base branch.
|
|
80
|
+
* @property {string[]} ignoredBranches Branches with runs for the same commit, not read.
|
|
81
|
+
* @property {FailingJob[]} failingJobs
|
|
82
|
+
* @property {Escalation} escalation
|
|
83
|
+
*/
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Injected collaborators, so a test can drive the check without a GitHub account,
|
|
87
|
+
* a network, or the real bead database.
|
|
88
|
+
* @typedef {object} CheckDeps
|
|
89
|
+
* @property {Runner} [gh]
|
|
90
|
+
* @property {Runner} [bd]
|
|
91
|
+
* @property {Runner} [git]
|
|
92
|
+
*/
|
|
93
|
+
|
|
94
|
+
// ─── Contract ─────────────────────────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
/** Exit codes are the contract: 0 passed, 1 failed, 2 inconclusive.
|
|
97
|
+
* @type {Record<Outcome, number>} */
|
|
98
|
+
export const EXIT_CODES = { passed: 0, failed: 1, inconclusive: 2 };
|
|
99
|
+
|
|
100
|
+
/** The label that keys the escalation search, so a re-run finds the bead it filed. */
|
|
101
|
+
export const ESCALATION_LABEL = 'lifecycle:post-merge-escalation';
|
|
102
|
+
|
|
103
|
+
/** Seconds the check waits for a run to appear and settle before it reports inconclusive. */
|
|
104
|
+
export const DEFAULT_WAIT_SECONDS = 300;
|
|
105
|
+
|
|
106
|
+
/** Used only when `gh` reports no default branch for the repository. */
|
|
107
|
+
const DEFAULT_BASE_BRANCH = 'main';
|
|
108
|
+
|
|
109
|
+
/** Seconds between two `gh run list` calls while a run is still in flight. */
|
|
110
|
+
const POLL_INTERVAL_MS = 10_000;
|
|
111
|
+
|
|
112
|
+
/** Every field the check reads from a run, and nothing else. */
|
|
113
|
+
const RUN_FIELDS = 'databaseId,url,workflowName,headBranch,headSha,status,conclusion';
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Conclusions that mean the commit is good. `skipped` and `neutral` are not failures:
|
|
117
|
+
* this repository's release job is skipped on every non-release run.
|
|
118
|
+
*/
|
|
119
|
+
const PASSING_CONCLUSIONS = new Set(['success', 'neutral', 'skipped']);
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Conclusions that mean the commit is bad. `cancelled`, `stale` and `action_required`
|
|
123
|
+
* are deliberately absent: they report no verdict on the commit, so they are
|
|
124
|
+
* inconclusive rather than a failure a human must be paged for.
|
|
125
|
+
*/
|
|
126
|
+
const FAILING_CONCLUSIONS = new Set(['failure', 'timed_out', 'startup_failure']);
|
|
127
|
+
|
|
128
|
+
class UsageError extends Error {}
|
|
129
|
+
|
|
130
|
+
// ─── Classification ───────────────────────────────────────────────────────────
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* @param {WorkflowRun[]} runs
|
|
134
|
+
* @returns {string[]} The distinct conclusions in `runs`, in run order.
|
|
135
|
+
*/
|
|
136
|
+
function conclusionsOf(runs) {
|
|
137
|
+
return [...new Set(runs.map((run) => run.conclusion ?? 'null'))];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Classify the runs for one commit. Pure: the exit-code contract is decided here,
|
|
142
|
+
* so the three outcomes are readable in one place.
|
|
143
|
+
*
|
|
144
|
+
* An empty run set is inconclusive, never passed — a commit with no run has not been
|
|
145
|
+
* verified, and reporting that as a pass would hide a broken workflow.
|
|
146
|
+
*
|
|
147
|
+
* @param {WorkflowRun[]} runs Completed runs for the commit on the base branch.
|
|
148
|
+
* @returns {{ outcome: Outcome, reason: string, failingRuns: WorkflowRun[], conclusions: string[] }}
|
|
149
|
+
*/
|
|
150
|
+
export function classifyRuns(runs) {
|
|
151
|
+
if (runs.length === 0) {
|
|
152
|
+
return { outcome: 'inconclusive', reason: 'no-run', failingRuns: [], conclusions: [] };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const failingRuns = runs.filter((run) => FAILING_CONCLUSIONS.has(run.conclusion ?? ''));
|
|
156
|
+
if (failingRuns.length > 0) {
|
|
157
|
+
return { outcome: 'failed', reason: 'run-failed', failingRuns, conclusions: conclusionsOf(runs) };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const unrecognised = runs.filter((run) => !PASSING_CONCLUSIONS.has(run.conclusion ?? ''));
|
|
161
|
+
if (unrecognised.length > 0) {
|
|
162
|
+
return {
|
|
163
|
+
outcome: 'inconclusive',
|
|
164
|
+
reason: 'unrecognised-conclusion',
|
|
165
|
+
failingRuns: [],
|
|
166
|
+
conclusions: conclusionsOf(unrecognised),
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return { outcome: 'passed', reason: 'runs-passed', failingRuns: [], conclusions: conclusionsOf(runs) };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ─── Runners ──────────────────────────────────────────────────────────────────
|
|
174
|
+
|
|
175
|
+
/** @type {Runner} */
|
|
176
|
+
async function runGh(args) {
|
|
177
|
+
const { stdout } = await execFileAsync(GH_PATH, args, { encoding: 'utf-8' });
|
|
178
|
+
return stdout;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** @type {Runner} */
|
|
182
|
+
async function runBd(args) {
|
|
183
|
+
const { stdout } = await execFileAsync(BD_PATH, args, { encoding: 'utf-8' });
|
|
184
|
+
return stdout;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* A git runner bound to one working tree, so the check reads the repository it was
|
|
189
|
+
* pointed at rather than the process's own directory.
|
|
190
|
+
* @param {string} cwd
|
|
191
|
+
* @returns {Runner}
|
|
192
|
+
*/
|
|
193
|
+
function gitRunner(cwd) {
|
|
194
|
+
return async (args) => {
|
|
195
|
+
const { stdout } = await execFileAsync(GIT_PATH, args, { cwd, encoding: 'utf-8' });
|
|
196
|
+
return stdout;
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ─── Repository and commit ────────────────────────────────────────────────────
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Read the repository slug and its default branch through `gh`, so the check works in
|
|
204
|
+
* any consuming repository instead of assuming one slug and one branch. `gh` resolves
|
|
205
|
+
* the remote itself, which is also the GitHub-only test: a remote it cannot map to a
|
|
206
|
+
* GitHub host exits non-zero.
|
|
207
|
+
*
|
|
208
|
+
* @param {Runner} gh
|
|
209
|
+
* @param {string} [base] Caller-supplied base branch, overriding the default branch.
|
|
210
|
+
* @returns {Promise<{ nameWithOwner: string, base: string } | null>} null when `gh`
|
|
211
|
+
* cannot see a GitHub repository.
|
|
212
|
+
*/
|
|
213
|
+
async function readRepository(gh, base) {
|
|
214
|
+
try {
|
|
215
|
+
const raw = await gh(['repo', 'view', '--json', 'nameWithOwner,defaultBranchRef']);
|
|
216
|
+
const parsed = JSON.parse(raw);
|
|
217
|
+
const defaultBranch = parsed?.defaultBranchRef?.name;
|
|
218
|
+
return {
|
|
219
|
+
nameWithOwner: typeof parsed?.nameWithOwner === 'string' ? parsed.nameWithOwner : '',
|
|
220
|
+
base: base ?? (typeof defaultBranch === 'string' && defaultBranch !== '' ? defaultBranch : DEFAULT_BASE_BRANCH),
|
|
221
|
+
};
|
|
222
|
+
} catch {
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Resolve the full SHA of the merge commit. `gh run list --commit` matches on the full
|
|
229
|
+
* SHA and returns nothing for a short revision, so every caller-supplied revision is
|
|
230
|
+
* expanded through git before it reaches gh.
|
|
231
|
+
*
|
|
232
|
+
* The fetch is what makes the check read the merge that just landed rather than a stale
|
|
233
|
+
* local ref. A failed fetch is not fatal: the local ref still resolves, and the report
|
|
234
|
+
* carries `fetchFailed`.
|
|
235
|
+
*
|
|
236
|
+
* @param {Runner} git
|
|
237
|
+
* @param {{ commit?: string, base: string, fetch: boolean }} options
|
|
238
|
+
* @returns {Promise<{ sha: string, fetchFailed: boolean } | null>} null when no revision
|
|
239
|
+
* resolves to a commit.
|
|
240
|
+
*/
|
|
241
|
+
async function resolveCommit(git, { commit, base, fetch }) {
|
|
242
|
+
let fetchFailed = false;
|
|
243
|
+
if (fetch) {
|
|
244
|
+
try {
|
|
245
|
+
await git(['fetch', '--quiet', 'origin', base]);
|
|
246
|
+
} catch {
|
|
247
|
+
fetchFailed = true;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const revisions = commit === undefined ? [`origin/${base}`, base] : [commit];
|
|
252
|
+
for (const revision of revisions) {
|
|
253
|
+
try {
|
|
254
|
+
const sha = (await git(['rev-parse', '--verify', '--quiet', `${revision}^{commit}`])).trim();
|
|
255
|
+
if (sha !== '') return { sha, fetchFailed };
|
|
256
|
+
} catch {
|
|
257
|
+
// Try the next candidate revision.
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
return null;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// ─── Runs ─────────────────────────────────────────────────────────────────────
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* @param {Runner} gh
|
|
267
|
+
* @param {string} sha
|
|
268
|
+
* @returns {Promise<WorkflowRun[]>}
|
|
269
|
+
*/
|
|
270
|
+
async function listRuns(gh, sha) {
|
|
271
|
+
const raw = await gh(['run', 'list', '--commit', sha, '--limit', '30', '--json', RUN_FIELDS]);
|
|
272
|
+
const parsed = raw.trim() === '' ? [] : JSON.parse(raw);
|
|
273
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* @param {WorkflowRun} run
|
|
278
|
+
* @returns {boolean}
|
|
279
|
+
*/
|
|
280
|
+
function isSettled(run) {
|
|
281
|
+
return run.status === 'completed';
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Wait for a run to appear for the commit on the base branch and to settle. Runs for
|
|
286
|
+
* other branches are ignored: a `pull_request` run on a release branch shares no
|
|
287
|
+
* verdict about the base branch, and gh keeps no such run for a merged commit.
|
|
288
|
+
*
|
|
289
|
+
* @param {object} options
|
|
290
|
+
* @param {Runner} options.gh
|
|
291
|
+
* @param {string} options.sha
|
|
292
|
+
* @param {string} options.base
|
|
293
|
+
* @param {number} options.waitSeconds `0` checks once instead of polling.
|
|
294
|
+
* @returns {Promise<{ runs: WorkflowRun[], ignoredBranches: string[], reason: string }>}
|
|
295
|
+
*/
|
|
296
|
+
async function awaitRuns({ gh, sha, base, waitSeconds }) {
|
|
297
|
+
const deadline = Date.now() + waitSeconds * 1000;
|
|
298
|
+
|
|
299
|
+
for (;;) {
|
|
300
|
+
const all = await listRuns(gh, sha);
|
|
301
|
+
const runs = all.filter((run) => run.headBranch === base);
|
|
302
|
+
const ignoredBranches = [
|
|
303
|
+
...new Set(all.filter((run) => run.headBranch !== base).map((run) => run.headBranch)),
|
|
304
|
+
];
|
|
305
|
+
|
|
306
|
+
if (runs.length > 0 && runs.every(isSettled)) {
|
|
307
|
+
return { runs, ignoredBranches, reason: 'settled' };
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if (Date.now() >= deadline) {
|
|
311
|
+
return { runs, ignoredBranches, reason: runs.length === 0 ? 'no-run' : 'still-running' };
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
await new Promise((resolve) => setTimeout(resolve, Math.min(POLL_INTERVAL_MS, deadline - Date.now())));
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Read the jobs of a failing run and keep the ones that concluded in failure.
|
|
320
|
+
* @param {Runner} gh
|
|
321
|
+
* @param {WorkflowRun} run
|
|
322
|
+
* @returns {Promise<FailingJob[]>}
|
|
323
|
+
*/
|
|
324
|
+
async function readFailingJobs(gh, run) {
|
|
325
|
+
const raw = await gh(['run', 'view', String(run.databaseId), '--json', 'jobs']);
|
|
326
|
+
const parsed = JSON.parse(raw);
|
|
327
|
+
/** @type {Array<{ name?: unknown, url?: unknown, conclusion?: unknown }>} */
|
|
328
|
+
const jobs = Array.isArray(parsed?.jobs) ? parsed.jobs : [];
|
|
329
|
+
return jobs
|
|
330
|
+
.filter((job) => FAILING_CONCLUSIONS.has(typeof job.conclusion === 'string' ? job.conclusion : ''))
|
|
331
|
+
.map((job) => ({
|
|
332
|
+
name: typeof job.name === 'string' ? job.name : 'unnamed job',
|
|
333
|
+
url: typeof job.url === 'string' ? job.url : run.url,
|
|
334
|
+
}));
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// ─── Escalation ───────────────────────────────────────────────────────────────
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* The marker that keeps escalation filing from paging the same human twice for one run: every
|
|
341
|
+
* escalation bead carries it in its description, so a second run of the check finds the bead and
|
|
342
|
+
* files nothing. A run id is immutable, so one bead per run is the intent; `bd list` omits closed
|
|
343
|
+
* beads, so the guarantee is one **open** bead per run and a resolved run can be escalated again.
|
|
344
|
+
* See `findFiledEscalation` for the limit this leaves.
|
|
345
|
+
* @param {WorkflowRun} run
|
|
346
|
+
* @returns {string}
|
|
347
|
+
*/
|
|
348
|
+
export function runMarker(run) {
|
|
349
|
+
return `post-merge-run:${run.databaseId}`;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* @param {WorkflowRun} run
|
|
354
|
+
* @param {string} commit
|
|
355
|
+
* @param {string} base
|
|
356
|
+
* @returns {string}
|
|
357
|
+
*/
|
|
358
|
+
function escalationTitle(run, commit, base) {
|
|
359
|
+
return `[post-merge] ${run.workflowName || 'workflow'} failed for ${commit.slice(0, 7)} on ${base}`;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* @param {object} input
|
|
364
|
+
* @param {WorkflowRun} input.run
|
|
365
|
+
* @param {FailingJob[]} input.jobs
|
|
366
|
+
* @param {string} input.commit
|
|
367
|
+
* @param {string} input.base
|
|
368
|
+
* @returns {string}
|
|
369
|
+
*/
|
|
370
|
+
function escalationDescription({ run, jobs, commit, base }) {
|
|
371
|
+
const failingJobs =
|
|
372
|
+
jobs.length > 0
|
|
373
|
+
? jobs.map((job) => `- Failing job: ${job.name} — ${job.url}`).join('\n')
|
|
374
|
+
: '- Failing job: none reported by the run';
|
|
375
|
+
|
|
376
|
+
return [
|
|
377
|
+
`The CI run for merge commit ${commit} on ${base} concluded \`${run.conclusion ?? 'unknown'}\`.`,
|
|
378
|
+
'',
|
|
379
|
+
`- Workflow: ${run.workflowName || 'workflow'} (run ${run.databaseId})`,
|
|
380
|
+
`- Run: ${run.url}`,
|
|
381
|
+
failingJobs,
|
|
382
|
+
'',
|
|
383
|
+
runMarker(run),
|
|
384
|
+
'',
|
|
385
|
+
'## Steps to Reproduce',
|
|
386
|
+
'',
|
|
387
|
+
`1. Open ${run.url} and read the failing job log.`,
|
|
388
|
+
`2. Check out ${commit} and run the failing job's commands locally.`,
|
|
389
|
+
'',
|
|
390
|
+
'## Acceptance Criteria',
|
|
391
|
+
'',
|
|
392
|
+
`- [ ] The failing job is green on a later commit of ${base}, or the merge is reverted.`,
|
|
393
|
+
'- [ ] This bead is closed by the human who resolved it.',
|
|
394
|
+
].join('\n');
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Find an **open** escalation this check already filed for the run.
|
|
399
|
+
*
|
|
400
|
+
* `bd list` omits closed beads, so a bead a human already resolved is not found and a later check
|
|
401
|
+
* of the same run files a fresh one. That repeats a signal for a run someone closed; it never
|
|
402
|
+
* loses one. Tracked as a known limitation — do not read this as one bead per run forever.
|
|
403
|
+
* @param {Runner} bd
|
|
404
|
+
* @param {string} marker
|
|
405
|
+
* @returns {Promise<string | null>} The bead id, or null when nothing was filed yet.
|
|
406
|
+
*/
|
|
407
|
+
async function findFiledEscalation(bd, marker) {
|
|
408
|
+
const raw = await bd(['list', '--json', '--limit', '0', '-l', ESCALATION_LABEL]);
|
|
409
|
+
const beads = raw.trim() === '' ? [] : JSON.parse(raw);
|
|
410
|
+
const filed = (Array.isArray(beads) ? beads : []).find(
|
|
411
|
+
(bead) => typeof bead?.description === 'string' && bead.description.includes(marker),
|
|
412
|
+
);
|
|
413
|
+
return filed ? String(filed.id) : null;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* File the P1 escalation bead for a failed run — at most one **open** bead per run, whatever the
|
|
418
|
+
* number of check invocations. A bead a human has closed is not found again, so this can repeat
|
|
419
|
+
* an escalation for a run already resolved (see `findFiledEscalation`). The bead is independent:
|
|
420
|
+
* it carries no parent, so the feature bead is never reopened and never gains a child.
|
|
421
|
+
*
|
|
422
|
+
* @param {object} input
|
|
423
|
+
* @param {Runner} input.bd
|
|
424
|
+
* @param {WorkflowRun} input.run
|
|
425
|
+
* @param {FailingJob[]} input.jobs
|
|
426
|
+
* @param {string} input.commit
|
|
427
|
+
* @param {string} input.base
|
|
428
|
+
* @returns {Promise<Escalation>}
|
|
429
|
+
*/
|
|
430
|
+
export async function fileEscalation({ bd, run, jobs, commit, base }) {
|
|
431
|
+
const existing = await findFiledEscalation(bd, runMarker(run));
|
|
432
|
+
if (existing !== null) return { filed: false, existing };
|
|
433
|
+
|
|
434
|
+
try {
|
|
435
|
+
const beadId = (
|
|
436
|
+
await bd([
|
|
437
|
+
'create',
|
|
438
|
+
escalationTitle(run, commit, base),
|
|
439
|
+
'--type', 'bug',
|
|
440
|
+
'--priority', '1',
|
|
441
|
+
'--description', escalationDescription({ run, jobs, commit, base }),
|
|
442
|
+
'--labels', `execution:hitl,${ESCALATION_LABEL}`,
|
|
443
|
+
// The bead is human-owned, so it never inherits an executor label from a parent.
|
|
444
|
+
'--no-inherit-labels',
|
|
445
|
+
'--validate',
|
|
446
|
+
'--silent',
|
|
447
|
+
])
|
|
448
|
+
).trim();
|
|
449
|
+
return { filed: true, beadId };
|
|
450
|
+
} catch (error) {
|
|
451
|
+
return { filed: false, detail: error instanceof Error ? error.message : String(error) };
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// ─── Check ────────────────────────────────────────────────────────────────────
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* @param {object} input
|
|
459
|
+
* @param {string} input.outcome
|
|
460
|
+
* @param {string} input.reason
|
|
461
|
+
* @param {Partial<CheckReport>} [input.rest]
|
|
462
|
+
* @returns {CheckReport}
|
|
463
|
+
*/
|
|
464
|
+
function buildReport({ outcome, reason, rest = {} }) {
|
|
465
|
+
/** @type {Outcome} */
|
|
466
|
+
const settled = outcome === 'passed' || outcome === 'failed' ? outcome : 'inconclusive';
|
|
467
|
+
return {
|
|
468
|
+
outcome: settled,
|
|
469
|
+
exitCode: EXIT_CODES[settled],
|
|
470
|
+
reason,
|
|
471
|
+
base: DEFAULT_BASE_BRANCH,
|
|
472
|
+
repository: null,
|
|
473
|
+
commit: null,
|
|
474
|
+
runs: [],
|
|
475
|
+
ignoredBranches: [],
|
|
476
|
+
failingJobs: [],
|
|
477
|
+
escalation: { filed: false },
|
|
478
|
+
...rest,
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* Run the check and return its report. Every expected failure — no repository, no run,
|
|
484
|
+
* a run still in flight — is a report, not a thrown error.
|
|
485
|
+
*
|
|
486
|
+
* @param {object} input
|
|
487
|
+
* @param {string} input.cwd Directory of the repository checkout to read.
|
|
488
|
+
* @param {string} [input.base] Base branch; the repository default when omitted.
|
|
489
|
+
* @param {string} [input.commit] Merge commit; the base branch tip when omitted.
|
|
490
|
+
* @param {number} [input.waitSeconds]
|
|
491
|
+
* @param {boolean} [input.fetch] Fetch the base branch before resolving it. Default true.
|
|
492
|
+
* @param {CheckDeps} [input.deps]
|
|
493
|
+
* @returns {Promise<CheckReport>}
|
|
494
|
+
*/
|
|
495
|
+
export async function checkPostMerge({
|
|
496
|
+
cwd,
|
|
497
|
+
base,
|
|
498
|
+
commit,
|
|
499
|
+
waitSeconds = DEFAULT_WAIT_SECONDS,
|
|
500
|
+
fetch = true,
|
|
501
|
+
deps = {},
|
|
502
|
+
}) {
|
|
503
|
+
const gh = deps.gh ?? runGh;
|
|
504
|
+
const bd = deps.bd ?? runBd;
|
|
505
|
+
const git = deps.git ?? gitRunner(cwd);
|
|
506
|
+
|
|
507
|
+
const repository = await readRepository(gh, base);
|
|
508
|
+
if (repository === null) {
|
|
509
|
+
return buildReport({
|
|
510
|
+
outcome: 'inconclusive',
|
|
511
|
+
reason: 'no-github-repository',
|
|
512
|
+
rest: { base: base ?? DEFAULT_BASE_BRANCH },
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
const resolved = await resolveCommit(git, { commit, base: repository.base, fetch });
|
|
517
|
+
if (resolved === null) {
|
|
518
|
+
return buildReport({
|
|
519
|
+
outcome: 'inconclusive',
|
|
520
|
+
reason: 'unresolved-commit',
|
|
521
|
+
rest: { base: repository.base, repository: repository.nameWithOwner },
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
const sha = resolved.sha;
|
|
526
|
+
const baseReport = { base: repository.base, repository: repository.nameWithOwner, commit: sha };
|
|
527
|
+
|
|
528
|
+
let settled;
|
|
529
|
+
try {
|
|
530
|
+
settled = await awaitRuns({ gh, sha, base: repository.base, waitSeconds });
|
|
531
|
+
} catch (error) {
|
|
532
|
+
return buildReport({
|
|
533
|
+
outcome: 'inconclusive',
|
|
534
|
+
reason: 'gh-failed',
|
|
535
|
+
rest: { ...baseReport, escalation: { filed: false, detail: error instanceof Error ? error.message : String(error) } },
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
const { runs, ignoredBranches } = settled;
|
|
540
|
+
|
|
541
|
+
if (settled.reason !== 'settled') {
|
|
542
|
+
return buildReport({
|
|
543
|
+
outcome: 'inconclusive',
|
|
544
|
+
reason: settled.reason,
|
|
545
|
+
rest: { ...baseReport, runs, ignoredBranches },
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
const classification = classifyRuns(runs);
|
|
550
|
+
const runsReport = { ...baseReport, runs, ignoredBranches };
|
|
551
|
+
|
|
552
|
+
if (classification.outcome !== 'failed') {
|
|
553
|
+
return buildReport({
|
|
554
|
+
outcome: classification.outcome,
|
|
555
|
+
reason: classification.reason,
|
|
556
|
+
rest: runsReport,
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// One escalation per failing run, keyed on the first failing run's id. The check
|
|
561
|
+
// reports every failing run, and files for the first so a repeated run converges.
|
|
562
|
+
const failingRun = classification.failingRuns[0];
|
|
563
|
+
const jobs = await readFailingJobs(gh, failingRun).catch(() => []);
|
|
564
|
+
const escalation = await fileEscalation({ bd, run: failingRun, jobs, commit: sha, base: repository.base });
|
|
565
|
+
|
|
566
|
+
return buildReport({
|
|
567
|
+
outcome: 'failed',
|
|
568
|
+
reason: classification.reason,
|
|
569
|
+
rest: { ...runsReport, failingJobs: jobs, escalation },
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// ─── CLI ──────────────────────────────────────────────────────────────────────
|
|
574
|
+
|
|
575
|
+
const USAGE = `Usage: node post-merge-check.mjs [--base <branch>] [--commit <sha>] [--wait <seconds>] [--no-fetch]
|
|
576
|
+
|
|
577
|
+
Checks the CI runs for the base branch's merge commit and prints a JSON report.
|
|
578
|
+
|
|
579
|
+
Exit codes: 0 passed, 1 failed (one P1 escalation bead is filed), 2 inconclusive.
|
|
580
|
+
|
|
581
|
+
--base <branch> base branch; the repository's default branch when omitted
|
|
582
|
+
--commit <sha> commit to check; the base branch tip when omitted
|
|
583
|
+
--wait <seconds> how long to wait for a run to appear and settle (default ${DEFAULT_WAIT_SECONDS});
|
|
584
|
+
--wait 0 checks once
|
|
585
|
+
--no-fetch read the local base branch ref instead of fetching it first
|
|
586
|
+
`;
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* @typedef {object} CliOptions
|
|
590
|
+
* @property {string} [base]
|
|
591
|
+
* @property {string} [commit]
|
|
592
|
+
* @property {number} [waitSeconds]
|
|
593
|
+
* @property {boolean} fetch
|
|
594
|
+
*/
|
|
595
|
+
|
|
596
|
+
/**
|
|
597
|
+
* @param {string[]} argv
|
|
598
|
+
* @returns {CliOptions}
|
|
599
|
+
*/
|
|
600
|
+
function parseArgs(argv) {
|
|
601
|
+
/** @type {CliOptions} */
|
|
602
|
+
const options = { fetch: true };
|
|
603
|
+
|
|
604
|
+
for (let i = 0; i < argv.length; i++) {
|
|
605
|
+
const arg = argv[i];
|
|
606
|
+
if (arg === '--base' && argv[i + 1] !== undefined) { options.base = argv[++i]; }
|
|
607
|
+
else if (arg === '--commit' && argv[i + 1] !== undefined) { options.commit = argv[++i]; }
|
|
608
|
+
else if (arg === '--wait' && argv[i + 1] !== undefined) { options.waitSeconds = Number(argv[++i]); }
|
|
609
|
+
else if (arg === '--no-fetch') { options.fetch = false; }
|
|
610
|
+
else { throw new UsageError(`unexpected argument: ${arg}`); }
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
if (options.waitSeconds !== undefined && !Number.isFinite(options.waitSeconds)) {
|
|
614
|
+
throw new UsageError('--wait needs a number of seconds');
|
|
615
|
+
}
|
|
616
|
+
return options;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* @param {object} options
|
|
621
|
+
* @param {string[]} options.argv CLI arguments (without node/script).
|
|
622
|
+
* @param {string} [options.cwd] Repository checkout to read.
|
|
623
|
+
* @param {(chunk: string) => void} [options.write] stdout sink; injectable for tests.
|
|
624
|
+
* @param {CheckDeps} [options.deps] Collaborators; injectable for tests.
|
|
625
|
+
* @returns {Promise<number>} process exit code.
|
|
626
|
+
*/
|
|
627
|
+
export async function runCli({ argv, cwd = process.cwd(), write = (chunk) => process.stdout.write(chunk), deps = {} }) {
|
|
628
|
+
if (argv.includes('--help') || argv.includes('-h')) {
|
|
629
|
+
write(USAGE);
|
|
630
|
+
return 0;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
const { base, commit, waitSeconds, fetch } = parseArgs(argv);
|
|
634
|
+
const report = await checkPostMerge({ cwd, base, commit, waitSeconds, fetch, deps });
|
|
635
|
+
write(`${JSON.stringify(report, null, 2)}\n`);
|
|
636
|
+
return report.exitCode;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// Only run the CLI when invoked directly, so tests can import the module.
|
|
640
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
641
|
+
runCli({ argv: process.argv.slice(2) })
|
|
642
|
+
.then((code) => {
|
|
643
|
+
process.exitCode = code;
|
|
644
|
+
})
|
|
645
|
+
.catch((error) => {
|
|
646
|
+
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
|
647
|
+
// A check that never reached a verdict is inconclusive, never a pass and never a
|
|
648
|
+
// failure a human must be paged for.
|
|
649
|
+
process.exitCode = EXIT_CODES.inconclusive;
|
|
650
|
+
});
|
|
651
|
+
}
|