mandrel 2.67.0 → 2.69.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 +15 -11
- package/.agents/docs/agentrc-reference.json +3 -1
- package/.agents/docs/configuration.md +36 -1
- package/.agents/schemas/agentrc.schema.json +14 -1
- package/.agents/schemas/story-deliver-terminal.schema.json +23 -1
- package/.agents/schemas/validation-evidence.schema.json +3 -1
- package/.agents/scripts/coverage-capture.js +65 -9
- package/.agents/scripts/evidence-gate.js +106 -8
- package/.agents/scripts/lib/baselines/coverage-refresh-scope.js +60 -0
- package/.agents/scripts/lib/baselines/crap-updater-cli.js +101 -4
- package/.agents/scripts/lib/baselines/refresh-service.js +1 -1
- package/.agents/scripts/lib/baselines/seat-missing.js +228 -0
- package/.agents/scripts/lib/child-exec.js +39 -1
- package/.agents/scripts/lib/close-validation/gates.js +59 -19
- package/.agents/scripts/lib/close-validation/process.js +23 -24
- package/.agents/scripts/lib/close-validation/runner.js +71 -40
- package/.agents/scripts/lib/config/gates/coverage.schema.js +21 -0
- package/.agents/scripts/lib/config/quality.js +7 -1
- package/.agents/scripts/lib/config/temp-paths.js +15 -0
- package/.agents/scripts/lib/config-settings-schema-delivery.js +1 -1
- package/.agents/scripts/lib/coverage-baseline.js +78 -5
- package/.agents/scripts/lib/coverage-capture-affected.js +345 -0
- package/.agents/scripts/lib/coverage-capture-delta.js +180 -0
- package/.agents/scripts/lib/coverage-capture-fullscope.js +53 -32
- package/.agents/scripts/lib/coverage-capture-incremental.js +49 -26
- package/.agents/scripts/lib/coverage-capture-usage.js +1 -1
- package/.agents/scripts/lib/coverage-capture.js +121 -81
- package/.agents/scripts/lib/full-suite-lock.js +49 -46
- package/.agents/scripts/lib/full-suite-queue.js +83 -8
- package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
- package/.agents/scripts/lib/observability/source-classifier.js +1 -0
- package/.agents/scripts/lib/orchestration/code-review.js +15 -3
- package/.agents/scripts/lib/orchestration/merge-poll.js +5 -0
- package/.agents/scripts/lib/orchestration/review-deposit.js +219 -0
- package/.agents/scripts/lib/orchestration/review-providers/code-review.js +11 -7
- package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +29 -10
- package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +124 -73
- package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +38 -20
- package/.agents/scripts/lib/orchestration/single-story-close/phases/lock-wait-pending.js +8 -2
- package/.agents/scripts/lib/orchestration/single-story-close/review-overlap.js +161 -0
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +47 -7
- package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +8 -16
- package/.agents/scripts/lib/process-group.js +1 -1
- package/.agents/scripts/lib/supervised-suite.js +247 -0
- package/.agents/scripts/lib/wave-runner/cross-run-overlap.js +120 -0
- package/.agents/scripts/lib/wave-runner/live-probe.js +5 -1
- package/.agents/scripts/quality-preview.js +112 -14
- package/.agents/scripts/stories-wave-tick.js +47 -0
- package/.agents/scripts/story-review-compute.js +207 -0
- package/.agents/scripts/update-coverage-baseline.js +15 -10
- package/.agents/scripts/update-crap-baseline.js +12 -2
- package/.agents/scripts/update-maintainability-baseline.js +12 -2
- package/.agents/workflows/audit-security.md +0 -1
- package/.agents/workflows/helpers/code-review.md +7 -5
- package/.agents/workflows/helpers/deliver-digest.md +39 -36
- package/.agents/workflows/helpers/deliver-reference.md +115 -5
- package/.agents/workflows/helpers/deliver-story.md +2 -1
- package/docs/CHANGELOG.md +33 -0
- package/lib/cli/registry.js +125 -18
- package/lib/migrations/steps/strip-removed-agentrc-keys.js +0 -5
- package/package.json +1 -1
|
@@ -103,10 +103,13 @@ function resolveScopeEnvelope(opts, config) {
|
|
|
103
103
|
* createReviewProviderFn?: typeof createReviewProvider,
|
|
104
104
|
* upsertCommentFn?: typeof upsertStructuredComment,
|
|
105
105
|
* renderFindingsFn?: typeof renderFindings,
|
|
106
|
+
* deferPost?: boolean,
|
|
106
107
|
* }} opts
|
|
107
108
|
* @returns {Promise<{
|
|
108
109
|
* status: 'ok'|'no-changes'|'invalid',
|
|
109
110
|
* severity: { critical: number, high: number, medium: number, suggestion: number },
|
|
111
|
+
* findings?: Array<object>,
|
|
112
|
+
* providerName?: string,
|
|
110
113
|
* report?: string,
|
|
111
114
|
* posted: boolean,
|
|
112
115
|
* postedCommentId: number|null,
|
|
@@ -196,9 +199,10 @@ async function resolvePromptMessages(reviewProvider, reviewInput, logger) {
|
|
|
196
199
|
}
|
|
197
200
|
|
|
198
201
|
/**
|
|
199
|
-
* Posting failure is non-fatal and surfaces as `posted: false`.
|
|
202
|
+
* Posting failure is non-fatal and surfaces as `posted: false`. Also posts a
|
|
203
|
+
* `deferPost` review's held report.
|
|
200
204
|
*/
|
|
201
|
-
async function postReviewComment({
|
|
205
|
+
export async function postReviewComment({
|
|
202
206
|
upsertCommentFn,
|
|
203
207
|
provider,
|
|
204
208
|
commentTargetId,
|
|
@@ -230,6 +234,12 @@ async function postReviewComment({
|
|
|
230
234
|
}
|
|
231
235
|
}
|
|
232
236
|
|
|
237
|
+
/** A `deferPost` review holds its report for the caller to post. */
|
|
238
|
+
async function postUnlessDeferred(opts, post) {
|
|
239
|
+
if (opts.deferPost) return { posted: false, postedCommentId: null };
|
|
240
|
+
return postReviewComment(post);
|
|
241
|
+
}
|
|
242
|
+
|
|
233
243
|
/**
|
|
234
244
|
* Which review provider(s) raised the critical findings. A chain reports its
|
|
235
245
|
* own per-entry attribution; any other provider owns every critical.
|
|
@@ -309,7 +319,7 @@ async function executeReviewPipeline({ opts, config, envelope }) {
|
|
|
309
319
|
degradations,
|
|
310
320
|
});
|
|
311
321
|
|
|
312
|
-
const { posted, postedCommentId } = await
|
|
322
|
+
const { posted, postedCommentId } = await postUnlessDeferred(opts, {
|
|
313
323
|
upsertCommentFn,
|
|
314
324
|
provider,
|
|
315
325
|
commentTargetId,
|
|
@@ -320,6 +330,8 @@ async function executeReviewPipeline({ opts, config, envelope }) {
|
|
|
320
330
|
return {
|
|
321
331
|
status: 'ok',
|
|
322
332
|
severity,
|
|
333
|
+
findings,
|
|
334
|
+
providerName,
|
|
323
335
|
report,
|
|
324
336
|
posted,
|
|
325
337
|
postedCommentId,
|
|
@@ -26,6 +26,11 @@ export function pollIntervalMs(checksStatus, intervalSeconds) {
|
|
|
26
26
|
}
|
|
27
27
|
export const DEFAULT_MAX_BUDGET_SECONDS = 3600;
|
|
28
28
|
|
|
29
|
+
/** CI never reddens in the first minute, so only green or red polls on. */
|
|
30
|
+
export function probeSettlesAsyncWait(checksStatus) {
|
|
31
|
+
return checksStatus !== 'success' && checksStatus !== 'failure';
|
|
32
|
+
}
|
|
33
|
+
|
|
29
34
|
/** Bounds every `gh` spawn so a hang degrades to the probe-error path. */
|
|
30
35
|
export const MERGE_WAIT_GH_TIMEOUT_MS = 60_000;
|
|
31
36
|
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* review-deposit.js — the held Story-scope review a worker computes at push
|
|
3
|
+
* time, and its identity.
|
|
4
|
+
*
|
|
5
|
+
* The identity is the **diff digest**: a sha256 of the exact
|
|
6
|
+
* `git diff --no-color <base>...<head>` text the review provider is handed.
|
|
7
|
+
* A clean base-sync merge moves HEAD without changing that text, while any
|
|
8
|
+
* commit that changes the diff changes it — so close adopts a deposit, and
|
|
9
|
+
* posts a held result, on digest equality, never on SHA equality.
|
|
10
|
+
*
|
|
11
|
+
* The file lives beside the terminal envelope; nothing deletes it but the
|
|
12
|
+
* Story temp purge. A mismatched deposit is simply not adopted.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { createHash } from 'node:crypto';
|
|
16
|
+
import nodeFs from 'node:fs';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
|
|
19
|
+
import { storyReviewDepositPath } from '../config/temp-paths.js';
|
|
20
|
+
import { resolveSharedBaseRef } from './review-base-ref.js';
|
|
21
|
+
|
|
22
|
+
const DEPOSIT_KIND = 'story-review-deposit';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @param {{ cwd: string, ref: string, gitSpawnFn: Function }} args
|
|
26
|
+
* @returns {string|null} the commit `ref` points at.
|
|
27
|
+
*/
|
|
28
|
+
export function resolveRefSha({ cwd, ref, gitSpawnFn }) {
|
|
29
|
+
try {
|
|
30
|
+
const probe = gitSpawnFn(
|
|
31
|
+
cwd,
|
|
32
|
+
'rev-parse',
|
|
33
|
+
'--verify',
|
|
34
|
+
'--quiet',
|
|
35
|
+
`${ref}^{commit}`,
|
|
36
|
+
);
|
|
37
|
+
const sha = probe?.status === 0 ? String(probe.stdout ?? '').trim() : '';
|
|
38
|
+
return sha.length > 0 ? sha : null;
|
|
39
|
+
} catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* sha256 of the three-dot diff text, spawned exactly as the review provider
|
|
46
|
+
* spawns it. `null` when the diff cannot be read — an unknown identity that
|
|
47
|
+
* never matches anything.
|
|
48
|
+
*
|
|
49
|
+
* @param {{ cwd: string, baseRef: string|null, headRef: string|null,
|
|
50
|
+
* gitSpawnFn: Function }} args
|
|
51
|
+
* @returns {string|null}
|
|
52
|
+
*/
|
|
53
|
+
export function computeReviewDiffDigest({ cwd, baseRef, headRef, gitSpawnFn }) {
|
|
54
|
+
if (!baseRef || !headRef) return null;
|
|
55
|
+
try {
|
|
56
|
+
const diff = gitSpawnFn(
|
|
57
|
+
cwd,
|
|
58
|
+
'diff',
|
|
59
|
+
'--no-color',
|
|
60
|
+
`${baseRef}...${headRef}`,
|
|
61
|
+
);
|
|
62
|
+
if (diff?.status !== 0) return null;
|
|
63
|
+
return createHash('sha256')
|
|
64
|
+
.update(String(diff.stdout ?? ''), 'utf8')
|
|
65
|
+
.digest('hex');
|
|
66
|
+
} catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Flatten a computed review `result` into the deposit record.
|
|
73
|
+
*
|
|
74
|
+
* @param {{ storyId: number, headSha: string, baseRef: string,
|
|
75
|
+
* diffDigest: string, result: object, createdAt?: string }} args
|
|
76
|
+
* @returns {object}
|
|
77
|
+
*/
|
|
78
|
+
export function buildReviewDeposit({
|
|
79
|
+
storyId,
|
|
80
|
+
headSha,
|
|
81
|
+
baseRef,
|
|
82
|
+
diffDigest,
|
|
83
|
+
result,
|
|
84
|
+
createdAt = new Date().toISOString(),
|
|
85
|
+
}) {
|
|
86
|
+
return {
|
|
87
|
+
kind: DEPOSIT_KIND,
|
|
88
|
+
storyId,
|
|
89
|
+
headSha,
|
|
90
|
+
baseRef,
|
|
91
|
+
diffDigest,
|
|
92
|
+
createdAt,
|
|
93
|
+
provider: result.providerName ?? null,
|
|
94
|
+
severity: result.severity,
|
|
95
|
+
halted: !!result.halted,
|
|
96
|
+
criticalByProvider: result.criticalByProvider ?? {},
|
|
97
|
+
findings: Array.isArray(result.findings) ? result.findings : [],
|
|
98
|
+
report: result.report ?? '',
|
|
99
|
+
degraded: !!result.degraded,
|
|
100
|
+
degradations: Array.isArray(result.degradations) ? result.degradations : [],
|
|
101
|
+
blockerReason: result.blockerReason ?? null,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The deposit as `computeStoryScopeReview`'s `{ result }` shape, ready for
|
|
107
|
+
* `settleStoryScopeReview` to post.
|
|
108
|
+
*
|
|
109
|
+
* @param {object} deposit
|
|
110
|
+
* @returns {{ result: object }}
|
|
111
|
+
*/
|
|
112
|
+
export function depositAsComputedReview(deposit) {
|
|
113
|
+
return {
|
|
114
|
+
result: {
|
|
115
|
+
status: 'ok',
|
|
116
|
+
severity: deposit.severity,
|
|
117
|
+
findings: deposit.findings,
|
|
118
|
+
providerName: deposit.provider,
|
|
119
|
+
report: deposit.report,
|
|
120
|
+
posted: false,
|
|
121
|
+
postedCommentId: null,
|
|
122
|
+
halted: deposit.halted,
|
|
123
|
+
criticalByProvider: deposit.criticalByProvider,
|
|
124
|
+
degraded: deposit.degraded,
|
|
125
|
+
degradations: deposit.degradations,
|
|
126
|
+
blockerReason: deposit.blockerReason,
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Atomic write (pid-scoped tmp + rename): close may read it mid-write.
|
|
133
|
+
*
|
|
134
|
+
* @param {object} deposit
|
|
135
|
+
* @param {{ config?: object, fsImpl?: typeof nodeFs }} [deps]
|
|
136
|
+
* @returns {string} the path written.
|
|
137
|
+
*/
|
|
138
|
+
export function writeReviewDeposit(deposit, { config, fsImpl = nodeFs } = {}) {
|
|
139
|
+
const target = storyReviewDepositPath(deposit.storyId, config);
|
|
140
|
+
fsImpl.mkdirSync(path.dirname(target), { recursive: true });
|
|
141
|
+
const tmpPath = `${target}.${process.pid}.tmp`;
|
|
142
|
+
fsImpl.writeFileSync(
|
|
143
|
+
tmpPath,
|
|
144
|
+
`${JSON.stringify(deposit, null, 2)}\n`,
|
|
145
|
+
'utf8',
|
|
146
|
+
);
|
|
147
|
+
fsImpl.renameSync(tmpPath, target);
|
|
148
|
+
return target;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* @param {unknown} record
|
|
153
|
+
* @param {number} storyId
|
|
154
|
+
* @returns {boolean}
|
|
155
|
+
*/
|
|
156
|
+
function isDeposit(record, storyId) {
|
|
157
|
+
return (
|
|
158
|
+
record?.kind === DEPOSIT_KIND &&
|
|
159
|
+
record.storyId === storyId &&
|
|
160
|
+
typeof record.diffDigest === 'string' &&
|
|
161
|
+
record.diffDigest.length > 0 &&
|
|
162
|
+
typeof record.report === 'string' &&
|
|
163
|
+
typeof record.severity === 'object' &&
|
|
164
|
+
record.severity !== null
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* @param {number} storyId
|
|
170
|
+
* @param {{ config?: object, fsImpl?: typeof nodeFs }} [deps]
|
|
171
|
+
* @returns {object|null} the Story's deposit, or `null` when absent or
|
|
172
|
+
* malformed.
|
|
173
|
+
*/
|
|
174
|
+
function readReviewDeposit(storyId, { config, fsImpl = nodeFs } = {}) {
|
|
175
|
+
try {
|
|
176
|
+
const raw = fsImpl.readFileSync(
|
|
177
|
+
storyReviewDepositPath(storyId, config),
|
|
178
|
+
'utf8',
|
|
179
|
+
);
|
|
180
|
+
const record = JSON.parse(raw);
|
|
181
|
+
return isDeposit(record, storyId) ? record : null;
|
|
182
|
+
} catch {
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Close's held-review start point: the base ref, the diff digest of
|
|
189
|
+
* `origin/<base>...<sha>`, and the worker deposit when its digest matches.
|
|
190
|
+
*
|
|
191
|
+
* @param {{ cwd: string, storyId: number|string, sha: string,
|
|
192
|
+
* baseBranch: string, gitSpawnFn: Function, config?: object,
|
|
193
|
+
* readDepositFn?: typeof readReviewDeposit }} args
|
|
194
|
+
* @returns {{ baseRef: string|null, diffDigest: string|null,
|
|
195
|
+
* deposit: object|null }}
|
|
196
|
+
*/
|
|
197
|
+
export function probeHeldReviewDiff({
|
|
198
|
+
cwd,
|
|
199
|
+
storyId,
|
|
200
|
+
sha,
|
|
201
|
+
baseBranch,
|
|
202
|
+
gitSpawnFn,
|
|
203
|
+
config,
|
|
204
|
+
readDepositFn = readReviewDeposit,
|
|
205
|
+
}) {
|
|
206
|
+
const base = resolveSharedBaseRef({ baseBranch, cwd, gitSpawnFn });
|
|
207
|
+
const baseRef = base.resolved ? base.ref : null;
|
|
208
|
+
const diffDigest = computeReviewDiffDigest({
|
|
209
|
+
cwd,
|
|
210
|
+
baseRef,
|
|
211
|
+
headRef: sha,
|
|
212
|
+
gitSpawnFn,
|
|
213
|
+
});
|
|
214
|
+
const deposit = diffDigest
|
|
215
|
+
? readDepositFn(Number(storyId), { config })
|
|
216
|
+
: null;
|
|
217
|
+
const matches = deposit?.diffDigest === diffDigest;
|
|
218
|
+
return { baseRef, diffDigest, deposit: matches ? deposit : null };
|
|
219
|
+
}
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* @typedef {import('./types.js').ReviewProvider} ReviewProvider
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
import {
|
|
18
|
+
import { spawnCaptureAsync } from '../../child-exec.js';
|
|
19
19
|
import { gitSpawn } from '../../git-utils.js';
|
|
20
20
|
import { PROJECT_ROOT } from '../../project-root.js';
|
|
21
21
|
import { parseProviderFindings } from './parse-findings.js';
|
|
@@ -62,14 +62,15 @@ function buildPrompt(input, diff) {
|
|
|
62
62
|
}
|
|
63
63
|
|
|
64
64
|
/**
|
|
65
|
-
* The prompt rides stdin so no shell ever quotes it.
|
|
65
|
+
* The prompt rides stdin so no shell ever quotes it. Asynchronous: the
|
|
66
|
+
* review overlaps close's gate children, whose output must keep draining.
|
|
66
67
|
*
|
|
67
68
|
* @param {string} prompt
|
|
68
|
-
* @param {Function} [run] - `
|
|
69
|
-
* @returns {{ status: number, stdout: string, stderr: string }}
|
|
69
|
+
* @param {Function} [run] - `(file, args, options)` seam for tests.
|
|
70
|
+
* @returns {Promise<{ status: number, stdout: string, stderr: string }>}
|
|
70
71
|
*/
|
|
71
72
|
function invokeClaude(prompt, run) {
|
|
72
|
-
return
|
|
73
|
+
return spawnCaptureAsync('claude', [...CLAUDE_ARGS], {
|
|
73
74
|
cwd: PROJECT_ROOT,
|
|
74
75
|
input: prompt,
|
|
75
76
|
shell: process.platform === 'win32',
|
|
@@ -144,7 +145,7 @@ function assertInput(input) {
|
|
|
144
145
|
* gitSpawnFn?: typeof gitSpawn,
|
|
145
146
|
* spawnFn?: Function,
|
|
146
147
|
* logger?: { info?: Function, warn?: Function },
|
|
147
|
-
* }} [deps] - `spawnFn` replaces
|
|
148
|
+
* }} [deps] - `spawnFn` replaces the async runner for the `claude` call.
|
|
148
149
|
* @returns {ReviewProvider}
|
|
149
150
|
*/
|
|
150
151
|
export function createCodeReviewProviderForRegistry(deps = {}) {
|
|
@@ -183,7 +184,10 @@ export function createCodeReviewProviderForRegistry(deps = {}) {
|
|
|
183
184
|
logger?.info?.(
|
|
184
185
|
`[code-review] Invoking claude --print --effort low for Story #${ticketId} (${baseRef}...${headRef})...`,
|
|
185
186
|
);
|
|
186
|
-
const result = invokeClaude(
|
|
187
|
+
const result = await invokeClaude(
|
|
188
|
+
buildPrompt(input, diff.stdout),
|
|
189
|
+
spawnFn,
|
|
190
|
+
);
|
|
187
191
|
if (result.status !== 0) {
|
|
188
192
|
logger?.warn?.(
|
|
189
193
|
`[code-review] claude exited ${result.status}; emitting advisory.`,
|
|
@@ -12,12 +12,14 @@ import { buildDefaultGates as defaultBuildDefaultGates } from '../../../close-va
|
|
|
12
12
|
import { runCloseValidation as defaultRunCloseValidation } from '../../../close-validation/runner.js';
|
|
13
13
|
import { LOCK_WAIT_EXPIRED_EXIT_CODE } from '../../../full-suite-lock.js';
|
|
14
14
|
import { parseLockWaitOutcome } from '../../../full-suite-queue.js';
|
|
15
|
+
import { parseSuiteTimings } from '../../../supervised-suite.js';
|
|
15
16
|
import { createGateLogSink as defaultCreateGateLogSink } from '../gate-log.js';
|
|
16
17
|
import { runPreGateSteps as defaultRunPreGateSteps } from './pre-gate-steps.js';
|
|
17
18
|
|
|
18
19
|
/**
|
|
19
20
|
* Pre-gate self-heal steps, then the gates (throws on first failure). The
|
|
20
21
|
* steps commit in the worktree before scoring, so the gates see their output.
|
|
22
|
+
* `onPreGateStepsDone` (must not throw) runs once the tree is final.
|
|
21
23
|
*
|
|
22
24
|
* @param {{
|
|
23
25
|
* cwd: string,
|
|
@@ -34,10 +36,12 @@ import { runPreGateSteps as defaultRunPreGateSteps } from './pre-gate-steps.js';
|
|
|
34
36
|
* runBaselineUpwardWriteback?: Function,
|
|
35
37
|
* runContextBudgetWriteback?: Function,
|
|
36
38
|
* createGateLogSink?: typeof defaultCreateGateLogSink,
|
|
39
|
+
* onPreGateStepsDone?: () => void,
|
|
37
40
|
* }} args
|
|
38
41
|
* @returns {Promise<{
|
|
39
42
|
* gates: Record<string, 'passed'|'skipped'>|null,
|
|
40
|
-
* lockWait: { waitedSeconds: number, expired: boolean }|null,
|
|
43
|
+
* lockWait: { waitedSeconds: number, expired: boolean, holder?: object }|null,
|
|
44
|
+
* suiteTimings: { lockWaitMs: number, hostWaitMs: number|null, testRunMs: number }|null,
|
|
41
45
|
* pending: boolean,
|
|
42
46
|
* }>} `pending` (with `gates: null`) when a lock wait expired; any other
|
|
43
47
|
* failure throws with `err.closeGate` naming the gate.
|
|
@@ -57,6 +61,7 @@ export async function runCloseValidationPhase({
|
|
|
57
61
|
runBaselineUpwardWriteback,
|
|
58
62
|
runContextBudgetWriteback,
|
|
59
63
|
createGateLogSink = defaultCreateGateLogSink,
|
|
64
|
+
onPreGateStepsDone,
|
|
60
65
|
}) {
|
|
61
66
|
await runPreGateSteps({
|
|
62
67
|
cwd,
|
|
@@ -70,6 +75,7 @@ export async function runCloseValidationPhase({
|
|
|
70
75
|
runBaselineUpwardWriteback,
|
|
71
76
|
runContextBudgetWriteback,
|
|
72
77
|
});
|
|
78
|
+
onPreGateStepsDone?.();
|
|
73
79
|
|
|
74
80
|
progress(
|
|
75
81
|
'VALIDATE',
|
|
@@ -101,7 +107,6 @@ export async function runCloseValidationPhase({
|
|
|
101
107
|
baseBranch,
|
|
102
108
|
storyBranch,
|
|
103
109
|
config,
|
|
104
|
-
deferOnLockExpiry: true,
|
|
105
110
|
});
|
|
106
111
|
} finally {
|
|
107
112
|
// The sink is async-buffered; settle it before anything reads it, on
|
|
@@ -116,6 +121,7 @@ export async function runCloseValidationPhase({
|
|
|
116
121
|
return {
|
|
117
122
|
gates: gateOutcomes(gateList, validation),
|
|
118
123
|
lockWait,
|
|
124
|
+
suiteTimings: lockWaits.suiteTimings(),
|
|
119
125
|
pending: false,
|
|
120
126
|
};
|
|
121
127
|
}
|
|
@@ -129,7 +135,7 @@ export async function runCloseValidationPhase({
|
|
|
129
135
|
* gateLog: { replay: () => void },
|
|
130
136
|
* progress: (tag: string, msg: string) => void,
|
|
131
137
|
* }} args
|
|
132
|
-
* @returns {{ gates: null, lockWait: object, pending: true }}
|
|
138
|
+
* @returns {{ gates: null, lockWait: object, suiteTimings: null, pending: true }}
|
|
133
139
|
*/
|
|
134
140
|
function settleFailedValidation({ validation, lockWait, gateLog, progress }) {
|
|
135
141
|
const [first] = validation.failed;
|
|
@@ -139,7 +145,7 @@ function settleFailedValidation({ validation, lockWait, gateLog, progress }) {
|
|
|
139
145
|
'VALIDATE',
|
|
140
146
|
`⏸ ${gate.name} deferred: the full-suite lock wait expired after ${lockWait.waitedSeconds}s. Nothing was spawned; close will report pending.`,
|
|
141
147
|
);
|
|
142
|
-
return { gates: null, lockWait, pending: true };
|
|
148
|
+
return { gates: null, lockWait, suiteTimings: null, pending: true };
|
|
143
149
|
}
|
|
144
150
|
gateLog.replay();
|
|
145
151
|
const err = new Error(
|
|
@@ -151,27 +157,40 @@ function settleFailedValidation({ validation, lockWait, gateLog, progress }) {
|
|
|
151
157
|
}
|
|
152
158
|
|
|
153
159
|
/**
|
|
154
|
-
* Parsed from the gate log
|
|
160
|
+
* Parsed from the gate log: the one place that sees waits and timings both
|
|
155
161
|
* in-process and in gate children.
|
|
156
162
|
*
|
|
157
163
|
* @param {{ sink: (m: string) => void, progress: (tag: string, msg: string) => void }} args
|
|
158
|
-
* @returns {{ log: (m: string) => void, summary: () => { waitedSeconds: number, expired: boolean }|null }}
|
|
164
|
+
* @returns {{ log: (m: string) => void, summary: () => { waitedSeconds: number, expired: boolean, holder?: object }|null, suiteTimings: () => object|null }}
|
|
159
165
|
*/
|
|
160
166
|
function trackLockWaits({ sink, progress }) {
|
|
161
167
|
let tally = null;
|
|
168
|
+
let timings = null;
|
|
162
169
|
return {
|
|
163
170
|
log(line) {
|
|
164
171
|
sink(line);
|
|
172
|
+
timings = parseSuiteTimings(line) ?? timings;
|
|
165
173
|
if (!String(line).includes('[full-suite-lock]')) return;
|
|
166
174
|
progress('LOCK', line);
|
|
167
175
|
const outcome = parseLockWaitOutcome(line);
|
|
168
176
|
if (!outcome) return;
|
|
169
|
-
tally =
|
|
170
|
-
waitedSeconds: (tally?.waitedSeconds ?? 0) + outcome.waitedSeconds,
|
|
171
|
-
expired: Boolean(tally?.expired) || outcome.expired,
|
|
172
|
-
};
|
|
177
|
+
tally = mergeLockWait(tally, outcome);
|
|
173
178
|
},
|
|
174
179
|
summary: () => tally,
|
|
180
|
+
suiteTimings: () => timings,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* @param {{ waitedSeconds: number, expired: boolean, holder?: object }|null} tally
|
|
186
|
+
* @param {{ waitedSeconds: number, expired: boolean, holder?: object }} outcome
|
|
187
|
+
*/
|
|
188
|
+
function mergeLockWait(tally, outcome) {
|
|
189
|
+
const holder = outcome.holder ?? tally?.holder;
|
|
190
|
+
return {
|
|
191
|
+
waitedSeconds: (tally?.waitedSeconds ?? 0) + outcome.waitedSeconds,
|
|
192
|
+
expired: Boolean(tally?.expired) || outcome.expired,
|
|
193
|
+
...(holder ? { holder } : {}),
|
|
175
194
|
};
|
|
176
195
|
}
|
|
177
196
|
|
|
@@ -49,29 +49,6 @@ export function buildStoryReviewCrossRefBody({
|
|
|
49
49
|
);
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
async function invokeStoryReviewCore({
|
|
53
|
-
storyId,
|
|
54
|
-
storyBranch,
|
|
55
|
-
baseRef,
|
|
56
|
-
prNumber,
|
|
57
|
-
provider,
|
|
58
|
-
runCodeReviewFn,
|
|
59
|
-
gitSpawnFn,
|
|
60
|
-
progress,
|
|
61
|
-
}) {
|
|
62
|
-
return runStoryReviewCore({
|
|
63
|
-
storyId,
|
|
64
|
-
baseRef,
|
|
65
|
-
headRef: storyBranch,
|
|
66
|
-
commentTargetId: prNumber,
|
|
67
|
-
provider,
|
|
68
|
-
progress,
|
|
69
|
-
progressTag: 'REVIEW',
|
|
70
|
-
runCodeReviewFn,
|
|
71
|
-
gitSpawnFn,
|
|
72
|
-
});
|
|
73
|
-
}
|
|
74
|
-
|
|
75
52
|
async function postStoryReviewCrossRef({
|
|
76
53
|
provider,
|
|
77
54
|
storyId,
|
|
@@ -115,7 +92,122 @@ async function postStoryReviewCrossRef({
|
|
|
115
92
|
}
|
|
116
93
|
|
|
117
94
|
/**
|
|
118
|
-
*
|
|
95
|
+
* Run the review against `headRef`; `deferPost` holds the report unposted.
|
|
96
|
+
*
|
|
97
|
+
* @returns {Promise<{ outcome: object }|{ result: object }>} a final
|
|
98
|
+
* `outcome` (unresolvable base), or the `result` to settle.
|
|
99
|
+
*/
|
|
100
|
+
export async function computeStoryScopeReview({
|
|
101
|
+
cwd,
|
|
102
|
+
storyId,
|
|
103
|
+
headRef,
|
|
104
|
+
baseBranch,
|
|
105
|
+
commentTargetId = null,
|
|
106
|
+
deferPost = false,
|
|
107
|
+
provider,
|
|
108
|
+
runCodeReviewFn,
|
|
109
|
+
gitSpawnFn,
|
|
110
|
+
progress,
|
|
111
|
+
}) {
|
|
112
|
+
const base = resolveSharedBaseRef({ baseBranch, cwd, gitSpawnFn });
|
|
113
|
+
if (!base.resolved) {
|
|
114
|
+
return {
|
|
115
|
+
outcome: unresolvedBaseReviewOutcome({
|
|
116
|
+
storyId,
|
|
117
|
+
baseBranch,
|
|
118
|
+
remoteRef: base.remoteRef,
|
|
119
|
+
progress,
|
|
120
|
+
}),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
const target = deferPost
|
|
124
|
+
? 'held until the PR exists'
|
|
125
|
+
: `→ PR #${commentTargetId}`;
|
|
126
|
+
progress(
|
|
127
|
+
'REVIEW',
|
|
128
|
+
`Running Story-scope code review for Story #${storyId} (${base.ref}...${headRef}) ${target}...`,
|
|
129
|
+
);
|
|
130
|
+
const result = await runStoryReviewCore({
|
|
131
|
+
storyId,
|
|
132
|
+
baseRef: base.ref,
|
|
133
|
+
headRef,
|
|
134
|
+
commentTargetId,
|
|
135
|
+
provider,
|
|
136
|
+
progress,
|
|
137
|
+
progressTag: 'REVIEW',
|
|
138
|
+
runCodeReviewFn: deferPost
|
|
139
|
+
? (opts) => runCodeReviewFn({ ...opts, deferPost: true })
|
|
140
|
+
: runCodeReviewFn,
|
|
141
|
+
gitSpawnFn,
|
|
142
|
+
});
|
|
143
|
+
return { result };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Settle a computed review on the PR: post a held report via `postReportFn`,
|
|
148
|
+
* report the tally, cross-reference the Story.
|
|
149
|
+
*
|
|
150
|
+
* @returns {Promise<object>} the review outcome.
|
|
151
|
+
*/
|
|
152
|
+
export async function settleStoryScopeReview({
|
|
153
|
+
computed,
|
|
154
|
+
storyId,
|
|
155
|
+
prUrl,
|
|
156
|
+
prNumber,
|
|
157
|
+
provider,
|
|
158
|
+
progress,
|
|
159
|
+
postReportFn = null,
|
|
160
|
+
}) {
|
|
161
|
+
if (computed.outcome) return computed.outcome;
|
|
162
|
+
const posting = postReportFn
|
|
163
|
+
? await postReportFn({
|
|
164
|
+
provider,
|
|
165
|
+
commentTargetId: prNumber,
|
|
166
|
+
report: computed.result.report,
|
|
167
|
+
logger: {
|
|
168
|
+
info: (m) => progress('REVIEW', m),
|
|
169
|
+
warn: (m) => progress('REVIEW', `⚠️ ${m}`),
|
|
170
|
+
},
|
|
171
|
+
})
|
|
172
|
+
: {};
|
|
173
|
+
const result = { ...computed.result, ...posting };
|
|
174
|
+
const sev = result.severity ?? {
|
|
175
|
+
critical: 0,
|
|
176
|
+
high: 0,
|
|
177
|
+
medium: 0,
|
|
178
|
+
suggestion: 0,
|
|
179
|
+
};
|
|
180
|
+
const outcome = formatReviewOutcomeLines({
|
|
181
|
+
severity: sev,
|
|
182
|
+
degradations: result.degradations,
|
|
183
|
+
prNumber,
|
|
184
|
+
posted: result.posted,
|
|
185
|
+
});
|
|
186
|
+
for (const line of outcome) progress('REVIEW', line);
|
|
187
|
+
|
|
188
|
+
const crossRefPosted = await postStoryReviewCrossRef({
|
|
189
|
+
provider,
|
|
190
|
+
storyId,
|
|
191
|
+
prUrl,
|
|
192
|
+
prNumber,
|
|
193
|
+
result,
|
|
194
|
+
severity: sev,
|
|
195
|
+
progress,
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
return {
|
|
199
|
+
halted: !!result.halted,
|
|
200
|
+
severity: sev,
|
|
201
|
+
criticalByProvider: result.criticalByProvider,
|
|
202
|
+
posted: result.posted,
|
|
203
|
+
postedCommentId: result.postedCommentId ?? null,
|
|
204
|
+
...degradationEnvelope(result.degradations),
|
|
205
|
+
crossRefPosted,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* The serial review, posting to the PR. Skips on an unparseable PR number or an unresolvable base (recording a
|
|
119
211
|
* degradation); a runner throw propagates and fails the close.
|
|
120
212
|
*
|
|
121
213
|
* @param {{
|
|
@@ -161,64 +253,23 @@ export async function runStoryScopeReview({
|
|
|
161
253
|
);
|
|
162
254
|
return { halted: false, skipped: true };
|
|
163
255
|
}
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
if (!base.resolved) {
|
|
167
|
-
return unresolvedBaseReviewOutcome({
|
|
168
|
-
storyId,
|
|
169
|
-
baseBranch,
|
|
170
|
-
remoteRef: base.remoteRef,
|
|
171
|
-
progress,
|
|
172
|
-
});
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
progress(
|
|
176
|
-
'REVIEW',
|
|
177
|
-
`Running Story-scope code review for Story #${storyId} (${base.ref}...${storyBranch}) → PR #${prNumber}...`,
|
|
178
|
-
);
|
|
179
|
-
|
|
180
|
-
const result = await invokeStoryReviewCore({
|
|
256
|
+
const computed = await computeStoryScopeReview({
|
|
257
|
+
cwd,
|
|
181
258
|
storyId,
|
|
182
|
-
storyBranch,
|
|
183
|
-
|
|
184
|
-
prNumber,
|
|
259
|
+
headRef: storyBranch,
|
|
260
|
+
baseBranch,
|
|
261
|
+
commentTargetId: prNumber,
|
|
185
262
|
provider,
|
|
186
263
|
runCodeReviewFn,
|
|
187
264
|
gitSpawnFn,
|
|
188
265
|
progress,
|
|
189
266
|
});
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
critical: 0,
|
|
193
|
-
high: 0,
|
|
194
|
-
medium: 0,
|
|
195
|
-
suggestion: 0,
|
|
196
|
-
};
|
|
197
|
-
const outcome = formatReviewOutcomeLines({
|
|
198
|
-
severity: sev,
|
|
199
|
-
degradations: result.degradations,
|
|
200
|
-
prNumber,
|
|
201
|
-
posted: result.posted,
|
|
202
|
-
});
|
|
203
|
-
for (const line of outcome) progress('REVIEW', line);
|
|
204
|
-
|
|
205
|
-
const crossRefPosted = await postStoryReviewCrossRef({
|
|
206
|
-
provider,
|
|
267
|
+
return settleStoryScopeReview({
|
|
268
|
+
computed,
|
|
207
269
|
storyId,
|
|
208
270
|
prUrl,
|
|
209
271
|
prNumber,
|
|
210
|
-
|
|
211
|
-
severity: sev,
|
|
272
|
+
provider,
|
|
212
273
|
progress,
|
|
213
274
|
});
|
|
214
|
-
|
|
215
|
-
return {
|
|
216
|
-
halted: !!result.halted,
|
|
217
|
-
severity: sev,
|
|
218
|
-
criticalByProvider: result.criticalByProvider,
|
|
219
|
-
posted: result.posted,
|
|
220
|
-
postedCommentId: result.postedCommentId ?? null,
|
|
221
|
-
...degradationEnvelope(result.degradations),
|
|
222
|
-
crossRefPosted,
|
|
223
|
-
};
|
|
224
275
|
}
|