mandrel 2.65.0 → 2.67.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/acceptance-critic.md +7 -7
- package/.agents/agents/auditor.md +17 -18
- package/.agents/agents/plan-critic.md +5 -5
- package/.agents/agents/story-worker.md +5 -5
- package/.agents/docs/agentrc-reference.json +2 -1
- package/.agents/docs/configuration.md +2 -1
- package/.agents/docs/execution-reference.md +27 -5
- package/.agents/docs/workflows.md +4 -2
- package/.agents/instructions.md +12 -13
- package/.agents/rules/ci-remediation.md +3 -3
- package/.agents/rules/gherkin-standards.md +3 -2
- package/.agents/rules/git-conventions-reference.md +17 -8
- package/.agents/rules/git-conventions.md +10 -8
- package/.agents/rules/testing-standards.md +8 -7
- package/.agents/runtime-deps.json +1 -1
- package/.agents/schemas/agentrc.schema.json +6 -1
- package/.agents/scripts/boot-sweep.js +97 -9
- package/.agents/scripts/bootstrap.js +94 -89
- package/.agents/scripts/{git-cleanup.js → clean-git.js} +2 -2
- package/.agents/scripts/clean-temp.js +54 -0
- package/.agents/scripts/clean-worktrees.js +593 -0
- package/.agents/scripts/drain-pending-cleanup.js +5 -4
- package/.agents/scripts/lib/baselines/duplication-scanner.js +17 -7
- package/.agents/scripts/lib/bootstrap/project-bootstrap.js +78 -78
- package/.agents/scripts/lib/clean-temp.js +440 -0
- package/.agents/scripts/lib/cli/standard-args.js +60 -76
- package/.agents/scripts/lib/cli-args.js +26 -0
- package/.agents/scripts/lib/config/gates/shared.js +3 -3
- package/.agents/scripts/lib/config-settings-schema-delivery.js +11 -2
- package/.agents/scripts/lib/feedback-loop/graduate-steps.js +205 -0
- package/.agents/scripts/lib/feedback-loop/graduator-core.js +47 -782
- package/.agents/scripts/lib/feedback-loop/graduator-gh.js +449 -0
- package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
- package/.agents/scripts/lib/observability/close-telemetry.js +330 -0
- package/.agents/scripts/lib/observability/runtime-friction.js +2 -0
- package/.agents/scripts/lib/observability/signal-validator.js +17 -5
- package/.agents/scripts/lib/observability/source-classifier.js +3 -1
- package/.agents/scripts/lib/orchestration/code-review.js +22 -0
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/cli.js +1 -1
- package/.agents/scripts/lib/orchestration/plan-metrics.js +76 -63
- package/.agents/scripts/lib/orchestration/plan-runner/worktree-sweep.js +149 -97
- package/.agents/scripts/lib/orchestration/review-providers/review-provider-factory.js +23 -0
- package/.agents/scripts/lib/orchestration/run-epilogue.js +6 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +2 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +349 -263
- package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +21 -7
- package/.agents/scripts/lib/orchestration/single-story-close/phases/review-override.js +4 -0
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +327 -314
- package/.agents/scripts/lib/orchestration/ticket-validator.js +19 -36
- package/.agents/scripts/lib/signals/detectors/common.js +63 -51
- package/.agents/scripts/lib/single-story-sweep.js +2 -2
- package/.agents/scripts/lib/temp-removal.js +110 -0
- package/.agents/scripts/lib/temp-retention.js +122 -73
- package/.agents/scripts/lib/transpile.js +28 -3
- package/.agents/scripts/lib/worktree/canonical-path.js +34 -0
- package/.agents/scripts/lib/worktree/lifecycle/reap.js +15 -4
- package/.agents/scripts/single-story-close.js +10 -2
- package/.agents/scripts/single-story-confirm-merge.js +267 -238
- package/.agents/scripts/single-story-init.js +120 -17
- package/.agents/skills/core/idea-refinement/SKILL.md +6 -6
- package/.agents/skills/stack/qa/qa-harness/SKILL.md +1 -2
- package/.agents/workflows/audit-architecture.md +5 -4
- package/.agents/workflows/audit-documentation.md +5 -5
- package/.agents/workflows/audit-performance.md +10 -10
- package/.agents/workflows/{git-cleanup.md → clean-git.md} +10 -10
- package/.agents/workflows/clean-temp.md +67 -0
- package/.agents/workflows/clean-worktrees.md +63 -0
- package/.agents/workflows/git-deliver.md +1 -1
- package/.agents/workflows/helpers/acceptance-self-eval.md +11 -10
- package/.agents/workflows/helpers/audit-lens-core.md +30 -57
- package/.agents/workflows/helpers/deliver-digest.md +2 -2
- package/.agents/workflows/helpers/deliver-reference.md +3 -1
- package/.agents/workflows/helpers/deliver-story-reference.md +2 -2
- package/.agents/workflows/helpers/deliver-story.md +6 -1
- package/.agents/workflows/helpers/parallel-tooling.md +16 -18
- package/.agents/workflows/mandrel-deliver.md +1 -1
- package/.agents/workflows/mandrel-plan.md +6 -5
- package/docs/CHANGELOG.md +39 -0
- package/lib/cli/guarded-sync.js +87 -0
- package/lib/cli/sync-agents.js +9 -92
- package/lib/cli/sync-commands.js +9 -101
- package/package.json +2 -2
|
@@ -24,13 +24,26 @@ const SUPPORTED_FLAGS = Object.freeze({
|
|
|
24
24
|
|
|
25
25
|
const FLAG_NAMES = Object.keys(SUPPORTED_FLAGS);
|
|
26
26
|
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
27
|
+
const isNullish = (v) => v === null || v === undefined;
|
|
28
|
+
|
|
29
|
+
/** Per-type rules shared by built-in and extra flags. */
|
|
30
|
+
const FLAG_TYPES = Object.freeze({
|
|
31
|
+
string: {
|
|
32
|
+
normalise: (v, orDefault) =>
|
|
33
|
+
typeof v === 'string' && v.length > 0 ? v : orDefault(null),
|
|
34
|
+
isAbsent: (v) => isNullish(v) || v === '',
|
|
35
|
+
},
|
|
36
|
+
boolean: { normalise: (v) => v === true, isAbsent: (v) => !v },
|
|
37
|
+
ticket: { normalise: (v) => parseTicketId(v), isAbsent: isNullish },
|
|
38
|
+
integer: {
|
|
39
|
+
normalise: (v, orDefault) => (v === undefined ? orDefault(undefined) : v),
|
|
40
|
+
isAbsent: (v) => v === undefined || Number.isNaN(v),
|
|
41
|
+
},
|
|
42
|
+
'string-multi': {
|
|
43
|
+
normalise: (v, orDefault) => (Array.isArray(v) ? v : orDefault([])),
|
|
44
|
+
isAbsent: (v) => !Array.isArray(v) || v.length === 0,
|
|
45
|
+
},
|
|
46
|
+
});
|
|
34
47
|
|
|
35
48
|
function camelCase(name) {
|
|
36
49
|
return name.replace(/-([a-z])/g, (_m, c) => c.toUpperCase());
|
|
@@ -50,16 +63,33 @@ function normaliseCallSignature(opts) {
|
|
|
50
63
|
return { argv: argv ?? [], schema, extras };
|
|
51
64
|
}
|
|
52
65
|
|
|
53
|
-
function
|
|
66
|
+
function flagEntries(schema, extras) {
|
|
67
|
+
const entries = Object.entries(SUPPORTED_FLAGS).map(
|
|
68
|
+
([flag, { key, type }]) => ({
|
|
69
|
+
flag,
|
|
70
|
+
key,
|
|
71
|
+
type,
|
|
72
|
+
def: {},
|
|
73
|
+
required: schema?.[flag]?.required === true,
|
|
74
|
+
}),
|
|
75
|
+
);
|
|
76
|
+
for (const [flag, def] of Object.entries(extras ?? {})) {
|
|
77
|
+
entries.push({
|
|
78
|
+
flag,
|
|
79
|
+
key: def.alias ?? camelCase(flag),
|
|
80
|
+
type: def.type,
|
|
81
|
+
def,
|
|
82
|
+
required: def.required === true,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
return entries;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function buildDefineFlagsSpec(entries) {
|
|
54
89
|
const spec = {};
|
|
55
|
-
for (const
|
|
90
|
+
for (const { flag, key, type, def } of entries) {
|
|
56
91
|
spec[flag] = { type, alias: key };
|
|
57
|
-
|
|
58
|
-
if (!extras) return spec;
|
|
59
|
-
for (const [flag, def] of Object.entries(extras)) {
|
|
60
|
-
const entry = { type: def.type, alias: def.alias ?? camelCase(flag) };
|
|
61
|
-
if ('default' in def) entry.default = def.default;
|
|
62
|
-
spec[flag] = entry;
|
|
92
|
+
if ('default' in def) spec[flag].default = def.default;
|
|
63
93
|
}
|
|
64
94
|
return spec;
|
|
65
95
|
}
|
|
@@ -119,10 +149,10 @@ function validateExtras(extras) {
|
|
|
119
149
|
`parseStandardCliArgs: extras["${flag}"] must be an object`,
|
|
120
150
|
);
|
|
121
151
|
}
|
|
122
|
-
if (!
|
|
152
|
+
if (!Object.hasOwn(FLAG_TYPES, def.type)) {
|
|
123
153
|
const err = new Error(
|
|
124
154
|
`parseStandardCliArgs: extras["${flag}"].type "${def.type}" is unsupported. ` +
|
|
125
|
-
`Supported: ${
|
|
155
|
+
`Supported: ${Object.keys(FLAG_TYPES).join(', ')}.`,
|
|
126
156
|
);
|
|
127
157
|
err.code = 'UNKNOWN_EXTRAS_TYPE';
|
|
128
158
|
throw err;
|
|
@@ -130,15 +160,6 @@ function validateExtras(extras) {
|
|
|
130
160
|
}
|
|
131
161
|
}
|
|
132
162
|
|
|
133
|
-
function isAbsent(type, cur) {
|
|
134
|
-
if (type === 'ticket') return cur === null || cur === undefined;
|
|
135
|
-
if (type === 'string') return cur === null || cur === undefined || cur === '';
|
|
136
|
-
if (type === 'boolean') return !cur;
|
|
137
|
-
if (type === 'integer') return cur === undefined || Number.isNaN(cur);
|
|
138
|
-
if (type === 'string-multi') return !Array.isArray(cur) || cur.length === 0;
|
|
139
|
-
return cur === undefined || cur === null;
|
|
140
|
-
}
|
|
141
|
-
|
|
142
163
|
function throwMissing(flag) {
|
|
143
164
|
const err = new Error(
|
|
144
165
|
`parseStandardCliArgs: missing required flag --${flag}`,
|
|
@@ -148,56 +169,19 @@ function throwMissing(flag) {
|
|
|
148
169
|
throw err;
|
|
149
170
|
}
|
|
150
171
|
|
|
151
|
-
function
|
|
152
|
-
|
|
153
|
-
for (const
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
if (isAbsent(meta.type, values[meta.key])) throwMissing(flag);
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
function enforceExtrasRequired(values, extras) {
|
|
161
|
-
if (!extras) return;
|
|
162
|
-
for (const [flag, def] of Object.entries(extras)) {
|
|
163
|
-
if (!def || def.required !== true) continue;
|
|
164
|
-
const key = def.alias ?? camelCase(flag);
|
|
165
|
-
if (isAbsent(def.type, values[key])) throwMissing(flag);
|
|
172
|
+
function normaliseValues(raw, entries) {
|
|
173
|
+
const out = {};
|
|
174
|
+
for (const { key, type, def } of entries) {
|
|
175
|
+
const orDefault = (empty) => ('default' in def ? def.default : empty);
|
|
176
|
+
out[key] = FLAG_TYPES[type].normalise(raw[key], orDefault);
|
|
166
177
|
}
|
|
178
|
+
return out;
|
|
167
179
|
}
|
|
168
180
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
for (const [, { key, type }] of Object.entries(SUPPORTED_FLAGS)) {
|
|
173
|
-
let v = raw[key];
|
|
174
|
-
if (type === 'ticket') {
|
|
175
|
-
v = parseTicketId(v);
|
|
176
|
-
} else if (type === 'string') {
|
|
177
|
-
v = typeof v === 'string' && v.length > 0 ? v : null;
|
|
178
|
-
} else if (type === 'boolean') {
|
|
179
|
-
v = v === true;
|
|
180
|
-
}
|
|
181
|
-
out[key] = v;
|
|
181
|
+
function enforceRequired(values, entries) {
|
|
182
|
+
for (const { flag, key, type, required } of entries) {
|
|
183
|
+
if (required && FLAG_TYPES[type].isAbsent(values[key])) throwMissing(flag);
|
|
182
184
|
}
|
|
183
|
-
if (!extras) return out;
|
|
184
|
-
for (const [flag, def] of Object.entries(extras)) {
|
|
185
|
-
const key = def.alias ?? camelCase(flag);
|
|
186
|
-
let v = raw[key];
|
|
187
|
-
if (def.type === 'boolean') {
|
|
188
|
-
v = v === true;
|
|
189
|
-
} else if (def.type === 'string') {
|
|
190
|
-
if (v === undefined) v = 'default' in def ? def.default : null;
|
|
191
|
-
} else if (def.type === 'ticket') {
|
|
192
|
-
v = parseTicketId(v);
|
|
193
|
-
} else if (def.type === 'string-multi') {
|
|
194
|
-
if (!Array.isArray(v)) v = 'default' in def ? def.default : [];
|
|
195
|
-
} else if (def.type === 'integer') {
|
|
196
|
-
if (v === undefined && 'default' in def) v = def.default;
|
|
197
|
-
}
|
|
198
|
-
out[key] = v;
|
|
199
|
-
}
|
|
200
|
-
return out;
|
|
201
185
|
}
|
|
202
186
|
|
|
203
187
|
/**
|
|
@@ -222,13 +206,13 @@ export function parseStandardCliArgs(opts = {}) {
|
|
|
222
206
|
err.flag = unknown;
|
|
223
207
|
throw err;
|
|
224
208
|
}
|
|
209
|
+
const entries = flagEntries(schema, extras);
|
|
225
210
|
const { values: raw, positionals } = defineFlags(
|
|
226
|
-
buildDefineFlagsSpec(
|
|
211
|
+
buildDefineFlagsSpec(entries),
|
|
227
212
|
argv,
|
|
228
213
|
);
|
|
229
|
-
const values = normaliseValues(raw,
|
|
230
|
-
enforceRequired(values,
|
|
231
|
-
enforceExtrasRequired(values, extras);
|
|
214
|
+
const values = normaliseValues(raw, entries);
|
|
215
|
+
enforceRequired(values, entries);
|
|
232
216
|
return { values, positionals };
|
|
233
217
|
}
|
|
234
218
|
|
|
@@ -133,6 +133,30 @@ function tolerantOverrideReviewBlock(value) {
|
|
|
133
133
|
}
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
+
/**
|
|
137
|
+
* Best-effort: absent or junk is `null` plus a warning, never a throw.
|
|
138
|
+
*
|
|
139
|
+
* @param {unknown} value
|
|
140
|
+
* @returns {{ tokens: number|null, warning: string|null }}
|
|
141
|
+
*/
|
|
142
|
+
export function parseWorkerTokens(value) {
|
|
143
|
+
if (value == null) {
|
|
144
|
+
return {
|
|
145
|
+
tokens: null,
|
|
146
|
+
warning:
|
|
147
|
+
'--worker-tokens not supplied; telemetry.workerTokens records null.',
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
const raw = String(value).trim();
|
|
151
|
+
if (/^\d+$/.test(raw) && Number.isSafeInteger(Number(raw))) {
|
|
152
|
+
return { tokens: Number(raw), warning: null };
|
|
153
|
+
}
|
|
154
|
+
return {
|
|
155
|
+
tokens: null,
|
|
156
|
+
warning: `--worker-tokens must be a non-negative integer (got ${JSON.stringify(value)}); telemetry.workerTokens records null.`,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
136
160
|
/**
|
|
137
161
|
* Throws when a validating flag parser rejects a value; error handlers use
|
|
138
162
|
* {@link parseSprintArgsTolerant} instead of re-calling this.
|
|
@@ -164,6 +188,7 @@ export function parseSprintArgs(
|
|
|
164
188
|
'merge-watch-mode': { type: 'string' },
|
|
165
189
|
'rerun-advisory': { type: 'string' },
|
|
166
190
|
'override-review-block': { type: 'string' },
|
|
191
|
+
'worker-tokens': { type: 'string' },
|
|
167
192
|
executor: { type: 'string' },
|
|
168
193
|
cwd: { type: 'string' },
|
|
169
194
|
'recut-of': { type: 'string' },
|
|
@@ -194,6 +219,7 @@ export function parseSprintArgs(
|
|
|
194
219
|
overrideReviewBlock: tolerant
|
|
195
220
|
? tolerantOverrideReviewBlock(values['override-review-block'])
|
|
196
221
|
: parseOverrideReviewBlock(values['override-review-block']),
|
|
222
|
+
workerTokens: values['worker-tokens'],
|
|
197
223
|
executor: values.executor ?? null,
|
|
198
224
|
cwd:
|
|
199
225
|
(typeof values.cwd === 'string' && values.cwd.trim()) ||
|
|
@@ -23,7 +23,7 @@ export const LIST_OR_EXTENDER_OF_STRINGS = {
|
|
|
23
23
|
],
|
|
24
24
|
};
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
const TOLERANCE_SCHEMA = {
|
|
27
27
|
type: 'object',
|
|
28
28
|
description:
|
|
29
29
|
'How much a rollup may drift from the committed baseline before the gate reports a regression.',
|
|
@@ -47,7 +47,7 @@ export const TOLERANCE_SCHEMA = {
|
|
|
47
47
|
/**
|
|
48
48
|
* `"*"` is optional: the resolver injects the framework default when absent.
|
|
49
49
|
*/
|
|
50
|
-
|
|
50
|
+
const FLOORS_SCHEMA = {
|
|
51
51
|
type: 'object',
|
|
52
52
|
description:
|
|
53
53
|
'Workspace-keyed absolute floors: `{ "<workspace>": { "<metric>": number } }`. `"*"` is the project-wide catch-all; the metric keyset is open so per-rollup keys flow through without each gate enumerating them. Floors are absolute — unlike `tolerance`, they are enforced regardless of the baseline.',
|
|
@@ -57,7 +57,7 @@ export const FLOORS_SCHEMA = {
|
|
|
57
57
|
},
|
|
58
58
|
};
|
|
59
59
|
|
|
60
|
-
|
|
60
|
+
const COMPONENTS_SCHEMA = {
|
|
61
61
|
type: 'object',
|
|
62
62
|
description:
|
|
63
63
|
'Per-gate component map — component name to the glob list whose files roll up under it. Defaults to `{ "*": ["**"] }` at the resolver layer.',
|
|
@@ -283,8 +283,9 @@ const TEMP_RETENTION_SCHEMA = {
|
|
|
283
283
|
description:
|
|
284
284
|
'Story #4794. Auto-purge of spent temp artifacts once their Story lands. ' +
|
|
285
285
|
'Classification is an allowlist: only the declared classes below are ever ' +
|
|
286
|
-
'deleted, so
|
|
287
|
-
'size and left alone
|
|
286
|
+
'deleted, so unrecognized files under tempRoot are reported with their ' +
|
|
287
|
+
'size and left alone (`/clean-temp` is the operator path for them). ' +
|
|
288
|
+
'signals.ndjson is never purged by any path.',
|
|
288
289
|
properties: {
|
|
289
290
|
enabled: {
|
|
290
291
|
type: 'boolean',
|
|
@@ -326,6 +327,14 @@ const TEMP_RETENTION_SCHEMA = {
|
|
|
326
327
|
'Age-floored only; the current run is always excluded.',
|
|
327
328
|
default: true,
|
|
328
329
|
},
|
|
330
|
+
scratch: {
|
|
331
|
+
type: 'boolean',
|
|
332
|
+
description:
|
|
333
|
+
'<tempRoot>/scratch/ — agent-authored scratch. ' +
|
|
334
|
+
'`scratch/story-<id>/` is purged when that Story lands; any ' +
|
|
335
|
+
'other `scratch/` entry is age-floored.',
|
|
336
|
+
default: true,
|
|
337
|
+
},
|
|
329
338
|
},
|
|
330
339
|
additionalProperties: false,
|
|
331
340
|
},
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One finding's walk through the graduator, as route → dedup → file steps.
|
|
3
|
+
* Every step receives the gh context as the single `gh` value.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { routeOwnership } from '../github/framework-repo.js';
|
|
7
|
+
import {
|
|
8
|
+
createFollowUpIssue,
|
|
9
|
+
ensureIssueLabels,
|
|
10
|
+
findExistingFollowUp,
|
|
11
|
+
probePathStatus,
|
|
12
|
+
searchFollowUpByMarker,
|
|
13
|
+
updateFollowUpIssue,
|
|
14
|
+
} from './graduator-gh.js';
|
|
15
|
+
|
|
16
|
+
/** @returns {Promise<string|null>} A skip reason, or `null` to continue. */
|
|
17
|
+
export async function probeFindingPath({ finding, gitRef, gh }) {
|
|
18
|
+
// A path-less finding would misclassify as `file-removed`; skip the probe.
|
|
19
|
+
if (typeof finding.path !== 'string' || finding.path.trim().length === 0) {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
const { exists, probeError } = await probePathStatus({
|
|
23
|
+
ref: gitRef,
|
|
24
|
+
path: finding.path,
|
|
25
|
+
...gh,
|
|
26
|
+
});
|
|
27
|
+
if (probeError) return 'probe-error';
|
|
28
|
+
return exists ? null : 'file-removed';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Unroutable findings are deferred and named, never re-pointed at the
|
|
33
|
+
* consumer's repo.
|
|
34
|
+
*
|
|
35
|
+
* @returns {{ source: string, routedRepo: object|null, deferred: object|null, skipReason: string|null }}
|
|
36
|
+
*/
|
|
37
|
+
export function routeFinding({
|
|
38
|
+
finding,
|
|
39
|
+
classifier,
|
|
40
|
+
repos,
|
|
41
|
+
currentRepo,
|
|
42
|
+
logger,
|
|
43
|
+
spec,
|
|
44
|
+
}) {
|
|
45
|
+
const source = classifier(finding.path, null);
|
|
46
|
+
const routing = routeOwnership({ bucket: source, repos, currentRepo });
|
|
47
|
+
if (!routing.routable) {
|
|
48
|
+
const logLine = `[${spec.fnName}] unroutable ${source} finding (${routing.missingKey} is unset) — not filed: ${finding.title ?? finding.path ?? `finding ${finding.index}`}`;
|
|
49
|
+
logger?.warn?.(logLine);
|
|
50
|
+
const deferred = {
|
|
51
|
+
finding,
|
|
52
|
+
routedRepo: null,
|
|
53
|
+
source,
|
|
54
|
+
logLine,
|
|
55
|
+
missingKey: routing.missingKey,
|
|
56
|
+
};
|
|
57
|
+
return { source, routedRepo: null, deferred, skipReason: 'unroutable' };
|
|
58
|
+
}
|
|
59
|
+
const routedRepo = routing.routedRepo;
|
|
60
|
+
if (routing.crossRepo) {
|
|
61
|
+
const logLine = spec.buildCrossRepoLog({ finding, routedRepo, source });
|
|
62
|
+
logger?.info?.(logLine);
|
|
63
|
+
const deferred = { finding, routedRepo, source, logLine };
|
|
64
|
+
return { source, routedRepo, deferred, skipReason: 'cross-repo-deferred' };
|
|
65
|
+
}
|
|
66
|
+
return { source, routedRepo, deferred: null, skipReason: null };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Checks the content-hash marker, then the legacy ordinal marker so
|
|
71
|
+
* pre-fingerprint filings are not re-filed.
|
|
72
|
+
*
|
|
73
|
+
* @returns {Promise<{ number: number|null, state: string, url: string }|null>}
|
|
74
|
+
*/
|
|
75
|
+
export async function dedupFinding({
|
|
76
|
+
finding,
|
|
77
|
+
epicId,
|
|
78
|
+
routedRepo,
|
|
79
|
+
contentMarker,
|
|
80
|
+
gh,
|
|
81
|
+
spec,
|
|
82
|
+
}) {
|
|
83
|
+
const probe = (marker) =>
|
|
84
|
+
searchFollowUpByMarker({
|
|
85
|
+
marker,
|
|
86
|
+
owner: routedRepo.owner,
|
|
87
|
+
repo: routedRepo.repo,
|
|
88
|
+
...gh,
|
|
89
|
+
});
|
|
90
|
+
const hit = await probe(contentMarker);
|
|
91
|
+
if (hit) return hit;
|
|
92
|
+
const legacyMarker =
|
|
93
|
+
typeof spec.buildLegacyMarker === 'function'
|
|
94
|
+
? spec.buildLegacyMarker(epicId, finding.index)
|
|
95
|
+
: null;
|
|
96
|
+
return legacyMarker ? probe(legacyMarker) : null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* An open, numbered match is refreshed and recorded on `filed` as
|
|
101
|
+
* `action: 'updated'` (a live write, counted like a creation). Anything else
|
|
102
|
+
* is `already-filed` and untouched: a closed follow-up is a human's decision.
|
|
103
|
+
*/
|
|
104
|
+
export async function recordRecurrence(walk, existing, body) {
|
|
105
|
+
const { finding, source, routedRepo, envelope, decorate, skip, gh } = walk;
|
|
106
|
+
walk.filedMarkers?.add(walk.contentMarker);
|
|
107
|
+
if (existing.state !== 'open' || existing.number === null) {
|
|
108
|
+
skip('already-filed');
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
const updated = await updateFollowUpIssue({
|
|
112
|
+
owner: routedRepo.owner,
|
|
113
|
+
repo: routedRepo.repo,
|
|
114
|
+
number: existing.number,
|
|
115
|
+
body,
|
|
116
|
+
...gh,
|
|
117
|
+
});
|
|
118
|
+
if (updated.error) {
|
|
119
|
+
envelope.errors.push(
|
|
120
|
+
`finding ${finding.index} (${finding.path}): ${updated.error}`,
|
|
121
|
+
);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
envelope.filed.push(
|
|
125
|
+
decorate(
|
|
126
|
+
{
|
|
127
|
+
index: finding.index,
|
|
128
|
+
action: 'updated',
|
|
129
|
+
issueNumber: existing.number,
|
|
130
|
+
severity: finding.severity,
|
|
131
|
+
path: finding.path,
|
|
132
|
+
source,
|
|
133
|
+
repo: `${routedRepo.owner}/${routedRepo.repo}`,
|
|
134
|
+
url: updated.url || existing.url || null,
|
|
135
|
+
},
|
|
136
|
+
finding,
|
|
137
|
+
),
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function strongReadExisting(walk, labels) {
|
|
142
|
+
const { epicId, finding, contentMarker, routedRepo, spec } = walk;
|
|
143
|
+
return findExistingFollowUp({
|
|
144
|
+
markers:
|
|
145
|
+
typeof spec.buildMatchTokens === 'function'
|
|
146
|
+
? spec.buildMatchTokens({ epicId, finding, contentMarker })
|
|
147
|
+
: [contentMarker],
|
|
148
|
+
owner: routedRepo.owner,
|
|
149
|
+
repo: routedRepo.repo,
|
|
150
|
+
labels,
|
|
151
|
+
...walk.gh,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Cap → label mint → strong-read confirm → create. */
|
|
156
|
+
export async function fileFinding(walk, { title, body, labels }) {
|
|
157
|
+
const { finding, routedRepo, envelope, decorate, skip, gh } = walk;
|
|
158
|
+
if (envelope.filed.length >= walk.maxFilingsPerRun) {
|
|
159
|
+
return skip('cap-reached');
|
|
160
|
+
}
|
|
161
|
+
// Before the strong read too: `gh issue list --label <absent>` exits 0
|
|
162
|
+
// with `[]`, silently defeating the dedup confirm.
|
|
163
|
+
const ensured = await ensureIssueLabels({
|
|
164
|
+
owner: routedRepo.owner,
|
|
165
|
+
repo: routedRepo.repo,
|
|
166
|
+
labels,
|
|
167
|
+
labelCache: walk.labelCache,
|
|
168
|
+
...gh,
|
|
169
|
+
});
|
|
170
|
+
envelope.errors.push(...ensured.errors);
|
|
171
|
+
if (ensured.missing.length > 0) return skip('label-ensure-failed');
|
|
172
|
+
|
|
173
|
+
const confirmed = await strongReadExisting(walk, labels);
|
|
174
|
+
if (confirmed) return recordRecurrence(walk, confirmed, body);
|
|
175
|
+
|
|
176
|
+
const created = await createFollowUpIssue({
|
|
177
|
+
owner: routedRepo.owner,
|
|
178
|
+
repo: routedRepo.repo,
|
|
179
|
+
title,
|
|
180
|
+
body,
|
|
181
|
+
labels,
|
|
182
|
+
...gh,
|
|
183
|
+
});
|
|
184
|
+
if (created.error) {
|
|
185
|
+
envelope.errors.push(
|
|
186
|
+
`finding ${finding.index} (${finding.path}): ${created.error}`,
|
|
187
|
+
);
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
walk.filedMarkers?.add(walk.contentMarker);
|
|
191
|
+
envelope.filed.push(
|
|
192
|
+
decorate(
|
|
193
|
+
{
|
|
194
|
+
index: finding.index,
|
|
195
|
+
action: 'created',
|
|
196
|
+
severity: finding.severity,
|
|
197
|
+
path: finding.path,
|
|
198
|
+
source: walk.source,
|
|
199
|
+
repo: `${routedRepo.owner}/${routedRepo.repo}`,
|
|
200
|
+
url: created.url,
|
|
201
|
+
},
|
|
202
|
+
finding,
|
|
203
|
+
),
|
|
204
|
+
);
|
|
205
|
+
}
|