mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-07 05:56:21 -10:00
fix(cli): scope slice ls and mission ls tell shipped, superseded and closed apart (#2434)
`rig scope` now gives each slice one standing and counts it once. A slice in `closed/` is closed; one whose status is shipped* or done is shipped; one whose status is closed* or superseded, or whose stage is superseded or retired, is closed; everything else is active, blocked included. `slice ls --state` filters on that standing (the default stays `active`, and the help says what each value means), and `mission ls` and `mission show` count by it, with a new `shipped=` count, so a mission whose slices all shipped no longer reads as all active. A malformed slice file reads as empty frontmatter instead of throwing. The advisory dependency graph reads the active set, so it no longer offers a superseded slice as ready to build. The daemon's copy of the scope types matches the CLI's. Reported in #2427; thanks to @gifflen.
This commit is contained in:
1 parent
6a35cd39f3
commit
51c2bd9d4d
6 files changed
+97
-54
No files matched your search
@@ -213,7 +213,7 @@ function buildSliceLsCommand(): Command {
|
||||
const cmd = new Command("ls")
|
||||
.description("List slices in a mission (or across all missions)")
|
||||
.option("--mission <name>", "Restrict to a single mission")
|
||||
.option("--state <state>", "Filter: active | closed | shipped | all", "active")
|
||||
.option("--state <state>", "Filter: active (in flight, blocked included) | shipped (delivered in place: shipped or done) | closed (in closed/, or superseded, retired or closed in place) | all", "active")
|
||||
.option("--json", "Machine-readable output")
|
||||
.action(async (opts, command) => {
|
||||
const out = makeStdout();
|
||||
@@ -727,12 +727,13 @@ function buildMissionLsCommand(): Command {
|
||||
id: m.id,
|
||||
path: m.absPath,
|
||||
activeSliceCount: m.activeSliceCount,
|
||||
shippedSliceCount: m.shippedSliceCount,
|
||||
closedSliceCount: m.closedSliceCount,
|
||||
}));
|
||||
emit(out, { ok: true, count: rows.length, missions: rows }, json,
|
||||
rows.length === 0
|
||||
? ["(no missions)"]
|
||||
: rows.map((r) => `${r.name} ${r.id ?? "—"} active=${r.activeSliceCount} closed=${r.closedSliceCount}`),
|
||||
: rows.map((r) => `${r.name} ${r.id ?? "—"} active=${r.activeSliceCount} shipped=${r.shippedSliceCount} closed=${r.closedSliceCount}`),
|
||||
);
|
||||
} catch (err) {
|
||||
fail(err, json, out);
|
||||
@@ -764,6 +765,7 @@ function buildMissionShowCommand(): Command {
|
||||
id: mission.id,
|
||||
path: mission.absPath,
|
||||
activeSliceCount: mission.activeSliceCount,
|
||||
shippedSliceCount: mission.shippedSliceCount,
|
||||
closedSliceCount: mission.closedSliceCount,
|
||||
frontmatter: mission.frontmatter,
|
||||
trust,
|
||||
@@ -778,6 +780,7 @@ function buildMissionShowCommand(): Command {
|
||||
out.write(` id: ${mission.id ?? "—"}\n`);
|
||||
out.write(formatTrustLine(trust));
|
||||
out.write(` active slices: ${mission.activeSliceCount}\n`);
|
||||
out.write(` shipped slices: ${mission.shippedSliceCount}\n`);
|
||||
out.write(` closed slices: ${mission.closedSliceCount}\n`);
|
||||
out.write(` path: ${mission.absPath}\n`);
|
||||
if (readme) {
|
||||
@@ -1100,6 +1103,7 @@ function buildAuditCommand(): Command {
|
||||
frontmatter: {},
|
||||
id: null,
|
||||
activeSliceCount: 0,
|
||||
shippedSliceCount: 0,
|
||||
closedSliceCount: 0,
|
||||
};
|
||||
const graph = buildMissionDependencyGraph(auditMission);
|
||||
|
||||
@@ -362,31 +362,23 @@ export function listMissions(missionsRoot: string): MissionInfo[] {
|
||||
const hasReadme = readmePath !== null;
|
||||
if (!hasReadme) continue;
|
||||
const frontmatter = readFrontmatter(readmePath);
|
||||
const slicesDir = path.join(absPath, "slices");
|
||||
const closedDir = path.join(absPath, "closed");
|
||||
const activeSliceCount = countSliceDirs(slicesDir);
|
||||
const closedSliceCount = countSliceDirs(closedDir);
|
||||
const id = pickIdFromFrontmatter(frontmatter);
|
||||
out.push({
|
||||
name: entry.name,
|
||||
absPath,
|
||||
readmePath: hasReadme ? readmePath : null,
|
||||
frontmatter,
|
||||
id,
|
||||
activeSliceCount,
|
||||
closedSliceCount,
|
||||
});
|
||||
out.push(withSliceCounts({ name: entry.name, absPath, readmePath, frontmatter, id }));
|
||||
}
|
||||
// Stable order: by mission name.
|
||||
out.sort((a, b) => a.name.localeCompare(b.name));
|
||||
return out;
|
||||
}
|
||||
|
||||
function countSliceDirs(dir: string): number {
|
||||
if (!fs.existsSync(dir)) return 0;
|
||||
return fs.readdirSync(dir, { withFileTypes: true })
|
||||
.filter((e) => e.isDirectory() && SLICE_DIRNAME_RE.test(e.name))
|
||||
.length;
|
||||
/** The mission with each of its slices counted once, by its standing (the same one `slice ls` filters on). */
|
||||
function withSliceCounts(base: Omit<MissionInfo, "activeSliceCount" | "shippedSliceCount" | "closedSliceCount">): MissionInfo {
|
||||
const mission: MissionInfo = { ...base, activeSliceCount: 0, shippedSliceCount: 0, closedSliceCount: 0 };
|
||||
for (const { standing } of slicesWithStanding(mission)) {
|
||||
if (standing === "active") mission.activeSliceCount++;
|
||||
else if (standing === "shipped") mission.shippedSliceCount++;
|
||||
else mission.closedSliceCount++;
|
||||
}
|
||||
return mission;
|
||||
}
|
||||
|
||||
function pickIdFromFrontmatter(fm: Record<string, unknown>): string | null {
|
||||
@@ -425,15 +417,7 @@ export function findMission(missionsRoot: string, identifier: string): MissionIn
|
||||
function buildMissionInfo(missionsRoot: string, absPath: string): MissionInfo {
|
||||
const readmePath = resolveNodeFile(absPath);
|
||||
const frontmatter = readmePath ? readFrontmatter(readmePath) : {};
|
||||
return {
|
||||
name: path.basename(absPath),
|
||||
absPath,
|
||||
readmePath,
|
||||
frontmatter,
|
||||
id: pickIdFromFrontmatter(frontmatter),
|
||||
activeSliceCount: countSliceDirs(path.join(absPath, "slices")),
|
||||
closedSliceCount: countSliceDirs(path.join(absPath, "closed")),
|
||||
};
|
||||
return withSliceCounts({ name: path.basename(absPath), absPath, readmePath, frontmatter, id: pickIdFromFrontmatter(frontmatter) });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
@@ -444,35 +428,35 @@ export function listSlices(
|
||||
mission: MissionInfo,
|
||||
state: SliceState,
|
||||
): SliceInfo[] {
|
||||
const dirs: Array<{ root: string; bucket: "active" | "closed" }> = [];
|
||||
if (state === "active" || state === "shipped" || state === "all") {
|
||||
dirs.push({ root: path.join(mission.absPath, "slices"), bucket: "active" });
|
||||
}
|
||||
if (state === "closed" || state === "all") {
|
||||
dirs.push({ root: path.join(mission.absPath, "closed"), bucket: "closed" });
|
||||
}
|
||||
const out: SliceInfo[] = [];
|
||||
for (const { root } of dirs) {
|
||||
return slicesWithStanding(mission)
|
||||
.filter(({ standing }) => state === "all" || standing === state)
|
||||
.map(({ slice }) => slice);
|
||||
}
|
||||
|
||||
/** Where a slice stands. In closed/, it's closed. In place: shipped (or done) is delivered; a closed or superseded
|
||||
* status, or a superseded or retired stage, is an exit, so closed; anything else, blocked work included, is active. */
|
||||
export function sliceStanding(slice: SliceInfo, inClosedFolder: boolean): Exclude<SliceState, "all"> {
|
||||
if (inClosedFolder) return "closed";
|
||||
const status = (slice.status ?? "").toLowerCase();
|
||||
const stage = typeof slice.frontmatter.stage === "string" ? slice.frontmatter.stage.toLowerCase() : "";
|
||||
if (status === "done" || status.startsWith("shipped")) return "shipped";
|
||||
if (status.startsWith("closed") || status === "superseded" || stage === "superseded" || stage === "retired") return "closed";
|
||||
return "active";
|
||||
}
|
||||
|
||||
/** Every slice in the mission (slices/ and closed/), in order, each with its standing. */
|
||||
function slicesWithStanding(mission: MissionInfo): Array<{ slice: SliceInfo; standing: Exclude<SliceState, "all"> }> {
|
||||
const out: Array<{ slice: SliceInfo; standing: Exclude<SliceState, "all"> }> = [];
|
||||
for (const [folder, inClosedFolder] of [["slices", false], ["closed", true]] as const) {
|
||||
const root = path.join(mission.absPath, folder);
|
||||
if (!fs.existsSync(root)) continue;
|
||||
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const m = SLICE_DIRNAME_RE.exec(entry.name);
|
||||
if (!m) continue;
|
||||
const sliceInfo = buildSliceInfo(mission, root, entry.name);
|
||||
out.push(sliceInfo);
|
||||
if (!entry.isDirectory() || !SLICE_DIRNAME_RE.test(entry.name)) continue;
|
||||
const slice = buildSliceInfo(mission, root, entry.name);
|
||||
out.push({ slice, standing: sliceStanding(slice, inClosedFolder) });
|
||||
}
|
||||
}
|
||||
out.sort((a, b) => (a.nn ?? Number.MAX_SAFE_INTEGER) - (b.nn ?? Number.MAX_SAFE_INTEGER) || a.name.localeCompare(b.name));
|
||||
if (state === "active") {
|
||||
// active = not in closed/, and not already delivered in-place.
|
||||
return out.filter((s) => {
|
||||
const st = (s.status ?? "").toLowerCase();
|
||||
return st !== "done" && !st.startsWith("closed") && !st.startsWith("shipped");
|
||||
});
|
||||
}
|
||||
if (state === "shipped") {
|
||||
return out.filter((s) => (s.status ?? "").toLowerCase().startsWith("shipped"));
|
||||
}
|
||||
out.sort((a, b) => (a.slice.nn ?? Number.MAX_SAFE_INTEGER) - (b.slice.nn ?? Number.MAX_SAFE_INTEGER) || a.slice.name.localeCompare(b.slice.name));
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
@@ -30,7 +30,10 @@ export interface MissionInfo {
|
||||
/** Minted dot-ID for this mission (read from frontmatter if present,
|
||||
* otherwise inferred from the folder name pattern). */
|
||||
id: string | null;
|
||||
/** Slices in flight (blocked included); delivered in place; and closed (closed/, or an exit in place). Each slice is
|
||||
* counted once, by the standing `slice ls --state` filters on. */
|
||||
activeSliceCount: number;
|
||||
shippedSliceCount: number;
|
||||
closedSliceCount: number;
|
||||
}
|
||||
|
||||
|
||||
@@ -678,6 +678,17 @@ describe("rig scope mission ls (HG-8)", () => {
|
||||
const names = parsed.missions.map((m: { name: string }) => m.name).sort();
|
||||
expect(names).toEqual(["backlog", "release-0.3.2"]);
|
||||
});
|
||||
|
||||
it("counts a mission's shipped slices apart from its active ones (#2427)", async () => {
|
||||
for (const nn of ["02-shipped-a", "03-shipped-b"]) {
|
||||
writeFile(path.join(env.missionsRoot, "release-0.3.2", "slices", nn, "README.md"), `---\nid: OPR.0.3.2.${nn[1]}\nstatus: shipped\n---\n# ${nn}\n`);
|
||||
}
|
||||
const text = await run(["mission", "ls"], env.missionsRoot);
|
||||
expect(text.stdout).toContain("release-0.3.2 OPR.0.3.2 active=1 shipped=2 closed=0");
|
||||
const json = JSON.parse((await run(["mission", "ls", "--json"], env.missionsRoot)).stdout);
|
||||
expect(json.missions.find((m: { name: string }) => m.name === "release-0.3.2"))
|
||||
.toMatchObject({ activeSliceCount: 1, shippedSliceCount: 2, closedSliceCount: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("rig scope mission show", () => {
|
||||
|
||||
@@ -326,6 +326,44 @@ describe("listMissions + listSlices + nextSliceNN", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("a slice's standing: in flight, delivered in place, or closed (#2427)", () => {
|
||||
let root: string;
|
||||
let missionsRoot: string;
|
||||
|
||||
beforeEach(() => {
|
||||
root = mktemp();
|
||||
missionsRoot = path.join(root, "missions");
|
||||
writeFile(path.join(missionsRoot, "m1", "README.md"), "---\nid: OPR.9.1\n---\n# m1\n");
|
||||
const slice = (dir: string, frontmatter: string) => writeFile(path.join(missionsRoot, "m1", dir, "README.md"), `---\n${frontmatter}\n---\nbody\n`);
|
||||
slice("slices/01-open", "id: OPR.9.1.1\nstatus: active");
|
||||
slice("slices/02-blocked", "id: OPR.9.1.2\nstatus: blocked");
|
||||
slice("slices/03-shipped", "id: OPR.9.1.3\nstatus: shipped");
|
||||
slice("slices/04-done", "id: OPR.9.1.4\nstatus: done");
|
||||
slice("slices/05-replaced", "id: OPR.9.1.5\nstatus: active\nstage: superseded\nsuperseded-by: OPR.9.1.9");
|
||||
slice("slices/06-superseded", "id: OPR.9.1.6\nstatus: superseded");
|
||||
slice("closed/07-stale", "id: OPR.9.1.7\nstatus: closed-stale");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("slice ls: active is work in flight (blocked included); shipped is delivered in place; closed includes exits in place", () => {
|
||||
const mission = findMission(missionsRoot, "m1");
|
||||
const names = (state: "active" | "shipped" | "closed" | "all") => listSlices(mission, state).map((s) => s.name);
|
||||
expect(names("active")).toEqual(["01-open", "02-blocked"]);
|
||||
expect(names("shipped")).toEqual(["03-shipped", "04-done"]);
|
||||
expect(names("closed")).toEqual(["05-replaced", "06-superseded", "07-stale"]);
|
||||
expect(names("all")).toHaveLength(7);
|
||||
});
|
||||
|
||||
it("mission counts give each slice one standing, the same as slice ls", () => {
|
||||
const counts = { activeSliceCount: 2, shippedSliceCount: 2, closedSliceCount: 3 };
|
||||
expect(listMissions(missionsRoot)[0]).toMatchObject(counts);
|
||||
expect(findMission(missionsRoot, "m1")).toMatchObject(counts);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findSlice + resolution variants", () => {
|
||||
let root: string;
|
||||
let missionsRoot: string;
|
||||
|
||||
@@ -30,7 +30,10 @@ export interface MissionInfo {
|
||||
/** Minted dot-ID for this mission (read from frontmatter if present,
|
||||
* otherwise inferred from the folder name pattern). */
|
||||
id: string | null;
|
||||
/** Slices in flight (blocked included); delivered in place; and closed (closed/, or an exit in place). Each slice is
|
||||
* counted once, by the standing `slice ls --state` filters on. */
|
||||
activeSliceCount: number;
|
||||
shippedSliceCount: number;
|
||||
closedSliceCount: number;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user