Skip to content
Open
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
47 changes: 32 additions & 15 deletions src/app/api/devops/tickets/[id]/type/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down Expand Up @@ -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
Expand Down
36 changes: 36 additions & 0 deletions src/lib/devops.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<DevOpsWorkItem | null> {
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}`
);
}
Comment on lines +512 to +527
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'> {
Expand Down
Loading