@indigoai-us/hq-cli 5.222.0 → 5.224.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/CHANGELOG.md +24 -1
- package/dist/command-catalog.generated.d.ts +24 -0
- package/dist/command-catalog.generated.js +31 -0
- package/dist/commands/lanes-import.d.ts +15 -0
- package/dist/commands/lanes-import.js +1213 -0
- package/dist/commands/lanes.js +164 -50
- package/dist/lib/daemon/hq-cloud.d.ts +2 -0
- package/dist/lib/daemon/hq-cloud.js +7 -1
- package/dist/lib/daemon/run.js +8 -0
- package/dist/lib/lanes/reconcile.js +1 -1
- package/dist/lib/lanes/spawn.js +62 -11
- package/dist/lib/lanes/types.d.ts +3 -3
- package/dist/lib/lanes/watch.d.ts +18 -1
- package/dist/lib/lanes/watch.js +122 -2
- package/package.json +1 -1
|
@@ -0,0 +1,1213 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { isDeepStrictEqual } from "node:util";
|
|
5
|
+
import { LanesError } from "../lib/lanes/errors.js";
|
|
6
|
+
import { assertNoSecretText } from "../lib/lanes/text-scan.js";
|
|
7
|
+
import { createLocalTrackerStore, } from "../lib/lanes/tracker-store.js";
|
|
8
|
+
import { LANES_TRACKER_FLAG, LANES_TRACKER_MESH_FLAG } from "./lanes-tracker.js";
|
|
9
|
+
const IMPORT_DATE = "2026-09-25";
|
|
10
|
+
const IMPORT_KEY_PREFIX = "trk7";
|
|
11
|
+
const MAX_TEXT_BYTES = 32_000;
|
|
12
|
+
function fail(error, message, details = {}) {
|
|
13
|
+
throw new LanesError(error, message, details);
|
|
14
|
+
}
|
|
15
|
+
function isObject(value) {
|
|
16
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
17
|
+
}
|
|
18
|
+
function within(root, candidate) {
|
|
19
|
+
const relative = path.relative(root, candidate);
|
|
20
|
+
return relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative));
|
|
21
|
+
}
|
|
22
|
+
function validSegment(value, field) {
|
|
23
|
+
const normalized = value.trim();
|
|
24
|
+
if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/.test(normalized) || normalized === "." || normalized === "..") {
|
|
25
|
+
return fail("tracker_import_input_invalid", `${field} must be a safe path segment`, { field });
|
|
26
|
+
}
|
|
27
|
+
return normalized;
|
|
28
|
+
}
|
|
29
|
+
function relativeSource(root, absolutePath) {
|
|
30
|
+
return path.relative(root, absolutePath).split(path.sep).join("/");
|
|
31
|
+
}
|
|
32
|
+
function resolveSourcePath(root, raw, field) {
|
|
33
|
+
if (typeof raw !== "string" || !raw.trim()) {
|
|
34
|
+
return fail("tracker_import_input_invalid", `${field} must be a path`, { field });
|
|
35
|
+
}
|
|
36
|
+
const lexicalRoot = path.resolve(root);
|
|
37
|
+
const candidate = path.resolve(lexicalRoot, raw);
|
|
38
|
+
if (!within(lexicalRoot, candidate)) {
|
|
39
|
+
return fail("tracker_import_path_outside_root", `${field} resolves outside the HQ root`, { field, path: candidate });
|
|
40
|
+
}
|
|
41
|
+
try {
|
|
42
|
+
const rootReal = fs.realpathSync(root);
|
|
43
|
+
const real = fs.realpathSync(candidate);
|
|
44
|
+
if (!within(rootReal, real)) {
|
|
45
|
+
return fail("tracker_import_path_outside_root", `${field} resolves outside the HQ root`, { field, path: real });
|
|
46
|
+
}
|
|
47
|
+
return real;
|
|
48
|
+
}
|
|
49
|
+
catch (err) {
|
|
50
|
+
if (err.code === "ENOENT")
|
|
51
|
+
return candidate;
|
|
52
|
+
if (err instanceof LanesError)
|
|
53
|
+
throw err;
|
|
54
|
+
return fail("tracker_import_path_unreadable", `${field} could not be resolved`, { field, path: candidate });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
function readText(file, source) {
|
|
58
|
+
try {
|
|
59
|
+
const stat = fs.lstatSync(file);
|
|
60
|
+
if (!stat.isFile() || stat.isSymbolicLink()) {
|
|
61
|
+
return fail("tracker_import_input_invalid", `${source} must be a regular file`, { path: file, source });
|
|
62
|
+
}
|
|
63
|
+
return fs.readFileSync(file, "utf8");
|
|
64
|
+
}
|
|
65
|
+
catch (err) {
|
|
66
|
+
if (err instanceof LanesError)
|
|
67
|
+
throw err;
|
|
68
|
+
const code = err.code;
|
|
69
|
+
if (code === "ENOENT")
|
|
70
|
+
return fail("tracker_import_input_missing", `${source} file is missing`, { path: file, source });
|
|
71
|
+
return fail("tracker_import_input_unreadable", `${source} file could not be read`, { path: file, source });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
function readJsonObject(file, source) {
|
|
75
|
+
const text = readText(file, source);
|
|
76
|
+
let parsed;
|
|
77
|
+
try {
|
|
78
|
+
parsed = JSON.parse(text);
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return fail("tracker_import_input_malformed", `${source} file contains invalid JSON`, { path: file, source });
|
|
82
|
+
}
|
|
83
|
+
if (!isObject(parsed)) {
|
|
84
|
+
return fail("tracker_import_input_invalid", `${source} file must contain a JSON object`, { path: file, source });
|
|
85
|
+
}
|
|
86
|
+
return parsed;
|
|
87
|
+
}
|
|
88
|
+
function companyFromPrd(prd, file) {
|
|
89
|
+
const metadata = isObject(prd.metadata) ? prd.metadata : undefined;
|
|
90
|
+
const raw = prd.company ?? metadata?.company;
|
|
91
|
+
if (raw === undefined || raw === null)
|
|
92
|
+
return null;
|
|
93
|
+
if (typeof raw === "string")
|
|
94
|
+
return raw;
|
|
95
|
+
if (isObject(raw) && typeof raw.slug === "string")
|
|
96
|
+
return raw.slug;
|
|
97
|
+
return fail("tracker_import_prd_invalid", "Project company metadata must name a company slug", { path: file });
|
|
98
|
+
}
|
|
99
|
+
function resolveCompanyPrd(root, company, rawPath) {
|
|
100
|
+
const candidate = resolveSourcePath(root, rawPath, "prd");
|
|
101
|
+
const rootReal = fs.realpathSync(root);
|
|
102
|
+
const candidateParts = path.relative(rootReal, candidate).split(path.sep);
|
|
103
|
+
if (candidateParts[0] !== "companies" || candidateParts[1] !== company || candidateParts[2] !== "projects" || candidateParts.length !== 5 || candidateParts[4] !== "prd.json") {
|
|
104
|
+
return fail("tracker_import_scope_refused", "Team prd.json must belong to the selected company's project", { path: candidate, company });
|
|
105
|
+
}
|
|
106
|
+
let real;
|
|
107
|
+
try {
|
|
108
|
+
const stat = fs.lstatSync(candidate);
|
|
109
|
+
if (!stat.isFile() || stat.isSymbolicLink()) {
|
|
110
|
+
return fail("tracker_import_input_invalid", "Team prd.json must be a regular file", { path: candidate, source: "prd" });
|
|
111
|
+
}
|
|
112
|
+
real = fs.realpathSync(candidate);
|
|
113
|
+
}
|
|
114
|
+
catch (err) {
|
|
115
|
+
if (err instanceof LanesError)
|
|
116
|
+
throw err;
|
|
117
|
+
if (err.code === "ENOENT") {
|
|
118
|
+
return fail("tracker_import_input_missing", "Team prd.json file is missing", { path: candidate, source: "prd" });
|
|
119
|
+
}
|
|
120
|
+
return fail("tracker_import_input_unreadable", "Team prd.json path could not be resolved", { path: candidate, source: "prd" });
|
|
121
|
+
}
|
|
122
|
+
if (!within(rootReal, real)) {
|
|
123
|
+
return fail("tracker_import_scope_refused", "Team prd.json resolves outside this HQ root", { path: real });
|
|
124
|
+
}
|
|
125
|
+
const parts = path.relative(rootReal, real).split(path.sep);
|
|
126
|
+
if (parts[0] !== "companies" || parts[1] !== company || parts[2] !== "projects" || parts.length !== 5 || parts[4] !== "prd.json") {
|
|
127
|
+
return fail("tracker_import_scope_refused", "Team prd.json must belong to the selected company's project", { path: real, company });
|
|
128
|
+
}
|
|
129
|
+
const projectId = validSegment(parts[3], "project");
|
|
130
|
+
return { path: real, projectId };
|
|
131
|
+
}
|
|
132
|
+
function resolveStatusPath(root, company, rawPath) {
|
|
133
|
+
const candidate = resolveSourcePath(root, rawPath, "status");
|
|
134
|
+
let real;
|
|
135
|
+
try {
|
|
136
|
+
const stat = fs.lstatSync(candidate);
|
|
137
|
+
if (!stat.isFile() || stat.isSymbolicLink()) {
|
|
138
|
+
return fail("tracker_import_input_invalid", "Team status must be a regular file", { path: candidate, source: "status" });
|
|
139
|
+
}
|
|
140
|
+
real = fs.realpathSync(candidate);
|
|
141
|
+
}
|
|
142
|
+
catch (err) {
|
|
143
|
+
if (err instanceof LanesError)
|
|
144
|
+
throw err;
|
|
145
|
+
if (err.code === "ENOENT") {
|
|
146
|
+
return fail("tracker_import_input_missing", "Team status file is missing", { path: candidate, source: "status" });
|
|
147
|
+
}
|
|
148
|
+
return fail("tracker_import_input_unreadable", "Team status path could not be resolved", { path: candidate, source: "status" });
|
|
149
|
+
}
|
|
150
|
+
const rootReal = fs.realpathSync(root);
|
|
151
|
+
const parts = path.relative(rootReal, real).split(path.sep);
|
|
152
|
+
if (!within(rootReal, real) || (parts[0] === "companies" && parts[1] !== company)) {
|
|
153
|
+
return fail("tracker_import_scope_refused", "Team status resolves outside the selected company's HQ root", { path: real, company });
|
|
154
|
+
}
|
|
155
|
+
return real;
|
|
156
|
+
}
|
|
157
|
+
function requireStoryList(prd, file) {
|
|
158
|
+
const raw = prd.userStories === undefined ? prd.stories : prd.userStories;
|
|
159
|
+
if (!Array.isArray(raw)) {
|
|
160
|
+
return fail("tracker_import_prd_invalid", "Project prd.json must contain a userStories or stories array", { path: file });
|
|
161
|
+
}
|
|
162
|
+
return raw.map((story, index) => {
|
|
163
|
+
if (!isObject(story)) {
|
|
164
|
+
return fail("tracker_import_prd_invalid", "Project story must be an object", { path: file, index });
|
|
165
|
+
}
|
|
166
|
+
return story;
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
function safeSourceId(value, fallback) {
|
|
170
|
+
if (typeof value === "string" && /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/.test(value) && value !== "." && value !== "..") {
|
|
171
|
+
return `id:${value}`;
|
|
172
|
+
}
|
|
173
|
+
return fallback;
|
|
174
|
+
}
|
|
175
|
+
function digest(value) {
|
|
176
|
+
return createHash("sha256").update(JSON.stringify(value)).digest("hex").slice(0, 24);
|
|
177
|
+
}
|
|
178
|
+
function importKey(team, sourcePath, sourceItemId) {
|
|
179
|
+
return `${IMPORT_KEY_PREFIX}:${team}:${sourcePath}:${sourceItemId}`;
|
|
180
|
+
}
|
|
181
|
+
function importedRecordId(candidate) {
|
|
182
|
+
return `trk7-${createHash("sha256").update(candidate.importKey).digest("hex")}`;
|
|
183
|
+
}
|
|
184
|
+
function normalizeName(value) {
|
|
185
|
+
return value.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim().replace(/\s+/g, " ");
|
|
186
|
+
}
|
|
187
|
+
function finiteNumber(raw) {
|
|
188
|
+
if (typeof raw === "number")
|
|
189
|
+
return Number.isFinite(raw) ? raw : null;
|
|
190
|
+
if (typeof raw !== "string" || !/^-?(?:\d+(?:\.\d*)?|\.\d+)$/.test(raw.trim()))
|
|
191
|
+
return null;
|
|
192
|
+
const parsed = Number(raw);
|
|
193
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
194
|
+
}
|
|
195
|
+
function isoTimestamp(raw) {
|
|
196
|
+
if (typeof raw !== "string" || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d{1,3})?)?(?:Z|[+-]\d{2}:\d{2})$/.test(raw))
|
|
197
|
+
return null;
|
|
198
|
+
const parsed = new Date(raw);
|
|
199
|
+
return Number.isFinite(parsed.getTime()) ? parsed.toISOString() : null;
|
|
200
|
+
}
|
|
201
|
+
function sourceTimestamp(raw) {
|
|
202
|
+
const timestamp = isoTimestamp(raw);
|
|
203
|
+
if (timestamp)
|
|
204
|
+
return timestamp;
|
|
205
|
+
if (typeof raw === "string" && /^\d{4}-\d{2}-\d{2}$/.test(raw.trim())) {
|
|
206
|
+
const date = dateFrom(raw);
|
|
207
|
+
return date ? `${date}T00:00:00.000Z` : null;
|
|
208
|
+
}
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
function dateFrom(raw) {
|
|
212
|
+
if (typeof raw !== "string")
|
|
213
|
+
return null;
|
|
214
|
+
const match = /^(\d{4}-\d{2}-\d{2})/.exec(raw.trim());
|
|
215
|
+
if (!match)
|
|
216
|
+
return null;
|
|
217
|
+
const parsed = new Date(`${match[1]}T00:00:00.000Z`);
|
|
218
|
+
return Number.isFinite(parsed.getTime()) && parsed.toISOString().slice(0, 10) === match[1] ? match[1] : null;
|
|
219
|
+
}
|
|
220
|
+
function dateInText(raw) {
|
|
221
|
+
const match = /\b(\d{4}-\d{2}-\d{2})\b/.exec(raw);
|
|
222
|
+
return match ? dateFrom(match[1]) : null;
|
|
223
|
+
}
|
|
224
|
+
function direction(raw) {
|
|
225
|
+
if (typeof raw !== "string")
|
|
226
|
+
return null;
|
|
227
|
+
const normalized = raw.trim().toLowerCase();
|
|
228
|
+
if (/^(?:up|higher|increase|increasing|more|greater|fewer errors prevented)$/i.test(normalized))
|
|
229
|
+
return "up";
|
|
230
|
+
if (/^(?:down|lower|decrease|decreasing|less|fewer|reduce)$/i.test(normalized))
|
|
231
|
+
return "down";
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
function addSkip(result, entry) {
|
|
235
|
+
result.skipped.push(entry);
|
|
236
|
+
}
|
|
237
|
+
function addSecretError(result, field, category, sourcePath, kind) {
|
|
238
|
+
result.errors.push({ error: "secret_text_refused", field, category, source_path: sourcePath });
|
|
239
|
+
addSkip(result, { kind, source_path: sourcePath, reason: "secret_text_refused", field, category });
|
|
240
|
+
}
|
|
241
|
+
function checkedText(result, kind, sourcePath, sourceItemId, field, raw, options = {}) {
|
|
242
|
+
if (typeof raw !== "string" || !raw.trim()) {
|
|
243
|
+
addSkip(result, { kind, source_path: sourcePath, source_item_id: sourceItemId, reason: "missing_or_empty_field", field });
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
if (Buffer.byteLength(raw, "utf8") > MAX_TEXT_BYTES) {
|
|
247
|
+
addSkip(result, { kind, source_path: sourcePath, source_item_id: sourceItemId, reason: "field_too_long", field });
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
try {
|
|
251
|
+
assertNoSecretText(field, raw);
|
|
252
|
+
}
|
|
253
|
+
catch (err) {
|
|
254
|
+
const category = err instanceof LanesError && typeof err.details.kind === "string" ? err.details.kind : "sensitive_text";
|
|
255
|
+
addSecretError(result, field, category, sourcePath, kind);
|
|
256
|
+
return null;
|
|
257
|
+
}
|
|
258
|
+
return options.preserveWhitespace ? raw : raw.trim();
|
|
259
|
+
}
|
|
260
|
+
function recordCandidate(team, kind, sourcePath, sourceItemId, managed, createOnly = {}, warnings = []) {
|
|
261
|
+
const key = importKey(team, sourcePath, sourceItemId);
|
|
262
|
+
return {
|
|
263
|
+
kind,
|
|
264
|
+
importKey: key,
|
|
265
|
+
sourcePath,
|
|
266
|
+
sourceItemId,
|
|
267
|
+
managed: { ...managed, import_key: key, source_path: sourcePath, source_item_id: sourceItemId },
|
|
268
|
+
createOnly,
|
|
269
|
+
warnings,
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
function uniqueCandidates(candidates, result) {
|
|
273
|
+
const counts = new Map();
|
|
274
|
+
for (const candidate of candidates)
|
|
275
|
+
counts.set(candidate.importKey, (counts.get(candidate.importKey) ?? 0) + 1);
|
|
276
|
+
const reported = new Set();
|
|
277
|
+
const unique = [];
|
|
278
|
+
for (const candidate of candidates) {
|
|
279
|
+
if (counts.get(candidate.importKey) === 1) {
|
|
280
|
+
unique.push(candidate);
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
if (reported.has(candidate.importKey))
|
|
284
|
+
continue;
|
|
285
|
+
reported.add(candidate.importKey);
|
|
286
|
+
result.errors.push({
|
|
287
|
+
error: "tracker_import_duplicate_source_key",
|
|
288
|
+
path: candidate.sourcePath,
|
|
289
|
+
source_item_id: candidate.sourceItemId,
|
|
290
|
+
});
|
|
291
|
+
addSkip(result, {
|
|
292
|
+
kind: candidate.kind,
|
|
293
|
+
source_path: candidate.sourcePath,
|
|
294
|
+
source_item_id: candidate.sourceItemId,
|
|
295
|
+
reason: "duplicate_source_key",
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
return unique;
|
|
299
|
+
}
|
|
300
|
+
function kpiEntries(raw) {
|
|
301
|
+
if (Array.isArray(raw))
|
|
302
|
+
return raw.map((value, index) => ({ value, hint: "", index }));
|
|
303
|
+
if (!isObject(raw))
|
|
304
|
+
return null;
|
|
305
|
+
const metricKeys = ["name", "value", "target", "unit", "direction", "better", "measured_at"];
|
|
306
|
+
if (metricKeys.some((key) => key in raw))
|
|
307
|
+
return [{ value: raw, hint: "", index: 0 }];
|
|
308
|
+
return Object.entries(raw).map(([hint, value], index) => ({ value, hint, index }));
|
|
309
|
+
}
|
|
310
|
+
function parseKpis(status, result, team, statusSource) {
|
|
311
|
+
const candidates = [];
|
|
312
|
+
const seenNames = new Set();
|
|
313
|
+
const groups = [
|
|
314
|
+
{ field: "kpis", value: status.kpis },
|
|
315
|
+
{ field: "kpi", value: status.kpi },
|
|
316
|
+
{ field: "kpi_headline", value: status.kpi_headline },
|
|
317
|
+
];
|
|
318
|
+
for (const group of groups) {
|
|
319
|
+
if (group.value === undefined || group.value === null)
|
|
320
|
+
continue;
|
|
321
|
+
const entries = kpiEntries(group.value);
|
|
322
|
+
if (entries === null) {
|
|
323
|
+
addSkip(result, { kind: "kpi", source_path: statusSource, source_item_id: group.field, reason: "unsupported_kpi_shape" });
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
for (const entry of entries) {
|
|
327
|
+
const itemId = isObject(entry.value)
|
|
328
|
+
? safeSourceId(entry.value.id, `hash:${digest({ name: entry.value.name ?? entry.hint, unit: entry.value.unit })}`)
|
|
329
|
+
: `index:${entry.index}`;
|
|
330
|
+
if (!isObject(entry.value)) {
|
|
331
|
+
addSkip(result, { kind: "kpi", source_path: statusSource, source_item_id: itemId, reason: "unsupported_kpi_item_shape" });
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
const raw = entry.value;
|
|
335
|
+
const sourceItemId = `${group.field}:${itemId}`;
|
|
336
|
+
const name = checkedText(result, "kpi", statusSource, sourceItemId, "name", raw.name ?? entry.hint);
|
|
337
|
+
if (!name)
|
|
338
|
+
continue;
|
|
339
|
+
const duplicateKey = normalizeName(name);
|
|
340
|
+
if (seenNames.has(duplicateKey)) {
|
|
341
|
+
addSkip(result, { kind: "kpi", source_path: statusSource, source_item_id: sourceItemId, reason: "duplicate_kpi_name" });
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
const unit = checkedText(result, "kpi", statusSource, sourceItemId, "unit", raw.unit);
|
|
345
|
+
const definitionRaw = raw.definition ?? raw.detail ?? raw.note ?? name;
|
|
346
|
+
const definition = checkedText(result, "kpi", statusSource, sourceItemId, "definition", definitionRaw);
|
|
347
|
+
const target = finiteNumber(raw.target);
|
|
348
|
+
const value = finiteNumber(raw.value);
|
|
349
|
+
const measuredAt = isoTimestamp(raw.measured_at);
|
|
350
|
+
const better = direction(raw.direction ?? raw.better);
|
|
351
|
+
const missing = [];
|
|
352
|
+
if (!unit)
|
|
353
|
+
missing.push("unit");
|
|
354
|
+
if (target === null)
|
|
355
|
+
missing.push("target");
|
|
356
|
+
if (value === null)
|
|
357
|
+
missing.push("value");
|
|
358
|
+
if (!measuredAt)
|
|
359
|
+
missing.push("measured_at");
|
|
360
|
+
if (!better)
|
|
361
|
+
missing.push("direction_or_better");
|
|
362
|
+
if (!definition)
|
|
363
|
+
missing.push("definition");
|
|
364
|
+
if (missing.length) {
|
|
365
|
+
addSkip(result, { kind: "kpi", source_path: statusSource, source_item_id: sourceItemId, reason: "missing_or_invalid_fields", fields: missing });
|
|
366
|
+
continue;
|
|
367
|
+
}
|
|
368
|
+
seenNames.add(duplicateKey);
|
|
369
|
+
const managed = {
|
|
370
|
+
name,
|
|
371
|
+
definition,
|
|
372
|
+
unit,
|
|
373
|
+
target,
|
|
374
|
+
direction: better,
|
|
375
|
+
values: [{
|
|
376
|
+
at: measuredAt,
|
|
377
|
+
value,
|
|
378
|
+
source: statusSource,
|
|
379
|
+
import_key: importKey(team, statusSource, sourceItemId),
|
|
380
|
+
}],
|
|
381
|
+
};
|
|
382
|
+
candidates.push(recordCandidate(team, "kpi", statusSource, sourceItemId, managed, {
|
|
383
|
+
status: "proposed",
|
|
384
|
+
approved_by: null,
|
|
385
|
+
approved_at: null,
|
|
386
|
+
}));
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
if (groups.every((group) => group.value === undefined || group.value === null)) {
|
|
390
|
+
addSkip(result, { kind: "kpi", source_path: statusSource, reason: "no_kpi_field" });
|
|
391
|
+
}
|
|
392
|
+
return candidates;
|
|
393
|
+
}
|
|
394
|
+
function entriesForField(raw) {
|
|
395
|
+
if (Array.isArray(raw))
|
|
396
|
+
return raw.map((value, index) => ({ value, index }));
|
|
397
|
+
if (isObject(raw))
|
|
398
|
+
return [{ value: raw, index: 0 }];
|
|
399
|
+
if (typeof raw === "string")
|
|
400
|
+
return [{ value: raw, index: 0 }];
|
|
401
|
+
return null;
|
|
402
|
+
}
|
|
403
|
+
function closedBlocker(raw) {
|
|
404
|
+
const state = raw.status ?? raw.state;
|
|
405
|
+
return raw.closed === true || raw.resolved === true || raw.closed_at != null || raw.resolved_at != null ||
|
|
406
|
+
(typeof state === "string" && /^(closed|resolved|done|dropped)$/i.test(state));
|
|
407
|
+
}
|
|
408
|
+
function parseStatusBlockers(status, result, team, statusSource, now) {
|
|
409
|
+
const candidates = [];
|
|
410
|
+
const groups = ["blockers", "blocked"];
|
|
411
|
+
let found = false;
|
|
412
|
+
for (const field of groups) {
|
|
413
|
+
const raw = status[field];
|
|
414
|
+
if (raw === undefined || raw === null)
|
|
415
|
+
continue;
|
|
416
|
+
found = true;
|
|
417
|
+
const entries = entriesForField(raw);
|
|
418
|
+
if (!entries) {
|
|
419
|
+
addSkip(result, { kind: "blocker", source_path: statusSource, source_item_id: field, reason: "unsupported_blocker_shape" });
|
|
420
|
+
continue;
|
|
421
|
+
}
|
|
422
|
+
for (const entry of entries) {
|
|
423
|
+
const object = isObject(entry.value) ? entry.value : null;
|
|
424
|
+
const itemId = object
|
|
425
|
+
? safeSourceId(object.id, `hash:${digest({ what: object.what ?? object.item ?? object.reason, story: object.story_id })}`)
|
|
426
|
+
: `index:${entry.index}`;
|
|
427
|
+
const sourceItemId = `${field}:${itemId}`;
|
|
428
|
+
const sourceClosed = object !== null && closedBlocker(object);
|
|
429
|
+
const rawWhat = typeof entry.value === "string" ? entry.value : object?.what ?? object?.item ?? object?.reason ?? object?.note;
|
|
430
|
+
const rawWaiting = object?.waiting_on ?? object?.blocked_on ?? object?.owner;
|
|
431
|
+
const rawNext = object?.next_step ?? object?.next;
|
|
432
|
+
const what = sourceClosed && rawWhat == null ? null : checkedText(result, "blocker", statusSource, sourceItemId, "what", rawWhat);
|
|
433
|
+
const waitingOn = sourceClosed && rawWaiting == null ? null : rawWaiting === undefined ? null : checkedText(result, "blocker", statusSource, sourceItemId, "waiting_on", rawWaiting);
|
|
434
|
+
const nextStep = sourceClosed && rawNext == null ? null : rawNext === undefined ? null : checkedText(result, "blocker", statusSource, sourceItemId, "next_step", rawNext);
|
|
435
|
+
const storyIdRaw = object?.story_id ?? object?.story;
|
|
436
|
+
const storyId = typeof storyIdRaw === "string" && storyIdRaw.trim() ? validSegment(storyIdRaw, "story id") : null;
|
|
437
|
+
const openedAt = dateFrom(object?.opened_at ?? object?.since ?? object?.added ?? object?.at);
|
|
438
|
+
const missing = [
|
|
439
|
+
...(!sourceClosed && !what ? ["what"] : []),
|
|
440
|
+
...(!sourceClosed && !waitingOn ? ["waiting_on"] : []),
|
|
441
|
+
...(!sourceClosed && !nextStep ? ["next_step"] : []),
|
|
442
|
+
...(!sourceClosed && !storyId ? ["story_id"] : []),
|
|
443
|
+
...(!sourceClosed && !openedAt ? ["opened_at"] : []),
|
|
444
|
+
];
|
|
445
|
+
if (missing.length) {
|
|
446
|
+
addSkip(result, { kind: "blocker", source_path: statusSource, source_item_id: sourceItemId, reason: "missing_required_blocker_fields", fields: missing });
|
|
447
|
+
continue;
|
|
448
|
+
}
|
|
449
|
+
const managed = {};
|
|
450
|
+
if (what)
|
|
451
|
+
managed.what = what;
|
|
452
|
+
if (waitingOn)
|
|
453
|
+
managed.waiting_on = waitingOn;
|
|
454
|
+
if (nextStep)
|
|
455
|
+
managed.next_step = nextStep;
|
|
456
|
+
if (storyId)
|
|
457
|
+
managed.story_id = storyId;
|
|
458
|
+
if (openedAt)
|
|
459
|
+
managed.opened_at = `${openedAt}T00:00:00.000Z`;
|
|
460
|
+
let closedSourceWithoutTimestamp = false;
|
|
461
|
+
if (sourceClosed) {
|
|
462
|
+
const rawResolvedAt = object?.resolved_at ?? object?.closed_at;
|
|
463
|
+
closedSourceWithoutTimestamp = rawResolvedAt === undefined || rawResolvedAt === null;
|
|
464
|
+
const resolvedAt = closedSourceWithoutTimestamp ? now().toISOString() : sourceTimestamp(rawResolvedAt);
|
|
465
|
+
if (!resolvedAt) {
|
|
466
|
+
addSkip(result, { kind: "blocker", source_path: statusSource, source_item_id: sourceItemId, reason: "invalid_resolution_timestamp", field: "closed_at" });
|
|
467
|
+
continue;
|
|
468
|
+
}
|
|
469
|
+
const resolution = checkedText(result, "blocker", statusSource, sourceItemId, "resolution", object?.resolution ?? "Closed in source status");
|
|
470
|
+
if (!resolution)
|
|
471
|
+
continue;
|
|
472
|
+
managed.resolved_at = resolvedAt;
|
|
473
|
+
managed.resolution = resolution;
|
|
474
|
+
}
|
|
475
|
+
else {
|
|
476
|
+
managed.resolved_at = null;
|
|
477
|
+
managed.resolution = null;
|
|
478
|
+
}
|
|
479
|
+
const candidate = recordCandidate(team, "blocker", statusSource, sourceItemId, managed);
|
|
480
|
+
if (sourceClosed) {
|
|
481
|
+
candidate.closedSource = true;
|
|
482
|
+
candidate.closedSourceWithoutTimestamp = closedSourceWithoutTimestamp;
|
|
483
|
+
}
|
|
484
|
+
candidates.push(candidate);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
if (!found)
|
|
488
|
+
addSkip(result, { kind: "blocker", source_path: statusSource, reason: "no_blocker_field" });
|
|
489
|
+
return candidates;
|
|
490
|
+
}
|
|
491
|
+
function parseBlockedStories(stories, result, team, prdSource) {
|
|
492
|
+
const candidates = [];
|
|
493
|
+
for (const [index, story] of stories.entries()) {
|
|
494
|
+
const state = typeof story.state === "string" ? story.state : story.status;
|
|
495
|
+
if (typeof state !== "string" || state.toLowerCase() !== "blocked")
|
|
496
|
+
continue;
|
|
497
|
+
const rawId = story.id;
|
|
498
|
+
const storyId = typeof rawId === "string" && rawId.trim() ? validSegment(rawId, "story id") : null;
|
|
499
|
+
const sourceItemId = `story:${storyId ?? `index-${index}`}`;
|
|
500
|
+
const what = checkedText(result, "blocker", prdSource, sourceItemId, "what", story.title ?? story.description);
|
|
501
|
+
const rawWaiting = story.waiting_on ?? story.blocked_on;
|
|
502
|
+
const waitingOn = rawWaiting === undefined ? null : checkedText(result, "blocker", prdSource, sourceItemId, "waiting_on", rawWaiting);
|
|
503
|
+
const notes = isObject(story.notes) ? story.notes : undefined;
|
|
504
|
+
const rawNext = story.next_step ?? notes?.next_step;
|
|
505
|
+
const nextStep = rawNext === undefined ? null : checkedText(result, "blocker", prdSource, sourceItemId, "next_step", rawNext);
|
|
506
|
+
const opened = dateFrom(story.created_at ?? story.updated_at);
|
|
507
|
+
const missing = [
|
|
508
|
+
...(!storyId ? ["story_id"] : []),
|
|
509
|
+
...(what ? [] : ["what"]),
|
|
510
|
+
...(waitingOn ? [] : ["waiting_on"]),
|
|
511
|
+
...(nextStep ? [] : ["next_step"]),
|
|
512
|
+
...(!opened ? ["opened_at"] : []),
|
|
513
|
+
];
|
|
514
|
+
if (missing.length) {
|
|
515
|
+
addSkip(result, { kind: "blocker", source_path: prdSource, source_item_id: sourceItemId, reason: "missing_required_blocker_fields", fields: missing });
|
|
516
|
+
continue;
|
|
517
|
+
}
|
|
518
|
+
const managed = { story_id: storyId, what, waiting_on: waitingOn, next_step: nextStep, opened_at: `${opened}T00:00:00.000Z`, resolved_at: null, resolution: null };
|
|
519
|
+
candidates.push(recordCandidate(team, "blocker", prdSource, sourceItemId, managed));
|
|
520
|
+
}
|
|
521
|
+
return candidates;
|
|
522
|
+
}
|
|
523
|
+
function markdownGoalDrafts(text, sourcePath) {
|
|
524
|
+
const lines = text.split(/\r?\n/);
|
|
525
|
+
const objectives = [];
|
|
526
|
+
const pendingKeyResults = [];
|
|
527
|
+
let inKpis = false;
|
|
528
|
+
let inGoalCriteria = false;
|
|
529
|
+
let goalHeadingPending = false;
|
|
530
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
531
|
+
const line = lines[index] ?? "";
|
|
532
|
+
const trimmed = line.trim();
|
|
533
|
+
const heading = /^#{1,6}\s+(.+)$/.exec(trimmed)?.[1];
|
|
534
|
+
if (heading) {
|
|
535
|
+
inKpis = false;
|
|
536
|
+
inGoalCriteria = /^goal and (?:done|success) criteria\b/i.test(heading);
|
|
537
|
+
goalHeadingPending = inGoalCriteria;
|
|
538
|
+
continue;
|
|
539
|
+
}
|
|
540
|
+
const plain = trimmed.replace(/^[-*+]\s+/, "").replace(/\*\*/g, "").trim();
|
|
541
|
+
const labeled = /^(objective|okr|goal)\.?\s*[:.]\s*(.+)$/i.exec(plain);
|
|
542
|
+
if (labeled?.[2]) {
|
|
543
|
+
objectives.push({ text: labeled[2].trim(), line: index + 1, keyResults: pendingKeyResults.splice(0) });
|
|
544
|
+
goalHeadingPending = false;
|
|
545
|
+
continue;
|
|
546
|
+
}
|
|
547
|
+
const goalSentence = /^(?:the\s+)?goal\s+is\s+(?:to\s+)?(.+)$/i.exec(plain);
|
|
548
|
+
if (goalSentence?.[1]) {
|
|
549
|
+
objectives.push({ text: goalSentence[1].trim(), line: index + 1, keyResults: pendingKeyResults.splice(0) });
|
|
550
|
+
continue;
|
|
551
|
+
}
|
|
552
|
+
if (goalHeadingPending && trimmed) {
|
|
553
|
+
objectives.push({ text: plain.replace(/^\d+[.)]\s*/, "").trim(), line: index + 1, keyResults: pendingKeyResults.splice(0) });
|
|
554
|
+
goalHeadingPending = false;
|
|
555
|
+
}
|
|
556
|
+
if (/^\s*(?:KPIs?|Key results?)\s*:/i.test(line)) {
|
|
557
|
+
inKpis = true;
|
|
558
|
+
continue;
|
|
559
|
+
}
|
|
560
|
+
if (inKpis && !trimmed)
|
|
561
|
+
continue;
|
|
562
|
+
const bullet = /^\s*(?:[-*+]\s+|\d+[.)]\s+)(.+)$/.exec(line);
|
|
563
|
+
if (inKpis && bullet?.[1]) {
|
|
564
|
+
const keyResult = { sourceItemId: `line:${index + 1}`, text: bullet[1].trim() };
|
|
565
|
+
const current = objectives.at(-1);
|
|
566
|
+
if (current)
|
|
567
|
+
current.keyResults.push(keyResult);
|
|
568
|
+
else
|
|
569
|
+
pendingKeyResults.push(keyResult);
|
|
570
|
+
continue;
|
|
571
|
+
}
|
|
572
|
+
if (inGoalCriteria && bullet?.[1]) {
|
|
573
|
+
const keyResult = { sourceItemId: `line:${index + 1}`, text: bullet[1].trim() };
|
|
574
|
+
const current = objectives.at(-1);
|
|
575
|
+
if (current)
|
|
576
|
+
current.keyResults.push(keyResult);
|
|
577
|
+
else
|
|
578
|
+
pendingKeyResults.push(keyResult);
|
|
579
|
+
}
|
|
580
|
+
if (inKpis && trimmed && !bullet && !/^\s/.test(line))
|
|
581
|
+
inKpis = false;
|
|
582
|
+
}
|
|
583
|
+
if (objectives.length === 0)
|
|
584
|
+
return [];
|
|
585
|
+
const period = /\b(?:each|every)\s+week\b|week-over-week/i.test(text)
|
|
586
|
+
? "weekly"
|
|
587
|
+
: /\b(?:each|every)\s+hour\b|hourly/i.test(text)
|
|
588
|
+
? "hourly"
|
|
589
|
+
: /trailing\s+\d+\s+days?/i.exec(text)?.[0] ?? "unspecified";
|
|
590
|
+
return objectives.map((objective, index) => ({
|
|
591
|
+
sourcePath,
|
|
592
|
+
sourceItemId: `brief-line:${objective.line}:${index}`,
|
|
593
|
+
objective: objective.text,
|
|
594
|
+
period,
|
|
595
|
+
keyResults: objective.keyResults.map((item) => ({ ...item })),
|
|
596
|
+
warnings: period === "unspecified" ? [{ field: "period", reason: "period_not_stated_in_source" }] : [],
|
|
597
|
+
}));
|
|
598
|
+
}
|
|
599
|
+
function keyResultDraft(item, sourceItemId) {
|
|
600
|
+
if (typeof item === "string")
|
|
601
|
+
return { sourceItemId, text: item };
|
|
602
|
+
if (!isObject(item))
|
|
603
|
+
return { sourceItemId, text: "", invalidFields: ["key_result"] };
|
|
604
|
+
const invalidFields = [];
|
|
605
|
+
const rawText = item.text ?? item.name;
|
|
606
|
+
if (typeof rawText !== "string")
|
|
607
|
+
invalidFields.push("key_result");
|
|
608
|
+
if ("kpi_name" in item && item.kpi_name !== undefined && typeof item.kpi_name !== "string") {
|
|
609
|
+
invalidFields.push("kpi_name");
|
|
610
|
+
}
|
|
611
|
+
return {
|
|
612
|
+
sourceItemId,
|
|
613
|
+
text: typeof rawText === "string" ? rawText : "",
|
|
614
|
+
...(typeof item.kpi_name === "string" ? { kpiName: item.kpi_name } : {}),
|
|
615
|
+
...(finiteNumber(item.target) !== null ? { target: finiteNumber(item.target) } : {}),
|
|
616
|
+
...(invalidFields.length ? { invalidFields } : {}),
|
|
617
|
+
};
|
|
618
|
+
}
|
|
619
|
+
function structuredOkrDrafts(status, sourcePath) {
|
|
620
|
+
const drafts = [];
|
|
621
|
+
const skips = [];
|
|
622
|
+
for (const field of ["okrs", "okr", "goals", "goal", "objective"]) {
|
|
623
|
+
const raw = status[field];
|
|
624
|
+
if (raw === undefined || raw === null)
|
|
625
|
+
continue;
|
|
626
|
+
const values = Array.isArray(raw) ? raw : [raw];
|
|
627
|
+
for (const [index, value] of values.entries()) {
|
|
628
|
+
if (typeof value === "string") {
|
|
629
|
+
const keyResultsRaw = status.key_results ?? status.keyResults ?? status.krs;
|
|
630
|
+
const keyResults = Array.isArray(keyResultsRaw) ? keyResultsRaw : [];
|
|
631
|
+
drafts.push({
|
|
632
|
+
sourcePath,
|
|
633
|
+
sourceItemId: `${field}:${index}`,
|
|
634
|
+
objective: value,
|
|
635
|
+
period: typeof status.period === "string" && status.period.trim() ? status.period.trim() : "unspecified",
|
|
636
|
+
keyResults: keyResults.map((item, krIndex) => keyResultDraft(item, `${field}:${index}:kr:${krIndex}`)),
|
|
637
|
+
warnings: typeof status.period === "string" ? [] : [{ field: "period", reason: "period_not_stated_in_source" }],
|
|
638
|
+
});
|
|
639
|
+
continue;
|
|
640
|
+
}
|
|
641
|
+
if (!isObject(value)) {
|
|
642
|
+
skips.push({ kind: "okr", source_path: sourcePath, source_item_id: `${field}:${index}`, reason: "unsupported_okr_shape" });
|
|
643
|
+
continue;
|
|
644
|
+
}
|
|
645
|
+
const objective = value.objective ?? value.goal ?? value.text;
|
|
646
|
+
const rawResults = value.key_results ?? value.keyResults ?? value.krs;
|
|
647
|
+
const results = Array.isArray(rawResults) ? rawResults : [];
|
|
648
|
+
drafts.push({
|
|
649
|
+
sourcePath,
|
|
650
|
+
sourceItemId: safeSourceId(value.id, `${field}:${index}`),
|
|
651
|
+
objective: typeof objective === "string" ? objective : "",
|
|
652
|
+
period: typeof value.period === "string" && value.period.trim() ? value.period.trim() : "unspecified",
|
|
653
|
+
keyResults: results.map((item, krIndex) => keyResultDraft(item, isObject(item) ? safeSourceId(item.id, `index:${krIndex}`) : `index:${krIndex}`)),
|
|
654
|
+
warnings: typeof value.period === "string" ? [] : [{ field: "period", reason: "period_not_stated_in_source" }],
|
|
655
|
+
});
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
return { drafts, skips };
|
|
659
|
+
}
|
|
660
|
+
function kpiMatch(text, explicitName, candidates) {
|
|
661
|
+
const query = normalizeName(explicitName ?? text);
|
|
662
|
+
if (!query)
|
|
663
|
+
return null;
|
|
664
|
+
const matches = candidates.filter((candidate) => {
|
|
665
|
+
const name = normalizeName(String(candidate.managed.name ?? ""));
|
|
666
|
+
return name !== "" && query === name;
|
|
667
|
+
});
|
|
668
|
+
return matches.length === 1 ? matches[0] : null;
|
|
669
|
+
}
|
|
670
|
+
function okrCandidates(drafts, kpis, result, team) {
|
|
671
|
+
const candidates = [];
|
|
672
|
+
for (const draft of drafts) {
|
|
673
|
+
const sourceItemId = draft.sourceItemId;
|
|
674
|
+
const objective = checkedText(result, "okr", draft.sourcePath, sourceItemId, "objective", draft.objective);
|
|
675
|
+
const period = checkedText(result, "okr", draft.sourcePath, sourceItemId, "period", draft.period);
|
|
676
|
+
if (!objective || !period)
|
|
677
|
+
continue;
|
|
678
|
+
if (draft.keyResults.length === 0) {
|
|
679
|
+
addSkip(result, { kind: "okr", source_path: draft.sourcePath, source_item_id: sourceItemId, reason: "no_key_results_found" });
|
|
680
|
+
continue;
|
|
681
|
+
}
|
|
682
|
+
const keyResults = [];
|
|
683
|
+
const keyLinks = [];
|
|
684
|
+
let invalid = false;
|
|
685
|
+
for (const keyResult of draft.keyResults) {
|
|
686
|
+
if (keyResult.invalidFields?.length) {
|
|
687
|
+
addSkip(result, {
|
|
688
|
+
kind: "okr",
|
|
689
|
+
source_path: draft.sourcePath,
|
|
690
|
+
source_item_id: `${sourceItemId}:${keyResult.sourceItemId}`,
|
|
691
|
+
reason: "unsupported_key_result_shape",
|
|
692
|
+
fields: keyResult.invalidFields,
|
|
693
|
+
});
|
|
694
|
+
invalid = true;
|
|
695
|
+
continue;
|
|
696
|
+
}
|
|
697
|
+
const explicitKpiName = keyResult.kpiName === undefined
|
|
698
|
+
? undefined
|
|
699
|
+
: checkedText(result, "okr", draft.sourcePath, `${sourceItemId}:${keyResult.sourceItemId}`, "kpi_name", keyResult.kpiName);
|
|
700
|
+
if (keyResult.kpiName !== undefined && explicitKpiName === null) {
|
|
701
|
+
invalid = true;
|
|
702
|
+
continue;
|
|
703
|
+
}
|
|
704
|
+
const text = checkedText(result, "okr", draft.sourcePath, `${sourceItemId}:${keyResult.sourceItemId}`, "key_result", keyResult.text);
|
|
705
|
+
if (!text) {
|
|
706
|
+
invalid = true;
|
|
707
|
+
continue;
|
|
708
|
+
}
|
|
709
|
+
const match = explicitKpiName === null ? null : kpiMatch(text, explicitKpiName, kpis);
|
|
710
|
+
const krId = `kr-${digest({ importKey: importKey(team, draft.sourcePath, sourceItemId), item: keyResult.sourceItemId })}`;
|
|
711
|
+
if (!match) {
|
|
712
|
+
result.unlinked_key_results.push({
|
|
713
|
+
source_path: draft.sourcePath,
|
|
714
|
+
source_item_id: sourceItemId,
|
|
715
|
+
key_result_id: krId,
|
|
716
|
+
text,
|
|
717
|
+
reason: "no_imported_kpi_name_match",
|
|
718
|
+
});
|
|
719
|
+
continue;
|
|
720
|
+
}
|
|
721
|
+
const target = keyResult.target ?? finiteNumber(match.managed.target);
|
|
722
|
+
if (target === null || !Number.isFinite(target)) {
|
|
723
|
+
result.unlinked_key_results.push({
|
|
724
|
+
source_path: draft.sourcePath,
|
|
725
|
+
source_item_id: sourceItemId,
|
|
726
|
+
key_result_id: krId,
|
|
727
|
+
text,
|
|
728
|
+
reason: "missing_finite_target",
|
|
729
|
+
});
|
|
730
|
+
continue;
|
|
731
|
+
}
|
|
732
|
+
keyResults.push({ kr_id: krId, text, kpi_id: null, target });
|
|
733
|
+
keyLinks.push({ krId, importKey: match.importKey });
|
|
734
|
+
}
|
|
735
|
+
if (invalid || keyResults.length === 0) {
|
|
736
|
+
if (keyResults.length === 0 && !invalid) {
|
|
737
|
+
addSkip(result, { kind: "okr", source_path: draft.sourcePath, source_item_id: sourceItemId, reason: "no_linked_key_results" });
|
|
738
|
+
}
|
|
739
|
+
continue;
|
|
740
|
+
}
|
|
741
|
+
const candidate = recordCandidate(team, "okr", draft.sourcePath, sourceItemId, {
|
|
742
|
+
objective,
|
|
743
|
+
period,
|
|
744
|
+
key_results: keyResults,
|
|
745
|
+
}, {
|
|
746
|
+
status: "proposed",
|
|
747
|
+
approved_by: null,
|
|
748
|
+
approved_at: null,
|
|
749
|
+
}, draft.warnings);
|
|
750
|
+
candidate.keyLinks = keyLinks;
|
|
751
|
+
candidates.push(candidate);
|
|
752
|
+
}
|
|
753
|
+
return candidates;
|
|
754
|
+
}
|
|
755
|
+
function aliasesForTeam(team, projectId, statusStem) {
|
|
756
|
+
return [...new Set([team, projectId, statusStem, team.replace(/-/g, " "), projectId.replace(/-/g, " "), statusStem.replace(/-/g, " ")])]
|
|
757
|
+
.filter((value) => value.length >= 3);
|
|
758
|
+
}
|
|
759
|
+
function mentionsTeam(text, aliases) {
|
|
760
|
+
return aliases.some((alias) => {
|
|
761
|
+
const pattern = alias.split(/[-\s]+/).map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("[-\\s]+");
|
|
762
|
+
return new RegExp(`(?:^|[^a-z0-9])${pattern}(?:$|[^a-z0-9])`, "i").test(text);
|
|
763
|
+
});
|
|
764
|
+
}
|
|
765
|
+
function globalDirectiveItems(markdown, aliases) {
|
|
766
|
+
const lines = markdown.split(/\r?\n/);
|
|
767
|
+
const items = [];
|
|
768
|
+
let sectionDate = null;
|
|
769
|
+
let current = [];
|
|
770
|
+
const flush = () => {
|
|
771
|
+
const text = current.join("\n").trim();
|
|
772
|
+
current = [];
|
|
773
|
+
if (!text)
|
|
774
|
+
return;
|
|
775
|
+
const inlineDate = dateInText(text);
|
|
776
|
+
if ((inlineDate ?? sectionDate) !== IMPORT_DATE || !mentionsTeam(text, aliases))
|
|
777
|
+
return;
|
|
778
|
+
items.push({ text, sourceItemId: `hash:${digest(text)}` });
|
|
779
|
+
};
|
|
780
|
+
for (const line of lines) {
|
|
781
|
+
const heading = /^#{1,6}\s+(.+)$/.exec(line.trim())?.[1];
|
|
782
|
+
if (heading) {
|
|
783
|
+
flush();
|
|
784
|
+
sectionDate = dateInText(heading);
|
|
785
|
+
continue;
|
|
786
|
+
}
|
|
787
|
+
const trimmed = line.trim();
|
|
788
|
+
if (!trimmed) {
|
|
789
|
+
flush();
|
|
790
|
+
continue;
|
|
791
|
+
}
|
|
792
|
+
if (/^(?:[-*+]\s+|\d+[.)]\s+)/.test(trimmed) && current.length > 0) {
|
|
793
|
+
flush();
|
|
794
|
+
}
|
|
795
|
+
current.push(line);
|
|
796
|
+
}
|
|
797
|
+
flush();
|
|
798
|
+
return items;
|
|
799
|
+
}
|
|
800
|
+
function statusDirectiveItems(status, result, statusSource) {
|
|
801
|
+
const items = [];
|
|
802
|
+
for (const field of ["directives", "operator_directives"]) {
|
|
803
|
+
const raw = status[field];
|
|
804
|
+
if (raw === undefined || raw === null)
|
|
805
|
+
continue;
|
|
806
|
+
const entries = Array.isArray(raw) ? raw : isObject(raw) ? [raw] : typeof raw === "string" ? [raw] : null;
|
|
807
|
+
if (!entries) {
|
|
808
|
+
addSkip(result, { kind: "directive", source_path: statusSource, source_item_id: field, reason: "unsupported_directive_shape" });
|
|
809
|
+
continue;
|
|
810
|
+
}
|
|
811
|
+
for (const entry of entries) {
|
|
812
|
+
const object = isObject(entry) ? entry : null;
|
|
813
|
+
const rawText = typeof entry === "string" ? entry : object?.text ?? object?.directive;
|
|
814
|
+
const explicitId = object ? safeSourceId(object.id, "") : "";
|
|
815
|
+
if (!explicitId && (typeof rawText !== "string" || !rawText.trim())) {
|
|
816
|
+
addSkip(result, { kind: "directive", source_path: statusSource, source_item_id: field, reason: "directive_identity_missing" });
|
|
817
|
+
continue;
|
|
818
|
+
}
|
|
819
|
+
const sourceItemId = `${field}:${explicitId || `hash:${digest(rawText)}`}`;
|
|
820
|
+
const entryDate = dateFrom(object?.date ?? object?.at ?? object?.created_at) ??
|
|
821
|
+
(typeof rawText === "string" ? dateInText(rawText) : null);
|
|
822
|
+
if (entryDate !== IMPORT_DATE) {
|
|
823
|
+
addSkip(result, { kind: "directive", source_path: statusSource, source_item_id: sourceItemId, reason: entryDate ? "outside_requested_date" : "directive_date_missing" });
|
|
824
|
+
continue;
|
|
825
|
+
}
|
|
826
|
+
const text = checkedText(result, "directive", statusSource, sourceItemId, "text", rawText, { preserveWhitespace: true });
|
|
827
|
+
if (text === null)
|
|
828
|
+
continue;
|
|
829
|
+
items.push({ text, sourceItemId });
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
return items;
|
|
833
|
+
}
|
|
834
|
+
function directiveCandidates(team, status, statusSource, globalItems, globalSource, result) {
|
|
835
|
+
const fromStatus = statusDirectiveItems(status, result, statusSource)
|
|
836
|
+
.map((item) => ({ ...item, sourcePath: statusSource }));
|
|
837
|
+
const fromGlobal = globalItems.map((item) => ({ ...item, sourcePath: globalSource }));
|
|
838
|
+
const combined = [...fromStatus, ...fromGlobal];
|
|
839
|
+
if (combined.length === 0 && status.directives === undefined && status.operator_directives === undefined && globalItems.length === 0) {
|
|
840
|
+
addSkip(result, { kind: "directive", source_path: statusSource, reason: "no_directive_source" });
|
|
841
|
+
}
|
|
842
|
+
const candidates = [];
|
|
843
|
+
for (const item of combined) {
|
|
844
|
+
const text = checkedText(result, "directive", item.sourcePath, item.sourceItemId, "text", item.text, { preserveWhitespace: true });
|
|
845
|
+
if (text === null)
|
|
846
|
+
continue;
|
|
847
|
+
candidates.push(recordCandidate(team, "directive", item.sourcePath, item.sourceItemId, {
|
|
848
|
+
text,
|
|
849
|
+
date: IMPORT_DATE,
|
|
850
|
+
source_url: null,
|
|
851
|
+
source_path: item.sourcePath,
|
|
852
|
+
teams: [team],
|
|
853
|
+
}, { policy_path: null, acknowledgements: [] }));
|
|
854
|
+
}
|
|
855
|
+
return candidates;
|
|
856
|
+
}
|
|
857
|
+
function statusStem(statusPath) {
|
|
858
|
+
const base = path.basename(statusPath);
|
|
859
|
+
if (base === "manager-status.json")
|
|
860
|
+
return validSegment(path.basename(path.dirname(statusPath)), "status project");
|
|
861
|
+
return validSegment(base.replace(/-manager-status\.json$/, "").replace(/\.json$/, ""), "status project");
|
|
862
|
+
}
|
|
863
|
+
function safeError(err, fallbackPath) {
|
|
864
|
+
if (err instanceof LanesError) {
|
|
865
|
+
const details = err.details;
|
|
866
|
+
return {
|
|
867
|
+
error: err.error,
|
|
868
|
+
...(typeof details.path === "string" ? { path: details.path } : fallbackPath ? { path: fallbackPath } : {}),
|
|
869
|
+
...(typeof details.source === "string" ? { source: details.source } : {}),
|
|
870
|
+
...(typeof details.field === "string" ? { field: details.field } : {}),
|
|
871
|
+
};
|
|
872
|
+
}
|
|
873
|
+
const code = err?.code;
|
|
874
|
+
return { error: typeof code === "string" ? `tracker_import_${code.toLowerCase()}` : "tracker_import_input_error", ...(fallbackPath ? { path: fallbackPath } : {}) };
|
|
875
|
+
}
|
|
876
|
+
function addTeamError(result, err, fallbackPath) {
|
|
877
|
+
result.errors.push(safeError(err, fallbackPath));
|
|
878
|
+
result.status = result.actions.length > 0 ? "imported_with_errors" : "error";
|
|
879
|
+
}
|
|
880
|
+
function preview(candidate, kpiIds, isApply, managedOverrides = {}) {
|
|
881
|
+
const fields = { ...candidate.managed, ...managedOverrides };
|
|
882
|
+
if (candidate.kind === "okr" && Array.isArray(fields.key_results) && candidate.keyLinks) {
|
|
883
|
+
fields.key_results = fields.key_results.map((kr) => {
|
|
884
|
+
const link = candidate.keyLinks.find((value) => value.krId === kr.kr_id);
|
|
885
|
+
return {
|
|
886
|
+
...kr,
|
|
887
|
+
kpi_id: link?.importKey ? kpiIds.get(link.importKey) ?? null : null,
|
|
888
|
+
...(link?.importKey && !kpiIds.has(link.importKey) && !isApply ? { pending_kpi_import_key: link.importKey } : {}),
|
|
889
|
+
};
|
|
890
|
+
});
|
|
891
|
+
}
|
|
892
|
+
return { ...fields, ...candidate.createOnly };
|
|
893
|
+
}
|
|
894
|
+
function sameManaged(record, candidate, fields) {
|
|
895
|
+
const { values: importedValues, ...otherFields } = fields;
|
|
896
|
+
const ordinaryFieldsMatch = Object.entries(otherFields).every(([key, value]) => isDeepStrictEqual(record[key], value)) &&
|
|
897
|
+
isDeepStrictEqual(record.import_key, candidate.importKey);
|
|
898
|
+
if (!ordinaryFieldsMatch || candidate.kind !== "kpi")
|
|
899
|
+
return ordinaryFieldsMatch;
|
|
900
|
+
if (!Array.isArray(record.values) || !Array.isArray(importedValues))
|
|
901
|
+
return false;
|
|
902
|
+
const matchingValues = record.values.filter((value) => isObject(value) && value.import_key === candidate.importKey);
|
|
903
|
+
return matchingValues.length === importedValues.length && isDeepStrictEqual(matchingValues, importedValues);
|
|
904
|
+
}
|
|
905
|
+
function mergeImportedKpiValues(record, candidate) {
|
|
906
|
+
if (!Array.isArray(record.values) || !Array.isArray(candidate.managed.values)) {
|
|
907
|
+
return fail("tracker_record_invalid", "KPI values must be arrays before importing a source reading", { id: record.id });
|
|
908
|
+
}
|
|
909
|
+
return [
|
|
910
|
+
...record.values.filter((value) => !isObject(value) || value.import_key !== candidate.importKey),
|
|
911
|
+
...candidate.managed.values,
|
|
912
|
+
];
|
|
913
|
+
}
|
|
914
|
+
function definitionChanged(kind, record, fields) {
|
|
915
|
+
const definitionFields = kind === "kpi"
|
|
916
|
+
? ["name", "definition", "unit", "target", "direction"]
|
|
917
|
+
: kind === "okr"
|
|
918
|
+
? ["objective", "period", "key_results"]
|
|
919
|
+
: kind === "directive"
|
|
920
|
+
? ["text"]
|
|
921
|
+
: [];
|
|
922
|
+
return definitionFields.some((field) => !isDeepStrictEqual(record[field], fields[field]));
|
|
923
|
+
}
|
|
924
|
+
function candidateInitial(candidate, kpiIds) {
|
|
925
|
+
const initial = { ...candidate.managed, ...candidate.createOnly };
|
|
926
|
+
if (candidate.kind === "okr" && Array.isArray(candidate.managed.key_results) && candidate.keyLinks) {
|
|
927
|
+
initial.key_results = candidate.managed.key_results.map((kr) => {
|
|
928
|
+
const link = candidate.keyLinks.find((value) => value.krId === kr.kr_id);
|
|
929
|
+
return { ...kr, kpi_id: link?.importKey ? kpiIds.get(link.importKey) ?? null : null };
|
|
930
|
+
});
|
|
931
|
+
}
|
|
932
|
+
return initial;
|
|
933
|
+
}
|
|
934
|
+
function importedFields(candidate, kpiIds) {
|
|
935
|
+
const fields = { ...candidate.managed };
|
|
936
|
+
if (candidate.kind === "okr" && Array.isArray(candidate.managed.key_results) && candidate.keyLinks) {
|
|
937
|
+
fields.key_results = candidate.managed.key_results.map((kr) => {
|
|
938
|
+
const link = candidate.keyLinks.find((value) => value.krId === kr.kr_id);
|
|
939
|
+
return { ...kr, kpi_id: link?.importKey ? kpiIds.get(link.importKey) ?? null : null };
|
|
940
|
+
});
|
|
941
|
+
}
|
|
942
|
+
return fields;
|
|
943
|
+
}
|
|
944
|
+
async function executeCandidates(work, candidates, mode, company, now, kpiIds) {
|
|
945
|
+
const kinds = [...new Set(candidates.map((candidate) => candidate.kind))];
|
|
946
|
+
const existing = new Map();
|
|
947
|
+
try {
|
|
948
|
+
for (const kind of kinds)
|
|
949
|
+
existing.set(kind, work.store.list(kind));
|
|
950
|
+
}
|
|
951
|
+
catch (err) {
|
|
952
|
+
addTeamError(work.result, err);
|
|
953
|
+
return;
|
|
954
|
+
}
|
|
955
|
+
for (const candidate of candidates) {
|
|
956
|
+
const records = existing.get(candidate.kind) ?? [];
|
|
957
|
+
const current = records.find((record) => record.import_key === candidate.importKey);
|
|
958
|
+
const preserveResolution = candidate.closedSourceWithoutTimestamp && current?.resolved_at != null
|
|
959
|
+
? { resolved_at: current.resolved_at, resolution: current.resolution }
|
|
960
|
+
: {};
|
|
961
|
+
const fields = { ...importedFields(candidate, kpiIds), ...preserveResolution };
|
|
962
|
+
if (candidate.closedSource && !current) {
|
|
963
|
+
addSkip(work.result, {
|
|
964
|
+
kind: candidate.kind,
|
|
965
|
+
source_path: candidate.sourcePath,
|
|
966
|
+
source_item_id: candidate.sourceItemId,
|
|
967
|
+
reason: "source_blocker_closed_before_import",
|
|
968
|
+
});
|
|
969
|
+
continue;
|
|
970
|
+
}
|
|
971
|
+
if (mode === "apply" && candidate.kind === "okr" && candidate.keyLinks?.some((link) => link.importKey && !kpiIds.has(link.importKey))) {
|
|
972
|
+
addSkip(work.result, {
|
|
973
|
+
kind: candidate.kind,
|
|
974
|
+
source_path: candidate.sourcePath,
|
|
975
|
+
source_item_id: candidate.sourceItemId,
|
|
976
|
+
reason: "linked_kpi_not_imported",
|
|
977
|
+
});
|
|
978
|
+
continue;
|
|
979
|
+
}
|
|
980
|
+
if (mode === "dry-run") {
|
|
981
|
+
const action = !current ? "create" : sameManaged(current, candidate, fields) ? "unchanged" : "update";
|
|
982
|
+
const recordId = current?.id ?? null;
|
|
983
|
+
work.result.actions.push({
|
|
984
|
+
kind: candidate.kind,
|
|
985
|
+
action,
|
|
986
|
+
...(recordId ? { id: recordId } : {}),
|
|
987
|
+
source_path: candidate.sourcePath,
|
|
988
|
+
source_item_id: candidate.sourceItemId,
|
|
989
|
+
record: preview(candidate, kpiIds, false, preserveResolution),
|
|
990
|
+
...(candidate.warnings.length ? { warnings: candidate.warnings } : {}),
|
|
991
|
+
});
|
|
992
|
+
if (candidate.kind === "kpi" && recordId)
|
|
993
|
+
kpiIds.set(candidate.importKey, recordId);
|
|
994
|
+
continue;
|
|
995
|
+
}
|
|
996
|
+
try {
|
|
997
|
+
let record;
|
|
998
|
+
let action;
|
|
999
|
+
if (!current) {
|
|
1000
|
+
record = work.store.create({
|
|
1001
|
+
kind: candidate.kind,
|
|
1002
|
+
id: importedRecordId(candidate),
|
|
1003
|
+
company,
|
|
1004
|
+
project_id: work.projectId,
|
|
1005
|
+
...candidateInitial(candidate, kpiIds),
|
|
1006
|
+
});
|
|
1007
|
+
action = "created";
|
|
1008
|
+
}
|
|
1009
|
+
else if (sameManaged(current, candidate, fields)) {
|
|
1010
|
+
record = current;
|
|
1011
|
+
action = "unchanged";
|
|
1012
|
+
}
|
|
1013
|
+
else {
|
|
1014
|
+
record = work.store.update(candidate.kind, current.id, current.version, (live) => ({
|
|
1015
|
+
...live,
|
|
1016
|
+
...fields,
|
|
1017
|
+
...(candidate.kind === "kpi" ? { values: mergeImportedKpiValues(live, candidate) } : {}),
|
|
1018
|
+
...((candidate.kind === "kpi" || candidate.kind === "okr") && definitionChanged(candidate.kind, live, fields)
|
|
1019
|
+
? { status: "proposed", approved_by: null, approved_at: null }
|
|
1020
|
+
: {}),
|
|
1021
|
+
...(candidate.kind === "directive" && definitionChanged(candidate.kind, live, fields) ? { acknowledgements: [] } : {}),
|
|
1022
|
+
}));
|
|
1023
|
+
action = "updated";
|
|
1024
|
+
}
|
|
1025
|
+
if (candidate.kind === "kpi")
|
|
1026
|
+
kpiIds.set(candidate.importKey, record.id);
|
|
1027
|
+
work.result.actions.push({
|
|
1028
|
+
kind: candidate.kind,
|
|
1029
|
+
action,
|
|
1030
|
+
id: record.id,
|
|
1031
|
+
version: record.version,
|
|
1032
|
+
source_path: candidate.sourcePath,
|
|
1033
|
+
source_item_id: candidate.sourceItemId,
|
|
1034
|
+
...(candidate.warnings.length ? { warnings: candidate.warnings } : {}),
|
|
1035
|
+
});
|
|
1036
|
+
}
|
|
1037
|
+
catch (err) {
|
|
1038
|
+
addTeamError(work.result, err);
|
|
1039
|
+
work.result.skipped.push({ kind: candidate.kind, source_path: candidate.sourcePath, source_item_id: candidate.sourceItemId, reason: "write_failed" });
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
function statusOkrCandidates(status, statusSource, result) {
|
|
1044
|
+
const { drafts, skips } = structuredOkrDrafts(status, statusSource);
|
|
1045
|
+
result.skipped.push(...skips);
|
|
1046
|
+
return drafts;
|
|
1047
|
+
}
|
|
1048
|
+
function makeEmptyTeamResult(team) {
|
|
1049
|
+
return { team, status: "error", sources: { prd: null, status: null, brief: null, directives: null }, actions: [], skipped: [], errors: [], unlinked_key_results: [] };
|
|
1050
|
+
}
|
|
1051
|
+
async function prepareTeam(root, company, row, index, globalMarkdown, globalDirectivePath, deps) {
|
|
1052
|
+
const team = typeof row.team === "string" && row.team.trim() ? row.team.trim() : `unknown-${index}`;
|
|
1053
|
+
const result = makeEmptyTeamResult(team);
|
|
1054
|
+
let prdPath = "";
|
|
1055
|
+
try {
|
|
1056
|
+
validSegment(team, "team");
|
|
1057
|
+
const resolved = resolveCompanyPrd(root, company, row.prd);
|
|
1058
|
+
prdPath = resolved.path;
|
|
1059
|
+
const prdSource = relativeSource(fs.realpathSync(root), prdPath);
|
|
1060
|
+
result.project_id = resolved.projectId;
|
|
1061
|
+
result.sources.prd = prdSource;
|
|
1062
|
+
const prd = readJsonObject(prdPath, "prd");
|
|
1063
|
+
const prdCompany = companyFromPrd(prd, prdPath);
|
|
1064
|
+
if (prdCompany !== null && prdCompany !== company) {
|
|
1065
|
+
fail("tracker_import_scope_refused", "Project company does not match the selected company", { path: prdPath, company, project_company: prdCompany });
|
|
1066
|
+
}
|
|
1067
|
+
const stories = requireStoryList(prd, prdPath);
|
|
1068
|
+
const statusPath = resolveStatusPath(root, company, row.status);
|
|
1069
|
+
const statusSource = relativeSource(fs.realpathSync(root), statusPath);
|
|
1070
|
+
result.sources.status = statusSource;
|
|
1071
|
+
const status = readJsonObject(statusPath, "status");
|
|
1072
|
+
const rootReal = fs.realpathSync(root);
|
|
1073
|
+
const globalPath = resolveSourcePath(root, "workspace/lane-briefs/operator-session-directives.md", "directives");
|
|
1074
|
+
const globalSource = relativeSource(rootReal, globalPath);
|
|
1075
|
+
result.sources.directives = globalSource;
|
|
1076
|
+
const statusProjectStem = statusStem(statusPath);
|
|
1077
|
+
const briefPath = path.join(rootReal, "workspace", "lane-briefs", `${statusProjectStem}-manager-brief.md`);
|
|
1078
|
+
let briefText = null;
|
|
1079
|
+
if (fs.existsSync(briefPath)) {
|
|
1080
|
+
const realBrief = resolveSourcePath(root, briefPath, "brief");
|
|
1081
|
+
briefText = readText(realBrief, "brief");
|
|
1082
|
+
result.sources.brief = relativeSource(rootReal, realBrief);
|
|
1083
|
+
}
|
|
1084
|
+
else {
|
|
1085
|
+
addSkip(result, { kind: "okr", source_path: relativeSource(rootReal, briefPath), reason: "team_brief_missing" });
|
|
1086
|
+
}
|
|
1087
|
+
const candidates = [];
|
|
1088
|
+
const kpis = parseKpis(status, result, team, statusSource);
|
|
1089
|
+
candidates.push(...kpis);
|
|
1090
|
+
candidates.push(...parseStatusBlockers(status, result, team, statusSource, deps.now));
|
|
1091
|
+
candidates.push(...parseBlockedStories(stories, result, team, prdSource));
|
|
1092
|
+
const globalItems = globalDirectiveItems(globalMarkdown, aliasesForTeam(team, resolved.projectId, statusProjectStem));
|
|
1093
|
+
candidates.push(...directiveCandidates(team, status, statusSource, globalItems, globalSource, result));
|
|
1094
|
+
const statusOkrs = statusOkrCandidates(status, statusSource, result);
|
|
1095
|
+
const briefOkrs = briefText === null ? [] : markdownGoalDrafts(briefText, result.sources.brief);
|
|
1096
|
+
if (statusOkrs.length === 0 && briefOkrs.length === 0) {
|
|
1097
|
+
addSkip(result, { kind: "okr", source_path: statusSource, reason: "no_explicit_goal_or_okr_source" });
|
|
1098
|
+
}
|
|
1099
|
+
candidates.push(...okrCandidates([...statusOkrs, ...briefOkrs], kpis, result, team));
|
|
1100
|
+
const store = (deps.store ?? ((directory) => createLocalTrackerStore(directory, deps.now)))(path.dirname(prdPath));
|
|
1101
|
+
const importCandidates = uniqueCandidates(candidates, result);
|
|
1102
|
+
result.status = result.errors.length ? "imported_with_errors" : "imported";
|
|
1103
|
+
return { result, company, projectId: resolved.projectId, store, candidates: importCandidates };
|
|
1104
|
+
}
|
|
1105
|
+
catch (err) {
|
|
1106
|
+
addTeamError(result, err, prdPath || (typeof row.prd === "string" ? row.prd : undefined));
|
|
1107
|
+
return { result, company, projectId: result.project_id ?? team, store: { read: () => null, list: () => [], create: () => fail("tracker_import_unavailable", "No project store is available"), update: () => fail("tracker_import_unavailable", "No project store is available") }, candidates: [] };
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
export function registerLaneTrackerImportCommand(parent, deps, wrap) {
|
|
1111
|
+
parent.command("import")
|
|
1112
|
+
.description("Backfill team tracker records from project ledgers and manager status files")
|
|
1113
|
+
.requiredOption("--company <slug>", "Company slug")
|
|
1114
|
+
.option("--team <slug>", "Import one team from the juniors registry")
|
|
1115
|
+
.option("--dry-run", "Plan tracker records without writing")
|
|
1116
|
+
.option("--apply", "Write tracker records to the selected company's projects")
|
|
1117
|
+
.option("--json", "Emit JSON on stdout (default)")
|
|
1118
|
+
.action(wrap(async (options) => {
|
|
1119
|
+
if (options.dryRun === options.apply) {
|
|
1120
|
+
fail("tracker_import_mode_invalid", "Choose exactly one of --dry-run or --apply", {});
|
|
1121
|
+
}
|
|
1122
|
+
const requestedCompany = options.company.trim();
|
|
1123
|
+
if (!requestedCompany) {
|
|
1124
|
+
fail("tracker_import_company_invalid", "--company must be a non-empty company slug", {});
|
|
1125
|
+
}
|
|
1126
|
+
const company = await deps.resolveCompany(requestedCompany);
|
|
1127
|
+
if (!company.slug)
|
|
1128
|
+
fail("tracker_company_unresolved", "The active HQ company could not be resolved", {});
|
|
1129
|
+
if (!await deps.trackerEnabled(company)) {
|
|
1130
|
+
fail("tracker_disabled", `hq lanes import requires the ${LANES_TRACKER_FLAG} flag`, { company: company.slug });
|
|
1131
|
+
}
|
|
1132
|
+
if (await deps.trackerMeshEnabled(company)) {
|
|
1133
|
+
fail("tracker_mesh_unavailable", `hq lanes import cannot write while ${LANES_TRACKER_MESH_FLAG} is enabled`, { company: company.slug });
|
|
1134
|
+
}
|
|
1135
|
+
const root = deps.hqRoot();
|
|
1136
|
+
const registryPath = resolveSourcePath(root, "workspace/lane-briefs/juniors.json", "juniors registry");
|
|
1137
|
+
const registry = readJsonObject(registryPath, "juniors registry");
|
|
1138
|
+
if (!Array.isArray(registry.juniors)) {
|
|
1139
|
+
fail("tracker_import_registry_invalid", "juniors.json must contain a juniors array", { path: registryPath });
|
|
1140
|
+
}
|
|
1141
|
+
const rows = registry.juniors.filter(isObject);
|
|
1142
|
+
if (rows.length !== registry.juniors.length) {
|
|
1143
|
+
fail("tracker_import_registry_invalid", "juniors.json contains a non-object team row", { path: registryPath });
|
|
1144
|
+
}
|
|
1145
|
+
const requestedTeam = options.team === undefined ? undefined : validSegment(options.team, "team");
|
|
1146
|
+
const selectedRows = requestedTeam === undefined ? rows : rows.filter((row) => row.team === requestedTeam);
|
|
1147
|
+
if (selectedRows.length === 0) {
|
|
1148
|
+
fail("tracker_import_team_not_found", "No juniors.json row matches the requested team", { team: requestedTeam, path: registryPath });
|
|
1149
|
+
}
|
|
1150
|
+
const globalPath = resolveSourcePath(root, "workspace/lane-briefs/operator-session-directives.md", "directives");
|
|
1151
|
+
const globalMarkdown = readText(globalPath, "operator-session-directives.md");
|
|
1152
|
+
const works = [];
|
|
1153
|
+
for (const [index, row] of selectedRows.entries()) {
|
|
1154
|
+
works.push(await prepareTeam(root, company.slug, row, index, globalMarkdown, globalPath, deps));
|
|
1155
|
+
}
|
|
1156
|
+
const mode = options.apply ? "apply" : "dry-run";
|
|
1157
|
+
const kpiIds = new Map();
|
|
1158
|
+
for (const work of works) {
|
|
1159
|
+
try {
|
|
1160
|
+
for (const record of work.store.list("kpi")) {
|
|
1161
|
+
if (typeof record.import_key === "string")
|
|
1162
|
+
kpiIds.set(record.import_key, record.id);
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
catch (err) {
|
|
1166
|
+
addTeamError(work.result, err);
|
|
1167
|
+
work.preloadFailed = true;
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
const nonOkrs = works.map((work) => work.candidates.filter((candidate) => candidate.kind !== "okr"));
|
|
1171
|
+
for (const [index, work] of works.entries()) {
|
|
1172
|
+
if (work.preloadFailed)
|
|
1173
|
+
continue;
|
|
1174
|
+
await executeCandidates(work, nonOkrs[index], mode, company.slug, deps.now, kpiIds);
|
|
1175
|
+
}
|
|
1176
|
+
for (const work of works) {
|
|
1177
|
+
if (work.preloadFailed)
|
|
1178
|
+
continue;
|
|
1179
|
+
const okrs = work.candidates.filter((candidate) => candidate.kind === "okr");
|
|
1180
|
+
await executeCandidates(work, okrs, mode, company.slug, deps.now, kpiIds);
|
|
1181
|
+
}
|
|
1182
|
+
for (const work of works) {
|
|
1183
|
+
if (work.result.errors.length) {
|
|
1184
|
+
work.result.status = work.result.actions.length > 0 ? "imported_with_errors" : "error";
|
|
1185
|
+
}
|
|
1186
|
+
else {
|
|
1187
|
+
work.result.status = "imported";
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
const actions = works.flatMap((work) => work.result.actions);
|
|
1191
|
+
const summary = {
|
|
1192
|
+
teams: works.length,
|
|
1193
|
+
created: actions.filter((action) => action.action === "created").length,
|
|
1194
|
+
updated: actions.filter((action) => action.action === "updated").length,
|
|
1195
|
+
unchanged: actions.filter((action) => action.action === "unchanged").length,
|
|
1196
|
+
would_create: actions.filter((action) => action.action === "create").length,
|
|
1197
|
+
would_update: actions.filter((action) => action.action === "update").length,
|
|
1198
|
+
skipped: works.reduce((total, work) => total + work.result.skipped.length, 0),
|
|
1199
|
+
errors: works.reduce((total, work) => total + work.result.errors.length, 0),
|
|
1200
|
+
};
|
|
1201
|
+
console.log(JSON.stringify({
|
|
1202
|
+
ok: summary.errors === 0,
|
|
1203
|
+
action: "lanes.import",
|
|
1204
|
+
company: company.slug,
|
|
1205
|
+
mode,
|
|
1206
|
+
teams: works.map((work) => work.result),
|
|
1207
|
+
summary,
|
|
1208
|
+
}, null, 2));
|
|
1209
|
+
if (summary.errors > 0)
|
|
1210
|
+
process.exitCode = 2;
|
|
1211
|
+
}));
|
|
1212
|
+
}
|
|
1213
|
+
//# sourceMappingURL=lanes-import.js.map
|