Bulk work items with a plan file

Creating a feature and its eight stories one command at a time means eight confirmation prompts, eight chances to typo a parent id, and no way to review the shape of the whole thing before it exists. devops-utils azdo apply takes one declarative plan file — YAML or JSON — and applies the entire batch after a single review-and-confirm.

devops-utils azdo apply plan.yml             # preview, confirm once, apply
devops-utils azdo apply plan.yml --dry-run   # preview only, change nothing
devops-utils azdo apply plan.yml --yes --out results.json
cat plan.yml | devops-utils azdo apply -     # read the plan from stdin

The shape of a plan

Three top-level keys: a project, an optional defaults map merged into every item, and an ordered list of items.

project: MyProject
defaults:
  area_path: MyProject\Platform
  fields:
    Custom.Source: git-history
items:
  - ref: feat                    # a local handle other items can point at
    type: Feature
    title: Payments succeed even when the acquirer is flaky
    tags: [payments, backend]
  - type: User Story
    title: As a shopper my payment goes through after a transient error
    parent: ref:feat             # parented under the Feature created above
    assigned_to: dev@example.com

An item without id is a create (needs type and title); an item with id is an update. ref:<name> points at an item defined earlier in the same run — that’s what makes a hierarchy expressible in one file.

Named keys cover the common fields — type, title, description, state, assigned_to, tags, area_path, iteration_path, parent, links, comments — and anything else goes under fields: by reference name. Unknown top-level keys are a warning in the preview, not an error, so a typo is visible without blocking the run.

Worked example: a feature with its stories

Features and User Stories describe value delivered to the end user — what they can now do and why it matters. The technical work that delivers it (backoff, alerting) goes in Tasks under the story.

project: MyProject
defaults:
  area_path: MyProject\Payments
  iteration_path: MyProject\Sprint 4
  tags: [payments]
items:
  - ref: feat
    type: Feature
    title: Payments succeed even when the acquirer is flaky
    description: >-
      <p>Shoppers no longer see failed orders when the payment acquirer
      has a brief outage, so fewer checkouts are abandoned.</p>
    fields:
      Microsoft.VSTS.Scheduling.StartDate: 2026-08-03
      Microsoft.VSTS.Scheduling.TargetDate: 2026-09-30

  - ref: retry
    type: User Story
    title: As a shopper my payment goes through after a transient error
    parent: ref:feat
    assigned_to: dev@example.com
    fields:
      Microsoft.VSTS.Scheduling.StoryPoints: 5
      Microsoft.VSTS.Common.AcceptanceCriteria: >-
        <p>A payment hit by a transient acquirer error completes without
        the shopper retrying.</p>

  - type: Task
    title: Add exponential backoff to the capture client
    parent: ref:retry
    links:
      - {kind: commit, repo: MyRepo, value: 0a1b2c3d}
      - {kind: hyperlink, value: "https://wiki/retries"}
    comments:
      - "Implemented in the capture client; see linked commit."

  - type: Task
    title: Alert when the retry budget is exhausted
    parent: ref:retry
devops-utils azdo apply plan.yml --dry-run

The preview lists all four operations with defaults already merged in. Drop --dry-run, answer the single Apply all 4 operation(s)? prompt, and the tree exists.

Worked example: a bulk triage sweep

Updates need nothing but an id:

project: MyProject
items:
  - id: 41
    state: Closed
    comments: ["Superseded by #1500."]
  - id: 42
    state: Active
    assigned_to: dev@example.com
    tags: [triaged, backend]
  - id: 43
    iteration_path: MyProject\Sprint 5
    fields: {Custom.RiskLevel: Low}

Generating that file from a query is a two-liner:

devops-utils azdo list --project MyProject --tag obsolete \
  | jq '{project: "MyProject", items: [.[] | {id: .id, state: "Closed"}]}' \
  | devops-utils azdo apply -

Reading the results

Per-item results are printed as JSON on stdout (and written to --out if you ask), one entry per item:

[
  {"ref": "feat", "action": "create", "status": "created", "id": 1501, "url": "https://…"},
  {"ref": "retry", "action": "create", "status": "created", "id": 1502, "url": "https://…"},
  {"ref": null, "action": "update", "status": "failed", "id": 43,
   "error": "AzureDevOpsError: TF401320: Rule Error…"}
]

status is created, updated, failed, or skipped. The exit code is non-zero if anything failed, so CI notices.

By default a failure does not stop the run — the remaining independent items still apply, and only items whose ref: target failed fail with a message naming the cause. Pass --stop-on-error to halt at the first failure instead; everything after it reports as skipped.

Notes that save time

  • state on a create is applied as a follow-up patch, because most process templates refuse to create an item directly in a closed state. Writing state: Closed on a new item works.

  • All of an item’s links go out in a single request, so a partially linked item isn’t a thing.

  • Field values keep their YAML type. A number in the plan stays a number — unlike the CLI’s --field NAME=VALUE, which is always a string. If a numeric or boolean write is being rejected on the command line, a plan is the fix.

  • Order matters only for ref: — a reference must point backwards.

The same thing from an agent

azdo_apply_plan exposes this as an MCP tool and as a Python callable, taking the plan as a dict:

from devops_utils.agent.tools import azdo_apply_plan

results = azdo_apply_plan(
    {
        "project": "MyProject",
        "items": [{"type": "Task", "title": "Fix flaky test"}],
    }
)

Over MCP it is gated by the same human confirmation as every other write tool — one elicitation covering the whole batch. This is the intended path for an agent that has just mined a backlog out of git history and wants to push it: build one plan, review it once. The bundled git-history-workitems skill does exactly that.