mandrel 2.54.0 → 2.55.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/.agents/agents/story-worker.md +24 -23
- package/.agents/audit-checklists/accessibility.md +0 -3
- package/.agents/audit-checklists/mobile.md +0 -4
- package/.agents/docs/agentrc-reference.json +4 -2
- package/.agents/docs/configuration.md +2 -0
- package/.agents/schemas/agentrc.schema.json +15 -1
- package/.agents/schemas/lifecycle/merge.unlanded.schema.json +2 -1
- package/.agents/schemas/story-deliver-terminal.schema.json +1 -0
- package/.agents/scripts/audit-to-stories.js +158 -7
- package/.agents/scripts/check-audit-attribution.js +119 -62
- package/.agents/scripts/check-test-portability.js +512 -0
- package/.agents/scripts/coverage-capture.js +17 -10
- package/.agents/scripts/evidence-gate.js +31 -4
- package/.agents/scripts/generate-workflows-doc.js +65 -14
- package/.agents/scripts/git-cleanup.js +4 -0
- package/.agents/scripts/lib/ITicketingProvider.js +78 -0
- package/.agents/scripts/lib/audit-advisories.js +195 -0
- package/.agents/scripts/lib/audit-attribution.js +22 -0
- package/.agents/scripts/lib/audit-to-stories/dedupe-against-github.js +68 -5
- package/.agents/scripts/lib/audit-to-stories/issue-index.js +83 -0
- package/.agents/scripts/lib/audit-to-stories/ledger-commit.js +60 -114
- package/.agents/scripts/lib/audit-to-stories/ledger-pr.js +347 -0
- package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +169 -44
- package/.agents/scripts/lib/baselines/merge-envelopes.js +298 -32
- package/.agents/scripts/lib/bootstrap/baseline-merge-driver.js +180 -14
- package/.agents/scripts/lib/cli-args.js +26 -0
- package/.agents/scripts/lib/close-validation/gates.js +113 -7
- package/.agents/scripts/lib/close-validation/process.js +7 -3
- package/.agents/scripts/lib/close-validation/runner.js +62 -11
- package/.agents/scripts/lib/config/ci.js +28 -9
- package/.agents/scripts/lib/config-settings-schema-delivery.js +7 -0
- package/.agents/scripts/lib/config-settings-schema.js +19 -1
- package/.agents/scripts/lib/coverage-capture-fullscope.js +23 -11
- package/.agents/scripts/lib/coverage-capture-incremental.js +22 -16
- package/.agents/scripts/lib/coverage-capture-usage.js +5 -1
- package/.agents/scripts/lib/coverage-capture.js +77 -3
- package/.agents/scripts/lib/findings/route-finding.js +4 -2
- package/.agents/scripts/lib/full-suite-lock.js +232 -6
- package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
- package/.agents/scripts/lib/git/sync-from-base.js +130 -13
- package/.agents/scripts/lib/observability/source-classifier.js +1 -0
- package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +10 -2
- package/.agents/scripts/lib/orchestration/check-baselines/phases/refresh-ack.js +75 -15
- package/.agents/scripts/lib/orchestration/deliver-recover.js +82 -43
- package/.agents/scripts/lib/orchestration/dependency-candidates.js +8 -4
- package/.agents/scripts/lib/orchestration/epic-candidates.js +9 -4
- package/.agents/scripts/lib/orchestration/epic-container.js +66 -4
- package/.agents/scripts/lib/orchestration/epic-rollup.js +233 -84
- package/.agents/scripts/lib/orchestration/file-assumptions.js +218 -16
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/branches.js +93 -7
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/git-probes.js +22 -6
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/parse-args.js +26 -5
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/phase-drivers.js +13 -2
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +35 -5
- package/.agents/scripts/lib/orchestration/merge-block-class.js +18 -3
- package/.agents/scripts/lib/orchestration/merge-poll.js +284 -40
- package/.agents/scripts/lib/orchestration/plan-persist/epic-adoption.js +49 -2
- package/.agents/scripts/lib/orchestration/plan-persist/epic-ops.js +43 -7
- package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +24 -1
- package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +5 -0
- package/.agents/scripts/lib/orchestration/plan-persist/summary.js +3 -0
- package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +63 -0
- package/.agents/scripts/lib/orchestration/plan-persist/wave-serialisation.js +110 -0
- package/.agents/scripts/lib/orchestration/planning/memory-pool-advisory.js +130 -40
- package/.agents/scripts/lib/orchestration/resolve-stories.js +44 -1
- package/.agents/scripts/lib/orchestration/review-providers/native.js +31 -11
- package/.agents/scripts/lib/orchestration/review-providers/scoped-lint.js +27 -24
- package/.agents/scripts/lib/orchestration/run-epilogue.js +59 -38
- package/.agents/scripts/lib/orchestration/single-story-close/close-note.js +81 -0
- package/.agents/scripts/lib/orchestration/single-story-close/failed-terminal.js +40 -51
- package/.agents/scripts/lib/orchestration/single-story-close/phases/auto-merge.js +10 -2
- package/.agents/scripts/lib/orchestration/single-story-close/phases/base-sync.js +101 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +351 -28
- package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +27 -6
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +117 -22
- package/.agents/scripts/lib/orchestration/story-close/baseline-upward-writeback.js +94 -12
- package/.agents/scripts/lib/orchestration/story-close/format-autofix.js +6 -1
- package/.agents/scripts/lib/orchestration/ticket-validator.js +25 -14
- package/.agents/scripts/lib/orchestration/ticketing/bulk.js +30 -0
- package/.agents/scripts/lib/orchestration/verify-credit.js +37 -0
- package/.agents/scripts/lib/pinned-override-notes.js +41 -53
- package/.agents/scripts/lib/pinned-override-resolve.js +212 -0
- package/.agents/scripts/lib/qa/resolve-qa-contract.js +18 -0
- package/.agents/scripts/lib/single-story-sweep/sweep-lock.js +173 -9
- package/.agents/scripts/lib/skills/walk-skill-files.js +24 -7
- package/.agents/scripts/lib/test-temp.js +167 -30
- package/.agents/scripts/lib/validation-evidence.js +37 -0
- package/.agents/scripts/lib/wave-runner/footprint.js +167 -14
- package/.agents/scripts/lib/wave-runner/live-probe.js +7 -1
- package/.agents/scripts/lib/wave-runner/ready-set.js +1 -1
- package/.agents/scripts/merge-baseline.js +175 -21
- package/.agents/scripts/providers/github/errors.js +22 -1
- package/.agents/scripts/providers/github/issues.js +106 -1
- package/.agents/scripts/providers/github/sub-issue-add.js +18 -1
- package/.agents/scripts/providers/github.js +6 -0
- package/.agents/scripts/resolve-stories.js +44 -34
- package/.agents/scripts/single-story-close.js +5 -0
- package/.agents/scripts/stories-wave-tick.js +37 -13
- package/.agents/templates/docs/audit-sweep-runbook.md +41 -7
- package/.agents/workflows/audit-accessibility.md +16 -31
- package/.agents/workflows/audit-mobile.md +20 -37
- package/.agents/workflows/git-cleanup.md +17 -3
- package/.agents/workflows/helpers/audit-lens-core.md +45 -0
- package/.agents/workflows/helpers/deliver-digest.md +7 -6
- package/.agents/workflows/helpers/deliver-reference.md +35 -14
- package/.agents/workflows/helpers/deliver-story-reference.md +7 -4
- package/.agents/workflows/helpers/deliver-story.md +15 -12
- package/.agents/workflows/helpers/plan-reference.md +7 -0
- package/.agents/workflows/mandrel-plan.md +4 -7
- package/.agents/workflows/memory-consolidate.md +14 -9
- package/docs/CHANGELOG.md +27 -0
- package/lib/cli/registry.js +64 -21
- package/lib/cli/sync.js +27 -2
- package/package.json +7 -4
|
@@ -12,8 +12,9 @@
|
|
|
12
12
|
* This module closes that hole from both ends:
|
|
13
13
|
*
|
|
14
14
|
* - {@link runLedgerCommit} (`--auto --ledger-commit`) commits the changed
|
|
15
|
-
* ledger onto a
|
|
16
|
-
* and opens a PR against
|
|
15
|
+
* ledger onto a `chore/audit-ledger-<YYYY-MM-DD>-<shortsha>` branch cut
|
|
16
|
+
* from `origin/<base>`, pushes it, and opens a PR against
|
|
17
|
+
* `project.baseBranch` through the `gh` wrapper.
|
|
17
18
|
* Auto-merge is never requested: a ledger PR records machine-derived state
|
|
18
19
|
* a human should glance at, so landing it stays an operator decision.
|
|
19
20
|
* - {@link resolveLedgerSummary} answers the question the *unflagged* sweep
|
|
@@ -30,6 +31,7 @@
|
|
|
30
31
|
import { gh as defaultGh } from '../gh-exec.js';
|
|
31
32
|
import { gitSync } from '../git-utils.js';
|
|
32
33
|
import { DEFAULT_LEDGER_PATH } from './ledger.js';
|
|
34
|
+
import { openLedgerPullRequest, probeGit } from './ledger-pr.js';
|
|
33
35
|
|
|
34
36
|
/** Fallback base branch when config carries no `project.baseBranch`. */
|
|
35
37
|
const DEFAULT_BASE_BRANCH = 'main';
|
|
@@ -64,43 +66,6 @@ async function resolveBaseBranch(explicit) {
|
|
|
64
66
|
return DEFAULT_BASE_BRANCH;
|
|
65
67
|
}
|
|
66
68
|
|
|
67
|
-
/**
|
|
68
|
-
* Run a read-only git probe that must never throw: a checkout with no commits
|
|
69
|
-
* (or no repository at all) is a legitimate answer of "nothing to report",
|
|
70
|
-
* not a crash. The write path below uses {@link runStep} instead, where a
|
|
71
|
-
* failure IS fatal.
|
|
72
|
-
* @param {(cwd: string, ...args: string[]) => string} git
|
|
73
|
-
* @param {string} cwd
|
|
74
|
-
* @param {string[]} args
|
|
75
|
-
* @returns {string} trimmed stdout, or `''` when git failed.
|
|
76
|
-
*/
|
|
77
|
-
function probeGit(git, cwd, args) {
|
|
78
|
-
try {
|
|
79
|
-
const out = git(cwd, ...args);
|
|
80
|
-
return typeof out === 'string' ? out.trim() : '';
|
|
81
|
-
} catch (_) {
|
|
82
|
-
return '';
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/**
|
|
87
|
-
* Wrap one write step so a git or `gh` failure surfaces as a fatal error that
|
|
88
|
-
* names the step that broke. Accepts sync and async steps alike.
|
|
89
|
-
* @param {string} name
|
|
90
|
-
* @param {() => unknown} fn
|
|
91
|
-
* @returns {Promise<unknown>}
|
|
92
|
-
*/
|
|
93
|
-
async function runStep(name, fn) {
|
|
94
|
-
try {
|
|
95
|
-
return await fn();
|
|
96
|
-
} catch (error) {
|
|
97
|
-
throw new Error(
|
|
98
|
-
`--ledger-commit failed at step "${name}": ${error?.message ?? error}`,
|
|
99
|
-
{ cause: error },
|
|
100
|
-
);
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
|
|
104
69
|
/**
|
|
105
70
|
* Inspect whether the ledger changed and whether this checkout could persist
|
|
106
71
|
* it at all. Module-local: the two exported entry points below are the whole
|
|
@@ -127,14 +92,10 @@ async function assessLedgerPersistence({
|
|
|
127
92
|
git = gitSync,
|
|
128
93
|
} = {}) {
|
|
129
94
|
const base = await resolveBaseBranch(baseBranch);
|
|
130
|
-
const
|
|
131
|
-
|
|
132
|
-
const hasOrigin =
|
|
133
|
-
|
|
134
|
-
.map((line) => line.trim())
|
|
135
|
-
.includes('origin');
|
|
136
|
-
const headBranch = probeGit(git, cwd, ['rev-parse', '--abbrev-ref', 'HEAD']);
|
|
137
|
-
const onBaseBranch = headBranch === base;
|
|
95
|
+
const probe = (args) => probeGit(git, cwd, args);
|
|
96
|
+
const changed = ledgerIsDirty(probe, ledgerPath);
|
|
97
|
+
const hasOrigin = hasOriginRemote(probe);
|
|
98
|
+
const headBranch = headBranchOf(probe);
|
|
138
99
|
|
|
139
100
|
return {
|
|
140
101
|
ledgerPath,
|
|
@@ -142,11 +103,46 @@ async function assessLedgerPersistence({
|
|
|
142
103
|
changed,
|
|
143
104
|
hasOrigin,
|
|
144
105
|
headBranch,
|
|
145
|
-
onBaseBranch,
|
|
146
|
-
unpersisted: changed && (!hasOrigin ||
|
|
106
|
+
onBaseBranch: headBranch === base,
|
|
107
|
+
unpersisted: changed && (!hasOrigin || headBranch !== base),
|
|
147
108
|
};
|
|
148
109
|
}
|
|
149
110
|
|
|
111
|
+
/**
|
|
112
|
+
* Has the sweep actually written new memory? Scoped to the ledger pathspec, so
|
|
113
|
+
* unrelated dirt in the checkout is never mistaken for it.
|
|
114
|
+
* @param {(args: string[]) => string} probe
|
|
115
|
+
* @param {string} ledgerPath
|
|
116
|
+
* @returns {boolean}
|
|
117
|
+
*/
|
|
118
|
+
function ledgerIsDirty(probe, ledgerPath) {
|
|
119
|
+
return probe(['status', '--porcelain', '--', ledgerPath]).length > 0;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Is there an `origin` to push to at all? The ephemeral-clone shape that makes
|
|
124
|
+
* a sweep amnesiac usually has none.
|
|
125
|
+
* @param {(args: string[]) => string} probe
|
|
126
|
+
* @returns {boolean}
|
|
127
|
+
*/
|
|
128
|
+
function hasOriginRemote(probe) {
|
|
129
|
+
return probe(['remote'])
|
|
130
|
+
.split('\n')
|
|
131
|
+
.map((line) => line.trim())
|
|
132
|
+
.includes('origin');
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The branch HEAD is on, or `''` when the checkout is detached or has no
|
|
137
|
+
* commits — both of which read as "not the base branch", which is the answer
|
|
138
|
+
* the callers need.
|
|
139
|
+
* @param {(args: string[]) => string} probe
|
|
140
|
+
* @returns {string}
|
|
141
|
+
*/
|
|
142
|
+
function headBranchOf(probe) {
|
|
143
|
+
return probe(['rev-parse', '--abbrev-ref', 'HEAD']);
|
|
144
|
+
}
|
|
145
|
+
|
|
150
146
|
/**
|
|
151
147
|
* Warn that the reconciled ledger has nowhere to go. Names the file, because
|
|
152
148
|
* "state will be lost" is unactionable without knowing which state.
|
|
@@ -196,34 +192,13 @@ export async function resolveLedgerSummary({
|
|
|
196
192
|
}
|
|
197
193
|
|
|
198
194
|
/**
|
|
199
|
-
*
|
|
200
|
-
*
|
|
201
|
-
* @param {string} ledgerPath
|
|
202
|
-
* @param {string} date
|
|
203
|
-
* @returns {string}
|
|
204
|
-
*/
|
|
205
|
-
function pullRequestBody(ledgerPath, date) {
|
|
206
|
-
return [
|
|
207
|
-
`Reconciles the cross-run audit ledger (\`${ledgerPath}\`) written by the`,
|
|
208
|
-
`unattended \`audit-to-stories --auto\` sweep on ${date}.`,
|
|
209
|
-
'',
|
|
210
|
-
'Ledger-only change — no source, workflow or documentation file is touched.',
|
|
211
|
-
'Merging it is what gives the next sweep a memory: without it the ledger',
|
|
212
|
-
'dies with the checkout and every later run re-proposes findings this one',
|
|
213
|
-
'already filed, and re-surfaces findings a human already rejected.',
|
|
214
|
-
'',
|
|
215
|
-
'Auto-merge is deliberately not requested: the ledger records machine-derived',
|
|
216
|
-
'lifecycle state, and a human glance before it lands is the point.',
|
|
217
|
-
].join('\n');
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
/**
|
|
221
|
-
* Commit the changed ledger onto a dated branch and open a PR for it.
|
|
195
|
+
* Commit the changed ledger onto a unique branch cut from the remote base and
|
|
196
|
+
* open a PR for it.
|
|
222
197
|
*
|
|
223
|
-
*
|
|
224
|
-
*
|
|
225
|
-
* caller runs this *after* printing the run summary, so a broken
|
|
226
|
-
* costs the operator the sweep's findings.
|
|
198
|
+
* Assesses the checkout, then hands the whole write sequence to
|
|
199
|
+
* {@link openLedgerPullRequest}. Every git/`gh` failure is fatal and names its
|
|
200
|
+
* step; the caller runs this *after* printing the run summary, so a broken
|
|
201
|
+
* remote never costs the operator the sweep's findings.
|
|
227
202
|
*
|
|
228
203
|
* @param {object} [params]
|
|
229
204
|
* @param {string} [params.ledgerPath]
|
|
@@ -233,7 +208,8 @@ function pullRequestBody(ledgerPath, date) {
|
|
|
233
208
|
* @param {{ pr: { create: (flags: string[]) => Promise<unknown> } }} [params.gh]
|
|
234
209
|
* @param {Date|string|number} [params.now]
|
|
235
210
|
* @returns {Promise<{ committed: boolean, reason?: string, branch?: string,
|
|
236
|
-
* subject?: string, baseBranch?: string,
|
|
211
|
+
* subject?: string, baseBranch?: string, prUrl?: string|null,
|
|
212
|
+
* resumed?: boolean, ledgerPath: string }>}
|
|
237
213
|
*/
|
|
238
214
|
export async function runLedgerCommit({
|
|
239
215
|
ledgerPath = DEFAULT_LEDGER_PATH,
|
|
@@ -249,42 +225,12 @@ export async function runLedgerCommit({
|
|
|
249
225
|
cwd,
|
|
250
226
|
git,
|
|
251
227
|
});
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
const date = isoDate(now);
|
|
257
|
-
const branch = `chore/audit-ledger-${date}`;
|
|
258
|
-
const subject = `chore(audit): reconcile audit ledger ${date}`;
|
|
259
|
-
|
|
260
|
-
await runStep('create-branch', () => git(cwd, 'checkout', '-b', branch));
|
|
261
|
-
await runStep('stage-ledger', () => git(cwd, 'add', '--', ledgerPath));
|
|
262
|
-
// The `-- <path>` pathspec is what keeps the commit ledger-only even when
|
|
263
|
-
// the sweep's checkout carries unrelated dirt.
|
|
264
|
-
await runStep('commit-ledger', () =>
|
|
265
|
-
git(cwd, 'commit', '-m', subject, '--', ledgerPath),
|
|
266
|
-
);
|
|
267
|
-
await runStep('push-branch', () =>
|
|
268
|
-
git(cwd, 'push', '--set-upstream', 'origin', branch),
|
|
269
|
-
);
|
|
270
|
-
await runStep('open-pull-request', () =>
|
|
271
|
-
gh.pr.create([
|
|
272
|
-
'--base',
|
|
273
|
-
state.baseBranch,
|
|
274
|
-
'--head',
|
|
275
|
-
branch,
|
|
276
|
-
'--title',
|
|
277
|
-
subject,
|
|
278
|
-
'--body',
|
|
279
|
-
pullRequestBody(ledgerPath, date),
|
|
280
|
-
]),
|
|
281
|
-
);
|
|
282
|
-
|
|
283
|
-
return {
|
|
284
|
-
committed: true,
|
|
285
|
-
branch,
|
|
286
|
-
subject,
|
|
287
|
-
baseBranch: state.baseBranch,
|
|
228
|
+
return openLedgerPullRequest({
|
|
229
|
+
state,
|
|
288
230
|
ledgerPath,
|
|
289
|
-
|
|
231
|
+
cwd,
|
|
232
|
+
git,
|
|
233
|
+
gh,
|
|
234
|
+
date: isoDate(now),
|
|
235
|
+
});
|
|
290
236
|
}
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/audit-to-stories/ledger-pr.js — everything the ledger PR is made of.
|
|
3
|
+
*
|
|
4
|
+
* The mechanical half of `--ledger-commit`: how the branch is named, when the
|
|
5
|
+
* checkout is refused, the commit sequence itself, the push, and the PR body.
|
|
6
|
+
* `ledger-commit.js` next door keeps only the two entry points and the
|
|
7
|
+
* persistence assessment they share, so the run sequence there reads as a
|
|
8
|
+
* sequence rather than as a git driver.
|
|
9
|
+
*
|
|
10
|
+
* Every refusal in this module happens **before** its first write, so a refused
|
|
11
|
+
* run cannot have left a branch or a commit behind. The git and `gh` seams are
|
|
12
|
+
* injected (`.agents/rules/test-seams.md`), so the whole retry matrix is
|
|
13
|
+
* assertable without a live remote.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Run a read-only git probe that must never throw: a checkout with no commits
|
|
18
|
+
* (or no repository at all) is a legitimate answer of "nothing to report", not
|
|
19
|
+
* a crash. The write path uses `runStep` instead, where a failure IS fatal.
|
|
20
|
+
*
|
|
21
|
+
* @param {(cwd: string, ...args: string[]) => string} git
|
|
22
|
+
* @param {string} cwd
|
|
23
|
+
* @param {string[]} args
|
|
24
|
+
* @returns {string} trimmed stdout, or `''` when git failed.
|
|
25
|
+
*/
|
|
26
|
+
export function probeGit(git, cwd, args) {
|
|
27
|
+
try {
|
|
28
|
+
const out = git(cwd, ...args);
|
|
29
|
+
return typeof out === 'string' ? out.trim() : '';
|
|
30
|
+
} catch (_) {
|
|
31
|
+
return '';
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Does a ref resolve in this checkout? Read-only, and never fatal — an absent
|
|
37
|
+
* ref is the answer, not an error.
|
|
38
|
+
*
|
|
39
|
+
* @param {(cwd: string, ...args: string[]) => string} git
|
|
40
|
+
* @param {string} cwd
|
|
41
|
+
* @param {string} ref
|
|
42
|
+
* @returns {boolean}
|
|
43
|
+
*/
|
|
44
|
+
function refExists(git, cwd, ref) {
|
|
45
|
+
return (
|
|
46
|
+
probeGit(git, cwd, ['rev-parse', '--verify', '--quiet', ref]).length > 0
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The short sha the branch name carries.
|
|
52
|
+
*
|
|
53
|
+
* Dating the branch alone was not enough to make a retry safe: a second run on
|
|
54
|
+
* the same day found `chore/audit-ledger-<date>` already present and failed at
|
|
55
|
+
* `create-branch`, so the *first* failure (usually a push) permanently poisoned
|
|
56
|
+
* every retry that day. Qualifying the name with the base commit makes it
|
|
57
|
+
* unique across bases while staying **deterministic** for the same base — which
|
|
58
|
+
* is exactly what lets a retry recognise its own half-finished branch.
|
|
59
|
+
*
|
|
60
|
+
* @param {(cwd: string, ...args: string[]) => string} git
|
|
61
|
+
* @param {string} cwd
|
|
62
|
+
* @param {string} baseRef — `origin/<base>`.
|
|
63
|
+
* @returns {string}
|
|
64
|
+
*/
|
|
65
|
+
function shortSha(git, cwd, baseRef) {
|
|
66
|
+
for (const ref of [baseRef, 'HEAD']) {
|
|
67
|
+
const sha = probeGit(git, cwd, ['rev-parse', '--short', ref]);
|
|
68
|
+
if (sha) return sha;
|
|
69
|
+
}
|
|
70
|
+
return 'initial';
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Resolve the ledger branch for this run, and whether it is a **resume**.
|
|
75
|
+
*
|
|
76
|
+
* A ledger branch that exists locally and has never been pushed is the wreckage
|
|
77
|
+
* of a failed run, not a landed one: its commit is already made, so the work
|
|
78
|
+
* left is the push and the PR. Recognising it is what turns a failed push plus
|
|
79
|
+
* its retry into exactly one PR instead of a stranded branch and a run
|
|
80
|
+
* reporting `ledger-unchanged` — which is what the ledger file honestly is once
|
|
81
|
+
* its change has been committed onto that branch.
|
|
82
|
+
*
|
|
83
|
+
* @param {{ git: Function, cwd: string, base: string, date: string }} params
|
|
84
|
+
* @returns {{ branch: string, resuming: boolean }}
|
|
85
|
+
*/
|
|
86
|
+
function resolveLedgerBranch({ git, cwd, base, date }) {
|
|
87
|
+
const branch = `chore/audit-ledger-${date}-${shortSha(git, cwd, `origin/${base}`)}`;
|
|
88
|
+
return {
|
|
89
|
+
branch,
|
|
90
|
+
resuming:
|
|
91
|
+
refExists(git, cwd, `refs/heads/${branch}`) &&
|
|
92
|
+
!refExists(git, cwd, `refs/remotes/origin/${branch}`),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Refuse, naming the step, when the checkout cannot legitimately produce a
|
|
98
|
+
* ledger PR. Both refusals happen **before** any write, so a refused run leaves
|
|
99
|
+
* no branch and no commit behind.
|
|
100
|
+
*
|
|
101
|
+
* HEAD parked off the base branch is the one an unattended sweep actually
|
|
102
|
+
* meets: a job that has already checked out a feature branch would otherwise
|
|
103
|
+
* cut its ledger branch from that branch's tip and open a PR carrying every
|
|
104
|
+
* unrelated commit on it.
|
|
105
|
+
*
|
|
106
|
+
* @param {{ hasOrigin: boolean, onBaseBranch: boolean, headBranch: string,
|
|
107
|
+
* baseBranch: string }} state
|
|
108
|
+
* @param {string} branch — the ledger branch name, allowed as a resume HEAD.
|
|
109
|
+
*/
|
|
110
|
+
function assertCommittable(state, branch) {
|
|
111
|
+
if (!state.hasOrigin) {
|
|
112
|
+
throw new Error(
|
|
113
|
+
'--ledger-commit failed at step "verify-origin": this checkout has no "origin" remote, ' +
|
|
114
|
+
'so the ledger branch could never be pushed. Add the remote, or commit the ledger by hand.',
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
if (!state.onBaseBranch && state.headBranch !== branch) {
|
|
118
|
+
throw new Error(
|
|
119
|
+
`--ledger-commit failed at step "verify-base-branch": HEAD is on "${state.headBranch || '(detached)'}", ` +
|
|
120
|
+
`not the base branch "${state.baseBranch}". Nothing was committed — the ledger branch is cut from ` +
|
|
121
|
+
`origin/${state.baseBranch}, and running from a feature branch would carry its commits into the ledger PR. ` +
|
|
122
|
+
`Check out ${state.baseBranch} and re-run.`,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Extract the PR URL `gh pr create` prints, so the caller can name it in the
|
|
129
|
+
* run summary. A wrapper that returns something else yields `null` rather than
|
|
130
|
+
* a fabricated link.
|
|
131
|
+
*
|
|
132
|
+
* @param {unknown} result
|
|
133
|
+
* @returns {string|null}
|
|
134
|
+
*/
|
|
135
|
+
function pullRequestUrl(result) {
|
|
136
|
+
const text = typeof result === 'string' ? result : (result?.stdout ?? '');
|
|
137
|
+
const match = /https?:\/\/\S+/.exec(String(text ?? ''));
|
|
138
|
+
return match ? match[0] : null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Wrap one write step so a git or `gh` failure surfaces as a fatal error that
|
|
143
|
+
* names the step that broke. Accepts sync and async steps alike.
|
|
144
|
+
* @param {string} name
|
|
145
|
+
* @param {() => unknown} fn
|
|
146
|
+
* @returns {Promise<unknown>}
|
|
147
|
+
*/
|
|
148
|
+
async function runStep(name, fn) {
|
|
149
|
+
try {
|
|
150
|
+
return await fn();
|
|
151
|
+
} catch (error) {
|
|
152
|
+
throw new Error(
|
|
153
|
+
`--ledger-commit failed at step "${name}": ${error?.message ?? error}`,
|
|
154
|
+
{ cause: error },
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Compose the ledger PR body. Kept separate so the step sequence below reads
|
|
161
|
+
* as a sequence and not as a string-building exercise.
|
|
162
|
+
* @param {string} ledgerPath
|
|
163
|
+
* @param {string} date
|
|
164
|
+
* @returns {string}
|
|
165
|
+
*/
|
|
166
|
+
function pullRequestBody(ledgerPath, date) {
|
|
167
|
+
return [
|
|
168
|
+
`Reconciles the cross-run audit ledger (\`${ledgerPath}\`) written by the`,
|
|
169
|
+
`unattended \`audit-to-stories --auto\` sweep on ${date}.`,
|
|
170
|
+
'',
|
|
171
|
+
'Ledger-only change — no source, workflow or documentation file is touched.',
|
|
172
|
+
'Merging it is what gives the next sweep a memory: without it the ledger',
|
|
173
|
+
'dies with the checkout and every later run re-proposes findings this one',
|
|
174
|
+
'already filed, and re-surfaces findings a human already rejected.',
|
|
175
|
+
'',
|
|
176
|
+
'Auto-merge is deliberately not requested: the ledger records machine-derived',
|
|
177
|
+
'lifecycle state, and a human glance before it lands is the point.',
|
|
178
|
+
].join('\n');
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Cut the ledger branch from `origin/<base>` and commit the ledger onto it —
|
|
183
|
+
* or, when `resuming`, simply check out the branch a failed run already
|
|
184
|
+
* committed onto, because those steps have already succeeded.
|
|
185
|
+
*
|
|
186
|
+
* @param {object} ctx
|
|
187
|
+
* @returns {Promise<void>}
|
|
188
|
+
*/
|
|
189
|
+
async function commitLedgerOnto({
|
|
190
|
+
git,
|
|
191
|
+
cwd,
|
|
192
|
+
branch,
|
|
193
|
+
base,
|
|
194
|
+
ledgerPath,
|
|
195
|
+
subject,
|
|
196
|
+
resuming = false,
|
|
197
|
+
}) {
|
|
198
|
+
if (resuming) {
|
|
199
|
+
// The commit already exists on that branch; all it is missing is a push.
|
|
200
|
+
await runStep('resume-branch', () => git(cwd, 'checkout', branch));
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
await runStep('fetch-base', () => git(cwd, 'fetch', 'origin', base));
|
|
204
|
+
await runStep('create-branch', () =>
|
|
205
|
+
git(cwd, 'checkout', '-b', branch, `origin/${base}`),
|
|
206
|
+
);
|
|
207
|
+
await runStep('stage-ledger', () => git(cwd, 'add', '--', ledgerPath));
|
|
208
|
+
// The `-- <path>` pathspec is what keeps the commit ledger-only even when
|
|
209
|
+
// the sweep's checkout carries unrelated dirt.
|
|
210
|
+
await runStep('commit-ledger', () =>
|
|
211
|
+
git(cwd, 'commit', '-m', subject, '--', ledgerPath),
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Push the ledger branch and open its PR, returning the PR URL.
|
|
217
|
+
*
|
|
218
|
+
* Auto-merge is never requested: the ledger records machine-derived lifecycle
|
|
219
|
+
* state a human should glance at, so landing it stays an operator decision.
|
|
220
|
+
*
|
|
221
|
+
* @param {object} ctx
|
|
222
|
+
* @returns {Promise<string|null>}
|
|
223
|
+
*/
|
|
224
|
+
async function pushAndOpenPullRequest({
|
|
225
|
+
git,
|
|
226
|
+
cwd,
|
|
227
|
+
gh,
|
|
228
|
+
branch,
|
|
229
|
+
base,
|
|
230
|
+
subject,
|
|
231
|
+
ledgerPath,
|
|
232
|
+
date,
|
|
233
|
+
}) {
|
|
234
|
+
await runStep('push-branch', () =>
|
|
235
|
+
git(cwd, 'push', '--set-upstream', 'origin', branch),
|
|
236
|
+
);
|
|
237
|
+
return pullRequestUrl(
|
|
238
|
+
await runStep('open-pull-request', () =>
|
|
239
|
+
gh.pr.create([
|
|
240
|
+
'--base',
|
|
241
|
+
base,
|
|
242
|
+
'--head',
|
|
243
|
+
branch,
|
|
244
|
+
'--title',
|
|
245
|
+
subject,
|
|
246
|
+
'--body',
|
|
247
|
+
pullRequestBody(ledgerPath, date),
|
|
248
|
+
]),
|
|
249
|
+
),
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Put the checkout back on the branch the run started on.
|
|
255
|
+
*
|
|
256
|
+
* Best-effort by design, and called from a `finally`: on the failure path
|
|
257
|
+
* especially — where the next thing the operator runs is the retry — leaving
|
|
258
|
+
* them parked on a half-finished ledger branch is its own defect, but a failure
|
|
259
|
+
* to restore must never mask the failure that caused it.
|
|
260
|
+
*
|
|
261
|
+
* @param {{ git: Function, cwd: string, startBranch: string, branch: string }} params
|
|
262
|
+
*/
|
|
263
|
+
function restoreBranch({ git, cwd, startBranch, branch }) {
|
|
264
|
+
if (!startBranch || startBranch === branch) return;
|
|
265
|
+
try {
|
|
266
|
+
git(cwd, 'checkout', startBranch);
|
|
267
|
+
} catch (_) {
|
|
268
|
+
// Deliberately swallowed — see the contract above.
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Run the whole `--ledger-commit` write sequence against an assessed checkout:
|
|
274
|
+
* refuse or skip, cut (or resume) the branch, push, open the PR, and put the
|
|
275
|
+
* checkout back where it started.
|
|
276
|
+
*
|
|
277
|
+
* **Re-runnable**, which is the property an unattended sweep needs. The two
|
|
278
|
+
* ways a retry used to misbehave are both closed here:
|
|
279
|
+
*
|
|
280
|
+
* - The branch name is qualified by the base commit, so a same-day retry no
|
|
281
|
+
* longer collides with the branch a failed run left behind.
|
|
282
|
+
* - A ledger already committed on an **unpushed** ledger branch resumes at
|
|
283
|
+
* the push rather than reporting `ledger-unchanged` (the ledger file is
|
|
284
|
+
* clean — it is committed, just not pushed) and abandoning the work.
|
|
285
|
+
* Across a failed push and its retry that yields exactly one PR.
|
|
286
|
+
*
|
|
287
|
+
* The branch the run started on is restored in a `finally`, so a failure
|
|
288
|
+
* anywhere in the sequence — and success alike — leaves the operator's checkout
|
|
289
|
+
* where they left it rather than parked on a ledger branch.
|
|
290
|
+
*
|
|
291
|
+
* @param {{ state: object, ledgerPath: string, cwd: string, git: Function,
|
|
292
|
+
* gh: object, date: string }} params
|
|
293
|
+
* @returns {Promise<object>} the result the CLI summarises.
|
|
294
|
+
*/
|
|
295
|
+
export async function openLedgerPullRequest({
|
|
296
|
+
state,
|
|
297
|
+
ledgerPath,
|
|
298
|
+
cwd,
|
|
299
|
+
git,
|
|
300
|
+
gh,
|
|
301
|
+
date,
|
|
302
|
+
}) {
|
|
303
|
+
const base = state.baseBranch;
|
|
304
|
+
const subject = `chore(audit): reconcile audit ledger ${date}`;
|
|
305
|
+
const { branch, resuming } = resolveLedgerBranch({ git, cwd, base, date });
|
|
306
|
+
|
|
307
|
+
if (!state.changed && !resuming) {
|
|
308
|
+
return { committed: false, reason: 'ledger-unchanged', ledgerPath };
|
|
309
|
+
}
|
|
310
|
+
assertCommittable(state, branch);
|
|
311
|
+
|
|
312
|
+
const startBranch = state.headBranch;
|
|
313
|
+
let prUrl = null;
|
|
314
|
+
try {
|
|
315
|
+
await commitLedgerOnto({
|
|
316
|
+
git,
|
|
317
|
+
cwd,
|
|
318
|
+
branch,
|
|
319
|
+
base,
|
|
320
|
+
ledgerPath,
|
|
321
|
+
subject,
|
|
322
|
+
resuming,
|
|
323
|
+
});
|
|
324
|
+
prUrl = await pushAndOpenPullRequest({
|
|
325
|
+
git,
|
|
326
|
+
cwd,
|
|
327
|
+
gh,
|
|
328
|
+
branch,
|
|
329
|
+
base,
|
|
330
|
+
subject,
|
|
331
|
+
ledgerPath,
|
|
332
|
+
date,
|
|
333
|
+
});
|
|
334
|
+
} finally {
|
|
335
|
+
restoreBranch({ git, cwd, startBranch, branch });
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
return {
|
|
339
|
+
committed: true,
|
|
340
|
+
resumed: resuming,
|
|
341
|
+
branch,
|
|
342
|
+
subject,
|
|
343
|
+
baseBranch: base,
|
|
344
|
+
prUrl,
|
|
345
|
+
ledgerPath,
|
|
346
|
+
};
|
|
347
|
+
}
|