diff --git a/src/app/api/devops/tickets/[id]/type/route.ts b/src/app/api/devops/tickets/[id]/type/route.ts index 7c7b4509..e2fa6304 100644 --- a/src/app/api/devops/tickets/[id]/type/route.ts +++ b/src/app/api/devops/tickets/[id]/type/route.ts @@ -20,7 +20,7 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise< } const body = await request.json(); - const { type, project, additionalFields } = body; + const { type, additionalFields } = body; if (!type) { return NextResponse.json({ error: 'Type is required' }, { status: 400 }); @@ -62,26 +62,43 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise< } } - // If project is provided, use it directly - if (project) { - const updatedWorkItem = await devopsService.changeWorkItemType( - project, - ticketId, - type, - validatedAdditionalFields + // Resolve the owning project from the WIT API. This is the source of + // truth: clients may not know (or may have a stale guess of) which + // project owns the ticket, especially after a move. One org-level + // work-item fetch is cheaper and more reliable than the older + // findProjectForWorkItem iteration that silently swallowed per-project + // errors and surfaced bogus 404s. + let projectName: string | undefined; + try { + const orgWorkItem = await devopsService.getWorkItemByIdOrgLevel(ticketId, [ + 'System.TeamProject', + ]); + if (!orgWorkItem) { + return NextResponse.json( + { error: `Ticket ${ticketId} not found in this organization` }, + { status: 404 } + ); + } + projectName = orgWorkItem.fields['System.TeamProject'] as string; + } catch (err) { + console.error(`Org-level lookup failed for ticket ${ticketId}:`, err); + return NextResponse.json( + { + error: `Could not resolve the project for ticket ${ticketId}: ${err instanceof Error ? err.message : 'unknown error'}`, + }, + { status: 500 } ); - const ticket = workItemToTicket(updatedWorkItem); - return NextResponse.json({ ticket }); } - // Fallback: find the project - const found = await devopsService.findProjectForWorkItem(ticketId); - if (!found) { - return NextResponse.json({ error: 'Ticket not found' }, { status: 404 }); + if (!projectName) { + return NextResponse.json( + { error: `Ticket ${ticketId} has no project assigned` }, + { status: 404 } + ); } const updatedWorkItem = await devopsService.changeWorkItemType( - found.project.name, + projectName, ticketId, type, validatedAdditionalFields diff --git a/src/lib/devops.ts b/src/lib/devops.ts index 1074c895..c2da570d 100644 --- a/src/lib/devops.ts +++ b/src/lib/devops.ts @@ -492,6 +492,42 @@ export class AzureDevOpsService { return response.json(); } + // Org-level work item fetch — works without knowing the project up front. + // Used by routes that mutate a single work item: cheaper and more reliable + // than iterating every accessible project to find which one owns it. + // Pass `fields` to request a minimal payload (e.g. ['System.TeamProject']) + // when the caller only needs a specific field; omit it for the full expansion. + async getWorkItemByIdOrgLevel( + workItemId: number, + fields?: string[] + ): Promise { + const selector = + fields && fields.length > 0 + ? `fields=${fields.map(encodeURIComponent).join(',')}` + : `$expand=all`; + const response = await fetch( + `${this.baseUrl}/_apis/wit/workitems/${workItemId}?${selector}&api-version=7.1`, + { headers: this.headers } + ); + if (response.status === 404) return null; + if (!response.ok) { + // Azure DevOps usually puts the actionable reason (permission scope, + // invalid request, etc.) in the response body — include it so the 500s + // surfaced by callers aren't just bare status codes. + let detail = ''; + try { + const body = await response.text(); + if (body) detail = `: ${body.slice(0, 500)}`; + } catch { + // ignore — body read shouldn't mask the underlying HTTP failure + } + throw new Error( + `Failed to fetch work item ${workItemId}: ${response.status} ${response.statusText}${detail}` + ); + } + return response.json(); + } + // Lightweight check if a work item exists (org-level, no project needed, minimal fields) // Returns: 'exists' | 'not_found' | 'error' async workItemExists(workItemId: number): Promise<'exists' | 'not_found' | 'error'> {