@deftai/directive-core 0.94.0 → 0.95.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/dist/authz/classify.js +485 -0
- package/dist/doctor/main.js +41 -9
- package/dist/doctor/types.d.ts +2 -2
- package/dist/freshness/bind.d.ts +67 -0
- package/dist/freshness/bind.js +201 -0
- package/dist/freshness/cli.d.ts +30 -0
- package/dist/freshness/cli.js +180 -0
- package/dist/freshness/compare.d.ts +14 -0
- package/dist/freshness/compare.js +134 -0
- package/dist/freshness/generation.d.ts +44 -0
- package/dist/freshness/generation.js +172 -0
- package/dist/freshness/index.d.ts +13 -0
- package/dist/freshness/index.js +13 -0
- package/dist/freshness/report.d.ts +43 -0
- package/dist/freshness/report.js +139 -0
- package/dist/freshness/types.d.ts +70 -0
- package/dist/freshness/types.js +30 -0
- package/dist/handoff-evidence/index.d.ts +8 -0
- package/dist/handoff-evidence/index.js +7 -0
- package/dist/handoff-evidence/validate.d.ts +105 -0
- package/dist/handoff-evidence/validate.js +423 -0
- package/dist/hooks/dispatcher.d.ts +3 -0
- package/dist/hooks/dispatcher.js +12 -3
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/init-deposit/agent-hooks.d.ts +1 -1
- package/dist/init-deposit/agent-hooks.js +5 -2
- package/dist/init-deposit/init-deposit.d.ts +7 -1
- package/dist/init-deposit/init-deposit.js +24 -4
- package/dist/init-deposit/refresh.d.ts +9 -1
- package/dist/init-deposit/refresh.js +48 -5
- package/dist/policy/index.d.ts +3 -0
- package/dist/policy/index.js +35 -0
- package/dist/release-e2e/greenfield-python-free-smoke.js +7 -3
- package/dist/session/ritual-entrypoint.d.ts +6 -2
- package/dist/session/ritual-entrypoint.js +15 -2
- package/dist/session/session-ready.js +31 -14
- package/dist/session/session-start.d.ts +2 -1
- package/dist/session/session-start.js +66 -3
- package/dist/session/verify-session-ritual.d.ts +4 -2
- package/dist/session/verify-session-ritual.js +11 -11
- package/dist/slash/product-set.js +4 -1
- package/dist/swarm/verify-review-clean.d.ts +4 -3
- package/dist/swarm/verify-review-clean.js +28 -73
- package/dist/triage/classify/index.d.ts +9 -0
- package/dist/triage/classify/index.js +34 -2
- package/dist/triage/classify/label-mirror.d.ts +143 -0
- package/dist/triage/classify/label-mirror.js +684 -0
- package/dist/triage/help/registry-data.d.ts +7 -7
- package/dist/triage/help/registry-data.js +27 -7
- package/dist/triage/queue/show.d.ts +1 -1
- package/dist/triage/queue/show.js +4 -2
- package/dist/vbrief-validate/project-definition.js +1 -1
- package/dist/verify-env/agent-hook-readiness.d.ts +42 -0
- package/dist/verify-env/agent-hook-readiness.js +150 -0
- package/dist/verify-env/agent-hooks-live-probe.d.ts +16 -4
- package/dist/verify-env/agent-hooks-live-probe.js +120 -107
- package/dist/verify-env/agent-hooks.js +11 -3
- package/dist/verify-env/index.d.ts +1 -0
- package/dist/verify-env/index.js +1 -0
- package/package.json +7 -3
|
@@ -0,0 +1,684 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tier-1 deterministic SCM label mirror (#1423 Wave 1).
|
|
3
|
+
*
|
|
4
|
+
* Classifies cached issues with the existing #1129 engine, then mirrors the
|
|
5
|
+
* outcome as SCM labels (dry-run default, --apply to write). Never accepts into
|
|
6
|
+
* the xBRIEF lifecycle and never writes proposed/ scopes.
|
|
7
|
+
*
|
|
8
|
+
* Intentionally does NOT import from ./index.js (SLizard P1 cycle). The classify
|
|
9
|
+
* engine is injected via LabelMirrorEngine / mirrorLabels() wrapper in index.ts.
|
|
10
|
+
*/
|
|
11
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
12
|
+
import { join, resolve } from "node:path";
|
|
13
|
+
import { referenceTypeMatches } from "@deftai/directive-types";
|
|
14
|
+
import { hasArtifactSuffix, resolveLifecycleRoot, resolveProjectDefinitionPath, } from "../../layout/resolve.js";
|
|
15
|
+
import { readPlanPolicy } from "../../policy/plan-extensions.js";
|
|
16
|
+
import { ScmLabelClient } from "../../vbrief-reconcile/labels.js";
|
|
17
|
+
import { isRepoMutationAllowed } from "../../vbrief-reconcile/repo-guard.js";
|
|
18
|
+
import { latestDecisions, readAuditLog } from "../actions/candidates-log.js";
|
|
19
|
+
import { resolveCandidatesLogPath } from "../cache-path.js";
|
|
20
|
+
import { iterCachedIssues } from "../summary/index.js";
|
|
21
|
+
export const DEFAULT_IDEMPOTENCY_LABEL = "triaged";
|
|
22
|
+
export const CACHE_DIR_NAME = ".deft-cache";
|
|
23
|
+
export const CACHE_SOURCE = "github-issue";
|
|
24
|
+
const VALID_ACTIONS = new Set(["defer", "archive", "escalate", "accept"]);
|
|
25
|
+
/**
|
|
26
|
+
* Repo-qualified xBRIEF github-issue keys (`owner/name\\0number`).
|
|
27
|
+
* Unlike number-only extractReferencedIssues, this never collides across repos.
|
|
28
|
+
*/
|
|
29
|
+
export function extractReferencedRepoIssueKeys(projectRoot, lifecycleFolders = ["pending", "active"]) {
|
|
30
|
+
const referenced = new Set();
|
|
31
|
+
let root;
|
|
32
|
+
try {
|
|
33
|
+
root = resolveLifecycleRoot(projectRoot ?? process.cwd());
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return referenced;
|
|
37
|
+
}
|
|
38
|
+
for (const folder of lifecycleFolders) {
|
|
39
|
+
const folderPath = join(root, folder);
|
|
40
|
+
let entries;
|
|
41
|
+
try {
|
|
42
|
+
entries = readdirSync(folderPath).filter((f) => hasArtifactSuffix(f));
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
for (const name of entries) {
|
|
48
|
+
try {
|
|
49
|
+
const raw = readFileSync(join(folderPath, name), { encoding: "utf8" });
|
|
50
|
+
const data = JSON.parse(raw);
|
|
51
|
+
if (typeof data !== "object" || data === null || Array.isArray(data)) {
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
const plan = data.plan;
|
|
55
|
+
if (typeof plan !== "object" || plan === null || Array.isArray(plan)) {
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
const refs = plan.references ?? [];
|
|
59
|
+
if (!Array.isArray(refs)) {
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
for (const ref of refs) {
|
|
63
|
+
if (typeof ref !== "object" || ref === null || Array.isArray(ref)) {
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
const r = ref;
|
|
67
|
+
if (!referenceTypeMatches(String(r.type ?? ""), "github-issue")) {
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
const uri = r.uri;
|
|
71
|
+
if (typeof uri !== "string") {
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
const cleaned = uri.trim().replace(/\/+$/, "");
|
|
75
|
+
const parts = cleaned
|
|
76
|
+
.split("://")
|
|
77
|
+
.pop()
|
|
78
|
+
?.split("/")
|
|
79
|
+
.filter((p) => p.length > 0) ?? [];
|
|
80
|
+
if (parts.length >= 4 &&
|
|
81
|
+
parts[parts.length - 2] === "issues" &&
|
|
82
|
+
/^\d+$/.test(parts[parts.length - 1] ?? "")) {
|
|
83
|
+
const owner = parts[parts.length - 4] ?? "";
|
|
84
|
+
const repoName = parts[parts.length - 3] ?? "";
|
|
85
|
+
const n = Number.parseInt(parts[parts.length - 1] ?? "", 10);
|
|
86
|
+
if (owner.length > 0 && repoName.length > 0 && Number.isFinite(n)) {
|
|
87
|
+
referenced.add(`${owner}/${repoName}\0${n}`.toLowerCase());
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
// tolerate corrupt artifacts
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return referenced;
|
|
98
|
+
}
|
|
99
|
+
function asStringList(value) {
|
|
100
|
+
if (!Array.isArray(value)) {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
const out = [];
|
|
104
|
+
for (const item of value) {
|
|
105
|
+
if (typeof item !== "string" || item.trim().length === 0) {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
out.push(item);
|
|
109
|
+
}
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
function sanitizeReportFragment(value) {
|
|
113
|
+
return value.replace(/\r?\n/g, " ");
|
|
114
|
+
}
|
|
115
|
+
/** Validate plan.policy.triageLabelMirror payload. */
|
|
116
|
+
export function validateLabelMirrorPolicy(raw) {
|
|
117
|
+
const errors = [];
|
|
118
|
+
const warnings = [];
|
|
119
|
+
if (raw === undefined || raw === null) {
|
|
120
|
+
return { errors, warnings };
|
|
121
|
+
}
|
|
122
|
+
if (typeof raw !== "object" || Array.isArray(raw)) {
|
|
123
|
+
errors.push("plan.policy.triageLabelMirror must be an object");
|
|
124
|
+
return { errors, warnings };
|
|
125
|
+
}
|
|
126
|
+
const obj = raw;
|
|
127
|
+
if ("enabled" in obj && typeof obj.enabled !== "boolean") {
|
|
128
|
+
errors.push("plan.policy.triageLabelMirror.enabled must be a boolean when set");
|
|
129
|
+
}
|
|
130
|
+
if ("idempotencyLabel" in obj) {
|
|
131
|
+
if (typeof obj.idempotencyLabel !== "string" || obj.idempotencyLabel.trim().length === 0) {
|
|
132
|
+
errors.push("plan.policy.triageLabelMirror.idempotencyLabel must be a non-empty string");
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
if ("alwaysLabels" in obj) {
|
|
136
|
+
const list = asStringList(obj.alwaysLabels);
|
|
137
|
+
if (list === null) {
|
|
138
|
+
errors.push("plan.policy.triageLabelMirror.alwaysLabels must be a list of non-empty strings");
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
if ("actionLabels" in obj) {
|
|
142
|
+
const al = obj.actionLabels;
|
|
143
|
+
if (typeof al !== "object" || al === null || Array.isArray(al)) {
|
|
144
|
+
errors.push("plan.policy.triageLabelMirror.actionLabels must be an object");
|
|
145
|
+
}
|
|
146
|
+
else {
|
|
147
|
+
for (const [key, value] of Object.entries(al)) {
|
|
148
|
+
if (!VALID_ACTIONS.has(key)) {
|
|
149
|
+
errors.push(`plan.policy.triageLabelMirror.actionLabels.${key}: action must be one of ${[...VALID_ACTIONS].sort().join(", ")}`);
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
const list = asStringList(value);
|
|
153
|
+
if (list === null) {
|
|
154
|
+
errors.push(`plan.policy.triageLabelMirror.actionLabels.${key} must be a list of non-empty strings`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
const known = new Set(["enabled", "idempotencyLabel", "alwaysLabels", "actionLabels"]);
|
|
160
|
+
const extra = Object.keys(obj)
|
|
161
|
+
.filter((k) => !known.has(k))
|
|
162
|
+
.sort();
|
|
163
|
+
if (extra.length > 0) {
|
|
164
|
+
warnings.push(`plan.policy.triageLabelMirror: ignoring unrecognised key(s) ${extra.join(", ")}`);
|
|
165
|
+
}
|
|
166
|
+
return { errors, warnings };
|
|
167
|
+
}
|
|
168
|
+
/** Default resolved policy when triageLabelMirror is absent. */
|
|
169
|
+
export function defaultLabelMirrorPolicy() {
|
|
170
|
+
return {
|
|
171
|
+
enabled: true,
|
|
172
|
+
idempotencyLabel: DEFAULT_IDEMPOTENCY_LABEL,
|
|
173
|
+
alwaysLabels: [DEFAULT_IDEMPOTENCY_LABEL],
|
|
174
|
+
actionLabels: {},
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
function loadProjectDefinition(projectRoot) {
|
|
178
|
+
let path;
|
|
179
|
+
try {
|
|
180
|
+
path = resolveProjectDefinitionPath(projectRoot);
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
path = join(projectRoot, "xbrief", "PROJECT-DEFINITION.xbrief.json");
|
|
184
|
+
}
|
|
185
|
+
try {
|
|
186
|
+
const raw = readFileSync(path, { encoding: "utf8" });
|
|
187
|
+
const data = JSON.parse(raw);
|
|
188
|
+
return typeof data === "object" && data !== null && !Array.isArray(data)
|
|
189
|
+
? data
|
|
190
|
+
: null;
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
/** Resolve effective label-mirror policy from PROJECT-DEFINITION or inline object. */
|
|
197
|
+
export function resolveLabelMirrorPolicy(options) {
|
|
198
|
+
if (options?.override !== undefined && options.override !== null) {
|
|
199
|
+
return materializePolicy(options.override);
|
|
200
|
+
}
|
|
201
|
+
const data = options?.projectDefinition !== undefined
|
|
202
|
+
? options.projectDefinition
|
|
203
|
+
: loadProjectDefinition(options?.projectRoot ?? process.cwd());
|
|
204
|
+
if (data === null) {
|
|
205
|
+
return defaultLabelMirrorPolicy();
|
|
206
|
+
}
|
|
207
|
+
const plan = data.plan;
|
|
208
|
+
if (typeof plan !== "object" || plan === null || Array.isArray(plan)) {
|
|
209
|
+
return defaultLabelMirrorPolicy();
|
|
210
|
+
}
|
|
211
|
+
const policy = readPlanPolicy(plan);
|
|
212
|
+
if (typeof policy !== "object" || policy === null || Array.isArray(policy)) {
|
|
213
|
+
return defaultLabelMirrorPolicy();
|
|
214
|
+
}
|
|
215
|
+
const raw = policy.triageLabelMirror;
|
|
216
|
+
if (raw === undefined || raw === null) {
|
|
217
|
+
return defaultLabelMirrorPolicy();
|
|
218
|
+
}
|
|
219
|
+
if (typeof raw !== "object" || Array.isArray(raw)) {
|
|
220
|
+
return defaultLabelMirrorPolicy();
|
|
221
|
+
}
|
|
222
|
+
return materializePolicy(raw);
|
|
223
|
+
}
|
|
224
|
+
function materializePolicy(raw) {
|
|
225
|
+
const idempotencyLabel = typeof raw.idempotencyLabel === "string" && raw.idempotencyLabel.trim().length > 0
|
|
226
|
+
? raw.idempotencyLabel.trim()
|
|
227
|
+
: DEFAULT_IDEMPOTENCY_LABEL;
|
|
228
|
+
const alwaysFromPolicy = asStringList(raw.alwaysLabels ?? null);
|
|
229
|
+
const alwaysLabels = alwaysFromPolicy !== null ? alwaysFromPolicy : [idempotencyLabel].filter((x) => x.length > 0);
|
|
230
|
+
const actionLabels = {};
|
|
231
|
+
const al = raw.actionLabels;
|
|
232
|
+
if (typeof al === "object" && al !== null && !Array.isArray(al)) {
|
|
233
|
+
for (const action of VALID_ACTIONS) {
|
|
234
|
+
const list = asStringList(al[action]);
|
|
235
|
+
if (list !== null && list.length > 0) {
|
|
236
|
+
actionLabels[action] = list;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return {
|
|
241
|
+
enabled: raw.enabled !== false,
|
|
242
|
+
idempotencyLabel,
|
|
243
|
+
alwaysLabels,
|
|
244
|
+
actionLabels,
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
/** Labels to apply for a classified action (always + action-mapped). */
|
|
248
|
+
export function desiredLabelsForClassification(action, policy) {
|
|
249
|
+
const desired = new Set(policy.alwaysLabels);
|
|
250
|
+
const mapped = policy.actionLabels[action];
|
|
251
|
+
if (mapped !== undefined) {
|
|
252
|
+
for (const label of mapped) {
|
|
253
|
+
desired.add(label);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
// Always include the idempotency marker even if alwaysLabels was customized empty.
|
|
257
|
+
if (policy.idempotencyLabel.length > 0) {
|
|
258
|
+
desired.add(policy.idempotencyLabel);
|
|
259
|
+
}
|
|
260
|
+
return [...desired].sort();
|
|
261
|
+
}
|
|
262
|
+
function issueLabelNames(issue) {
|
|
263
|
+
const raw = issue.labels ?? [];
|
|
264
|
+
const names = [];
|
|
265
|
+
if (!Array.isArray(raw)) {
|
|
266
|
+
return names;
|
|
267
|
+
}
|
|
268
|
+
for (const item of raw) {
|
|
269
|
+
if (typeof item === "object" && item !== null && "name" in item) {
|
|
270
|
+
const name = item.name;
|
|
271
|
+
if (typeof name === "string" && name.length > 0) {
|
|
272
|
+
names.push(name);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
else if (typeof item === "string" && item.length > 0) {
|
|
276
|
+
names.push(item);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
return names;
|
|
280
|
+
}
|
|
281
|
+
function readCachedRawIssue(cacheRoot, repo, issueNumber) {
|
|
282
|
+
const [owner, name] = repo.split("/", 2);
|
|
283
|
+
const rawPath = join(cacheRoot, CACHE_SOURCE, owner ?? "", name ?? "", String(issueNumber), "raw.json");
|
|
284
|
+
if (!existsSync(rawPath)) {
|
|
285
|
+
return null;
|
|
286
|
+
}
|
|
287
|
+
try {
|
|
288
|
+
const data = JSON.parse(readFileSync(rawPath, { encoding: "utf8" }));
|
|
289
|
+
if (typeof data !== "object" || data === null || Array.isArray(data)) {
|
|
290
|
+
return null;
|
|
291
|
+
}
|
|
292
|
+
return data;
|
|
293
|
+
}
|
|
294
|
+
catch {
|
|
295
|
+
return null;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
/** Load latest decisions as Map keyed by `repo\0number` for hasTriageDecision. */
|
|
299
|
+
function loadLatestDecisionMap(projectRoot) {
|
|
300
|
+
try {
|
|
301
|
+
const path = resolveCandidatesLogPath(projectRoot);
|
|
302
|
+
return latestDecisions(readAuditLog(path));
|
|
303
|
+
}
|
|
304
|
+
catch {
|
|
305
|
+
return new Map();
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
function decisionMapHas(map, repo, issueNumber) {
|
|
309
|
+
return map.has(`${repo}\0${issueNumber}`);
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* Run Tier-1 label mirror over the github-issue cache.
|
|
313
|
+
* Dry-run by default (no SCM writes). Pass dryRun: false to apply.
|
|
314
|
+
* Requires options.engine (classify/index wrapper injects it).
|
|
315
|
+
*/
|
|
316
|
+
export function mirrorLabels(projectRoot, options) {
|
|
317
|
+
const root = resolve(projectRoot);
|
|
318
|
+
const dryRun = options.dryRun ?? true;
|
|
319
|
+
const policy = resolveLabelMirrorPolicy({ projectRoot: root });
|
|
320
|
+
const cacheRoot = options.cacheRoot ?? join(root, CACHE_DIR_NAME);
|
|
321
|
+
const useLiveLabels = options.useLiveLabels ?? !dryRun;
|
|
322
|
+
const client = options.client ?? (useLiveLabels || !dryRun ? new ScmLabelClient() : undefined);
|
|
323
|
+
const engine = options.engine;
|
|
324
|
+
const items = [];
|
|
325
|
+
const outcomeBase = {
|
|
326
|
+
project_root: root,
|
|
327
|
+
dry_run: dryRun,
|
|
328
|
+
policy,
|
|
329
|
+
};
|
|
330
|
+
if (!policy.enabled) {
|
|
331
|
+
return [
|
|
332
|
+
0,
|
|
333
|
+
{
|
|
334
|
+
...outcomeBase,
|
|
335
|
+
scanned: 0,
|
|
336
|
+
planned: 0,
|
|
337
|
+
applied: 0,
|
|
338
|
+
unchanged: 0,
|
|
339
|
+
skipped_already_triaged: 0,
|
|
340
|
+
skipped_no_match: 0,
|
|
341
|
+
skipped_unreadable: 0,
|
|
342
|
+
errors: 0,
|
|
343
|
+
items: [
|
|
344
|
+
{
|
|
345
|
+
repo: "",
|
|
346
|
+
issue_number: 0,
|
|
347
|
+
action: null,
|
|
348
|
+
reason: null,
|
|
349
|
+
ruleKind: null,
|
|
350
|
+
current: [],
|
|
351
|
+
desired: [],
|
|
352
|
+
add: [],
|
|
353
|
+
status: "skipped_disabled",
|
|
354
|
+
message: "plan.policy.triageLabelMirror.enabled is false",
|
|
355
|
+
},
|
|
356
|
+
],
|
|
357
|
+
},
|
|
358
|
+
];
|
|
359
|
+
}
|
|
360
|
+
const rules = engine.resolveClassifyRules({ projectRoot: root });
|
|
361
|
+
const holdMarkers = engine.resolveHoldMarkers({ projectRoot: root });
|
|
362
|
+
// Repo-qualified xBRIEF keys avoid number-only collisions across multi-repo
|
|
363
|
+
// caches and do not require project identity (#1423 Greptile P1 class).
|
|
364
|
+
const referencedKeys = extractReferencedRepoIssueKeys(root);
|
|
365
|
+
const decisions = loadLatestDecisionMap(root);
|
|
366
|
+
const now = options.now ?? new Date();
|
|
367
|
+
let pairs = iterCachedIssues(cacheRoot);
|
|
368
|
+
if (options.repo !== undefined && options.repo !== null && options.repo.trim().length > 0) {
|
|
369
|
+
const want = options.repo.trim().toLowerCase();
|
|
370
|
+
pairs = pairs.filter(([repo]) => repo.toLowerCase() === want);
|
|
371
|
+
}
|
|
372
|
+
let planned = 0;
|
|
373
|
+
let applied = 0;
|
|
374
|
+
let unchanged = 0;
|
|
375
|
+
let skippedAlready = 0;
|
|
376
|
+
let skippedNoMatch = 0;
|
|
377
|
+
let skippedUnreadable = 0;
|
|
378
|
+
let errors = 0;
|
|
379
|
+
for (const [repo, issueNumber] of pairs) {
|
|
380
|
+
const issue = readCachedRawIssue(cacheRoot, repo, issueNumber);
|
|
381
|
+
if (issue === null) {
|
|
382
|
+
skippedUnreadable += 1;
|
|
383
|
+
items.push({
|
|
384
|
+
repo,
|
|
385
|
+
issue_number: issueNumber,
|
|
386
|
+
action: null,
|
|
387
|
+
reason: null,
|
|
388
|
+
ruleKind: null,
|
|
389
|
+
current: [],
|
|
390
|
+
desired: [],
|
|
391
|
+
add: [],
|
|
392
|
+
status: "skipped_unreadable",
|
|
393
|
+
message: "missing or unreadable raw.json",
|
|
394
|
+
});
|
|
395
|
+
continue;
|
|
396
|
+
}
|
|
397
|
+
let current;
|
|
398
|
+
if (useLiveLabels && client !== undefined) {
|
|
399
|
+
try {
|
|
400
|
+
current = client.fetchLabels(repo, issueNumber);
|
|
401
|
+
}
|
|
402
|
+
catch (exc) {
|
|
403
|
+
current = issueLabelNames(issue);
|
|
404
|
+
if (!dryRun) {
|
|
405
|
+
errors += 1;
|
|
406
|
+
items.push({
|
|
407
|
+
repo,
|
|
408
|
+
issue_number: issueNumber,
|
|
409
|
+
action: null,
|
|
410
|
+
reason: null,
|
|
411
|
+
ruleKind: null,
|
|
412
|
+
current,
|
|
413
|
+
desired: [],
|
|
414
|
+
add: [],
|
|
415
|
+
status: "error",
|
|
416
|
+
message: `fetchLabels failed: ${exc instanceof Error ? exc.message : String(exc)}`,
|
|
417
|
+
});
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
else {
|
|
423
|
+
current = issueLabelNames(issue);
|
|
424
|
+
}
|
|
425
|
+
if (current.includes(policy.idempotencyLabel)) {
|
|
426
|
+
skippedAlready += 1;
|
|
427
|
+
items.push({
|
|
428
|
+
repo,
|
|
429
|
+
issue_number: issueNumber,
|
|
430
|
+
action: null,
|
|
431
|
+
reason: null,
|
|
432
|
+
ruleKind: null,
|
|
433
|
+
current: [...current].sort(),
|
|
434
|
+
desired: [...current].sort(),
|
|
435
|
+
add: [],
|
|
436
|
+
status: "skipped_already_triaged",
|
|
437
|
+
message: `already has ${policy.idempotencyLabel}`,
|
|
438
|
+
});
|
|
439
|
+
continue;
|
|
440
|
+
}
|
|
441
|
+
// Pass number set only when this exact repo+number is referenced from xBRIEF.
|
|
442
|
+
const key = `${repo.toLowerCase()}\0${issueNumber}`;
|
|
443
|
+
const vbriefReferenced = referencedKeys.has(key)
|
|
444
|
+
? new Set([issueNumber])
|
|
445
|
+
: new Set();
|
|
446
|
+
const classification = engine.classifyIssue(issue, {
|
|
447
|
+
rules: rules,
|
|
448
|
+
holdMarkers,
|
|
449
|
+
vbriefReferenced,
|
|
450
|
+
hasTriageDecision: decisionMapHas(decisions, repo, issueNumber),
|
|
451
|
+
now,
|
|
452
|
+
});
|
|
453
|
+
if (classification === null) {
|
|
454
|
+
skippedNoMatch += 1;
|
|
455
|
+
items.push({
|
|
456
|
+
repo,
|
|
457
|
+
issue_number: issueNumber,
|
|
458
|
+
action: null,
|
|
459
|
+
reason: null,
|
|
460
|
+
ruleKind: null,
|
|
461
|
+
current: [...current].sort(),
|
|
462
|
+
desired: [],
|
|
463
|
+
add: [],
|
|
464
|
+
status: "skipped_no_match",
|
|
465
|
+
});
|
|
466
|
+
continue;
|
|
467
|
+
}
|
|
468
|
+
const desired = desiredLabelsForClassification(classification.action, policy);
|
|
469
|
+
const currentSet = new Set(current);
|
|
470
|
+
const add = desired.filter((label) => !currentSet.has(label)).sort();
|
|
471
|
+
if (add.length === 0) {
|
|
472
|
+
unchanged += 1;
|
|
473
|
+
items.push({
|
|
474
|
+
repo,
|
|
475
|
+
issue_number: issueNumber,
|
|
476
|
+
action: classification.action,
|
|
477
|
+
reason: classification.reason,
|
|
478
|
+
ruleKind: classification.ruleKind,
|
|
479
|
+
current: [...current].sort(),
|
|
480
|
+
desired,
|
|
481
|
+
add: [],
|
|
482
|
+
status: "unchanged",
|
|
483
|
+
});
|
|
484
|
+
continue;
|
|
485
|
+
}
|
|
486
|
+
if (dryRun) {
|
|
487
|
+
planned += 1;
|
|
488
|
+
items.push({
|
|
489
|
+
repo,
|
|
490
|
+
issue_number: issueNumber,
|
|
491
|
+
action: classification.action,
|
|
492
|
+
reason: classification.reason,
|
|
493
|
+
ruleKind: classification.ruleKind,
|
|
494
|
+
current: [...current].sort(),
|
|
495
|
+
desired,
|
|
496
|
+
add,
|
|
497
|
+
status: "planned",
|
|
498
|
+
});
|
|
499
|
+
continue;
|
|
500
|
+
}
|
|
501
|
+
// --apply path: SCM boundary + write
|
|
502
|
+
const mutateGate = isRepoMutationAllowed(repo, root, {
|
|
503
|
+
allowCrossRepo: options.allowCrossRepo,
|
|
504
|
+
allowlist: options.repoAllowlist,
|
|
505
|
+
explicitRepo: options.repo ?? null,
|
|
506
|
+
});
|
|
507
|
+
if (!mutateGate.allowed) {
|
|
508
|
+
errors += 1;
|
|
509
|
+
items.push({
|
|
510
|
+
repo,
|
|
511
|
+
issue_number: issueNumber,
|
|
512
|
+
action: classification.action,
|
|
513
|
+
reason: classification.reason,
|
|
514
|
+
ruleKind: classification.ruleKind,
|
|
515
|
+
current: [...current].sort(),
|
|
516
|
+
desired,
|
|
517
|
+
add,
|
|
518
|
+
status: "error",
|
|
519
|
+
message: mutateGate.reason ?? `refusing cross-repo mutation on ${repo}`,
|
|
520
|
+
});
|
|
521
|
+
continue;
|
|
522
|
+
}
|
|
523
|
+
if (client === undefined) {
|
|
524
|
+
errors += 1;
|
|
525
|
+
items.push({
|
|
526
|
+
repo,
|
|
527
|
+
issue_number: issueNumber,
|
|
528
|
+
action: classification.action,
|
|
529
|
+
reason: classification.reason,
|
|
530
|
+
ruleKind: classification.ruleKind,
|
|
531
|
+
current: [...current].sort(),
|
|
532
|
+
desired,
|
|
533
|
+
add,
|
|
534
|
+
status: "error",
|
|
535
|
+
message: "no LabelClient available for apply",
|
|
536
|
+
});
|
|
537
|
+
continue;
|
|
538
|
+
}
|
|
539
|
+
try {
|
|
540
|
+
client.apply(repo, issueNumber, add, []);
|
|
541
|
+
applied += 1;
|
|
542
|
+
items.push({
|
|
543
|
+
repo,
|
|
544
|
+
issue_number: issueNumber,
|
|
545
|
+
action: classification.action,
|
|
546
|
+
reason: classification.reason,
|
|
547
|
+
ruleKind: classification.ruleKind,
|
|
548
|
+
current: [...current].sort(),
|
|
549
|
+
desired,
|
|
550
|
+
add,
|
|
551
|
+
status: "applied",
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
catch (exc) {
|
|
555
|
+
errors += 1;
|
|
556
|
+
items.push({
|
|
557
|
+
repo,
|
|
558
|
+
issue_number: issueNumber,
|
|
559
|
+
action: classification.action,
|
|
560
|
+
reason: classification.reason,
|
|
561
|
+
ruleKind: classification.ruleKind,
|
|
562
|
+
current: [...current].sort(),
|
|
563
|
+
desired,
|
|
564
|
+
add,
|
|
565
|
+
status: "error",
|
|
566
|
+
message: exc instanceof Error ? exc.message : String(exc),
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
const outcome = {
|
|
571
|
+
...outcomeBase,
|
|
572
|
+
scanned: pairs.length,
|
|
573
|
+
planned,
|
|
574
|
+
applied,
|
|
575
|
+
unchanged,
|
|
576
|
+
skipped_already_triaged: skippedAlready,
|
|
577
|
+
skipped_no_match: skippedNoMatch,
|
|
578
|
+
skipped_unreadable: skippedUnreadable,
|
|
579
|
+
errors,
|
|
580
|
+
items,
|
|
581
|
+
};
|
|
582
|
+
return [errors > 0 ? 1 : 0, outcome];
|
|
583
|
+
}
|
|
584
|
+
/** Human-readable digest for dry-run / apply reports. */
|
|
585
|
+
export function renderLabelMirrorReport(outcome) {
|
|
586
|
+
const lines = [];
|
|
587
|
+
const mode = outcome.dry_run ? "dry-run" : "apply";
|
|
588
|
+
lines.push(`triage:classify --mirror (${mode})`);
|
|
589
|
+
lines.push(`scanned=${outcome.scanned} planned=${outcome.planned} applied=${outcome.applied} ` +
|
|
590
|
+
`unchanged=${outcome.unchanged} already_triaged=${outcome.skipped_already_triaged} ` +
|
|
591
|
+
`no_match=${outcome.skipped_no_match} unreadable=${outcome.skipped_unreadable} ` +
|
|
592
|
+
`errors=${outcome.errors}`);
|
|
593
|
+
lines.push(`idempotencyLabel=${outcome.policy.idempotencyLabel} alwaysLabels=${JSON.stringify(outcome.policy.alwaysLabels)}`);
|
|
594
|
+
lines.push("");
|
|
595
|
+
const plannedOrApplied = outcome.items.filter((i) => i.status === "planned" || i.status === "applied");
|
|
596
|
+
lines.push(outcome.dry_run ? "Would add labels:" : "Added labels:");
|
|
597
|
+
if (plannedOrApplied.length === 0) {
|
|
598
|
+
lines.push("- none");
|
|
599
|
+
}
|
|
600
|
+
else {
|
|
601
|
+
for (const item of plannedOrApplied) {
|
|
602
|
+
const actionPart = item.action !== null ? ` action=${item.action}` : "";
|
|
603
|
+
const rulePart = item.ruleKind !== null ? ` rule=${item.ruleKind}` : "";
|
|
604
|
+
const addPart = sanitizeReportFragment(item.add.join(", +"));
|
|
605
|
+
lines.push(`- ${item.repo}#${item.issue_number}:${actionPart}${rulePart} +${addPart}`);
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
const already = outcome.items.filter((i) => i.status === "skipped_already_triaged");
|
|
609
|
+
if (already.length > 0) {
|
|
610
|
+
lines.push("");
|
|
611
|
+
lines.push(`Skipped (already ${outcome.policy.idempotencyLabel}): ${already.length}`);
|
|
612
|
+
}
|
|
613
|
+
const noMatch = outcome.items.filter((i) => i.status === "skipped_no_match");
|
|
614
|
+
if (noMatch.length > 0) {
|
|
615
|
+
lines.push(`Skipped (no classify match): ${noMatch.length}`);
|
|
616
|
+
}
|
|
617
|
+
const errs = outcome.items.filter((i) => i.status === "error");
|
|
618
|
+
if (errs.length > 0) {
|
|
619
|
+
lines.push("");
|
|
620
|
+
lines.push("Errors:");
|
|
621
|
+
for (const item of errs) {
|
|
622
|
+
const msg = sanitizeReportFragment(item.message ?? "unknown error");
|
|
623
|
+
lines.push(`- ${item.repo}#${item.issue_number}: ${msg}`);
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
if (outcome.dry_run && plannedOrApplied.length > 0) {
|
|
627
|
+
lines.push("");
|
|
628
|
+
lines.push("Dry-run -- re-run with --mirror --apply to write these labels via SCM.");
|
|
629
|
+
}
|
|
630
|
+
return `${lines.join("\n")}\n`;
|
|
631
|
+
}
|
|
632
|
+
/** JSON-serializable outcome (stable key order not required). */
|
|
633
|
+
export function labelMirrorOutcomeToJson(outcome) {
|
|
634
|
+
return {
|
|
635
|
+
project_root: outcome.project_root,
|
|
636
|
+
dry_run: outcome.dry_run,
|
|
637
|
+
scanned: outcome.scanned,
|
|
638
|
+
planned: outcome.planned,
|
|
639
|
+
applied: outcome.applied,
|
|
640
|
+
unchanged: outcome.unchanged,
|
|
641
|
+
skipped_already_triaged: outcome.skipped_already_triaged,
|
|
642
|
+
skipped_no_match: outcome.skipped_no_match,
|
|
643
|
+
skipped_unreadable: outcome.skipped_unreadable,
|
|
644
|
+
errors: outcome.errors,
|
|
645
|
+
policy: {
|
|
646
|
+
enabled: outcome.policy.enabled,
|
|
647
|
+
idempotencyLabel: outcome.policy.idempotencyLabel,
|
|
648
|
+
alwaysLabels: [...outcome.policy.alwaysLabels],
|
|
649
|
+
actionLabels: Object.fromEntries(Object.entries(outcome.policy.actionLabels).map(([k, v]) => [k, [...(v ?? [])]])),
|
|
650
|
+
},
|
|
651
|
+
items: outcome.items.map((i) => ({
|
|
652
|
+
repo: i.repo,
|
|
653
|
+
issue_number: i.issue_number,
|
|
654
|
+
action: i.action,
|
|
655
|
+
reason: i.reason,
|
|
656
|
+
ruleKind: i.ruleKind,
|
|
657
|
+
current: [...i.current],
|
|
658
|
+
desired: [...i.desired],
|
|
659
|
+
add: [...i.add],
|
|
660
|
+
status: i.status,
|
|
661
|
+
...(i.message !== undefined ? { message: i.message } : {}),
|
|
662
|
+
})),
|
|
663
|
+
};
|
|
664
|
+
}
|
|
665
|
+
/** Validate triageLabelMirror on a plan object (vbrief_validate hook). */
|
|
666
|
+
export function validateTriageLabelMirrorOnPlan(plan, filepath) {
|
|
667
|
+
const out = [];
|
|
668
|
+
if (typeof plan !== "object" || plan === null || Array.isArray(plan)) {
|
|
669
|
+
return out;
|
|
670
|
+
}
|
|
671
|
+
const policy = readPlanPolicy(plan);
|
|
672
|
+
const raw = typeof policy === "object" && policy !== null && !Array.isArray(policy)
|
|
673
|
+
? policy.triageLabelMirror
|
|
674
|
+
: undefined;
|
|
675
|
+
if (raw === undefined || raw === null) {
|
|
676
|
+
return out;
|
|
677
|
+
}
|
|
678
|
+
const { errors } = validateLabelMirrorPolicy(raw);
|
|
679
|
+
for (const err of errors) {
|
|
680
|
+
out.push(`${filepath}: ${err} (#1423)`);
|
|
681
|
+
}
|
|
682
|
+
return out;
|
|
683
|
+
}
|
|
684
|
+
//# sourceMappingURL=label-mirror.js.map
|