diff --git a/src/lib/issue-lane.test.ts b/src/lib/issue-lane.test.ts index 7df7bec..4a360f1 100644 --- a/src/lib/issue-lane.test.ts +++ b/src/lib/issue-lane.test.ts @@ -234,3 +234,121 @@ describe("serializeLaneData", () => { expect((data.reason as string).length).toBeLessThanOrEqual(500); }); }); + +describe("classifyByHeuristics config-aware", () => { + afterEach(() => { + resetLaneConfig(); + }); + + it("default config stays backward-compatible: concrete -> normal", () => { + const result = classifyByHeuristics("Fix login bug", "Login fails when password is wrong", ["priority/p2"]); + expect(result.lane).toBe("normal"); + }); + + it("default config stays backward-compatible: architecture -> escalated", () => { + const result = classifyByHeuristics("Design migration strategy", "Need to plan database migration strategy", ["priority/p1"]); + expect(result.lane).toBe("escalated"); + }); + + it("default config stays backward-compatible: backlog label -> backlog", () => { + const result = classifyByHeuristics("Fix bug", null, ["status/backlog"]); + expect(result.lane).toBe("backlog"); + }); + + it("single claimable lane: actionable goes to that lane", () => { + setLaneConfig({ + lanes: [ + { id: "work", title: "Work", claimable: true }, + { id: "backlog", title: "Backlog", claimable: false }, + ], + }); + const result = classifyByHeuristics("Fix login bug", "Login fails.", ["bug"]); + expect(result.lane).toBe("work"); + }); + + it("single claimable lane: high-complexity goes to same lane (no escalation)", () => { + setLaneConfig({ + lanes: [ + { id: "work", title: "Work", claimable: true }, + { id: "backlog", title: "Backlog", claimable: false }, + ], + }); + const result = classifyByHeuristics("Architecture review", "Design doc for auth.", ["type/feature"]); + expect(result.lane).toBe("work"); + }); + + it("custom escalation lane: high-complexity goes to escalation lane", () => { + setLaneConfig({ + lanes: [ + { id: "normal", title: "Normal", claimable: true, role: "default" }, + { id: "expert", title: "Expert", claimable: true, role: "escalation" }, + { id: "backlog", title: "Backlog", claimable: false }, + ], + }); + const result = classifyByHeuristics("Architecture review", "Design doc for auth.", ["type/feature"]); + expect(result.lane).toBe("expert"); + }); + + it("backlog goes to configured non-claimable lane", () => { + setLaneConfig({ + lanes: [ + { id: "work", title: "Work", claimable: true }, + { id: "parked", title: "Parked", claimable: false }, + ], + }); + const result = classifyByHeuristics("Research options", null, ["type/research"]); + expect(result.lane).toBe("parked"); + }); + + it("no hardcoded lane allowlist: custom lanes work", () => { + setLaneConfig({ + lanes: [ + { id: "fast", title: "Fast Lane", claimable: true }, + { id: "slow", title: "Slow Lane", claimable: true, role: "escalation" }, + { id: "parked", title: "Parked", claimable: false }, + ], + }); + expect(classifyByHeuristics("Fix typo", null, ["bug"]).lane).toBe("fast"); + expect(classifyByHeuristics("RFC: new flow", "Design doc.", ["type/feature"]).lane).toBe("slow"); + expect(classifyByHeuristics("Research", null, ["status/backlog"]).lane).toBe("parked"); + }); +}); + +describe("buildLaneClassificationPrompt config-aware", () => { + afterEach(() => { + resetLaneConfig(); + }); + + it("default config includes normal, escalated, backlog in prompt", () => { + const prompt = buildLaneClassificationPrompt("Test", "body", [], "open"); + expect(prompt).toContain('"normal"|"escalated"|"backlog"'); + expect(prompt).toContain("normal:"); + expect(prompt).toContain("escalated:"); + expect(prompt).toContain("backlog:"); + }); + + it("custom config uses configured lane ids in prompt", () => { + setLaneConfig({ + lanes: [ + { id: "fast", title: "Fast Lane", claimable: true }, + { id: "slow", title: "Slow Lane", claimable: true, role: "escalation" }, + { id: "parked", title: "Parked", claimable: false }, + ], + }); + const prompt = buildLaneClassificationPrompt("Test", "body", [], "open"); + expect(prompt).toContain('"fast"|"slow"|"parked"'); + expect(prompt).not.toContain('"normal"'); + expect(prompt).not.toContain('"escalated"'); + }); + + it("single claimable lane config has only that lane and backlog in prompt", () => { + setLaneConfig({ + lanes: [ + { id: "work", title: "Work", claimable: true }, + { id: "backlog", title: "Backlog", claimable: false }, + ], + }); + const prompt = buildLaneClassificationPrompt("Test", "body", [], "open"); + expect(prompt).toContain('"work"|"backlog"'); + }); +}); diff --git a/src/lib/issue-lane.ts b/src/lib/issue-lane.ts index 962e048..f3f74ff 100644 --- a/src/lib/issue-lane.ts +++ b/src/lib/issue-lane.ts @@ -1,5 +1,5 @@ import { VALID_CONFIDENCE } from "@/types"; -import { isValidLane as isValidLaneConfig } from "@/lib/lane-config"; +import { classifyLaneFromSignals, getConfiguredLanes, getDefaultClaimableLane, getEscalationLane, getBacklogLane, isValidLane as isValidLaneConfig } from "@/lib/lane-config"; /** * A lane classification result for an issue. @@ -96,6 +96,7 @@ export function validateLaneRecord(data: unknown): { /** * Build a prompt for the model to classify an issue's execution lane. * This prompt is generic — no hardcoded agent names, repo names, or owner names. + * Uses configured lanes so the model returns valid lane ids. */ export function buildLaneClassificationPrompt( title: string, @@ -105,22 +106,46 @@ export function buildLaneClassificationPrompt( ): string { const truncatedBody = body ? (body.length > 8000 ? body.slice(0, 8000) + "\n...[truncated]" : body) : "(no body)"; + const lanes = getConfiguredLanes(); + const laneIds = lanes.map((l) => `"${l.id}"`).join("|"); + + // Build lane definitions from config + const defaultLane = getDefaultClaimableLane(); + const escalationLane = getEscalationLane(); + const backlogLane = getBacklogLane(); + + const laneDefinitions: string[] = []; + if (defaultLane) { + laneDefinitions.push( + `- ${defaultLane.id}: concrete, scoped, testable implementation work suitable for a normal worker.`, + ); + } + // Only add escalation definition if it differs from default + if (escalationLane && escalationLane.id !== defaultLane?.id) { + laneDefinitions.push( + `- ${escalationLane.id}: requires higher-judgment model support, such as architecture/security/API/auth boundary design, database/schema migration strategy, distributed/cross-service design, ambiguous product behavior, broad refactor planning, RFC/design/alternatives decisions, or audit parent decomposition.`, + ); + } + if (backlogLane) { + laneDefinitions.push( + `- ${backlogLane.id}: not actionable yet, placeholder, missing enough detail, or a parent/umbrella item with no direct work remaining.`, + ); + } + return `You are a task routing assistant. Classify this GitHub issue into an execution lane. Return ONLY compact JSON with this exact schema: -{"lane":"normal"|"escalated"|"backlog","confidence":"high"|"medium"|"low","reason":"short reason"} +{"lane":${laneIds},"confidence":"high"|"medium"|"low","reason":"short reason"} Lane definitions: -- normal: concrete, scoped, testable implementation work suitable for a normal worker. -- escalated: requires higher-judgment model support, such as architecture/security/API/auth boundary design, database/schema migration strategy, distributed/cross-service design, ambiguous product behavior, broad refactor planning, RFC/design/alternatives decisions, or audit parent decomposition. -- backlog: not actionable yet, placeholder, missing enough detail, or a parent/umbrella item with no direct work remaining. +${laneDefinitions.join("\n")} Routing rules: -- Do not route to escalated only because labels include needs-escalation, escalated, priority/p1, or because the issue came from an audit. -- Do route broad audit parent/umbrella issues to escalated for decomposition/design unless already decomposed. -- Documentation, tests, CI, lint, release/version drift, bounded frontend/backend fixes, and concrete follow-up issues usually go to normal. -- If the issue already contains a reasonable implementation approach and acceptance criteria, prefer normal. -- If confidence is low and the issue is not actionable, choose backlog. +- Do not route to ${escalationLane?.id ?? "escalated"} only because labels include needs-escalation, escalated, priority/p1, or because the issue came from an audit. +- Do route broad audit parent/umbrella issues to ${escalationLane?.id ?? "escalated"} for decomposition/design unless already decomposed. +- Documentation, tests, CI, lint, release/version drift, bounded frontend/backend fixes, and concrete follow-up issues usually go to ${defaultLane?.id ?? "normal"}. +- If the issue already contains a reasonable implementation approach and acceptance criteria, prefer ${defaultLane?.id ?? "normal"}. +- If confidence is low and the issue is not actionable, choose ${backlogLane?.id ?? "backlog"}. Issue: title: ${title} @@ -134,6 +159,7 @@ ${truncatedBody}`; /** * Build a fallback classification when model classification fails. * Uses simple heuristics based on labels and title/body content. + * Returns configured lane ids — never hardcoded strings. */ export function classifyByHeuristics( title: string, @@ -145,7 +171,11 @@ export function classifyByHeuristics( // Check for backlog indicators if (labelSet.has("status/backlog") || labelSet.has("type/research")) { - return { lane: "backlog", confidence: "high", reason: "Issue marked as backlog or research type" }; + return { + lane: classifyLaneFromSignals({ isBacklog: true, isEscalation: false }), + confidence: "high", + reason: "Issue marked as backlog or research type", + }; } // Check for escalated indicators (but not just priority/escalated labels) @@ -164,11 +194,19 @@ export function classifyByHeuristics( ]; const hasEscalationKeyword = escalationKeywords.some((kw) => text.includes(kw)); if (hasEscalationKeyword && !labelSet.has("status/backlog")) { - return { lane: "escalated", confidence: "medium", reason: "Issue contains architecture/design/audit decomposition keywords" }; + return { + lane: classifyLaneFromSignals({ isBacklog: false, isEscalation: true }), + confidence: "medium", + reason: "Issue contains architecture/design/audit decomposition keywords", + }; } // Default to normal for concrete, actionable issues - return { lane: "normal", confidence: "medium", reason: "Default classification: concrete implementation work" }; + return { + lane: classifyLaneFromSignals({ isBacklog: false, isEscalation: false }), + confidence: "medium", + reason: "Default classification: concrete implementation work", + }; } /** diff --git a/src/lib/issue-reconciliation.test.ts b/src/lib/issue-reconciliation.test.ts index 9b440bd..9dff3f0 100644 --- a/src/lib/issue-reconciliation.test.ts +++ b/src/lib/issue-reconciliation.test.ts @@ -1,6 +1,7 @@ -import { describe, expect, it, vi, beforeEach } from "vitest"; +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; import { classifyLaneByHeuristics, + evaluateLaneSignals, extractFixingIssueNumbers, prBranchMatchesIssue, checkPrHealth, @@ -10,6 +11,7 @@ import { executeActions, shouldReclassifyStaleBacklog, } from "./issue-reconciliation"; +import { setLaneConfig, resetLaneConfig } from "./lane-config"; const githubModule = await import("./github"); @@ -794,3 +796,242 @@ describe("executeActions", () => { expect(addLabelMock).toHaveBeenCalledTimes(1); }); }); + +describe("classifyLaneByHeuristics config-aware", () => { + afterEach(() => { + resetLaneConfig(); + }); + + it("default config stays backward-compatible: concrete issue -> normal", () => { + const result = classifyLaneByHeuristics( + "Add dark mode toggle to settings page", + "Implement a toggle in the settings page.", + ["enhancement"], + ); + expect(result.lane).toBe("normal"); + }); + + it("default config stays backward-compatible: architecture issue -> escalated", () => { + const result = classifyLaneByHeuristics( + "Database migration strategy for user tables", + "We need to plan the migration strategy.", + ["enhancement"], + ); + expect(result.lane).toBe("escalated"); + }); + + it("default config stays backward-compatible: placeholder -> backlog", () => { + const result = classifyLaneByHeuristics( + "New feature TBD", + "This is a placeholder. More details needed.", + ["enhancement"], + ); + expect(result.lane).toBe("backlog"); + }); + + it("single claimable lane: actionable issue goes to that lane", () => { + setLaneConfig({ + lanes: [ + { id: "default", title: "Default", claimable: true }, + { id: "backlog", title: "Backlog", claimable: false }, + ], + }); + const result = classifyLaneByHeuristics( + "Add dark mode toggle", + "Implement a toggle.", + ["enhancement"], + ); + expect(result.lane).toBe("default"); + }); + + it("single claimable lane: high-complexity goes to same lane (no escalation lane)", () => { + setLaneConfig({ + lanes: [ + { id: "default", title: "Default", claimable: true }, + { id: "backlog", title: "Backlog", claimable: false }, + ], + }); + const result = classifyLaneByHeuristics( + "Database migration strategy", + "Plan the migration strategy.", + ["enhancement"], + ); + expect(result.lane).toBe("default"); + }); + + it("single claimable lane: high-complexity goes to explicit escalation lane when configured", () => { + setLaneConfig({ + lanes: [ + { id: "default", title: "Default", claimable: true, role: "default" }, + { id: "expert", title: "Expert", claimable: true, role: "escalation" }, + { id: "backlog", title: "Backlog", claimable: false }, + ], + }); + const result = classifyLaneByHeuristics( + "Database migration strategy", + "Plan the migration strategy.", + ["enhancement"], + ); + expect(result.lane).toBe("expert"); + }); + + it("backlog signals go to configured non-claimable lane", () => { + setLaneConfig({ + lanes: [ + { id: "default", title: "Default", claimable: true }, + { id: "parked", title: "Parked", claimable: false }, + ], + }); + const result = classifyLaneByHeuristics( + "New feature TBD", + "This is a placeholder.", + ["enhancement"], + ); + expect(result.lane).toBe("parked"); + }); + + it("custom multi-lane config: high-complexity to configured escalation lane", () => { + setLaneConfig({ + lanes: [ + { id: "alpha", title: "Alpha", claimable: true, role: "default" }, + { id: "beta", title: "Beta", claimable: true, role: "escalation" }, + { id: "gamma", title: "Gamma", claimable: false }, + ], + }); + const result = classifyLaneByHeuristics( + "Architecture review for auth service", + "Design doc for new authentication redesign.", + ["type/feature"], + ); + expect(result.lane).toBe("beta"); + }); + + it("no hardcoded lane allowlist rejects configured custom lanes", () => { + setLaneConfig({ + lanes: [ + { id: "fast", title: "Fast Lane", claimable: true }, + { id: "slow", title: "Slow Lane", claimable: true, role: "escalation" }, + { id: "parked", title: "Parked", claimable: false }, + ], + }); + const normalResult = classifyLaneByHeuristics("Fix typo", null, ["bug"]); + expect(normalResult.lane).toBe("fast"); + const escalatedResult = classifyLaneByHeuristics( + "RFC: new auth flow", + "Design document for authentication redesign", + ["type/feature"], + ); + expect(escalatedResult.lane).toBe("slow"); + const backlogResult = classifyLaneByHeuristics( + "Research API rate limiting", + null, + ["enhancement", "status/backlog"], + ); + expect(backlogResult.lane).toBe("parked"); + }); + + it("never returns unknown lane ids", () => { + setLaneConfig({ + lanes: [ + { id: "custom1", title: "Custom 1", claimable: true }, + { id: "custom2", title: "Custom 2", claimable: false }, + ], + }); + const lanes = new Set(); + // Test all signal combinations + lanes.add( + classifyLaneByHeuristics("Fix typo", null, ["bug"]).lane, + ); + lanes.add( + classifyLaneByHeuristics("Architecture review", "Design doc.", ["type/feature"]).lane, + ); + lanes.add( + classifyLaneByHeuristics("TBD", "placeholder", ["enhancement"]).lane, + ); + for (const lane of lanes) { + expect(["custom1", "custom2"]).toContain(lane); + } + }); +}); + +describe("shouldReclassifyStaleBacklog config-aware", () => { + afterEach(() => { + resetLaneConfig(); + }); + + it("default config: reclass backlog->normal for concrete issue", () => { + expect( + shouldReclassifyStaleBacklog("backlog", "Add dark mode toggle", null, ["status/ready", "enhancement"]), + ).toBe("normal"); + }); + + it("default config: reclass backlog->escalated for architecture issue", () => { + expect( + shouldReclassifyStaleBacklog("backlog", "Database migration strategy for user tables", null, ["status/ready", "enhancement"]), + ).toBe("escalated"); + }); + + it("single claimable lane: reclass to that lane", () => { + setLaneConfig({ + lanes: [ + { id: "default", title: "Default", claimable: true }, + { id: "backlog", title: "Backlog", claimable: false }, + ], + }); + expect( + shouldReclassifyStaleBacklog("backlog", "Add dark mode toggle", null, ["status/ready"]), + ).toBe("default"); + }); + + it("single claimable lane: high-complexity reclass to same lane (no escalation)", () => { + setLaneConfig({ + lanes: [ + { id: "default", title: "Default", claimable: true }, + { id: "backlog", title: "Backlog", claimable: false }, + ], + }); + expect( + shouldReclassifyStaleBacklog("backlog", "Database migration strategy", null, ["status/ready"]), + ).toBe("default"); + }); + + it("custom escalation lane: high-complexity reclass to escalation lane", () => { + setLaneConfig({ + lanes: [ + { id: "normal", title: "Normal", claimable: true, role: "default" }, + { id: "expert", title: "Expert", claimable: true, role: "escalation" }, + { id: "backlog", title: "Backlog", claimable: false }, + ], + }); + expect( + shouldReclassifyStaleBacklog("backlog", "Database migration strategy", null, ["status/ready"]), + ).toBe("expert"); + }); + + it("falls back to default claimable when classifier returns backlog for active-status issue", () => { + setLaneConfig({ + lanes: [ + { id: "work", title: "Work", claimable: true }, + { id: "backlog", title: "Backlog", claimable: false }, + ], + }); + expect( + shouldReclassifyStaleBacklog("backlog", "New feature TBD", "This is a placeholder.", ["status/ready"]), + ).toBe("work"); + }); + + it("rejection of unknown lane ids: never writes hardcoded lane", () => { + setLaneConfig({ + lanes: [ + { id: "alpha", title: "Alpha", claimable: true }, + { id: "beta", title: "Beta", claimable: true, role: "escalation" }, + { id: "gamma", title: "Gamma", claimable: false }, + ], + }); + // gamma is the backlog lane; reclassify from gamma + const result1 = shouldReclassifyStaleBacklog("gamma", "Fix typo", null, ["status/ready"]); + expect(["alpha", "beta"]).toContain(result1); + const result2 = shouldReclassifyStaleBacklog("gamma", "Architecture review", "Design doc.", ["status/ready"]); + expect(["alpha", "beta"]).toContain(result2); + }); +}); diff --git a/src/lib/issue-reconciliation.ts b/src/lib/issue-reconciliation.ts index 05f973e..21c735a 100644 --- a/src/lib/issue-reconciliation.ts +++ b/src/lib/issue-reconciliation.ts @@ -1,68 +1,104 @@ import { GitHubIssue } from "@/types"; import { GithubPR, closeIssue as githubCloseIssue, addIssueLabel as githubAddIssueLabel, removeIssueLabel as githubRemoveIssueLabel } from "@/lib/github"; -import { isBacklogLane } from "@/lib/lane-config"; +import { classifyLaneFromSignals, getDefaultClaimableLane, isBacklogLane, LaneSignals } from "@/lib/lane-config"; // ─── Lane Classification Helpers ────────────────────────────────────────────── /** - * Heuristic lane classification when model calls are unavailable. - * Uses label patterns and issue content to infer the correct execution lane. + * Shared escalation keyword list used by both classifyLaneByHeuristics and + * shouldReclassifyStaleBacklog. */ -export function classifyLaneByHeuristics( +const ESCALATION_KEYWORDS = [ + "architecture", + "audit", + "design doc", + "rfc", + "alternatives considered", + "migration strategy", + "cross-service", + "distributed system", + "audit parent", + "parent issue", + "umbrella", + "decomposition", +]; + +/** + * Shared backlog signal list. + */ +const BACKLOG_SIGNALS = [ + "status/backlog", + "type/research", + "tbd", + "to be determined", + "placeholder", + "more details needed", + "needs more info", +]; + +/** + * Shared escalation label signals. + */ +const ESCALATION_LABELS = ["needs-escalation", "needs-gpt"]; + +/** + * Evaluate heuristic signals for an issue. Returns structured signals that can + * be mapped to a configured lane via classifyLaneFromSignals. + */ +export function evaluateLaneSignals( title: string, body: string | null, labels: string[], -): { lane: "normal" | "escalated" | "backlog"; confidence: "high" | "medium" | "low"; reason: string } { +): LaneSignals & { reason: string } { const text = `${title} ${body ?? ""}`.toLowerCase(); const labelSet = new Set(labels.map((l) => l.toLowerCase())); - // Escalated indicators: architecture, design, audit decomposition, cross-service - const escalatedSignals = [ - "architecture", - "audit", - "design doc", - "rfc", - "alternatives considered", - "migration strategy", - "cross-service", - "distributed system", - "audit parent", - "parent issue", - "umbrella", - "decomposition", - ]; - - const backlogSignals = [ - "status/backlog", - "type/research", - "tbd", - "to be determined", - "placeholder", - "more details needed", - "needs more info", - ]; - // Check backlog first (highest priority exclusion) - for (const signal of backlogSignals) { + for (const signal of BACKLOG_SIGNALS) { if (text.includes(signal) || labelSet.has(signal)) { - return { lane: "backlog", confidence: "high", reason: `Backlog signal detected: ${signal}` }; + return { isBacklog: true, isEscalation: false, reason: `Backlog signal detected: ${signal}` }; } } // Explicit escalation labels take precedence over text heuristics - const escalatedLabelSignals = ["needs-escalation", "needs-gpt"]; - if (escalatedLabelSignals.some((s) => labelSet.has(s))) { - return { lane: "escalated", confidence: "high", reason: "Escalation label detected" }; + if (ESCALATION_LABELS.some((s) => labelSet.has(s))) { + return { isBacklog: false, isEscalation: true, reason: "Escalation label detected" }; } // Check escalated signals - const escalationMatches = escalatedSignals.filter((s) => text.includes(s)); + const escalationMatches = ESCALATION_KEYWORDS.filter((s) => text.includes(s)); if (escalationMatches.length > 0 && !labelSet.has("status/backlog")) { - return { lane: "escalated", confidence: "medium", reason: `Escalation keywords: ${escalationMatches.join(", ")}` }; + return { isBacklog: false, isEscalation: true, reason: `Escalation keywords: ${escalationMatches.join(", ")}` }; } - // Default to normal for concrete, actionable issues - return { lane: "normal", confidence: "medium", reason: "Default classification: concrete implementation work" }; + // Default: concrete, actionable issues + return { isBacklog: false, isEscalation: false, reason: "Default classification: concrete implementation work" }; +} + +/** + * Heuristic lane classification when model calls are unavailable. + * Uses label patterns and issue content to infer the correct execution lane. + * Returns a configured lane id — never an unknown string. + */ +export function classifyLaneByHeuristics( + title: string, + body: string | null, + labels: string[], +): { lane: string; confidence: "high" | "medium" | "low"; reason: string } { + const signals = evaluateLaneSignals(title, body, labels); + + let confidence: "high" | "medium" | "low" = "medium"; + if (signals.isBacklog) { + confidence = "high"; + } else if (signals.isEscalation && ESCALATION_LABELS.some((l) => labels.map((x) => x.toLowerCase()).includes(l))) { + confidence = "high"; + } + + return { + lane: classifyLaneFromSignals({ isBacklog: signals.isBacklog, isEscalation: signals.isEscalation }), + confidence, + reason: signals.reason, + }; } /** @@ -73,16 +109,16 @@ export function classifyLaneByHeuristics( * Returns the new lane to use, or null when no reclassification is needed. * * Delegates to classifyLaneByHeuristics so escalation signals from title/body - * are respected. Falls back to "normal" if the classifier still returns - * "backlog" (e.g. stale text mentions) — an active-status issue must never - * stay in the backlog lane. + * are respected. Falls back to the default claimable lane if the classifier + * still returns a backlog lane (e.g. stale text mentions) — an active-status + * issue must never stay in the backlog lane. */ export function shouldReclassifyStaleBacklog( existingLane: string | null, title: string, body: string | null, currentLabels: string[], -): "normal" | "escalated" | null { +): string | null { if (!existingLane || !isBacklogLane(existingLane)) { return null; } @@ -104,15 +140,14 @@ export function shouldReclassifyStaleBacklog( // Reuse the same heuristic classifier as first-time classification. const classification = classifyLaneByHeuristics(title, body, currentLabels); - // If classifier still says backlog (stale text mentions), fall back to normal. - // An issue with an active status label must never remain in the backlog lane. + // If classifier still says backlog (stale text mentions), fall back to default + // claimable lane. An issue with an active status label must never remain in + // the backlog lane. if (isBacklogLane(classification.lane)) { - return "normal"; + return getDefaultClaimableLane()?.id ?? "normal"; } - // classifyLaneByHeuristics returns one of "normal", "escalated", or backlog lane id. - // Since we've ruled out backlog above, this is safe to cast. - return classification.lane as "normal" | "escalated"; + return classification.lane; } // ─── Merged PR Detection ───────────────────────────────────────────────────── diff --git a/src/lib/lane-config.test.ts b/src/lib/lane-config.test.ts index 07c3a17..4cdebf5 100644 --- a/src/lib/lane-config.test.ts +++ b/src/lib/lane-config.test.ts @@ -1,5 +1,8 @@ import { afterEach, describe, expect, it } from "vitest"; import { + classifyLaneFromSignals, + getDefaultClaimableLane, + getEscalationLane, getBacklogLane, getClaimableLanes, getConfiguredLanes, @@ -199,3 +202,151 @@ describe("lane-config reset", () => { expect(getLaneIds()).toEqual(["normal", "escalated", "backlog"]); }); }); + +describe("lane-config classification helpers", () => { + afterEach(() => { + resetLaneConfig(); + }); + + describe("getDefaultClaimableLane", () => { + it("returns the lane with role=default by default config", () => { + expect(getDefaultClaimableLane()?.id).toBe("normal"); + }); + + it("returns the first claimable lane when no role is set", () => { + setLaneConfig({ + lanes: [ + { id: "fast", title: "Fast", claimable: true }, + { id: "slow", title: "Slow", claimable: true }, + { id: "parked", title: "Parked", claimable: false }, + ], + }); + expect(getDefaultClaimableLane()?.id).toBe("fast"); + }); + + it("prefers explicit role=default over first claimable", () => { + setLaneConfig({ + lanes: [ + { id: "fast", title: "Fast", claimable: true }, + { id: "default-lane", title: "Default", claimable: true, role: "default" }, + { id: "parked", title: "Parked", claimable: false }, + ], + }); + expect(getDefaultClaimableLane()?.id).toBe("default-lane"); + }); + }); + + describe("getEscalationLane", () => { + it("returns the lane with role=escalation by default config", () => { + expect(getEscalationLane()?.id).toBe("escalated"); + }); + + it("falls back to default claimable when no escalation role exists", () => { + setLaneConfig({ + lanes: [ + { id: "default", title: "Default", claimable: true }, + { id: "backlog", title: "Backlog", claimable: false }, + ], + }); + expect(getEscalationLane()?.id).toBe("default"); + }); + + it("returns explicit escalation lane when configured", () => { + setLaneConfig({ + lanes: [ + { id: "normal", title: "Normal", claimable: true, role: "default" }, + { id: "senior-review", title: "Senior Review", claimable: true, role: "escalation" }, + { id: "backlog", title: "Backlog", claimable: false }, + ], + }); + expect(getEscalationLane()?.id).toBe("senior-review"); + }); + }); + + describe("classifyLaneFromSignals", () => { + it("maps backlog signals to the non-claimable lane", () => { + expect( + classifyLaneFromSignals({ isBacklog: true, isEscalation: false }), + ).toBe("backlog"); + }); + + it("maps escalation signals to the escalation lane", () => { + expect( + classifyLaneFromSignals({ isBacklog: false, isEscalation: true }), + ).toBe("escalated"); + }); + + it("maps default signals to the default claimable lane", () => { + expect( + classifyLaneFromSignals({ isBacklog: false, isEscalation: false }), + ).toBe("normal"); + }); + + it("with single claimable lane: all actionable goes to that lane", () => { + setLaneConfig({ + lanes: [ + { id: "work", title: "Work", claimable: true }, + { id: "backlog", title: "Backlog", claimable: false }, + ], + }); + expect( + classifyLaneFromSignals({ isBacklog: false, isEscalation: false }), + ).toBe("work"); + // High-complexity falls back to same lane since no escalation role + expect( + classifyLaneFromSignals({ isBacklog: false, isEscalation: true }), + ).toBe("work"); + expect( + classifyLaneFromSignals({ isBacklog: true, isEscalation: false }), + ).toBe("backlog"); + }); + + it("with single claimable lane and escalation role: maps correctly", () => { + setLaneConfig({ + lanes: [ + { id: "work", title: "Work", claimable: true, role: "default" }, + { id: "expert", title: "Expert", claimable: true, role: "escalation" }, + { id: "backlog", title: "Backlog", claimable: false }, + ], + }); + expect( + classifyLaneFromSignals({ isBacklog: false, isEscalation: false }), + ).toBe("work"); + expect( + classifyLaneFromSignals({ isBacklog: false, isEscalation: true }), + ).toBe("expert"); + expect( + classifyLaneFromSignals({ isBacklog: true, isEscalation: false }), + ).toBe("backlog"); + }); + + it("with no backlog lane: backlog signals fall back to default claimable", () => { + setLaneConfig({ + lanes: [ + { id: "work", title: "Work", claimable: true }, + ], + }); + expect( + classifyLaneFromSignals({ isBacklog: true, isEscalation: false }), + ).toBe("work"); + }); + + it("never returns unknown lane ids", () => { + setLaneConfig({ + lanes: [ + { id: "alpha", title: "Alpha", claimable: true }, + { id: "beta", title: "Beta", claimable: true, role: "escalation" }, + { id: "gamma", title: "Gamma", claimable: false }, + ], + }); + const results = [ + classifyLaneFromSignals({ isBacklog: false, isEscalation: false }), + classifyLaneFromSignals({ isBacklog: false, isEscalation: true }), + classifyLaneFromSignals({ isBacklog: true, isEscalation: false }), + ]; + for (const lane of results) { + expect(["alpha", "beta", "gamma"]).toContain(lane); + } + }); + }); +}); diff --git a/src/lib/lane-config.ts b/src/lib/lane-config.ts index 0388a8e..a8a7e1e 100644 --- a/src/lib/lane-config.ts +++ b/src/lib/lane-config.ts @@ -19,6 +19,8 @@ export interface LaneConfig { title: string; /** Whether workers may claim issues in this lane */ claimable: boolean; + /** Optional role hint for heuristic classification ("default" or "escalation") */ + role?: "default" | "escalation"; /** Optional human-readable description */ description?: string; /** Optional hex color for UI rendering */ @@ -49,6 +51,7 @@ const DEFAULT_LANE_CONFIG: LaneConfigSet = { id: "normal", title: "Normal", claimable: true, + role: "default", description: "Standard execution lane for concrete, scoped implementation work.", color: "#3b82f6", }, @@ -56,6 +59,7 @@ const DEFAULT_LANE_CONFIG: LaneConfigSet = { id: "escalated", title: "Escalated", claimable: true, + role: "escalation", description: "Requires higher-judgment model support (architecture, design, cross-service).", color: "#f97316", }, @@ -151,6 +155,69 @@ export function isBacklogLane(id: string): boolean { return backlog !== undefined && backlog.id === id; } +// ─── Classification Helpers ────────────────────────────────────────────────── + +/** + * Return the default claimable lane. + * Prefers a lane with role "default", falls back to the first claimable lane. + */ +export function getDefaultClaimableLane(): LaneConfig | undefined { + const lanes = getConfiguredLanes(); + const explicitDefault = lanes.find((l) => l.claimable && l.role === "default"); + if (explicitDefault) return explicitDefault; + return lanes.find((l) => l.claimable); +} + +/** + * Return the escalation lane, if configured. + * Prefers a lane with role "escalation". Falls back to the default claimable + * lane when no escalation lane exists — ensuring we never return an unknown id. + */ +export function getEscalationLane(): LaneConfig | undefined { + const lanes = getConfiguredLanes(); + const explicitEscalation = lanes.find((l) => l.claimable && l.role === "escalation"); + if (explicitEscalation) return explicitEscalation; + // Fall back to default claimable so we always have a valid lane id. + return getDefaultClaimableLane(); +} + +/** + * Signals used by heuristic classification to decide which lane an issue belongs to. + */ +export interface LaneSignals { + /** Issue has backlog/not-ready indicators (status/backlog, placeholder, etc.) */ + isBacklog: boolean; + /** Issue has escalation/high-complexity indicators (architecture, RFC, etc.) */ + isEscalation: boolean; +} + +/** + * Map heuristic signals to a configured lane id. + * Never returns an unknown lane id — always falls back to the default claimable lane. + */ +export function classifyLaneFromSignals(signals: LaneSignals): string { + if (signals.isBacklog) { + const backlog = getBacklogLane(); + if (backlog) return backlog.id; + // No non-claimable lane configured — fall back to default claimable. + const defaultLane = getDefaultClaimableLane(); + if (defaultLane) return defaultLane.id; + } + + if (signals.isEscalation) { + const escalation = getEscalationLane(); + if (escalation) return escalation.id; + } + + // Default: actionable issue -> default claimable lane. + const defaultLane = getDefaultClaimableLane(); + if (defaultLane) return defaultLane.id; + + // Should never happen (config validation requires at least one claimable lane), + // but provide a safe fallback. + return "normal"; +} + // ─── Validation ─────────────────────────────────────────────────────────────── /**