Azure DevOps from the command line

devops-utils azdo is a task-shaped interface to Azure DevOps work items, builds, repositories and pull requests. It talks plain REST, works against Services (cloud) and Server (on-prem) alike, and prints JSON and nothing but JSON on stdout — so every command below composes with jq.

Needs the azure extra and the environment variables from Install and configure.

Tip

Every example works unchanged with uvx --from "devops-utils[azure]" devops-utils azdo … if you’d rather not install anything.

For the full parameter-by-parameter reference — link kinds, field reference names, WIQL semantics — see Azure DevOps work-item tools. This page is the cookbook.

Two rules worth knowing first

Writes ask before they write. create, update, comment, tag, link, unlink, attach, build-tag, pr-comment and apply print a preview of the pending change and prompt for confirmation. Add --yes to skip the prompt in a script, or --dry-run to see the preview and stop.

stdout is data, stderr is conversation. Previews, prompts and warnings go to stderr, so devops-utils azdo get 42 --full | jq .fields works even for commands that prompted you. Output is UTF-8 whatever the shell’s code page, so accented text needs no chcp or PYTHONUTF8 preamble on Windows.

Finding work

What’s on my plate?

--mine uses the WIQL @Me macro — the server resolves the identity behind your token, so nothing has to be configured:

devops-utils azdo list --project MyProject --mine --state Active

The team’s open bugs, newest 20

devops-utils azdo list --project MyProject \
  --type Bug --state New --state Active --top 20

Repeatable filters (--state, --type, --tag) are OR within a flag and AND across flags; tags AND with each other.

Everything in the current sprint, for one team

devops-utils azdo list --project MyProject \
  --area-path 'MyProject\Payments' \
  --iteration-path 'MyProject\Sprint 3'

Both paths match the node and everything under it (WIQL UNDER), so MyProject\Payments also picks up MyProject\Payments\Fraud.

Walk an Epic’s backlog

devops-utils azdo list --project MyProject --parent 1400          # direct children
devops-utils azdo list --project MyProject --parent 1400 --state New

--parent returns direct children only — walk a level at a time to map a tree.

Search by words in the title or description

devops-utils azdo search --project MyProject "login timeout"
devops-utils azdo search --project MyProject "retry" --type Bug --tag payments

search accepts the same filters as list, so it narrows rather than replaces them.

Read one item in full

list and search return a trimmed shape on purpose — find the id there, then:

devops-utils azdo get 42                # id, title, type, state, assignee, tags
devops-utils azdo get 42 --relations    # + parent/child/commit/PR links
devops-utils azdo get 42 --full         # + every raw field: description, dates, Custom.*

--full is the only way to read back custom fields and scheduling dates. It composes with --relations.

Changing work

File a bug under the right Epic

devops-utils azdo create --project MyProject \
  --type Bug --title "Checkout times out at 30s" \
  --parent 1400 \
  --area-path 'MyProject\Payments' \
  --assigned-to dev@example.com \
  --tag payments --tag regression

Move an item along

devops-utils azdo update 42 --state Resolved --assigned-to qa@example.com
devops-utils azdo update 42 --iteration-path 'MyProject\Sprint 4'   # push to next sprint

State names come from your process template — Closed, Done and Resolved are all real answers depending on the project.

Leave a note, add tags

devops-utils azdo comment 42 "Reproduced on staging; capture pool is exhausted."
devops-utils azdo tag 42 backend urgent              # merged with existing tags
devops-utils azdo tag 42 triaged --mode replace      # replaces them

Connect the item to the code

devops-utils azdo link 42 --kind commit --project MyProject --repo MyRepo --value 0a1b2c3d
devops-utils azdo link 42 --kind pull_request --project MyProject --repo MyRepo --value 77
devops-utils azdo link 42 --kind hyperlink --value "https://wiki/retries"
devops-utils azdo link 42 --kind parent --value 1400

Re-parenting is an unlink followed by a link:

devops-utils azdo unlink 42 --kind parent --value 1400
devops-utils azdo link   42 --kind parent --value 1500

Attach evidence

devops-utils azdo attach 42 ./trace.log --comment "Timeout trace from staging"

Set a date or a custom field

There is no named flag for dates or estimates — they’re ordinary fields, set by reference name:

devops-utils azdo update 1400 \
  --field Microsoft.VSTS.Scheduling.StartDate=2026-08-03 \
  --field Microsoft.VSTS.Scheduling.TargetDate=2026-09-30 \
  --field Custom.RiskLevel=High

Confirm it landed with azdo get 1400 --full — create/update return the trimmed shape, which omits Microsoft.VSTS.*.

Do it to fifty items at once

One plan file, one confirmation — see Bulk work items with a plan file.

Diagnosing a failed pipeline

The usual descent: find the run, read its timeline, tail the log of whatever is red.

# 1. Which pipelines exist?
devops-utils azdo definitions --project MyProject --name 'CI*'

# 2. What failed on main lately?
devops-utils azdo builds --project MyProject --branch main --result failed --top 5

# 3. Where in the run did it break? (stages/jobs/tasks + error issues + log ids)
devops-utils azdo timeline 1234 --project MyProject

# 4. Which logs are worth reading, and how long are they?
devops-utils azdo logs 1234 --project MyProject

# 5. Tail the interesting one instead of downloading 40k lines
devops-utils azdo log 1234 7 --project MyProject --start-line 800

Then tie the finding to a work item, and annotate the run itself:

devops-utils azdo link 42 --kind build --value 1234    # no project/repo needed
devops-utils azdo build-tag 1234 flaky --project MyProject

Builds have no comments — tags are the annotation mechanism.

Watch a running pipeline

devops-utils azdo builds --project MyProject --status inProgress
devops-utils azdo build 1234 --project MyProject | jq '{status, result, branch}'

Searching repositories

Three tiers, cheapest first:

# Repository names
devops-utils azdo repos --project MyProject --name api

# File paths — Git Items API glob, works everywhere including on-prem
devops-utils azdo files --project MyProject --repo MyRepo --pattern '*.yml'
devops-utils azdo files --project MyProject --repo MyRepo --pattern 'src/**/*.py' --branch develop

# File contents — needs the Search extension
devops-utils azdo code-search "connection pool" --project MyProject --repo MyRepo

code-search is always available on cloud; on-prem needs the Search extension installed. When it isn’t, files is the portable fallback.

Pull requests

devops-utils azdo pr-comment 77 "Nit: this retry should be capped." \
  --project MyProject --repo MyRepo

# reply into an existing thread
devops-utils azdo pr-comment 77 "Agreed, capped at 3." \
  --project MyProject --repo MyRepo --thread 5

Commits cannot be commented on — Azure DevOps exposes no REST endpoint for it. Comment on the PR that contains the commit, or on the work item that links it.

Scripting recipes

Because stdout is clean JSON:

# Ids of my active bugs
devops-utils azdo list --project MyProject --mine --type Bug --state Active \
  | jq -r '.[].id'

# Close everything tagged 'obsolete', unattended
devops-utils azdo list --project MyProject --tag obsolete | jq -r '.[].id' \
  | xargs -I{} devops-utils azdo update {} --state Closed --yes

# Capture the new id when creating
NEW=$(devops-utils azdo create --project MyProject --type Task \
        --title "Fix flaky test" --yes | jq -r .id)

# CSV of the sprint
devops-utils azdo list --project MyProject --iteration-path 'MyProject\Sprint 3' \
  | jq -r '.[] | [.id, .type, .state, .title] | @csv'

Without jq (PowerShell, cmd)

-o id prints bare ids and --select PATH keeps only a /-separated path (dotted field names need no quoting), so the common recipes need no jq:

# Ids of my active bugs
devops-utils azdo list --project MyProject --mine --type Bug --state Active -o id

# Close everything tagged 'obsolete', unattended
devops-utils azdo list --project MyProject --tag obsolete -o id |
  ForEach-Object { devops-utils azdo update $_ --state Closed --yes }

# Capture the new id when creating
$new = devops-utils azdo create --project MyProject --type Task `
         --title "Fix flaky test" --yes -o id

# One field, unquoted
devops-utils azdo get 42 --full --select fields/Microsoft.VSTS.Scheduling.TargetDate -o raw

# Anything richer: parse the JSON
devops-utils azdo list --project MyProject --iteration-path 'MyProject\Sprint 3' |
  ConvertFrom-Json | Export-Csv sprint.csv -NoTypeInformation

In Windows PowerShell 5.1, run [Console]::OutputEncoding = [Text.UTF8Encoding]::new() before piping into ConvertFrom-Json, or non-ASCII titles arrive garbled (PowerShell 7 needs nothing).

For a fully unattended run (CI), either pass --yes per command or export DEVOPS_UTILS_SKIP_CONFIRMATION=1 once. Prefer --yes: it keeps the blast radius to the command you meant.

The same operations elsewhere

Every command here exists as an MCP tool (azdo_*, served by devops-utils-mcp) and as a plain Python callable in devops_utils.agent.tools, all reading the same environment variables:

from devops_utils.agent import tools

for item in tools.azdo_list_work_items(
    "MyProject", states=["Active"], assigned_to="@Me"
):
    print(item["id"], item["title"])

See Wire it into an AI agent for wiring those into Claude Code or another agent.