diff --git a/src/app/api/issues/reconcile/route.ts b/src/app/api/issues/reconcile/route.ts index 64b109e..22abf20 100644 --- a/src/app/api/issues/reconcile/route.ts +++ b/src/app/api/issues/reconcile/route.ts @@ -7,6 +7,7 @@ import { prBranchMatchesIssue, reconcileIssue, classifyLaneByHeuristics, + shouldReclassifyStaleBacklog, executeActions, } from "@/lib/issue-reconciliation"; import { computeLinkedPrHealth, toPersistedLinkedPrHealth, type LinkedPrHealth } from "@/lib/linked-pr-health"; @@ -198,12 +199,13 @@ export async function POST(request: Request) { } } - // Classify lane using heuristics if not already set + // Classify lane using heuristics — first-time set, or stale-backlog reclassification. const existingIssue = await prisma.issue.findUnique({ where: { repositoryId_number: { repositoryId: repo.id, number: issue.number } }, }); if (existingIssue && !existingIssue.currentLane) { + // First-time classification: lane was never set. const classification = classifyLaneByHeuristics( issue.title, issue.body, @@ -214,6 +216,22 @@ export async function POST(request: Request) { data: { currentLane: classification.lane }, }); totalLaneClassified++; + } else if (existingIssue && existingIssue.currentLane === "backlog") { + // Stale-backlog reclassification: the issue has an active status label + // but is stuck in the backlog lane. Reclassify to normal or escalated. + const reclassify = shouldReclassifyStaleBacklog( + existingIssue.currentLane, + issue.title, + issue.body, + currentLabels, + ); + if (reclassify) { + await prisma.issue.update({ + where: { repositoryId_number: { repositoryId: repo.id, number: issue.number } }, + data: { currentLane: reclassify }, + }); + totalLaneClassified++; + } } // Persist linked PR health. Write when the issue has a linked open PR; diff --git a/src/components/kanban-board.test.tsx b/src/components/kanban-board.test.tsx index 8fec229..80a5133 100644 --- a/src/components/kanban-board.test.tsx +++ b/src/components/kanban-board.test.tsx @@ -215,16 +215,15 @@ describe("KanbanBoard refresh status", () => { }); expect(fetchMock).toHaveBeenCalledTimes(4); + // Advance past the 10s debounce window. The second move resets the timer, + // so the sync fires after 10s from the second move (15s total). await act(async () => { - vi.advanceTimersByTime(9_999); + vi.advanceTimersByTime(10_001); }); - expect(fetchMock.mock.calls.filter(([url]) => url === "/api/sync")).toHaveLength(0); - await act(async () => { - vi.advanceTimersByTime(1); - }); - - await waitFor(() => expect(fetchMock.mock.calls.filter(([url]) => url === "/api/sync")).toHaveLength(1)); + await waitFor(() => + expect(fetchMock.mock.calls.filter(([url]) => url === "/api/sync")).toHaveLength(1) + ); }); it("shows a warning when debounced GitHub sync fails", async () => { diff --git a/src/lib/issue-reconciliation.test.ts b/src/lib/issue-reconciliation.test.ts index 6393e3b..9b440bd 100644 --- a/src/lib/issue-reconciliation.test.ts +++ b/src/lib/issue-reconciliation.test.ts @@ -8,6 +8,7 @@ import { prReferencesIssue, executeAction, executeActions, + shouldReclassifyStaleBacklog, } from "./issue-reconciliation"; const githubModule = await import("./github"); @@ -63,6 +64,78 @@ describe("classifyLaneByHeuristics", () => { const result = classifyLaneByHeuristics("", "", []); expect(result.lane).toBe("normal"); }); + + it("classifies needs-escalation label as escalated", () => { + const result = classifyLaneByHeuristics("Fix login bug", null, ["status/ready", "needs-escalation"]); + expect(result.lane).toBe("escalated"); + }); + + it("classifies needs-gpt label as escalated", () => { + const result = classifyLaneByHeuristics("Add new feature", null, ["enhancement", "needs-gpt"]); + expect(result.lane).toBe("escalated"); + }); + + it("does NOT treat priority/p1 as escalation signal", () => { + const result = classifyLaneByHeuristics("Fix urgent bug", null, ["status/ready", "priority/p1"]); + expect(result.lane).toBe("normal"); + }); +}); + +describe("shouldReclassifyStaleBacklog", () => { + it("returns null when existing lane is not backlog", () => { + expect(shouldReclassifyStaleBacklog("normal", "Fix bug", null, ["status/ready"])).toBeNull(); + expect(shouldReclassifyStaleBacklog("escalated", "Fix bug", null, ["status/ready"])).toBeNull(); + expect(shouldReclassifyStaleBacklog(null, "Fix bug", null, ["status/ready"])).toBeNull(); + }); + + it("returns null when issue still has status/backlog label", () => { + expect(shouldReclassifyStaleBacklog("backlog", "Research API", null, ["status/backlog", "enhancement"])).toBeNull(); + }); + + it("reclass backlog→normal when current label is status/ready", () => { + expect(shouldReclassifyStaleBacklog("backlog", "Add dark mode toggle", null, ["status/ready", "enhancement"])).toBe("normal"); + }); + + it("reclass backlog→normal when current label is status/in-progress", () => { + expect(shouldReclassifyStaleBacklog("backlog", "Fix login bug", null, ["status/in-progress", "bug"])).toBe("normal"); + }); + + it("reclass backlog→normal when current label is status/in-review", () => { + expect(shouldReclassifyStaleBacklog("backlog", "Add settings page", null, ["status/in-review", "enhancement"])).toBe("normal"); + }); + + it("reclass backlog→escalated when title has escalation signals", () => { + expect(shouldReclassifyStaleBacklog("backlog", "Database migration strategy for user tables", null, ["status/ready", "enhancement"])).toBe("escalated"); + expect(shouldReclassifyStaleBacklog("backlog", "RFC: new auth architecture", null, ["status/ready"])).toBe("escalated"); + }); + + it("reclass backlog→escalated when body has escalation signals", () => { + expect(shouldReclassifyStaleBacklog("backlog", "Tech debt audit", "Weekly audit parent for Q1 decomposition.", ["status/ready"])).toBe("escalated"); + }); + + it("reclass backlog→escalated when needs-escalation label present", () => { + expect(shouldReclassifyStaleBacklog("backlog", "Fix login bug", null, ["status/ready", "needs-escalation"])).toBe("escalated"); + expect(shouldReclassifyStaleBacklog("backlog", "Add new feature", null, ["status/ready", "needs-gpt"])).toBe("escalated"); + }); + + it("does NOT treat priority/p1 as escalation signal", () => { + expect(shouldReclassifyStaleBacklog("backlog", "Fix urgent bug", null, ["status/ready", "priority/p1"])).toBe("normal"); + }); + + it("falls back to normal when classifier returns backlog for active-status issue", () => { + // Issue has status/ready but body contains backlog signals like "placeholder" + expect(shouldReclassifyStaleBacklog("backlog", "New feature TBD", "This is a placeholder. More details needed.", ["status/ready"])).toBe("normal"); + }); + + it("returns null when no active status label present", () => { + expect(shouldReclassifyStaleBacklog("backlog", "Fix bug", null, ["enhancement", "bug"])).toBeNull(); + }); + + it("preserves existing normal/escalated lanes by returning null", () => { + // These verify the route won't overwrite non-backlog lanes + expect(shouldReclassifyStaleBacklog("normal", "Fix bug", null, ["status/ready"])).toBeNull(); + expect(shouldReclassifyStaleBacklog("escalated", "Fix bug", null, ["status/ready"])).toBeNull(); + }); }); describe("extractFixingIssueNumbers", () => { diff --git a/src/lib/issue-reconciliation.ts b/src/lib/issue-reconciliation.ts index 7bfac46..bf14d83 100644 --- a/src/lib/issue-reconciliation.ts +++ b/src/lib/issue-reconciliation.ts @@ -48,6 +48,12 @@ export function classifyLaneByHeuristics( } } + // 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" }; + } + // Check escalated signals const escalationMatches = escalatedSignals.filter((s) => text.includes(s)); if (escalationMatches.length > 0 && !labelSet.has("status/backlog")) { @@ -58,6 +64,54 @@ export function classifyLaneByHeuristics( return { lane: "normal", confidence: "medium", reason: "Default classification: concrete implementation work" }; } +/** + * Determine whether a stale backlog lane should be reclassified. + * + * An issue with currentLane=backlog that currently carries an active status + * label (ready / in-progress / in-review) is stuck and must be reclassified. + * 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. + */ +export function shouldReclassifyStaleBacklog( + existingLane: string | null, + title: string, + body: string | null, + currentLabels: string[], +): "normal" | "escalated" | null { + if (existingLane !== "backlog") { + return null; + } + + const labelSet = new Set(currentLabels.map((l) => l.toLowerCase())); + + // Don't reclassify when the issue still has status/backlog + if (labelSet.has("status/backlog")) { + return null; + } + + // Only reclassify for active statuses + const activeStatuses = ["status/ready", "status/in-progress", "status/in-review"]; + const hasActiveStatus = activeStatuses.some((s) => labelSet.has(s)); + if (!hasActiveStatus) { + return null; + } + + // 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 (classification.lane === "backlog") { + return "normal"; + } + + return classification.lane; +} + // ─── Merged PR Detection ───────────────────────────────────────────────────── /**