# Azure DevOps work-item tools A limited, LLM-friendly interface to Azure DevOps work items, working against both **Services (cloud)** and **Server (on-prem)**. Requires the `azure` extra (`httpx`). ## Configuration (no machine credentials) Credentials are never read from the machine (no `az` CLI, credential files, or Windows credential store). Everything comes from environment variables — set directly, or loaded from the first existing env file of `$DEVOPS_UTILS_ENV_FILE`, `./.env.devops-utils`, `~/.devops-utils.env` (real environment variables win; see the install guide): | Variable | Required | Notes | | --- | --- | --- | | `AZURE_DEVOPS_ORG_URL` | yes | Cloud `https://dev.azure.com/{org}` or on-prem `https://server/tfs/{collection}` | | `AZURE_DEVOPS_TOKEN` | yes | Bearer token or PAT | | `AZURE_DEVOPS_AUTH_SCHEME` | no | `bearer` (default) sends `Authorization: Bearer`; `pat` sends `Authorization: Basic base64(":"+token)` | | `AZURE_DEVOPS_API_VERSION` | no | Default `7.1`; lower it for older on-prem servers | | `DEVOPS_UTILS_SKIP_CONFIRMATION` | no | Truthy (`1`/`true`/`yes`/`on`) bypasses the MCP write confirmation and the CLI's confirmation prompt (see *Human-in-the-loop*) | ## Human-in-the-loop (MCP writes) On the **MCP server**, all ten write tools (`azdo_create_work_item`, `azdo_comment_work_item`, `azdo_set_work_item_tags`, `azdo_update_work_item`, `azdo_add_work_item_link`, `azdo_remove_work_item_link`, `azdo_add_work_item_attachment`, `azdo_tag_build`, `azdo_comment_pull_request`, `azdo_apply_plan`) require human approval via MCP **elicitation** before mutating Azure DevOps — the tool describes the pending change and applies it only on `accept`; `decline`/`cancel` returns a `cancelled` status and writes nothing. When the client can't prompt (elicitation unsupported / non-interactive), the write is **blocked** unless `DEVOPS_UTILS_SKIP_CONFIRMATION` is truthy, which allows unattended automation. Read tools are not gated. Implemented in `src/devops_utils/mcp/server.py` (`_confirm_write` + `WRITE_TOOLS`). ## Human-in-the-loop (CLI writes) The **CLI**'s write commands (`create`, `comment`, `tag`, `update`, `link`, `unlink`, `build-tag`, `pr-comment`, `attach`, `apply`) print a preview of the pending change and prompt before calling Azure DevOps — `apply` (bulk plan) previews every operation in the batch and asks a single `Apply all N operation(s)?` confirmation. Pass `--yes`/`-y` to skip the prompt, or `--dry-run` to print the preview and exit without applying anything. `DEVOPS_UTILS_SKIP_CONFIRMATION` also skips the prompt, for scripted use. Implemented in `src/devops_utils/cli/commands/azdo.py` (`_confirm_or_dry_run`) and shared with the MCP server via `src/devops_utils/core/confirmation.py`. The preview, the `(dry run — not applied)` marker, and the prompt all go to **stderr**, so stdout carries nothing but the command's JSON result and `azdo create … --yes -o id` (or `| jq .id`) works. With `--dry-run`, or when the prompt is declined, stdout is empty and the exit code is 0. ## Surfaces The same operations are exposed three ways, all reading the env vars above: - **CLI**: `devops-utils azdo {repos,files,code-search,list,search,get,create,update,comment,tag,link,unlink,attach,apply,definitions,builds,build,timeline,logs,log,build-tag,pr-comment}` - **MCP tools**: `azdo_*` (run `devops-utils-mcp`) - **Agent callables**: `devops_utils.agent.tools.azdo_*` Core logic lives in `src/devops_utils/core/azure_devops/` (`client.py`, `workitems.py`, `repos.py`, `builds.py`, `pullrequests.py`, `bulk.py`) and is surface-agnostic. Nothing has to be installed to use the CLI or the MCP server — with `uv` present, `uvx` resolves the package and its extra per invocation: `uvx --from "devops-utils[azure]" devops-utils azdo …` and `uvx --from "devops-utils[mcp]" devops-utils-mcp`. The extra is mandatory (`[azure]` for `azdo`, `[mcp]` for the server, `[all]` for everything); `uv tool install "devops-utils[all]"` puts the bare commands on `PATH`. ## References (`link` / `azdo_add_work_item_link`) One entry point covers every reference kind: | kind | needs | `value` | relation | | --- | --- | --- | --- | | `commit` | project + repo | commit SHA | `ArtifactLink` `vstfs:///Git/Commit/...` | | `pull_request` | project + repo | PR id | `ArtifactLink` `vstfs:///Git/PullRequestId/...` | | `branch` | project + repo | branch name | `ArtifactLink` `vstfs:///Git/Ref/...GB{branch}` | | `build` | — | build id | `ArtifactLink` `vstfs:///Build/Build/{id}` | | `work_item` | — | target work-item id | `System.LinkTypes.Related` | | `parent` | — | target work-item id | `System.LinkTypes.Hierarchy-Reverse` | | `child` | — | target work-item id | `System.LinkTypes.Hierarchy-Forward` | | `predecessor` | — | target work-item id | `System.LinkTypes.Dependency-Reverse` | | `successor` | — | target work-item id | `System.LinkTypes.Dependency-Forward` | | `hyperlink` | — | raw URL | `Hyperlink` | `unlink` (`azdo unlink` / `azdo_remove_work_item_link`) removes a reference using the same kind/value pairs — e.g. re-parenting is `unlink --kind parent --value ` followed by `link --kind parent --value `. The relation is located by rel type + URL and removed by index with a guarding JSON-Patch `test` op. ## Hierarchy (parent/child) - **Create under a parent**: `azdo create --parent ` / `azdo_create_work_item(..., parent=)` links the new item under an existing one (e.g. a Task under a User Story) in the same API call. - **Read relations**: `azdo get --relations` / `azdo_get_work_item(id, relations=True)` returns a `relations` list of `{kind, target, ...}` dicts — work-item kinds (`parent`, `child`, `predecessor`, `successor`, `work_item`) carry the target work-item id; `hyperlink`/`attachment`/artifact kinds carry the URL. - **Read every field**: `azdo get --full` / `azdo_get_work_item(id, full=True)` adds `rev` and a `fields` map of the raw work item, keyed by reference name — descriptions (`System.Description`, HTML), scheduling dates (`Microsoft.VSTS.Scheduling.*`), and `Custom.*` fields all live there. Composes with `--relations`. `list`/`search` stay trimmed: find ids there, then `--full` a single item. `update` (`azdo update` / `azdo_update_work_item`) changes `System.State`, `System.AssignedTo`, `System.Title`, `System.Description`, `System.AreaPath`, and/or `System.IterationPath` on an existing item — state names are process-template-specific (`Closed`/`Done`/`Resolved`). ## Area path / iteration path `create`, `update`, `list`, and `search` all take `--area-path` / `--iteration-path` (`area_path` / `iteration_path` on the `azdo_*` tools). On `create`/`update` they set `System.AreaPath`/`System.IterationPath` directly. On `list`/`search` they *filter*, using WIQL `UNDER` — matching the given node and everything nested under it in the Azure Boards area/iteration tree, e.g. `--area-path 'MyProject\Team A'` also matches `MyProject\Team A\Sub-team`. ## Custom fields `create` and `update` (CLI and `azdo_*` tools) also take arbitrary process- or org-specific fields via repeatable `--field NAME=VALUE` (CLI) or a `fields` dict (`azdo_create_work_item`/`azdo_update_work_item`), e.g. `--field Custom.RiskLevel=High` or `fields={"Custom.RiskLevel": "High"}`. Use the field's **reference name**, not its display label — reference names are process/org specific, so check the target project's process configuration. `fields` is applied after the named args, so a key that targets e.g. `System.AreaPath` wins over `--area-path`. Read them back with `azdo get --full` — custom fields used to be write-only, which made "did that patch land?" unanswerable without hitting REST by hand. The CLI's `--field NAME=VALUE` always yields a **string**; the `fields` dict on the `azdo_*` tools preserves real JSON types, which is the path to use when a numeric/boolean write is rejected. ## Scheduling fields (start / target / due date, effort) There is no named parameter for dates or estimates — they are ordinary fields, set through `fields` / `--field` by reference name: `Microsoft.VSTS.Scheduling.StartDate`, `.TargetDate` (the pair Delivery Plans render, normally on Epic/Feature), `.DueDate`, `.FinishDate`, `.Effort`, `.StoryPoints`, `.OriginalEstimate`, `.RemainingWork`, `.CompletedWork`, plus `Microsoft.VSTS.Common.Priority`/`.Severity`/`.BusinessValue`. Values are ISO 8601 (`2026-08-03` or `2026-08-03T00:00:00Z`, stored UTC). ```bash devops-utils azdo update 1400 \ --field Microsoft.VSTS.Scheduling.StartDate=2026-08-03 \ --field Microsoft.VSTS.Scheduling.TargetDate=2026-09-30 ``` Which fields a type has is process-template-specific; an absent one fails the patch with an unknown-field error. `create`/`update` return the trimmed shape, which drops every `Microsoft.VSTS.*` key — confirm with `azdo get --full`. ## Work-item research filters `list`/`search` (and their `azdo_*` counterparts) filter by state, type, assignee, area/iteration path, and **tags** (repeatable `--tag`, AND semantics). `--mine` / `assigned_to="@Me"` uses the WIQL `@Me` macro — the server resolves the identity behind the token, so "assigned to me" needs no configured email. "Pending" work is the non-closed states of the process template (e.g. `--state New --state Active`). ## Repo search (three tiers) 1. **Repo names**: `azdo repos [--project P] [--name SUBSTR]` / `azdo_list_repositories(project, name_filter=...)`. 2. **File paths**: `azdo files --project P --repo R --pattern '*.yml'` / `azdo_find_repo_files` — Git Items API glob, works on cloud and on-prem. 3. **Code content**: `azdo code-search "TEXT" --project P [--repo R]` / `azdo_code_search` — Search extension (cloud always; the almsearch host is derived automatically). On-prem servers without it get a clear error; fall back to tier 2. ## Builds - **Definitions**: `azdo definitions --project P [--name 'CI*']` / `azdo_list_build_definitions` lists pipelines as `{id, name, path, type, queue_status, web_url}`. - **Query**: `azdo builds --project P [--definition ID] [--branch B] [--status S] [--result R] [--top N]` / `azdo_list_builds(project, ...)` lists builds (newest first) as trimmed `{id, number, definition, status, result, branch, requested_for, queue_time, finish_time, web_url}` dicts; `azdo build --project P` / `azdo_get_build` fetches one. Short branch names are expanded to `refs/heads/...`. - **Timeline**: `azdo timeline --project P` / `azdo_get_build_timeline` returns the run's stages/jobs/tasks with `state`, `result`, error/warning `issues`, and each step's `log_id` — the first stop when diagnosing a failed run. - **Logs**: `azdo logs --project P` / `azdo_list_build_logs` lists `{id, line_count}`; `azdo log --project P [--start-line N] [--end-line N]` / `azdo_get_build_log` prints content. Tail big logs (`start_line = line_count - 200`) instead of fetching whole. - **Link to a work item**: `azdo link --kind build --value ` — no `project`/`repo` needed (the artifact URI carries only the build id). - **Tag**: `azdo build-tag TAG [TAG ...] --project P` / `azdo_tag_build` adds tags (builds have no comments; tags are the annotation) and returns the resulting tag list. ## Pull-request comments `azdo pr-comment "TEXT" --project P --repo R [--thread N]` / `azdo_comment_pull_request` posts a new (active) comment thread on a PR, or a reply when a thread id is given. Returns `{thread_id, comment_id, status}`. **Commits cannot be commented on** — Azure DevOps has no documented REST endpoint for commit comments (the web UI uses an internal API). Comment on the PR containing the commit, or on the work item that links it. Comments use the `System.History` field and search uses WIQL `CONTAINS`, both for maximum on-prem compatibility (no preview-only endpoints or separate Search host).