Skip to content

Commit db0b46b

Browse files
waleedlatif1claude
andcommitted
azure_devops: address bugbot review comments
- triggers/utils: match build.complete result case-insensitively, accept stopped/cancelled in addition to failed/canceled/partiallySucceeded so PascalCase and legacy Azure DevOps payloads aren't dropped - get_work_items_batch: chunk comma-separated IDs into 200-batch loops with proper status checks (was failing or returning incomplete data on >200 IDs) - Add tests for both behaviors Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 609a647 commit db0b46b

4 files changed

Lines changed: 144 additions & 11 deletions

File tree

apps/sim/tools/azure_devops/azure-devops.test.ts

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
* @vitest-environment node
33
*/
44
import { afterEach, describe, expect, it, vi } from 'vitest'
5+
import { isAzureDevOpsEventMatch } from '@/triggers/azure_devops/utils'
56
import { tools } from '../registry'
67
import type { ToolConfig } from '../types'
78
import { addCommentTool } from './add_comment'
@@ -572,9 +573,11 @@ describe('Azure DevOps response transforms', () => {
572573
})
573574

574575
const batch = await getWorkItemsBatchTool.transformResponse!(
575-
responseJson({ value: [rawWorkItem] })
576+
responseJson({ value: [rawWorkItem] }),
577+
{ ...baseParams, ids: '101' } satisfies GetWorkItemsBatchParams
576578
)
577579
expect(batch.output.metadata.count).toBe(1)
580+
expect(batch.output.metadata.totalRequested).toBe(1)
578581
})
579582

580583
it('hydrates WIQL query results in chunks of 200 IDs', async () => {
@@ -602,6 +605,26 @@ describe('Azure DevOps response transforms', () => {
602605
expect(result.output.metadata.workItems).toHaveLength(2)
603606
})
604607

608+
it('chunks Get Work Items Batch requests larger than 200 IDs', async () => {
609+
const fetchMock = vi
610+
.fn()
611+
.mockImplementation(() => Promise.resolve(responseJson({ value: [rawWorkItem] })))
612+
globalThis.fetch = fetchMock as unknown as typeof fetch
613+
614+
const ids = Array.from({ length: 350 }, (_, i) => String(i + 1)).join(',')
615+
616+
const result = await getWorkItemsBatchTool.transformResponse!(
617+
responseJson({ value: [rawWorkItem] }),
618+
{ ...baseParams, ids } satisfies GetWorkItemsBatchParams
619+
)
620+
621+
expect(fetchMock).toHaveBeenCalledTimes(1)
622+
const followupChunk = new URL(String(fetchMock.mock.calls[0][0]))
623+
expect(followupChunk.searchParams.get('ids')?.split(',')).toHaveLength(150)
624+
expect(result.output.metadata.totalRequested).toBe(350)
625+
expect(result.output.metadata.workItems).toHaveLength(2)
626+
})
627+
605628
it('throws when WIQL hydration fetch returns a non-OK status', async () => {
606629
const fetchMock = vi
607630
.fn()
@@ -672,3 +695,57 @@ describe('Azure DevOps response transforms', () => {
672695
})
673696
})
674697
})
698+
699+
describe('Azure DevOps trigger event matching', () => {
700+
const baseBuild = { eventType: 'build.complete' }
701+
const baseWorkItem = { eventType: 'workitem.created' }
702+
703+
it('matches build.complete results case-insensitively including stopped/Failed/Canceled', () => {
704+
for (const result of [
705+
'failed',
706+
'Failed',
707+
'FAILED',
708+
'canceled',
709+
'Canceled',
710+
'cancelled',
711+
'Cancelled',
712+
'stopped',
713+
'Stopped',
714+
'partiallySucceeded',
715+
'PartiallySucceeded',
716+
]) {
717+
expect(
718+
isAzureDevOpsEventMatch('azure_devops_build_failed', {
719+
...baseBuild,
720+
resource: { result },
721+
})
722+
).toBe(true)
723+
}
724+
})
725+
726+
it('does not match successful build.complete payloads', () => {
727+
for (const result of ['succeeded', 'Succeeded', 'inProgress']) {
728+
expect(
729+
isAzureDevOpsEventMatch('azure_devops_build_failed', {
730+
...baseBuild,
731+
resource: { result },
732+
})
733+
).toBe(false)
734+
}
735+
})
736+
737+
it('ignores non-build event types when expecting build.complete', () => {
738+
expect(
739+
isAzureDevOpsEventMatch('azure_devops_build_failed', {
740+
eventType: 'workitem.created',
741+
resource: { result: 'failed' },
742+
})
743+
).toBe(false)
744+
})
745+
746+
it('matches workitem.created and passes through generic webhook', () => {
747+
expect(isAzureDevOpsEventMatch('azure_devops_work_item_created', baseWorkItem)).toBe(true)
748+
expect(isAzureDevOpsEventMatch('azure_devops_work_item_created', baseBuild)).toBe(false)
749+
expect(isAzureDevOpsEventMatch('azure_devops_webhook', { eventType: 'anything' })).toBe(true)
750+
})
751+
})

apps/sim/tools/azure_devops/get_work_items_batch.ts

Lines changed: 57 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ export const getWorkItemsBatchTool: ToolConfig<GetWorkItemsBatchParams, GetWorkI
1212
id: 'azure_devops_get_work_items_batch',
1313
name: 'Azure DevOps Get Work Items Batch',
1414
description:
15-
'Fetch full details for multiple work items by ID from Azure DevOps in a single call. Pass comma-separated IDs (e.g. "123,456,789"). Maximum 200 IDs per request.',
15+
'Fetch full details for multiple work items by ID from Azure DevOps. Pass comma-separated IDs (e.g. "123,456,789"). Requests with more than 200 IDs are automatically split into chunks.',
1616
version: '1.0.0',
1717

1818
params: {
@@ -33,7 +33,7 @@ export const getWorkItemsBatchTool: ToolConfig<GetWorkItemsBatchParams, GetWorkI
3333
required: true,
3434
visibility: 'user-or-llm',
3535
description:
36-
'Comma-separated work item IDs to fetch (e.g. "123,456,789"). Maximum 200 IDs.',
36+
'Comma-separated work item IDs to fetch (e.g. "123,456,789"). Lists longer than 200 IDs are chunked automatically.',
3737
},
3838
accessToken: {
3939
type: 'string',
@@ -45,10 +45,15 @@ export const getWorkItemsBatchTool: ToolConfig<GetWorkItemsBatchParams, GetWorkI
4545

4646
request: {
4747
url: (params) => {
48+
const allIds = params.ids
49+
.split(',')
50+
.map((id) => id.trim())
51+
.filter(Boolean)
52+
const firstChunk = allIds.slice(0, 200)
4853
const url = new URL(
4954
`https://dev.azure.com/${params.organization.trim()}/${params.project.trim()}/_apis/wit/workitems`
5055
)
51-
url.searchParams.set('ids', params.ids)
56+
url.searchParams.set('ids', firstChunk.join(','))
5257
url.searchParams.set('$expand', 'all')
5358
url.searchParams.set('api-version', '7.2-preview.3')
5459
return url.toString()
@@ -60,22 +65,61 @@ export const getWorkItemsBatchTool: ToolConfig<GetWorkItemsBatchParams, GetWorkI
6065
}),
6166
},
6267

63-
transformResponse: async (response) => {
64-
const data = await response.json()
65-
const workItems: AzureDevOpsWorkItem[] = (data.value ?? []).map(
68+
transformResponse: async (response, params) => {
69+
const firstData = await response.json()
70+
const workItems: AzureDevOpsWorkItem[] = (firstData.value ?? []).map(
6671
(raw: AzureDevOpsRawWorkItem) => mapWorkItem(raw)
6772
)
6873

74+
const allIds = params!.ids
75+
.split(',')
76+
.map((id) => id.trim())
77+
.filter(Boolean)
78+
79+
if (allIds.length > 200) {
80+
const BATCH_SIZE = 200
81+
const organization = params!.organization.trim()
82+
const project = params!.project.trim()
83+
const authHeader = `Basic ${btoa(`:${params!.accessToken}`)}`
84+
85+
for (let i = BATCH_SIZE; i < allIds.length; i += BATCH_SIZE) {
86+
const chunk = allIds.slice(i, i + BATCH_SIZE)
87+
const detailsUrl = new URL(
88+
`https://dev.azure.com/${organization}/${project}/_apis/wit/workitems`
89+
)
90+
detailsUrl.searchParams.set('ids', chunk.join(','))
91+
detailsUrl.searchParams.set('$expand', 'all')
92+
detailsUrl.searchParams.set('api-version', '7.2-preview.3')
93+
94+
const chunkResponse = await fetch(detailsUrl.toString(), {
95+
method: 'GET',
96+
headers: { 'Content-Type': 'application/json', Authorization: authHeader },
97+
})
98+
99+
if (!chunkResponse.ok) {
100+
const errorBody = await chunkResponse.text().catch(() => '')
101+
throw new Error(
102+
`Failed to fetch work item batch chunk (${chunkResponse.status}): ${errorBody || chunkResponse.statusText}`
103+
)
104+
}
105+
106+
const chunkData = await chunkResponse.json()
107+
for (const raw of chunkData.value ?? []) {
108+
workItems.push(mapWorkItem(raw as AzureDevOpsRawWorkItem))
109+
}
110+
}
111+
}
112+
69113
const content =
70114
workItems.length === 0
71115
? 'No work items found for the provided IDs.'
72-
: `Found ${workItems.length} work item(s):\n\n${workItems.map(formatWorkItem).join('\n\n')}`
116+
: `Found ${workItems.length} work item(s) (of ${allIds.length} requested):\n\n${workItems.map(formatWorkItem).join('\n\n')}`
73117

74118
return {
75119
success: true,
76120
output: {
77121
content,
78-
metadata: { count: workItems.length, workItems },
122+
metadata: { count: workItems.length, totalRequested: allIds.length, workItems },
79123
},
80124
}
81125
},
@@ -90,6 +134,11 @@ export const getWorkItemsBatchTool: ToolConfig<GetWorkItemsBatchParams, GetWorkI
90134
description: 'Work items metadata',
91135
properties: {
92136
count: { type: 'number', description: 'Number of work items returned' },
137+
totalRequested: {
138+
type: 'number',
139+
description: 'Total number of IDs requested (across all chunks)',
140+
optional: true,
141+
},
93142
workItems: {
94143
type: 'array',
95144
description: 'Array of work item details',

apps/sim/tools/azure_devops/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,7 @@ export interface GetWorkItemsBatchResponse extends ToolResponse {
306306
content: string
307307
metadata: {
308308
count: number
309+
totalRequested?: number
309310
workItems: AzureDevOpsWorkItem[]
310311
}
311312
}

apps/sim/triggers/azure_devops/utils.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,8 +54,14 @@ export function isAzureDevOpsEventMatch(triggerId: string, body: Record<string,
5454
return false
5555
}
5656
const resource = body.resource as Record<string, unknown> | undefined
57-
const result = resource?.result as string | undefined
58-
return result === 'failed' || result === 'canceled' || result === 'partiallySucceeded'
57+
const result = (resource?.result as string | undefined)?.toLowerCase()
58+
return (
59+
result === 'failed' ||
60+
result === 'canceled' ||
61+
result === 'cancelled' ||
62+
result === 'stopped' ||
63+
result === 'partiallysucceeded'
64+
)
5965
}
6066

6167
if (triggerId === 'azure_devops_work_item_created') {

0 commit comments

Comments
 (0)