diff --git a/src/app/api/devops/tickets/[id]/state/route.ts b/src/app/api/devops/tickets/[id]/state/route.ts index bd3fe1df..9705a257 100644 --- a/src/app/api/devops/tickets/[id]/state/route.ts +++ b/src/app/api/devops/tickets/[id]/state/route.ts @@ -28,11 +28,34 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise< const organization = request.headers.get('x-devops-org') || undefined; const devopsService = new AzureDevOpsService(session.accessToken, organization); + console.log('[state PATCH] incoming', { + ticketId, + state, + project: body.project, + organization, + }); + // If project is provided in the body, use it directly if (body.project) { - const updatedWorkItem = await devopsService.updateTicketState(body.project, ticketId, state); - const ticket = workItemToTicket(updatedWorkItem); - return NextResponse.json({ ticket }); + try { + const updatedWorkItem = await devopsService.updateTicketState( + body.project, + ticketId, + state + ); + console.log('[state PATCH] success', { ticketId, state, project: body.project }); + const ticket = workItemToTicket(updatedWorkItem); + return NextResponse.json({ ticket }); + } catch (err) { + console.error('[state PATCH] DevOps rejected update', { + ticketId, + state, + project: body.project, + error: err instanceof Error ? err.message : err, + }); + const message = err instanceof Error ? err.message : 'Failed to update ticket state'; + return NextResponse.json({ error: message }, { status: 400 }); + } } // Fallback: search all projects to find the ticket @@ -42,23 +65,39 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise< try { const workItem = await devopsService.getWorkItem(project.name, ticketId); if (workItem) { - const updatedWorkItem = await devopsService.updateTicketState( - project.name, - ticketId, - state - ); + try { + const updatedWorkItem = await devopsService.updateTicketState( + project.name, + ticketId, + state + ); + console.log('[state PATCH] success (fallback project lookup)', { + ticketId, + state, + project: project.name, + }); - const ticket = workItemToTicket(updatedWorkItem, { - id: project.id, - name: project.name, - devOpsProject: project.name, - devOpsOrg: organization || '', - tags: [], - createdAt: new Date(), - updatedAt: new Date(), - }); + const ticket = workItemToTicket(updatedWorkItem, { + id: project.id, + name: project.name, + devOpsProject: project.name, + devOpsOrg: organization || '', + tags: [], + createdAt: new Date(), + updatedAt: new Date(), + }); - return NextResponse.json({ ticket }); + return NextResponse.json({ ticket }); + } catch (err) { + console.error('[state PATCH] DevOps rejected update (fallback)', { + ticketId, + state, + project: project.name, + error: err instanceof Error ? err.message : err, + }); + const message = err instanceof Error ? err.message : 'Failed to update ticket state'; + return NextResponse.json({ error: message }, { status: 400 }); + } } } catch { // Ticket not in this project, continue @@ -66,9 +105,10 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise< } } + console.warn('[state PATCH] ticket not found in any project', { ticketId }); return NextResponse.json({ error: 'Ticket not found' }, { status: 404 }); } catch (error) { - console.error('Error updating ticket state:', error); + console.error('[state PATCH] unexpected error', error); return NextResponse.json({ error: 'Failed to update ticket state' }, { status: 500 }); } } diff --git a/src/app/kanban/page.tsx b/src/app/kanban/page.tsx index 98d932c8..28bd3d02 100644 --- a/src/app/kanban/page.tsx +++ b/src/app/kanban/page.tsx @@ -201,12 +201,21 @@ function StandupPageContent() { const handleStateChange = useCallback( async (itemId: number, targetState: string) => { + console.log('[Kanban] PATCH /state', { itemId, targetState }); const response = await devOpsPatch(`/api/devops/tickets/${itemId}/state`, { state: targetState, }); if (!response.ok) { - throw new Error('Failed to update state'); + const bodyText = await response.text().catch(() => ''); + console.error('[Kanban] state PATCH rejected', { + itemId, + targetState, + status: response.status, + statusText: response.statusText, + body: bodyText, + }); + throw new Error(`Failed to update state (${response.status}): ${bodyText}`); } fetchStandupData(true, true); diff --git a/src/components/tickets/KanbanBoard.tsx b/src/components/tickets/KanbanBoard.tsx index 9ef4a143..fcfa7ce7 100644 --- a/src/components/tickets/KanbanBoard.tsx +++ b/src/components/tickets/KanbanBoard.tsx @@ -214,14 +214,20 @@ export default function KanbanBoard({ const { active, over } = event; setActiveId(null); - if (!over) return; + if (!over) { + console.log('[Kanban DnD] drag ended with no drop target'); + return; + } const activeItemId = active.id as number; const overId = over.id as string; // Find the original item (from props, not local state) const originalItem = sourceItems.find((t) => t.id === activeItemId); - if (!originalItem) return; + if (!originalItem) { + console.warn('[Kanban DnD] active item not found in sourceItems', { activeItemId }); + return; + } // Determine the target state let targetState: string | null = null; @@ -235,8 +241,17 @@ export default function KanbanBoard({ } } + const fromState = getItemState(originalItem); + console.log('[Kanban DnD] drag end', { + itemId: activeItemId, + overId, + fromState, + targetState, + knownStates: stateNames, + }); + // If state hasn't changed, reset to original - if (!targetState || getItemState(originalItem) === targetState) { + if (!targetState || fromState === targetState) { setLocalItems(sourceItems); return; } @@ -245,14 +260,30 @@ export default function KanbanBoard({ if (onTicketStateChange) { setIsUpdating(true); try { + console.log('[Kanban DnD] calling onTicketStateChange', { + itemId: activeItemId, + fromState, + targetState, + }); await onTicketStateChange(activeItemId, targetState); + console.log('[Kanban DnD] state change succeeded', { + itemId: activeItemId, + targetState, + }); } catch (error) { - console.error('Failed to update item state:', error); + console.error('[Kanban DnD] state change failed — rolling back', { + itemId: activeItemId, + fromState, + targetState, + error, + }); // Rollback on failure setLocalItems(sourceItems); } finally { setIsUpdating(false); } + } else { + console.warn('[Kanban DnD] no onTicketStateChange handler wired — change will not persist'); } }, [sourceItems, localItems, onTicketStateChange, stateNames] diff --git a/src/lib/devops.ts b/src/lib/devops.ts index bd6335d8..2e33d5a5 100644 --- a/src/lib/devops.ts +++ b/src/lib/devops.ts @@ -1196,21 +1196,37 @@ export class AzureDevOpsService { state: string ): Promise { const patchDocument = [{ op: 'add', path: '/fields/System.State', value: state }]; + const url = `${this.baseUrl}/${encodeURIComponent(projectName)}/_apis/wit/workitems/${workItemId}?api-version=7.0`; - const response = await fetch( - `${this.baseUrl}/${encodeURIComponent(projectName)}/_apis/wit/workitems/${workItemId}?api-version=7.0`, - { - method: 'PATCH', - headers: { - ...this.headers, - 'Content-Type': 'application/json-patch+json', - }, - body: JSON.stringify(patchDocument), - } - ); + console.log('[devops.updateTicketState] PATCH', { + url, + projectName, + workItemId, + state, + }); + + const response = await fetch(url, { + method: 'PATCH', + headers: { + ...this.headers, + 'Content-Type': 'application/json-patch+json', + }, + body: JSON.stringify(patchDocument), + }); if (!response.ok) { - throw new Error(`Failed to update work item: ${response.statusText}`); + const errorBody = await response.text().catch(() => ''); + console.error('[devops.updateTicketState] DevOps rejected PATCH', { + projectName, + workItemId, + state, + status: response.status, + statusText: response.statusText, + body: errorBody, + }); + throw new Error( + `Failed to update work item ${workItemId} state to "${state}": ${response.status} ${response.statusText} — ${errorBody}` + ); } return response.json();