@deftai/directive-core 0.94.0 → 0.96.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/cache/archive.d.ts +134 -0
- package/dist/cache/archive.js +630 -0
- package/dist/cache/index.d.ts +1 -0
- package/dist/cache/index.js +1 -0
- package/dist/cache/main.js +298 -1
- package/dist/content-contracts/skills/helpers.d.ts +1 -1
- package/dist/content-contracts/skills/helpers.js +1 -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 +3 -0
- package/dist/index.js +3 -0
- package/dist/init-deposit/agent-hooks.d.ts +1 -1
- package/dist/init-deposit/agent-hooks.js +5 -2
- package/dist/init-deposit/hygiene.d.ts +1 -1
- package/dist/init-deposit/hygiene.js +16 -4
- 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/init-deposit/scaffold.js +6 -5
- package/dist/parent-turn-shape/evaluate.d.ts +84 -0
- package/dist/parent-turn-shape/evaluate.js +353 -0
- package/dist/parent-turn-shape/index.d.ts +8 -0
- package/dist/parent-turn-shape/index.js +8 -0
- 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/review-monitor/constants.js +3 -2
- package/dist/review-monitor/tier-detection.d.ts +7 -3
- package/dist/review-monitor/tier-detection.js +18 -1
- package/dist/review-monitor/verify.js +18 -0
- package/dist/scope/index.d.ts +2 -0
- package/dist/scope/index.js +2 -0
- package/dist/scope/main.d.ts +10 -0
- package/dist/scope/main.js +109 -24
- package/dist/scope/promote-from-issue.d.ts +49 -0
- package/dist/scope/promote-from-issue.js +367 -0
- package/dist/scope/promote-path.d.ts +39 -0
- package/dist/scope/promote-path.js +105 -0
- 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/routing.d.ts +4 -2
- package/dist/swarm/routing.js +26 -4
- package/dist/swarm/verify-review-clean.d.ts +4 -3
- package/dist/swarm/verify-review-clean.js +28 -73
- package/dist/triage/actions/index.js +62 -2
- package/dist/triage/actions/types.d.ts +8 -1
- package/dist/triage/author-filter.d.ts +51 -0
- package/dist/triage/author-filter.js +152 -0
- 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 +206 -0
- package/dist/triage/classify/label-mirror.js +914 -0
- package/dist/triage/help/registry-data.d.ts +49 -38
- package/dist/triage/help/registry-data.js +134 -39
- package/dist/triage/index.d.ts +1 -0
- package/dist/triage/index.js +1 -0
- package/dist/triage/queue/index.d.ts +1 -0
- package/dist/triage/queue/index.js +1 -0
- package/dist/triage/queue/render.d.ts +2 -0
- package/dist/triage/queue/render.js +6 -0
- 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 +11 -3
|
@@ -0,0 +1,914 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tier-1 deterministic SCM label mirror (#1423 Wave 1 + Wave 2 bootstrap).
|
|
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
|
+
* Wave 2 (#3125): open-only default, operator digest (totals + by state/rule/action
|
|
9
|
+
* + samples), batched rate-limit-aware apply. Bootstrap mass-triage entrypoint is
|
|
10
|
+
* `triage:classify -- --mirror` with these filters (not triage:accept).
|
|
11
|
+
*
|
|
12
|
+
* Intentionally does NOT import from ./index.js (SLizard P1 cycle). The classify
|
|
13
|
+
* engine is injected via LabelMirrorEngine / mirrorLabels() wrapper in index.ts.
|
|
14
|
+
*/
|
|
15
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
16
|
+
import { join, resolve } from "node:path";
|
|
17
|
+
import { referenceTypeMatches } from "@deftai/directive-types";
|
|
18
|
+
import { hasArtifactSuffix, resolveLifecycleRoot, resolveProjectDefinitionPath, } from "../../layout/resolve.js";
|
|
19
|
+
import { readPlanPolicy } from "../../policy/plan-extensions.js";
|
|
20
|
+
import { ScmLabelClient } from "../../vbrief-reconcile/labels.js";
|
|
21
|
+
import { isRepoMutationAllowed } from "../../vbrief-reconcile/repo-guard.js";
|
|
22
|
+
import { latestDecisions, readAuditLog } from "../actions/candidates-log.js";
|
|
23
|
+
import { authorLoginFromRawIssue, matchesAuthorFilter, } from "../author-filter.js";
|
|
24
|
+
import { resolveCandidatesLogPath } from "../cache-path.js";
|
|
25
|
+
import { iterCachedIssues } from "../summary/index.js";
|
|
26
|
+
export const DEFAULT_IDEMPOTENCY_LABEL = "triaged";
|
|
27
|
+
export const CACHE_DIR_NAME = ".deft-cache";
|
|
28
|
+
export const CACHE_SOURCE = "github-issue";
|
|
29
|
+
/** Default apply batch size for rate-limit awareness (#3125). */
|
|
30
|
+
export const DEFAULT_APPLY_BATCH_SIZE = 10;
|
|
31
|
+
/** Default delay between apply batches in ms (#3125). */
|
|
32
|
+
export const DEFAULT_APPLY_DELAY_MS = 1000;
|
|
33
|
+
/** Default sample count in human digest (#3125). */
|
|
34
|
+
export const DEFAULT_DIGEST_SAMPLE_LIMIT = 15;
|
|
35
|
+
const VALID_ACTIONS = new Set(["defer", "archive", "escalate", "accept"]);
|
|
36
|
+
/**
|
|
37
|
+
* Repo-qualified xBRIEF github-issue keys (`owner/name\\0number`).
|
|
38
|
+
* Unlike number-only extractReferencedIssues, this never collides across repos.
|
|
39
|
+
*/
|
|
40
|
+
export function extractReferencedRepoIssueKeys(projectRoot, lifecycleFolders = ["pending", "active"]) {
|
|
41
|
+
const referenced = new Set();
|
|
42
|
+
let root;
|
|
43
|
+
try {
|
|
44
|
+
root = resolveLifecycleRoot(projectRoot ?? process.cwd());
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return referenced;
|
|
48
|
+
}
|
|
49
|
+
for (const folder of lifecycleFolders) {
|
|
50
|
+
const folderPath = join(root, folder);
|
|
51
|
+
let entries;
|
|
52
|
+
try {
|
|
53
|
+
entries = readdirSync(folderPath).filter((f) => hasArtifactSuffix(f));
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
for (const name of entries) {
|
|
59
|
+
try {
|
|
60
|
+
const raw = readFileSync(join(folderPath, name), { encoding: "utf8" });
|
|
61
|
+
const data = JSON.parse(raw);
|
|
62
|
+
if (typeof data !== "object" || data === null || Array.isArray(data)) {
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
const plan = data.plan;
|
|
66
|
+
if (typeof plan !== "object" || plan === null || Array.isArray(plan)) {
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
const refs = plan.references ?? [];
|
|
70
|
+
if (!Array.isArray(refs)) {
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
for (const ref of refs) {
|
|
74
|
+
if (typeof ref !== "object" || ref === null || Array.isArray(ref)) {
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
const r = ref;
|
|
78
|
+
if (!referenceTypeMatches(String(r.type ?? ""), "github-issue")) {
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
const uri = r.uri;
|
|
82
|
+
if (typeof uri !== "string") {
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
const cleaned = uri.trim().replace(/\/+$/, "");
|
|
86
|
+
const parts = cleaned
|
|
87
|
+
.split("://")
|
|
88
|
+
.pop()
|
|
89
|
+
?.split("/")
|
|
90
|
+
.filter((p) => p.length > 0) ?? [];
|
|
91
|
+
if (parts.length >= 4 &&
|
|
92
|
+
parts[parts.length - 2] === "issues" &&
|
|
93
|
+
/^\d+$/.test(parts[parts.length - 1] ?? "")) {
|
|
94
|
+
const owner = parts[parts.length - 4] ?? "";
|
|
95
|
+
const repoName = parts[parts.length - 3] ?? "";
|
|
96
|
+
const n = Number.parseInt(parts[parts.length - 1] ?? "", 10);
|
|
97
|
+
if (owner.length > 0 && repoName.length > 0 && Number.isFinite(n)) {
|
|
98
|
+
referenced.add(`${owner}/${repoName}\0${n}`.toLowerCase());
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
// tolerate corrupt artifacts
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return referenced;
|
|
109
|
+
}
|
|
110
|
+
function asStringList(value) {
|
|
111
|
+
if (!Array.isArray(value)) {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
const out = [];
|
|
115
|
+
for (const item of value) {
|
|
116
|
+
if (typeof item !== "string" || item.trim().length === 0) {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
out.push(item);
|
|
120
|
+
}
|
|
121
|
+
return out;
|
|
122
|
+
}
|
|
123
|
+
function sanitizeReportFragment(value) {
|
|
124
|
+
return value.replace(/\r?\n/g, " ");
|
|
125
|
+
}
|
|
126
|
+
/** Validate plan.policy.triageLabelMirror payload. */
|
|
127
|
+
export function validateLabelMirrorPolicy(raw) {
|
|
128
|
+
const errors = [];
|
|
129
|
+
const warnings = [];
|
|
130
|
+
if (raw === undefined || raw === null) {
|
|
131
|
+
return { errors, warnings };
|
|
132
|
+
}
|
|
133
|
+
if (typeof raw !== "object" || Array.isArray(raw)) {
|
|
134
|
+
errors.push("plan.policy.triageLabelMirror must be an object");
|
|
135
|
+
return { errors, warnings };
|
|
136
|
+
}
|
|
137
|
+
const obj = raw;
|
|
138
|
+
if ("enabled" in obj && typeof obj.enabled !== "boolean") {
|
|
139
|
+
errors.push("plan.policy.triageLabelMirror.enabled must be a boolean when set");
|
|
140
|
+
}
|
|
141
|
+
if ("idempotencyLabel" in obj) {
|
|
142
|
+
if (typeof obj.idempotencyLabel !== "string" || obj.idempotencyLabel.trim().length === 0) {
|
|
143
|
+
errors.push("plan.policy.triageLabelMirror.idempotencyLabel must be a non-empty string");
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
if ("alwaysLabels" in obj) {
|
|
147
|
+
const list = asStringList(obj.alwaysLabels);
|
|
148
|
+
if (list === null) {
|
|
149
|
+
errors.push("plan.policy.triageLabelMirror.alwaysLabels must be a list of non-empty strings");
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
if ("actionLabels" in obj) {
|
|
153
|
+
const al = obj.actionLabels;
|
|
154
|
+
if (typeof al !== "object" || al === null || Array.isArray(al)) {
|
|
155
|
+
errors.push("plan.policy.triageLabelMirror.actionLabels must be an object");
|
|
156
|
+
}
|
|
157
|
+
else {
|
|
158
|
+
for (const [key, value] of Object.entries(al)) {
|
|
159
|
+
if (!VALID_ACTIONS.has(key)) {
|
|
160
|
+
errors.push(`plan.policy.triageLabelMirror.actionLabels.${key}: action must be one of ${[...VALID_ACTIONS].sort().join(", ")}`);
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
const list = asStringList(value);
|
|
164
|
+
if (list === null) {
|
|
165
|
+
errors.push(`plan.policy.triageLabelMirror.actionLabels.${key} must be a list of non-empty strings`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
const known = new Set(["enabled", "idempotencyLabel", "alwaysLabels", "actionLabels"]);
|
|
171
|
+
const extra = Object.keys(obj)
|
|
172
|
+
.filter((k) => !known.has(k))
|
|
173
|
+
.sort();
|
|
174
|
+
if (extra.length > 0) {
|
|
175
|
+
warnings.push(`plan.policy.triageLabelMirror: ignoring unrecognised key(s) ${extra.join(", ")}`);
|
|
176
|
+
}
|
|
177
|
+
return { errors, warnings };
|
|
178
|
+
}
|
|
179
|
+
/** Default resolved policy when triageLabelMirror is absent. */
|
|
180
|
+
export function defaultLabelMirrorPolicy() {
|
|
181
|
+
return {
|
|
182
|
+
enabled: true,
|
|
183
|
+
idempotencyLabel: DEFAULT_IDEMPOTENCY_LABEL,
|
|
184
|
+
alwaysLabels: [DEFAULT_IDEMPOTENCY_LABEL],
|
|
185
|
+
actionLabels: {},
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
function loadProjectDefinition(projectRoot) {
|
|
189
|
+
let path;
|
|
190
|
+
try {
|
|
191
|
+
path = resolveProjectDefinitionPath(projectRoot);
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
path = join(projectRoot, "xbrief", "PROJECT-DEFINITION.xbrief.json");
|
|
195
|
+
}
|
|
196
|
+
try {
|
|
197
|
+
const raw = readFileSync(path, { encoding: "utf8" });
|
|
198
|
+
const data = JSON.parse(raw);
|
|
199
|
+
return typeof data === "object" && data !== null && !Array.isArray(data)
|
|
200
|
+
? data
|
|
201
|
+
: null;
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
/** Resolve effective label-mirror policy from PROJECT-DEFINITION or inline object. */
|
|
208
|
+
export function resolveLabelMirrorPolicy(options) {
|
|
209
|
+
if (options?.override !== undefined && options.override !== null) {
|
|
210
|
+
return materializePolicy(options.override);
|
|
211
|
+
}
|
|
212
|
+
const data = options?.projectDefinition !== undefined
|
|
213
|
+
? options.projectDefinition
|
|
214
|
+
: loadProjectDefinition(options?.projectRoot ?? process.cwd());
|
|
215
|
+
if (data === null) {
|
|
216
|
+
return defaultLabelMirrorPolicy();
|
|
217
|
+
}
|
|
218
|
+
const plan = data.plan;
|
|
219
|
+
if (typeof plan !== "object" || plan === null || Array.isArray(plan)) {
|
|
220
|
+
return defaultLabelMirrorPolicy();
|
|
221
|
+
}
|
|
222
|
+
const policy = readPlanPolicy(plan);
|
|
223
|
+
if (typeof policy !== "object" || policy === null || Array.isArray(policy)) {
|
|
224
|
+
return defaultLabelMirrorPolicy();
|
|
225
|
+
}
|
|
226
|
+
const raw = policy.triageLabelMirror;
|
|
227
|
+
if (raw === undefined || raw === null) {
|
|
228
|
+
return defaultLabelMirrorPolicy();
|
|
229
|
+
}
|
|
230
|
+
if (typeof raw !== "object" || Array.isArray(raw)) {
|
|
231
|
+
return defaultLabelMirrorPolicy();
|
|
232
|
+
}
|
|
233
|
+
return materializePolicy(raw);
|
|
234
|
+
}
|
|
235
|
+
function materializePolicy(raw) {
|
|
236
|
+
const idempotencyLabel = typeof raw.idempotencyLabel === "string" && raw.idempotencyLabel.trim().length > 0
|
|
237
|
+
? raw.idempotencyLabel.trim()
|
|
238
|
+
: DEFAULT_IDEMPOTENCY_LABEL;
|
|
239
|
+
const alwaysFromPolicy = asStringList(raw.alwaysLabels ?? null);
|
|
240
|
+
const alwaysLabels = alwaysFromPolicy !== null ? alwaysFromPolicy : [idempotencyLabel].filter((x) => x.length > 0);
|
|
241
|
+
const actionLabels = {};
|
|
242
|
+
const al = raw.actionLabels;
|
|
243
|
+
if (typeof al === "object" && al !== null && !Array.isArray(al)) {
|
|
244
|
+
for (const action of VALID_ACTIONS) {
|
|
245
|
+
const list = asStringList(al[action]);
|
|
246
|
+
if (list !== null && list.length > 0) {
|
|
247
|
+
actionLabels[action] = list;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
return {
|
|
252
|
+
enabled: raw.enabled !== false,
|
|
253
|
+
idempotencyLabel,
|
|
254
|
+
alwaysLabels,
|
|
255
|
+
actionLabels,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
/** Labels to apply for a classified action (always + action-mapped). */
|
|
259
|
+
export function desiredLabelsForClassification(action, policy) {
|
|
260
|
+
const desired = new Set(policy.alwaysLabels);
|
|
261
|
+
const mapped = policy.actionLabels[action];
|
|
262
|
+
if (mapped !== undefined) {
|
|
263
|
+
for (const label of mapped) {
|
|
264
|
+
desired.add(label);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
// Always include the idempotency marker even if alwaysLabels was customized empty.
|
|
268
|
+
if (policy.idempotencyLabel.length > 0) {
|
|
269
|
+
desired.add(policy.idempotencyLabel);
|
|
270
|
+
}
|
|
271
|
+
return [...desired].sort();
|
|
272
|
+
}
|
|
273
|
+
function issueLabelNames(issue) {
|
|
274
|
+
const raw = issue.labels ?? [];
|
|
275
|
+
const names = [];
|
|
276
|
+
if (!Array.isArray(raw)) {
|
|
277
|
+
return names;
|
|
278
|
+
}
|
|
279
|
+
for (const item of raw) {
|
|
280
|
+
if (typeof item === "object" && item !== null && "name" in item) {
|
|
281
|
+
const name = item.name;
|
|
282
|
+
if (typeof name === "string" && name.length > 0) {
|
|
283
|
+
names.push(name);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
else if (typeof item === "string" && item.length > 0) {
|
|
287
|
+
names.push(item);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
return names;
|
|
291
|
+
}
|
|
292
|
+
function readCachedRawIssue(cacheRoot, repo, issueNumber) {
|
|
293
|
+
const [owner, name] = repo.split("/", 2);
|
|
294
|
+
const rawPath = join(cacheRoot, CACHE_SOURCE, owner ?? "", name ?? "", String(issueNumber), "raw.json");
|
|
295
|
+
if (!existsSync(rawPath)) {
|
|
296
|
+
return null;
|
|
297
|
+
}
|
|
298
|
+
try {
|
|
299
|
+
const data = JSON.parse(readFileSync(rawPath, { encoding: "utf8" }));
|
|
300
|
+
if (typeof data !== "object" || data === null || Array.isArray(data)) {
|
|
301
|
+
return null;
|
|
302
|
+
}
|
|
303
|
+
return data;
|
|
304
|
+
}
|
|
305
|
+
catch {
|
|
306
|
+
return null;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
/** Load latest decisions as Map keyed by `repo\0number` for hasTriageDecision. */
|
|
310
|
+
function loadLatestDecisionMap(projectRoot) {
|
|
311
|
+
try {
|
|
312
|
+
const path = resolveCandidatesLogPath(projectRoot);
|
|
313
|
+
return latestDecisions(readAuditLog(path));
|
|
314
|
+
}
|
|
315
|
+
catch {
|
|
316
|
+
return new Map();
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
function decisionMapHas(map, repo, issueNumber) {
|
|
320
|
+
return map.has(`${repo}\0${issueNumber}`);
|
|
321
|
+
}
|
|
322
|
+
function defaultSleepMs(ms) {
|
|
323
|
+
if (ms <= 0) {
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
// Sync sleep: LabelClient.apply is sync; keep mirrorLabels non-async (#3125).
|
|
327
|
+
const sab = new SharedArrayBuffer(4);
|
|
328
|
+
const ia = new Int32Array(sab);
|
|
329
|
+
Atomics.wait(ia, 0, 0, ms);
|
|
330
|
+
}
|
|
331
|
+
function normalizeIssueState(raw) {
|
|
332
|
+
if (typeof raw !== "string" || raw.trim().length === 0) {
|
|
333
|
+
return null;
|
|
334
|
+
}
|
|
335
|
+
return raw.trim().toLowerCase();
|
|
336
|
+
}
|
|
337
|
+
function isClosedState(state) {
|
|
338
|
+
return state === "closed";
|
|
339
|
+
}
|
|
340
|
+
/** Build digest aggregates + samples from mirror items (#3125). */
|
|
341
|
+
export function buildLabelMirrorDigest(items, sampleLimit = DEFAULT_DIGEST_SAMPLE_LIMIT) {
|
|
342
|
+
const limit = Number.isFinite(sampleLimit) && sampleLimit >= 0
|
|
343
|
+
? Math.floor(sampleLimit)
|
|
344
|
+
: DEFAULT_DIGEST_SAMPLE_LIMIT;
|
|
345
|
+
const byState = {};
|
|
346
|
+
const byRule = {};
|
|
347
|
+
const byAction = {};
|
|
348
|
+
const writeItems = items.filter((i) => i.status === "planned" || i.status === "applied");
|
|
349
|
+
for (const item of writeItems) {
|
|
350
|
+
const st = item.state ?? "unknown";
|
|
351
|
+
byState[st] = (byState[st] ?? 0) + 1;
|
|
352
|
+
const rule = item.ruleKind ?? "(none)";
|
|
353
|
+
byRule[rule] = (byRule[rule] ?? 0) + 1;
|
|
354
|
+
const action = item.action ?? "(none)";
|
|
355
|
+
byAction[action] = (byAction[action] ?? 0) + 1;
|
|
356
|
+
}
|
|
357
|
+
const samples = writeItems.slice(0, limit);
|
|
358
|
+
return {
|
|
359
|
+
by_state: byState,
|
|
360
|
+
by_rule: byRule,
|
|
361
|
+
by_action: byAction,
|
|
362
|
+
samples,
|
|
363
|
+
sample_limit: limit,
|
|
364
|
+
sample_truncated: writeItems.length > limit,
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
function formatMissingLabelHint(message, labels) {
|
|
368
|
+
const lower = message.toLowerCase();
|
|
369
|
+
const looksMissing = lower.includes("not found") ||
|
|
370
|
+
lower.includes("could not add label") ||
|
|
371
|
+
lower.includes("invalid label") ||
|
|
372
|
+
lower.includes("unknown label") ||
|
|
373
|
+
(lower.includes("label") && (lower.includes("404") || lower.includes("does not exist")));
|
|
374
|
+
if (!looksMissing) {
|
|
375
|
+
return message;
|
|
376
|
+
}
|
|
377
|
+
const want = labels.length > 0 ? labels.join(", ") : "triaged";
|
|
378
|
+
return (`${message} — ensure label(s) exist on the repo before --apply ` +
|
|
379
|
+
`(create missing labels e.g. \`gh label create "${want.split(",")[0]?.trim() ?? "triaged"}"\`; ` +
|
|
380
|
+
`idempotency + actionLabels must exist or apply fails closed per issue).`);
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* Run Tier-1 label mirror over the github-issue cache (bootstrap mass-triage surface).
|
|
384
|
+
* Dry-run by default (no SCM writes). Pass dryRun: false to apply.
|
|
385
|
+
* Default state filter is open-only (#3125); pass includeClosed: true for archive stamps.
|
|
386
|
+
* Requires options.engine (classify/index wrapper injects it).
|
|
387
|
+
* Never calls triage:accept / never writes proposed/ xBRIEFs.
|
|
388
|
+
*/
|
|
389
|
+
export function mirrorLabels(projectRoot, options) {
|
|
390
|
+
const root = resolve(projectRoot);
|
|
391
|
+
const dryRun = options.dryRun ?? true;
|
|
392
|
+
const policy = resolveLabelMirrorPolicy({ projectRoot: root });
|
|
393
|
+
const cacheRoot = options.cacheRoot ?? join(root, CACHE_DIR_NAME);
|
|
394
|
+
const useLiveLabels = options.useLiveLabels ?? !dryRun;
|
|
395
|
+
const client = options.client ?? (useLiveLabels || !dryRun ? new ScmLabelClient() : undefined);
|
|
396
|
+
const engine = options.engine;
|
|
397
|
+
const includeClosed = options.includeClosed === true;
|
|
398
|
+
const authorFilter = options.authorFilter !== undefined && options.authorFilter !== null
|
|
399
|
+
? options.authorFilter
|
|
400
|
+
: null;
|
|
401
|
+
const sampleLimit = options.sampleLimit ?? DEFAULT_DIGEST_SAMPLE_LIMIT;
|
|
402
|
+
const batchSize = options.batchSize !== undefined
|
|
403
|
+
? Math.max(1, Math.floor(options.batchSize))
|
|
404
|
+
: DEFAULT_APPLY_BATCH_SIZE;
|
|
405
|
+
const delayMs = options.delayMs !== undefined && options.delayMs >= 0
|
|
406
|
+
? Math.floor(options.delayMs)
|
|
407
|
+
: dryRun
|
|
408
|
+
? 0
|
|
409
|
+
: DEFAULT_APPLY_DELAY_MS;
|
|
410
|
+
const sleepMs = options.sleepMs ?? defaultSleepMs;
|
|
411
|
+
const repoFilter = options.repo !== undefined && options.repo !== null && options.repo.trim().length > 0
|
|
412
|
+
? options.repo.trim()
|
|
413
|
+
: null;
|
|
414
|
+
const filters = {
|
|
415
|
+
include_closed: includeClosed,
|
|
416
|
+
repo: repoFilter,
|
|
417
|
+
author: authorFilter !== null ? authorFilter.display : null,
|
|
418
|
+
author_logins: authorFilter !== null ? authorFilter.allowLogins : null,
|
|
419
|
+
};
|
|
420
|
+
const items = [];
|
|
421
|
+
const outcomeBase = {
|
|
422
|
+
project_root: root,
|
|
423
|
+
dry_run: dryRun,
|
|
424
|
+
policy,
|
|
425
|
+
filters,
|
|
426
|
+
batch_size: dryRun ? undefined : batchSize,
|
|
427
|
+
delay_ms: dryRun ? undefined : delayMs,
|
|
428
|
+
};
|
|
429
|
+
const emptyDigest = buildLabelMirrorDigest([], sampleLimit);
|
|
430
|
+
if (!policy.enabled) {
|
|
431
|
+
return [
|
|
432
|
+
0,
|
|
433
|
+
{
|
|
434
|
+
...outcomeBase,
|
|
435
|
+
scanned: 0,
|
|
436
|
+
planned: 0,
|
|
437
|
+
applied: 0,
|
|
438
|
+
unchanged: 0,
|
|
439
|
+
skipped_already_triaged: 0,
|
|
440
|
+
skipped_no_match: 0,
|
|
441
|
+
skipped_unreadable: 0,
|
|
442
|
+
skipped_closed: 0,
|
|
443
|
+
skipped_author: 0,
|
|
444
|
+
errors: 0,
|
|
445
|
+
digest: emptyDigest,
|
|
446
|
+
items: [
|
|
447
|
+
{
|
|
448
|
+
repo: "",
|
|
449
|
+
issue_number: 0,
|
|
450
|
+
state: null,
|
|
451
|
+
action: null,
|
|
452
|
+
reason: null,
|
|
453
|
+
ruleKind: null,
|
|
454
|
+
current: [],
|
|
455
|
+
desired: [],
|
|
456
|
+
add: [],
|
|
457
|
+
status: "skipped_disabled",
|
|
458
|
+
message: "plan.policy.triageLabelMirror.enabled is false",
|
|
459
|
+
},
|
|
460
|
+
],
|
|
461
|
+
},
|
|
462
|
+
];
|
|
463
|
+
}
|
|
464
|
+
const rules = engine.resolveClassifyRules({ projectRoot: root });
|
|
465
|
+
const holdMarkers = engine.resolveHoldMarkers({ projectRoot: root });
|
|
466
|
+
// Repo-qualified xBRIEF keys avoid number-only collisions across multi-repo
|
|
467
|
+
// caches and do not require project identity (#1423 Greptile P1 class).
|
|
468
|
+
const referencedKeys = extractReferencedRepoIssueKeys(root);
|
|
469
|
+
const decisions = loadLatestDecisionMap(root);
|
|
470
|
+
const now = options.now ?? new Date();
|
|
471
|
+
let pairs = iterCachedIssues(cacheRoot);
|
|
472
|
+
if (repoFilter !== null) {
|
|
473
|
+
const want = repoFilter.toLowerCase();
|
|
474
|
+
pairs = pairs.filter(([repo]) => repo.toLowerCase() === want);
|
|
475
|
+
}
|
|
476
|
+
let planned = 0;
|
|
477
|
+
let applied = 0;
|
|
478
|
+
let unchanged = 0;
|
|
479
|
+
let skippedAlready = 0;
|
|
480
|
+
let skippedNoMatch = 0;
|
|
481
|
+
let skippedUnreadable = 0;
|
|
482
|
+
let skippedClosed = 0;
|
|
483
|
+
let skippedAuthor = 0;
|
|
484
|
+
let errors = 0;
|
|
485
|
+
let applyWritesSinceSleep = 0;
|
|
486
|
+
for (const [repo, issueNumber] of pairs) {
|
|
487
|
+
const issue = readCachedRawIssue(cacheRoot, repo, issueNumber);
|
|
488
|
+
if (issue === null) {
|
|
489
|
+
skippedUnreadable += 1;
|
|
490
|
+
items.push({
|
|
491
|
+
repo,
|
|
492
|
+
issue_number: issueNumber,
|
|
493
|
+
state: null,
|
|
494
|
+
action: null,
|
|
495
|
+
reason: null,
|
|
496
|
+
ruleKind: null,
|
|
497
|
+
current: [],
|
|
498
|
+
desired: [],
|
|
499
|
+
add: [],
|
|
500
|
+
status: "skipped_unreadable",
|
|
501
|
+
message: "missing or unreadable raw.json",
|
|
502
|
+
});
|
|
503
|
+
continue;
|
|
504
|
+
}
|
|
505
|
+
const state = normalizeIssueState(issue.state);
|
|
506
|
+
// Open-only default: skip closed before classify (avoids mass-stamping archive).
|
|
507
|
+
if (!includeClosed && isClosedState(state)) {
|
|
508
|
+
skippedClosed += 1;
|
|
509
|
+
items.push({
|
|
510
|
+
repo,
|
|
511
|
+
issue_number: issueNumber,
|
|
512
|
+
state,
|
|
513
|
+
action: null,
|
|
514
|
+
reason: null,
|
|
515
|
+
ruleKind: null,
|
|
516
|
+
current: issueLabelNames(issue).sort(),
|
|
517
|
+
desired: [],
|
|
518
|
+
add: [],
|
|
519
|
+
status: "skipped_closed",
|
|
520
|
+
message: "open-only default; pass includeClosed / --include-closed to mirror closed issues",
|
|
521
|
+
});
|
|
522
|
+
continue;
|
|
523
|
+
}
|
|
524
|
+
// Author filter (#3129): AND with open-only / other filters; missing author = non-match.
|
|
525
|
+
if (authorFilter !== null) {
|
|
526
|
+
const login = authorLoginFromRawIssue(issue);
|
|
527
|
+
if (!matchesAuthorFilter(login, authorFilter)) {
|
|
528
|
+
skippedAuthor += 1;
|
|
529
|
+
items.push({
|
|
530
|
+
repo,
|
|
531
|
+
issue_number: issueNumber,
|
|
532
|
+
state,
|
|
533
|
+
action: null,
|
|
534
|
+
reason: null,
|
|
535
|
+
ruleKind: null,
|
|
536
|
+
current: issueLabelNames(issue).sort(),
|
|
537
|
+
desired: [],
|
|
538
|
+
add: [],
|
|
539
|
+
status: "skipped_author",
|
|
540
|
+
message: login === null
|
|
541
|
+
? `author filter ${authorFilter.display}: missing author on cache row (unknown — excluded)`
|
|
542
|
+
: `author filter ${authorFilter.display}: author.login=${login} does not match`,
|
|
543
|
+
});
|
|
544
|
+
continue;
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
let current;
|
|
548
|
+
if (useLiveLabels && client !== undefined) {
|
|
549
|
+
try {
|
|
550
|
+
current = client.fetchLabels(repo, issueNumber);
|
|
551
|
+
}
|
|
552
|
+
catch (exc) {
|
|
553
|
+
current = issueLabelNames(issue);
|
|
554
|
+
if (!dryRun) {
|
|
555
|
+
errors += 1;
|
|
556
|
+
items.push({
|
|
557
|
+
repo,
|
|
558
|
+
issue_number: issueNumber,
|
|
559
|
+
state,
|
|
560
|
+
action: null,
|
|
561
|
+
reason: null,
|
|
562
|
+
ruleKind: null,
|
|
563
|
+
current,
|
|
564
|
+
desired: [],
|
|
565
|
+
add: [],
|
|
566
|
+
status: "error",
|
|
567
|
+
message: `fetchLabels failed: ${exc instanceof Error ? exc.message : String(exc)}`,
|
|
568
|
+
});
|
|
569
|
+
continue;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
else {
|
|
574
|
+
current = issueLabelNames(issue);
|
|
575
|
+
}
|
|
576
|
+
if (current.includes(policy.idempotencyLabel)) {
|
|
577
|
+
skippedAlready += 1;
|
|
578
|
+
items.push({
|
|
579
|
+
repo,
|
|
580
|
+
issue_number: issueNumber,
|
|
581
|
+
state,
|
|
582
|
+
action: null,
|
|
583
|
+
reason: null,
|
|
584
|
+
ruleKind: null,
|
|
585
|
+
current: [...current].sort(),
|
|
586
|
+
desired: [...current].sort(),
|
|
587
|
+
add: [],
|
|
588
|
+
status: "skipped_already_triaged",
|
|
589
|
+
message: `already has ${policy.idempotencyLabel}`,
|
|
590
|
+
});
|
|
591
|
+
continue;
|
|
592
|
+
}
|
|
593
|
+
// Pass number set only when this exact repo+number is referenced from xBRIEF.
|
|
594
|
+
const key = `${repo.toLowerCase()}\0${issueNumber}`;
|
|
595
|
+
const vbriefReferenced = referencedKeys.has(key)
|
|
596
|
+
? new Set([issueNumber])
|
|
597
|
+
: new Set();
|
|
598
|
+
const classification = engine.classifyIssue(issue, {
|
|
599
|
+
rules: rules,
|
|
600
|
+
holdMarkers,
|
|
601
|
+
vbriefReferenced,
|
|
602
|
+
hasTriageDecision: decisionMapHas(decisions, repo, issueNumber),
|
|
603
|
+
now,
|
|
604
|
+
});
|
|
605
|
+
if (classification === null) {
|
|
606
|
+
skippedNoMatch += 1;
|
|
607
|
+
items.push({
|
|
608
|
+
repo,
|
|
609
|
+
issue_number: issueNumber,
|
|
610
|
+
state,
|
|
611
|
+
action: null,
|
|
612
|
+
reason: null,
|
|
613
|
+
ruleKind: null,
|
|
614
|
+
current: [...current].sort(),
|
|
615
|
+
desired: [],
|
|
616
|
+
add: [],
|
|
617
|
+
status: "skipped_no_match",
|
|
618
|
+
});
|
|
619
|
+
continue;
|
|
620
|
+
}
|
|
621
|
+
const desired = desiredLabelsForClassification(classification.action, policy);
|
|
622
|
+
const currentSet = new Set(current);
|
|
623
|
+
const add = desired.filter((label) => !currentSet.has(label)).sort();
|
|
624
|
+
if (add.length === 0) {
|
|
625
|
+
unchanged += 1;
|
|
626
|
+
items.push({
|
|
627
|
+
repo,
|
|
628
|
+
issue_number: issueNumber,
|
|
629
|
+
state,
|
|
630
|
+
action: classification.action,
|
|
631
|
+
reason: classification.reason,
|
|
632
|
+
ruleKind: classification.ruleKind,
|
|
633
|
+
current: [...current].sort(),
|
|
634
|
+
desired,
|
|
635
|
+
add: [],
|
|
636
|
+
status: "unchanged",
|
|
637
|
+
});
|
|
638
|
+
continue;
|
|
639
|
+
}
|
|
640
|
+
if (dryRun) {
|
|
641
|
+
planned += 1;
|
|
642
|
+
items.push({
|
|
643
|
+
repo,
|
|
644
|
+
issue_number: issueNumber,
|
|
645
|
+
state,
|
|
646
|
+
action: classification.action,
|
|
647
|
+
reason: classification.reason,
|
|
648
|
+
ruleKind: classification.ruleKind,
|
|
649
|
+
current: [...current].sort(),
|
|
650
|
+
desired,
|
|
651
|
+
add,
|
|
652
|
+
status: "planned",
|
|
653
|
+
});
|
|
654
|
+
continue;
|
|
655
|
+
}
|
|
656
|
+
// --apply path: SCM boundary + write (batched + delay for rate-limit awareness)
|
|
657
|
+
const mutateGate = isRepoMutationAllowed(repo, root, {
|
|
658
|
+
allowCrossRepo: options.allowCrossRepo,
|
|
659
|
+
allowlist: options.repoAllowlist,
|
|
660
|
+
explicitRepo: options.repo ?? null,
|
|
661
|
+
});
|
|
662
|
+
if (!mutateGate.allowed) {
|
|
663
|
+
errors += 1;
|
|
664
|
+
items.push({
|
|
665
|
+
repo,
|
|
666
|
+
issue_number: issueNumber,
|
|
667
|
+
state,
|
|
668
|
+
action: classification.action,
|
|
669
|
+
reason: classification.reason,
|
|
670
|
+
ruleKind: classification.ruleKind,
|
|
671
|
+
current: [...current].sort(),
|
|
672
|
+
desired,
|
|
673
|
+
add,
|
|
674
|
+
status: "error",
|
|
675
|
+
message: mutateGate.reason ?? `refusing cross-repo mutation on ${repo}`,
|
|
676
|
+
});
|
|
677
|
+
continue;
|
|
678
|
+
}
|
|
679
|
+
if (client === undefined) {
|
|
680
|
+
errors += 1;
|
|
681
|
+
items.push({
|
|
682
|
+
repo,
|
|
683
|
+
issue_number: issueNumber,
|
|
684
|
+
state,
|
|
685
|
+
action: classification.action,
|
|
686
|
+
reason: classification.reason,
|
|
687
|
+
ruleKind: classification.ruleKind,
|
|
688
|
+
current: [...current].sort(),
|
|
689
|
+
desired,
|
|
690
|
+
add,
|
|
691
|
+
status: "error",
|
|
692
|
+
message: "no LabelClient available for apply",
|
|
693
|
+
});
|
|
694
|
+
continue;
|
|
695
|
+
}
|
|
696
|
+
try {
|
|
697
|
+
// Count every SCM write *attempt* toward the batch (including failures) so
|
|
698
|
+
// rate-limit delay still applies under partial failure storms (#3125 Greptile P1).
|
|
699
|
+
if (applyWritesSinceSleep > 0 && applyWritesSinceSleep % batchSize === 0 && delayMs > 0) {
|
|
700
|
+
sleepMs(delayMs);
|
|
701
|
+
}
|
|
702
|
+
applyWritesSinceSleep += 1;
|
|
703
|
+
client.apply(repo, issueNumber, add, []);
|
|
704
|
+
applied += 1;
|
|
705
|
+
items.push({
|
|
706
|
+
repo,
|
|
707
|
+
issue_number: issueNumber,
|
|
708
|
+
state,
|
|
709
|
+
action: classification.action,
|
|
710
|
+
reason: classification.reason,
|
|
711
|
+
ruleKind: classification.ruleKind,
|
|
712
|
+
current: [...current].sort(),
|
|
713
|
+
desired,
|
|
714
|
+
add,
|
|
715
|
+
status: "applied",
|
|
716
|
+
});
|
|
717
|
+
}
|
|
718
|
+
catch (exc) {
|
|
719
|
+
errors += 1;
|
|
720
|
+
const rawMsg = exc instanceof Error ? exc.message : String(exc);
|
|
721
|
+
items.push({
|
|
722
|
+
repo,
|
|
723
|
+
issue_number: issueNumber,
|
|
724
|
+
state,
|
|
725
|
+
action: classification.action,
|
|
726
|
+
reason: classification.reason,
|
|
727
|
+
ruleKind: classification.ruleKind,
|
|
728
|
+
current: [...current].sort(),
|
|
729
|
+
desired,
|
|
730
|
+
add,
|
|
731
|
+
status: "error",
|
|
732
|
+
message: formatMissingLabelHint(rawMsg, add),
|
|
733
|
+
});
|
|
734
|
+
// Partial failure: continue remaining issues (idempotent re-run skips applied).
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
const digest = buildLabelMirrorDigest(items, sampleLimit);
|
|
738
|
+
const outcome = {
|
|
739
|
+
...outcomeBase,
|
|
740
|
+
scanned: pairs.length,
|
|
741
|
+
planned,
|
|
742
|
+
applied,
|
|
743
|
+
unchanged,
|
|
744
|
+
skipped_already_triaged: skippedAlready,
|
|
745
|
+
skipped_no_match: skippedNoMatch,
|
|
746
|
+
skipped_unreadable: skippedUnreadable,
|
|
747
|
+
skipped_closed: skippedClosed,
|
|
748
|
+
skipped_author: skippedAuthor,
|
|
749
|
+
errors,
|
|
750
|
+
digest,
|
|
751
|
+
items,
|
|
752
|
+
};
|
|
753
|
+
return [errors > 0 ? 1 : 0, outcome];
|
|
754
|
+
}
|
|
755
|
+
function formatCountMap(map) {
|
|
756
|
+
const keys = Object.keys(map).sort();
|
|
757
|
+
if (keys.length === 0) {
|
|
758
|
+
return [" (none)"];
|
|
759
|
+
}
|
|
760
|
+
return keys.map((k) => ` ${k}: ${map[k]}`);
|
|
761
|
+
}
|
|
762
|
+
/** Human-readable digest for dry-run / apply reports (bootstrap mass-triage UX). */
|
|
763
|
+
export function renderLabelMirrorReport(outcome) {
|
|
764
|
+
const lines = [];
|
|
765
|
+
const mode = outcome.dry_run ? "dry-run" : "apply";
|
|
766
|
+
lines.push(`triage:classify --mirror (${mode}) — bootstrap mass-triage (#1423 Wave 2)`);
|
|
767
|
+
const stateFilter = outcome.filters.include_closed ? "all (include-closed)" : "open-only";
|
|
768
|
+
const repoPart = outcome.filters.repo ?? "*";
|
|
769
|
+
const authorPart = outcome.filters.author ?? "*";
|
|
770
|
+
lines.push(`filters: state=${stateFilter} repo=${repoPart} author=${authorPart}`);
|
|
771
|
+
lines.push(`scanned=${outcome.scanned} planned=${outcome.planned} applied=${outcome.applied} ` +
|
|
772
|
+
`unchanged=${outcome.unchanged} already_triaged=${outcome.skipped_already_triaged} ` +
|
|
773
|
+
`no_match=${outcome.skipped_no_match} closed_skipped=${outcome.skipped_closed} ` +
|
|
774
|
+
`author_skipped=${outcome.skipped_author} ` +
|
|
775
|
+
`unreadable=${outcome.skipped_unreadable} errors=${outcome.errors}`);
|
|
776
|
+
lines.push(`idempotencyLabel=${outcome.policy.idempotencyLabel} alwaysLabels=${JSON.stringify(outcome.policy.alwaysLabels)}`);
|
|
777
|
+
if (!outcome.dry_run) {
|
|
778
|
+
lines.push(`apply: batch_size=${outcome.batch_size ?? DEFAULT_APPLY_BATCH_SIZE} delay_ms=${outcome.delay_ms ?? DEFAULT_APPLY_DELAY_MS}`);
|
|
779
|
+
}
|
|
780
|
+
lines.push("");
|
|
781
|
+
lines.push("By state (planned/applied):");
|
|
782
|
+
lines.push(...formatCountMap(outcome.digest.by_state));
|
|
783
|
+
lines.push("By rule (planned/applied):");
|
|
784
|
+
lines.push(...formatCountMap(outcome.digest.by_rule));
|
|
785
|
+
lines.push("By action (planned/applied):");
|
|
786
|
+
lines.push(...formatCountMap(outcome.digest.by_action));
|
|
787
|
+
lines.push("");
|
|
788
|
+
const writeTotal = outcome.planned + outcome.applied;
|
|
789
|
+
lines.push(outcome.dry_run
|
|
790
|
+
? `Samples (up to ${outcome.digest.sample_limit} of ${writeTotal} planned):`
|
|
791
|
+
: `Samples (up to ${outcome.digest.sample_limit} of ${writeTotal} applied/planned):`);
|
|
792
|
+
if (outcome.digest.samples.length === 0) {
|
|
793
|
+
lines.push("- none");
|
|
794
|
+
}
|
|
795
|
+
else {
|
|
796
|
+
for (const item of outcome.digest.samples) {
|
|
797
|
+
const actionPart = item.action !== null ? ` action=${item.action}` : "";
|
|
798
|
+
const rulePart = item.ruleKind !== null ? ` rule=${item.ruleKind}` : "";
|
|
799
|
+
const statePart = item.state !== null ? ` state=${item.state}` : "";
|
|
800
|
+
const addPart = sanitizeReportFragment(item.add.join(", +"));
|
|
801
|
+
lines.push(`- ${item.repo}#${item.issue_number}:${statePart}${actionPart}${rulePart} +${addPart}`);
|
|
802
|
+
}
|
|
803
|
+
if (outcome.digest.sample_truncated) {
|
|
804
|
+
const remaining = writeTotal - outcome.digest.samples.length;
|
|
805
|
+
lines.push(`… and ${remaining} more (use --json for full items list)`);
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
if (outcome.skipped_closed > 0) {
|
|
809
|
+
lines.push("");
|
|
810
|
+
lines.push(`Skipped closed (open-only default): ${outcome.skipped_closed} — re-run with --include-closed to include archive`);
|
|
811
|
+
}
|
|
812
|
+
if (outcome.skipped_author > 0) {
|
|
813
|
+
lines.push("");
|
|
814
|
+
lines.push(`Skipped (author filter ${outcome.filters.author ?? "?"}): ${outcome.skipped_author}`);
|
|
815
|
+
}
|
|
816
|
+
const already = outcome.items.filter((i) => i.status === "skipped_already_triaged");
|
|
817
|
+
if (already.length > 0) {
|
|
818
|
+
lines.push("");
|
|
819
|
+
lines.push(`Skipped (already ${outcome.policy.idempotencyLabel}): ${already.length}`);
|
|
820
|
+
}
|
|
821
|
+
const noMatch = outcome.items.filter((i) => i.status === "skipped_no_match");
|
|
822
|
+
if (noMatch.length > 0) {
|
|
823
|
+
lines.push(`Skipped (no classify match): ${noMatch.length}`);
|
|
824
|
+
}
|
|
825
|
+
const errs = outcome.items.filter((i) => i.status === "error");
|
|
826
|
+
if (errs.length > 0) {
|
|
827
|
+
lines.push("");
|
|
828
|
+
lines.push(`Errors (partial failure report; ${errs.length} of ${outcome.scanned}):`);
|
|
829
|
+
for (const item of errs) {
|
|
830
|
+
const msg = sanitizeReportFragment(item.message ?? "unknown error");
|
|
831
|
+
lines.push(`- ${item.repo}#${item.issue_number}: ${msg}`);
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
if (outcome.dry_run && writeTotal > 0) {
|
|
835
|
+
lines.push("");
|
|
836
|
+
lines.push("Dry-run — re-run with --mirror --apply to write these labels via SCM (batched; never triage:accept).");
|
|
837
|
+
}
|
|
838
|
+
return `${lines.join("\n")}\n`;
|
|
839
|
+
}
|
|
840
|
+
/** JSON-serializable outcome including Wave 2 digest aggregates. */
|
|
841
|
+
export function labelMirrorOutcomeToJson(outcome) {
|
|
842
|
+
return {
|
|
843
|
+
project_root: outcome.project_root,
|
|
844
|
+
dry_run: outcome.dry_run,
|
|
845
|
+
scanned: outcome.scanned,
|
|
846
|
+
planned: outcome.planned,
|
|
847
|
+
applied: outcome.applied,
|
|
848
|
+
unchanged: outcome.unchanged,
|
|
849
|
+
skipped_already_triaged: outcome.skipped_already_triaged,
|
|
850
|
+
skipped_no_match: outcome.skipped_no_match,
|
|
851
|
+
skipped_unreadable: outcome.skipped_unreadable,
|
|
852
|
+
skipped_closed: outcome.skipped_closed,
|
|
853
|
+
skipped_author: outcome.skipped_author,
|
|
854
|
+
errors: outcome.errors,
|
|
855
|
+
filters: {
|
|
856
|
+
include_closed: outcome.filters.include_closed,
|
|
857
|
+
repo: outcome.filters.repo,
|
|
858
|
+
author: outcome.filters.author,
|
|
859
|
+
author_logins: outcome.filters.author_logins,
|
|
860
|
+
},
|
|
861
|
+
digest: {
|
|
862
|
+
by_state: { ...outcome.digest.by_state },
|
|
863
|
+
by_rule: { ...outcome.digest.by_rule },
|
|
864
|
+
by_action: { ...outcome.digest.by_action },
|
|
865
|
+
sample_limit: outcome.digest.sample_limit,
|
|
866
|
+
sample_truncated: outcome.digest.sample_truncated,
|
|
867
|
+
samples: outcome.digest.samples.map((i) => itemToJson(i)),
|
|
868
|
+
},
|
|
869
|
+
...(outcome.batch_size !== undefined ? { batch_size: outcome.batch_size } : {}),
|
|
870
|
+
...(outcome.delay_ms !== undefined ? { delay_ms: outcome.delay_ms } : {}),
|
|
871
|
+
policy: {
|
|
872
|
+
enabled: outcome.policy.enabled,
|
|
873
|
+
idempotencyLabel: outcome.policy.idempotencyLabel,
|
|
874
|
+
alwaysLabels: [...outcome.policy.alwaysLabels],
|
|
875
|
+
actionLabels: Object.fromEntries(Object.entries(outcome.policy.actionLabels).map(([k, v]) => [k, [...(v ?? [])]])),
|
|
876
|
+
},
|
|
877
|
+
items: outcome.items.map((i) => itemToJson(i)),
|
|
878
|
+
};
|
|
879
|
+
}
|
|
880
|
+
function itemToJson(i) {
|
|
881
|
+
return {
|
|
882
|
+
repo: i.repo,
|
|
883
|
+
issue_number: i.issue_number,
|
|
884
|
+
state: i.state,
|
|
885
|
+
action: i.action,
|
|
886
|
+
reason: i.reason,
|
|
887
|
+
ruleKind: i.ruleKind,
|
|
888
|
+
current: [...i.current],
|
|
889
|
+
desired: [...i.desired],
|
|
890
|
+
add: [...i.add],
|
|
891
|
+
status: i.status,
|
|
892
|
+
...(i.message !== undefined ? { message: i.message } : {}),
|
|
893
|
+
};
|
|
894
|
+
}
|
|
895
|
+
/** Validate triageLabelMirror on a plan object (vbrief_validate hook). */
|
|
896
|
+
export function validateTriageLabelMirrorOnPlan(plan, filepath) {
|
|
897
|
+
const out = [];
|
|
898
|
+
if (typeof plan !== "object" || plan === null || Array.isArray(plan)) {
|
|
899
|
+
return out;
|
|
900
|
+
}
|
|
901
|
+
const policy = readPlanPolicy(plan);
|
|
902
|
+
const raw = typeof policy === "object" && policy !== null && !Array.isArray(policy)
|
|
903
|
+
? policy.triageLabelMirror
|
|
904
|
+
: undefined;
|
|
905
|
+
if (raw === undefined || raw === null) {
|
|
906
|
+
return out;
|
|
907
|
+
}
|
|
908
|
+
const { errors } = validateLabelMirrorPolicy(raw);
|
|
909
|
+
for (const err of errors) {
|
|
910
|
+
out.push(`${filepath}: ${err} (#1423)`);
|
|
911
|
+
}
|
|
912
|
+
return out;
|
|
913
|
+
}
|
|
914
|
+
//# sourceMappingURL=label-mirror.js.map
|