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
118 changes: 118 additions & 0 deletions src/lib/issue-lane.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"');
});
});
64 changes: 51 additions & 13 deletions src/lib/issue-lane.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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,
Expand All @@ -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}
Expand All @@ -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,
Expand All @@ -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)
Expand All @@ -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",
};
}

/**
Expand Down
Loading