Skip to content
Merged
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
20 changes: 19 additions & 1 deletion src/app/api/issues/reconcile/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
prBranchMatchesIssue,
reconcileIssue,
classifyLaneByHeuristics,
shouldReclassifyStaleBacklog,
executeActions,
} from "@/lib/issue-reconciliation";
import { computeLinkedPrHealth, toPersistedLinkedPrHealth, type LinkedPrHealth } from "@/lib/linked-pr-health";
Expand Down Expand Up @@ -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,
Expand All @@ -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;
Expand Down
13 changes: 6 additions & 7 deletions src/components/kanban-board.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
73 changes: 73 additions & 0 deletions src/lib/issue-reconciliation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
prReferencesIssue,
executeAction,
executeActions,
shouldReclassifyStaleBacklog,
} from "./issue-reconciliation";

const githubModule = await import("./github");
Expand Down Expand Up @@ -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", () => {
Expand Down
54 changes: 54 additions & 0 deletions src/lib/issue-reconciliation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")) {
Expand All @@ -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 ─────────────────────────────────────────────────────

/**
Expand Down