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
78 changes: 59 additions & 19 deletions src/app/api/devops/tickets/[id]/state/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -42,33 +65,50 @@ 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 });
}
Comment on lines 49 to +100
}
} catch {
// Ticket not in this project, continue
continue;
}
}

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 });
}
}
11 changes: 10 additions & 1 deletion src/app/kanban/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => '<no body>');
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);
Expand Down
39 changes: 35 additions & 4 deletions src/components/tickets/KanbanBoard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
}
Expand All @@ -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');
Comment on lines 244 to +286
}
},
[sourceItems, localItems, onTicketStateChange, stateNames]
Expand Down
40 changes: 28 additions & 12 deletions src/lib/devops.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1196,21 +1196,37 @@ export class AzureDevOpsService {
state: string
): Promise<DevOpsWorkItem> {
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(() => '<no body>');
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}`
Comment on lines +1218 to +1228
);
}

return response.json();
Expand Down