diff --git a/src/lib/lane-config.test.ts b/src/lib/lane-config.test.ts new file mode 100644 index 0000000..2d06318 --- /dev/null +++ b/src/lib/lane-config.test.ts @@ -0,0 +1,174 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { + getBacklogLane, + getClaimableLanes, + getConfiguredLanes, + getLaneById, + getLaneIds, + isClaimableLane, + isValidLane, + resetLaneConfig, + setLaneConfig, +} from "./lane-config"; + +describe("lane-config defaults", () => { + it("returns three default lanes", () => { + const lanes = getConfiguredLanes(); + expect(lanes).toHaveLength(3); + expect(lanes.map((l) => l.id)).toEqual(["normal", "escalated", "backlog"]); + }); + + it("normal and escalated are claimable, backlog is not", () => { + expect(isClaimableLane("normal")).toBe(true); + expect(isClaimableLane("escalated")).toBe(true); + expect(isClaimableLane("backlog")).toBe(false); + }); + + it("getClaimableLanes returns only claimable lanes", () => { + const claimable = getClaimableLanes(); + expect(claimable).toHaveLength(2); + expect(claimable.map((l) => l.id)).toEqual(["normal", "escalated"]); + }); + + it("getBacklogLane returns the non-claimable lane", () => { + const backlog = getBacklogLane(); + expect(backlog).toBeDefined(); + expect(backlog?.id).toBe("backlog"); + expect(backlog?.claimable).toBe(false); + }); + + it("isValidLane returns correct values for defaults", () => { + expect(isValidLane("normal")).toBe(true); + expect(isValidLane("escalated")).toBe(true); + expect(isValidLane("backlog")).toBe(true); + expect(isValidLane("unknown")).toBe(false); + }); + + it("getLaneById returns the lane or undefined", () => { + expect(getLaneById("normal")?.title).toBe("Normal"); + expect(getLaneById("nonexistent")).toBeUndefined(); + }); + + it("getLaneIds returns all configured ids", () => { + expect(getLaneIds()).toEqual(["normal", "escalated", "backlog"]); + }); + + it("returns a deep copy (mutations do not leak)", () => { + const lanes = getConfiguredLanes(); + lanes[0].id = "mutated"; + expect(getConfiguredLanes()[0].id).toBe("normal"); + }); +}); + +describe("lane-config custom config", () => { + afterEach(() => { + resetLaneConfig(); + }); + + it("accepts a single claimable lane plus backlog", () => { + setLaneConfig({ + lanes: [ + { id: "default", title: "Default", claimable: true }, + { id: "backlog", title: "Backlog", claimable: false }, + ], + }); + + expect(getConfiguredLanes()).toHaveLength(2); + expect(isClaimableLane("default")).toBe(true); + expect(isClaimableLane("backlog")).toBe(false); + expect(getBacklogLane()?.id).toBe("backlog"); + }); + + it("accepts multiple claimable lanes plus backlog", () => { + setLaneConfig({ + lanes: [ + { id: "fast", title: "Fast Lane", claimable: true }, + { id: "slow", title: "Slow Lane", claimable: true }, + { id: "parked", title: "Parked", claimable: false }, + ], + }); + + expect(getClaimableLanes()).toHaveLength(2); + expect(isClaimableLane("fast")).toBe(true); + expect(isClaimableLane("slow")).toBe(true); + expect(isClaimableLane("parked")).toBe(false); + expect(getBacklogLane()?.id).toBe("parked"); + }); + + it("rejects empty lane array", () => { + expect(() => setLaneConfig({ lanes: [] })).toThrow( + "Lane config must contain at least one lane", + ); + }); + + it("rejects duplicate lane ids", () => { + expect(() => + setLaneConfig({ + lanes: [ + { id: "a", title: "A", claimable: true }, + { id: "a", title: "A dup", claimable: false }, + ], + }), + ).toThrow("Duplicate lane id: a"); + }); + + it("rejects empty lane id", () => { + expect(() => + setLaneConfig({ lanes: [{ id: "", title: "X", claimable: true }] }), + ).toThrow("Lane id must be a non-empty string"); + }); + + it("rejects missing title", () => { + // @ts-expect-error — testing runtime validation + expect(() => setLaneConfig({ lanes: [{ id: "x", claimable: true }] })).toThrow( + 'Lane "x" must have a non-empty title', + ); + }); + + it("rejects all non-claimable lanes", () => { + expect(() => + setLaneConfig({ + lanes: [ + { id: "a", title: "A", claimable: false }, + { id: "b", title: "B", claimable: false }, + ], + }), + ).toThrow("Lane config must contain at least one claimable lane"); + }); + + it("supports optional fields (description, color, defaultAgent)", () => { + setLaneConfig({ + lanes: [ + { + id: "custom", + title: "Custom", + claimable: true, + description: "A custom lane", + color: "#ff0000", + defaultAgent: "custom-agent", + }, + { id: "backlog", title: "Backlog", claimable: false }, + ], + }); + + const lane = getLaneById("custom"); + expect(lane?.description).toBe("A custom lane"); + expect(lane?.color).toBe("#ff0000"); + expect(lane?.defaultAgent).toBe("custom-agent"); + }); +}); + +describe("lane-config reset", () => { + it("resetLaneConfig restores defaults", () => { + setLaneConfig({ + lanes: [ + { id: "only", title: "Only", claimable: true }, + ], + }); + expect(getConfiguredLanes()).toHaveLength(1); + + resetLaneConfig(); + expect(getConfiguredLanes()).toHaveLength(3); + expect(getLaneIds()).toEqual(["normal", "escalated", "backlog"]); + }); +}); diff --git a/src/lib/lane-config.ts b/src/lib/lane-config.ts new file mode 100644 index 0000000..7c80a71 --- /dev/null +++ b/src/lib/lane-config.ts @@ -0,0 +1,181 @@ +/** + * Central lane configuration module. + * + * Provides a typed, configurable lane model so that all lane consumers go + * through one helper instead of scattered string unions and constants. + * + * The default config preserves current behavior (normal / escalated / backlog). + */ + +// ─── Types ──────────────────────────────────────────────────────────────────── + +/** + * A single lane definition. + */ +export interface LaneConfig { + /** Unique identifier (e.g. "normal", "escalated", "backlog") */ + id: string; + /** Display title */ + title: string; + /** Whether workers may claim issues in this lane */ + claimable: boolean; + /** Optional human-readable description */ + description?: string; + /** Optional hex color for UI rendering */ + color?: string; + /** Default agent that handles this lane (optional) */ + defaultAgent?: string; +} + +/** + * Full lane configuration set. + */ +export interface LaneConfigSet { + lanes: LaneConfig[]; +} + +// ─── Defaults ───────────────────────────────────────────────────────────────── + +/** + * Default lane configuration — equivalent to current behavior. + * + * - `normal`: claimable, standard worker lane + * - `escalated`: claimable, requires higher-judgment model support + * - `backlog`: non-claimable, needs grooming before work can start + */ +const DEFAULT_LANE_CONFIG: LaneConfigSet = { + lanes: [ + { + id: "normal", + title: "Normal", + claimable: true, + description: "Standard execution lane for concrete, scoped implementation work.", + color: "#3b82f6", + }, + { + id: "escalated", + title: "Escalated", + claimable: true, + description: "Requires higher-judgment model support (architecture, design, cross-service).", + color: "#f97316", + }, + { + id: "backlog", + title: "Backlog", + claimable: false, + description: "Needs grooming before work can start. Not directly claimable.", + color: "#6b7280", + }, + ], +}; + +// ─── Config State ───────────────────────────────────────────────────────────── + +let laneConfigSet: LaneConfigSet = DEFAULT_LANE_CONFIG; + +/** + * Override the lane configuration (e.g. from environment or custom config file). + * + * Validates that every lane has a unique, non-empty id and that at least one + * claimable lane exists. Throws on invalid config. + */ +export function setLaneConfig(config: LaneConfigSet): void { + validateLaneConfigSet(config); + laneConfigSet = config; +} + +/** + * Reset to the default lane configuration. + */ +export function resetLaneConfig(): void { + laneConfigSet = DEFAULT_LANE_CONFIG; +} + +// ─── Public Helpers ─────────────────────────────────────────────────────────── + +/** + * Return all configured lanes (deep copy). + */ +export function getConfiguredLanes(): LaneConfig[] { + return laneConfigSet.lanes.map((lane) => ({ ...lane })); +} + +/** + * Return only claimable lanes. + */ +export function getClaimableLanes(): LaneConfig[] { + return getConfiguredLanes().filter((lane) => lane.claimable); +} + +/** + * Return the non-claimable "backlog" fallback lane, if configured. + * Returns `undefined` when no non-claimable lane exists. + */ +export function getBacklogLane(): LaneConfig | undefined { + return getConfiguredLanes().find((lane) => !lane.claimable); +} + +/** + * Check whether a lane id is configured. + */ +export function isValidLane(id: string): boolean { + return getConfiguredLanes().some((lane) => lane.id === id); +} + +/** + * Check whether a lane id is both configured and claimable. + */ +export function isClaimableLane(id: string): boolean { + return getConfiguredLanes().some((lane) => lane.id === id && lane.claimable); +} + +/** + * Look up a lane by id. Returns `undefined` when not found. + */ +export function getLaneById(id: string): LaneConfig | undefined { + return getConfiguredLanes().find((lane) => lane.id === id); +} + +/** + * Return all configured lane ids. + */ +export function getLaneIds(): string[] { + return getConfiguredLanes().map((lane) => lane.id); +} + +// ─── Validation ─────────────────────────────────────────────────────────────── + +/** + * Validate a LaneConfigSet and throw on errors. + */ +function validateLaneConfigSet(config: LaneConfigSet): void { + if (!Array.isArray(config.lanes) || config.lanes.length === 0) { + throw new Error("Lane config must contain at least one lane"); + } + + const ids = new Set(); + + for (const lane of config.lanes) { + if (typeof lane.id !== "string" || lane.id.trim().length === 0) { + throw new Error(`Lane id must be a non-empty string, got: ${JSON.stringify(lane.id)}`); + } + + if (ids.has(lane.id)) { + throw new Error(`Duplicate lane id: ${lane.id}`); + } + ids.add(lane.id); + + if (typeof lane.title !== "string" || lane.title.trim().length === 0) { + throw new Error(`Lane "${lane.id}" must have a non-empty title`); + } + + if (typeof lane.claimable !== "boolean") { + throw new Error(`Lane "${lane.id}" must have a boolean claimable field`); + } + } + + const hasClaimable = config.lanes.some((l) => l.claimable); + if (!hasClaimable) { + throw new Error("Lane config must contain at least one claimable lane"); + } +}