Integrations

Azure DevOps CLI: Command-Line Productivity

Boost productivity with the Azure DevOps CLI for work items, pipelines, PRs, and automated development workflows

Azure DevOps CLI: Command-Line Productivity

The Azure DevOps CLI extension transforms how you interact with Azure DevOps by bringing work items, pipelines, pull requests, and repository management directly into your terminal. Instead of clicking through the web UI for routine tasks, you can script entire workflows, chain commands together, and automate repetitive operations in seconds. If you spend any meaningful amount of time in Azure DevOps, the CLI pays for itself on day one.

Prerequisites

  • Azure CLI installed (version 2.30.0 or later)
  • An Azure DevOps organization and project
  • A Personal Access Token (PAT) or Azure AD authentication configured
  • Node.js v14 or later (for the Node.js integration examples)
  • Basic familiarity with bash scripting and command-line tools
  • jq installed for JSON processing (optional but highly recommended)

Installing the Azure DevOps CLI Extension

The Azure DevOps CLI is not part of the base Azure CLI installation. It ships as an extension that you install separately:

# Install the Azure CLI (if not already installed)
curl -sL https://aka.ms/InstallAzureCLIDeb | sudo bash

# Install the Azure DevOps extension
az extension add --name azure-devops

# Verify the installation
az devops --help

If you already have an older version installed, upgrade it:

az extension update --name azure-devops

One thing I see developers trip over is version conflicts. If az extension add fails, remove the old version first with az extension remove --name azure-devops and then reinstall. Clean installs resolve most extension issues.

Authentication and Configuration

Before running any commands, you need to authenticate and set default configuration values. There are two main authentication paths.

Personal Access Token (PAT)

The simplest approach is a PAT. Generate one in Azure DevOps under User Settings > Personal Access Tokens with the scopes you need (Full access works for development, but scope it down for CI/CD):

# Login with a PAT (interactive prompt)
az devops login

# Or pass it via environment variable
export AZURE_DEVOPS_EXT_PAT=your-pat-token-here

Azure AD Authentication

If your organization uses Azure AD, you can authenticate through the standard Azure CLI login:

az login

Setting Defaults

Setting defaults eliminates the need to pass --org and --project on every single command. This is critical for productivity:

# Set your default organization and project
az devops configure --defaults \
  organization=https://dev.azure.com/your-org \
  project=your-project

# Verify your defaults
az devops configure --list

I always set these in my shell profile so every new terminal session is ready to go. Add this to your .bashrc or .zshrc:

export AZURE_DEVOPS_EXT_PAT="your-pat-here"
az devops configure --defaults \
  organization=https://dev.azure.com/your-org \
  project=your-project 2>/dev/null

Work Item Commands

Work items are the bread and butter of Azure DevOps project management. The CLI makes it fast to create, update, and query them without leaving your editor.

Creating Work Items

# Create a user story
az boards work-item create \
  --type "User Story" \
  --title "Implement user authentication API" \
  --assigned-to "[email protected]" \
  --area "MyProject\Backend" \
  --iteration "MyProject\Sprint 42"

# Create a bug with description
az boards work-item create \
  --type "Bug" \
  --title "Login endpoint returns 500 on expired tokens" \
  --description "When a user submits an expired JWT, the /api/login endpoint throws an unhandled exception instead of returning 401." \
  --assigned-to "[email protected]" \
  --fields "Microsoft.VSTS.Common.Priority=1" "Microsoft.VSTS.Common.Severity=2 - High"

# Create a task linked to a parent story
az boards work-item create \
  --type "Task" \
  --title "Add token expiration check middleware" \
  --assigned-to "[email protected]" \
  --fields "System.Parent=12345"

Updating Work Items

# Update the state of a work item
az boards work-item update \
  --id 12345 \
  --state "Active"

# Update multiple fields at once
az boards work-item update \
  --id 12345 \
  --fields "[email protected]" \
    "Microsoft.VSTS.Scheduling.RemainingWork=4" \
    "System.State=Active"

# Add a comment to a work item
az boards work-item update \
  --id 12345 \
  --discussion "Completed initial implementation. PR #789 is ready for review."

Querying Work Items

WIQL (Work Item Query Language) is how you search for work items from the CLI. It is SQL-like and quite powerful:

# Query work items assigned to you
az boards query \
  --wiql "SELECT [System.Id], [System.Title], [System.State] \
          FROM WorkItems \
          WHERE [System.AssignedTo] = @Me \
          AND [System.State] <> 'Closed' \
          ORDER BY [System.ChangedDate] DESC"

# Show details for a specific work item
az boards work-item show --id 12345

# Show only specific fields
az boards work-item show --id 12345 \
  --fields "System.Title" "System.State" "System.AssignedTo"

You can also run saved queries by ID, which is useful when your team has standardized queries in the web UI:

az boards query --id "your-saved-query-guid"

Pipeline Commands

Managing pipelines from the CLI is where real time savings happen, especially when you are iterating on pipeline configurations.

Listing and Running Pipelines

# List all pipelines in the project
az pipelines list --output table

# Show details for a specific pipeline
az pipelines show --id 42

# Run a pipeline
az pipelines run --id 42

# Run a pipeline on a specific branch with parameters
az pipelines run \
  --id 42 \
  --branch feature/auth-module \
  --parameters "environment=staging" "runTests=true"

# Run and wait for completion (useful in scripts)
az pipelines run --id 42 --branch main --open

Monitoring Builds

# List recent builds
az pipelines build list --top 10 --output table

# Show a specific build
az pipelines build show --id 5678

# List builds for a specific pipeline
az pipelines build list --definition-ids 42 --top 5 --output table

# Show build logs
az pipelines runs show --id 5678 --open

One pattern I use constantly is watching for a build to finish after triggering it:

# Trigger a build and capture the build ID
BUILD_ID=$(az pipelines run --id 42 --branch main --query "id" -o tsv)

# Poll until complete
while true; do
  STATUS=$(az pipelines build show --id $BUILD_ID --query "status" -o tsv)
  echo "Build $BUILD_ID: $STATUS"
  if [ "$STATUS" = "completed" ]; then
    RESULT=$(az pipelines build show --id $BUILD_ID --query "result" -o tsv)
    echo "Result: $RESULT"
    break
  fi
  sleep 30
done

Pull Request Commands

Pull requests are another area where the CLI shines. Creating a PR with the right reviewers, linked work items, and proper description from the command line is fast once you have the commands memorized.

Creating Pull Requests

# Create a basic PR
az repos pr create \
  --source-branch feature/auth-module \
  --target-branch main \
  --title "Add JWT authentication middleware" \
  --description "Implements token validation middleware with refresh token support."

# Create a PR with reviewers and work item links
az repos pr create \
  --source-branch feature/auth-module \
  --target-branch main \
  --title "Add JWT authentication middleware" \
  --description "Implements token validation middleware with refresh token support." \
  --reviewers "[email protected]" "[email protected]" \
  --work-items 12345 12346

# Create a draft PR
az repos pr create \
  --source-branch feature/auth-module \
  --target-branch main \
  --title "[DRAFT] Add JWT authentication middleware" \
  --draft true

Reviewing and Approving PRs

# List open PRs
az repos pr list --status active --output table

# List PRs assigned to you for review
az repos pr list --reviewer-id "[email protected]" --status active --output table

# Show PR details
az repos pr show --id 789

# Approve a PR
az repos pr set-vote --id 789 --vote approve

# Approve with message
az repos pr set-vote --id 789 --vote approve

# Request changes
az repos pr set-vote --id 789 --vote reject

# Complete (merge) a PR
az repos pr update --id 789 --status completed

# Complete with squash merge
az repos pr update --id 789 --status completed --squash true --delete-source-branch true

PR Policies and Comments

# List PR reviewers
az repos pr reviewer list --id 789

# Add a reviewer
az repos pr reviewer add --id 789 --reviewers "[email protected]"

# List PR comments/threads
az repos pr list --id 789 --output table

Repository Commands

# List repositories in the project
az repos list --output table

# Show repository details
az repos show --repository my-api

# Create a new repository
az repos create --name "new-microservice"

# Clone a repository (outputs the clone URL)
CLONE_URL=$(az repos show --repository my-api --query "remoteUrl" -o tsv)
git clone $CLONE_URL

# List branches
az repos ref list --repository my-api --filter heads/ --output table

# Delete a branch
az repos ref delete \
  --name "refs/heads/feature/old-branch" \
  --repository my-api \
  --object-id $(az repos ref list --repository my-api --filter heads/feature/old-branch --query "[0].objectId" -o tsv)

Project and Team Management

# List all projects in the organization
az devops project list --output table

# Show project details
az devops project show --project my-project

# List teams
az devops team list --output table

# Show team members
az devops team list-member --team "Backend Team"

# Create a new project
az devops project create \
  --name "new-project" \
  --description "Microservice for payment processing" \
  --source-control git \
  --process agile

Output Formatting

The Azure CLI supports multiple output formats, and choosing the right one for the task matters a lot.

Table Format

Best for human-readable output in the terminal:

az boards query \
  --wiql "SELECT [System.Id], [System.Title] FROM WorkItems WHERE [System.State] = 'Active'" \
  --output table

JSON Format

Best for scripting and piping to jq:

az boards work-item show --id 12345 --output json | jq '.fields["System.Title"]'

TSV Format

Best for piping to other commands and assigning to variables:

TITLE=$(az boards work-item show --id 12345 --query "fields.\"System.Title\"" -o tsv)
echo "Working on: $TITLE"

JMESPath Queries

The --query parameter uses JMESPath syntax to filter output before it reaches your terminal. This is invaluable:

# Get just the IDs of active work items
az boards query \
  --wiql "SELECT [System.Id] FROM WorkItems WHERE [System.State] = 'Active'" \
  --query "[].id" -o tsv

# Get pipeline names and IDs
az pipelines list --query "[].{Name:name, ID:id}" -o table

Scripting with the CLI

The real power of the CLI emerges when you combine commands into scripts. Here are patterns I use regularly.

Sprint Standup Report

#!/bin/bash
# standup-report.sh - Generate a standup report from Azure DevOps

echo "=== Standup Report - $(date +%Y-%m-%d) ==="
echo ""

echo "--- In Progress ---"
az boards query \
  --wiql "SELECT [System.Id], [System.Title], [System.WorkItemType] \
          FROM WorkItems \
          WHERE [System.AssignedTo] = @Me \
          AND [System.State] = 'Active' \
          ORDER BY [Microsoft.VSTS.Common.Priority] ASC" \
  --output table

echo ""
echo "--- Completed Yesterday ---"
az boards query \
  --wiql "SELECT [System.Id], [System.Title], [System.WorkItemType] \
          FROM WorkItems \
          WHERE [System.AssignedTo] = @Me \
          AND [System.State] = 'Closed' \
          AND [System.ChangedDate] >= @Today - 1 \
          ORDER BY [System.ChangedDate] DESC" \
  --output table

echo ""
echo "--- Open PRs ---"
az repos pr list \
  --creator "[email protected]" \
  --status active \
  --output table

Bulk Work Item Creation

#!/bin/bash
# create-tasks.sh - Create multiple tasks from a file

PARENT_ID=$1
TASK_FILE=$2

if [ -z "$PARENT_ID" ] || [ -z "$TASK_FILE" ]; then
  echo "Usage: ./create-tasks.sh <parent-work-item-id> <task-file.txt>"
  exit 1
fi

while IFS= read -r task; do
  [ -z "$task" ] && continue
  echo "Creating task: $task"
  az boards work-item create \
    --type "Task" \
    --title "$task" \
    --fields "System.Parent=$PARENT_ID" \
    --output table
done < "$TASK_FILE"

Creating Aliases for Common Workflows

Shell aliases and functions eliminate keystrokes for commands you run dozens of times a day:

# Add to .bashrc or .zshrc

# Quick work item lookup
alias wi='az boards work-item show --id'

# List my active items
alias mywork='az boards query --wiql "SELECT [System.Id],[System.Title],[System.State] FROM WorkItems WHERE [System.AssignedTo]=@Me AND [System.State]<>\"Closed\" ORDER BY [Microsoft.VSTS.Common.Priority] ASC" -o table'

# Quick PR creation from current branch
function newpr() {
  local branch=$(git branch --show-current)
  local target=${1:-main}
  local title=${2:-$branch}
  az repos pr create \
    --source-branch "$branch" \
    --target-branch "$target" \
    --title "$title" \
    --open
}

# Trigger a pipeline and watch it
function runpipe() {
  local pipeline_id=$1
  local branch=${2:-main}
  local build_id=$(az pipelines run --id "$pipeline_id" --branch "$branch" --query "id" -o tsv)
  echo "Started build $build_id"
  echo "Watching..."
  while true; do
    local status=$(az pipelines build show --id "$build_id" --query "status" -o tsv)
    if [ "$status" = "completed" ]; then
      local result=$(az pipelines build show --id "$build_id" --query "result" -o tsv)
      echo "Build $build_id completed: $result"
      return
    fi
    sleep 15
  done
}

# Move a work item to a new state
function move() {
  az boards work-item update --id "$1" --state "$2" --output table
}

Integrating CLI with Node.js child_process

For more complex automation, wrapping the CLI in Node.js gives you the full power of a programming language. This is the approach I use for our internal tooling:

var childProcess = require("child_process");
var util = require("util");

var exec = util.promisify(childProcess.exec);

/**
 * Execute an Azure DevOps CLI command and return parsed JSON
 */
function azDevOps(command) {
  var fullCommand = "az " + command + " -o json";

  return exec(fullCommand, { maxBuffer: 10 * 1024 * 1024 })
    .then(function (result) {
      if (result.stderr && result.stderr.trim()) {
        console.warn("CLI warning:", result.stderr.trim());
      }
      return JSON.parse(result.stdout);
    })
    .catch(function (err) {
      console.error("CLI error:", err.message);
      throw err;
    });
}

/**
 * Get all active work items assigned to a specific user
 */
function getActiveWorkItems(assignee) {
  var wiql =
    "SELECT [System.Id],[System.Title],[System.State],[System.WorkItemType] " +
    "FROM WorkItems " +
    "WHERE [System.AssignedTo]='" + assignee + "' " +
    "AND [System.State]<>'Closed' " +
    "ORDER BY [Microsoft.VSTS.Common.Priority] ASC";

  return azDevOps('boards query --wiql "' + wiql + '"');
}

/**
 * Create a work item with error handling
 */
function createWorkItem(type, title, fields) {
  var command = 'boards work-item create --type "' + type + '" --title "' + title + '"';

  if (fields && Object.keys(fields).length > 0) {
    var fieldArgs = Object.keys(fields).map(function (key) {
      return '"' + key + "=" + fields[key] + '"';
    });
    command += " --fields " + fieldArgs.join(" ");
  }

  return azDevOps(command);
}

/**
 * Get pipeline status with build details
 */
function getPipelineStatus(pipelineId, top) {
  var count = top || 5;
  return azDevOps(
    "pipelines build list --definition-ids " + pipelineId + " --top " + count
  );
}

/**
 * Trigger a pipeline run and poll for completion
 */
function runPipelineAndWait(pipelineId, branch, pollInterval) {
  var interval = pollInterval || 15000;

  return azDevOps(
    'pipelines run --id ' + pipelineId + ' --branch "' + branch + '"'
  ).then(function (run) {
    var buildId = run.id;
    console.log("Started build " + buildId + " on branch " + branch);

    return new Promise(function (resolve, reject) {
      var timer = setInterval(function () {
        azDevOps("pipelines build show --id " + buildId)
          .then(function (build) {
            console.log("Build " + buildId + ": " + build.status);
            if (build.status === "completed") {
              clearInterval(timer);
              if (build.result === "succeeded") {
                resolve(build);
              } else {
                reject(new Error("Build failed with result: " + build.result));
              }
            }
          })
          .catch(function (err) {
            clearInterval(timer);
            reject(err);
          });
      }, interval);
    });
  });
}

// Example usage
function main() {
  console.log("Fetching active work items...");

  getActiveWorkItems("[email protected]")
    .then(function (items) {
      console.log("Found " + items.length + " active items");
      items.forEach(function (item) {
        console.log("  [" + item.id + "] " + item.fields["System.Title"]);
      });

      return getPipelineStatus(42, 3);
    })
    .then(function (builds) {
      console.log("\nRecent builds:");
      builds.forEach(function (build) {
        console.log(
          "  Build " + build.id + ": " + build.status + " (" + build.result + ")"
        );
      });
    })
    .catch(function (err) {
      console.error("Error:", err.message);
      process.exit(1);
    });
}

main();

Bulk Operations with jq

For bulk operations, piping CLI output through jq is extremely effective. These are real patterns from my daily workflow:

# Close all resolved work items older than 30 days
az boards query \
  --wiql "SELECT [System.Id] FROM WorkItems \
          WHERE [System.State] = 'Resolved' \
          AND [System.ChangedDate] < @Today - 30" \
  -o json | jq -r '.[].id' | while read id; do
    echo "Closing work item $id"
    az boards work-item update --id "$id" --state "Closed" --output none
  done

# Export all active bugs to CSV
echo "ID,Title,Priority,AssignedTo" > bugs.csv
az boards query \
  --wiql "SELECT [System.Id] FROM WorkItems \
          WHERE [System.WorkItemType] = 'Bug' \
          AND [System.State] = 'Active'" \
  -o json | jq -r '.[].id' | while read id; do
    az boards work-item show --id "$id" -o json | \
      jq -r '[
        .id,
        .fields["System.Title"],
        .fields["Microsoft.VSTS.Common.Priority"],
        .fields["System.AssignedTo"].displayName // "Unassigned"
      ] | @csv'
  done >> bugs.csv

# Reassign all items from one developer to another
az boards query \
  --wiql "SELECT [System.Id] FROM WorkItems \
          WHERE [System.AssignedTo] = '[email protected]' \
          AND [System.State] <> 'Closed'" \
  -o json | jq -r '.[].id' | while read id; do
    az boards work-item update --id "$id" \
      --fields "[email protected]" --output none
    echo "Reassigned $id"
  done

# Get all failed builds from the last week with error summaries
az pipelines build list \
  --result failed \
  --top 50 \
  -o json | jq -r '.[] | select(.finishTime > (now - 604800 | todate)) | "\(.id) \(.definition.name) \(.sourceBranch)"'

CI/CD Pipeline Integration

You can use the Azure DevOps CLI inside your pipelines themselves to create cross-pipeline orchestration, update work items automatically, or trigger downstream processes:

# azure-pipelines.yml
trigger:
  - main

pool:
  vmImage: 'ubuntu-latest'

steps:
  - task: AzureCLI@2
    displayName: 'Update work items on successful build'
    inputs:
      azureSubscription: 'your-service-connection'
      scriptType: 'bash'
      scriptLocation: 'inlineScript'
      inlineScript: |
        az extension add --name azure-devops

        # Get work items linked to commits in this build
        COMMIT_MSG=$(git log -1 --pretty=%B)
        WORK_ITEM_ID=$(echo "$COMMIT_MSG" | grep -oP '#\K[0-9]+' | head -1)

        if [ -n "$WORK_ITEM_ID" ]; then
          echo "Updating work item #$WORK_ITEM_ID"
          az boards work-item update \
            --id "$WORK_ITEM_ID" \
            --discussion "Build $(Build.BuildNumber) succeeded. Deployed to staging." \
            --org "$(System.CollectionUri)" \
            --project "$(System.TeamProject)"
        fi
    env:
      AZURE_DEVOPS_EXT_PAT: $(System.AccessToken)

  - task: AzureCLI@2
    displayName: 'Trigger deployment pipeline'
    inputs:
      azureSubscription: 'your-service-connection'
      scriptType: 'bash'
      scriptLocation: 'inlineScript'
      inlineScript: |
        az extension add --name azure-devops

        az pipelines run \
          --id $(DEPLOY_PIPELINE_ID) \
          --branch "$(Build.SourceBranch)" \
          --parameters "buildNumber=$(Build.BuildNumber)" "environment=staging" \
          --org "$(System.CollectionUri)" \
          --project "$(System.TeamProject)"
    env:
      AZURE_DEVOPS_EXT_PAT: $(System.AccessToken)

Complete Working Example

Here is a complete set of scripts that automate a daily development workflow. The system includes a bash wrapper script and a Node.js orchestrator.

devflow.sh — Daily Workflow Automation

#!/bin/bash
# devflow.sh - Daily development workflow automation
# Usage: ./devflow.sh [standup|start|pr|release]

set -e

ACTION=$1
BRANCH=$(git branch --show-current 2>/dev/null || echo "none")

function standup() {
  echo "========================================="
  echo "  STANDUP REPORT - $(date +%Y-%m-%d)"
  echo "========================================="

  echo ""
  echo "IN PROGRESS:"
  az boards query \
    --wiql "SELECT [System.Id],[System.Title],[System.WorkItemType] \
            FROM WorkItems WHERE [System.AssignedTo]=@Me \
            AND [System.State]='Active'" \
    -o table 2>/dev/null || echo "  (none)"

  echo ""
  echo "COMPLETED SINCE YESTERDAY:"
  az boards query \
    --wiql "SELECT [System.Id],[System.Title] FROM WorkItems \
            WHERE [System.AssignedTo]=@Me \
            AND [System.State]='Closed' \
            AND [System.ChangedDate]>=@Today-1" \
    -o table 2>/dev/null || echo "  (none)"

  echo ""
  echo "OPEN PULL REQUESTS:"
  az repos pr list --creator "$(az devops user show --query 'user.mailAddress' -o tsv 2>/dev/null)" \
    --status active -o table 2>/dev/null || echo "  (none)"

  echo ""
  echo "RECENT BUILDS:"
  az pipelines build list --top 5 -o table 2>/dev/null || echo "  (none)"
}

function start_work() {
  local WORK_ITEM_ID=$2
  if [ -z "$WORK_ITEM_ID" ]; then
    echo "Usage: ./devflow.sh start <work-item-id>"
    exit 1
  fi

  # Get work item title for branch name
  local TITLE=$(az boards work-item show --id "$WORK_ITEM_ID" \
    --query "fields.\"System.Title\"" -o tsv)
  local SLUG=$(echo "$TITLE" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/--*/-/g' | head -c 50)
  local BRANCH_NAME="feature/${WORK_ITEM_ID}-${SLUG}"

  echo "Starting work on #${WORK_ITEM_ID}: ${TITLE}"
  echo "Branch: ${BRANCH_NAME}"

  # Create and checkout branch
  git checkout -b "$BRANCH_NAME" main
  git push -u origin "$BRANCH_NAME"

  # Move work item to Active
  az boards work-item update --id "$WORK_ITEM_ID" --state "Active" --output none
  az boards work-item update --id "$WORK_ITEM_ID" \
    --discussion "Started development on branch ${BRANCH_NAME}" --output none

  echo "Ready to work. Branch created and work item activated."
}

function create_pr() {
  if [ "$BRANCH" = "main" ] || [ "$BRANCH" = "none" ]; then
    echo "Error: switch to a feature branch first."
    exit 1
  fi

  # Extract work item ID from branch name
  local WI_ID=$(echo "$BRANCH" | grep -oP '\d+' | head -1)

  # Get work item title
  local TITLE=""
  if [ -n "$WI_ID" ]; then
    TITLE=$(az boards work-item show --id "$WI_ID" \
      --query "fields.\"System.Title\"" -o tsv 2>/dev/null)
  fi

  if [ -z "$TITLE" ]; then
    TITLE="$BRANCH"
  fi

  # Push latest changes
  git push origin "$BRANCH"

  # Create the PR
  local PR_CMD="az repos pr create --source-branch $BRANCH --target-branch main --title \"$TITLE\""

  if [ -n "$WI_ID" ]; then
    PR_CMD="$PR_CMD --work-items $WI_ID"
  fi

  eval "$PR_CMD" --output table

  echo "Pull request created."
}

function release() {
  local PIPELINE_ID=$2
  if [ -z "$PIPELINE_ID" ]; then
    echo "Usage: ./devflow.sh release <pipeline-id>"
    exit 1
  fi

  echo "Triggering release pipeline $PIPELINE_ID on main..."
  local BUILD_ID=$(az pipelines run --id "$PIPELINE_ID" --branch main --query "id" -o tsv)
  echo "Build $BUILD_ID started. Monitoring..."

  while true; do
    local STATUS=$(az pipelines build show --id "$BUILD_ID" --query "status" -o tsv)
    local RESULT=$(az pipelines build show --id "$BUILD_ID" --query "result" -o tsv)
    echo "  $(date +%H:%M:%S) - Status: $STATUS, Result: $RESULT"

    if [ "$STATUS" = "completed" ]; then
      if [ "$RESULT" = "succeeded" ]; then
        echo "Release build succeeded."
      else
        echo "Release build failed with result: $RESULT"
        exit 1
      fi
      break
    fi
    sleep 20
  done
}

case "$ACTION" in
  standup)  standup ;;
  start)    start_work "$@" ;;
  pr)       create_pr ;;
  release)  release "$@" ;;
  *)
    echo "Usage: ./devflow.sh [standup|start|pr|release]"
    echo "  standup              - Generate daily standup report"
    echo "  start <work-item-id> - Create branch and activate work item"
    echo "  pr                   - Create PR from current branch"
    echo "  release <pipeline-id> - Trigger and monitor release pipeline"
    ;;
esac

devflow-node.js — Node.js Orchestrator

var childProcess = require("child_process");
var util = require("util");
var fs = require("fs");

var exec = util.promisify(childProcess.exec);

function runAz(command) {
  return exec("az " + command + " -o json", {
    maxBuffer: 10 * 1024 * 1024,
  }).then(function (result) {
    try {
      return JSON.parse(result.stdout);
    } catch (e) {
      return result.stdout.trim();
    }
  });
}

function runShell(command) {
  return exec(command).then(function (result) {
    return result.stdout.trim();
  });
}

/**
 * Generate a full sprint health report
 */
function sprintHealthReport() {
  var report = {
    generated: new Date().toISOString(),
    activeItems: [],
    blockedItems: [],
    openPRs: [],
    failedBuilds: [],
  };

  return Promise.all([
    runAz(
      'boards query --wiql "SELECT [System.Id],[System.Title],[System.State] FROM WorkItems WHERE [System.AssignedTo]=@Me AND [System.State]=\'Active\'"'
    ),
    runAz(
      'boards query --wiql "SELECT [System.Id],[System.Title] FROM WorkItems WHERE [System.AssignedTo]=@Me AND [System.Tags] CONTAINS \'blocked\'"'
    ),
    runAz("repos pr list --status active"),
    runAz("pipelines build list --result failed --top 10"),
  ])
    .then(function (results) {
      report.activeItems = results[0] || [];
      report.blockedItems = results[1] || [];
      report.openPRs = results[2] || [];
      report.failedBuilds = results[3] || [];

      report.summary = {
        activeCount: report.activeItems.length,
        blockedCount: report.blockedItems.length,
        openPRCount: report.openPRs.length,
        failedBuildCount: report.failedBuilds.length,
      };

      return report;
    });
}

/**
 * Auto-create tasks from a template for a user story
 */
function createTasksFromTemplate(storyId, templatePath) {
  var template = JSON.parse(fs.readFileSync(templatePath, "utf8"));

  var promises = template.tasks.map(function (task) {
    return runAz(
      'boards work-item create --type "Task" --title "' +
        task.title +
        '" --fields "System.Parent=' +
        storyId +
        '" "Microsoft.VSTS.Scheduling.RemainingWork=' +
        task.hours +
        '"'
    ).then(function (result) {
      console.log("Created task: " + task.title + " (ID: " + result.id + ")");
      return result;
    });
  });

  return Promise.all(promises);
}

/**
 * Find and close stale PRs
 */
function closeStalePRs(daysOld) {
  var cutoff = new Date();
  cutoff.setDate(cutoff.getDate() - daysOld);

  return runAz("repos pr list --status active").then(function (prs) {
    var stale = prs.filter(function (pr) {
      return new Date(pr.creationDate) < cutoff;
    });

    console.log("Found " + stale.length + " PRs older than " + daysOld + " days");

    var closePromises = stale.map(function (pr) {
      console.log(
        "  Abandoning PR #" + pr.pullRequestId + ": " + pr.title
      );
      return runAz(
        "repos pr update --id " + pr.pullRequestId + " --status abandoned"
      );
    });

    return Promise.all(closePromises);
  });
}

// CLI interface
var action = process.argv[2];

switch (action) {
  case "health":
    sprintHealthReport().then(function (report) {
      console.log(JSON.stringify(report, null, 2));
    });
    break;

  case "tasks":
    var storyId = process.argv[3];
    var templatePath = process.argv[4];
    if (!storyId || !templatePath) {
      console.log("Usage: node devflow-node.js tasks <story-id> <template.json>");
      process.exit(1);
    }
    createTasksFromTemplate(storyId, templatePath);
    break;

  case "stale-prs":
    var days = parseInt(process.argv[3]) || 14;
    closeStalePRs(days);
    break;

  default:
    console.log("Usage: node devflow-node.js [health|tasks|stale-prs]");
    console.log("  health              - Sprint health report");
    console.log("  tasks <id> <file>   - Create tasks from template");
    console.log("  stale-prs [days]    - Close stale PRs (default: 14 days)");
}

Task Template (task-template.json)

{
  "tasks": [
    { "title": "Design API contract", "hours": 4 },
    { "title": "Implement endpoint logic", "hours": 8 },
    { "title": "Write unit tests", "hours": 4 },
    { "title": "Write integration tests", "hours": 4 },
    { "title": "Update API documentation", "hours": 2 },
    { "title": "Code review and revisions", "hours": 2 }
  ]
}

Common Issues and Troubleshooting

1. "The resource cannot be found" on Work Item Queries

This almost always means your default project is not set or is set to the wrong project. Run az devops configure --list and verify both organization and project are correct. WIQL queries do not cross project boundaries by default.

2. PAT Token Expiration with No Clear Error

When your PAT expires, the CLI returns vague authentication errors rather than telling you the token expired. If commands that previously worked suddenly return 401 or TF400813 errors, generate a new PAT first before debugging anything else. I set a calendar reminder 7 days before expiration.

3. Output Truncation in Pipelines

When running the CLI inside Azure Pipelines, the output buffer can fill up if you are querying large result sets. Always use --top to limit results, and use --query with JMESPath to reduce the payload before it hits stdout. In Node.js, increase maxBuffer in the exec options.

4. WIQL Quoting Issues on Windows vs. Linux

WIQL queries with single quotes inside double quotes behave differently on Windows CMD vs. bash. On Windows, you may need to escape differently or use a WIQL file:

# Save WIQL to a file to avoid quoting issues
echo 'SELECT [System.Id] FROM WorkItems WHERE [System.State] = "Active"' > query.wiql
az boards query --wiql @query.wiql

5. Extension Version Conflicts After CLI Update

After updating the Azure CLI itself, the DevOps extension can break due to API version mismatches. The fix is straightforward: remove and reinstall the extension. Run az extension remove --name azure-devops && az extension add --name azure-devops.

6. Rate Limiting on Bulk Operations

Azure DevOps APIs have rate limits. If you are running bulk operations (updating hundreds of work items, for instance), add a sleep between calls. A 200ms delay between requests keeps you well under the limits while still being fast.

Best Practices

  • Always set default organization and project. Typing --org and --project on every command is a waste of keystrokes and a source of errors. Set them once with az devops configure --defaults.

  • Use JMESPath queries to reduce output. Do not pipe massive JSON blobs through multiple tools when --query can extract exactly what you need at the source. Learn the JMESPath syntax — it pays dividends.

  • Store your PAT in an environment variable, not in scripts. Never hardcode tokens. Use AZURE_DEVOPS_EXT_PAT and load it from a secrets manager or encrypted file in your shell profile.

  • Add --output none for scripted updates. When running bulk operations in a loop, suppress output with --output none to avoid flooding the terminal and to speed up execution.

  • Use --detect for repository-aware commands. The CLI can auto-detect the organization and project from your git remote URL. Pass --detect true when working inside a cloned Azure DevOps repository.

  • Version your CLI scripts alongside your code. Keep automation scripts in a tools/ or scripts/ directory in your repository. When the team shares these scripts, everyone benefits from the same automation.

  • Wrap CLI calls in retry logic for CI/CD. Network blips happen. In pipeline scripts and Node.js wrappers, add retry logic with exponential backoff for transient failures.

  • Prefer TSV output for variable assignment. When capturing a single value into a bash variable, -o tsv gives you a clean string without quotes or brackets. JSON output requires additional parsing.

  • Scope PATs to minimum required permissions. For CI/CD service accounts, create PATs with only the scopes needed (e.g., Work Items Read/Write, Code Read, Build Read/Execute). Full access tokens are a security risk.

References

Powered by Contentful