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
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:
@@ -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' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -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' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -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' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user