Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions src/commands/triage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import type { Command } from "commander";
import { findSeedsDir } from "../config.ts";
import { computeMetrics } from "../graph.ts";
import { accent, brand, muted, outputJson, printIssueOneLine } from "../output.ts";
import { readIssues } from "../store.ts";
import type { Issue } from "../types.ts";

interface TriageEntry {
id: string;
title: string;
status: Issue["status"];
priority: number;
pagerank: number;
betweenness: number;
criticalPathLength: number;
score: number;
}

export async function run(
opts: { json?: boolean; limit?: string },
seedsDir?: string,
): Promise<void> {
const jsonMode = opts.json === true;
const limit = opts.limit !== undefined ? Number(opts.limit) : 0;

const dir = seedsDir ?? (await findSeedsDir());
const issues = await readIssues(dir);

// Only open issues participate — same eligibility as `sd ready`
const closedIds = new Set(issues.filter((i) => i.status === "closed").map((i) => i.id));
const openIssues = issues.filter((i) => i.status !== "closed");

// Compute metrics across all open issues (blocked ones inform the graph)
const metrics = computeMetrics(openIssues);

// Ready = open + all blockers closed
const ready = openIssues.filter((i) => (i.blockedBy ?? []).every((bid) => closedIds.has(bid)));

// Sort ready issues by composite score descending
const ranked = ready
.map((issue): TriageEntry => {
const m = metrics.get(issue.id);
return {
id: issue.id,
title: issue.title,
status: issue.status,
priority: issue.priority,
pagerank: m?.pagerank ?? 0,
betweenness: m?.betweenness ?? 0,
criticalPathLength: m?.criticalPathLength ?? 0,
score: m?.score ?? 0,
};
})
.sort((a, b) => b.score - a.score || a.priority - b.priority);

const output = limit > 0 ? ranked.slice(0, limit) : ranked;

if (jsonMode) {
outputJson({ success: true, command: "triage", issues: output, count: output.length });
return;
}

if (output.length === 0) {
console.log(muted("No ready issues."));
return;
}

for (const entry of output) {
const issue = issues.find((i) => i.id === entry.id);
if (issue) {
printIssueOneLine(issue);
const scoreStr = brand(`${(entry.score * 100).toFixed(0)}pts`);
const cpStr = entry.criticalPathLength > 0 ? ` · cp:${entry.criticalPathLength}` : "";
const bStr = entry.betweenness > 0.01 ? ` · btw:${entry.betweenness.toFixed(2)}` : "";
console.log(` ${muted("score:")} ${scoreStr}${cpStr}${bStr}`);
}
}
console.log(`\n${accent(`${output.length} ready issue(s)`)} ${muted("(ranked by graph score)")}`);
}

export function register(program: Command): void {
program
.command("triage")
.description("Ready issues ranked by graph score (PageRank + betweenness + critical path)")
.option("--json", "Output as JSON")
.option("--limit <n>", "Return top N issues only")
.action(async (opts: { json?: boolean; limit?: string }) => {
await run(opts);
});
}
88 changes: 88 additions & 0 deletions src/graph.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { describe, expect, it } from "bun:test";
import { computeMetrics } from "./graph.ts";
import type { Issue } from "./types.ts";

function makeIssue(id: string, blocks: string[] = [], blockedBy: string[] = []): Issue {
return {
id,
title: id,
status: "open",
type: "task",
priority: 2,
blocks,
blockedBy,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
}

describe("computeMetrics", () => {
it("returns empty map for no issues", () => {
const m = computeMetrics([]);
expect(m.size).toBe(0);
});

it("returns metrics for a single issue", () => {
const m = computeMetrics([makeIssue("a")]);
expect(m.has("a")).toBe(true);
const entry = m.get("a");
expect(entry).toBeDefined();
expect(entry?.criticalPathLength).toBe(0);
expect(entry?.score).toBeGreaterThanOrEqual(0);
});

it("critical path: linear chain a→b→c gives a=2, b=1, c=0", () => {
// a blocks b, b blocks c
const a = makeIssue("a", ["b"], []);
const b = makeIssue("b", ["c"], ["a"]);
const c = makeIssue("c", [], ["b"]);
const m = computeMetrics([a, b, c]);
expect(m.get("a")?.criticalPathLength).toBe(2);
expect(m.get("b")?.criticalPathLength).toBe(1);
expect(m.get("c")?.criticalPathLength).toBe(0);
});

it("bottleneck ranks higher than leaf in a diamond graph", () => {
// a→b, a→c, b→d, c→d — a and d are the bottleneck/sink
const a = makeIssue("a", ["b", "c"], []);
const b = makeIssue("b", ["d"], ["a"]);
const c = makeIssue("c", ["d"], ["a"]);
const d = makeIssue("d", [], ["b", "c"]);
const m = computeMetrics([a, b, c, d]);

// a has the longest critical path (2: a→b→d or a→c→d) and blocks the most
expect(m.get("a")?.criticalPathLength).toBe(2);
// b and c are equivalent leaves with cp=1
expect(m.get("b")?.criticalPathLength).toBe(1);
expect(m.get("c")?.criticalPathLength).toBe(1);
// d is a terminal with cp=0
expect(m.get("d")?.criticalPathLength).toBe(0);

// a should score higher than b or c (it unblocks more work)
expect(m.get("a")?.score).toBeGreaterThan(m.get("b")?.score ?? 0);
expect(m.get("a")?.score).toBeGreaterThan(m.get("c")?.score ?? 0);
});

it("scores are in [0,1] range", () => {
const issues = [
makeIssue("a", ["b", "c"]),
makeIssue("b", ["d"], ["a"]),
makeIssue("c", ["d"], ["a"]),
makeIssue("d", [], ["b", "c"]),
];
const m = computeMetrics(issues);
for (const [, entry] of m) {
expect(entry.score).toBeGreaterThanOrEqual(0);
expect(entry.score).toBeLessThanOrEqual(1);
}
});

it("ignores edges to issues not in the set", () => {
// b references external id "z" that isn't in the list
const a = makeIssue("a", ["b"]);
const b = makeIssue("b", ["z"], ["a"]);
expect(() => computeMetrics([a, b])).not.toThrow();
const m = computeMetrics([a, b]);
expect(m.get("b")?.criticalPathLength).toBe(0); // z filtered out
});
});
Loading