diff --git a/apps/docs/components/icons.tsx b/apps/docs/components/icons.tsx index 6508cb8bbb5..ab234725964 100644 --- a/apps/docs/components/icons.tsx +++ b/apps/docs/components/icons.tsx @@ -3126,6 +3126,36 @@ export function AzureIcon(props: SVGProps) { ) } +export function AzureDevOpsIcon(props: SVGProps) { + const id = useId() + const gradientId = `azure_devops_gradient_${id}` + return ( + + + + + + + + + + + + + ) +} + export const GroqIcon = (props: SVGProps) => ( = { ashby: AshbyIcon, athena: AthenaIcon, attio: AttioIcon, + azure_devops: AzureDevOpsIcon, box: BoxCompanyIcon, brandfetch: BrandfetchIcon, brightdata: BrightDataIcon, diff --git a/apps/docs/content/docs/en/tools/azure_devops.mdx b/apps/docs/content/docs/en/tools/azure_devops.mdx new file mode 100644 index 00000000000..38b3bfb69d8 --- /dev/null +++ b/apps/docs/content/docs/en/tools/azure_devops.mdx @@ -0,0 +1,554 @@ +--- +title: Azure DevOps +description: Interact with Azure DevOps pipelines, builds, and work items +--- + +import { BlockInfoCard } from "@/components/ui/block-info-card" + + + +{/* MANUAL-CONTENT-START:intro */} +[Azure DevOps](https://azure.microsoft.com/en-us/products/devops) is Microsoft's end-to-end DevOps platform for planning, building, testing, and shipping software. It powers engineering at tens of thousands of enterprises across automotive, financial services, government, and any organization built on the Microsoft stack. + +With the Azure DevOps integration in Sim, you can: + +- **Inspect pipelines and runs**: List pipelines, fetch metadata, and walk through run history with status and result +- **Triage build failures**: Pull build timelines to see which stage, job, or task failed, then fetch the exact log for the failing step +- **Audit changes between builds**: Surface the work items that landed between any two builds — useful for release notes and regression hunts +- **Query work items with WIQL**: Run full WIQL queries and get hydrated work item fields back in a single call, not just IDs +- **Manage work item lifecycle**: Create, update, and read Issues, Tasks, and Epics with structured fields — title, description, priority, assignee, area path, iteration, tags, effort, and dates +- **Collaborate via comments**: Add internal or public comments to work items and read full comment history +- **React in real time**: Trigger workflows when builds fail or new work items are created via Azure DevOps service hooks + +These capabilities let your Sim agents close the loop on the DevOps lifecycle — automatically triaging broken builds, drafting release notes between deployments, syncing work items across systems, and keeping engineering operations running while your team focuses on shipping. +{/* MANUAL-CONTENT-END */} + + +## Usage Instructions + +Integrate Azure DevOps into your workflow. List and inspect pipelines and builds, query and manage work items, and add or read comments. + + + +## Tools + +### `azure_devops_list_pipelines` + +List all pipelines in an Azure DevOps project. Returns pipeline ID, name, folder, revision, and URL. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `organization` | string | Yes | Azure DevOps organization name | +| `project` | string | Yes | Azure DevOps project name | +| `orderBy` | string | No | Field to sort results by \(e.g. "name"\) | +| `top` | number | No | Maximum number of pipelines to return | +| `continuationToken` | string | No | Continuation token for paginating results | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `content` | string | Human-readable summary of pipelines | +| `metadata` | object | Pipelines metadata | +| ↳ `count` | number | Total number of pipelines returned | +| ↳ `pipelines` | array | Array of pipeline objects | +| ↳ `id` | number | Pipeline ID | +| ↳ `name` | string | Pipeline name | +| ↳ `folder` | string | Folder path \(e.g. "\\\\"\) | +| ↳ `revision` | number | Pipeline revision number | +| ↳ `url` | string | Pipeline API URL | + +### `azure_devops_get_pipeline` + +Get details for a specific pipeline in an Azure DevOps project, including configuration and repository info. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `organization` | string | Yes | Azure DevOps organization name | +| `project` | string | Yes | Azure DevOps project name | +| `pipelineId` | number | Yes | ID of the pipeline to retrieve | +| `pipelineVersion` | number | No | Specific revision of the pipeline to retrieve \(defaults to latest\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `content` | string | Human-readable summary of the pipeline | +| `metadata` | object | Pipeline detail metadata | +| ↳ `pipeline` | object | Full pipeline detail object | +| ↳ `id` | number | Pipeline ID | +| ↳ `name` | string | Pipeline name | +| ↳ `folder` | string | Folder path | +| ↳ `revision` | number | Pipeline revision number | +| ↳ `url` | string | Pipeline API URL | +| ↳ `configuration` | object | Pipeline configuration | +| ↳ `type` | string | Configuration type \(e.g. "yaml"\) | +| ↳ `path` | string | YAML file path in the repository | +| ↳ `repository` | object | Source repository info | +| ↳ `id` | string | Repository ID | +| ↳ `type` | string | Repository type \(e.g. "azureReposGit"\) | +| ↳ `links` | object | Hypermedia links | +| ↳ `self` | string | API self-link | +| ↳ `web` | string | Browser URL for the pipeline | + +### `azure_devops_list_pipeline_runs` + +List runs for a specific pipeline in an Azure DevOps project. Returns run ID, name, state, result, and timestamps. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `organization` | string | Yes | Azure DevOps organization name | +| `project` | string | Yes | Azure DevOps project name | +| `pipelineId` | number | Yes | ID of the pipeline whose runs to list | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `content` | string | Human-readable summary of pipeline runs | +| `metadata` | object | Pipeline runs metadata | +| ↳ `count` | number | Total number of runs returned | +| ↳ `runs` | array | Array of pipeline run objects | +| ↳ `id` | number | Run ID | +| ↳ `name` | string | Run name \(e.g. "20210601.1"\) | +| ↳ `state` | string | Run state \(e.g. "completed", "inProgress"\) | +| ↳ `result` | string | Run result \(e.g. "succeeded", "failed"\) — absent if still running | +| ↳ `createdDate` | string | ISO 8601 creation timestamp | +| ↳ `finishedDate` | string | ISO 8601 finish timestamp — absent if still running | +| ↳ `url` | string | Run API URL | +| ↳ `webUrl` | string | Browser URL for the run | + +### `azure_devops_get_pipeline_run` + +Get details for a specific pipeline run in an Azure DevOps project. Returns run state, result, timestamps, and the pipeline reference. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `organization` | string | Yes | Azure DevOps organization name | +| `project` | string | Yes | Azure DevOps project name | +| `pipelineId` | number | Yes | ID of the pipeline | +| `runId` | number | Yes | ID of the run to retrieve | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `content` | string | Human-readable summary of the pipeline run | +| `metadata` | object | Pipeline run metadata | +| ↳ `run` | object | Full pipeline run detail object | +| ↳ `id` | number | Run ID | +| ↳ `name` | string | Run name \(e.g. "20210601.1"\) | +| ↳ `state` | string | Run state \(e.g. "completed", "inProgress"\) | +| ↳ `result` | string | Run result \(e.g. "succeeded", "failed"\) — absent if still running | +| ↳ `createdDate` | string | ISO 8601 creation timestamp | +| ↳ `finishedDate` | string | ISO 8601 finish timestamp — absent if still running | +| ↳ `url` | string | Run API URL | +| ↳ `webUrl` | string | Browser URL for the run | +| ↳ `pipeline` | object | Pipeline reference | +| ↳ `id` | number | Pipeline ID | +| ↳ `name` | string | Pipeline name | +| ↳ `folder` | string | Pipeline folder | +| ↳ `revision` | number | Pipeline revision number | +| ↳ `url` | string | Pipeline API URL | + +### `azure_devops_list_builds` + +List builds in an Azure DevOps project. Optionally filter by pipeline definition, status, result, or branch. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `organization` | string | Yes | Azure DevOps organization name | +| `project` | string | Yes | Azure DevOps project name | +| `definitionIds` | string | No | Comma-separated pipeline definition IDs to filter by \(e.g. "1,2,3"\) | +| `top` | number | No | Maximum number of builds to return | +| `statusFilter` | string | No | Filter by build status: inProgress, completed, cancelling, postponed, notStarted, none | +| `resultFilter` | string | No | Filter by build result: succeeded, partiallySucceeded, failed, canceled | +| `branchName` | string | No | Filter by source branch name \(e.g. "refs/heads/main"\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `content` | string | Human-readable summary of builds | +| `metadata` | object | Builds metadata | +| ↳ `count` | number | Total number of builds returned | +| ↳ `builds` | array | Array of build objects | +| ↳ `id` | number | Build ID | +| ↳ `buildNumber` | string | Build number \(e.g. "20210601.1"\) | +| ↳ `status` | string | Build status \(e.g. "completed", "inProgress"\) | +| ↳ `result` | string | Build result \(e.g. "succeeded", "failed"\) — absent if still running | +| ↳ `queueTime` | string | ISO 8601 queue timestamp | +| ↳ `startTime` | string | ISO 8601 start timestamp | +| ↳ `finishTime` | string | ISO 8601 finish timestamp — absent if still running | +| ↳ `sourceBranch` | string | Source branch \(e.g. "refs/heads/main"\) | +| ↳ `sourceVersion` | string | Source commit SHA | +| ↳ `definition` | object | Pipeline definition reference | +| ↳ `id` | number | Definition ID | +| ↳ `name` | string | Definition name | +| ↳ `webUrl` | string | Browser URL for the build | + +### `azure_devops_list_build_logs` + +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. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `organization` | string | Yes | Azure DevOps organization name | +| `project` | string | Yes | Azure DevOps project name | +| `buildId` | number | Yes | The build ID whose logs to list | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `content` | string | Human-readable summary of build logs | +| `metadata` | object | Build logs metadata | +| ↳ `count` | number | Total number of log entries returned | +| ↳ `logs` | array | Array of log entry objects | +| ↳ `id` | number | Log entry ID — use with Get Build Log to fetch content | +| ↳ `type` | string | Log type \(e.g. "Container", "Task", "Section"\) | +| ↳ `url` | string | API URL for the log entry | +| ↳ `lineCount` | number | Number of lines in the log | +| ↳ `createdOn` | string | ISO 8601 creation timestamp | +| ↳ `lastChangedOn` | string | ISO 8601 last-changed timestamp | + +### `azure_devops_get_build_log` + +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. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `organization` | string | Yes | Azure DevOps organization name | +| `project` | string | Yes | Azure DevOps project name | +| `buildId` | number | Yes | The build ID containing the log | +| `logId` | number | Yes | The log entry ID to fetch \(from List Build Logs\) | +| `startLine` | number | No | First line to return \(1-based, inclusive\) | +| `endLine` | number | No | Last line to return \(1-based, inclusive\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `content` | string | Raw log text | +| `metadata` | object | Log metadata | +| ↳ `lineCount` | number | Number of lines in the returned log text | + +### `azure_devops_get_build_timeline` + +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. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `organization` | string | Yes | Azure DevOps organization name | +| `project` | string | Yes | Azure DevOps project name | +| `buildId` | number | Yes | ID of the build whose timeline to retrieve | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `content` | string | Summary of the build timeline, highlighting failed steps | +| `metadata` | object | Build timeline metadata | +| ↳ `totalCount` | number | Total number of timeline records | +| ↳ `failedCount` | number | Number of failed records | +| ↳ `records` | array | All timeline records \(stages, jobs, tasks\) | +| ↳ `id` | string | Record GUID | +| ↳ `name` | string | Step name \(e.g. "Run tests"\) | +| ↳ `type` | string | Stage \| Phase \| Job \| Task | +| ↳ `result` | string | succeeded \| failed \| skipped \| canceled \| null | +| ↳ `logId` | number | Log ID to pass to Get Build Log, or null | +| ↳ `errorCount` | number | Number of errors | +| ↳ `warningCount` | number | Number of warnings | +| ↳ `startTime` | string | ISO 8601 start timestamp | +| ↳ `finishTime` | string | ISO 8601 finish timestamp | +| ↳ `failedRecords` | array | Subset of records where result === "failed" — use logId to fetch logs | +| ↳ `id` | string | Record GUID | +| ↳ `name` | string | Step name | +| ↳ `type` | string | Stage \| Phase \| Job \| Task | +| ↳ `result` | string | failed | +| ↳ `logId` | number | Log ID to pass to Get Build Log | +| ↳ `errorCount` | number | Number of errors | +| ↳ `warningCount` | number | Number of warnings | +| ↳ `startTime` | string | ISO 8601 start timestamp | +| ↳ `finishTime` | string | ISO 8601 finish timestamp | + +### `azure_devops_get_work_items_between_builds` + +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. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `organization` | string | Yes | Azure DevOps organization name | +| `project` | string | Yes | Azure DevOps project name | +| `fromBuildId` | number | Yes | The older build ID \(start of range\) | +| `toBuildId` | number | Yes | The newer build ID \(end of range\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `content` | string | Human-readable summary of work items between builds | +| `metadata` | object | Work items metadata | +| ↳ `count` | number | Total number of work item references returned | +| ↳ `workItems` | array | Array of work item references | +| ↳ `id` | string | Work item ID | +| ↳ `url` | string | API URL for the work item | + +### `azure_devops_query_work_items` + +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). + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `organization` | string | Yes | Azure DevOps organization name | +| `project` | string | Yes | Azure DevOps project name | +| `wiqlQuery` | string | Yes | 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. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `content` | string | Human-readable summary of matching work items | +| `metadata` | object | Work items metadata | +| ↳ `count` | number | Number of work items returned | +| ↳ `workItems` | array | Array of work item details | +| ↳ `id` | number | Work item ID | +| ↳ `title` | string | Work item title | +| ↳ `state` | string | Current state for Basic process \(e.g. To Do, Doing, Done\) | +| ↳ `workItemType` | string | Work item type returned by Azure DevOps \(e.g. Issue, Task, Epic\) | +| ↳ `assignedTo` | string | Display name of assigned user, or null if unassigned | +| ↳ `areaPath` | string | Area path of the work item | +| ↳ `url` | string | API URL for the work item | + +### `azure_devops_get_work_item` + +Fetch full details of a single work item by ID from Azure DevOps, including title, state, type, assignee, and area path. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `organization` | string | Yes | Azure DevOps organization name | +| `project` | string | Yes | Azure DevOps project name | +| `workItemId` | number | Yes | The work item ID to fetch | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `content` | string | Human-readable summary of the work item | +| `metadata` | object | Work item metadata | +| ↳ `workItem` | object | Full work item details | +| ↳ `id` | number | Work item ID | +| ↳ `title` | string | Work item title | +| ↳ `state` | string | Current state for Basic process \(e.g. To Do, Doing, Done\) | +| ↳ `workItemType` | string | Work item type returned by Azure DevOps \(e.g. Issue, Task, Epic\) | +| ↳ `assignedTo` | string | Display name of assigned user, or null if unassigned | +| ↳ `areaPath` | string | Area path of the work item | +| ↳ `url` | string | API URL for the work item | + +### `azure_devops_get_work_items_batch` + +Fetch full details for multiple work items by ID from Azure DevOps in a single call. Pass comma-separated IDs (e.g. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `organization` | string | Yes | Azure DevOps organization name | +| `project` | string | Yes | Azure DevOps project name | +| `ids` | string | Yes | Comma-separated work item IDs to fetch \(e.g. "123,456,789"\). Maximum 200 IDs. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `content` | string | Human-readable summary of the fetched work items | +| `metadata` | object | Work items metadata | +| ↳ `count` | number | Number of work items returned | +| ↳ `workItems` | array | Array of work item details | +| ↳ `id` | number | Work item ID | +| ↳ `title` | string | Work item title | +| ↳ `state` | string | Current state for Basic process \(e.g. To Do, Doing, Done\) | +| ↳ `workItemType` | string | Work item type returned by Azure DevOps \(e.g. Issue, Task, Epic\) | +| ↳ `assignedTo` | string | Display name of assigned user, or null if unassigned | +| ↳ `areaPath` | string | Area path of the work item | +| ↳ `url` | string | API URL for the work item | + +### `azure_devops_create_work_item` + +Create a new Basic-process work item (Issue, Task, or Epic) in Azure DevOps. Returns the created work item with its assigned ID. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `organization` | string | Yes | Azure DevOps organization name | +| `project` | string | Yes | Azure DevOps project name | +| `workItemType` | string | Yes | Basic-process work item type to create \("Issue", "Task", or "Epic"\). Use Issue for bug or defect tracking. | +| `title` | string | Yes | Title of the new work item | +| `description` | string | No | HTML description of the work item \(optional\) | +| `assignedTo` | string | No | Email or display name of the user to assign the work item to \(optional\) | +| `priority` | number | No | Priority of the work item \(1 = Critical, 2 = High, 3 = Medium, 4 = Low\) | +| `effort` | number | No | Effort \(Microsoft.VSTS.Scheduling.Effort\). Basic process: Issue only. | +| `startDate` | string | No | Start date \(Microsoft.VSTS.Scheduling.StartDate\), ISO 8601. Basic process: Epic only. | +| `targetDate` | string | No | Target date \(Microsoft.VSTS.Scheduling.TargetDate\), ISO 8601. Basic process: Epic only. | +| `activity` | string | No | Activity \(Microsoft.VSTS.Common.Activity\). One of Deployment, Design, Development, Documentation, Requirements, Testing. Basic process: Task only. | +| `remainingWork` | number | No | Remaining work hours \(Microsoft.VSTS.Scheduling.RemainingWork\). Basic process: Task only. | +| `completedWork` | number | No | Completed work hours \(Microsoft.VSTS.Scheduling.CompletedWork\). Basic process: Task only. | +| `areaPath` | string | No | Area path for the work item, e.g. "MyProject\\\\Team" \(optional\) | +| `iterationPath` | string | No | Iteration path for the work item, e.g. "MyProject\\\\Sprint 1" \(optional\) | +| `tags` | string | No | Semicolon-separated tags, e.g. "issue; p1; auth" \(optional\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `content` | string | Human-readable summary of the created work item | +| `metadata` | object | Created work item metadata | +| ↳ `workItem` | object | Full details of the created work item | +| ↳ `id` | number | Assigned work item ID | +| ↳ `title` | string | Work item title | +| ↳ `state` | string | Initial state for Basic process \(e.g. To Do, Doing, Done\) | +| ↳ `workItemType` | string | Work item type returned by Azure DevOps \(e.g. Issue, Task, Epic\) | +| ↳ `assignedTo` | string | Display name of assigned user, or null if unassigned | +| ↳ `areaPath` | string | Area path of the work item | +| ↳ `url` | string | API URL for the created work item | + +### `azure_devops_update_work_item` + +Update one or more fields on an existing work item in Azure DevOps. Provide only the fields you want to change. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `organization` | string | Yes | Azure DevOps organization name | +| `project` | string | Yes | Azure DevOps project name | +| `workItemId` | number | Yes | ID of the work item to update | +| `title` | string | No | New title for the work item \(optional\) | +| `description` | string | No | New HTML description for the work item \(optional\) | +| `assignedTo` | string | No | Email or display name to reassign the work item to \(optional\) | +| `areaPath` | string | No | New area path for the work item \(optional\) | +| `priority` | number | No | Priority of the work item \(1 = Critical, 2 = High, 3 = Medium, 4 = Low\) \(optional\) | +| `state` | string | No | New state for Basic-process work items: "To Do", "Doing", or "Done" \(optional\) | +| `effort` | number | No | Effort \(Microsoft.VSTS.Scheduling.Effort\). Basic process: Issue only. | +| `startDate` | string | No | Start date \(Microsoft.VSTS.Scheduling.StartDate\), ISO 8601. Basic process: Epic only. | +| `targetDate` | string | No | Target date \(Microsoft.VSTS.Scheduling.TargetDate\), ISO 8601. Basic process: Epic only. | +| `activity` | string | No | Activity \(Microsoft.VSTS.Common.Activity\). One of Deployment, Design, Development, Documentation, Requirements, Testing. Basic process: Task only. | +| `remainingWork` | number | No | Remaining work hours \(Microsoft.VSTS.Scheduling.RemainingWork\). Basic process: Task only. | +| `completedWork` | number | No | Completed work hours \(Microsoft.VSTS.Scheduling.CompletedWork\). Basic process: Task only. | +| `tags` | string | No | Semicolon-separated tags to set on the work item \(optional\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `content` | string | Human-readable summary of the updated work item | +| `metadata` | object | Updated work item metadata | +| ↳ `workItem` | object | Full details of the updated work item | +| ↳ `id` | number | Work item ID | +| ↳ `title` | string | Work item title | +| ↳ `state` | string | Current state after update | +| ↳ `workItemType` | string | Work item type returned by Azure DevOps \(e.g. Issue, Task, Epic\) | +| ↳ `assignedTo` | string | Display name of assigned user, or null if unassigned | +| ↳ `areaPath` | string | Area path of the work item | +| ↳ `url` | string | API URL for the work item | + +### `azure_devops_add_comment` + +Add a comment to a work item in Azure DevOps. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `organization` | string | Yes | Azure DevOps organization name | +| `project` | string | Yes | Azure DevOps project name | +| `workItemId` | number | Yes | ID of the work item to comment on | +| `text` | string | Yes | Comment text \(HTML supported, e.g. "<p>My comment</p>"\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `content` | string | Human-readable confirmation of the added comment | +| `metadata` | object | Added comment metadata | +| ↳ `comment` | object | Full details of the created comment | +| ↳ `workItemId` | number | Work item the comment belongs to | +| ↳ `commentId` | number | Comment ID | +| ↳ `version` | number | Comment version | +| ↳ `text` | string | Comment text | +| ↳ `renderedText` | string | Rendered HTML comment text when available | +| ↳ `createdBy` | string | Display name of the comment author, or null | +| ↳ `createdDate` | string | ISO timestamp when comment was created | +| ↳ `modifiedBy` | string | Display name of the last modifier, or null | +| ↳ `modifiedDate` | string | ISO timestamp when comment was modified | +| ↳ `isDeleted` | boolean | Whether the comment is deleted | +| ↳ `url` | string | API URL for the comment | + +### `azure_devops_get_comments` + +List comments for an Azure DevOps work item. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `organization` | string | Yes | Azure DevOps organization name | +| `project` | string | Yes | Azure DevOps project name | +| `workItemId` | number | Yes | ID of the work item whose comments should be listed | +| `top` | number | No | Maximum number of comments to return | +| `continuationToken` | string | No | Continuation token for paginating comments | +| `includeDeleted` | boolean | No | Whether deleted comments should be returned | +| `expand` | string | No | Additional comment data to include: none, reactions, renderedText, renderedTextOnly, all | +| `order` | string | No | Sort order for comments: asc or desc | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `content` | string | Human-readable summary of work item comments | +| `metadata` | object | Comments metadata | +| ↳ `count` | number | Number of comments returned in this page | +| ↳ `totalCount` | number | Total number of comments on the work item | +| ↳ `continuationToken` | string | Continuation token for the next page | +| ↳ `nextPage` | string | API URL for the next page | +| ↳ `url` | string | API URL for this comments list | +| ↳ `comments` | array | Array of work item comments | +| ↳ `workItemId` | number | Work item ID | +| ↳ `commentId` | number | Comment ID | +| ↳ `version` | number | Comment version | +| ↳ `text` | string | Comment text | +| ↳ `renderedText` | string | Rendered HTML comment text when available | +| ↳ `createdBy` | string | Display name of the comment author | +| ↳ `createdDate` | string | ISO 8601 creation timestamp | +| ↳ `modifiedBy` | string | Display name of the last modifier | +| ↳ `modifiedDate` | string | ISO 8601 modified timestamp | +| ↳ `isDeleted` | boolean | Whether the comment is deleted | +| ↳ `url` | string | API URL for the comment | + + diff --git a/apps/docs/content/docs/en/tools/meta.json b/apps/docs/content/docs/en/tools/meta.json index 5448fe6407b..5e8a637079b 100644 --- a/apps/docs/content/docs/en/tools/meta.json +++ b/apps/docs/content/docs/en/tools/meta.json @@ -17,6 +17,7 @@ "ashby", "athena", "attio", + "azure_devops", "box", "brandfetch", "brightdata", diff --git a/apps/docs/content/docs/en/triggers/azure_devops.mdx b/apps/docs/content/docs/en/triggers/azure_devops.mdx new file mode 100644 index 00000000000..d22c9ae8257 --- /dev/null +++ b/apps/docs/content/docs/en/triggers/azure_devops.mdx @@ -0,0 +1,83 @@ +--- +title: Azure Devops +description: Available Azure Devops triggers for automating workflows +--- + +import { BlockInfoCard } from "@/components/ui/block-info-card" + + + +Azure Devops provides 3 triggers for automating workflows based on events. + +## Triggers + +### Azure DevOps Build Failed + +Trigger workflow when an Azure DevOps build fails, is canceled, or partially succeeds + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `buildId` | number | Build ID | +| `buildNumber` | string | Build number string \(e.g. 20240101.1\) | +| `result` | string | Build result: failed \| canceled \| partiallySucceeded | +| `pipelineId` | number | Pipeline definition ID | +| `pipelineName` | string | Pipeline definition name | +| `projectName` | string | Azure DevOps project name | +| `branch` | string | Source branch name \(refs/heads/ prefix stripped\) | +| `commitSha` | string | Source commit SHA | +| `triggeredBy` | string | Display name of the person who triggered the build | +| `triggeredByEmail` | string | Email/unique name of the person who triggered the build | +| `startTime` | string | Build start time \(ISO 8601\) | +| `finishTime` | string | Build finish time \(ISO 8601\) | +| `buildUrl` | string | API URL for the build resource | + + +--- + +### Azure DevOps Webhook (All Service Hook Events) + +Trigger on whichever service hook event types you configure in Azure DevOps. Sim does not filter deliveries for this trigger. + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventType` | string | Service hook event type \(e.g. build.complete, workitem.created\) | +| `notificationId` | number | Notification ID | +| `subscriptionId` | string | Service hook subscription ID | +| `publisherId` | string | Publisher ID \(e.g. tfs\) | +| `createdDate` | string | Event creation time \(ISO 8601\) | +| `resource` | json | Event resource payload | +| `resourceContainers` | json | Resource container references \(project, collection, etc.\) | +| `message` | json | Short message object | +| `detailedMessage` | json | Detailed message object | + + +--- + +### Azure DevOps Work Item Created + +Trigger workflow when a work item is created in Azure DevOps + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `workItemId` | number | Work item ID | +| `workItemType` | string | Work item type for Basic process \(e.g. Issue, Task, Epic\) | +| `title` | string | Work item title | +| `state` | string | Work item state for Basic process \(e.g. To Do, Doing, Done\) | +| `createdBy` | string | Display name of the creator | +| `assignedTo` | string | Assignee display name, or empty string if unassigned | +| `priority` | number | Priority \(1–4\), or 0 if not set | +| `areaPath` | string | Area path | +| `iterationPath` | string | Iteration path | +| `description` | string | Work item description \(HTML\), or empty string if not set | +| `projectName` | string | Azure DevOps project name | +| `workItemUrl` | string | API URL for the work item resource | + diff --git a/apps/docs/content/docs/en/triggers/meta.json b/apps/docs/content/docs/en/triggers/meta.json index 70d13afd920..ab14483dd52 100644 --- a/apps/docs/content/docs/en/triggers/meta.json +++ b/apps/docs/content/docs/en/triggers/meta.json @@ -8,6 +8,7 @@ "airtable", "ashby", "attio", + "azure_devops", "calcom", "calendly", "circleback", diff --git a/apps/sim/app/(landing)/integrations/data/icon-mapping.ts b/apps/sim/app/(landing)/integrations/data/icon-mapping.ts index ab6f6b1831c..9a1c6030bc9 100644 --- a/apps/sim/app/(landing)/integrations/data/icon-mapping.ts +++ b/apps/sim/app/(landing)/integrations/data/icon-mapping.ts @@ -20,6 +20,7 @@ import { AshbyIcon, AthenaIcon, AttioIcon, + AzureDevOpsIcon, AzureIcon, BoxCompanyIcon, BrainIcon, @@ -226,6 +227,7 @@ export const blockTypeToIconMap: Record = { ashby: AshbyIcon, athena: AthenaIcon, attio: AttioIcon, + azure_devops: AzureDevOpsIcon, box: BoxCompanyIcon, brandfetch: BrandfetchIcon, brightdata: BrightDataIcon, diff --git a/apps/sim/app/(landing)/integrations/data/integrations.json b/apps/sim/app/(landing)/integrations/data/integrations.json index b3d8b3bc4fc..cca205b9006 100644 --- a/apps/sim/app/(landing)/integrations/data/integrations.json +++ b/apps/sim/app/(landing)/integrations/data/integrations.json @@ -1882,6 +1882,105 @@ "integrationTypes": ["security"], "tags": ["identity", "microsoft-365"] }, + { + "type": "azure_devops", + "slug": "azure-devops", + "name": "Azure DevOps", + "description": "Interact with Azure DevOps pipelines, builds, and work items", + "longDescription": "Integrate Azure DevOps into your workflow. List and inspect pipelines and builds, query and manage work items, and add or read comments.", + "bgColor": "#0078D4", + "iconName": "AzureDevOpsIcon", + "docsUrl": "https://docs.sim.ai/tools/azure_devops", + "operations": [ + { + "name": "List Pipelines", + "description": "List all pipelines in an Azure DevOps project. Returns pipeline ID, name, folder, revision, and URL." + }, + { + "name": "Get Pipeline", + "description": "Get details for a specific pipeline in an Azure DevOps project, including configuration and repository info." + }, + { + "name": "List Pipeline Runs", + "description": "List runs for a specific pipeline in an Azure DevOps project. Returns run ID, name, state, result, and timestamps." + }, + { + "name": "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." + }, + { + "name": "List Builds", + "description": "List builds in an Azure DevOps project. Optionally filter by pipeline definition, status, result, or branch." + }, + { + "name": "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." + }, + { + "name": "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." + }, + { + "name": "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." + }, + { + "name": "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." + }, + { + "name": "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)." + }, + { + "name": "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." + }, + { + "name": "Get Work Items Batch", + "description": "Fetch full details for multiple work items by ID from Azure DevOps in a single call. Pass comma-separated IDs (e.g. " + }, + { + "name": "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." + }, + { + "name": "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." + }, + { + "name": "Add Comment", + "description": "Add a comment to a work item in Azure DevOps." + }, + { + "name": "Get Comments", + "description": "List comments for an Azure DevOps work item." + } + ], + "operationCount": 16, + "triggers": [ + { + "id": "azure_devops_build_failed", + "name": "Azure DevOps Build Failed", + "description": "Trigger workflow when an Azure DevOps build fails, is canceled, or partially succeeds" + }, + { + "id": "azure_devops_work_item_created", + "name": "Azure DevOps Work Item Created", + "description": "Trigger workflow when a work item is created in Azure DevOps" + }, + { + "id": "azure_devops_webhook", + "name": "Azure DevOps Webhook (All Service Hook Events)", + "description": "Trigger on whichever service hook event types you configure in Azure DevOps. Sim does not filter deliveries for this trigger." + } + ], + "triggerCount": 3, + "authType": "api-key", + "category": "tools", + "integrationTypes": ["developer-tools", "productivity"], + "tags": ["ci-cd", "project-management", "version-control"] + }, { "type": "box", "slug": "box", @@ -4057,6 +4156,10 @@ "name": "Fetch", "description": "Fetch and parse a file from a URL with optional custom headers." }, + { + "name": "Get", + "description": "Get a workspace file object from a selected file or canonical workspace file ID." + }, { "name": "Write", "description": "Create a new workspace file. If a file with the same name already exists, a numeric suffix is added (e.g., " @@ -4066,7 +4169,7 @@ "description": "Append content to an existing workspace file. The file must already exist. Content is added to the end of the file." } ], - "operationCount": 4, + "operationCount": 5, "triggers": [], "triggerCount": 0, "authType": "none", diff --git a/apps/sim/blocks/blocks/azure_devops.test.ts b/apps/sim/blocks/blocks/azure_devops.test.ts new file mode 100644 index 00000000000..8180f6d60e2 --- /dev/null +++ b/apps/sim/blocks/blocks/azure_devops.test.ts @@ -0,0 +1,162 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { AzureDevOpsBlock } from './azure_devops' + +const expectedToolIds = [ + 'azure_devops_add_comment', + 'azure_devops_create_work_item', + 'azure_devops_get_build_log', + 'azure_devops_get_build_timeline', + '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', +] + +describe('AzureDevOpsBlock', () => { + const block = AzureDevOpsBlock + + it('exposes every Azure DevOps tool through the operation dropdown and tool access list', () => { + const operation = block.subBlocks.find((subBlock) => subBlock.id === 'operation') + expect(operation?.type).toBe('dropdown') + expect(block.tools.access.sort()).toEqual(expectedToolIds) + const operationOptions = + typeof operation?.options === 'function' ? operation.options() : operation?.options + expect(operationOptions?.map((option) => option.id).sort()).toEqual(expectedToolIds) + }) + + it('limits update work item state to Azure DevOps Basic process options', () => { + const state = block.subBlocks.find((subBlock) => subBlock.id === 'state') + expect(state?.type).toBe('dropdown') + expect(state?.options).toEqual([ + { label: 'To Do', id: 'To Do' }, + { label: 'Doing', id: 'Doing' }, + { label: 'Done', id: 'Done' }, + ]) + }) + + it('limits create work item types to the Azure DevOps Basic process options', () => { + const workItemType = block.subBlocks.find((subBlock) => subBlock.id === 'workItemType') + expect(workItemType?.type).toBe('dropdown') + expect(workItemType?.options).toEqual([ + { label: 'Issue', id: 'Issue' }, + { label: 'Task', id: 'Task' }, + { label: 'Epic', id: 'Epic' }, + ]) + expect(workItemType?.value?.()).toBe('Issue') + }) + + it('routes every operation to the matching tool id without serialization-time coercion', () => { + for (const toolId of expectedToolIds) { + expect(block.tools.config.tool?.({ operation: toolId })).toBe(toolId) + } + }) + + it('maps common params and coerces numeric fields at execution time', () => { + const pipelineRunParams = block.tools.config.params?.({ + accessToken: 'pat-token', + organization: 'mzxchandra', + project: 'sim-testing', + operation: 'azure_devops_get_pipeline_run', + pipelineId: '42', + runId: '99', + }) + + expect(pipelineRunParams).toMatchObject({ + accessToken: 'pat-token', + organization: 'mzxchandra', + project: 'sim-testing', + pipelineId: 42, + runId: 99, + }) + + const listBuildParams = block.tools.config.params?.({ + accessToken: 'pat-token', + organization: 'mzxchandra', + project: 'sim-testing', + operation: 'azure_devops_list_builds', + resultFilter: 'failed', + top: '10', + }) + + expect(listBuildParams).toMatchObject({ + accessToken: 'pat-token', + organization: 'mzxchandra', + project: 'sim-testing', + resultFilter: 'failed', + top: 10, + }) + + const getBuildLogParams = block.tools.config.params?.({ + accessToken: 'pat-token', + organization: 'mzxchandra', + project: 'sim-testing', + operation: 'azure_devops_get_build_log', + buildId: '101', + logId: '3', + }) + + expect(getBuildLogParams).toMatchObject({ + accessToken: 'pat-token', + organization: 'mzxchandra', + project: 'sim-testing', + buildId: 101, + logId: 3, + }) + + const createWorkItemParams = block.tools.config.params?.({ + accessToken: 'pat-token', + organization: 'mzxchandra', + project: 'sim-testing', + operation: 'azure_devops_create_work_item', + workItemType: 'Issue', + title: 'Pipeline failure', + priority: '2', + }) + + expect(createWorkItemParams).toMatchObject({ + accessToken: 'pat-token', + organization: 'mzxchandra', + project: 'sim-testing', + workItemType: 'Issue', + title: 'Pipeline failure', + priority: 2, + }) + + const updateWorkItemParams = block.tools.config.params?.({ + accessToken: 'pat-token', + organization: 'mzxchandra', + project: 'sim-testing', + operation: 'azure_devops_update_work_item', + workItemId: '101', + state: 'Doing', + effort: '8', + priority: '1', + }) + + expect(updateWorkItemParams).toMatchObject({ + accessToken: 'pat-token', + organization: 'mzxchandra', + project: 'sim-testing', + workItemId: 101, + state: 'Doing', + effort: 8, + priority: 1, + }) + }) + + it('declares downstream outputs for pipeline, build, work item, and comment operations', () => { + expect(block.outputs.content).toBeDefined() + expect(block.outputs.metadata).toBeDefined() + }) +}) diff --git a/apps/sim/blocks/blocks/azure_devops.ts b/apps/sim/blocks/blocks/azure_devops.ts new file mode 100644 index 00000000000..7af9bb2ffe8 --- /dev/null +++ b/apps/sim/blocks/blocks/azure_devops.ts @@ -0,0 +1,627 @@ +import { AzureDevOpsIcon } from '@/components/icons' +import type { BlockConfig } from '@/blocks/types' +import { AuthMode, IntegrationType } from '@/blocks/types' +import type { AzureDevOpsBasicWorkItemType, AzureDevOpsResponse } from '@/tools/azure_devops/types' +import { AZURE_DEVOPS_BASIC_WORK_ITEM_STATES } from '@/tools/azure_devops/utils' +import { getTrigger } from '@/triggers' + +/** Accepts ISO 8601 or YYYY-MM-DD; expands the bare date form to a UTC midnight ISO timestamp. */ +function normalizeDate(input: unknown): string | undefined { + if (typeof input !== 'string' || input.trim() === '') return undefined + const value = input.trim() + return /^\d{4}-\d{2}-\d{2}$/.test(value) ? `${value}T00:00:00Z` : value +} + +export const AzureDevOpsBlock: BlockConfig = { + type: 'azure_devops', + name: 'Azure DevOps', + description: 'Interact with Azure DevOps pipelines, builds, and work items', + longDescription: + 'Integrate Azure DevOps into your workflow. List and inspect pipelines and builds, query and manage work items, and add or read comments.', + docsLink: 'https://docs.sim.ai/tools/azure_devops', + category: 'tools', + integrationType: IntegrationType.DeveloperTools, + tags: ['ci-cd', 'project-management', 'version-control'], + bgColor: '#0078D4', + icon: AzureDevOpsIcon, + authMode: AuthMode.ApiKey, + triggerAllowed: true, + + subBlocks: [ + { + id: 'operation', + title: 'Operation', + type: 'dropdown', + options: [ + // Pipeline + { label: 'List Pipelines', id: 'azure_devops_list_pipelines' }, + { label: 'Get Pipeline', id: 'azure_devops_get_pipeline' }, + { label: 'List Pipeline Runs', id: 'azure_devops_list_pipeline_runs' }, + { label: 'Get Pipeline Run', id: 'azure_devops_get_pipeline_run' }, + // Builds + { label: 'List Builds', id: 'azure_devops_list_builds' }, + { label: 'List Build Logs', id: 'azure_devops_list_build_logs' }, + { label: 'Get Build Log', id: 'azure_devops_get_build_log' }, + { label: 'Get Build Timeline', id: 'azure_devops_get_build_timeline' }, + { + label: 'Get Work Items Between Builds', + id: 'azure_devops_get_work_items_between_builds', + }, + // Work Items + { label: 'Query Work Items', id: 'azure_devops_query_work_items' }, + { label: 'Get Work Item', id: 'azure_devops_get_work_item' }, + { label: 'Get Work Items Batch', id: 'azure_devops_get_work_items_batch' }, + { label: 'Create Work Item', id: 'azure_devops_create_work_item' }, + { label: 'Update Work Item', id: 'azure_devops_update_work_item' }, + { label: 'Add Comment', id: 'azure_devops_add_comment' }, + { label: 'Get Comments', id: 'azure_devops_get_comments' }, + ], + value: () => 'azure_devops_list_pipelines', + }, + + // ── Shared auth + org/project ──────────────────────────────────────────── + { + id: 'accessToken', + title: 'Personal Access Token', + type: 'short-input', + password: true, + required: true, + placeholder: 'Requires Build: Read and Work Items: Read & Write scopes', + }, + { + id: 'organization', + title: 'Organization', + type: 'short-input', + required: true, + placeholder: 'e.g. contoso', + }, + { + id: 'project', + title: 'Project', + type: 'short-input', + required: true, + placeholder: 'e.g. MyApp', + }, + + // ── Pipeline fields ────────────────────────────────────────────────────── + { + id: 'pipelineId', + title: 'Pipeline ID', + type: 'short-input', + required: true, + condition: { + field: 'operation', + value: [ + 'azure_devops_get_pipeline', + 'azure_devops_list_pipeline_runs', + 'azure_devops_get_pipeline_run', + ], + }, + }, + { + id: 'runId', + title: 'Run ID', + type: 'short-input', + required: true, + condition: { field: 'operation', value: 'azure_devops_get_pipeline_run' }, + }, + + // ── Build fields ───────────────────────────────────────────────────────── + { + id: 'resultFilter', + title: 'Filter by Result', + type: 'dropdown', + options: [ + { label: 'Any', id: '' }, + { label: 'Succeeded', id: 'succeeded' }, + { label: 'Failed', id: 'failed' }, + { label: 'Canceled', id: 'canceled' }, + { label: 'Partially Succeeded', id: 'partiallySucceeded' }, + ], + condition: { field: 'operation', value: 'azure_devops_list_builds' }, + mode: 'advanced', + }, + { + id: 'top', + title: 'Max Results', + type: 'short-input', + placeholder: '50', + condition: { field: 'operation', value: 'azure_devops_list_builds' }, + mode: 'advanced', + }, + { + id: 'buildId', + title: 'Build ID', + type: 'short-input', + required: true, + condition: { + field: 'operation', + value: [ + 'azure_devops_list_build_logs', + 'azure_devops_get_build_log', + 'azure_devops_get_build_timeline', + ], + }, + }, + { + id: 'logId', + title: 'Log ID', + type: 'short-input', + required: true, + condition: { field: 'operation', value: 'azure_devops_get_build_log' }, + }, + { + id: 'fromBuildId', + title: 'From Build ID', + type: 'short-input', + required: true, + condition: { field: 'operation', value: 'azure_devops_get_work_items_between_builds' }, + }, + { + id: 'toBuildId', + title: 'To Build ID', + type: 'short-input', + required: true, + condition: { field: 'operation', value: 'azure_devops_get_work_items_between_builds' }, + }, + + // ── Work Item fields ───────────────────────────────────────────────────── + { + id: 'wiqlQuery', + title: 'WIQL Query', + type: 'long-input', + required: true, + placeholder: + 'SELECT [System.Id], [System.Title], [System.State] FROM workitems WHERE [System.TeamProject] = @project ORDER BY [System.CreatedDate] DESC', + condition: { field: 'operation', value: 'azure_devops_query_work_items' }, + }, + { + id: 'workItemId', + title: 'Work Item ID', + type: 'short-input', + required: true, + condition: { + field: 'operation', + value: [ + 'azure_devops_get_work_item', + 'azure_devops_update_work_item', + 'azure_devops_add_comment', + 'azure_devops_get_comments', + ], + }, + }, + { + id: 'workItemIds', + title: 'Work Item IDs', + type: 'short-input', + required: true, + placeholder: 'Comma-separated IDs, e.g. 1,2,3', + condition: { field: 'operation', value: 'azure_devops_get_work_items_batch' }, + }, + { + id: 'workItemType', + title: 'Work Item Type', + type: 'dropdown', + required: true, + options: [ + { label: 'Issue', id: 'Issue' }, + { label: 'Task', id: 'Task' }, + { label: 'Epic', id: 'Epic' }, + ], + value: () => 'Issue', + condition: { field: 'operation', value: 'azure_devops_create_work_item' }, + }, + { + id: 'title', + title: 'Title', + type: 'short-input', + required: { field: 'operation', value: 'azure_devops_create_work_item' }, + condition: { + field: 'operation', + value: ['azure_devops_create_work_item', 'azure_devops_update_work_item'], + }, + }, + { + id: 'description', + title: 'Description', + type: 'long-input', + condition: { + field: 'operation', + value: ['azure_devops_create_work_item', 'azure_devops_update_work_item'], + }, + }, + { + id: 'assignedTo', + title: 'Assigned To', + type: 'short-input', + placeholder: 'Email or display name', + condition: { + field: 'operation', + value: ['azure_devops_create_work_item', 'azure_devops_update_work_item'], + }, + mode: 'advanced', + }, + { + id: 'priority', + title: 'Priority', + type: 'dropdown', + options: [ + { label: '1 - Critical', id: '1' }, + { label: '2 - High', id: '2' }, + { label: '3 - Medium', id: '3' }, + { label: '4 - Low', id: '4' }, + ], + condition: { + field: 'operation', + value: ['azure_devops_create_work_item', 'azure_devops_update_work_item'], + }, + mode: 'advanced', + }, + { + id: 'effort', + title: 'Effort', + type: 'short-input', + placeholder: 'Numeric effort (Issue only)', + condition: { + field: 'operation', + value: 'azure_devops_create_work_item', + and: { field: 'workItemType', value: 'Issue' }, + }, + mode: 'advanced', + }, + { + id: 'effort', + title: 'Effort', + type: 'short-input', + placeholder: 'Numeric effort (Issue only)', + condition: { field: 'operation', value: 'azure_devops_update_work_item' }, + mode: 'advanced', + }, + { + id: 'startDate', + title: 'Start Date', + type: 'short-input', + placeholder: 'YYYY-MM-DD (Epic only)', + condition: { + field: 'operation', + value: 'azure_devops_create_work_item', + and: { field: 'workItemType', value: 'Epic' }, + }, + mode: 'advanced', + }, + { + id: 'startDate', + title: 'Start Date', + type: 'short-input', + placeholder: 'YYYY-MM-DD (Epic only)', + condition: { field: 'operation', value: 'azure_devops_update_work_item' }, + mode: 'advanced', + }, + { + id: 'targetDate', + title: 'Target Date', + type: 'short-input', + placeholder: 'YYYY-MM-DD (Epic only)', + condition: { + field: 'operation', + value: 'azure_devops_create_work_item', + and: { field: 'workItemType', value: 'Epic' }, + }, + mode: 'advanced', + }, + { + id: 'targetDate', + title: 'Target Date', + type: 'short-input', + placeholder: 'YYYY-MM-DD (Epic only)', + condition: { field: 'operation', value: 'azure_devops_update_work_item' }, + mode: 'advanced', + }, + { + id: 'activity', + title: 'Activity', + type: 'dropdown', + options: [ + { label: 'Deployment', id: 'Deployment' }, + { label: 'Design', id: 'Design' }, + { label: 'Development', id: 'Development' }, + { label: 'Documentation', id: 'Documentation' }, + { label: 'Requirements', id: 'Requirements' }, + { label: 'Testing', id: 'Testing' }, + ], + condition: { + field: 'operation', + value: 'azure_devops_create_work_item', + and: { field: 'workItemType', value: 'Task' }, + }, + mode: 'advanced', + }, + { + id: 'activity', + title: 'Activity', + type: 'dropdown', + options: [ + { label: 'Deployment', id: 'Deployment' }, + { label: 'Design', id: 'Design' }, + { label: 'Development', id: 'Development' }, + { label: 'Documentation', id: 'Documentation' }, + { label: 'Requirements', id: 'Requirements' }, + { label: 'Testing', id: 'Testing' }, + ], + condition: { field: 'operation', value: 'azure_devops_update_work_item' }, + mode: 'advanced', + }, + { + id: 'remainingWork', + title: 'Remaining Work', + type: 'short-input', + placeholder: 'Hours (Task only)', + condition: { + field: 'operation', + value: 'azure_devops_create_work_item', + and: { field: 'workItemType', value: 'Task' }, + }, + mode: 'advanced', + }, + { + id: 'remainingWork', + title: 'Remaining Work', + type: 'short-input', + placeholder: 'Hours (Task only)', + condition: { field: 'operation', value: 'azure_devops_update_work_item' }, + mode: 'advanced', + }, + { + id: 'completedWork', + title: 'Completed Work', + type: 'short-input', + placeholder: 'Hours (Task only)', + condition: { + field: 'operation', + value: 'azure_devops_create_work_item', + and: { field: 'workItemType', value: 'Task' }, + }, + mode: 'advanced', + }, + { + id: 'completedWork', + title: 'Completed Work', + type: 'short-input', + placeholder: 'Hours (Task only)', + condition: { field: 'operation', value: 'azure_devops_update_work_item' }, + mode: 'advanced', + }, + { + id: 'areaPath', + title: 'Area Path', + type: 'short-input', + placeholder: 'e.g. MyProject\\Team', + condition: { + field: 'operation', + value: ['azure_devops_create_work_item', 'azure_devops_update_work_item'], + }, + mode: 'advanced', + }, + { + id: 'iterationPath', + title: 'Iteration Path', + type: 'short-input', + placeholder: 'e.g. MyProject\\Sprint 1', + condition: { field: 'operation', value: 'azure_devops_create_work_item' }, + mode: 'advanced', + }, + { + id: 'tags', + title: 'Tags', + type: 'short-input', + placeholder: 'Semicolon-separated, e.g. issue; p1; auth', + condition: { + field: 'operation', + value: ['azure_devops_create_work_item', 'azure_devops_update_work_item'], + }, + mode: 'advanced', + }, + { + id: 'state', + title: 'State', + type: 'dropdown', + options: AZURE_DEVOPS_BASIC_WORK_ITEM_STATES.map((state) => ({ + label: state, + id: state, + })), + condition: { field: 'operation', value: 'azure_devops_update_work_item' }, + }, + { + id: 'commentText', + title: 'Comment', + type: 'long-input', + required: true, + condition: { field: 'operation', value: 'azure_devops_add_comment' }, + }, + ...getTrigger('azure_devops_build_failed').subBlocks, + ...getTrigger('azure_devops_work_item_created').subBlocks, + ...getTrigger('azure_devops_webhook').subBlocks, + ], + + tools: { + access: [ + 'azure_devops_list_pipelines', + 'azure_devops_get_pipeline', + 'azure_devops_list_pipeline_runs', + 'azure_devops_get_pipeline_run', + 'azure_devops_list_builds', + 'azure_devops_list_build_logs', + 'azure_devops_get_build_log', + 'azure_devops_get_build_timeline', + 'azure_devops_get_work_items_between_builds', + 'azure_devops_query_work_items', + 'azure_devops_get_work_item', + 'azure_devops_get_work_items_batch', + 'azure_devops_create_work_item', + 'azure_devops_update_work_item', + 'azure_devops_add_comment', + 'azure_devops_get_comments', + ], + config: { + tool: (params) => params.operation as string, + params: (params) => { + const base = { + accessToken: params.accessToken as string, + organization: params.organization as string, + project: params.project as string, + } + switch (params.operation) { + case 'azure_devops_list_pipelines': + return base + case 'azure_devops_get_pipeline': + return { ...base, pipelineId: Number(params.pipelineId) } + case 'azure_devops_list_pipeline_runs': + return { ...base, pipelineId: Number(params.pipelineId) } + case 'azure_devops_get_pipeline_run': + return { ...base, pipelineId: Number(params.pipelineId), runId: Number(params.runId) } + case 'azure_devops_list_builds': + return { + ...base, + resultFilter: (params.resultFilter as string) || undefined, + top: params.top ? Number(params.top) : undefined, + } + case 'azure_devops_list_build_logs': + return { ...base, buildId: Number(params.buildId) } + case 'azure_devops_get_build_log': + return { ...base, buildId: Number(params.buildId), logId: Number(params.logId) } + case 'azure_devops_get_build_timeline': + return { ...base, buildId: Number(params.buildId) } + case 'azure_devops_get_work_items_between_builds': + return { + ...base, + fromBuildId: Number(params.fromBuildId), + toBuildId: Number(params.toBuildId), + } + case 'azure_devops_query_work_items': + return { ...base, wiqlQuery: params.wiqlQuery as string } + case 'azure_devops_get_work_item': + return { ...base, workItemId: Number(params.workItemId) } + case 'azure_devops_get_work_items_batch': + return { ...base, ids: params.workItemIds as string } + case 'azure_devops_create_work_item': + return { + ...base, + workItemType: params.workItemType as AzureDevOpsBasicWorkItemType, + title: params.title as string, + description: (params.description as string) || undefined, + assignedTo: (params.assignedTo as string) || undefined, + priority: params.priority ? Number(params.priority) : undefined, + effort: params.effort ? Number(params.effort) : undefined, + startDate: normalizeDate(params.startDate), + targetDate: normalizeDate(params.targetDate), + activity: (params.activity as string) || undefined, + remainingWork: params.remainingWork ? Number(params.remainingWork) : undefined, + completedWork: params.completedWork ? Number(params.completedWork) : undefined, + areaPath: (params.areaPath as string) || undefined, + iterationPath: (params.iterationPath as string) || undefined, + tags: (params.tags as string) || undefined, + } + case 'azure_devops_update_work_item': + return { + ...base, + workItemId: Number(params.workItemId), + title: (params.title as string) || undefined, + state: (params.state as string) || undefined, + assignedTo: (params.assignedTo as string) || undefined, + priority: params.priority ? Number(params.priority) : undefined, + effort: params.effort ? Number(params.effort) : undefined, + startDate: normalizeDate(params.startDate), + targetDate: normalizeDate(params.targetDate), + activity: (params.activity as string) || undefined, + remainingWork: params.remainingWork ? Number(params.remainingWork) : undefined, + completedWork: params.completedWork ? Number(params.completedWork) : undefined, + description: (params.description as string) || undefined, + areaPath: (params.areaPath as string) || undefined, + tags: (params.tags as string) || undefined, + } + case 'azure_devops_add_comment': + return { + ...base, + workItemId: Number(params.workItemId), + text: params.commentText as string, + } + case 'azure_devops_get_comments': + return { ...base, workItemId: Number(params.workItemId) } + default: + return base + } + }, + }, + }, + + inputs: { + operation: { type: 'string', description: 'Operation to perform' }, + accessToken: { type: 'string', description: 'Azure DevOps Personal Access Token' }, + organization: { type: 'string', description: 'Azure DevOps organization name' }, + project: { type: 'string', description: 'Azure DevOps project name' }, + pipelineId: { type: 'number', description: 'Pipeline ID' }, + runId: { type: 'number', description: 'Pipeline run ID' }, + resultFilter: { type: 'string', description: 'Build result filter' }, + top: { type: 'number', description: 'Maximum number of results' }, + buildId: { type: 'number', description: 'Build ID' }, + logId: { type: 'number', description: 'Build log ID' }, + fromBuildId: { type: 'number', description: 'Starting build ID for work item range' }, + toBuildId: { type: 'number', description: 'Ending build ID for work item range' }, + wiqlQuery: { type: 'string', description: 'WIQL query string' }, + workItemId: { type: 'number', description: 'Work item ID' }, + workItemIds: { type: 'string', description: 'Comma-separated work item IDs' }, + workItemType: { type: 'string', description: 'Basic work item type (Issue, Task, Epic)' }, + title: { type: 'string', description: 'Work item title' }, + description: { type: 'string', description: 'Work item description (HTML supported)' }, + assignedTo: { type: 'string', description: 'Assignee email or display name' }, + priority: { type: 'number', description: 'Work item priority (1–4)' }, + effort: { + type: 'number', + description: 'Work item effort (Microsoft.VSTS.Scheduling.Effort); Basic process: Issue only', + }, + startDate: { + type: 'string', + description: 'Start date (Microsoft.VSTS.Scheduling.StartDate); Basic process: Epic only', + }, + targetDate: { + type: 'string', + description: 'Target date (Microsoft.VSTS.Scheduling.TargetDate); Basic process: Epic only', + }, + activity: { + type: 'string', + description: 'Activity (Microsoft.VSTS.Common.Activity); Basic process: Task only', + }, + remainingWork: { + type: 'number', + description: + 'Remaining work hours (Microsoft.VSTS.Scheduling.RemainingWork); Basic process: Task only', + }, + completedWork: { + type: 'number', + description: + 'Completed work hours (Microsoft.VSTS.Scheduling.CompletedWork); Basic process: Task only', + }, + areaPath: { type: 'string', description: 'Area path' }, + iterationPath: { type: 'string', description: 'Iteration path' }, + tags: { type: 'string', description: 'Semicolon-separated tags' }, + state: { + type: 'string', + description: 'Basic-process work item state (To Do, Doing, Done)', + }, + commentText: { type: 'string', description: 'Comment text' }, + }, + + outputs: { + content: { type: 'string', description: 'Human-readable response from Azure DevOps' }, + metadata: { type: 'json', description: 'Structured Azure DevOps response data' }, + }, + + triggers: { + enabled: true, + available: [ + 'azure_devops_build_failed', + 'azure_devops_work_item_created', + 'azure_devops_webhook', + ], + }, +} diff --git a/apps/sim/blocks/registry.ts b/apps/sim/blocks/registry.ts index 2e008d00edb..64c0e44859e 100644 --- a/apps/sim/blocks/registry.ts +++ b/apps/sim/blocks/registry.ts @@ -17,6 +17,7 @@ import { AsanaBlock } from '@/blocks/blocks/asana' import { AshbyBlock } from '@/blocks/blocks/ashby' import { AthenaBlock } from '@/blocks/blocks/athena' import { AttioBlock } from '@/blocks/blocks/attio' +import { AzureDevOpsBlock } from '@/blocks/blocks/azure_devops' import { BoxBlock } from '@/blocks/blocks/box' import { BrandfetchBlock } from '@/blocks/blocks/brandfetch' import { BrightDataBlock } from '@/blocks/blocks/brightdata' @@ -258,6 +259,7 @@ export const registry: Record = { ashby: AshbyBlock, athena: AthenaBlock, attio: AttioBlock, + azure_devops: AzureDevOpsBlock, box: BoxBlock, brandfetch: BrandfetchBlock, brightdata: BrightDataBlock, diff --git a/apps/sim/components/icons.tsx b/apps/sim/components/icons.tsx index 6508cb8bbb5..ab234725964 100644 --- a/apps/sim/components/icons.tsx +++ b/apps/sim/components/icons.tsx @@ -3126,6 +3126,36 @@ export function AzureIcon(props: SVGProps) { ) } +export function AzureDevOpsIcon(props: SVGProps) { + const id = useId() + const gradientId = `azure_devops_gradient_${id}` + return ( + + + + + + + + + + + + + ) +} + export const GroqIcon = (props: SVGProps) => ( { + const triggerId = providerConfig.triggerId as string | undefined + const b = body as Record + + if (triggerId && triggerId !== 'azure_devops_webhook') { + const { isAzureDevOpsEventMatch } = await import('@/triggers/azure_devops/utils') + if (!isAzureDevOpsEventMatch(triggerId, b)) { + logger.debug( + `[${requestId}] Azure DevOps event mismatch for trigger ${triggerId}. Skipping execution.`, + { + webhookId: webhook.id, + workflowId: workflow.id, + triggerId, + eventType: b.eventType, + } + ) + return false + } + } + + return true + }, + + extractIdempotencyId(body: unknown): string | null { + const obj = body as Record | null + if (!obj) return null + const subscriptionId = + typeof obj.subscriptionId === 'string' && obj.subscriptionId ? obj.subscriptionId : null + const notificationId = + typeof obj.notificationId === 'number' || typeof obj.notificationId === 'string' + ? String(obj.notificationId) + : null + if (!subscriptionId || !notificationId) return null + return `azure_devops:${subscriptionId}:${notificationId}` + }, + + async formatInput({ body, webhook, requestId }: FormatInputContext): Promise { + const b = body as Record + const providerConfig = (webhook.providerConfig as Record) || {} + const triggerId = providerConfig.triggerId as string | undefined + const eventType = b.eventType as string | undefined + + if (triggerId === 'azure_devops_webhook') { + return { input: formatWebhookEnvelopeInput(b) } + } + + if (eventType === AZURE_DEVOPS_BUILD_FAILED_EVENT) { + return { input: formatBuildCompleteInput(b) } + } + + if (eventType === AZURE_DEVOPS_WORK_ITEM_CREATED_EVENT) { + return { input: formatWorkItemCreatedInput(b) } + } + + logger.warn(`[${requestId}] Azure DevOps: unknown eventType for specialized trigger`, { + triggerId, + eventType, + }) + return { + input: null, + skip: { + message: `Unsupported Azure DevOps event type "${eventType ?? 'unknown'}" for trigger ${triggerId ?? 'unknown'}`, + }, + } + }, +} diff --git a/apps/sim/lib/webhooks/providers/registry.ts b/apps/sim/lib/webhooks/providers/registry.ts index ce8e9c6af73..5c4d8eaea73 100644 --- a/apps/sim/lib/webhooks/providers/registry.ts +++ b/apps/sim/lib/webhooks/providers/registry.ts @@ -3,6 +3,7 @@ import { NextResponse } from 'next/server' import { airtableHandler } from '@/lib/webhooks/providers/airtable' import { ashbyHandler } from '@/lib/webhooks/providers/ashby' import { attioHandler } from '@/lib/webhooks/providers/attio' +import { azureDevOpsHandler } from '@/lib/webhooks/providers/azure-devops' import { calcomHandler } from '@/lib/webhooks/providers/calcom' import { calendlyHandler } from '@/lib/webhooks/providers/calendly' import { circlebackHandler } from '@/lib/webhooks/providers/circleback' @@ -52,6 +53,7 @@ const PROVIDER_HANDLERS: Record = { airtable: airtableHandler, ashby: ashbyHandler, attio: attioHandler, + azure_devops: azureDevOpsHandler, calendly: calendlyHandler, calcom: calcomHandler, circleback: circlebackHandler, diff --git a/apps/sim/tools/azure_devops/add_comment.ts b/apps/sim/tools/azure_devops/add_comment.ts new file mode 100644 index 00000000000..36630ffeeaf --- /dev/null +++ b/apps/sim/tools/azure_devops/add_comment.ts @@ -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 = { + 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. "

My comment

")', + }, + 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' }, + }, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/azure_devops/azure-devops.test.ts b/apps/sim/tools/azure_devops/azure-devops.test.ts new file mode 100644 index 00000000000..104232b18a5 --- /dev/null +++ b/apps/sim/tools/azure_devops/azure-devops.test.ts @@ -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(tool: ToolConfig, params: P): string { + return typeof tool.request.url === 'function' ? tool.request.url(params) : tool.request.url +} + +function buildHeaders(tool: ToolConfig, params: P): Record { + return tool.request.headers(params) +} + +function buildBody(tool: ToolConfig, 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: '

SimIntegrationTest comment

', + 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: '

Failure details

', + 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: '

Failure details

' }, + { 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: '

SimIntegrationTest comment

', + 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() + }) +}) diff --git a/apps/sim/tools/azure_devops/create_work_item.ts b/apps/sim/tools/azure_devops/create_work_item.ts new file mode 100644 index 00000000000..8ba561930ca --- /dev/null +++ b/apps/sim/tools/azure_devops/create_work_item.ts @@ -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 = { + 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' }, + }, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/azure_devops/get_build_log.ts b/apps/sim/tools/azure_devops/get_build_log.ts new file mode 100644 index 00000000000..300082db80f --- /dev/null +++ b/apps/sim/tools/azure_devops/get_build_log.ts @@ -0,0 +1,101 @@ +import type { GetBuildLogParams, GetBuildLogResponse } from '@/tools/azure_devops/types' +import type { ToolConfig } from '@/tools/types' + +export const getBuildLogTool: ToolConfig = { + 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' }, + }, + }, + }, +} diff --git a/apps/sim/tools/azure_devops/get_build_timeline.ts b/apps/sim/tools/azure_devops/get_build_timeline.ts new file mode 100644 index 00000000000..fbb10bdbaa7 --- /dev/null +++ b/apps/sim/tools/azure_devops/get_build_timeline.ts @@ -0,0 +1,164 @@ +import type { + AzureDevOpsBuildTimelineRecord, + GetBuildTimelineParams, + GetBuildTimelineResponse, +} from '@/tools/azure_devops/types' +import type { ToolConfig } from '@/tools/types' + +export const getBuildTimelineTool: ToolConfig = { + 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' }, + }, + }, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/azure_devops/get_comments.ts b/apps/sim/tools/azure_devops/get_comments.ts new file mode 100644 index 00000000000..0b41036ec46 --- /dev/null +++ b/apps/sim/tools/azure_devops/get_comments.ts @@ -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 = { + 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' }, + }, + }, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/azure_devops/get_pipeline.ts b/apps/sim/tools/azure_devops/get_pipeline.ts new file mode 100644 index 00000000000..c08af037ecd --- /dev/null +++ b/apps/sim/tools/azure_devops/get_pipeline.ts @@ -0,0 +1,167 @@ +import type { GetPipelineParams, GetPipelineResponse } from '@/tools/azure_devops/types' +import type { ToolConfig } from '@/tools/types' + +export const getPipelineTool: ToolConfig = { + 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 } +} diff --git a/apps/sim/tools/azure_devops/get_pipeline_run.ts b/apps/sim/tools/azure_devops/get_pipeline_run.ts new file mode 100644 index 00000000000..e17959e3459 --- /dev/null +++ b/apps/sim/tools/azure_devops/get_pipeline_run.ts @@ -0,0 +1,157 @@ +import type { GetPipelineRunParams, GetPipelineRunResponse } from '@/tools/azure_devops/types' +import type { ToolConfig } from '@/tools/types' + +export const getPipelineRunTool: ToolConfig = { + 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 + } +} diff --git a/apps/sim/tools/azure_devops/get_work_item.ts b/apps/sim/tools/azure_devops/get_work_item.ts new file mode 100644 index 00000000000..f1731cd444d --- /dev/null +++ b/apps/sim/tools/azure_devops/get_work_item.ts @@ -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 = { + 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' }, + }, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/azure_devops/get_work_items_batch.ts b/apps/sim/tools/azure_devops/get_work_items_batch.ts new file mode 100644 index 00000000000..3b5200d2af3 --- /dev/null +++ b/apps/sim/tools/azure_devops/get_work_items_batch.ts @@ -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 = + { + 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' }, + }, + }, + }, + }, + }, + }, + } diff --git a/apps/sim/tools/azure_devops/get_work_items_between_builds.ts b/apps/sim/tools/azure_devops/get_work_items_between_builds.ts new file mode 100644 index 00000000000..17df94db64b --- /dev/null +++ b/apps/sim/tools/azure_devops/get_work_items_between_builds.ts @@ -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 +} diff --git a/apps/sim/tools/azure_devops/index.ts b/apps/sim/tools/azure_devops/index.ts new file mode 100644 index 00000000000..d3866848403 --- /dev/null +++ b/apps/sim/tools/azure_devops/index.ts @@ -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, +} diff --git a/apps/sim/tools/azure_devops/list_build_logs.ts b/apps/sim/tools/azure_devops/list_build_logs.ts new file mode 100644 index 00000000000..1f8183d02c1 --- /dev/null +++ b/apps/sim/tools/azure_devops/list_build_logs.ts @@ -0,0 +1,134 @@ +import type { ListBuildLogsParams, ListBuildLogsResponse } from '@/tools/azure_devops/types' +import type { ToolConfig } from '@/tools/types' + +export const listBuildLogsTool: ToolConfig = { + 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 +} diff --git a/apps/sim/tools/azure_devops/list_builds.ts b/apps/sim/tools/azure_devops/list_builds.ts new file mode 100644 index 00000000000..dfdc087ed21 --- /dev/null +++ b/apps/sim/tools/azure_devops/list_builds.ts @@ -0,0 +1,203 @@ +import type { ListBuildsParams, ListBuildsResponse } from '@/tools/azure_devops/types' +import type { ToolConfig } from '@/tools/types' + +export const listBuildsTool: ToolConfig = { + 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 } } +} diff --git a/apps/sim/tools/azure_devops/list_pipeline_runs.ts b/apps/sim/tools/azure_devops/list_pipeline_runs.ts new file mode 100644 index 00000000000..ce84c5eb822 --- /dev/null +++ b/apps/sim/tools/azure_devops/list_pipeline_runs.ts @@ -0,0 +1,149 @@ +import type { ListPipelineRunsParams, ListPipelineRunsResponse } from '@/tools/azure_devops/types' +import type { ToolConfig } from '@/tools/types' + +export const listPipelineRunsTool: ToolConfig = { + 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 } } +} diff --git a/apps/sim/tools/azure_devops/list_pipelines.ts b/apps/sim/tools/azure_devops/list_pipelines.ts new file mode 100644 index 00000000000..913aebfda9f --- /dev/null +++ b/apps/sim/tools/azure_devops/list_pipelines.ts @@ -0,0 +1,133 @@ +import type { ListPipelinesParams, ListPipelinesResponse } from '@/tools/azure_devops/types' +import type { ToolConfig } from '@/tools/types' + +export const listPipelinesTool: ToolConfig = { + 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 +} diff --git a/apps/sim/tools/azure_devops/query_work_items.ts b/apps/sim/tools/azure_devops/query_work_items.ts new file mode 100644 index 00000000000..f57c6c901da --- /dev/null +++ b/apps/sim/tools/azure_devops/query_work_items.ts @@ -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 = { + 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' }, + }, + }, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/azure_devops/types.ts b/apps/sim/tools/azure_devops/types.ts new file mode 100644 index 00000000000..048409985ff --- /dev/null +++ b/apps/sim/tools/azure_devops/types.ts @@ -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 + } + } +} diff --git a/apps/sim/tools/azure_devops/update_work_item.ts b/apps/sim/tools/azure_devops/update_work_item.ts new file mode 100644 index 00000000000..e5e83c70a26 --- /dev/null +++ b/apps/sim/tools/azure_devops/update_work_item.ts @@ -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 = { + 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' }, + }, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/azure_devops/utils.ts b/apps/sim/tools/azure_devops/utils.ts new file mode 100644 index 00000000000..b0955498554 --- /dev/null +++ b/apps/sim/tools/azure_devops/utils.ts @@ -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 +} + +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 +} diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index b65d6bda699..4ad97b0cdfa 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -241,6 +241,24 @@ import { attioUpdateTaskTool, attioUpdateWebhookTool, } from '@/tools/attio' +import { + addCommentTool as azureDevopsAddCommentTool, + createWorkItemTool as azureDevopsCreateWorkItemTool, + getBuildLogTool as azureDevopsGetBuildLogTool, + getBuildTimelineTool as azureDevopsGetBuildTimelineTool, + getCommentsTool as azureDevopsGetCommentsTool, + getPipelineRunTool as azureDevopsGetPipelineRunTool, + getPipelineTool as azureDevopsGetPipelineTool, + getWorkItemsBatchTool as azureDevopsGetWorkItemsBatchTool, + getWorkItemsBetweenBuildsTool as azureDevopsGetWorkItemsBetweenBuildsTool, + getWorkItemTool as azureDevopsGetWorkItemTool, + listBuildLogsTool as azureDevopsListBuildLogsTool, + listBuildsTool as azureDevopsListBuildsTool, + listPipelineRunsTool as azureDevopsListPipelineRunsTool, + listPipelinesTool as azureDevopsListPipelinesTool, + queryWorkItemsTool as azureDevopsQueryWorkItemsTool, + updateWorkItemTool as azureDevopsUpdateWorkItemTool, +} from '@/tools/azure_devops' import { boxCopyFileTool, boxCreateFolderTool, @@ -3215,6 +3233,22 @@ export const tools: Record = { brightdata_serp_search: brightDataSerpSearchTool, brightdata_snapshot_status: brightDataSnapshotStatusTool, brightdata_sync_scrape: brightDataSyncScrapeTool, + azure_devops_list_pipelines: azureDevopsListPipelinesTool, + azure_devops_get_pipeline: azureDevopsGetPipelineTool, + azure_devops_list_pipeline_runs: azureDevopsListPipelineRunsTool, + azure_devops_get_pipeline_run: azureDevopsGetPipelineRunTool, + azure_devops_list_builds: azureDevopsListBuildsTool, + azure_devops_list_build_logs: azureDevopsListBuildLogsTool, + azure_devops_get_build_log: azureDevopsGetBuildLogTool, + azure_devops_get_build_timeline: azureDevopsGetBuildTimelineTool, + azure_devops_get_work_items_between_builds: azureDevopsGetWorkItemsBetweenBuildsTool, + azure_devops_query_work_items: azureDevopsQueryWorkItemsTool, + azure_devops_get_work_item: azureDevopsGetWorkItemTool, + azure_devops_get_work_items_batch: azureDevopsGetWorkItemsBatchTool, + azure_devops_create_work_item: azureDevopsCreateWorkItemTool, + azure_devops_update_work_item: azureDevopsUpdateWorkItemTool, + azure_devops_add_comment: azureDevopsAddCommentTool, + azure_devops_get_comments: azureDevopsGetCommentsTool, box_copy_file: boxCopyFileTool, box_create_folder: boxCreateFolderTool, box_delete_file: boxDeleteFileTool, diff --git a/apps/sim/triggers/azure_devops/build_failed.ts b/apps/sim/triggers/azure_devops/build_failed.ts new file mode 100644 index 00000000000..f43619215f8 --- /dev/null +++ b/apps/sim/triggers/azure_devops/build_failed.ts @@ -0,0 +1,34 @@ +import { AzureDevOpsIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + azureDevOpsTriggerOptions, + buildBuildFailedOutputs, + buildFailedSetupInstructions, +} from '@/triggers/azure_devops/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const azureDevOpsBuildFailedTrigger: TriggerConfig = { + id: 'azure_devops_build_failed', + name: 'Azure DevOps Build Failed', + provider: 'azure_devops', + description: + 'Trigger workflow when an Azure DevOps build fails, is canceled, or partially succeeds', + version: '1.0.0', + icon: AzureDevOpsIcon, + + subBlocks: buildTriggerSubBlocks({ + triggerId: 'azure_devops_build_failed', + triggerOptions: azureDevOpsTriggerOptions, + includeDropdown: true, + setupInstructions: buildFailedSetupInstructions, + }), + + outputs: buildBuildFailedOutputs(), + + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }, +} diff --git a/apps/sim/triggers/azure_devops/index.ts b/apps/sim/triggers/azure_devops/index.ts new file mode 100644 index 00000000000..5ef419ea36d --- /dev/null +++ b/apps/sim/triggers/azure_devops/index.ts @@ -0,0 +1,3 @@ +export { azureDevOpsBuildFailedTrigger } from './build_failed' +export { azureDevOpsWebhookTrigger } from './webhook' +export { azureDevOpsWorkItemCreatedTrigger } from './work_item_created' diff --git a/apps/sim/triggers/azure_devops/utils.ts b/apps/sim/triggers/azure_devops/utils.ts new file mode 100644 index 00000000000..5300126dd57 --- /dev/null +++ b/apps/sim/triggers/azure_devops/utils.ts @@ -0,0 +1,283 @@ +import type { TriggerOutput } from '@/triggers/types' + +export const azureDevOpsTriggerOptions = [ + { label: 'Build Failed', id: 'azure_devops_build_failed' }, + { label: 'Work Item Created', id: 'azure_devops_work_item_created' }, + { label: 'All Service Hook Events', id: 'azure_devops_webhook' }, +] + +export const AZURE_DEVOPS_BUILD_FAILED_EVENT = 'build.complete' +export const AZURE_DEVOPS_WORK_ITEM_CREATED_EVENT = 'workitem.created' + +function instructions(steps: string[]): string { + return steps.map((s, i) => `
${i + 1}. ${s}
`).join('') +} + +export const buildFailedSetupInstructions = instructions([ + 'Open your Azure DevOps project and go to Project settings → Service hooks.', + 'Click + Create subscription, choose Web Hooks, then Next.', + 'For Trigger on this type of event, select Build completed.', + 'Under Filters, set Build result to Failed (optionally add Canceled / Partially succeeded).', + 'Click Next, paste the Webhook URL above into the URL field.', + 'Leave other fields as defaults. Click Test to verify, then Finish.', +]) + +export const workItemCreatedSetupInstructions = instructions([ + 'Open your Azure DevOps project and go to Project settings → Service hooks.', + 'Click + Create subscription, choose Web Hooks, then Next.', + 'For Trigger on this type of event, select Work item created.', + 'Click Next, paste the Webhook URL above into the URL field.', + 'Leave other fields as defaults. Click Test to verify, then Finish.', +]) + +export const webhookSetupInstructions = instructions([ + 'Open your Azure DevOps project and go to Project settings → Service hooks.', + 'Click + Create subscription, choose Web Hooks, then Next.', + 'Select whichever event types you want this URL to receive (build, work item, release, etc.).', + 'Click Next, paste the Webhook URL above into the URL field.', + 'Leave other fields as defaults. Click Test to verify, then Finish.', + 'Sim does not filter deliveries for this trigger — configure event types in Azure DevOps.', +]) + +/** + * Returns whether an Azure DevOps service hook payload matches the configured trigger. + */ +export function isAzureDevOpsEventMatch(triggerId: string, body: Record): boolean { + if (triggerId === 'azure_devops_webhook') { + return true + } + + const eventType = body.eventType as string | undefined + + if (triggerId === 'azure_devops_build_failed') { + if (eventType !== AZURE_DEVOPS_BUILD_FAILED_EVENT) { + return false + } + const resource = body.resource as Record | undefined + const result = (resource?.result as string | undefined)?.toLowerCase() + return ( + result === 'failed' || + result === 'canceled' || + result === 'cancelled' || + result === 'stopped' || + result === 'partiallysucceeded' + ) + } + + if (triggerId === 'azure_devops_work_item_created') { + return eventType === AZURE_DEVOPS_WORK_ITEM_CREATED_EVENT + } + + return false +} + +export function buildBuildFailedOutputs(): Record { + return { + buildId: { + type: 'number', + description: 'Build ID', + }, + buildNumber: { + type: 'string', + description: 'Build number string (e.g. 20240101.1)', + }, + result: { + type: 'string', + description: 'Build result: failed | canceled | partiallySucceeded', + }, + pipelineId: { + type: 'number', + description: 'Pipeline definition ID', + }, + pipelineName: { + type: 'string', + description: 'Pipeline definition name', + }, + projectName: { + type: 'string', + description: 'Azure DevOps project name', + }, + branch: { + type: 'string', + description: 'Source branch name (refs/heads/ prefix stripped)', + }, + commitSha: { + type: 'string', + description: 'Source commit SHA', + }, + triggeredBy: { + type: 'string', + description: 'Display name of the person who triggered the build', + }, + triggeredByEmail: { + type: 'string', + description: 'Email/unique name of the person who triggered the build, or null if not set', + }, + startTime: { + type: 'string', + description: 'Build start time (ISO 8601)', + }, + finishTime: { + type: 'string', + description: 'Build finish time (ISO 8601)', + }, + buildUrl: { + type: 'string', + description: 'API URL for the build resource', + }, + } +} + +export function buildWorkItemCreatedOutputs(): Record { + return { + workItemId: { + type: 'number', + description: 'Work item ID', + }, + workItemType: { + type: 'string', + description: 'Work item type for Basic process (e.g. Issue, Task, Epic)', + }, + title: { + type: 'string', + description: 'Work item title', + }, + state: { + type: 'string', + description: 'Work item state for Basic process (e.g. To Do, Doing, Done)', + }, + createdBy: { + type: 'string', + description: 'Display name of the creator, or null if not set', + }, + assignedTo: { + type: 'string', + description: 'Assignee display name, or null if unassigned', + }, + priority: { + type: 'number', + description: 'Priority (1–4), or 0 if not set', + }, + areaPath: { + type: 'string', + description: 'Area path', + }, + iterationPath: { + type: 'string', + description: 'Iteration path', + }, + description: { + type: 'string', + description: 'Work item description (HTML), or null if not set', + }, + projectName: { + type: 'string', + description: 'Azure DevOps project name', + }, + workItemUrl: { + type: 'string', + description: 'API URL for the work item resource', + }, + } +} + +export function buildWebhookOutputs(): Record { + return { + eventType: { + type: 'string', + description: 'Service hook event type (e.g. build.complete, workitem.created)', + }, + notificationId: { + type: 'number', + description: 'Notification ID', + }, + subscriptionId: { + type: 'string', + description: 'Service hook subscription ID', + }, + publisherId: { + type: 'string', + description: 'Publisher ID (e.g. tfs)', + }, + createdDate: { + type: 'string', + description: 'Event creation time (ISO 8601)', + }, + resource: { + type: 'json', + description: 'Event resource payload', + }, + resourceContainers: { + type: 'json', + description: 'Resource container references (project, collection, etc.)', + }, + message: { + type: 'json', + description: 'Short message object', + }, + detailedMessage: { + type: 'json', + description: 'Detailed message object', + }, + } +} + +export function formatBuildCompleteInput(body: Record): Record { + const resource = (body.resource ?? {}) as Record + const definition = (resource.definition ?? {}) as Record + const project = (resource.project ?? {}) as Record + const requestedFor = (resource.requestedFor ?? {}) as Record + const sourceBranch = (resource.sourceBranch as string) ?? '' + + return { + buildId: Number(resource.id ?? 0), + buildNumber: (resource.buildNumber as string) ?? '', + result: (resource.result as string) ?? '', + pipelineId: Number(definition.id ?? 0), + pipelineName: (definition.name as string) ?? '', + projectName: (project.name as string) ?? '', + branch: sourceBranch.replace(/^refs\/heads\//, ''), + commitSha: (resource.sourceVersion as string) ?? '', + triggeredBy: (requestedFor.displayName as string) ?? null, + triggeredByEmail: (requestedFor.uniqueName as string) ?? null, + startTime: (resource.startTime as string) ?? '', + finishTime: (resource.finishTime as string) ?? '', + buildUrl: (resource.url as string) ?? '', + } +} + +export function formatWorkItemCreatedInput(body: Record): Record { + const resource = (body.resource ?? {}) as Record + const fields = (resource.fields ?? {}) as Record + + return { + workItemId: Number(resource.id ?? 0), + workItemType: (fields['System.WorkItemType'] as string) ?? '', + title: (fields['System.Title'] as string) ?? '', + state: (fields['System.State'] as string) ?? '', + createdBy: + (fields['System.CreatedBy'] as { displayName?: string } | undefined)?.displayName ?? null, + assignedTo: + (fields['System.AssignedTo'] as { displayName?: string } | undefined)?.displayName ?? null, + priority: Number(fields['Microsoft.VSTS.Common.Priority'] ?? 0), + areaPath: (fields['System.AreaPath'] as string) ?? '', + iterationPath: (fields['System.IterationPath'] as string) ?? '', + description: (fields['System.Description'] as string) ?? null, + projectName: (fields['System.TeamProject'] as string) ?? '', + workItemUrl: (resource.url as string) ?? '', + } +} + +export function formatWebhookEnvelopeInput(body: Record): Record { + return { + eventType: (body.eventType as string) ?? '', + notificationId: Number(body.notificationId ?? 0), + subscriptionId: (body.subscriptionId as string) ?? '', + publisherId: (body.publisherId as string) ?? '', + createdDate: (body.createdDate as string) ?? '', + resource: body.resource ?? null, + resourceContainers: body.resourceContainers ?? null, + message: body.message ?? null, + detailedMessage: body.detailedMessage ?? null, + } +} diff --git a/apps/sim/triggers/azure_devops/webhook.ts b/apps/sim/triggers/azure_devops/webhook.ts new file mode 100644 index 00000000000..fda04424090 --- /dev/null +++ b/apps/sim/triggers/azure_devops/webhook.ts @@ -0,0 +1,37 @@ +import { AzureDevOpsIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + azureDevOpsTriggerOptions, + buildWebhookOutputs, + webhookSetupInstructions, +} from '@/triggers/azure_devops/utils' +import type { TriggerConfig } from '@/triggers/types' + +/** + * Azure DevOps generic webhook trigger. + * Event filtering is determined by which events you enable on the service hook subscription. + */ +export const azureDevOpsWebhookTrigger: TriggerConfig = { + id: 'azure_devops_webhook', + name: 'Azure DevOps Webhook (All Service Hook Events)', + provider: 'azure_devops', + description: + 'Trigger on whichever service hook event types you configure in Azure DevOps. Sim does not filter deliveries for this trigger.', + version: '1.0.0', + icon: AzureDevOpsIcon, + + subBlocks: buildTriggerSubBlocks({ + triggerId: 'azure_devops_webhook', + triggerOptions: azureDevOpsTriggerOptions, + setupInstructions: webhookSetupInstructions, + }), + + outputs: buildWebhookOutputs(), + + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }, +} diff --git a/apps/sim/triggers/azure_devops/work_item_created.ts b/apps/sim/triggers/azure_devops/work_item_created.ts new file mode 100644 index 00000000000..289ddf46768 --- /dev/null +++ b/apps/sim/triggers/azure_devops/work_item_created.ts @@ -0,0 +1,32 @@ +import { AzureDevOpsIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + azureDevOpsTriggerOptions, + buildWorkItemCreatedOutputs, + workItemCreatedSetupInstructions, +} from '@/triggers/azure_devops/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const azureDevOpsWorkItemCreatedTrigger: TriggerConfig = { + id: 'azure_devops_work_item_created', + name: 'Azure DevOps Work Item Created', + provider: 'azure_devops', + description: 'Trigger workflow when a work item is created in Azure DevOps', + version: '1.0.0', + icon: AzureDevOpsIcon, + + subBlocks: buildTriggerSubBlocks({ + triggerId: 'azure_devops_work_item_created', + triggerOptions: azureDevOpsTriggerOptions, + setupInstructions: workItemCreatedSetupInstructions, + }), + + outputs: buildWorkItemCreatedOutputs(), + + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }, +} diff --git a/apps/sim/triggers/registry.ts b/apps/sim/triggers/registry.ts index b1d747f529f..bb4d252d751 100644 --- a/apps/sim/triggers/registry.ts +++ b/apps/sim/triggers/registry.ts @@ -31,6 +31,11 @@ import { attioWebhookTrigger, attioWorkspaceMemberCreatedTrigger, } from '@/triggers/attio' +import { + azureDevOpsBuildFailedTrigger, + azureDevOpsWebhookTrigger, + azureDevOpsWorkItemCreatedTrigger, +} from '@/triggers/azure_devops' import { calcomBookingCancelledTrigger, calcomBookingCreatedTrigger, @@ -340,6 +345,9 @@ export const TRIGGER_REGISTRY: TriggerRegistry = { ashby_candidate_delete: ashbyCandidateDeleteTrigger, ashby_job_create: ashbyJobCreateTrigger, ashby_offer_create: ashbyOfferCreateTrigger, + azure_devops_build_failed: azureDevOpsBuildFailedTrigger, + azure_devops_webhook: azureDevOpsWebhookTrigger, + azure_devops_work_item_created: azureDevOpsWorkItemCreatedTrigger, attio_webhook: attioWebhookTrigger, attio_record_created: attioRecordCreatedTrigger, attio_record_updated: attioRecordUpdatedTrigger,