From 45bebd60df3a7f947c84094a4ac81a92eff09386 Mon Sep 17 00:00:00 2001 From: Saffron Date: Thu, 18 Jun 2026 22:04:08 -0600 Subject: [PATCH] fix: exclude Renovate from groom pipeline + add DB id to groom task Three issues fixed: 1. next-task?mode=groom did not filter Renovate issues, causing them to surface as grooming candidates and block the pipeline. Now uses the same isRenovateIssue() filter as the normal queue path. 2. Groom task response only included GitHub issue number, not the Dispatch DB id. The groom POST endpoint needs the DB id for prisma.issue.findUnique(). Added id to IssueRef and select. 3. Groom route now falls back to findFirst by repoFullName + issueNumber when the DB id doesn't match, preventing 500s from id format mismatches. Fixes #316 --- .../api/agents/[agentName]/next-task/route.ts | 4 ++ src/app/api/issues/groom/route.test.ts | 12 ++++++ src/app/api/issues/groom/route.ts | 42 ++++++++++++++----- src/lib/agent-task.ts | 1 + 4 files changed, 48 insertions(+), 11 deletions(-) diff --git a/src/app/api/agents/[agentName]/next-task/route.ts b/src/app/api/agents/[agentName]/next-task/route.ts index 8660a0ed..0ed99fa7 100644 --- a/src/app/api/agents/[agentName]/next-task/route.ts +++ b/src/app/api/agents/[agentName]/next-task/route.ts @@ -8,6 +8,7 @@ import { createGroomTask, } from "@/lib/agent-task"; import { isBacklogLane, getBacklogLane } from "@/lib/lane-config"; +import { isRenovateIssue } from "@/lib/agent-queue"; import { fetchAgentQueueData } from "@/lib/agent-queue-fetch"; export async function GET( @@ -36,6 +37,7 @@ export async function GET( repository: { enabled: true }, }, select: { + id: true, number: true, title: true, url: true, @@ -47,6 +49,7 @@ export async function GET( }); const candidates = issues + .filter((issue) => !isRenovateIssue(issue)) .map((issue) => { const hasStatus = issue.labels.some((l) => l.startsWith("status/")); const hasPriority = issue.labels.some((l) => l.startsWith("priority/")); @@ -83,6 +86,7 @@ export async function GET( agentName, lane: best.currentLane ?? getBacklogLane()?.id ?? "backlog", issue: { + id: best.id, repoFullName: best.repository.fullName, number: best.number, title: best.title, diff --git a/src/app/api/issues/groom/route.test.ts b/src/app/api/issues/groom/route.test.ts index 8931f9c8..c5dcb069 100644 --- a/src/app/api/issues/groom/route.test.ts +++ b/src/app/api/issues/groom/route.test.ts @@ -4,6 +4,7 @@ import { resetAuthCaches } from "@/lib/auth"; const { mocks } = vi.hoisted(() => ({ mocks: { findIssue: vi.fn().mockResolvedValue(null), + findFirstIssue: vi.fn().mockResolvedValue(null), updateIssue: vi.fn().mockResolvedValue(undefined), createAuditLog: vi.fn().mockResolvedValue({ id: "log-1" }), removeIssueLabel: vi.fn().mockResolvedValue(undefined), @@ -16,6 +17,7 @@ vi.mock("@/lib/prisma", () => ({ prisma: { issue: { findUnique: mocks.findIssue, + findFirst: mocks.findFirstIssue, update: mocks.updateIssue, }, auditLog: { @@ -67,6 +69,7 @@ function mockIssue(extra?: Record) { describe("POST /api/issues/groom — auth", () => { beforeEach(() => { vi.clearAllMocks(); + mocks.findFirstIssue.mockResolvedValue(null); process.env.DISPATCH_AGENT_TOKEN = "test-token"; process.env.DISPATCH_AUTH_MODE = "disabled"; }); @@ -88,6 +91,7 @@ describe("POST /api/issues/groom — auth", () => { describe("POST /api/issues/groom — validation", () => { beforeEach(() => { vi.clearAllMocks(); + mocks.findFirstIssue.mockResolvedValue(null); process.env.DISPATCH_AGENT_TOKEN = "test-token"; process.env.DISPATCH_AUTH_MODE = "disabled"; }); @@ -149,6 +153,7 @@ describe("POST /api/issues/groom — validation", () => { describe("POST /api/issues/groom — actor resolution", () => { beforeEach(() => { vi.clearAllMocks(); + mocks.findFirstIssue.mockResolvedValue(null); process.env.DISPATCH_AGENT_TOKEN = "test-token"; process.env.DISPATCH_AUTH_MODE = "disabled"; mockIssue(); @@ -228,6 +233,7 @@ describe("POST /api/issues/groom — actor resolution", () => { describe("POST /api/issues/groom — promote_to_ready", () => { beforeEach(() => { vi.clearAllMocks(); + mocks.findFirstIssue.mockResolvedValue(null); process.env.DISPATCH_AGENT_TOKEN = "test-token"; process.env.DISPATCH_AUTH_MODE = "disabled"; mockIssue({ labels: ["status/backlog", "priority/p2"] }); @@ -338,6 +344,7 @@ describe("POST /api/issues/groom — promote_to_ready", () => { describe("POST /api/issues/groom — escalate", () => { beforeEach(() => { vi.clearAllMocks(); + mocks.findFirstIssue.mockResolvedValue(null); process.env.DISPATCH_AGENT_TOKEN = "test-token"; process.env.DISPATCH_AUTH_MODE = "disabled"; mockIssue({ labels: ["status/in-progress"] }); @@ -392,6 +399,7 @@ describe("POST /api/issues/groom — escalate", () => { describe("POST /api/issues/groom — mark_not_ready", () => { beforeEach(() => { vi.clearAllMocks(); + mocks.findFirstIssue.mockResolvedValue(null); process.env.DISPATCH_AGENT_TOKEN = "test-token"; process.env.DISPATCH_AUTH_MODE = "disabled"; mockIssue({ labels: ["status/backlog"] }); @@ -466,6 +474,7 @@ describe("POST /api/issues/groom — mark_not_ready", () => { describe("POST /api/issues/groom — mark_needs_info", () => { beforeEach(() => { vi.clearAllMocks(); + mocks.findFirstIssue.mockResolvedValue(null); process.env.DISPATCH_AGENT_TOKEN = "test-token"; process.env.DISPATCH_AUTH_MODE = "disabled"; mockIssue({ labels: ["status/backlog"] }); @@ -526,6 +535,7 @@ describe("POST /api/issues/groom — mark_needs_info", () => { describe("POST /api/issues/groom — mark_blocked", () => { beforeEach(() => { vi.clearAllMocks(); + mocks.findFirstIssue.mockResolvedValue(null); process.env.DISPATCH_AGENT_TOKEN = "test-token"; process.env.DISPATCH_AUTH_MODE = "disabled"; mockIssue({ labels: ["status/backlog"] }); @@ -586,6 +596,7 @@ describe("POST /api/issues/groom — mark_blocked", () => { describe("POST /api/issues/groom — error handling", () => { beforeEach(() => { vi.clearAllMocks(); + mocks.findFirstIssue.mockResolvedValue(null); process.env.DISPATCH_AGENT_TOKEN = "test-token"; process.env.DISPATCH_AUTH_MODE = "disabled"; }); @@ -617,6 +628,7 @@ describe("POST /api/issues/groom — error handling", () => { describe("POST /api/issues/groom — auth modes", () => { beforeEach(() => { vi.clearAllMocks(); + mocks.findFirstIssue.mockResolvedValue(null); resetAuthCaches(); mocks.findIssue.mockResolvedValue({ id: "issue-1", diff --git a/src/app/api/issues/groom/route.ts b/src/app/api/issues/groom/route.ts index 123e338b..df1ad9e7 100644 --- a/src/app/api/issues/groom/route.ts +++ b/src/app/api/issues/groom/route.ts @@ -64,9 +64,9 @@ export async function POST(request: Request) { agentName, } = body as Record; - if (!issueId || !repoFullName || typeof issueNumber !== "number" || typeof action !== "string") { + if (!repoFullName || typeof issueNumber !== "number" || typeof action !== "string") { return NextResponse.json( - { error: "Missing required fields: issueId, repoFullName, issueNumber, action" }, + { error: "Missing required fields: repoFullName, issueNumber, action" }, { status: 400 }, ); } @@ -99,17 +99,37 @@ export async function POST(request: Request) { } try { - const issue = await prisma.issue.findUnique({ - where: { id: issueId as string }, - include: { repository: true }, - }); + // Look up the issue: try by DB id first, fall back to repoFullName + issueNumber + let issue = null; + + if (issueId && typeof issueId === "string") { + issue = await prisma.issue.findUnique({ + where: { id: issueId }, + include: { repository: true }, + }); + } + + if (!issue) { + // Fallback: look up by repoFullName + issueNumber + issue = await prisma.issue.findFirst({ + where: { + number: issueNumber, + repository: { fullName: repoFullName as string }, + }, + include: { repository: true }, + }); + } if (!issue) { - return NextResponse.json({ error: "Issue not found in local cache" }, { status: 404 }); + return NextResponse.json( + { error: "Issue not found in local cache" }, + { status: 404 }, + ); } const effectiveRepo = (issue.repository?.fullName ?? repoFullName) as string; const effectiveNumber = issue.number; + const effectiveIssueId = issue.id; const beforeLabels = [...issue.labels]; let afterLabels = [...issue.labels]; const groomedAt = new Date(); @@ -197,12 +217,12 @@ export async function POST(request: Request) { // Update GitHub labels if status changed; always refresh lastSyncedAt if (action === "promote_to_ready") { await prisma.issue.update({ - where: { id: issueId as string }, + where: { id: effectiveIssueId }, data: { ...groomingData, labels: afterLabels, lastSyncedAt: new Date() }, }); } else { await prisma.issue.update({ - where: { id: issueId as string }, + where: { id: effectiveIssueId }, data: { ...groomingData, lastSyncedAt: new Date() }, }); } @@ -222,7 +242,7 @@ export async function POST(request: Request) { action: actionLabels[action], repoFullName: effectiveRepo, issueNumber: effectiveNumber, - issueId: issueId as string, + issueId: effectiveIssueId, beforeLabels, afterLabels: action === "promote_to_ready" ? afterLabels : beforeLabels, success: true, @@ -245,7 +265,7 @@ export async function POST(request: Request) { action: `issue_groomed_${action}`, repoFullName: repoFullName as string, issueNumber: issueNumber as number, - issueId: issueId as string, + issueId: (issueId as string) ?? null, beforeLabels: [], afterLabels: [], success: false, diff --git a/src/lib/agent-task.ts b/src/lib/agent-task.ts index 71484361..203d77df 100644 --- a/src/lib/agent-task.ts +++ b/src/lib/agent-task.ts @@ -1,4 +1,5 @@ export interface IssueRef { + id?: string; repoFullName: string; number: number; title: string;