From 831d6ce09195d9f057780f548848efc4a5b82a31 Mon Sep 17 00:00:00 2001 From: akash2017sky Date: Wed, 6 May 2026 18:21:23 +0100 Subject: [PATCH 1/2] fix: resolve work item project via org-level fetch on type change (#294) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The type-change route's fallback path (when the request body omits \`project\`) was iterating every project the user has access to via \`findProjectForWorkItem\`, swallowing per-project errors with a bare \`catch\`. If any of those getWorkItem calls failed silently — an OAuth scope hiccup on a cached project, a transient 5xx — the lookup returned null and the route surfaced \"Ticket not found\" with a 404, even though the work item plainly existed. Replace the iteration with \`getWorkItemByIdOrgLevel\`, which hits Azure DevOps' org-level work-item endpoint (no project segment) once and reads \`System.TeamProject\` off the response. One Graph call instead of N, no silent-error sink, and 404 / 500 carry the actual reason. Side effect: the body's \`project\` is now treated as a hint, not the source of truth. If the front-end ever sends a stale or wrong project the route still resolves correctly. Fixes #294 Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/api/devops/tickets/[id]/type/route.ts | 48 +++++++++++++------ src/lib/devops.ts | 17 +++++++ 2 files changed, 50 insertions(+), 15 deletions(-) diff --git a/src/app/api/devops/tickets/[id]/type/route.ts b/src/app/api/devops/tickets/[id]/type/route.ts index 7c7b4509..f984ac72 100644 --- a/src/app/api/devops/tickets/[id]/type/route.ts +++ b/src/app/api/devops/tickets/[id]/type/route.ts @@ -62,26 +62,44 @@ 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 - ); - const ticket = workItemToTicket(updatedWorkItem); - return NextResponse.json({ ticket }); + // Resolve the project. Prefer the body field (cheap, no extra Graph call) + // but fall back to fetching the work item at the org level, which works + // regardless of which project owns it. This replaces the older + // findProjectForWorkItem iteration that was prone to silent 404s when one + // of the cached projects no longer permitted a getWorkItem call. + let projectName: string | undefined = + typeof project === 'string' && project.trim() ? project.trim() : undefined; + + if (!projectName) { + try { + const orgWorkItem = await devopsService.getWorkItemByIdOrgLevel(ticketId); + 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 } + ); + } } - // 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 bd6335d8..cb84c9bc 100644 --- a/src/lib/devops.ts +++ b/src/lib/devops.ts @@ -492,6 +492,23 @@ 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. + async getWorkItemByIdOrgLevel(workItemId: number): Promise { + const response = await fetch( + `${this.baseUrl}/_apis/wit/workitems/${workItemId}?$expand=all&api-version=7.1`, + { headers: this.headers } + ); + if (response.status === 404) return null; + if (!response.ok) { + throw new Error( + `Failed to fetch work item ${workItemId}: ${response.status} ${response.statusText}` + ); + } + 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'> { From b135215e60d75f7eeb7e2bf8c279ce6d09d57a88 Mon Sep 17 00:00:00 2001 From: Ayush Date: Fri, 22 May 2026 19:39:10 +0530 Subject: [PATCH 2/2] fix: address Copilot review comments on PR #371 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Always resolve the owning project via getWorkItemByIdOrgLevel; remove the body `project` shortcut so a stale or wrong client value can no longer cause changeWorkItemType to target the wrong project (matches what the PR description originally promised). - Make getWorkItemByIdOrgLevel fields-selectable: the route now requests only `System.TeamProject` instead of `$expand=all`, dropping the payload to a single field. Default still falls back to `$expand=all` for any future caller that needs the full work item. - Include the DevOps response body (truncated to 500 chars) in non-404 error throws so permission-scope / 5xx failures surface with actionable detail instead of a bare status code. - Fix misleading "no extra Graph call" comment — this is the WIT API, not Graph. Co-Authored-By: Claude Opus 4.7 --- src/app/api/devops/tickets/[id]/type/route.ts | 49 +++++++++---------- src/lib/devops.ts | 25 ++++++++-- 2 files changed, 46 insertions(+), 28 deletions(-) diff --git a/src/app/api/devops/tickets/[id]/type/route.ts b/src/app/api/devops/tickets/[id]/type/route.ts index f984ac72..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,33 +62,32 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise< } } - // Resolve the project. Prefer the body field (cheap, no extra Graph call) - // but fall back to fetching the work item at the org level, which works - // regardless of which project owns it. This replaces the older - // findProjectForWorkItem iteration that was prone to silent 404s when one - // of the cached projects no longer permitted a getWorkItem call. - let projectName: string | undefined = - typeof project === 'string' && project.trim() ? project.trim() : undefined; - - if (!projectName) { - try { - const orgWorkItem = await devopsService.getWorkItemByIdOrgLevel(ticketId); - 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); + // 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: `Could not resolve the project for ticket ${ticketId}: ${err instanceof Error ? err.message : 'unknown error'}`, - }, - { status: 500 } + { 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 } + ); } if (!projectName) { diff --git a/src/lib/devops.ts b/src/lib/devops.ts index 467bb65c..c2da570d 100644 --- a/src/lib/devops.ts +++ b/src/lib/devops.ts @@ -495,15 +495,34 @@ export class AzureDevOpsService { // 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. - async getWorkItemByIdOrgLevel(workItemId: number): Promise { + // 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}?$expand=all&api-version=7.1`, + `${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}` + `Failed to fetch work item ${workItemId}: ${response.status} ${response.statusText}${detail}` ); } return response.json();