chore: import upstream snapshot with attribution
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
+122
View File
@@ -0,0 +1,122 @@
import type {
AddCommentParams,
AddCommentResponse,
AzureDevOpsComment,
} from '@/tools/azure_devops/types'
import type { AzureDevOpsRawComment } from '@/tools/azure_devops/utils'
import { formatComment, mapComment } from '@/tools/azure_devops/utils'
import type { ToolConfig } from '@/tools/types'
export const addCommentTool: ToolConfig<AddCommentParams, AddCommentResponse> = {
id: 'azure_devops_add_comment',
name: 'Azure DevOps Add Comment',
description: 'Add a comment to a work item in Azure DevOps.',
version: '1.0.0',
params: {
organization: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Azure DevOps organization name',
},
project: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Azure DevOps project name',
},
workItemId: {
type: 'number',
required: true,
visibility: 'user-or-llm',
description: 'ID of the work item to comment on',
},
text: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Comment text (HTML supported, e.g. "<p>My comment</p>")',
},
accessToken: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Azure DevOps Personal Access Token (scopes: Work Items: Read & Write)',
},
},
request: {
url: (params) => {
const url = new URL(
`https://dev.azure.com/${params.organization.trim()}/${params.project.trim()}/_apis/wit/workitems/${Number(params.workItemId)}/comments`
)
url.searchParams.set('api-version', '7.0-preview.3')
return url.toString()
},
method: 'POST',
headers: (params) => ({
'Content-Type': 'application/json',
Authorization: `Basic ${btoa(`:${params.accessToken}`)}`,
}),
body: (params) => ({ text: params.text }),
},
transformResponse: async (response) => {
const raw: AzureDevOpsRawComment = await response.json()
const comment: AzureDevOpsComment = mapComment(raw)
return {
success: true,
output: {
content: `Added comment #${comment.commentId}:\n\n${formatComment(comment)}`,
metadata: { comment },
},
}
},
outputs: {
content: {
type: 'string',
description: 'Human-readable confirmation of the added comment',
},
metadata: {
type: 'object',
description: 'Added comment metadata',
properties: {
comment: {
type: 'object',
description: 'Full details of the created comment',
properties: {
workItemId: { type: 'number', description: 'Work item the comment belongs to' },
commentId: { type: 'number', description: 'Comment ID' },
version: { type: 'number', description: 'Comment version' },
text: { type: 'string', description: 'Comment text' },
renderedText: {
type: 'string',
description: 'Rendered HTML comment text when available',
optional: true,
},
createdBy: {
type: 'string',
description: 'Display name of the comment author, or null',
nullable: true,
},
createdDate: { type: 'string', description: 'ISO timestamp when comment was created' },
modifiedBy: {
type: 'string',
description: 'Display name of the last modifier, or null',
nullable: true,
},
modifiedDate: {
type: 'string',
description: 'ISO timestamp when comment was modified',
},
isDeleted: { type: 'boolean', description: 'Whether the comment is deleted' },
url: { type: 'string', description: 'API URL for the comment' },
},
},
},
},
},
}
@@ -0,0 +1,788 @@
/**
* @vitest-environment node
*/
import { afterEach, describe, expect, it, vi } from 'vitest'
import { isAzureDevOpsEventMatch } from '@/triggers/azure_devops/utils'
import { tools } from '../registry'
import type { ToolConfig } from '../types'
import { addCommentTool } from './add_comment'
import { createWorkItemTool } from './create_work_item'
import { getBuildLogTool } from './get_build_log'
import { getBuildTimelineTool } from './get_build_timeline'
import { getCommentsTool } from './get_comments'
import { getPipelineTool } from './get_pipeline'
import { getPipelineRunTool } from './get_pipeline_run'
import { getWorkItemTool } from './get_work_item'
import { getWorkItemsBatchTool } from './get_work_items_batch'
import { getWorkItemsBetweenBuildsTool } from './get_work_items_between_builds'
import { listBuildLogsTool } from './list_build_logs'
import { listBuildsTool } from './list_builds'
import { listPipelineRunsTool } from './list_pipeline_runs'
import { listPipelinesTool } from './list_pipelines'
import { queryWorkItemsTool } from './query_work_items'
import type {
AddCommentParams,
CreateWorkItemParams,
GetBuildLogParams,
GetCommentsParams,
GetPipelineParams,
GetPipelineRunParams,
GetWorkItemParams,
GetWorkItemsBatchParams,
GetWorkItemsBetweenBuildsParams,
ListBuildLogsParams,
ListBuildsParams,
ListPipelineRunsParams,
ListPipelinesParams,
QueryWorkItemsParams,
UpdateWorkItemParams,
} from './types'
import { updateWorkItemTool } from './update_work_item'
const baseParams = {
organization: 'contoso',
project: 'Fabrikam',
accessToken: 'pat-token',
}
const authHeader = `Basic ${Buffer.from(':pat-token').toString('base64')}`
const allTools = [
addCommentTool,
createWorkItemTool,
getBuildLogTool,
getCommentsTool,
getPipelineTool,
getPipelineRunTool,
getWorkItemTool,
getWorkItemsBatchTool,
getWorkItemsBetweenBuildsTool,
listBuildLogsTool,
listBuildsTool,
listPipelineRunsTool,
listPipelinesTool,
queryWorkItemsTool,
updateWorkItemTool,
] as const
function buildUrl<P, R>(tool: ToolConfig<P, R>, params: P): string {
return typeof tool.request.url === 'function' ? tool.request.url(params) : tool.request.url
}
function buildHeaders<P, R>(tool: ToolConfig<P, R>, params: P): Record<string, string> {
return tool.request.headers(params)
}
function buildBody<P, R>(tool: ToolConfig<P, R>, params: P): unknown {
return tool.request.body?.(params)
}
function responseJson(body: unknown): Response {
return new Response(JSON.stringify(body))
}
const rawWorkItem = {
id: 101,
url: 'https://dev.azure.com/contoso/Fabrikam/_apis/wit/workItems/101',
fields: {
'System.Title': 'SimIntegrationTest Issue',
'System.State': 'Doing',
'System.WorkItemType': 'Issue',
'System.AssignedTo': { displayName: 'Ada Lovelace' },
'System.AreaPath': 'Fabrikam\\Platform',
},
}
const rawComment = {
workItemId: 101,
commentId: 9,
version: 1,
text: 'SimIntegrationTest comment',
renderedText: '<p>SimIntegrationTest comment</p>',
createdBy: { displayName: 'Ada Lovelace' },
createdDate: '2026-05-15T10:00:00Z',
modifiedBy: { displayName: 'Ada Lovelace' },
modifiedDate: '2026-05-15T10:00:00Z',
isDeleted: false,
url: 'https://dev.azure.com/contoso/Fabrikam/_apis/wit/workitems/101/comments/9',
id: 9,
}
describe('Azure DevOps tool contracts', () => {
it('exports and registers the full planned tool surface', () => {
const expectedIds = [
'azure_devops_add_comment',
'azure_devops_create_work_item',
'azure_devops_get_build_log',
'azure_devops_get_comments',
'azure_devops_get_pipeline',
'azure_devops_get_pipeline_run',
'azure_devops_get_work_item',
'azure_devops_get_work_items_batch',
'azure_devops_get_work_items_between_builds',
'azure_devops_list_build_logs',
'azure_devops_list_builds',
'azure_devops_list_pipeline_runs',
'azure_devops_list_pipelines',
'azure_devops_query_work_items',
'azure_devops_update_work_item',
]
expect(allTools.map((tool) => tool.id).sort()).toEqual(expectedIds)
for (const id of expectedIds) {
expect(tools[id]?.id).toBe(id)
}
})
it('sets Basic PAT auth on every tool', () => {
for (const tool of allTools) {
expect(
buildHeaders(tool, {
...baseParams,
pipelineId: 1,
runId: 2,
buildId: 3,
logId: 4,
fromBuildId: 5,
toBuildId: 6,
workItemId: 7,
ids: '7',
wiqlQuery: 'SELECT [System.Id] FROM workitems',
workItemType: 'Issue',
title: 'Issue title',
text: 'Comment text',
}).Authorization
).toBe(authHeader)
}
})
})
describe('Azure DevOps request builders', () => {
it('builds pipeline URLs and optional params', () => {
expect(buildUrl(listPipelinesTool, baseParams)).toBe(
'https://dev.azure.com/contoso/Fabrikam/_apis/pipelines?api-version=7.2-preview.1'
)
expect(
buildUrl(listPipelinesTool, {
...baseParams,
orderBy: 'name',
top: 10,
continuationToken: 'next-page',
} satisfies ListPipelinesParams)
).toBe(
'https://dev.azure.com/contoso/Fabrikam/_apis/pipelines?api-version=7.2-preview.1&orderBy=name&%24top=10&continuationToken=next-page'
)
expect(
buildUrl(getPipelineTool, {
...baseParams,
pipelineId: 42,
pipelineVersion: 3,
} satisfies GetPipelineParams)
).toBe(
'https://dev.azure.com/contoso/Fabrikam/_apis/pipelines/42?api-version=7.2-preview.1&pipelineVersion=3'
)
expect(
buildUrl(listPipelineRunsTool, {
...baseParams,
pipelineId: 42,
} satisfies ListPipelineRunsParams)
).toBe(
'https://dev.azure.com/contoso/Fabrikam/_apis/pipelines/42/runs?api-version=7.2-preview.1'
)
expect(
buildUrl(getPipelineRunTool, {
...baseParams,
pipelineId: 42,
runId: 99,
} satisfies GetPipelineRunParams)
).toBe(
'https://dev.azure.com/contoso/Fabrikam/_apis/pipelines/42/runs/99?api-version=7.2-preview.1'
)
})
it('builds build URLs and optional filters', () => {
expect(
buildUrl(listBuildsTool, {
...baseParams,
definitionIds: '1,2',
top: 20,
statusFilter: 'completed',
resultFilter: 'failed',
branchName: 'refs/heads/main',
} satisfies ListBuildsParams)
).toBe(
'https://dev.azure.com/contoso/Fabrikam/_apis/build/builds?api-version=7.2-preview.8&definitions=1%2C2&%24top=20&statusFilter=completed&resultFilter=failed&branchName=refs%2Fheads%2Fmain'
)
expect(
buildUrl(listBuildLogsTool, {
...baseParams,
buildId: 101,
} satisfies ListBuildLogsParams)
).toBe(
'https://dev.azure.com/contoso/Fabrikam/_apis/build/builds/101/logs?api-version=7.2-preview.2'
)
expect(
buildUrl(getBuildLogTool, {
...baseParams,
buildId: 101,
logId: 3,
startLine: 5,
endLine: 15,
} satisfies GetBuildLogParams)
).toBe(
'https://dev.azure.com/contoso/Fabrikam/_apis/build/builds/101/logs/3?api-version=7.2-preview.2&startLine=5&endLine=15'
)
expect(buildHeaders(getBuildLogTool, { ...baseParams, buildId: 101, logId: 3 }).Accept).toBe(
'text/plain'
)
})
it('uses the documented work-items-between-builds endpoint shape', () => {
expect(
buildUrl(getWorkItemsBetweenBuildsTool, {
...baseParams,
fromBuildId: 11,
toBuildId: 12,
} satisfies GetWorkItemsBetweenBuildsParams)
).toBe(
'https://dev.azure.com/contoso/Fabrikam/_apis/build/workitems?fromBuildId=11&toBuildId=12&api-version=7.2-preview.2'
)
})
it('builds work item URLs and bodies', () => {
expect(buildUrl(queryWorkItemsTool, baseParams)).toBe(
'https://dev.azure.com/contoso/Fabrikam/_apis/wit/wiql?api-version=7.2-preview.2'
)
expect(
buildBody(queryWorkItemsTool, {
...baseParams,
wiqlQuery: 'SELECT [System.Id] FROM workitems',
} satisfies QueryWorkItemsParams)
).toEqual({ query: 'SELECT [System.Id] FROM workitems' })
expect(
buildUrl(getWorkItemTool, {
...baseParams,
workItemId: 101,
} satisfies GetWorkItemParams)
).toBe(
'https://dev.azure.com/contoso/Fabrikam/_apis/wit/workitems/101?%24expand=all&api-version=7.2-preview.3'
)
expect(
buildUrl(getWorkItemsBatchTool, {
...baseParams,
ids: '101,102',
} satisfies GetWorkItemsBatchParams)
).toBe(
'https://dev.azure.com/contoso/Fabrikam/_apis/wit/workitems?ids=101%2C102&%24expand=all&api-version=7.2-preview.3'
)
})
it('builds JSON Patch work item write requests', () => {
const createParams = {
...baseParams,
workItemType: 'Issue',
title: 'Pipeline failure',
description: '<p>Failure details</p>',
assignedTo: 'ada@example.com',
areaPath: 'Fabrikam\\Platform',
} satisfies CreateWorkItemParams
expect(buildUrl(createWorkItemTool, createParams)).toBe(
'https://dev.azure.com/contoso/Fabrikam/_apis/wit/workitems/$Issue?api-version=7.2-preview.3'
)
expect(buildHeaders(createWorkItemTool, createParams)['Content-Type']).toBe(
'application/json-patch+json'
)
expect(buildBody(createWorkItemTool, createParams)).toEqual([
{ op: 'add', path: '/fields/System.Title', value: 'Pipeline failure' },
{ op: 'add', path: '/fields/System.Description', value: '<p>Failure details</p>' },
{ op: 'add', path: '/fields/System.AssignedTo', value: 'ada@example.com' },
{ op: 'add', path: '/fields/System.AreaPath', value: 'Fabrikam\\Platform' },
])
const updateParams = {
...baseParams,
workItemId: 101,
title: 'Updated pipeline failure',
state: 'Doing',
effort: 5,
} satisfies UpdateWorkItemParams
expect(buildUrl(updateWorkItemTool, updateParams)).toBe(
'https://dev.azure.com/contoso/Fabrikam/_apis/wit/workitems/101?api-version=7.2-preview.3'
)
expect(buildHeaders(updateWorkItemTool, updateParams)['Content-Type']).toBe(
'application/json-patch+json'
)
expect(buildBody(updateWorkItemTool, updateParams)).toEqual([
{ op: 'replace', path: '/fields/System.Title', value: 'Updated pipeline failure' },
{ op: 'replace', path: '/fields/System.State', value: 'Doing' },
{ op: 'replace', path: '/fields/Microsoft.VSTS.Scheduling.Effort', value: 5 },
])
expect(() => buildBody(updateWorkItemTool, { ...baseParams, workItemId: 101 })).toThrow(
/requires at least one field/
)
const createWithEffortParams = {
...createParams,
effort: 3,
} satisfies CreateWorkItemParams
expect(buildBody(createWorkItemTool, createWithEffortParams)).toContainEqual({
op: 'add',
path: '/fields/Microsoft.VSTS.Scheduling.Effort',
value: 3,
})
})
it('emits Epic-only scheduling patch ops on create', () => {
const epicParams = {
...baseParams,
workItemType: 'Epic',
title: 'Q3 platform epic',
startDate: '2026-06-01T00:00:00Z',
targetDate: '2026-09-30T00:00:00Z',
} satisfies CreateWorkItemParams
const body = buildBody(createWorkItemTool, epicParams)
expect(body).toContainEqual({
op: 'add',
path: '/fields/Microsoft.VSTS.Scheduling.StartDate',
value: '2026-06-01T00:00:00Z',
})
expect(body).toContainEqual({
op: 'add',
path: '/fields/Microsoft.VSTS.Scheduling.TargetDate',
value: '2026-09-30T00:00:00Z',
})
})
it('emits Task-only activity/work patch ops on create', () => {
const taskParams = {
...baseParams,
workItemType: 'Task',
title: 'Wire up retries',
activity: 'Development',
remainingWork: 4,
completedWork: 1,
} satisfies CreateWorkItemParams
const body = buildBody(createWorkItemTool, taskParams)
expect(body).toContainEqual({
op: 'add',
path: '/fields/Microsoft.VSTS.Common.Activity',
value: 'Development',
})
expect(body).toContainEqual({
op: 'add',
path: '/fields/Microsoft.VSTS.Scheduling.RemainingWork',
value: 4,
})
expect(body).toContainEqual({
op: 'add',
path: '/fields/Microsoft.VSTS.Scheduling.CompletedWork',
value: 1,
})
})
it('emits per-type replace ops on update when fields are provided', () => {
const updateAll = {
...baseParams,
workItemId: 101,
startDate: '2026-06-01T00:00:00Z',
activity: 'Testing',
remainingWork: 2,
} satisfies UpdateWorkItemParams
const body = buildBody(updateWorkItemTool, updateAll)
expect(body).toContainEqual({
op: 'replace',
path: '/fields/Microsoft.VSTS.Scheduling.StartDate',
value: '2026-06-01T00:00:00Z',
})
expect(body).toContainEqual({
op: 'replace',
path: '/fields/Microsoft.VSTS.Common.Activity',
value: 'Testing',
})
expect(body).toContainEqual({
op: 'replace',
path: '/fields/Microsoft.VSTS.Scheduling.RemainingWork',
value: 2,
})
})
it('builds comment URLs and bodies with comment API pinning', () => {
const addParams = {
...baseParams,
workItemId: 101,
text: 'SimIntegrationTest markdown comment',
} satisfies AddCommentParams
expect(buildUrl(addCommentTool, addParams)).toBe(
'https://dev.azure.com/contoso/Fabrikam/_apis/wit/workitems/101/comments?api-version=7.0-preview.3'
)
expect(buildBody(addCommentTool, addParams)).toEqual({
text: 'SimIntegrationTest markdown comment',
})
expect(
buildUrl(getCommentsTool, {
...baseParams,
workItemId: 101,
top: 2,
continuationToken: 'next',
includeDeleted: true,
expand: 'renderedText',
order: 'desc',
} satisfies GetCommentsParams)
).toBe(
'https://dev.azure.com/contoso/Fabrikam/_apis/wit/workitems/101/comments?api-version=7.2-preview.4&%24top=2&continuationToken=next&includeDeleted=true&%24expand=renderedText&order=desc'
)
})
})
describe('Azure DevOps response transforms', () => {
const originalFetch = globalThis.fetch
afterEach(() => {
globalThis.fetch = originalFetch
})
it('transforms list pipelines responses and empty results', async () => {
await expect(
listPipelinesTool.transformResponse!(responseJson({ count: 0, value: [] }))
).resolves.toEqual({
success: true,
output: { content: 'No pipelines found.', metadata: { count: 0, pipelines: [] } },
})
const result = await listPipelinesTool.transformResponse!(
responseJson({
value: [{ id: 1, name: 'CI', revision: 2, url: 'https://example/p/1' }],
})
)
expect(result.output.metadata).toEqual({
count: 1,
pipelines: [{ id: 1, name: 'CI', folder: '\\', revision: 2, url: 'https://example/p/1' }],
})
})
it('transforms pipeline detail and run responses with missing optional links', async () => {
const pipeline = await getPipelineTool.transformResponse!(
responseJson({
id: 42,
name: 'CI',
revision: 3,
url: 'https://example/p/42',
configuration: { type: 'yaml', path: '/azure-pipelines.yml' },
})
)
expect(pipeline.output.metadata.pipeline.links.web).toBe('')
expect(pipeline.output.metadata.pipeline.configuration.repository).toBeUndefined()
const runs = await listPipelineRunsTool.transformResponse!(responseJson({ value: [] }))
expect(runs.output).toEqual({
content: 'No pipeline runs found.',
metadata: { count: 0, runs: [] },
})
const run = await getPipelineRunTool.transformResponse!(
responseJson({
id: 99,
name: '20260515.1',
state: 'completed',
result: 'failed',
createdDate: '2026-05-15T10:00:00Z',
finishedDate: '2026-05-15T10:05:00Z',
url: 'https://example/r/99',
pipeline: { id: 42, name: 'CI', revision: 3, url: 'https://example/p/42' },
})
)
expect(run.output.metadata.run.pipeline.folder).toBe('\\')
expect(run.output.metadata.run.result).toBe('failed')
})
it('transforms build and log responses', async () => {
const builds = await listBuildsTool.transformResponse!(
responseJson({
value: [
{
id: 201,
buildNumber: '20260515.1',
status: 'completed',
result: 'failed',
queueTime: '2026-05-15T10:00:00Z',
sourceBranch: 'refs/heads/main',
sourceVersion: 'abc123',
},
],
})
)
expect(builds.output.metadata.builds[0].definition).toEqual({ id: 0, name: '' })
const logs = await listBuildLogsTool.transformResponse!(
responseJson({
count: 1,
value: [
{
id: 3,
type: 'Container',
url: 'https://example/log/3',
lineCount: 25,
createdOn: '2026-05-15T10:00:00Z',
},
],
})
)
expect(logs.output.metadata.logs[0].lineCount).toBe(25)
const log = await getBuildLogTool.transformResponse!(
new Response('line one\nline two\nline three\n')
)
expect(log.output.metadata.lineCount).toBe(3)
await expect(getBuildLogTool.transformResponse!(new Response(' '))).resolves.toEqual({
success: true,
output: { content: 'Log is empty.', metadata: { lineCount: 0 } },
})
})
it('transforms work item references and hydrated work items', async () => {
const betweenBuilds = await getWorkItemsBetweenBuildsTool.transformResponse!(
responseJson({ value: [{ id: 101, url: 'https://example/workitems/101' }] })
)
expect(betweenBuilds.output.metadata.workItems).toEqual([
{ id: '101', url: 'https://example/workitems/101' },
])
const getWorkItem = await getWorkItemTool.transformResponse!(responseJson(rawWorkItem))
expect(getWorkItem.output.metadata.workItem).toEqual({
id: 101,
title: 'SimIntegrationTest Issue',
state: 'Doing',
workItemType: 'Issue',
assignedTo: 'Ada Lovelace',
areaPath: 'Fabrikam\\Platform',
url: 'https://dev.azure.com/contoso/Fabrikam/_apis/wit/workItems/101',
})
const batch = await getWorkItemsBatchTool.transformResponse!(
responseJson({ value: [rawWorkItem] }),
{ ...baseParams, ids: '101' } satisfies GetWorkItemsBatchParams
)
expect(batch.output.metadata.count).toBe(1)
expect(batch.output.metadata.totalRequested).toBe(1)
})
it('hydrates WIQL query results in chunks of 200 IDs', async () => {
const fetchMock = vi
.fn()
.mockImplementation(() => Promise.resolve(responseJson({ value: [rawWorkItem] })))
globalThis.fetch = fetchMock as unknown as typeof fetch
const workItems = Array.from({ length: 201 }, (_, index) => ({
id: index + 1,
url: `https://example/workitems/${index + 1}`,
}))
const result = await queryWorkItemsTool.transformResponse!(responseJson({ workItems }), {
...baseParams,
wiqlQuery: 'SELECT [System.Id] FROM workitems',
} satisfies QueryWorkItemsParams)
expect(fetchMock).toHaveBeenCalledTimes(2)
const firstChunk = new URL(String(fetchMock.mock.calls[0][0]))
const secondChunk = new URL(String(fetchMock.mock.calls[1][0]))
expect(firstChunk.searchParams.get('ids')?.split(',')).toHaveLength(200)
expect(secondChunk.searchParams.get('ids')?.split(',')).toHaveLength(1)
expect(result.output.metadata.totalMatched).toBe(201)
expect(result.output.metadata.workItems).toHaveLength(2)
})
it('throws when Get Work Items Batch is invoked with no valid IDs', () => {
expect(() =>
buildUrl(getWorkItemsBatchTool, {
...baseParams,
ids: ' , , ',
} satisfies GetWorkItemsBatchParams)
).toThrow(/requires at least one work item ID/)
})
it('chunks Get Work Items Batch requests larger than 200 IDs', async () => {
const fetchMock = vi
.fn()
.mockImplementation(() => Promise.resolve(responseJson({ value: [rawWorkItem] })))
globalThis.fetch = fetchMock as unknown as typeof fetch
const ids = Array.from({ length: 350 }, (_, i) => String(i + 1)).join(',')
const result = await getWorkItemsBatchTool.transformResponse!(
responseJson({ value: [rawWorkItem] }),
{ ...baseParams, ids } satisfies GetWorkItemsBatchParams
)
expect(fetchMock).toHaveBeenCalledTimes(1)
const followupChunk = new URL(String(fetchMock.mock.calls[0][0]))
expect(followupChunk.searchParams.get('ids')?.split(',')).toHaveLength(150)
expect(result.output.metadata.totalRequested).toBe(350)
expect(result.output.metadata.workItems).toHaveLength(2)
})
it('throws when WIQL hydration fetch returns a non-OK status', async () => {
const fetchMock = vi
.fn()
.mockResolvedValue(new Response('forbidden', { status: 403, statusText: 'Forbidden' }))
globalThis.fetch = fetchMock as unknown as typeof fetch
await expect(
queryWorkItemsTool.transformResponse!(responseJson({ workItems: [{ id: 1, url: 'x' }] }), {
...baseParams,
wiqlQuery: 'SELECT [System.Id] FROM workitems',
} satisfies QueryWorkItemsParams)
).rejects.toThrow(/Failed to hydrate work item details/)
})
it('does not hydrate WIQL empty results', async () => {
const fetchMock = vi.fn()
globalThis.fetch = fetchMock as unknown as typeof fetch
const result = await queryWorkItemsTool.transformResponse!(responseJson({ workItems: [] }), {
...baseParams,
wiqlQuery: 'SELECT [System.Id] FROM workitems WHERE [System.Id] = 0',
} satisfies QueryWorkItemsParams)
expect(fetchMock).not.toHaveBeenCalled()
expect(result.output.metadata).toEqual({ count: 0, workItems: [] })
})
it('transforms create and update work item responses', async () => {
const created = await createWorkItemTool.transformResponse!(responseJson(rawWorkItem))
expect(created.output.content).toContain('Created work item #101')
const updated = await updateWorkItemTool.transformResponse!(responseJson(rawWorkItem))
expect(updated.output.content).toContain('Updated work item #101')
})
it('transforms comment responses and empty comment lists', async () => {
const added = await addCommentTool.transformResponse!(responseJson(rawComment))
expect(added.output.metadata.comment).toEqual({
workItemId: 101,
commentId: 9,
version: 1,
text: 'SimIntegrationTest comment',
renderedText: '<p>SimIntegrationTest comment</p>',
createdBy: 'Ada Lovelace',
createdDate: '2026-05-15T10:00:00Z',
modifiedBy: 'Ada Lovelace',
modifiedDate: '2026-05-15T10:00:00Z',
isDeleted: false,
url: 'https://dev.azure.com/contoso/Fabrikam/_apis/wit/workitems/101/comments/9',
})
const comments = await getCommentsTool.transformResponse!(
responseJson({
count: 1,
totalCount: 2,
comments: [rawComment],
continuationToken: 'next',
nextPage: 'https://example/next',
})
)
expect(comments.output.metadata.count).toBe(1)
expect(comments.output.metadata.continuationToken).toBe('next')
const empty = await getCommentsTool.transformResponse!(responseJson({ comments: [] }))
expect(empty.output).toEqual({
content: 'No comments found for this work item.',
metadata: { count: 0, totalCount: 0, comments: [] },
})
})
})
describe('Azure DevOps trigger event matching', () => {
const baseBuild = { eventType: 'build.complete' }
const baseWorkItem = { eventType: 'workitem.created' }
it('matches build.complete results case-insensitively including stopped/Failed/Canceled', () => {
for (const result of [
'failed',
'Failed',
'FAILED',
'canceled',
'Canceled',
'cancelled',
'Cancelled',
'stopped',
'Stopped',
'partiallySucceeded',
'PartiallySucceeded',
]) {
expect(
isAzureDevOpsEventMatch('azure_devops_build_failed', {
...baseBuild,
resource: { result },
})
).toBe(true)
}
})
it('does not match successful build.complete payloads', () => {
for (const result of ['succeeded', 'Succeeded', 'inProgress']) {
expect(
isAzureDevOpsEventMatch('azure_devops_build_failed', {
...baseBuild,
resource: { result },
})
).toBe(false)
}
})
it('ignores non-build event types when expecting build.complete', () => {
expect(
isAzureDevOpsEventMatch('azure_devops_build_failed', {
eventType: 'workitem.created',
resource: { result: 'failed' },
})
).toBe(false)
})
it('build timeline includes partiallySucceeded and succeededWithIssues in failedRecords', async () => {
const records = [
{ id: 'a', name: 'Step A', type: 'Task', result: 'succeeded', log: { id: 1 } },
{ id: 'b', name: 'Step B', type: 'Task', result: 'failed', log: { id: 2 } },
{ id: 'c', name: 'Step C', type: 'Task', result: 'partiallySucceeded', log: { id: 3 } },
{ id: 'd', name: 'Step D', type: 'Task', result: 'succeededWithIssues', log: { id: 4 } },
{ id: 'e', name: 'Step E', type: 'Task', result: 'skipped', log: null },
]
const result = await getBuildTimelineTool.transformResponse!(
new Response(JSON.stringify({ records }))
)
const failedIds = result.output.metadata.failedRecords.map((r) => r.id)
expect(failedIds).toEqual(['b', 'c', 'd'])
expect(result.output.metadata.failedCount).toBe(3)
})
it('matches workitem.created and passes through generic webhook', () => {
expect(isAzureDevOpsEventMatch('azure_devops_work_item_created', baseWorkItem)).toBe(true)
expect(isAzureDevOpsEventMatch('azure_devops_work_item_created', baseBuild)).toBe(false)
expect(isAzureDevOpsEventMatch('azure_devops_webhook', { eventType: 'anything' })).toBe(true)
})
it('extractIdempotencyId returns null when subscriptionId or notificationId is missing', async () => {
const { azureDevOpsHandler } = await import('@/lib/webhooks/providers/azure-devops')
expect(azureDevOpsHandler.extractIdempotencyId!({})).toBeNull()
expect(azureDevOpsHandler.extractIdempotencyId!({ subscriptionId: 'sub-1' })).toBeNull()
expect(azureDevOpsHandler.extractIdempotencyId!({ notificationId: 42 })).toBeNull()
expect(
azureDevOpsHandler.extractIdempotencyId!({ subscriptionId: 'sub-1', notificationId: 42 })
).toBe('azure_devops:sub-1:42')
expect(azureDevOpsHandler.extractIdempotencyId!(null)).toBeNull()
})
})
@@ -0,0 +1,247 @@
import type {
AzureDevOpsWorkItem,
CreateWorkItemParams,
CreateWorkItemResponse,
} from '@/tools/azure_devops/types'
import type { AzureDevOpsJsonPatchOp, AzureDevOpsRawWorkItem } from '@/tools/azure_devops/utils'
import {
appendEffortPatchOp,
appendFieldPatchOp,
formatWorkItem,
mapWorkItem,
} from '@/tools/azure_devops/utils'
import type { ToolConfig } from '@/tools/types'
export const createWorkItemTool: ToolConfig<CreateWorkItemParams, CreateWorkItemResponse> = {
id: 'azure_devops_create_work_item',
name: 'Azure DevOps Create Work Item',
description:
'Create a new Basic-process work item (Issue, Task, or Epic) in Azure DevOps. Returns the created work item with its assigned ID.',
version: '1.0.0',
params: {
organization: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Azure DevOps organization name',
},
project: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Azure DevOps project name',
},
workItemType: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description:
'Basic-process work item type to create ("Issue", "Task", or "Epic"). Use Issue for bug or defect tracking.',
},
title: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Title of the new work item',
},
description: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'HTML description of the work item (optional)',
},
assignedTo: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Email or display name of the user to assign the work item to (optional)',
},
priority: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Priority of the work item (1 = Critical, 2 = High, 3 = Medium, 4 = Low)',
},
effort: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Effort (Microsoft.VSTS.Scheduling.Effort). Basic process: Issue only.',
},
startDate: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description:
'Start date (Microsoft.VSTS.Scheduling.StartDate), ISO 8601. Basic process: Epic only.',
},
targetDate: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description:
'Target date (Microsoft.VSTS.Scheduling.TargetDate), ISO 8601. Basic process: Epic only.',
},
activity: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description:
'Activity (Microsoft.VSTS.Common.Activity). One of Deployment, Design, Development, Documentation, Requirements, Testing. Basic process: Task only.',
},
remainingWork: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description:
'Remaining work hours (Microsoft.VSTS.Scheduling.RemainingWork). Basic process: Task only.',
},
completedWork: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description:
'Completed work hours (Microsoft.VSTS.Scheduling.CompletedWork). Basic process: Task only.',
},
areaPath: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Area path for the work item, e.g. "MyProject\\\\Team" (optional)',
},
iterationPath: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Iteration path for the work item, e.g. "MyProject\\\\Sprint 1" (optional)',
},
tags: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Semicolon-separated tags, e.g. "issue; p1; auth" (optional)',
},
accessToken: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Azure DevOps Personal Access Token (scopes: Work Items: Read & Write)',
},
},
request: {
url: (params) =>
`https://dev.azure.com/${params.organization.trim()}/${params.project.trim()}/_apis/wit/workitems/$${encodeURIComponent(params.workItemType)}?api-version=7.2-preview.3`,
method: 'POST',
headers: (params) => ({
'Content-Type': 'application/json-patch+json',
Authorization: `Basic ${btoa(`:${params.accessToken}`)}`,
}),
body: (params) => {
const ops: AzureDevOpsJsonPatchOp[] = [
{ op: 'add', path: '/fields/System.Title', value: params.title },
]
if (params.description) {
ops.push({ op: 'add', path: '/fields/System.Description', value: params.description })
}
if (params.assignedTo) {
ops.push({ op: 'add', path: '/fields/System.AssignedTo', value: params.assignedTo })
}
if (params.priority !== undefined) {
ops.push({
op: 'add',
path: '/fields/Microsoft.VSTS.Common.Priority',
value: String(Number(params.priority)),
})
}
appendEffortPatchOp(ops, params.effort, 'add')
appendFieldPatchOp(
ops,
'Microsoft.VSTS.Scheduling.StartDate',
params.startDate,
'add',
'string'
)
appendFieldPatchOp(
ops,
'Microsoft.VSTS.Scheduling.TargetDate',
params.targetDate,
'add',
'string'
)
appendFieldPatchOp(ops, 'Microsoft.VSTS.Common.Activity', params.activity, 'add', 'string')
appendFieldPatchOp(
ops,
'Microsoft.VSTS.Scheduling.RemainingWork',
params.remainingWork,
'add',
'number'
)
appendFieldPatchOp(
ops,
'Microsoft.VSTS.Scheduling.CompletedWork',
params.completedWork,
'add',
'number'
)
if (params.areaPath) {
ops.push({ op: 'add', path: '/fields/System.AreaPath', value: params.areaPath })
}
if (params.iterationPath) {
ops.push({ op: 'add', path: '/fields/System.IterationPath', value: params.iterationPath })
}
if (params.tags) {
ops.push({ op: 'add', path: '/fields/System.Tags', value: params.tags })
}
return ops
},
},
transformResponse: async (response) => {
const raw: AzureDevOpsRawWorkItem = await response.json()
const workItem: AzureDevOpsWorkItem = mapWorkItem(raw)
return {
success: true,
output: {
content: `Created work item #${workItem.id}:\n\n${formatWorkItem(workItem)}`,
metadata: { workItem },
},
}
},
outputs: {
content: {
type: 'string',
description: 'Human-readable summary of the created work item',
},
metadata: {
type: 'object',
description: 'Created work item metadata',
properties: {
workItem: {
type: 'object',
description: 'Full details of the created work item',
properties: {
id: { type: 'number', description: 'Assigned work item ID' },
title: { type: 'string', description: 'Work item title' },
state: {
type: 'string',
description: 'Initial state for Basic process (e.g. To Do, Doing, Done)',
},
workItemType: {
type: 'string',
description: 'Work item type returned by Azure DevOps (e.g. Issue, Task, Epic)',
},
assignedTo: {
type: 'string',
description: 'Display name of assigned user, or null if unassigned',
},
areaPath: { type: 'string', description: 'Area path of the work item' },
url: { type: 'string', description: 'API URL for the created work item' },
},
},
},
},
},
}
@@ -0,0 +1,101 @@
import type { GetBuildLogParams, GetBuildLogResponse } from '@/tools/azure_devops/types'
import type { ToolConfig } from '@/tools/types'
export const getBuildLogTool: ToolConfig<GetBuildLogParams, GetBuildLogResponse> = {
id: 'azure_devops_get_build_log',
name: 'Azure DevOps Get Build Log',
description:
'Fetch the text content of a specific build log in Azure DevOps. Use List Build Logs first to get the log ID. Optionally retrieve only a line range with startLine/endLine.',
version: '1.0.0',
params: {
organization: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Azure DevOps organization name',
},
project: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Azure DevOps project name',
},
buildId: {
type: 'number',
required: true,
visibility: 'user-or-llm',
description: 'The build ID containing the log',
},
logId: {
type: 'number',
required: true,
visibility: 'user-or-llm',
description: 'The log entry ID to fetch (from List Build Logs)',
},
startLine: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'First line to return (1-based, inclusive)',
},
endLine: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Last line to return (1-based, inclusive)',
},
accessToken: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Azure DevOps Personal Access Token (scopes: Build: Read)',
},
},
request: {
url: (params) => {
const url = new URL(
`https://dev.azure.com/${params.organization.trim()}/${params.project.trim()}/_apis/build/builds/${params.buildId}/logs/${params.logId}`
)
url.searchParams.set('api-version', '7.2-preview.2')
if (params.startLine !== undefined)
url.searchParams.set('startLine', Number(params.startLine).toString())
if (params.endLine !== undefined)
url.searchParams.set('endLine', Number(params.endLine).toString())
return url.toString()
},
method: 'GET',
headers: (params) => ({
Accept: 'text/plain',
Authorization: `Basic ${btoa(`:${params.accessToken}`)}`,
}),
},
transformResponse: async (response) => {
const text = await response.text()
const trimmed = text.trim()
const lineCount = trimmed.length === 0 ? 0 : trimmed.split('\n').length
return {
success: true,
output: {
content: trimmed.length === 0 ? 'Log is empty.' : text,
metadata: {
lineCount,
},
},
}
},
outputs: {
content: { type: 'string', description: 'Raw log text' },
metadata: {
type: 'object',
description: 'Log metadata',
properties: {
lineCount: { type: 'number', description: 'Number of lines in the returned log text' },
},
},
},
}
@@ -0,0 +1,164 @@
import type {
AzureDevOpsBuildTimelineRecord,
GetBuildTimelineParams,
GetBuildTimelineResponse,
} from '@/tools/azure_devops/types'
import type { ToolConfig } from '@/tools/types'
export const getBuildTimelineTool: ToolConfig<GetBuildTimelineParams, GetBuildTimelineResponse> = {
id: 'azure_devops_get_build_timeline',
name: 'Azure DevOps Get Build Timeline',
description:
'Get the execution timeline for an Azure DevOps build — every stage, job, and task with its result and log ID. Use this to identify which steps failed before fetching their logs with Get Build Log.',
version: '1.0.0',
params: {
organization: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Azure DevOps organization name',
},
project: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Azure DevOps project name',
},
buildId: {
type: 'number',
required: true,
visibility: 'user-or-llm',
description: 'ID of the build whose timeline to retrieve',
},
accessToken: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Azure DevOps Personal Access Token (scopes: Build: Read)',
},
},
request: {
url: (params) =>
`https://dev.azure.com/${params.organization.trim()}/${params.project.trim()}/_apis/build/builds/${Number(params.buildId)}/timeline?api-version=7.2-preview.3`,
method: 'GET',
headers: (params) => ({
'Content-Type': 'application/json',
Authorization: `Basic ${btoa(`:${params.accessToken}`)}`,
}),
},
transformResponse: async (response) => {
const data = await response.json()
const records: AzureDevOpsBuildTimelineRecord[] = (data.records ?? []).map(
(r: {
id: string
name: string
type: string
result: string | null
log?: { id?: number } | null
errorCount?: number
warningCount?: number
startTime?: string
finishTime?: string
}) => ({
id: r.id,
name: r.name,
type: r.type,
result: r.result ?? null,
logId: r.log?.id ?? null,
errorCount: r.errorCount ?? 0,
warningCount: r.warningCount ?? 0,
startTime: r.startTime ?? '',
finishTime: r.finishTime ?? '',
})
)
const failedRecords = records.filter((r) => {
const result = r.result?.toLowerCase()
return (
result === 'failed' || result === 'partiallysucceeded' || result === 'succeededwithissues'
)
})
const content =
failedRecords.length === 0
? `Build timeline: ${records.length} record(s), no failures detected.`
: `Build timeline: ${records.length} record(s), ${failedRecords.length} failed:\n\n` +
failedRecords
.map(
(r) =>
`[${r.type}] ${r.name} — result: ${r.result}, logId: ${r.logId ?? 'none'}, errors: ${r.errorCount}`
)
.join('\n')
return {
success: true,
output: {
content,
metadata: {
totalCount: records.length,
failedCount: failedRecords.length,
records,
failedRecords,
},
},
}
},
outputs: {
content: {
type: 'string',
description: 'Summary of the build timeline, highlighting failed steps',
},
metadata: {
type: 'object',
description: 'Build timeline metadata',
properties: {
totalCount: { type: 'number', description: 'Total number of timeline records' },
failedCount: { type: 'number', description: 'Number of failed records' },
records: {
type: 'array',
description: 'All timeline records (stages, jobs, tasks)',
items: {
type: 'object',
properties: {
id: { type: 'string', description: 'Record GUID' },
name: { type: 'string', description: 'Step name (e.g. "Run tests")' },
type: { type: 'string', description: 'Stage | Phase | Job | Task' },
result: {
type: 'string',
description: 'succeeded | failed | skipped | canceled | null',
},
logId: { type: 'number', description: 'Log ID to pass to Get Build Log, or null' },
errorCount: { type: 'number', description: 'Number of errors' },
warningCount: { type: 'number', description: 'Number of warnings' },
startTime: { type: 'string', description: 'ISO 8601 start timestamp' },
finishTime: { type: 'string', description: 'ISO 8601 finish timestamp' },
},
},
},
failedRecords: {
type: 'array',
description:
'Subset of records where result is failed, partiallySucceeded, or succeededWithIssues — use logId to fetch logs',
items: {
type: 'object',
properties: {
id: { type: 'string', description: 'Record GUID' },
name: { type: 'string', description: 'Step name' },
type: { type: 'string', description: 'Stage | Phase | Job | Task' },
result: { type: 'string', description: 'failed' },
logId: { type: 'number', description: 'Log ID to pass to Get Build Log' },
errorCount: { type: 'number', description: 'Number of errors' },
warningCount: { type: 'number', description: 'Number of warnings' },
startTime: { type: 'string', description: 'ISO 8601 start timestamp' },
finishTime: { type: 'string', description: 'ISO 8601 finish timestamp' },
},
},
},
},
},
},
}
+186
View File
@@ -0,0 +1,186 @@
import type {
AzureDevOpsComment,
GetCommentsParams,
GetCommentsResponse,
} from '@/tools/azure_devops/types'
import type { AzureDevOpsRawComment } from '@/tools/azure_devops/utils'
import { formatComment, mapComment } from '@/tools/azure_devops/utils'
import type { ToolConfig } from '@/tools/types'
export const getCommentsTool: ToolConfig<GetCommentsParams, GetCommentsResponse> = {
id: 'azure_devops_get_comments',
name: 'Azure DevOps Get Comments',
description: 'List comments for an Azure DevOps work item.',
version: '1.0.0',
params: {
organization: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Azure DevOps organization name',
},
project: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Azure DevOps project name',
},
workItemId: {
type: 'number',
required: true,
visibility: 'user-or-llm',
description: 'ID of the work item whose comments should be listed',
},
top: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Maximum number of comments to return',
},
continuationToken: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Continuation token for paginating comments',
},
includeDeleted: {
type: 'boolean',
required: false,
visibility: 'user-or-llm',
description: 'Whether deleted comments should be returned',
},
expand: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description:
'Additional comment data to include: none, reactions, renderedText, renderedTextOnly, all',
},
order: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Sort order for comments: asc or desc',
},
accessToken: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Azure DevOps Personal Access Token (scopes: Work Items: Read)',
},
},
request: {
url: (params) => {
const url = new URL(
`https://dev.azure.com/${params.organization.trim()}/${params.project.trim()}/_apis/wit/workitems/${Number(params.workItemId)}/comments`
)
url.searchParams.set('api-version', '7.2-preview.4')
if (params.top) url.searchParams.set('$top', Number(params.top).toString())
if (params.continuationToken)
url.searchParams.set('continuationToken', params.continuationToken)
if (params.includeDeleted !== undefined)
url.searchParams.set('includeDeleted', String(params.includeDeleted))
if (params.expand) url.searchParams.set('$expand', params.expand)
if (params.order) url.searchParams.set('order', params.order)
return url.toString()
},
method: 'GET',
headers: (params) => ({
'Content-Type': 'application/json',
Authorization: `Basic ${btoa(`:${params.accessToken}`)}`,
}),
},
transformResponse: async (response) => {
const data = await response.json()
const comments: AzureDevOpsComment[] = (data.comments ?? []).map((raw: AzureDevOpsRawComment) =>
mapComment(raw)
)
const content =
comments.length === 0
? 'No comments found for this work item.'
: `Found ${data.count ?? comments.length} comment(s):\n\n${comments
.map(formatComment)
.join('\n\n')}`
return {
success: true,
output: {
content,
metadata: {
count: data.count ?? comments.length,
totalCount: data.totalCount ?? comments.length,
comments,
continuationToken: data.continuationToken,
nextPage: data.nextPage,
url: data.url,
},
},
}
},
outputs: {
content: {
type: 'string',
description: 'Human-readable summary of work item comments',
},
metadata: {
type: 'object',
description: 'Comments metadata',
properties: {
count: { type: 'number', description: 'Number of comments returned in this page' },
totalCount: { type: 'number', description: 'Total number of comments on the work item' },
continuationToken: {
type: 'string',
description: 'Continuation token for the next page',
optional: true,
},
nextPage: {
type: 'string',
description: 'API URL for the next page',
optional: true,
},
url: {
type: 'string',
description: 'API URL for this comments list',
optional: true,
},
comments: {
type: 'array',
description: 'Array of work item comments',
items: {
type: 'object',
properties: {
workItemId: { type: 'number', description: 'Work item ID' },
commentId: { type: 'number', description: 'Comment ID' },
version: { type: 'number', description: 'Comment version' },
text: { type: 'string', description: 'Comment text' },
renderedText: {
type: 'string',
description: 'Rendered HTML comment text when available',
optional: true,
},
createdBy: {
type: 'string',
description: 'Display name of the comment author',
nullable: true,
},
createdDate: { type: 'string', description: 'ISO 8601 creation timestamp' },
modifiedBy: {
type: 'string',
description: 'Display name of the last modifier',
nullable: true,
},
modifiedDate: { type: 'string', description: 'ISO 8601 modified timestamp' },
isDeleted: { type: 'boolean', description: 'Whether the comment is deleted' },
url: { type: 'string', description: 'API URL for the comment' },
},
},
},
},
},
},
}
+167
View File
@@ -0,0 +1,167 @@
import type { GetPipelineParams, GetPipelineResponse } from '@/tools/azure_devops/types'
import type { ToolConfig } from '@/tools/types'
export const getPipelineTool: ToolConfig<GetPipelineParams, GetPipelineResponse> = {
id: 'azure_devops_get_pipeline',
name: 'Azure DevOps Get Pipeline',
description:
'Get details for a specific pipeline in an Azure DevOps project, including configuration and repository info.',
version: '1.0.0',
params: {
organization: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Azure DevOps organization name',
},
project: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Azure DevOps project name',
},
pipelineId: {
type: 'number',
required: true,
visibility: 'user-or-llm',
description: 'ID of the pipeline to retrieve',
},
pipelineVersion: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Specific revision of the pipeline to retrieve (defaults to latest)',
},
accessToken: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Azure DevOps Personal Access Token (scopes: Build: Read, Pipeline: Read)',
},
},
request: {
url: (params) => {
const url = new URL(
`https://dev.azure.com/${params.organization.trim()}/${params.project.trim()}/_apis/pipelines/${params.pipelineId}`
)
url.searchParams.set('api-version', '7.2-preview.1')
if (params.pipelineVersion)
url.searchParams.set('pipelineVersion', Number(params.pipelineVersion).toString())
return url.toString()
},
method: 'GET',
headers: (params) => ({
'Content-Type': 'application/json',
Authorization: `Basic ${btoa(`:${params.accessToken}`)}`,
}),
},
transformResponse: async (response) => {
const data = await response.json()
const pipeline: AzureDevOpsPipelineDetailItem = {
id: data.id,
name: data.name,
folder: data.folder ?? '\\',
revision: data.revision,
url: data.url,
configuration: {
type: data.configuration?.type ?? 'unknown',
path: data.configuration?.path,
repository: data.configuration?.repository
? { id: data.configuration.repository.id, type: data.configuration.repository.type }
: undefined,
},
links: {
self: data._links?.self?.href ?? '',
web: data._links?.web?.href ?? '',
},
}
const pathLine = pipeline.configuration.path ? `\n Path: ${pipeline.configuration.path}` : ''
const repoLine = pipeline.configuration.repository
? `\n Repository: ${pipeline.configuration.repository.id} (${pipeline.configuration.repository.type})`
: ''
const content =
`Pipeline: ${pipeline.name} (ID: ${pipeline.id})\n` +
`Folder: ${pipeline.folder}\n` +
`Revision: ${pipeline.revision}\n` +
`Config type: ${pipeline.configuration.type}` +
pathLine +
repoLine +
`\nWeb URL: ${pipeline.links.web}`
return {
success: true,
output: {
content,
metadata: { pipeline },
},
}
},
outputs: {
content: { type: 'string', description: 'Human-readable summary of the pipeline' },
metadata: {
type: 'object',
description: 'Pipeline detail metadata',
properties: {
pipeline: {
type: 'object',
description: 'Full pipeline detail object',
properties: {
id: { type: 'number', description: 'Pipeline ID' },
name: { type: 'string', description: 'Pipeline name' },
folder: { type: 'string', description: 'Folder path' },
revision: { type: 'number', description: 'Pipeline revision number' },
url: { type: 'string', description: 'Pipeline API URL' },
configuration: {
type: 'object',
description: 'Pipeline configuration',
properties: {
type: { type: 'string', description: 'Configuration type (e.g. "yaml")' },
path: { type: 'string', description: 'YAML file path in the repository' },
repository: {
type: 'object',
description: 'Source repository info',
properties: {
id: { type: 'string', description: 'Repository ID' },
type: {
type: 'string',
description: 'Repository type (e.g. "azureReposGit")',
},
},
},
},
},
links: {
type: 'object',
description: 'Hypermedia links',
properties: {
self: { type: 'string', description: 'API self-link' },
web: { type: 'string', description: 'Browser URL for the pipeline' },
},
},
},
},
},
},
},
}
interface AzureDevOpsPipelineDetailItem {
id: number
name: string
folder: string
revision: number
url: string
configuration: {
type: string
path?: string
repository?: { id: string; type: string }
}
links: { self: string; web: string }
}
@@ -0,0 +1,157 @@
import type { GetPipelineRunParams, GetPipelineRunResponse } from '@/tools/azure_devops/types'
import type { ToolConfig } from '@/tools/types'
export const getPipelineRunTool: ToolConfig<GetPipelineRunParams, GetPipelineRunResponse> = {
id: 'azure_devops_get_pipeline_run',
name: 'Azure DevOps Get Pipeline Run',
description:
'Get details for a specific pipeline run in an Azure DevOps project. Returns run state, result, timestamps, and the pipeline reference.',
version: '1.0.0',
params: {
organization: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Azure DevOps organization name',
},
project: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Azure DevOps project name',
},
pipelineId: {
type: 'number',
required: true,
visibility: 'user-or-llm',
description: 'ID of the pipeline',
},
runId: {
type: 'number',
required: true,
visibility: 'user-or-llm',
description: 'ID of the run to retrieve',
},
accessToken: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Azure DevOps Personal Access Token (scopes: Build: Read, Pipeline: Read)',
},
},
request: {
url: (params) => {
const url = new URL(
`https://dev.azure.com/${params.organization.trim()}/${params.project.trim()}/_apis/pipelines/${params.pipelineId}/runs/${params.runId}`
)
url.searchParams.set('api-version', '7.2-preview.1')
return url.toString()
},
method: 'GET',
headers: (params) => ({
'Content-Type': 'application/json',
Authorization: `Basic ${btoa(`:${params.accessToken}`)}`,
}),
},
transformResponse: async (response) => {
const data = await response.json()
const run: AzureDevOpsPipelineRunDetailItem = {
id: data.id,
name: data.name,
state: data.state,
result: data.result,
createdDate: data.createdDate,
finishedDate: data.finishedDate,
url: data.url,
webUrl: data._links?.web?.href ?? '',
pipeline: {
id: data.pipeline?.id,
name: data.pipeline?.name,
folder: data.pipeline?.folder ?? '\\',
revision: data.pipeline?.revision,
url: data.pipeline?.url ?? '',
},
}
const resultLine = run.result ? ` | Result: ${run.result}` : ''
const finishedLine = run.finishedDate ? ` | Finished: ${run.finishedDate}` : ''
const content =
`Run: ${run.name} (ID: ${run.id})\n` +
`Pipeline: ${run.pipeline.name} (ID: ${run.pipeline.id})\n` +
`State: ${run.state}${resultLine}\n` +
`Created: ${run.createdDate}${finishedLine}\n` +
`Web URL: ${run.webUrl}`
return {
success: true,
output: {
content,
metadata: { run },
},
}
},
outputs: {
content: { type: 'string', description: 'Human-readable summary of the pipeline run' },
metadata: {
type: 'object',
description: 'Pipeline run metadata',
properties: {
run: {
type: 'object',
description: 'Full pipeline run detail object',
properties: {
id: { type: 'number', description: 'Run ID' },
name: { type: 'string', description: 'Run name (e.g. "20210601.1")' },
state: { type: 'string', description: 'Run state (e.g. "completed", "inProgress")' },
result: {
type: 'string',
description: 'Run result (e.g. "succeeded", "failed") — absent if still running',
},
createdDate: { type: 'string', description: 'ISO 8601 creation timestamp' },
finishedDate: {
type: 'string',
description: 'ISO 8601 finish timestamp — absent if still running',
},
url: { type: 'string', description: 'Run API URL' },
webUrl: { type: 'string', description: 'Browser URL for the run' },
pipeline: {
type: 'object',
description: 'Pipeline reference',
properties: {
id: { type: 'number', description: 'Pipeline ID' },
name: { type: 'string', description: 'Pipeline name' },
folder: { type: 'string', description: 'Pipeline folder' },
revision: { type: 'number', description: 'Pipeline revision number' },
url: { type: 'string', description: 'Pipeline API URL' },
},
},
},
},
},
},
},
}
interface AzureDevOpsPipelineRunDetailItem {
id: number
name: string
state: string
result?: string
createdDate: string
finishedDate?: string
url: string
webUrl: string
pipeline: {
id: number
name: string
folder: string
revision: number
url: string
}
}
@@ -0,0 +1,103 @@
import type { GetWorkItemParams, GetWorkItemResponse } from '@/tools/azure_devops/types'
import type { AzureDevOpsRawWorkItem } from '@/tools/azure_devops/utils'
import { formatWorkItem, mapWorkItem } from '@/tools/azure_devops/utils'
import type { ToolConfig } from '@/tools/types'
export const getWorkItemTool: ToolConfig<GetWorkItemParams, GetWorkItemResponse> = {
id: 'azure_devops_get_work_item',
name: 'Azure DevOps Get Work Item',
description:
'Fetch full details of a single work item by ID from Azure DevOps, including title, state, type, assignee, and area path.',
version: '1.0.0',
params: {
organization: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Azure DevOps organization name',
},
project: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Azure DevOps project name',
},
workItemId: {
type: 'number',
required: true,
visibility: 'user-or-llm',
description: 'The work item ID to fetch',
},
accessToken: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Azure DevOps Personal Access Token (scopes: Work Items: Read)',
},
},
request: {
url: (params) => {
const url = new URL(
`https://dev.azure.com/${params.organization.trim()}/${params.project.trim()}/_apis/wit/workitems/${Number(params.workItemId)}`
)
url.searchParams.set('$expand', 'all')
url.searchParams.set('api-version', '7.2-preview.3')
return url.toString()
},
method: 'GET',
headers: (params) => ({
'Content-Type': 'application/json',
Authorization: `Basic ${btoa(`:${params.accessToken}`)}`,
}),
},
transformResponse: async (response) => {
const raw: AzureDevOpsRawWorkItem = await response.json()
const workItem = mapWorkItem(raw)
return {
success: true,
output: {
content: formatWorkItem(workItem),
metadata: { workItem },
},
}
},
outputs: {
content: {
type: 'string',
description: 'Human-readable summary of the work item',
},
metadata: {
type: 'object',
description: 'Work item metadata',
properties: {
workItem: {
type: 'object',
description: 'Full work item details',
properties: {
id: { type: 'number', description: 'Work item ID' },
title: { type: 'string', description: 'Work item title' },
state: {
type: 'string',
description: 'Current state for Basic process (e.g. To Do, Doing, Done)',
},
workItemType: {
type: 'string',
description: 'Work item type returned by Azure DevOps (e.g. Issue, Task, Epic)',
},
assignedTo: {
type: 'string',
description: 'Display name of assigned user, or null if unassigned',
},
areaPath: { type: 'string', description: 'Area path of the work item' },
url: { type: 'string', description: 'API URL for the work item' },
},
},
},
},
},
}
@@ -0,0 +1,173 @@
import type {
AzureDevOpsWorkItem,
GetWorkItemsBatchParams,
GetWorkItemsBatchResponse,
} from '@/tools/azure_devops/types'
import type { AzureDevOpsRawWorkItem } from '@/tools/azure_devops/utils'
import { formatWorkItem, mapWorkItem } from '@/tools/azure_devops/utils'
import type { ToolConfig } from '@/tools/types'
export const getWorkItemsBatchTool: ToolConfig<GetWorkItemsBatchParams, GetWorkItemsBatchResponse> =
{
id: 'azure_devops_get_work_items_batch',
name: 'Azure DevOps Get Work Items Batch',
description:
'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.',
version: '1.0.0',
params: {
organization: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Azure DevOps organization name',
},
project: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Azure DevOps project name',
},
ids: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description:
'Comma-separated work item IDs to fetch (e.g. "123,456,789"). Lists longer than 200 IDs are chunked automatically.',
},
accessToken: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Azure DevOps Personal Access Token (scopes: Work Items: Read)',
},
},
request: {
url: (params) => {
const allIds = params.ids
.split(',')
.map((id) => id.trim())
.filter(Boolean)
if (allIds.length === 0) {
throw new Error('Get Work Items Batch requires at least one work item ID.')
}
const firstChunk = allIds.slice(0, 200)
const url = new URL(
`https://dev.azure.com/${params.organization.trim()}/${params.project.trim()}/_apis/wit/workitems`
)
url.searchParams.set('ids', firstChunk.join(','))
url.searchParams.set('$expand', 'all')
url.searchParams.set('api-version', '7.2-preview.3')
return url.toString()
},
method: 'GET',
headers: (params) => ({
'Content-Type': 'application/json',
Authorization: `Basic ${btoa(`:${params.accessToken}`)}`,
}),
},
transformResponse: async (response, params) => {
const firstData = await response.json()
const workItems: AzureDevOpsWorkItem[] = (firstData.value ?? []).map(
(raw: AzureDevOpsRawWorkItem) => mapWorkItem(raw)
)
const allIds = params!.ids
.split(',')
.map((id) => id.trim())
.filter(Boolean)
if (allIds.length > 200) {
const BATCH_SIZE = 200
const organization = params!.organization.trim()
const project = params!.project.trim()
const authHeader = `Basic ${btoa(`:${params!.accessToken}`)}`
for (let i = BATCH_SIZE; i < allIds.length; i += BATCH_SIZE) {
const chunk = allIds.slice(i, i + BATCH_SIZE)
const detailsUrl = new URL(
`https://dev.azure.com/${organization}/${project}/_apis/wit/workitems`
)
detailsUrl.searchParams.set('ids', chunk.join(','))
detailsUrl.searchParams.set('$expand', 'all')
detailsUrl.searchParams.set('api-version', '7.2-preview.3')
const chunkResponse = await fetch(detailsUrl.toString(), {
method: 'GET',
headers: { 'Content-Type': 'application/json', Authorization: authHeader },
})
if (!chunkResponse.ok) {
const errorBody = await chunkResponse.text().catch(() => '')
throw new Error(
`Failed to fetch work item batch chunk (${chunkResponse.status}): ${errorBody || chunkResponse.statusText}`
)
}
const chunkData = await chunkResponse.json()
for (const raw of chunkData.value ?? []) {
workItems.push(mapWorkItem(raw as AzureDevOpsRawWorkItem))
}
}
}
const content =
workItems.length === 0
? 'No work items found for the provided IDs.'
: `Found ${workItems.length} work item(s) (of ${allIds.length} requested):\n\n${workItems.map(formatWorkItem).join('\n\n')}`
return {
success: true,
output: {
content,
metadata: { count: workItems.length, totalRequested: allIds.length, workItems },
},
}
},
outputs: {
content: {
type: 'string',
description: 'Human-readable summary of the fetched work items',
},
metadata: {
type: 'object',
description: 'Work items metadata',
properties: {
count: { type: 'number', description: 'Number of work items returned' },
totalRequested: {
type: 'number',
description: 'Total number of IDs requested (across all chunks)',
optional: true,
},
workItems: {
type: 'array',
description: 'Array of work item details',
items: {
type: 'object',
properties: {
id: { type: 'number', description: 'Work item ID' },
title: { type: 'string', description: 'Work item title' },
state: {
type: 'string',
description: 'Current state for Basic process (e.g. To Do, Doing, Done)',
},
workItemType: {
type: 'string',
description: 'Work item type returned by Azure DevOps (e.g. Issue, Task, Epic)',
},
assignedTo: {
type: 'string',
description: 'Display name of assigned user, or null if unassigned',
},
areaPath: { type: 'string', description: 'Area path of the work item' },
url: { type: 'string', description: 'API URL for the work item' },
},
},
},
},
},
},
}
@@ -0,0 +1,126 @@
import type {
AzureDevOpsWorkItemRef,
GetWorkItemsBetweenBuildsParams,
GetWorkItemsBetweenBuildsResponse,
} from '@/tools/azure_devops/types'
import type { ToolConfig } from '@/tools/types'
export const getWorkItemsBetweenBuildsTool: ToolConfig<
GetWorkItemsBetweenBuildsParams,
GetWorkItemsBetweenBuildsResponse
> = {
id: 'azure_devops_get_work_items_between_builds',
name: 'Azure DevOps Get Work Items Between Builds',
description:
'Get work item references associated with commits between two builds in Azure DevOps. Returns work item IDs and URLs — use Get Work Items Batch for full field details.',
version: '1.0.0',
params: {
organization: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Azure DevOps organization name',
},
project: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Azure DevOps project name',
},
fromBuildId: {
type: 'number',
required: true,
visibility: 'user-or-llm',
description: 'The older build ID (start of range)',
},
toBuildId: {
type: 'number',
required: true,
visibility: 'user-or-llm',
description: 'The newer build ID (end of range)',
},
accessToken: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Azure DevOps Personal Access Token (scopes: Build: Read)',
},
},
request: {
url: (params) => {
const url = new URL(
`https://dev.azure.com/${params.organization.trim()}/${params.project.trim()}/_apis/build/workitems`
)
url.searchParams.set('fromBuildId', Number(params.fromBuildId).toString())
url.searchParams.set('toBuildId', Number(params.toBuildId).toString())
url.searchParams.set('api-version', '7.2-preview.2')
return url.toString()
},
method: 'GET',
headers: (params) => ({
'Content-Type': 'application/json',
Authorization: `Basic ${btoa(`:${params.accessToken}`)}`,
}),
},
transformResponse: async (response) => {
const data = await response.json()
const workItems: AzureDevOpsWorkItemRef[] = (data.value ?? []).map(
(w: AzureDevOpsRawWorkItemRef) => ({
id: String(w.id),
url: w.url,
})
)
const content =
workItems.length === 0
? 'No work items found between these builds.'
: `Found ${data.count ?? workItems.length} work item(s) between builds:\n\n${workItems
.map((w) => `- Work Item ID: ${w.id}\n URL: ${w.url}`)
.join('\n')}`
return {
success: true,
output: {
content,
metadata: {
count: data.count ?? workItems.length,
workItems,
},
},
}
},
outputs: {
content: {
type: 'string',
description: 'Human-readable summary of work items between builds',
},
metadata: {
type: 'object',
description: 'Work items metadata',
properties: {
count: { type: 'number', description: 'Total number of work item references returned' },
workItems: {
type: 'array',
description: 'Array of work item references',
items: {
type: 'object',
properties: {
id: { type: 'string', description: 'Work item ID' },
url: { type: 'string', description: 'API URL for the work item' },
},
},
},
},
},
},
}
interface AzureDevOpsRawWorkItemRef {
id: string | number
url: string
}
+37
View File
@@ -0,0 +1,37 @@
import { addCommentTool } from '@/tools/azure_devops/add_comment'
import { createWorkItemTool } from '@/tools/azure_devops/create_work_item'
import { getBuildLogTool } from '@/tools/azure_devops/get_build_log'
import { getBuildTimelineTool } from '@/tools/azure_devops/get_build_timeline'
import { getCommentsTool } from '@/tools/azure_devops/get_comments'
import { getPipelineTool } from '@/tools/azure_devops/get_pipeline'
import { getPipelineRunTool } from '@/tools/azure_devops/get_pipeline_run'
import { getWorkItemTool } from '@/tools/azure_devops/get_work_item'
import { getWorkItemsBatchTool } from '@/tools/azure_devops/get_work_items_batch'
import { getWorkItemsBetweenBuildsTool } from '@/tools/azure_devops/get_work_items_between_builds'
import { listBuildLogsTool } from '@/tools/azure_devops/list_build_logs'
import { listBuildsTool } from '@/tools/azure_devops/list_builds'
import { listPipelineRunsTool } from '@/tools/azure_devops/list_pipeline_runs'
import { listPipelinesTool } from '@/tools/azure_devops/list_pipelines'
import { queryWorkItemsTool } from '@/tools/azure_devops/query_work_items'
import { updateWorkItemTool } from '@/tools/azure_devops/update_work_item'
export * from '@/tools/azure_devops/types'
export {
listPipelinesTool,
getPipelineTool,
listPipelineRunsTool,
getPipelineRunTool,
listBuildsTool,
listBuildLogsTool,
getBuildLogTool,
getBuildTimelineTool,
getWorkItemsBetweenBuildsTool,
queryWorkItemsTool,
getWorkItemTool,
getWorkItemsBatchTool,
createWorkItemTool,
updateWorkItemTool,
addCommentTool,
getCommentsTool,
}
@@ -0,0 +1,134 @@
import type { ListBuildLogsParams, ListBuildLogsResponse } from '@/tools/azure_devops/types'
import type { ToolConfig } from '@/tools/types'
export const listBuildLogsTool: ToolConfig<ListBuildLogsParams, ListBuildLogsResponse> = {
id: 'azure_devops_list_build_logs',
name: 'Azure DevOps List Build Logs',
description:
'List all log entries for a specific build in Azure DevOps. Returns log IDs, types, and line counts — use the log ID with the Get Build Log tool to fetch actual log text.',
version: '1.0.0',
params: {
organization: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Azure DevOps organization name',
},
project: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Azure DevOps project name',
},
buildId: {
type: 'number',
required: true,
visibility: 'user-or-llm',
description: 'The build ID whose logs to list',
},
accessToken: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Azure DevOps Personal Access Token (scopes: Build: Read)',
},
},
request: {
url: (params) =>
`https://dev.azure.com/${params.organization.trim()}/${params.project.trim()}/_apis/build/builds/${params.buildId}/logs?api-version=7.2-preview.2`,
method: 'GET',
headers: (params) => ({
'Content-Type': 'application/json',
Authorization: `Basic ${btoa(`:${params.accessToken}`)}`,
}),
},
transformResponse: async (response) => {
const data = await response.json()
const logs: AzureDevOpsBuildLogItem[] = (data.value ?? []).map((l: AzureDevOpsRawBuildLog) => ({
id: l.id,
type: l.type,
url: l.url,
lineCount: l.lineCount,
createdOn: l.createdOn,
lastChangedOn: l.lastChangedOn,
}))
const content =
logs.length === 0
? 'No logs found.'
: `Found ${data.count ?? logs.length} log(s):\n\n${logs
.map(
(l) =>
`- Log ID: ${l.id}\n` +
` Type: ${l.type}\n` +
` Lines: ${l.lineCount}` +
(l.lastChangedOn ? `\n Last changed: ${l.lastChangedOn}` : '')
)
.join('\n')}`
return {
success: true,
output: {
content,
metadata: {
count: data.count ?? logs.length,
logs,
},
},
}
},
outputs: {
content: { type: 'string', description: 'Human-readable summary of build logs' },
metadata: {
type: 'object',
description: 'Build logs metadata',
properties: {
count: { type: 'number', description: 'Total number of log entries returned' },
logs: {
type: 'array',
description: 'Array of log entry objects',
items: {
type: 'object',
properties: {
id: {
type: 'number',
description: 'Log entry ID — use with Get Build Log to fetch content',
},
type: {
type: 'string',
description: 'Log type (e.g. "Container", "Task", "Section")',
},
url: { type: 'string', description: 'API URL for the log entry' },
lineCount: { type: 'number', description: 'Number of lines in the log' },
createdOn: { type: 'string', description: 'ISO 8601 creation timestamp' },
lastChangedOn: { type: 'string', description: 'ISO 8601 last-changed timestamp' },
},
},
},
},
},
},
}
interface AzureDevOpsBuildLogItem {
id: number
type: string
url: string
lineCount: number
createdOn?: string
lastChangedOn?: string
}
interface AzureDevOpsRawBuildLog {
id: number
type: string
url: string
lineCount: number
createdOn?: string
lastChangedOn?: string
}
+203
View File
@@ -0,0 +1,203 @@
import type { ListBuildsParams, ListBuildsResponse } from '@/tools/azure_devops/types'
import type { ToolConfig } from '@/tools/types'
export const listBuildsTool: ToolConfig<ListBuildsParams, ListBuildsResponse> = {
id: 'azure_devops_list_builds',
name: 'Azure DevOps List Builds',
description:
'List builds in an Azure DevOps project. Optionally filter by pipeline definition, status, result, or branch.',
version: '1.0.0',
params: {
organization: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Azure DevOps organization name',
},
project: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Azure DevOps project name',
},
definitionIds: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Comma-separated pipeline definition IDs to filter by (e.g. "1,2,3")',
},
top: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Maximum number of builds to return',
},
statusFilter: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description:
'Filter by build status: inProgress, completed, cancelling, postponed, notStarted, none',
},
resultFilter: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Filter by build result: succeeded, partiallySucceeded, failed, canceled',
},
branchName: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Filter by source branch name (e.g. "refs/heads/main")',
},
accessToken: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Azure DevOps Personal Access Token (scopes: Build: Read)',
},
},
request: {
url: (params) => {
const url = new URL(
`https://dev.azure.com/${params.organization.trim()}/${params.project.trim()}/_apis/build/builds`
)
url.searchParams.set('api-version', '7.2-preview.8')
if (params.definitionIds) url.searchParams.set('definitions', params.definitionIds)
if (params.top) url.searchParams.set('$top', Number(params.top).toString())
if (params.statusFilter) url.searchParams.set('statusFilter', params.statusFilter)
if (params.resultFilter) url.searchParams.set('resultFilter', params.resultFilter)
if (params.branchName) url.searchParams.set('branchName', params.branchName)
return url.toString()
},
method: 'GET',
headers: (params) => ({
'Content-Type': 'application/json',
Authorization: `Basic ${btoa(`:${params.accessToken}`)}`,
}),
},
transformResponse: async (response) => {
const data = await response.json()
const builds: AzureDevOpsBuildItem[] = (data.value ?? []).map((b: AzureDevOpsRawBuild) => ({
id: b.id,
buildNumber: b.buildNumber,
status: b.status,
result: b.result,
queueTime: b.queueTime,
startTime: b.startTime,
finishTime: b.finishTime,
sourceBranch: b.sourceBranch,
sourceVersion: b.sourceVersion,
definition: { id: b.definition?.id ?? 0, name: b.definition?.name ?? '' },
webUrl: b._links?.web?.href ?? '',
}))
const content =
builds.length === 0
? 'No builds found.'
: `Found ${data.count ?? builds.length} build(s):\n\n${builds
.map(
(b) =>
`- Build ${b.buildNumber} (ID: ${b.id})\n` +
` Pipeline: ${b.definition.name}\n` +
` Status: ${b.status}${b.result ? ` | Result: ${b.result}` : ''}\n` +
` Branch: ${b.sourceBranch}\n` +
` Queued: ${b.queueTime}${b.finishTime ? ` | Finished: ${b.finishTime}` : ''}`
)
.join('\n')}`
return {
success: true,
output: {
content,
metadata: {
count: data.count ?? builds.length,
builds,
},
},
}
},
outputs: {
content: { type: 'string', description: 'Human-readable summary of builds' },
metadata: {
type: 'object',
description: 'Builds metadata',
properties: {
count: { type: 'number', description: 'Total number of builds returned' },
builds: {
type: 'array',
description: 'Array of build objects',
items: {
type: 'object',
properties: {
id: { type: 'number', description: 'Build ID' },
buildNumber: { type: 'string', description: 'Build number (e.g. "20210601.1")' },
status: {
type: 'string',
description: 'Build status (e.g. "completed", "inProgress")',
},
result: {
type: 'string',
description: 'Build result (e.g. "succeeded", "failed") — absent if still running',
},
queueTime: { type: 'string', description: 'ISO 8601 queue timestamp' },
startTime: { type: 'string', description: 'ISO 8601 start timestamp' },
finishTime: {
type: 'string',
description: 'ISO 8601 finish timestamp — absent if still running',
},
sourceBranch: {
type: 'string',
description: 'Source branch (e.g. "refs/heads/main")',
},
sourceVersion: { type: 'string', description: 'Source commit SHA' },
definition: {
type: 'object',
description: 'Pipeline definition reference',
properties: {
id: { type: 'number', description: 'Definition ID' },
name: { type: 'string', description: 'Definition name' },
},
},
webUrl: { type: 'string', description: 'Browser URL for the build' },
},
},
},
},
},
},
}
interface AzureDevOpsBuildItem {
id: number
buildNumber: string
status: string
result?: string
queueTime: string
startTime?: string
finishTime?: string
sourceBranch: string
sourceVersion: string
definition: { id: number; name: string }
webUrl: string
}
interface AzureDevOpsRawBuild {
id: number
buildNumber: string
status: string
result?: string
queueTime: string
startTime?: string
finishTime?: string
sourceBranch: string
sourceVersion: string
definition?: { id: number; name?: string }
_links?: { web?: { href: string } }
}
@@ -0,0 +1,149 @@
import type { ListPipelineRunsParams, ListPipelineRunsResponse } from '@/tools/azure_devops/types'
import type { ToolConfig } from '@/tools/types'
export const listPipelineRunsTool: ToolConfig<ListPipelineRunsParams, ListPipelineRunsResponse> = {
id: 'azure_devops_list_pipeline_runs',
name: 'Azure DevOps List Pipeline Runs',
description:
'List runs for a specific pipeline in an Azure DevOps project. Returns run ID, name, state, result, and timestamps.',
version: '1.0.0',
params: {
organization: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Azure DevOps organization name',
},
project: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Azure DevOps project name',
},
pipelineId: {
type: 'number',
required: true,
visibility: 'user-or-llm',
description: 'ID of the pipeline whose runs to list',
},
accessToken: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Azure DevOps Personal Access Token (scopes: Build: Read, Pipeline: Read)',
},
},
request: {
url: (params) => {
const url = new URL(
`https://dev.azure.com/${params.organization.trim()}/${params.project.trim()}/_apis/pipelines/${params.pipelineId}/runs`
)
url.searchParams.set('api-version', '7.2-preview.1')
return url.toString()
},
method: 'GET',
headers: (params) => ({
'Content-Type': 'application/json',
Authorization: `Basic ${btoa(`:${params.accessToken}`)}`,
}),
},
transformResponse: async (response) => {
const data = await response.json()
const runs: AzureDevOpsPipelineRunItem[] = (data.value ?? []).map((r: AzureDevOpsRawRun) => ({
id: r.id,
name: r.name,
state: r.state,
result: r.result,
createdDate: r.createdDate,
finishedDate: r.finishedDate,
url: r.url,
webUrl: r._links?.web?.href ?? '',
}))
const content =
runs.length === 0
? 'No pipeline runs found.'
: `Found ${data.count ?? runs.length} run(s):\n\n${runs
.map(
(r) =>
`- Run ${r.name} (ID: ${r.id})\n` +
` State: ${r.state}${r.result ? ` | Result: ${r.result}` : ''}\n` +
` Created: ${r.createdDate}${r.finishedDate ? ` | Finished: ${r.finishedDate}` : ''}`
)
.join('\n')}`
return {
success: true,
output: {
content,
metadata: {
count: data.count ?? runs.length,
runs,
},
},
}
},
outputs: {
content: { type: 'string', description: 'Human-readable summary of pipeline runs' },
metadata: {
type: 'object',
description: 'Pipeline runs metadata',
properties: {
count: { type: 'number', description: 'Total number of runs returned' },
runs: {
type: 'array',
description: 'Array of pipeline run objects',
items: {
type: 'object',
properties: {
id: { type: 'number', description: 'Run ID' },
name: { type: 'string', description: 'Run name (e.g. "20210601.1")' },
state: {
type: 'string',
description: 'Run state (e.g. "completed", "inProgress")',
},
result: {
type: 'string',
description: 'Run result (e.g. "succeeded", "failed") — absent if still running',
},
createdDate: { type: 'string', description: 'ISO 8601 creation timestamp' },
finishedDate: {
type: 'string',
description: 'ISO 8601 finish timestamp — absent if still running',
},
url: { type: 'string', description: 'Run API URL' },
webUrl: { type: 'string', description: 'Browser URL for the run' },
},
},
},
},
},
},
}
interface AzureDevOpsPipelineRunItem {
id: number
name: string
state: string
result?: string
createdDate: string
finishedDate?: string
url: string
webUrl: string
}
interface AzureDevOpsRawRun {
id: number
name: string
state: string
result?: string
createdDate: string
finishedDate?: string
url: string
_links?: { web?: { href: string } }
}
@@ -0,0 +1,133 @@
import type { ListPipelinesParams, ListPipelinesResponse } from '@/tools/azure_devops/types'
import type { ToolConfig } from '@/tools/types'
export const listPipelinesTool: ToolConfig<ListPipelinesParams, ListPipelinesResponse> = {
id: 'azure_devops_list_pipelines',
name: 'Azure DevOps List Pipelines',
description:
'List all pipelines in an Azure DevOps project. Returns pipeline ID, name, folder, revision, and URL.',
version: '1.0.0',
params: {
organization: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Azure DevOps organization name',
},
project: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Azure DevOps project name',
},
orderBy: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Field to sort results by (e.g. "name")',
},
top: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Maximum number of pipelines to return',
},
continuationToken: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Continuation token for paginating results',
},
accessToken: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Azure DevOps Personal Access Token (scopes: Build: Read, Pipeline: Read)',
},
},
request: {
url: (params) => {
const url = new URL(
`https://dev.azure.com/${params.organization.trim()}/${params.project.trim()}/_apis/pipelines`
)
url.searchParams.set('api-version', '7.2-preview.1')
if (params.orderBy) url.searchParams.set('orderBy', params.orderBy)
if (params.top) url.searchParams.set('$top', Number(params.top).toString())
if (params.continuationToken)
url.searchParams.set('continuationToken', params.continuationToken)
return url.toString()
},
method: 'GET',
headers: (params) => ({
'Content-Type': 'application/json',
Authorization: `Basic ${btoa(`:${params.accessToken}`)}`,
}),
},
transformResponse: async (response) => {
const data = await response.json()
const pipelines: AzureDevOpsPipelineItem[] = (data.value ?? []).map(
(p: AzureDevOpsPipelineItem) => ({
id: p.id,
name: p.name,
folder: p.folder ?? '\\',
revision: p.revision,
url: p.url,
})
)
const content =
pipelines.length === 0
? 'No pipelines found.'
: `Found ${data.count ?? pipelines.length} pipeline(s):\n\n${pipelines
.map((p) => `- ${p.name} (ID: ${p.id})\n Folder: ${p.folder}\n URL: ${p.url}`)
.join('\n')}`
return {
success: true,
output: {
content,
metadata: {
count: data.count ?? pipelines.length,
pipelines,
},
},
}
},
outputs: {
content: { type: 'string', description: 'Human-readable summary of pipelines' },
metadata: {
type: 'object',
description: 'Pipelines metadata',
properties: {
count: { type: 'number', description: 'Total number of pipelines returned' },
pipelines: {
type: 'array',
description: 'Array of pipeline objects',
items: {
type: 'object',
properties: {
id: { type: 'number', description: 'Pipeline ID' },
name: { type: 'string', description: 'Pipeline name' },
folder: { type: 'string', description: 'Folder path (e.g. "\\\\")' },
revision: { type: 'number', description: 'Pipeline revision number' },
url: { type: 'string', description: 'Pipeline API URL' },
},
},
},
},
},
},
}
interface AzureDevOpsPipelineItem {
id: number
name: string
folder: string
revision: number
url: string
}
@@ -0,0 +1,164 @@
import type {
AzureDevOpsWorkItem,
QueryWorkItemsParams,
QueryWorkItemsResponse,
} from '@/tools/azure_devops/types'
import type { AzureDevOpsRawWorkItem } from '@/tools/azure_devops/utils'
import { formatWorkItem, mapWorkItem } from '@/tools/azure_devops/utils'
import type { ToolConfig } from '@/tools/types'
export const queryWorkItemsTool: ToolConfig<QueryWorkItemsParams, QueryWorkItemsResponse> = {
id: 'azure_devops_query_work_items',
name: 'Azure DevOps Query Work Items',
description:
'Execute a WIQL query to search for work items in Azure DevOps and return full field details. Use TOP N in your query to limit results (Azure enforces a 200-item maximum per batch fetch).',
version: '1.0.0',
params: {
organization: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Azure DevOps organization name',
},
project: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Azure DevOps project name',
},
wiqlQuery: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description:
'WIQL query string (e.g. "SELECT [System.Id] FROM workitems WHERE [System.State] = \'Doing\' ORDER BY [System.Id] ASC"). Use TOP N to limit results.',
},
accessToken: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Azure DevOps Personal Access Token (scopes: Work Items: Read)',
},
},
request: {
url: (params) =>
`https://dev.azure.com/${params.organization.trim()}/${params.project.trim()}/_apis/wit/wiql?api-version=7.2-preview.2`,
method: 'POST',
headers: (params) => ({
'Content-Type': 'application/json',
Authorization: `Basic ${btoa(`:${params.accessToken}`)}`,
}),
body: (params) => ({ query: params.wiqlQuery }),
},
transformResponse: async (response, params) => {
const wiqlData = await response.json()
const workItemRefs: Array<{ id: number; url: string }> = wiqlData.workItems ?? []
if (workItemRefs.length === 0) {
return {
success: true,
output: {
content: 'No work items matched the query.',
metadata: { count: 0, workItems: [] },
},
}
}
const allIds = workItemRefs.map((wi) => wi.id)
const BATCH_SIZE = 200
const organization = params!.organization.trim()
const project = params!.project.trim()
const authHeader = `Basic ${btoa(`:${params!.accessToken}`)}`
const workItems: AzureDevOpsWorkItem[] = []
for (let i = 0; i < allIds.length; i += BATCH_SIZE) {
const chunk = allIds.slice(i, i + BATCH_SIZE)
const detailsUrl = new URL(
`https://dev.azure.com/${organization}/${project}/_apis/wit/workitems`
)
detailsUrl.searchParams.set('ids', chunk.join(','))
detailsUrl.searchParams.set('$expand', 'all')
detailsUrl.searchParams.set('api-version', '7.2-preview.3')
const detailsResponse = await fetch(detailsUrl.toString(), {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: authHeader,
},
})
if (!detailsResponse.ok) {
const errorBody = await detailsResponse.text().catch(() => '')
throw new Error(
`Failed to hydrate work item details (${detailsResponse.status}): ${errorBody || detailsResponse.statusText}`
)
}
const detailsData = await detailsResponse.json()
for (const raw of detailsData.value ?? []) {
workItems.push(mapWorkItem(raw as AzureDevOpsRawWorkItem))
}
}
const content =
workItems.length === 0
? 'No work item details found.'
: `Found ${workItems.length} work item(s) (of ${allIds.length} matched):\n\n${workItems.map(formatWorkItem).join('\n\n')}`
return {
success: true,
output: {
content,
metadata: { count: workItems.length, totalMatched: allIds.length, workItems },
},
}
},
outputs: {
content: {
type: 'string',
description: 'Human-readable summary of matching work items',
},
metadata: {
type: 'object',
description: 'Work items metadata',
properties: {
count: { type: 'number', description: 'Number of work items returned (after hydration)' },
totalMatched: {
type: 'number',
description: 'Total number of work items matched by the WIQL query before hydration',
optional: true,
},
workItems: {
type: 'array',
description: 'Array of work item details',
items: {
type: 'object',
properties: {
id: { type: 'number', description: 'Work item ID' },
title: { type: 'string', description: 'Work item title' },
state: {
type: 'string',
description: 'Current state for Basic process (e.g. To Do, Doing, Done)',
},
workItemType: {
type: 'string',
description: 'Work item type returned by Azure DevOps (e.g. Issue, Task, Epic)',
},
assignedTo: {
type: 'string',
description: 'Display name of assigned user, or null if unassigned',
},
areaPath: { type: 'string', description: 'Area path of the work item' },
url: { type: 'string', description: 'API URL for the work item' },
},
},
},
},
},
},
}
+458
View File
@@ -0,0 +1,458 @@
import type { ToolResponse } from '@/tools/types'
export interface AzureDevOpsBaseParams {
/** Azure DevOps organization name */
organization: string
/** Azure DevOps project name */
project: string
/** Personal Access Token */
accessToken: string
}
// ── List Pipelines ──────────────────────────────────────────────────────────
export interface ListPipelinesParams extends AzureDevOpsBaseParams {
orderBy?: string
top?: number
continuationToken?: string
}
export interface AzureDevOpsPipeline {
id: number
name: string
folder: string
revision: number
url: string
}
export interface ListPipelinesResponse extends ToolResponse {
output: {
content: string
metadata: {
count: number
pipelines: AzureDevOpsPipeline[]
}
}
}
// ── Get Pipeline ────────────────────────────────────────────────────────────
export interface GetPipelineParams extends AzureDevOpsBaseParams {
pipelineId: number
pipelineVersion?: number
}
export interface AzureDevOpsPipelineConfiguration {
type: string
path?: string
repository?: {
id: string
type: string
}
}
export interface AzureDevOpsPipelineDetail extends AzureDevOpsPipeline {
configuration: AzureDevOpsPipelineConfiguration
links: {
self: string
web: string
}
}
export interface GetPipelineResponse extends ToolResponse {
output: {
content: string
metadata: {
pipeline: AzureDevOpsPipelineDetail
}
}
}
// ── List Pipeline Runs ──────────────────────────────────────────────────────
export interface ListPipelineRunsParams extends AzureDevOpsBaseParams {
pipelineId: number
}
export interface AzureDevOpsPipelineRun {
id: number
name: string
state: string
result?: string
createdDate: string
finishedDate?: string
url: string
webUrl: string
}
export interface ListPipelineRunsResponse extends ToolResponse {
output: {
content: string
metadata: {
count: number
runs: AzureDevOpsPipelineRun[]
}
}
}
// ── Get Pipeline Run ────────────────────────────────────────────────────────
export interface GetPipelineRunParams extends AzureDevOpsBaseParams {
pipelineId: number
runId: number
}
export interface AzureDevOpsPipelineRunDetail extends AzureDevOpsPipelineRun {
pipeline: {
id: number
name: string
folder: string
revision: number
url: string
}
}
export interface GetPipelineRunResponse extends ToolResponse {
output: {
content: string
metadata: {
run: AzureDevOpsPipelineRunDetail
}
}
}
// ── List Builds ─────────────────────────────────────────────────────────────
export interface ListBuildsParams extends AzureDevOpsBaseParams {
definitionIds?: string
top?: number
statusFilter?: string
resultFilter?: string
branchName?: string
}
export interface AzureDevOpsBuild {
id: number
buildNumber: string
status: string
result?: string
queueTime: string
startTime?: string
finishTime?: string
sourceBranch: string
sourceVersion: string
definition: {
id: number
name: string
}
webUrl: string
}
export interface ListBuildsResponse extends ToolResponse {
output: {
content: string
metadata: {
count: number
builds: AzureDevOpsBuild[]
}
}
}
// ── List Build Logs ─────────────────────────────────────────────────────────
export interface ListBuildLogsParams extends AzureDevOpsBaseParams {
buildId: number
}
export interface AzureDevOpsBuildLog {
id: number
type: string
url: string
lineCount: number
createdOn?: string
lastChangedOn?: string
}
export interface ListBuildLogsResponse extends ToolResponse {
output: {
content: string
metadata: {
count: number
logs: AzureDevOpsBuildLog[]
}
}
}
// ── Get Build Log ────────────────────────────────────────────────────────────
export interface GetBuildLogParams extends AzureDevOpsBaseParams {
buildId: number
logId: number
startLine?: number
endLine?: number
}
export interface GetBuildLogResponse extends ToolResponse {
output: {
content: string
metadata: {
lineCount: number
}
}
}
// ── Get Build Timeline ────────────────────────────────────────────────────────
export interface GetBuildTimelineParams extends AzureDevOpsBaseParams {
buildId: number
}
export interface AzureDevOpsBuildTimelineRecord {
id: string
name: string
type: string
result: string | null
logId: number | null
errorCount: number
warningCount: number
startTime: string
finishTime: string
}
export interface GetBuildTimelineResponse extends ToolResponse {
output: {
content: string
metadata: {
totalCount: number
failedCount: number
records: AzureDevOpsBuildTimelineRecord[]
failedRecords: AzureDevOpsBuildTimelineRecord[]
}
}
}
// ── Get Work Items Between Builds ────────────────────────────────────────────
export interface GetWorkItemsBetweenBuildsParams extends AzureDevOpsBaseParams {
fromBuildId: number
toBuildId: number
}
export interface AzureDevOpsWorkItemRef {
id: string
url: string
}
export interface GetWorkItemsBetweenBuildsResponse extends ToolResponse {
output: {
content: string
metadata: {
count: number
workItems: AzureDevOpsWorkItemRef[]
}
}
}
// ── Query Work Items ─────────────────────────────────────────────────────────
export interface QueryWorkItemsParams extends AzureDevOpsBaseParams {
wiqlQuery: string
}
export interface AzureDevOpsWorkItem {
id: number
title: string
state: string
workItemType: string
assignedTo: string | null
areaPath: string
url: string
}
export interface QueryWorkItemsResponse extends ToolResponse {
output: {
content: string
metadata: {
count: number
totalMatched?: number
workItems: AzureDevOpsWorkItem[]
}
}
}
// ── Get Work Item ─────────────────────────────────────────────────────────────
export interface GetWorkItemParams extends AzureDevOpsBaseParams {
workItemId: number
}
export interface GetWorkItemResponse extends ToolResponse {
output: {
content: string
metadata: {
workItem: AzureDevOpsWorkItem
}
}
}
// ── Get Work Items Batch ───────────────────────────────────────────────────────
export interface GetWorkItemsBatchParams extends AzureDevOpsBaseParams {
ids: string
}
export interface GetWorkItemsBatchResponse extends ToolResponse {
output: {
content: string
metadata: {
count: number
totalRequested?: number
workItems: AzureDevOpsWorkItem[]
}
}
}
// ── Create Work Item ───────────────────────────────────────────────────────────
export type AzureDevOpsBasicWorkItemType = 'Issue' | 'Task' | 'Epic'
export interface CreateWorkItemParams extends AzureDevOpsBaseParams {
workItemType: AzureDevOpsBasicWorkItemType
title: string
description?: string
assignedTo?: string
priority?: number
/** Microsoft.VSTS.Scheduling.Effort — Issue only in the Basic process. */
effort?: number
/** Microsoft.VSTS.Scheduling.StartDate — Epic only in the Basic process. ISO 8601. */
startDate?: string
/** Microsoft.VSTS.Scheduling.TargetDate — Epic only in the Basic process. ISO 8601. */
targetDate?: string
/** Microsoft.VSTS.Common.Activity — Task only in the Basic process. */
activity?: string
/** Microsoft.VSTS.Scheduling.RemainingWork — Task only in the Basic process. */
remainingWork?: number
/** Microsoft.VSTS.Scheduling.CompletedWork — Task only in the Basic process. */
completedWork?: number
areaPath?: string
iterationPath?: string
tags?: string
}
export interface CreateWorkItemResponse extends ToolResponse {
output: {
content: string
metadata: {
workItem: AzureDevOpsWorkItem
}
}
}
// ── Update Work Item ───────────────────────────────────────────────────────────
export interface UpdateWorkItemParams extends AzureDevOpsBaseParams {
workItemId: number
title?: string
description?: string
assignedTo?: string
priority?: number
/** Microsoft.VSTS.Scheduling.Effort — Issue only in the Basic process. */
effort?: number
/** Microsoft.VSTS.Scheduling.StartDate — Epic only in the Basic process. ISO 8601. */
startDate?: string
/** Microsoft.VSTS.Scheduling.TargetDate — Epic only in the Basic process. ISO 8601. */
targetDate?: string
/** Microsoft.VSTS.Common.Activity — Task only in the Basic process. */
activity?: string
/** Microsoft.VSTS.Scheduling.RemainingWork — Task only in the Basic process. */
remainingWork?: number
/** Microsoft.VSTS.Scheduling.CompletedWork — Task only in the Basic process. */
completedWork?: number
areaPath?: string
state?: string
tags?: string
}
export interface UpdateWorkItemResponse extends ToolResponse {
output: {
content: string
metadata: {
workItem: AzureDevOpsWorkItem
}
}
}
// ── Add Comment ────────────────────────────────────────────────────────────────
export interface AddCommentParams extends AzureDevOpsBaseParams {
workItemId: number
text: string
}
export interface AzureDevOpsComment {
workItemId: number
commentId: number
version: number
text: string
renderedText?: string
createdBy: string | null
createdDate: string
modifiedBy: string | null
modifiedDate: string
isDeleted: boolean
url: string
}
export interface AddCommentResponse extends ToolResponse {
output: {
content: string
metadata: {
comment: AzureDevOpsComment
}
}
}
// ── Response Union ────────────────────────────────────────────────────────────
export type AzureDevOpsResponse =
| ListPipelinesResponse
| GetPipelineResponse
| ListPipelineRunsResponse
| GetPipelineRunResponse
| ListBuildsResponse
| ListBuildLogsResponse
| GetBuildLogResponse
| GetBuildTimelineResponse
| GetWorkItemsBetweenBuildsResponse
| QueryWorkItemsResponse
| GetWorkItemResponse
| GetWorkItemsBatchResponse
| CreateWorkItemResponse
| UpdateWorkItemResponse
| AddCommentResponse
| GetCommentsResponse
// ── Get Comments ──────────────────────────────────────────────────────────────
export interface GetCommentsParams extends AzureDevOpsBaseParams {
workItemId: number
top?: number
continuationToken?: string
includeDeleted?: boolean
expand?: string
order?: string
}
export interface GetCommentsResponse extends ToolResponse {
output: {
content: string
metadata: {
count: number
totalCount: number
comments: AzureDevOpsComment[]
continuationToken?: string
nextPage?: string
url?: string
}
}
}
@@ -0,0 +1,268 @@
import type {
AzureDevOpsWorkItem,
UpdateWorkItemParams,
UpdateWorkItemResponse,
} from '@/tools/azure_devops/types'
import type { AzureDevOpsJsonPatchOp, AzureDevOpsRawWorkItem } from '@/tools/azure_devops/utils'
import {
appendEffortPatchOp,
appendFieldPatchOp,
formatWorkItem,
mapWorkItem,
} from '@/tools/azure_devops/utils'
import type { ToolConfig } from '@/tools/types'
export const updateWorkItemTool: ToolConfig<UpdateWorkItemParams, UpdateWorkItemResponse> = {
id: 'azure_devops_update_work_item',
name: 'Azure DevOps Update Work Item',
description:
'Update one or more fields on an existing work item in Azure DevOps. Provide only the fields you want to change.',
version: '1.0.0',
params: {
organization: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Azure DevOps organization name',
},
project: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Azure DevOps project name',
},
workItemId: {
type: 'number',
required: true,
visibility: 'user-or-llm',
description: 'ID of the work item to update',
},
title: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'New title for the work item (optional)',
},
description: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'New HTML description for the work item (optional)',
},
assignedTo: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Email or display name to reassign the work item to (optional)',
},
areaPath: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'New area path for the work item (optional)',
},
priority: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description:
'Priority of the work item (1 = Critical, 2 = High, 3 = Medium, 4 = Low) (optional)',
},
state: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'New state for Basic-process work items: "To Do", "Doing", or "Done" (optional)',
},
effort: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Effort (Microsoft.VSTS.Scheduling.Effort). Basic process: Issue only.',
},
startDate: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description:
'Start date (Microsoft.VSTS.Scheduling.StartDate), ISO 8601. Basic process: Epic only.',
},
targetDate: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description:
'Target date (Microsoft.VSTS.Scheduling.TargetDate), ISO 8601. Basic process: Epic only.',
},
activity: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description:
'Activity (Microsoft.VSTS.Common.Activity). One of Deployment, Design, Development, Documentation, Requirements, Testing. Basic process: Task only.',
},
remainingWork: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description:
'Remaining work hours (Microsoft.VSTS.Scheduling.RemainingWork). Basic process: Task only.',
},
completedWork: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description:
'Completed work hours (Microsoft.VSTS.Scheduling.CompletedWork). Basic process: Task only.',
},
tags: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Semicolon-separated tags to set on the work item (optional)',
},
accessToken: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Azure DevOps Personal Access Token (scopes: Work Items: Read & Write)',
},
},
request: {
url: (params) =>
`https://dev.azure.com/${params.organization.trim()}/${params.project.trim()}/_apis/wit/workitems/${Number(params.workItemId)}?api-version=7.2-preview.3`,
method: 'PATCH',
headers: (params) => ({
'Content-Type': 'application/json-patch+json',
Authorization: `Basic ${btoa(`:${params.accessToken}`)}`,
}),
body: (params) => {
const ops: AzureDevOpsJsonPatchOp[] = []
if (
!params.title &&
!params.description &&
!params.assignedTo &&
!params.areaPath &&
params.priority === undefined &&
!params.state &&
params.effort === undefined &&
!params.startDate &&
!params.targetDate &&
!params.activity &&
params.remainingWork === undefined &&
params.completedWork === undefined &&
!params.tags
) {
throw new Error('Update Work Item requires at least one field to update.')
}
if (params.title) {
ops.push({ op: 'replace', path: '/fields/System.Title', value: params.title })
}
if (params.description) {
ops.push({ op: 'replace', path: '/fields/System.Description', value: params.description })
}
if (params.assignedTo) {
ops.push({ op: 'replace', path: '/fields/System.AssignedTo', value: params.assignedTo })
}
if (params.areaPath) {
ops.push({ op: 'replace', path: '/fields/System.AreaPath', value: params.areaPath })
}
if (params.priority !== undefined) {
ops.push({
op: 'replace',
path: '/fields/Microsoft.VSTS.Common.Priority',
value: String(Number(params.priority)),
})
}
if (params.state) {
ops.push({ op: 'replace', path: '/fields/System.State', value: params.state })
}
appendEffortPatchOp(ops, params.effort, 'replace')
appendFieldPatchOp(
ops,
'Microsoft.VSTS.Scheduling.StartDate',
params.startDate,
'replace',
'string'
)
appendFieldPatchOp(
ops,
'Microsoft.VSTS.Scheduling.TargetDate',
params.targetDate,
'replace',
'string'
)
appendFieldPatchOp(
ops,
'Microsoft.VSTS.Common.Activity',
params.activity,
'replace',
'string'
)
appendFieldPatchOp(
ops,
'Microsoft.VSTS.Scheduling.RemainingWork',
params.remainingWork,
'replace',
'number'
)
appendFieldPatchOp(
ops,
'Microsoft.VSTS.Scheduling.CompletedWork',
params.completedWork,
'replace',
'number'
)
if (params.tags) {
ops.push({ op: 'replace', path: '/fields/System.Tags', value: params.tags })
}
return ops
},
},
transformResponse: async (response) => {
const raw: AzureDevOpsRawWorkItem = await response.json()
const workItem: AzureDevOpsWorkItem = mapWorkItem(raw)
return {
success: true,
output: {
content: `Updated work item #${workItem.id}:\n\n${formatWorkItem(workItem)}`,
metadata: { workItem },
},
}
},
outputs: {
content: {
type: 'string',
description: 'Human-readable summary of the updated work item',
},
metadata: {
type: 'object',
description: 'Updated work item metadata',
properties: {
workItem: {
type: 'object',
description: 'Full details of the updated work item',
properties: {
id: { type: 'number', description: 'Work item ID' },
title: { type: 'string', description: 'Work item title' },
state: { type: 'string', description: 'Current state after update' },
workItemType: {
type: 'string',
description: 'Work item type returned by Azure DevOps (e.g. Issue, Task, Epic)',
},
assignedTo: {
type: 'string',
description: 'Display name of assigned user, or null if unassigned',
},
areaPath: { type: 'string', description: 'Area path of the work item' },
url: { type: 'string', description: 'API URL for the work item' },
},
},
},
},
},
}
+120
View File
@@ -0,0 +1,120 @@
import type { AzureDevOpsComment, AzureDevOpsWorkItem } from '@/tools/azure_devops/types'
/** States for Azure DevOps Basic process work items (Issue, Task, Epic). */
export const AZURE_DEVOPS_BASIC_WORK_ITEM_STATES = ['To Do', 'Doing', 'Done'] as const
/** Work item types for Azure DevOps Basic process. */
export const AZURE_DEVOPS_BASIC_WORK_ITEM_TYPES = ['Issue', 'Task', 'Epic'] as const
export type AzureDevOpsJsonPatchOp = {
op: string
path: string
value: string | number
}
/**
* Appends a JSON-Patch op for a single work item field when the value is non-empty.
* Skips silently on undefined/empty-string. Numbers are validated; strings are
* passed through.
*/
export function appendFieldPatchOp(
ops: AzureDevOpsJsonPatchOp[],
refName: string,
value: string | number | undefined,
patchOp: 'add' | 'replace',
kind: 'number' | 'string'
): void {
if (value === undefined || value === '') return
if (kind === 'number') {
const numeric = Number(value)
if (Number.isNaN(numeric)) return
ops.push({ op: patchOp, path: `/fields/${refName}`, value: numeric })
return
}
ops.push({ op: patchOp, path: `/fields/${refName}`, value: String(value) })
}
/**
* Appends a Microsoft.VSTS.Scheduling.Effort patch when effort is a valid number.
* Field availability depends on work item type and process template (Issue in Basic).
*/
export function appendEffortPatchOp(
ops: AzureDevOpsJsonPatchOp[],
effort: number | string | undefined,
patchOp: 'add' | 'replace'
): void {
appendFieldPatchOp(ops, 'Microsoft.VSTS.Scheduling.Effort', effort, patchOp, 'number')
}
export function mapWorkItem(raw: AzureDevOpsRawWorkItem): AzureDevOpsWorkItem {
const fields = raw.fields ?? {}
return {
id: raw.id,
title: (fields['System.Title'] as string | undefined) ?? '',
state: (fields['System.State'] as string | undefined) ?? '',
workItemType: (fields['System.WorkItemType'] as string | undefined) ?? '',
assignedTo:
(fields['System.AssignedTo'] as { displayName?: string } | undefined)?.displayName ?? null,
areaPath: (fields['System.AreaPath'] as string | undefined) ?? '',
url: raw.url,
}
}
export function formatWorkItem(w: AzureDevOpsWorkItem): string {
return [
`ID: ${w.id} [${w.workItemType}] ${w.title}`,
` State: ${w.state}`,
` Assigned To: ${w.assignedTo ?? 'Unassigned'}`,
` Area: ${w.areaPath}`,
].join('\n')
}
export interface AzureDevOpsRawWorkItem {
id: number
url: string
fields: Record<string, unknown>
}
export function mapComment(raw: AzureDevOpsRawComment): AzureDevOpsComment {
return {
workItemId: raw.workItemId,
commentId: raw.commentId ?? raw.id,
version: raw.version,
text: raw.text,
renderedText: raw.renderedText,
createdBy: raw.createdBy?.displayName ?? null,
createdDate: raw.createdDate,
modifiedBy: raw.modifiedBy?.displayName ?? null,
modifiedDate: raw.modifiedDate,
isDeleted: raw.isDeleted ?? false,
url: raw.url,
}
}
export function formatComment(comment: AzureDevOpsComment): string {
return [
`Comment #${comment.commentId} on work item #${comment.workItemId}`,
` Author: ${comment.createdBy ?? 'Unknown'}`,
` Created: ${comment.createdDate}`,
` Text: ${comment.text}`,
].join('\n')
}
interface AzureDevOpsIdentityRef {
displayName?: string
}
export interface AzureDevOpsRawComment {
id: number
commentId?: number
workItemId: number
version: number
text: string
renderedText?: string
createdBy?: AzureDevOpsIdentityRef
createdDate: string
modifiedBy?: AzureDevOpsIdentityRef
modifiedDate: string
isDeleted?: boolean
url: string
}