nikos 0.1.0 → 0.2.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/README.md +45 -9
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +57 -1
- package/dist/commands/approve.d.ts +16 -0
- package/dist/commands/approve.js +120 -0
- package/dist/commands/ci.d.ts +18 -0
- package/dist/commands/ci.js +123 -0
- package/dist/commands/clear.d.ts +14 -0
- package/dist/commands/decide.d.ts +11 -0
- package/dist/commands/decide.js +3 -3
- package/dist/commands/decisions.d.ts +14 -0
- package/dist/commands/decisions.js +2 -2
- package/dist/commands/doctor.d.ts +11 -0
- package/dist/commands/doctor.js +92 -0
- package/dist/commands/export.d.ts +48 -0
- package/dist/commands/export.js +164 -0
- package/dist/commands/init.d.ts +16 -0
- package/dist/commands/init.js +16 -13
- package/dist/commands/join.d.ts +17 -0
- package/dist/commands/join.js +91 -0
- package/dist/commands/map.d.ts +65 -0
- package/dist/commands/map.js +202 -25
- package/dist/commands/migrate.d.ts +10 -0
- package/dist/commands/migrate.js +105 -0
- package/dist/commands/next.d.ts +19 -0
- package/dist/commands/next.js +29 -5
- package/dist/commands/plan.d.ts +8 -0
- package/dist/commands/plan.js +9 -8
- package/dist/commands/release.d.ts +66 -0
- package/dist/commands/release.js +119 -17
- package/dist/commands/show.d.ts +3 -0
- package/dist/commands/show.js +26 -4
- package/dist/commands/start.d.ts +28 -0
- package/dist/commands/start.js +138 -0
- package/dist/commands/state.d.ts +27 -0
- package/dist/commands/state.js +40 -22
- package/dist/commands/step.d.ts +17 -0
- package/dist/commands/step.js +38 -2
- package/dist/commands/story.d.ts +21 -0
- package/dist/commands/story.js +7 -10
- package/dist/commands/suggest.d.ts +16 -0
- package/dist/commands/suggest.js +140 -0
- package/dist/commands/tracker.d.ts +60 -0
- package/dist/commands/tracker.js +185 -0
- package/dist/commands/validate.d.ts +15 -0
- package/dist/commands/validate.js +149 -14
- package/dist/commands/verify.d.ts +20 -0
- package/dist/commands/video.d.ts +33 -0
- package/dist/commands/video.js +4 -3
- package/dist/index.d.ts +20 -0
- package/dist/index.js +24 -0
- package/dist/lib/approvals.d.ts +29 -0
- package/dist/lib/approvals.js +28 -0
- package/dist/lib/bets.d.ts +21 -0
- package/dist/lib/bets.js +9 -2
- package/dist/lib/frontmatter.d.ts +5 -0
- package/dist/lib/project.d.ts +25 -0
- package/dist/lib/project.js +25 -7
- package/dist/lib/settings.d.ts +29 -0
- package/dist/lib/settings.js +110 -0
- package/dist/lib/state-files.d.ts +44 -0
- package/dist/lib/state-files.js +102 -0
- package/dist/lib/store.d.ts +44 -0
- package/dist/lib/store.js +97 -0
- package/dist/lint/constitution.d.ts +5 -0
- package/dist/lint/gherkin.d.ts +12 -0
- package/dist/lint/gherkin.js +2 -2
- package/dist/lint/portable.d.ts +10 -0
- package/dist/mcp/server.d.ts +4 -0
- package/dist/mcp/server.js +239 -0
- package/dist/planning/bets.d.ts +31 -0
- package/dist/planning/cli.d.ts +8 -0
- package/dist/planning/cli.js +17 -1
- package/dist/planning/delivery.d.ts +12 -0
- package/dist/planning/delivery.js +8 -5
- package/dist/planning/krs.d.ts +28 -0
- package/dist/planning/model.d.ts +55 -0
- package/dist/planning/model.js +10 -10
- package/dist/planning/rollout.d.ts +72 -0
- package/dist/planning/rollout.js +206 -0
- package/dist/planning/server.d.ts +113 -0
- package/dist/planning/server.js +9 -6
- package/dist/planning/ui.d.ts +7 -0
- package/dist/planning/ui.js +26 -1
- package/dist/planning/upstream.d.ts +73 -0
- package/dist/planning/upstream.js +78 -20
- package/dist/verify/detect.d.ts +3 -0
- package/docs/format.md +112 -2
- package/docs/gherkin.md +3 -0
- package/docs/install.md +39 -0
- package/docs/library.md +53 -0
- package/docs/planning.md +51 -5
- package/docs/settings.md +112 -0
- package/package.json +12 -2
- package/schemas/approvals.schema.json +27 -0
- package/schemas/planning/kr.schema.json +1 -1
- package/schemas/settings.schema.json +40 -0
- package/schemas/state.schema.json +6 -2
- package/schemas/storymap.schema.json +13 -0
- package/skills/nikos-mode/SKILL.md +22 -1
- package/skills/nikos-mode/playbooks/build-a-story.md +1 -1
- package/skills/nikos-mode/playbooks/company.md +16 -0
- package/skills/nikos-mode/playbooks/index.md +4 -0
- package/skills/nikos-mode/playbooks/map-existing.md +14 -0
- package/skills/nikos-mode/playbooks/start.md +12 -0
- package/skills/nikos-mode/playbooks/tracker.md +14 -0
- package/skills/nikos-mode/trackers/github.md +18 -0
- package/skills/nikos-mode/trackers/gitlab.md +17 -0
- package/skills/nikos-mode/trackers/jira.md +19 -0
- package/skills/nikos-mode/trackers/linear.md +17 -0
- package/skills/verify/SKILL.md +9 -0
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
// `nikos planning rollout`: how the plans below this one are doing. planning/downstream.yaml
|
|
2
|
+
// lists the departments and teams that plan against this repo; rollout fetches a read-only copy
|
|
3
|
+
// of each into .nikos/downstream/<repo path>/ (gitignored), and of the ones they list in turn,
|
|
4
|
+
// and reports for each: has it joined, when did it last sync this plan, how is its delivery
|
|
5
|
+
// going, and which of its key results and bets point up here. Nothing is written to their repos.
|
|
6
|
+
import { spawnSync } from "node:child_process";
|
|
7
|
+
import { existsSync, readFileSync, rmSync, writeFileSync, mkdirSync } from "node:fs";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { parse, parseDocument } from "yaml";
|
|
10
|
+
import { isDone } from "../lib/project.js";
|
|
11
|
+
import { storyMapOf } from "../commands/map.js";
|
|
12
|
+
import { delivery, describeDelivery } from "./delivery.js";
|
|
13
|
+
import { progress, isStale } from "./krs.js";
|
|
14
|
+
import { betIds, krIds, loadBet, loadKr, today } from "./model.js";
|
|
15
|
+
import { git, ignoreCache, readLock, repoEntry, upstreamEntries } from "./upstream.js";
|
|
16
|
+
const MAX_DEPTH = 6;
|
|
17
|
+
const cache = (root) => join(root, ".nikos", "downstream");
|
|
18
|
+
const copyOf = (root, repo) => join(cache(root), ...repo.split("/"));
|
|
19
|
+
const indexOf = (root) => join(cache(root), "index.json");
|
|
20
|
+
/** This repo's path on its host, from the origin remote: owner/repo, or group/subgroup/project. */
|
|
21
|
+
export function ownRepo(root) {
|
|
22
|
+
const url = spawnSync("git", ["remote", "get-url", "origin"], { cwd: root, encoding: "utf8" }).stdout?.trim() ?? "";
|
|
23
|
+
const m = url.match(/^(?:[a-z+]+:\/\/[^/]+\/|[^@\s]+@[^:\s]+:)(.+?)(?:\.git)?\/?$/);
|
|
24
|
+
return m?.[1];
|
|
25
|
+
}
|
|
26
|
+
/** The plans a repo (or a copy of one) lists in planning/downstream.yaml. */
|
|
27
|
+
export function downstreamEntries(dir) {
|
|
28
|
+
const p = join(dir, "planning", "downstream.yaml");
|
|
29
|
+
if (!existsSync(p))
|
|
30
|
+
return [];
|
|
31
|
+
const list = parse(readFileSync(p, "utf8")) ?? [];
|
|
32
|
+
if (!Array.isArray(list))
|
|
33
|
+
throw new Error("planning/downstream.yaml should be a list of { name, repo }");
|
|
34
|
+
return list.map((e) => {
|
|
35
|
+
const name = typeof e === "string" ? e : String(e?.name ?? e?.repo ?? "");
|
|
36
|
+
if (!name)
|
|
37
|
+
throw new Error("planning/downstream.yaml: each entry needs a name or a repo");
|
|
38
|
+
return { name, ...(e?.repo ? { entry: repoEntry(e, "planning/downstream.yaml") } : {}) };
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
/** What a copy of a downstream plan says about itself. */
|
|
42
|
+
function read(root, dir, repo, self) {
|
|
43
|
+
const ups = (() => {
|
|
44
|
+
try {
|
|
45
|
+
return upstreamEntries(dir).map((u) => u.repo);
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return [];
|
|
49
|
+
}
|
|
50
|
+
})();
|
|
51
|
+
// Joined means it names this repo, or (when this repo's path isn't known) any plan at all.
|
|
52
|
+
const joined = self ? ups.includes(self) : ups.length > 0;
|
|
53
|
+
const lock = readLock(dir).find((l) => (self ? l.repo === self : !l.via));
|
|
54
|
+
const head = spawnSync("git", ["rev-parse", "HEAD"], { cwd: root, encoding: "utf8" }).stdout?.trim();
|
|
55
|
+
let deliv;
|
|
56
|
+
if (existsSync(join(dir, "intent", "storymap.yaml"))) {
|
|
57
|
+
try {
|
|
58
|
+
const map = storyMapOf(dir);
|
|
59
|
+
const stories = map.releases.flatMap((r) => r.stories).filter((s) => s.status !== "cancelled");
|
|
60
|
+
deliv = {
|
|
61
|
+
stories: stories.length,
|
|
62
|
+
done: stories.filter((s) => isDone(s.status)).length,
|
|
63
|
+
approved: stories.filter((s) => s.status === "approved").length,
|
|
64
|
+
inProgress: stories.filter((s) => s.status === "in_progress").length,
|
|
65
|
+
releases: map.releases.length,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
// A story map that doesn't read is the team's to fix; the rest of the rollout still shows.
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
const krs = krIds(dir).flatMap((id) => {
|
|
73
|
+
try {
|
|
74
|
+
const k = loadKr(dir, id).data;
|
|
75
|
+
return [{ id, title: k.title, ...(k.aligns_to ? { aligns_to: k.aligns_to } : {}), progress: progress(k), stale: isStale(k) }];
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return [];
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
const bets = betIds(dir).flatMap((id) => {
|
|
82
|
+
try {
|
|
83
|
+
const m = loadBet(dir, id).meta;
|
|
84
|
+
return [{ id, outcome: m.outcome, delivery: describeDelivery(delivery(dir, id)), krs: [m.krs?.primary, ...(m.krs?.also ?? [])].filter(Boolean) }];
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return [];
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
return {
|
|
91
|
+
repo,
|
|
92
|
+
state: joined ? "joined" : "not joined",
|
|
93
|
+
...(joined ? {} : { reason: `its planning/upstream.yaml doesn't list ${self ?? "a plan"} yet` }),
|
|
94
|
+
...(lock ? { synced: { on: lock.synced, commit: lock.commit, ...(head && self ? { current: lock.commit === head } : {}) } } : {}),
|
|
95
|
+
...(deliv ? { delivery: deliv } : {}),
|
|
96
|
+
krs,
|
|
97
|
+
bets,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* The plans below this one, at every level down. Fetches each read-only unless offline, when it
|
|
102
|
+
* reads the copies already on disk. Unreachable ones are reported, never fatal.
|
|
103
|
+
*/
|
|
104
|
+
export function rollout(root, opts = {}) {
|
|
105
|
+
const self = opts.as ?? ownRepo(root);
|
|
106
|
+
const index = existsSync(indexOf(root)) ? JSON.parse(readFileSync(indexOf(root), "utf8")) : {};
|
|
107
|
+
const plans = [];
|
|
108
|
+
const queue = downstreamEntries(root).map((listed) => ({ listed, depth: 1, parent: self ?? "" }));
|
|
109
|
+
const seen = new Set();
|
|
110
|
+
while (queue.length) {
|
|
111
|
+
const { listed, depth, via, parent } = queue.shift();
|
|
112
|
+
const base = { name: listed.name, ...(via ? { via } : {}), krs: [], bets: [] };
|
|
113
|
+
if (!listed.entry) {
|
|
114
|
+
plans.push({ ...base, state: "no repo yet" });
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
const e = listed.entry;
|
|
118
|
+
if (seen.has(e.repo))
|
|
119
|
+
continue;
|
|
120
|
+
seen.add(e.repo);
|
|
121
|
+
const dir = copyOf(root, e.repo);
|
|
122
|
+
if (!opts.offline) {
|
|
123
|
+
rmSync(dir, { recursive: true, force: true });
|
|
124
|
+
const clone = git(["clone", "--quiet", "--depth", "1", "--branch", e.ref, e.url, dir]);
|
|
125
|
+
if (clone.status !== 0) {
|
|
126
|
+
rmSync(dir, { recursive: true, force: true });
|
|
127
|
+
delete index[e.repo];
|
|
128
|
+
plans.push({ ...base, repo: e.repo, state: "unreachable", reason: (clone.stderr || "clone failed").trim().split("\n").pop() });
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
index[e.repo] = { commit: git(["rev-parse", "HEAD"], dir).stdout.trim(), fetched: today() };
|
|
132
|
+
rmSync(join(dir, ".git"), { recursive: true, force: true }); // a plain copy: nothing to push back
|
|
133
|
+
}
|
|
134
|
+
else if (!existsSync(dir)) {
|
|
135
|
+
plans.push({ ...base, repo: e.repo, state: "unreachable", reason: "not fetched yet: run nikos planning rollout" });
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
// A plan further down joins the one that listed it, not this repo.
|
|
139
|
+
const plan = { ...base, ...read(root, dir, e.repo, depth === 1 ? self : parent), ...(index[e.repo] ? { fetched: index[e.repo].fetched } : {}) };
|
|
140
|
+
if (depth > 1 && plan.synced)
|
|
141
|
+
delete plan.synced.current;
|
|
142
|
+
plans.push(plan);
|
|
143
|
+
if (depth >= MAX_DEPTH)
|
|
144
|
+
continue;
|
|
145
|
+
try {
|
|
146
|
+
for (const below of downstreamEntries(dir))
|
|
147
|
+
queue.push({ listed: below, depth: depth + 1, via: e.repo, parent: e.repo });
|
|
148
|
+
}
|
|
149
|
+
catch (err) {
|
|
150
|
+
plans.push({ name: `${e.repo}'s downstream.yaml`, state: "unreachable", reason: err.message, krs: [], bets: [] });
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
if (!opts.offline) {
|
|
154
|
+
ignoreCache(root);
|
|
155
|
+
mkdirSync(cache(root), { recursive: true });
|
|
156
|
+
writeFileSync(indexOf(root), JSON.stringify(index, null, 2));
|
|
157
|
+
}
|
|
158
|
+
return { ...(self ? { repo: self } : {}), plans };
|
|
159
|
+
}
|
|
160
|
+
/** The rollout as lines, one block per plan. */
|
|
161
|
+
export function formatRollout(r) {
|
|
162
|
+
if (!r.plans.length)
|
|
163
|
+
return "No plans below this one: list departments and teams in planning/downstream.yaml as `- name: Payments` and `repo: acme/payments-plan`.";
|
|
164
|
+
const out = [];
|
|
165
|
+
const joined = r.plans.filter((p) => p.state === "joined").length;
|
|
166
|
+
out.push(`${joined} of ${r.plans.length} plans below ${r.repo ?? "this one"} have joined.`);
|
|
167
|
+
for (const p of r.plans) {
|
|
168
|
+
const mark = p.state === "joined" ? "✓" : p.state === "not joined" ? "!" : "?";
|
|
169
|
+
out.push(`\n${mark} ${p.name}${p.repo && p.repo !== p.name ? ` (${p.repo})` : ""}${p.via ? `, below ${p.via}` : ""}: ${p.state}${p.reason ? `, ${p.reason}` : ""}`);
|
|
170
|
+
if (p.synced)
|
|
171
|
+
out.push(` synced on ${p.synced.on} at ${p.synced.commit.slice(0, 7)}${p.synced.current === false ? ", behind this plan: ask them to run nikos planning sync" : ""}`);
|
|
172
|
+
if (p.delivery)
|
|
173
|
+
out.push(` delivery: ${p.delivery.done} of ${p.delivery.stories} stories done${p.delivery.approved ? ` (${p.delivery.approved} approved)` : ""}, ${p.delivery.inProgress} in progress`);
|
|
174
|
+
for (const k of p.krs)
|
|
175
|
+
out.push(` KR ${k.id}: ${k.title}, ${Math.round(k.progress * 100)}%${k.stale ? ", stale" : ""}${k.aligns_to ? ` → ${k.aligns_to}` : ""}`);
|
|
176
|
+
for (const b of p.bets)
|
|
177
|
+
out.push(` bet ${b.id}: ${b.delivery}, ${b.outcome}${b.krs.length ? ` → ${b.krs.join(", ")}` : ""}`);
|
|
178
|
+
}
|
|
179
|
+
return out.join("\n");
|
|
180
|
+
}
|
|
181
|
+
/** List a department or team in planning/downstream.yaml, or give an entry already there its repo. */
|
|
182
|
+
export function addDownstream(root, input) {
|
|
183
|
+
const name = input.name?.trim();
|
|
184
|
+
if (!name)
|
|
185
|
+
throw new Error("Give the department's or team's name");
|
|
186
|
+
const entry = { name, ...(input.repo ? { repo: input.repo } : {}), ...(input.host ? { host: input.host } : {}) };
|
|
187
|
+
if (input.repo)
|
|
188
|
+
repoEntry(entry, "planning/downstream.yaml"); // a bad repo path or host fails before anything is written
|
|
189
|
+
const p = join(root, "planning", "downstream.yaml");
|
|
190
|
+
const doc = parseDocument(existsSync(p) ? readFileSync(p, "utf8") : "# The departments and teams that plan against this one: name, and their repo once they have one.\n[]\n");
|
|
191
|
+
if (!doc.contents || !("items" in doc.contents))
|
|
192
|
+
doc.contents = doc.createNode([]);
|
|
193
|
+
doc.contents.flow = false;
|
|
194
|
+
const items = doc.contents.items;
|
|
195
|
+
const at = items.findIndex((it) => {
|
|
196
|
+
const v = it?.toJSON?.() ?? it;
|
|
197
|
+
return (typeof v === "string" ? v : v?.name) === name || (input.repo && v?.repo === input.repo);
|
|
198
|
+
});
|
|
199
|
+
if (at >= 0)
|
|
200
|
+
items[at] = doc.createNode(entry);
|
|
201
|
+
else
|
|
202
|
+
items.push(doc.createNode(entry));
|
|
203
|
+
mkdirSync(join(root, "planning"), { recursive: true });
|
|
204
|
+
writeFileSync(p, doc.toString({ lineWidth: 0 }));
|
|
205
|
+
return { name, ...(input.repo ? { repo: input.repo } : {}), added: at < 0 };
|
|
206
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/** What a custom view can rely on. Keep this in step with VIEW_SCRIPT and the save kinds below. */
|
|
2
|
+
export declare const VIEW_CONTRACT = "# Custom planning views\n\nA custom view is a page at planning/views/<name>.html (lowercase letters, digits and hyphens). It is served at /views/<name>, and the server adds /nikos-view.js and the dashboard token to it.\n\nwindow.nikos.data(): Promise<object>\n The dashboard data: { project, today, plan, krs, bets, story, upstream, views, problems }.\n krs[]: id, title, owner, unit, baseline, target, by, latest, latestValue, progress, stale, hash, checkins, upstream.\n bets[]: id, owner, team, hypothesis, krs, starts, review_on, deadline, confidence, outcome, reviews, body, hash, delivery, releases.\n upstream[] is read-only: plans synced from other repos. Never save to it.\n\nwindow.nikos.save(kind, payload): Promise<{ wrote: string }>\n \"checkin\": { id, expect, value, note?, evidence? }\n \"bet\": { id, expect, fields: { owner?, team?, hypothesis?, starts?, review_on?, deadline?, confidence?, dependencies?, outcome? } }\n \"review\": { id, expect, decision: \"continue\" | \"change\" | \"stop\" | \"complete\", reason, outcome?: \"won\" | \"lost\" }\n\nexpect is the hash of the item as data() returned it. A save rejected with status 409 means the file changed on disk: call data() again and apply the change to the new version. Other rejections carry the reason. Saves only ever write inside planning/.\n";
|
|
3
|
+
/** Everything the views need, as JSON. Files that fail to load are listed in `problems`, not thrown. */
|
|
4
|
+
export declare function planningData(root: string): {
|
|
5
|
+
project: any;
|
|
6
|
+
today: string;
|
|
7
|
+
plan: any;
|
|
8
|
+
krs: {
|
|
9
|
+
id: any;
|
|
10
|
+
title: any;
|
|
11
|
+
objective: any;
|
|
12
|
+
owner: any;
|
|
13
|
+
unit: any;
|
|
14
|
+
baseline: any;
|
|
15
|
+
target: any;
|
|
16
|
+
original_target: any;
|
|
17
|
+
by: any;
|
|
18
|
+
stale_after_days: any;
|
|
19
|
+
aligns_to: any;
|
|
20
|
+
checkins: any;
|
|
21
|
+
latest: any;
|
|
22
|
+
latestValue: any;
|
|
23
|
+
progress: number;
|
|
24
|
+
stale: boolean;
|
|
25
|
+
hash: string;
|
|
26
|
+
file: string;
|
|
27
|
+
upstream: {
|
|
28
|
+
ref: string;
|
|
29
|
+
repo: string;
|
|
30
|
+
commit: string | null;
|
|
31
|
+
found: boolean;
|
|
32
|
+
kr: any;
|
|
33
|
+
readOnly: boolean;
|
|
34
|
+
} | null;
|
|
35
|
+
}[];
|
|
36
|
+
bets: {
|
|
37
|
+
id: any;
|
|
38
|
+
owner: any;
|
|
39
|
+
team: any;
|
|
40
|
+
hypothesis: any;
|
|
41
|
+
krs: {
|
|
42
|
+
primary: any;
|
|
43
|
+
also: any;
|
|
44
|
+
};
|
|
45
|
+
starts: any;
|
|
46
|
+
review_on: any;
|
|
47
|
+
deadline: any;
|
|
48
|
+
confidence: any;
|
|
49
|
+
dependencies: any;
|
|
50
|
+
outcome: any;
|
|
51
|
+
reviews: any;
|
|
52
|
+
body: string;
|
|
53
|
+
hash: string;
|
|
54
|
+
file: string;
|
|
55
|
+
delivery: {
|
|
56
|
+
text: string;
|
|
57
|
+
status: "Shipped" | "Building" | "Not started";
|
|
58
|
+
done: number;
|
|
59
|
+
total: number;
|
|
60
|
+
stories: {
|
|
61
|
+
id: string;
|
|
62
|
+
status: string;
|
|
63
|
+
}[];
|
|
64
|
+
releases: number[];
|
|
65
|
+
};
|
|
66
|
+
releases: {
|
|
67
|
+
number: number;
|
|
68
|
+
title: string;
|
|
69
|
+
href: string;
|
|
70
|
+
}[];
|
|
71
|
+
}[];
|
|
72
|
+
story: {
|
|
73
|
+
activities: {
|
|
74
|
+
id: string;
|
|
75
|
+
title: string;
|
|
76
|
+
steps: {
|
|
77
|
+
id: string;
|
|
78
|
+
title: string;
|
|
79
|
+
}[];
|
|
80
|
+
}[];
|
|
81
|
+
releases: import("../commands/map.js").MapRelease[];
|
|
82
|
+
unreleased: import("../commands/map.js").MapStory[];
|
|
83
|
+
};
|
|
84
|
+
upstream: {
|
|
85
|
+
repo: string;
|
|
86
|
+
commit?: string;
|
|
87
|
+
krs: any[];
|
|
88
|
+
bets: any[];
|
|
89
|
+
}[];
|
|
90
|
+
downstream: import("./rollout.js").Rollout | null;
|
|
91
|
+
views: string[];
|
|
92
|
+
problems: string[];
|
|
93
|
+
};
|
|
94
|
+
/** Write the whole dashboard as one offline HTML file. Warns first when other repos' plans are inside. */
|
|
95
|
+
export declare function exportSnapshot(root: string, file: string): {
|
|
96
|
+
wrote: string;
|
|
97
|
+
warned: boolean;
|
|
98
|
+
};
|
|
99
|
+
export interface RunningServer {
|
|
100
|
+
url: string;
|
|
101
|
+
token: string;
|
|
102
|
+
port: number;
|
|
103
|
+
close(): Promise<void>;
|
|
104
|
+
}
|
|
105
|
+
/** Start the dashboard on 127.0.0.1 with a fresh token. The url carries the token for the browser. */
|
|
106
|
+
export declare function startServer(root: string, opts?: {
|
|
107
|
+
port?: number;
|
|
108
|
+
}): Promise<RunningServer>;
|
|
109
|
+
/** `nikos planning serve`: run the dashboard until Ctrl+C, or export it to one file. */
|
|
110
|
+
export declare function runServe(root: string, opts?: {
|
|
111
|
+
port?: number;
|
|
112
|
+
exportTo?: string;
|
|
113
|
+
}): Promise<number>;
|
package/dist/planning/server.js
CHANGED
|
@@ -12,6 +12,7 @@ import { editBet, reviewBet } from "./bets.js";
|
|
|
12
12
|
import { delivery, describeDelivery } from "./delivery.js";
|
|
13
13
|
import { checkIn, isStale, latest, progress } from "./krs.js";
|
|
14
14
|
import { betIds, krIds, loadBet, loadKr, loadPlan, paths, StaleError, today } from "./model.js";
|
|
15
|
+
import { rollout } from "./rollout.js";
|
|
15
16
|
import { loadUpstream } from "./upstream.js";
|
|
16
17
|
import { renderDashboard } from "./ui.js";
|
|
17
18
|
const MAX_BODY = 100_000;
|
|
@@ -170,6 +171,8 @@ export function planningData(root) {
|
|
|
170
171
|
bets,
|
|
171
172
|
story: { activities: story.activities, releases: story.releases, unreleased: story.unreleased },
|
|
172
173
|
upstream,
|
|
174
|
+
// The plans below this one, from the copies the last rollout fetched: loading never fetches.
|
|
175
|
+
downstream: existsSync(join(root, "planning", "downstream.yaml")) ? rollout(root, { offline: true }) : null,
|
|
173
176
|
views,
|
|
174
177
|
problems,
|
|
175
178
|
};
|
|
@@ -177,13 +180,13 @@ export function planningData(root) {
|
|
|
177
180
|
/** Write the whole dashboard as one offline HTML file. Warns first when other repos' plans are inside. */
|
|
178
181
|
export function exportSnapshot(root, file) {
|
|
179
182
|
const out = resolve(file);
|
|
180
|
-
const
|
|
181
|
-
const
|
|
183
|
+
const data = planningData(root);
|
|
184
|
+
const names = [...loadUpstream(root).map((u) => u.repo), ...(data.downstream?.plans ?? []).filter((p) => p.repo && p.state !== "unreachable").map((p) => p.repo)];
|
|
185
|
+
const warned = names.length > 0;
|
|
182
186
|
if (warned) {
|
|
183
|
-
|
|
184
|
-
process.stderr.write(`! This snapshot includes plans from other repos (${names}); share it only with people allowed to see them.\n`);
|
|
187
|
+
process.stderr.write(`! This snapshot includes plans from other repos (${names.join(", ")}); share it only with people allowed to see them.\n`);
|
|
185
188
|
}
|
|
186
|
-
writeFileSync(out, renderDashboard({ data
|
|
189
|
+
writeFileSync(out, renderDashboard({ data, readOnly: true }));
|
|
187
190
|
return { wrote: out, warned };
|
|
188
191
|
}
|
|
189
192
|
/** Start the dashboard on 127.0.0.1 with a fresh token. The url carries the token for the browser. */
|
|
@@ -253,7 +256,7 @@ function serveGet(root, token, path, res) {
|
|
|
253
256
|
if (path === "/api/data")
|
|
254
257
|
return send(res, 200, planningData(root));
|
|
255
258
|
if (path === "/map")
|
|
256
|
-
return send(res, 200, renderMap(root), HTML);
|
|
259
|
+
return send(res, 200, renderMap(root, { live: true }), HTML);
|
|
257
260
|
if (path === "/nikos-view.js")
|
|
258
261
|
return send(res, 200, VIEW_SCRIPT, JS);
|
|
259
262
|
const m = /^\/views\/([^/]+)$/.exec(path);
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export interface DashboardOptions {
|
|
2
|
+
data: any;
|
|
3
|
+
token?: string;
|
|
4
|
+
readOnly: boolean;
|
|
5
|
+
}
|
|
6
|
+
/** The whole dashboard as one HTML page. Read-only renders carry no save code and no token. */
|
|
7
|
+
export declare function renderDashboard({ data, token, readOnly }: DashboardOptions): string;
|
package/dist/planning/ui.js
CHANGED
|
@@ -129,6 +129,7 @@ function render() {
|
|
|
129
129
|
else if (p[0] === 'bets') html = betsView();
|
|
130
130
|
else if (p[0] === 'timeline') html = timelineView();
|
|
131
131
|
else if (p[0] === 'guide') html = guideView();
|
|
132
|
+
else if (p[0] === 'rollout') html = rolloutView();
|
|
132
133
|
else html = krsView();
|
|
133
134
|
main.innerHTML = (D.problems && D.problems.length ? '<div class="notice">Some planning files could not be read: ' + esc(D.problems.join('; ')) + '</div>' : '') + html;
|
|
134
135
|
}
|
|
@@ -176,6 +177,30 @@ function guideView() {
|
|
|
176
177
|
'<h2 style="margin:20px 0 10px">What nikOS adds</h2><div class="cards">' + value + '</div>';
|
|
177
178
|
}
|
|
178
179
|
|
|
180
|
+
function rolloutView() {
|
|
181
|
+
var r = D.downstream;
|
|
182
|
+
if (!r || !r.plans.length) return '<h1>Rollout</h1><p class="muted">No plans below this one yet. List departments and teams in planning/downstream.yaml.</p>';
|
|
183
|
+
var joined = r.plans.filter(function (p) { return p.state === 'joined'; }).length;
|
|
184
|
+
var cls = { 'joined': 'green', 'not joined': 'amber', 'unreachable': '', 'no repo yet': '' };
|
|
185
|
+
var cards = r.plans.map(function (p) {
|
|
186
|
+
var rows = [];
|
|
187
|
+
if (p.synced) rows.push('Synced ' + esc(p.synced.on) + (p.synced.current === false ? ', behind this plan' : ''));
|
|
188
|
+
else if (p.state === 'joined') rows.push('Not synced yet');
|
|
189
|
+
if (p.delivery) {
|
|
190
|
+
var pct = p.delivery.stories ? Math.round(p.delivery.done / p.delivery.stories * 100) : 0;
|
|
191
|
+
rows.push(esc(p.delivery.done) + ' of ' + esc(p.delivery.stories) + ' stories done' + (p.delivery.approved ? ', ' + esc(p.delivery.approved) + ' approved' : '') + '<div class="progress"><div style="width:' + pct + '%"></div></div>');
|
|
192
|
+
}
|
|
193
|
+
var refs = p.krs.map(function (k) { return '<li>KR ' + esc(k.title) + ', ' + Math.round(k.progress * 100) + '%' + (k.stale ? ' ' + chip('Stale', 'amber') : '') + (k.aligns_to ? ' <span class="muted">→ ' + esc(k.aligns_to) + '</span>' : '') + '</li>'; })
|
|
194
|
+
.concat(p.bets.map(function (b) { return '<li>Bet ' + esc(b.id) + ', ' + esc(b.delivery) + (b.krs.length ? ' <span class="muted">→ ' + esc(b.krs.join(', ')) + '</span>' : '') + '</li>'; }));
|
|
195
|
+
return '<div class="card"><div class="row"><strong>' + esc(p.name) + '</strong>' + chip(p.state, cls[p.state] || '') + '</div>' +
|
|
196
|
+
'<div class="muted">' + esc(p.repo || 'no repo yet') + (p.via ? ' · below ' + esc(p.via) : '') + (p.fetched ? ' · read ' + esc(p.fetched) : '') + '</div>' +
|
|
197
|
+
(p.reason ? '<div class="muted" style="margin-top:6px">' + esc(p.reason) + '</div>' : '') +
|
|
198
|
+
rows.map(function (x) { return '<div style="margin-top:8px">' + x + '</div>'; }).join('') +
|
|
199
|
+
(refs.length ? '<ul class="checkins" style="margin-top:8px">' + refs.join('') + '</ul>' : '') + '</div>';
|
|
200
|
+
}).join('');
|
|
201
|
+
return '<h1>Rollout</h1><p class="muted">' + joined + ' of ' + r.plans.length + ' plans below this one have joined. Read from copies fetched by nikos planning rollout.</p><div class="cards">' + cards + '</div>';
|
|
202
|
+
}
|
|
203
|
+
|
|
179
204
|
function krCard(k) {
|
|
180
205
|
var chips = '';
|
|
181
206
|
if (k.stale) chips += chip('Stale', 'amber');
|
|
@@ -403,7 +428,7 @@ main.addEventListener('click', function (e) {
|
|
|
403
428
|
export function renderDashboard({ data, token, readOnly }) {
|
|
404
429
|
const live = !readOnly;
|
|
405
430
|
const json = JSON.stringify(data).replace(/[<\u2028\u2029]/g, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`);
|
|
406
|
-
const nav = `<a data-nav="krs" href="#/krs">Key results</a><a data-nav="bets" href="#/bets">Bets</a><a data-nav="timeline" href="#/timeline">Timeline</a
|
|
431
|
+
const nav = `<a data-nav="krs" href="#/krs">Key results</a><a data-nav="bets" href="#/bets">Bets</a><a data-nav="timeline" href="#/timeline">Timeline</a>${data.downstream ? `<a data-nav="rollout" href="#/rollout">Rollout</a>` : ""}<a data-nav="guide" href="#/guide">How it works</a>`;
|
|
407
432
|
// The link out appears only in the served dashboard: an export is a closed file with no outside links.
|
|
408
433
|
const links = live
|
|
409
434
|
? `<div class="links"><a href="https://www.nikovijay.com/connect?from=nikos-planning" target="_blank" rel="noopener">Made by Niko Vijay</a></div>\n`
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
export interface UpstreamEntry {
|
|
2
|
+
/** The repo's path on its host: owner/repo, or group/subgroup/project. */
|
|
3
|
+
repo: string;
|
|
4
|
+
ref: string;
|
|
5
|
+
url: string;
|
|
6
|
+
}
|
|
7
|
+
export interface LockEntry {
|
|
8
|
+
repo: string;
|
|
9
|
+
ref: string;
|
|
10
|
+
commit: string;
|
|
11
|
+
synced: string;
|
|
12
|
+
/** For a plan further up the chain: the upstream that named it. */
|
|
13
|
+
via?: string;
|
|
14
|
+
}
|
|
15
|
+
export declare const git: (args: string[], cwd?: string) => import("child_process").SpawnSyncReturns<string>;
|
|
16
|
+
/**
|
|
17
|
+
* The upstream plans listed in planning/upstream.yaml. A repo is its path on the host; the url is
|
|
18
|
+
* https://<host>/<repo>.git, with host github.com unless the entry names one (gitlab.com, or a
|
|
19
|
+
* self-managed host), and url: overrides it for anything else.
|
|
20
|
+
*/
|
|
21
|
+
export declare function upstreamEntries(root: string): UpstreamEntry[];
|
|
22
|
+
/** Check one { repo, ref, host, url } entry from a plan's yaml list, and work out its url. */
|
|
23
|
+
export declare function repoEntry(e: any, file: string): UpstreamEntry;
|
|
24
|
+
export declare function readLock(root: string): LockEntry[];
|
|
25
|
+
/** Make sure .nikos/ is gitignored, so upstream copies are never committed. */
|
|
26
|
+
export declare function ignoreCache(root: string): void;
|
|
27
|
+
export interface SyncReport {
|
|
28
|
+
synced: LockEntry[];
|
|
29
|
+
unresolved: {
|
|
30
|
+
repo: string;
|
|
31
|
+
reason: string;
|
|
32
|
+
}[];
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Fetch every upstream plan read-only, and the plans above those: a team that names its
|
|
36
|
+
* department also gets the department's company, so settings and plans reach every level.
|
|
37
|
+
* Unreachable ones are reported, never fatal.
|
|
38
|
+
*/
|
|
39
|
+
export declare function sync(root: string): SyncReport;
|
|
40
|
+
/** Compare each locked commit with the upstream's current one. */
|
|
41
|
+
export declare function checkStale(root: string): {
|
|
42
|
+
stale: {
|
|
43
|
+
repo: string;
|
|
44
|
+
locked: string;
|
|
45
|
+
current: string;
|
|
46
|
+
}[];
|
|
47
|
+
unresolved: {
|
|
48
|
+
repo: string;
|
|
49
|
+
reason: string;
|
|
50
|
+
}[];
|
|
51
|
+
};
|
|
52
|
+
/** The read-only copies on disk: each upstream's key results and bets. */
|
|
53
|
+
export declare function loadUpstream(root: string): {
|
|
54
|
+
repo: string;
|
|
55
|
+
commit?: string;
|
|
56
|
+
krs: any[];
|
|
57
|
+
bets: any[];
|
|
58
|
+
}[];
|
|
59
|
+
/** Find owner/repo#id among the synced copies. */
|
|
60
|
+
export declare function resolveRef(root: string, ref: string): {
|
|
61
|
+
repo: string;
|
|
62
|
+
kind: "kr" | "bet";
|
|
63
|
+
data: any;
|
|
64
|
+
} | undefined;
|
|
65
|
+
/** Every owner/repo#id this repo's planning files and story map name, with whether it resolves. */
|
|
66
|
+
export declare function crossRefs(root: string): {
|
|
67
|
+
ref: string;
|
|
68
|
+
file: string;
|
|
69
|
+
resolved: boolean;
|
|
70
|
+
}[];
|
|
71
|
+
export declare function runSync(root: string, opts?: {
|
|
72
|
+
check?: boolean;
|
|
73
|
+
}): Promise<number>;
|
|
@@ -1,17 +1,24 @@
|
|
|
1
1
|
// Plans this repo works toward, in other repos. planning/upstream.yaml lists them;
|
|
2
|
-
// `nikos planning sync` fetches a read-only copy of each into .nikos/upstream/<
|
|
2
|
+
// `nikos planning sync` fetches a read-only copy of each into .nikos/upstream/<repo path>/
|
|
3
3
|
// (gitignored) and records the commit it read in planning/upstream.lock. This repo only ever
|
|
4
|
-
// stores references like owner/repo#id, never the upstream content.
|
|
4
|
+
// stores references like owner/repo#id, never the upstream content. Any git host works:
|
|
5
|
+
// GitHub by default, GitLab (with nested groups) or a self-managed host through `host:`, and
|
|
6
|
+
// anything else through `url:`.
|
|
5
7
|
import { spawnSync } from "node:child_process";
|
|
6
|
-
import { appendFileSync, existsSync, readFileSync, rmSync } from "node:fs";
|
|
8
|
+
import { appendFileSync, existsSync, readdirSync, readFileSync, rmSync } from "node:fs";
|
|
7
9
|
import { basename, join } from "node:path";
|
|
8
10
|
import { parse, stringify } from "yaml";
|
|
9
|
-
import { CROSS_REF } from "../lib/bets.js";
|
|
11
|
+
import { CROSS_REF, REPO_PATH } from "../lib/bets.js";
|
|
10
12
|
import { parseFrontMatter } from "../lib/frontmatter.js";
|
|
11
13
|
import { walk } from "../lib/project.js";
|
|
12
14
|
import { betIds, krIds, loadBet, loadKr, paths, today, writePlanning } from "./model.js";
|
|
13
|
-
const git = (args, cwd) => spawnSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, GIT_TERMINAL_PROMPT: "0" } });
|
|
14
|
-
|
|
15
|
+
export const git = (args, cwd) => spawnSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, GIT_TERMINAL_PROMPT: "0" } });
|
|
16
|
+
const HOST = /^[A-Za-z0-9.-]+(:[0-9]+)?$/;
|
|
17
|
+
/**
|
|
18
|
+
* The upstream plans listed in planning/upstream.yaml. A repo is its path on the host; the url is
|
|
19
|
+
* https://<host>/<repo>.git, with host github.com unless the entry names one (gitlab.com, or a
|
|
20
|
+
* self-managed host), and url: overrides it for anything else.
|
|
21
|
+
*/
|
|
15
22
|
export function upstreamEntries(root) {
|
|
16
23
|
const p = paths(root).upstream;
|
|
17
24
|
if (!existsSync(p))
|
|
@@ -19,11 +26,16 @@ export function upstreamEntries(root) {
|
|
|
19
26
|
const list = parse(readFileSync(p, "utf8")) ?? [];
|
|
20
27
|
if (!Array.isArray(list))
|
|
21
28
|
throw new Error("planning/upstream.yaml should be a list of { repo, ref }");
|
|
22
|
-
return list.map((e) =>
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
29
|
+
return list.map((e) => repoEntry(e, "planning/upstream.yaml"));
|
|
30
|
+
}
|
|
31
|
+
/** Check one { repo, ref, host, url } entry from a plan's yaml list, and work out its url. */
|
|
32
|
+
export function repoEntry(e, file) {
|
|
33
|
+
if (typeof e?.repo !== "string" || !REPO_PATH.test(e.repo)) {
|
|
34
|
+
throw new Error(`${file}: '${e?.repo}' is not a repo path (owner/repo, or group/subgroup/project on GitLab)`);
|
|
35
|
+
}
|
|
36
|
+
if (e.host !== undefined && (typeof e.host !== "string" || !HOST.test(e.host)))
|
|
37
|
+
throw new Error(`${file}: '${e.host}' is not a host name, e.g. gitlab.com`);
|
|
38
|
+
return { repo: e.repo, ref: e.ref ?? "main", url: e.url ?? `https://${e.host ?? "github.com"}/${e.repo}.git` };
|
|
27
39
|
}
|
|
28
40
|
export function readLock(root) {
|
|
29
41
|
const p = paths(root).lock;
|
|
@@ -31,18 +43,27 @@ export function readLock(root) {
|
|
|
31
43
|
}
|
|
32
44
|
const cacheDir = (root, repo) => join(paths(root).cache, ...repo.split("/"));
|
|
33
45
|
/** Make sure .nikos/ is gitignored, so upstream copies are never committed. */
|
|
34
|
-
function ignoreCache(root) {
|
|
46
|
+
export function ignoreCache(root) {
|
|
35
47
|
const p = join(root, ".gitignore");
|
|
36
48
|
const text = existsSync(p) ? readFileSync(p, "utf8") : "";
|
|
37
49
|
if (!/^\/?\.nikos\/?$/m.test(text))
|
|
38
50
|
appendFileSync(p, `${text && !text.endsWith("\n") ? "\n" : ""}# nikos planning: read-only copies of upstream plans\n.nikos/\n`);
|
|
39
51
|
}
|
|
40
|
-
/**
|
|
52
|
+
/** How many levels up sync follows: team → group → department → company, with room to spare. */
|
|
53
|
+
const MAX_DEPTH = 6;
|
|
54
|
+
/**
|
|
55
|
+
* Fetch every upstream plan read-only, and the plans above those: a team that names its
|
|
56
|
+
* department also gets the department's company, so settings and plans reach every level.
|
|
57
|
+
* Unreachable ones are reported, never fatal.
|
|
58
|
+
*/
|
|
41
59
|
export function sync(root) {
|
|
42
60
|
ignoreCache(root);
|
|
43
61
|
const report = { synced: [], unresolved: [] };
|
|
44
62
|
const previous = readLock(root);
|
|
45
|
-
|
|
63
|
+
const queue = upstreamEntries(root).map((entry) => ({ entry, depth: 1 }));
|
|
64
|
+
const seen = new Set(queue.map((q) => q.entry.repo));
|
|
65
|
+
while (queue.length) {
|
|
66
|
+
const { entry: e, depth, via } = queue.shift();
|
|
46
67
|
const dir = cacheDir(root, e.repo);
|
|
47
68
|
rmSync(dir, { recursive: true, force: true });
|
|
48
69
|
const clone = git(["clone", "--quiet", "--depth", "1", "--branch", e.ref, e.url, dir]);
|
|
@@ -56,14 +77,42 @@ export function sync(root) {
|
|
|
56
77
|
}
|
|
57
78
|
const commit = git(["rev-parse", "HEAD"], dir).stdout.trim();
|
|
58
79
|
rmSync(join(dir, ".git"), { recursive: true, force: true }); // a plain copy: nothing to push back
|
|
59
|
-
report.synced.push({ repo: e.repo, ref: e.ref, commit, synced: today() });
|
|
80
|
+
report.synced.push({ repo: e.repo, ref: e.ref, commit, synced: today(), ...(via ? { via } : {}) });
|
|
81
|
+
if (depth >= MAX_DEPTH)
|
|
82
|
+
continue;
|
|
83
|
+
let above = [];
|
|
84
|
+
try {
|
|
85
|
+
above = upstreamEntries(dir);
|
|
86
|
+
}
|
|
87
|
+
catch (err) {
|
|
88
|
+
report.unresolved.push({ repo: `${e.repo}'s upstream.yaml`, reason: err.message });
|
|
89
|
+
}
|
|
90
|
+
for (const up of above) {
|
|
91
|
+
if (seen.has(up.repo))
|
|
92
|
+
continue;
|
|
93
|
+
seen.add(up.repo);
|
|
94
|
+
queue.push({ entry: up, depth: depth + 1, via: e.repo });
|
|
95
|
+
}
|
|
60
96
|
}
|
|
61
97
|
writePlanning(root, paths(root).lock, `# Written by nikos planning sync: the upstream commit each plan was read at.\n${stringify(report.synced)}`);
|
|
62
98
|
return report;
|
|
63
99
|
}
|
|
64
100
|
/** Compare each locked commit with the upstream's current one. */
|
|
65
101
|
export function checkStale(root) {
|
|
66
|
-
|
|
102
|
+
// This repo's own upstreams, and the ones further up that their copies name.
|
|
103
|
+
const entries = [...upstreamEntries(root)];
|
|
104
|
+
for (const l of readLock(root)) {
|
|
105
|
+
if (!REPO_PATH.test(l.repo))
|
|
106
|
+
continue;
|
|
107
|
+
try {
|
|
108
|
+
for (const e of upstreamEntries(cacheDir(root, l.repo)))
|
|
109
|
+
if (!entries.some((x) => x.repo === e.repo))
|
|
110
|
+
entries.push(e);
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
// A copy with a broken upstream.yaml was reported at sync.
|
|
114
|
+
}
|
|
115
|
+
}
|
|
67
116
|
const out = { stale: [], unresolved: [] };
|
|
68
117
|
for (const l of readLock(root)) {
|
|
69
118
|
const e = entries.find((x) => x.repo === l.repo);
|
|
@@ -78,13 +127,22 @@ export function checkStale(root) {
|
|
|
78
127
|
}
|
|
79
128
|
return out;
|
|
80
129
|
}
|
|
130
|
+
/** The repo paths under .nikos/upstream/ that hold a planning/ folder, at any group depth. */
|
|
131
|
+
function copiesOnDisk(cache, prefix = "", depth = 0) {
|
|
132
|
+
if (depth > 8 || !existsSync(cache))
|
|
133
|
+
return [];
|
|
134
|
+
return readdirSync(cache, { withFileTypes: true })
|
|
135
|
+
.filter((d) => d.isDirectory() && d.name !== "planning")
|
|
136
|
+
.flatMap((d) => {
|
|
137
|
+
const path = prefix ? `${prefix}/${d.name}` : d.name;
|
|
138
|
+
return existsSync(join(cache, d.name, "planning")) ? [path] : copiesOnDisk(join(cache, d.name), path, depth + 1);
|
|
139
|
+
});
|
|
140
|
+
}
|
|
81
141
|
/** The read-only copies on disk: each upstream's key results and bets. */
|
|
82
142
|
export function loadUpstream(root) {
|
|
83
143
|
const lock = readLock(root);
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
for (const owner of walk(paths(root).cache).map((p) => p.slice(paths(root).cache.length + 1).split(/[\\/]/).slice(0, 2).join("/")))
|
|
87
|
-
repos.add(owner);
|
|
144
|
+
// Locked repos, plus copies whose lock entry is missing (e.g. placed by hand).
|
|
145
|
+
const repos = new Set([...lock.map((l) => l.repo), ...copiesOnDisk(paths(root).cache)].filter((r) => REPO_PATH.test(r)));
|
|
88
146
|
const out = [];
|
|
89
147
|
for (const repo of repos) {
|
|
90
148
|
const root2 = cacheDir(root, repo);
|