mandrel 2.55.0 → 2.56.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/docs/agentrc-reference.json +4 -0
- package/.agents/docs/configuration.md +3 -0
- package/.agents/rules/ci-remediation.md +39 -21
- package/.agents/schemas/agentrc.schema.json +19 -0
- package/.agents/scripts/audit-to-stories.js +222 -75
- package/.agents/scripts/file-ci-gap.js +306 -0
- package/.agents/scripts/lib/audit-to-stories/audit-label-taxonomy.js +25 -1
- package/.agents/scripts/lib/audit-to-stories/dedupe-against-github.js +40 -52
- package/.agents/scripts/lib/audit-to-stories/finding-adapter.js +5 -1
- package/.agents/scripts/lib/audit-to-stories/issue-corpus.js +162 -0
- package/.agents/scripts/lib/audit-to-stories/issues-file.js +121 -0
- package/.agents/scripts/lib/audit-to-stories/ledger-commit.js +1 -1
- package/.agents/scripts/lib/audit-to-stories/ledger-record.js +126 -0
- package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +11 -0
- package/.agents/scripts/lib/config-settings-schema.js +33 -0
- package/.agents/scripts/lib/feedback-loop/graduator-core.js +53 -13
- package/.agents/scripts/lib/feedback-loop/prior-feedback-fetcher.js +71 -25
- package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +18 -25
- package/.agents/scripts/lib/{audit-to-stories/ledger.js → findings/audit-ledger.js} +131 -24
- package/.agents/scripts/lib/findings/route-finding.js +38 -0
- package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
- package/.agents/scripts/lib/github/framework-repo.js +148 -2
- package/.agents/scripts/lib/label-constants.js +6 -1
- package/.agents/scripts/lib/observability/source-classifier.js +1 -0
- package/.agents/scripts/lib/orchestration/ci-gap-intake.js +605 -0
- package/.agents/scripts/lib/orchestration/ci-rerun-guard.js +13 -8
- package/.agents/scripts/lib/orchestration/plan-persist/audit-provenance.js +197 -0
- package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +15 -2
- package/.agents/scripts/lib/orchestration/run-epilogue.js +4 -4
- package/.agents/scripts/lib/orchestration/story-follow-ups.js +32 -20
- package/.agents/scripts/pr-watch-with-update.js +3 -2
- package/.agents/workflows/audit-to-stories.md +63 -27
- package/.agents/workflows/helpers/deliver-story-reference.md +19 -4
- package/.agents/workflows/helpers/plan-reference.md +23 -0
- package/.agents/workflows/mandrel-plan.md +6 -6
- package/docs/CHANGELOG.md +10 -0
- package/package.json +1 -1
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/audit-to-stories/issues-file.js — the issue corpus dedup checks against,
|
|
3
|
+
* and the one normaliser every source of it goes through.
|
|
4
|
+
*
|
|
5
|
+
* Dedup needs exactly one thing from GitHub: the list of Issues carrying an
|
|
6
|
+
* `audit::*` label. Until Story #5301 the only way to get it was the provider,
|
|
7
|
+
* which spawns `gh`, so a host without a `gh` CLI — a Claude Code cloud
|
|
8
|
+
* sandbox, where `gh` is absent and direct API access is disabled but the
|
|
9
|
+
* GitHub MCP tools work fine — could not dedup at all. Every group classified
|
|
10
|
+
* `create` and a scheduled sweep re-filed findings it had already filed.
|
|
11
|
+
*
|
|
12
|
+
* `--issues-file <path>` breaks that coupling: the host fetches the list by
|
|
13
|
+
* whatever access path it has and hands over a JSON array. Nothing here knows
|
|
14
|
+
* how it was fetched, so MCP, REST, and a cached dump are all equally valid.
|
|
15
|
+
*
|
|
16
|
+
* A file that cannot be read as an issue array is a **hard error**, never a
|
|
17
|
+
* degrade. Falling back would mean "dedup did not run" on precisely the
|
|
18
|
+
* invocation whose whole purpose is that it does — and a create-only plan the
|
|
19
|
+
* operator reads as checked is how the duplicate-filing loop starts.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import fs from 'node:fs';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Flatten one raw issue onto the `{ number, state, title, body }` shape the
|
|
26
|
+
* dedupe module reads, collapsing every closed-ish state spelling (`CLOSED`,
|
|
27
|
+
* `state_reason: not_planned`, …) onto `'closed'`.
|
|
28
|
+
*
|
|
29
|
+
* Shared by both corpus sources — the provider's `searchIssues` hits and the
|
|
30
|
+
* `--issues-file` array — so a host can hand over a raw `list_issues` result
|
|
31
|
+
* verbatim without knowing which spelling this repo's dedup expects.
|
|
32
|
+
*
|
|
33
|
+
* @param {object} hit
|
|
34
|
+
* @returns {{ number: number, state: 'open'|'closed', title: string, body: string }}
|
|
35
|
+
*/
|
|
36
|
+
export function normaliseIssueHit(hit) {
|
|
37
|
+
return {
|
|
38
|
+
number: hit.number,
|
|
39
|
+
state: (hit.state ?? hit.state_reason ?? 'open')
|
|
40
|
+
.toString()
|
|
41
|
+
.toLowerCase()
|
|
42
|
+
.includes('closed')
|
|
43
|
+
? 'closed'
|
|
44
|
+
: 'open',
|
|
45
|
+
title: hit.title ?? '',
|
|
46
|
+
body: hit.body ?? '',
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Read and normalise a host-supplied issue corpus.
|
|
52
|
+
*
|
|
53
|
+
* `state` is read from either `state` or `state_reason`, so a closed-as-
|
|
54
|
+
* not-planned issue is recognised however the host's API spelled it. Only
|
|
55
|
+
* `number` and `body` are load-bearing: the number identifies the match and
|
|
56
|
+
* the body carries the provenance footers dedup confirms identity against.
|
|
57
|
+
*
|
|
58
|
+
* @param {string} filePath — path to a JSON array of issues.
|
|
59
|
+
* @param {{ readFileSyncImpl?: typeof fs.readFileSync }} [seams]
|
|
60
|
+
* @returns {Array<{ number: number, state: string, title: string, body: string }>}
|
|
61
|
+
* @throws {Error} when the file is missing, unreadable, not JSON, or not an array.
|
|
62
|
+
*/
|
|
63
|
+
export function loadIssuesFile(
|
|
64
|
+
filePath,
|
|
65
|
+
{ readFileSyncImpl = fs.readFileSync } = {},
|
|
66
|
+
) {
|
|
67
|
+
let raw;
|
|
68
|
+
try {
|
|
69
|
+
raw = readFileSyncImpl(filePath, 'utf8');
|
|
70
|
+
} catch (err) {
|
|
71
|
+
throw new Error(
|
|
72
|
+
`--issues-file: cannot read "${filePath}" (${err.message}). Dedup needs ` +
|
|
73
|
+
'the issue corpus to check against; running without it would classify ' +
|
|
74
|
+
'every group "create" and re-file findings already tracked.',
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
let parsed;
|
|
78
|
+
try {
|
|
79
|
+
parsed = JSON.parse(raw);
|
|
80
|
+
} catch (err) {
|
|
81
|
+
throw new Error(
|
|
82
|
+
`--issues-file: "${filePath}" is not valid JSON (${err.message}). ` +
|
|
83
|
+
'Expected a JSON array of issues, e.g. the result of listing every ' +
|
|
84
|
+
'issue labelled audit::* with state "all".',
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
if (!Array.isArray(parsed)) {
|
|
88
|
+
throw new Error(
|
|
89
|
+
`--issues-file: "${filePath}" holds ${describeShape(parsed)}, not a JSON ` +
|
|
90
|
+
'array of issues. Pass the issue list itself, not the envelope wrapping it.',
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
return parsed.filter(isIssueLike).map(normaliseIssueHit);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Whether one array entry can possibly identify an issue. An entry without a
|
|
98
|
+
* numeric `number` can never confirm a match — exactly as it could not when it
|
|
99
|
+
* came back from a search — so it is dropped rather than indexed under nothing.
|
|
100
|
+
*
|
|
101
|
+
* @param {unknown} entry
|
|
102
|
+
* @returns {boolean}
|
|
103
|
+
*/
|
|
104
|
+
function isIssueLike(entry) {
|
|
105
|
+
return (
|
|
106
|
+
Boolean(entry) &&
|
|
107
|
+
typeof entry === 'object' &&
|
|
108
|
+
typeof entry.number === 'number'
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Name what a non-array payload actually was, so the error points at the fix.
|
|
114
|
+
* @param {unknown} value
|
|
115
|
+
* @returns {string}
|
|
116
|
+
*/
|
|
117
|
+
function describeShape(value) {
|
|
118
|
+
if (value === null) return 'null';
|
|
119
|
+
if (Array.isArray(value)) return 'an array';
|
|
120
|
+
return `a JSON ${typeof value}`;
|
|
121
|
+
}
|
|
@@ -28,9 +28,9 @@
|
|
|
28
28
|
* complexity budget does not absorb a git driver.
|
|
29
29
|
*/
|
|
30
30
|
|
|
31
|
+
import { DEFAULT_LEDGER_PATH } from '../findings/audit-ledger.js';
|
|
31
32
|
import { gh as defaultGh } from '../gh-exec.js';
|
|
32
33
|
import { gitSync } from '../git-utils.js';
|
|
33
|
-
import { DEFAULT_LEDGER_PATH } from './ledger.js';
|
|
34
34
|
import { openLedgerPullRequest, probeGit } from './ledger-pr.js';
|
|
35
35
|
|
|
36
36
|
/** Fallback base branch when config carries no `project.baseBranch`. */
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/audit-to-stories/ledger-record.js — record what a run actually filed.
|
|
3
|
+
*
|
|
4
|
+
* The cross-run ledger can only suppress an already-filed finding if something
|
|
5
|
+
* tells it the finding was filed. Until Story #5305 nothing did: the reconcile
|
|
6
|
+
* ran *during* the scan, before any Issue existed, and its `issueStates` were
|
|
7
|
+
* derived from `matchedIssues` — the Issues dedup FOUND. A group classified
|
|
8
|
+
* `create` has none, so a freshly opened Issue contributed nothing and its
|
|
9
|
+
* entry persisted as `status: "new", issue: null` forever. On a host where
|
|
10
|
+
* GitHub-search dedup works that is invisible; on one where it cannot run,
|
|
11
|
+
* nothing suppresses anything and every sweep re-files everything.
|
|
12
|
+
*
|
|
13
|
+
* The missing input is the `groupKey → issueNumber` map, which exists only
|
|
14
|
+
* after the Issues are opened. `--wire-edges` already takes exactly that map
|
|
15
|
+
* and is already a required pass, which is why the record rides along with it
|
|
16
|
+
* rather than arriving as a second command an operator must remember.
|
|
17
|
+
*
|
|
18
|
+
* Nothing here reaches the network: the caller hands over the map it already
|
|
19
|
+
* holds, and the only I/O is the ledger read/write this module delegates to
|
|
20
|
+
* `ledger.js`. That is what lets the record run BEFORE the provider is loaded,
|
|
21
|
+
* so a host whose provider cannot be constructed still records what it filed.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import {
|
|
25
|
+
DEFAULT_LEDGER_PATH,
|
|
26
|
+
readLedger,
|
|
27
|
+
reconcileLedger,
|
|
28
|
+
writeLedger,
|
|
29
|
+
} from '../findings/audit-ledger.js';
|
|
30
|
+
import {
|
|
31
|
+
fingerprintAuditFinding,
|
|
32
|
+
toCanonicalFinding,
|
|
33
|
+
} from './finding-adapter.js';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Project the opened-issue map onto the `{ fingerprint → issueState }` shape
|
|
37
|
+
* `reconcileLedger` reads, and collect the findings those groups carry.
|
|
38
|
+
*
|
|
39
|
+
* Only groups present in the map contribute. A group the map does not mention
|
|
40
|
+
* was not opened — deduped, ledger-suppressed, or simply skipped — and
|
|
41
|
+
* inventing an Issue state for it is how a finding gets suppressed against an
|
|
42
|
+
* Issue that does not exist.
|
|
43
|
+
*
|
|
44
|
+
* Every recorded state is `open`: the map names Issues this run just created,
|
|
45
|
+
* and a just-created Issue is open. A later close is learned from the live
|
|
46
|
+
* lookup on the next run, where it outranks this record (`decideStatus` reads
|
|
47
|
+
* the closed-Issue branch first).
|
|
48
|
+
*
|
|
49
|
+
* @param {object} params
|
|
50
|
+
* @param {Array<object>} params.groups — the `create`-eligible groups.
|
|
51
|
+
* @param {Record<string, number>} params.issueByGroupKey — group key → issue number.
|
|
52
|
+
* Module-internal: `recordFiledIssues` is the only production entrypoint, and
|
|
53
|
+
* exporting this for tests alone trips the CI-only `dead-exports:production`
|
|
54
|
+
* gate. It is covered through `recordFiledIssues`, which is the seam that
|
|
55
|
+
* actually ships.
|
|
56
|
+
*
|
|
57
|
+
* @returns {{ findings: Array<object>, issueStates: Record<string, { state: string, number: number }>, groupsRecorded: number }}
|
|
58
|
+
*/
|
|
59
|
+
function issueStatesFromIssueMap({ groups, issueByGroupKey }) {
|
|
60
|
+
const findings = [];
|
|
61
|
+
const issueStates = {};
|
|
62
|
+
let groupsRecorded = 0;
|
|
63
|
+
|
|
64
|
+
for (const group of groups ?? []) {
|
|
65
|
+
const number = issueByGroupKey?.[group?.groupKey];
|
|
66
|
+
if (typeof number !== 'number') continue;
|
|
67
|
+
groupsRecorded += 1;
|
|
68
|
+
for (const finding of group.findings ?? []) {
|
|
69
|
+
findings.push(finding);
|
|
70
|
+
issueStates[fingerprintAuditFinding(finding).full] = {
|
|
71
|
+
state: 'open',
|
|
72
|
+
number,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return { findings, issueStates, groupsRecorded };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Fold the just-opened Issues onto the committed ledger.
|
|
82
|
+
*
|
|
83
|
+
* Only the mapped groups' findings are passed to `reconcileLedger`, so every
|
|
84
|
+
* other entry survives untouched — `reconcileLedger` copies the prior index
|
|
85
|
+
* forward and rewrites only what this scan hands it.
|
|
86
|
+
*
|
|
87
|
+
* @param {object} params
|
|
88
|
+
* @param {string} [params.ledgerPath]
|
|
89
|
+
* @param {Array<object>} params.groups — the `create`-eligible groups.
|
|
90
|
+
* @param {Record<string, number>} params.issueByGroupKey
|
|
91
|
+
* @param {boolean} [params.write=true] — `false` computes the record without
|
|
92
|
+
* persisting it, which is what `--dry-run` needs.
|
|
93
|
+
* @param {{ readLedgerImpl?: Function, writeLedgerImpl?: Function }} [seams]
|
|
94
|
+
* @returns {{ path: string, written: boolean, groupsRecorded: number, findingsRecorded: number, filed: number }}
|
|
95
|
+
*/
|
|
96
|
+
export function recordFiledIssues(
|
|
97
|
+
{ ledgerPath, groups, issueByGroupKey, write = true },
|
|
98
|
+
{ readLedgerImpl = readLedger, writeLedgerImpl = writeLedger } = {},
|
|
99
|
+
) {
|
|
100
|
+
const path = ledgerPath ?? DEFAULT_LEDGER_PATH;
|
|
101
|
+
const { findings, issueStates, groupsRecorded } = issueStatesFromIssueMap({
|
|
102
|
+
groups,
|
|
103
|
+
issueByGroupKey,
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
const { ledger: next, classifications } = reconcileLedger({
|
|
107
|
+
ledger: readLedgerImpl(path),
|
|
108
|
+
findings,
|
|
109
|
+
issueStates,
|
|
110
|
+
toCanonical: toCanonicalFinding,
|
|
111
|
+
});
|
|
112
|
+
// Nothing to record means nothing to write. The ledger is committed consumer
|
|
113
|
+
// state and `--ledger-commit` opens a PR only when it changed, so rewriting
|
|
114
|
+
// it with a fresh `generatedAt` and no new memory would manufacture a diff
|
|
115
|
+
// that says nothing.
|
|
116
|
+
const wrote = Boolean(write) && findings.length > 0;
|
|
117
|
+
if (wrote) writeLedgerImpl(path, next);
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
path,
|
|
121
|
+
written: wrote,
|
|
122
|
+
groupsRecorded,
|
|
123
|
+
findingsRecorded: findings.length,
|
|
124
|
+
filed: classifications.filter((c) => c.status === 'filed').length,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
20
|
import { SEVERITIES } from '../findings/severity.js';
|
|
21
|
+
import { auditLabelFooterForFindings } from './audit-label-taxonomy.js';
|
|
21
22
|
import { formatEpicGrouping } from './epic-grouping-directive.js';
|
|
22
23
|
import {
|
|
23
24
|
renderFingerprintFooter,
|
|
@@ -107,6 +108,16 @@ function formatMVPScope(groups) {
|
|
|
107
108
|
const footers = [
|
|
108
109
|
renderFingerprintFooter(findings),
|
|
109
110
|
renderSemanticKeyFooter(findings),
|
|
111
|
+
// ...and the `audit::*` labels the dedup corpus is listed by.
|
|
112
|
+
//
|
|
113
|
+
// Without them a Story the chained planning path files is absent from
|
|
114
|
+
// the pool an indexed sweep matches against, and with an index in play
|
|
115
|
+
// the exact lookup is answered from that pool without ever reaching
|
|
116
|
+
// the provider — so the fingerprint footer above cannot rescue it. The
|
|
117
|
+
// two footers are therefore a pair: one carries the identity, the
|
|
118
|
+
// other carries the reason the next sweep ever looks at this issue
|
|
119
|
+
// (Story #5307).
|
|
120
|
+
auditLabelFooterForFindings(findings),
|
|
110
121
|
]
|
|
111
122
|
.map((f) => ` ${f}`)
|
|
112
123
|
.join('\n');
|
|
@@ -16,6 +16,7 @@ import { SHELL_INJECTION_PATTERN_STRING } from './config-schema-shared.js';
|
|
|
16
16
|
// resolved AGENTRC_SCHEMA is unchanged.
|
|
17
17
|
import { DELIVERY_SCHEMA } from './config-settings-schema-delivery.js';
|
|
18
18
|
import compiledAgentrcValidator from './generated/agentrc-validator.js';
|
|
19
|
+
import { DEFAULT_FRAMEWORK_REPO } from './github/framework-repo.js';
|
|
19
20
|
import { SKILL_ID_RE } from './skills/walk-skill-files.js';
|
|
20
21
|
|
|
21
22
|
/**
|
|
@@ -386,6 +387,37 @@ const MERGE_METHODS_SCHEMA = {
|
|
|
386
387
|
additionalProperties: false,
|
|
387
388
|
};
|
|
388
389
|
|
|
390
|
+
/**
|
|
391
|
+
* Where follow-up work is filed when the repository that surfaced it does not
|
|
392
|
+
* own it. Ownership splits three ways — consumer / framework / platform — and
|
|
393
|
+
* `github.owner`/`github.repo` already carry the consumer bucket, so only the
|
|
394
|
+
* other two are configured here. Routing itself lives in
|
|
395
|
+
* `lib/github/framework-repo.js`; an unset bucket is reported as unroutable
|
|
396
|
+
* rather than silently re-pointed at the consumer's own tracker.
|
|
397
|
+
*/
|
|
398
|
+
const FOLLOW_UP_REPOS_SCHEMA = {
|
|
399
|
+
type: 'object',
|
|
400
|
+
description:
|
|
401
|
+
'Repository slugs for the non-consumer follow-up ownership buckets, used when a CI gap, retro proposal, or audit finding belongs to someone other than the repo that surfaced it.',
|
|
402
|
+
properties: {
|
|
403
|
+
framework: {
|
|
404
|
+
type: 'string',
|
|
405
|
+
pattern: '^[^/\\s]+/[^/\\s]+$',
|
|
406
|
+
description:
|
|
407
|
+
'`<owner>/<repo>` that owns framework-level defects. Defaults to the Mandrel mirror — the one bucket with a knowable default.',
|
|
408
|
+
default: DEFAULT_FRAMEWORK_REPO,
|
|
409
|
+
},
|
|
410
|
+
platform: {
|
|
411
|
+
type: ['string', 'null'],
|
|
412
|
+
pattern: '^[^/\\s]+/[^/\\s]+$',
|
|
413
|
+
description:
|
|
414
|
+
'`<owner>/<repo>` for a shared platform or infrastructure tracker (a shared base config, a runner fleet, a cross-repo toolchain). No default — nothing can guess a shared repo. Left unset, platform-owned findings file locally and say so.',
|
|
415
|
+
default: null,
|
|
416
|
+
},
|
|
417
|
+
},
|
|
418
|
+
additionalProperties: false,
|
|
419
|
+
};
|
|
420
|
+
|
|
389
421
|
const GITHUB_SCHEMA = {
|
|
390
422
|
type: 'object',
|
|
391
423
|
description:
|
|
@@ -432,6 +464,7 @@ const GITHUB_SCHEMA = {
|
|
|
432
464
|
'Default `timeoutMs` applied to every `gh` subprocess the provider facade spawns, so a stalled socket or long-poll cannot hang an orchestration indefinitely. A `GhExecTimeoutError` from a hit ceiling is classified `transient` and retried by `withTransientRetry`. Story #2860.',
|
|
433
465
|
default: 60000,
|
|
434
466
|
},
|
|
467
|
+
followUpRepos: FOLLOW_UP_REPOS_SCHEMA,
|
|
435
468
|
branchProtection: BRANCH_PROTECTION_SCHEMA,
|
|
436
469
|
mergeMethods: MERGE_METHODS_SCHEMA,
|
|
437
470
|
notifications: NOTIFICATIONS_SCHEMA,
|
|
@@ -45,12 +45,18 @@
|
|
|
45
45
|
* - **Durable cross-repo deferral.** Cross-repo-deferred findings are
|
|
46
46
|
* upserted into a structured comment on the Epic instead of only a
|
|
47
47
|
* log line.
|
|
48
|
+
* - **Routed, never re-pointed.** Ownership routing is delegated to
|
|
49
|
+
* `github/framework-repo.js#routeOwnership`; an unroutable bucket is
|
|
50
|
+
* skipped `unroutable` and named in that same durable comment. The
|
|
51
|
+
* predecessor resolved an absent framework slug to the consumer's own
|
|
52
|
+
* repo, which silently mis-filed framework-owned work.
|
|
48
53
|
*/
|
|
49
54
|
|
|
50
55
|
import { spawn as defaultSpawn } from 'node:child_process';
|
|
51
56
|
import { createHash } from 'node:crypto';
|
|
52
57
|
|
|
53
58
|
import { inNodeTestContext } from '../config/temp-paths.js';
|
|
59
|
+
import { routeOwnership } from '../github/framework-repo.js';
|
|
54
60
|
import { LABEL_COLORS } from '../label-constants.js';
|
|
55
61
|
import { classifyPathSource as defaultClassifier } from '../observability/source-classifier.js';
|
|
56
62
|
import { upsertStructuredComment } from '../orchestration/ticketing.js';
|
|
@@ -531,7 +537,7 @@ async function findExistingFollowUp({
|
|
|
531
537
|
*
|
|
532
538
|
* @returns {Promise<{ url: string|null, error: string|null }>}
|
|
533
539
|
*/
|
|
534
|
-
async function updateFollowUpIssue({
|
|
540
|
+
export async function updateFollowUpIssue({
|
|
535
541
|
owner,
|
|
536
542
|
repo,
|
|
537
543
|
number,
|
|
@@ -675,7 +681,7 @@ async function readLiveLabelNames({
|
|
|
675
681
|
* @param {number} [opts.timeoutMs]
|
|
676
682
|
* @returns {Promise<{ created: string[], missing: string[], errors: string[] }>}
|
|
677
683
|
*/
|
|
678
|
-
async function ensureIssueLabels({
|
|
684
|
+
export async function ensureIssueLabels({
|
|
679
685
|
owner,
|
|
680
686
|
repo,
|
|
681
687
|
labels,
|
|
@@ -947,7 +953,7 @@ async function processGraduateFinding({
|
|
|
947
953
|
decorate,
|
|
948
954
|
epicId,
|
|
949
955
|
currentRepo,
|
|
950
|
-
|
|
956
|
+
repos,
|
|
951
957
|
classifier,
|
|
952
958
|
gitRef,
|
|
953
959
|
ghPath,
|
|
@@ -994,12 +1000,26 @@ async function processGraduateFinding({
|
|
|
994
1000
|
}
|
|
995
1001
|
|
|
996
1002
|
const source = classifier(finding.path, null);
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
+
// Ownership routing is the shared SSOT's call, and an unroutable bucket is
|
|
1004
|
+
// an outcome rather than a fallback: the predecessor resolved an absent
|
|
1005
|
+
// framework slug to the CONSUMER's repo, silently filing framework-owned
|
|
1006
|
+
// work in the wrong place (see `github/framework-repo.js`). Unroutable
|
|
1007
|
+
// findings are deferred and named, never re-pointed.
|
|
1008
|
+
const routing = routeOwnership({ bucket: source, repos, currentRepo });
|
|
1009
|
+
if (!routing.routable) {
|
|
1010
|
+
const logLine = `[${spec.fnName}] unroutable ${source} finding (${routing.missingKey} is unset) — not filed: ${finding.title ?? finding.path ?? `finding ${finding.index}`}`;
|
|
1011
|
+
logger?.warn?.(logLine);
|
|
1012
|
+
crossRepoDeferred.push({
|
|
1013
|
+
finding,
|
|
1014
|
+
routedRepo: null,
|
|
1015
|
+
source,
|
|
1016
|
+
logLine,
|
|
1017
|
+
missingKey: routing.missingKey,
|
|
1018
|
+
});
|
|
1019
|
+
return skip('unroutable');
|
|
1020
|
+
}
|
|
1021
|
+
const routedRepo = routing.routedRepo;
|
|
1022
|
+
if (routing.crossRepo) {
|
|
1003
1023
|
const logLine = spec.buildCrossRepoLog({ finding, routedRepo, source });
|
|
1004
1024
|
logger?.info?.(logLine);
|
|
1005
1025
|
crossRepoDeferred.push({ finding, routedRepo, source, logLine });
|
|
@@ -1165,13 +1185,18 @@ function renderCrossRepoDeferredBody(deferred, spec) {
|
|
|
1165
1185
|
const header =
|
|
1166
1186
|
spec.crossRepoCommentHeader ??
|
|
1167
1187
|
'### Cross-repo-deferred findings\n\nThese findings route to a different repository and were **not** filed here. They are recorded for a cross-repo follow-up pass.';
|
|
1168
|
-
const rows = deferred.map(({ finding, routedRepo, logLine }) => {
|
|
1188
|
+
const rows = deferred.map(({ finding, routedRepo, logLine, missingKey }) => {
|
|
1169
1189
|
const path =
|
|
1170
1190
|
typeof finding.path === 'string' && finding.path.length > 0
|
|
1171
1191
|
? `\`${finding.path}\``
|
|
1172
1192
|
: '_(no path)_';
|
|
1193
|
+
// An unroutable finding has no destination to name — say which config
|
|
1194
|
+
// key would give it one instead of inventing a repo for the row.
|
|
1195
|
+
const destination = routedRepo
|
|
1196
|
+
? `${routedRepo.owner}/${routedRepo.repo}`
|
|
1197
|
+
: `**unroutable** (\`${missingKey}\` is unset)`;
|
|
1173
1198
|
return [
|
|
1174
|
-
`- ${path} (severity: ${finding.severity ?? 'n/a'}) → ${
|
|
1199
|
+
`- ${path} (severity: ${finding.severity ?? 'n/a'}) → ${destination}`,
|
|
1175
1200
|
` - ${logLine}`,
|
|
1176
1201
|
].join('\n');
|
|
1177
1202
|
});
|
|
@@ -1244,7 +1269,12 @@ async function persistCrossRepoDeferred({
|
|
|
1244
1269
|
* the durable cross-repo-deferred persistence
|
|
1245
1270
|
* @param {object} [opts.config]
|
|
1246
1271
|
* @param {{owner: string, repo: string}} opts.currentRepo
|
|
1247
|
-
* @param {{owner: string, repo: string}} [opts.frameworkRepo]
|
|
1272
|
+
* @param {{owner: string, repo: string}} [opts.frameworkRepo] — the
|
|
1273
|
+
* `framework` ownership bucket. Absent means **unroutable**, never the
|
|
1274
|
+
* consumer's repo: a framework-classified finding is then deferred and
|
|
1275
|
+
* named rather than filed in the wrong tracker.
|
|
1276
|
+
* @param {{owner: string, repo: string}} [opts.platformRepo] — the shared
|
|
1277
|
+
* platform/infra bucket, for a caller whose classifier can reach it.
|
|
1248
1278
|
* @param {string} [opts.gitRef='HEAD']
|
|
1249
1279
|
* @param {Function} [opts.classifier=classifyPathSource]
|
|
1250
1280
|
* @param {string} [opts.ghPath='gh']
|
|
@@ -1279,6 +1309,7 @@ export async function graduate({
|
|
|
1279
1309
|
config,
|
|
1280
1310
|
currentRepo,
|
|
1281
1311
|
frameworkRepo,
|
|
1312
|
+
platformRepo,
|
|
1282
1313
|
gitRef = 'HEAD',
|
|
1283
1314
|
classifier = defaultClassifier,
|
|
1284
1315
|
ghPath = 'gh',
|
|
@@ -1347,6 +1378,15 @@ export async function graduate({
|
|
|
1347
1378
|
return envelope;
|
|
1348
1379
|
}
|
|
1349
1380
|
|
|
1381
|
+
// The ownership map the routing SSOT resolves against. `platform` is
|
|
1382
|
+
// absent for both graduators today (their classifier is binary) and is
|
|
1383
|
+
// threaded so a caller that does know a shared-infra repo routes there
|
|
1384
|
+
// rather than into the nearest plausible tracker.
|
|
1385
|
+
const repos = {
|
|
1386
|
+
consumer: currentRepo,
|
|
1387
|
+
framework: frameworkRepo ?? null,
|
|
1388
|
+
platform: platformRepo ?? null,
|
|
1389
|
+
};
|
|
1350
1390
|
const crossRepoDeferred = [];
|
|
1351
1391
|
for (const finding of findings) {
|
|
1352
1392
|
await processGraduateFinding({
|
|
@@ -1355,7 +1395,7 @@ export async function graduate({
|
|
|
1355
1395
|
decorate,
|
|
1356
1396
|
epicId,
|
|
1357
1397
|
currentRepo,
|
|
1358
|
-
|
|
1398
|
+
repos,
|
|
1359
1399
|
classifier,
|
|
1360
1400
|
gitRef,
|
|
1361
1401
|
ghPath,
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
27
|
import { META_LABELS } from '../label-constants.js';
|
|
28
|
+
import { CI_GAP_INTAKE_MARKER } from '../orchestration/ci-gap-intake.js';
|
|
28
29
|
import { runChild } from './graduator-core.js';
|
|
29
30
|
|
|
30
31
|
const DEFAULT_LIMIT = 50;
|
|
@@ -135,8 +136,15 @@ function formatGhError(label, { code, stderr, spawnError }) {
|
|
|
135
136
|
* already-budgeted envelope, and trimming early avoids any ambient assumption
|
|
136
137
|
* that downstream consumers can rely on extra fields.
|
|
137
138
|
*
|
|
139
|
+
* `intake` is the one field derived rather than copied: an issue whose body
|
|
140
|
+
* carries the CI-gap intake marker is a filing awaiting graduation, not a
|
|
141
|
+
* finished report, and `/mandrel-plan` offers those a `/mandrel-plan <id>`
|
|
142
|
+
* rewrite. The body itself is NOT carried onto the envelope — the marker
|
|
143
|
+
* check is the whole reason it was fetched, and a planner payload does not
|
|
144
|
+
* need every intake issue's full text.
|
|
145
|
+
*
|
|
138
146
|
* @param {object} raw
|
|
139
|
-
* @returns {{ number: number, title: string, url: string, labels: string[] }|null}
|
|
147
|
+
* @returns {{ number: number, title: string, url: string, labels: string[], intake: boolean }|null}
|
|
140
148
|
*/
|
|
141
149
|
function normalizeIssue(raw) {
|
|
142
150
|
if (!raw || typeof raw !== 'object') return null;
|
|
@@ -144,12 +152,23 @@ function normalizeIssue(raw) {
|
|
|
144
152
|
if (number === null) return null;
|
|
145
153
|
const title = typeof raw.title === 'string' ? raw.title : '';
|
|
146
154
|
const url = typeof raw.url === 'string' ? raw.url : '';
|
|
147
|
-
const
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
155
|
+
const intake =
|
|
156
|
+
typeof raw.body === 'string' && raw.body.includes(CI_GAP_INTAKE_MARKER);
|
|
157
|
+
return { number, title, url, labels: normalizeLabels(raw.labels), intake };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Flatten `gh issue list --json labels` into plain names. `gh` returns label
|
|
162
|
+
* objects; a hand-built fixture may return strings. Pure.
|
|
163
|
+
*
|
|
164
|
+
* @param {unknown} raw
|
|
165
|
+
* @returns {string[]}
|
|
166
|
+
*/
|
|
167
|
+
function normalizeLabels(raw) {
|
|
168
|
+
if (!Array.isArray(raw)) return [];
|
|
169
|
+
return raw
|
|
170
|
+
.map((l) => (l && typeof l === 'object' ? l.name : l))
|
|
171
|
+
.filter((name) => typeof name === 'string');
|
|
153
172
|
}
|
|
154
173
|
|
|
155
174
|
/**
|
|
@@ -176,7 +195,7 @@ async function fetchByLabel({ owner, repo, label, ghPath, limit, spawnImpl }) {
|
|
|
176
195
|
'--label',
|
|
177
196
|
label,
|
|
178
197
|
'--json',
|
|
179
|
-
'number,title,labels,url',
|
|
198
|
+
'number,title,labels,url,body',
|
|
180
199
|
'--limit',
|
|
181
200
|
String(limit),
|
|
182
201
|
];
|
|
@@ -209,10 +228,32 @@ async function fetchByLabel({ owner, repo, label, ghPath, limit, spawnImpl }) {
|
|
|
209
228
|
}
|
|
210
229
|
|
|
211
230
|
/**
|
|
212
|
-
*
|
|
213
|
-
*
|
|
214
|
-
*
|
|
215
|
-
*
|
|
231
|
+
* Append every not-yet-seen issue to one bucket, marking it seen.
|
|
232
|
+
*
|
|
233
|
+
* An issue carrying more than one meta label must reach the planner exactly
|
|
234
|
+
* once, so the `seen` set spans all three buckets and the first bucket to
|
|
235
|
+
* claim a number keeps it. Mutates both arguments — one walk, three buckets.
|
|
236
|
+
*
|
|
237
|
+
* @param {object[]} bucket
|
|
238
|
+
* @param {object[]} issues
|
|
239
|
+
* @param {Set<number>} seen
|
|
240
|
+
* @returns {void}
|
|
241
|
+
*/
|
|
242
|
+
function dedupeInto(bucket, issues, seen) {
|
|
243
|
+
for (const issue of issues) {
|
|
244
|
+
if (seen.has(issue.number)) continue;
|
|
245
|
+
seen.add(issue.number);
|
|
246
|
+
bucket.push(issue);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Fetch the union of open issues carrying `meta::framework-gap`,
|
|
252
|
+
* `meta::consumer-improvement` or `meta::platform-gap` and split them into
|
|
253
|
+
* three arrays — one per ownership bucket in `github/framework-repo.js`, so
|
|
254
|
+
* a filing's bucket survives all the way to the planner. An issue carrying
|
|
255
|
+
* more than one label appears once, in that precedence order; dedupe-by-number
|
|
256
|
+
* runs across all three arrays so the planner sees each issue exactly once.
|
|
216
257
|
*
|
|
217
258
|
* The returned envelope is best-effort: every failure mode (gh missing, repo
|
|
218
259
|
* not found, non-zero exit, malformed JSON) is captured as a string in
|
|
@@ -227,6 +268,7 @@ async function fetchByLabel({ owner, repo, label, ghPath, limit, spawnImpl }) {
|
|
|
227
268
|
* @returns {Promise<{
|
|
228
269
|
* frameworkGaps: object[],
|
|
229
270
|
* consumerImprovements: object[],
|
|
271
|
+
* platformGaps: object[],
|
|
230
272
|
* recurringDefectClasses: Array<{ class: string, count: number, issues: number[] }>,
|
|
231
273
|
* fetchedAt: string,
|
|
232
274
|
* errors: string[],
|
|
@@ -251,6 +293,7 @@ export async function fetchPriorFeedback({
|
|
|
251
293
|
const envelope = {
|
|
252
294
|
frameworkGaps: [],
|
|
253
295
|
consumerImprovements: [],
|
|
296
|
+
platformGaps: [],
|
|
254
297
|
recurringDefectClasses: [],
|
|
255
298
|
fetchedAt: new Date().toISOString(),
|
|
256
299
|
errors,
|
|
@@ -258,7 +301,7 @@ export async function fetchPriorFeedback({
|
|
|
258
301
|
|
|
259
302
|
if (errors.length > 0) return envelope;
|
|
260
303
|
|
|
261
|
-
const [gapsResult, improvementsResult] = await Promise.all([
|
|
304
|
+
const [gapsResult, improvementsResult, platformResult] = await Promise.all([
|
|
262
305
|
fetchByLabel({
|
|
263
306
|
owner,
|
|
264
307
|
repo,
|
|
@@ -275,25 +318,27 @@ export async function fetchPriorFeedback({
|
|
|
275
318
|
limit,
|
|
276
319
|
spawnImpl,
|
|
277
320
|
}),
|
|
321
|
+
fetchByLabel({
|
|
322
|
+
owner,
|
|
323
|
+
repo,
|
|
324
|
+
label: META_LABELS.PLATFORM_GAP,
|
|
325
|
+
ghPath,
|
|
326
|
+
limit,
|
|
327
|
+
spawnImpl,
|
|
328
|
+
}),
|
|
278
329
|
]);
|
|
279
330
|
|
|
280
|
-
|
|
281
|
-
|
|
331
|
+
for (const { error } of [gapsResult, improvementsResult, platformResult]) {
|
|
332
|
+
if (error) errors.push(error);
|
|
333
|
+
}
|
|
282
334
|
|
|
283
335
|
// Dedupe by issue number across both arrays. Issues that carry both labels
|
|
284
336
|
// land in frameworkGaps first (deterministic) and are filtered out of
|
|
285
337
|
// consumerImprovements.
|
|
286
338
|
const seen = new Set();
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
envelope.frameworkGaps.push(issue);
|
|
291
|
-
}
|
|
292
|
-
for (const issue of improvementsResult.issues) {
|
|
293
|
-
if (seen.has(issue.number)) continue;
|
|
294
|
-
seen.add(issue.number);
|
|
295
|
-
envelope.consumerImprovements.push(issue);
|
|
296
|
-
}
|
|
339
|
+
dedupeInto(envelope.frameworkGaps, gapsResult.issues, seen);
|
|
340
|
+
dedupeInto(envelope.consumerImprovements, improvementsResult.issues, seen);
|
|
341
|
+
dedupeInto(envelope.platformGaps, platformResult.issues, seen);
|
|
297
342
|
|
|
298
343
|
// Story #4135 (Epic #4131, F11) — close the retro→planner loop: derive the
|
|
299
344
|
// recurring defect classes from the `friction::<class>` labels carried by
|
|
@@ -303,6 +348,7 @@ export async function fetchPriorFeedback({
|
|
|
303
348
|
envelope.recurringDefectClasses = extractRecurringDefectClasses([
|
|
304
349
|
...envelope.frameworkGaps,
|
|
305
350
|
...envelope.consumerImprovements,
|
|
351
|
+
...envelope.platformGaps,
|
|
306
352
|
]);
|
|
307
353
|
|
|
308
354
|
return envelope;
|