#!/usr/bin/env bash # # Get all work items (tickets) between two branches in Azure DevOps. # # Reports tickets from TWO sources: # 1. Formally linked work items (git commit <-> work item links) # 2. Ticket numbers scraped from commit messages (this repo references # tickets in text far more than it formally links them) # # Usage: # ./azdo-tickets-between-branches.sh # # Example: # ./azdo-tickets-between-branches.sh master release/5.4.0 # # Requires: curl, jq # Auth: set a Personal Access Token in AZDO_PAT (scopes: Code Read + Work Items Read). set -euo pipefail # ---- Config (override via env vars) ----------------------------------------- # Project collection base URL (host + collection), e.g. old visualstudio.com style. COLLECTION_URL="${AZDO_COLLECTION_URL:-https://flashmobilevending.visualstudio.com/DefaultCollection}" PROJECT="${AZDO_PROJECT:-GLU}" REPO="${AZDO_REPO:-glu-flash-console}" PAT="${AZDO_PAT:-}" API_VERSION="7.1" # Regex for ticket numbers in commit messages (this project uses 6-digit IDs). TICKET_REGEX="${AZDO_TICKET_REGEX:-[0-9]{6}}" # ----------------------------------------------------------------------------- BASE="${1:-}" TARGET="${2:-}" if [[ -z "$BASE" || -z "$TARGET" ]]; then echo "Usage: $0 " >&2 echo "Example: $0 master release/5.4.0" >&2 exit 1 fi if [[ -z "$PAT" ]]; then echo "Error: set AZDO_PAT to a Personal Access Token (Code Read + Work Items Read)." >&2 exit 1 fi for cmd in curl jq; do command -v "$cmd" >/dev/null 2>&1 || { echo "Error: '$cmd' is required." >&2; exit 1; } done GIT_URL="${COLLECTION_URL}/${PROJECT}/_apis/git/repositories/${REPO}" WIT_URL="${COLLECTION_URL}/${PROJECT}/_apis/wit/workitems" # ---- Step 1: commits on TARGET but not on BASE, with linked work items ------- commits_json=$(curl -sf -u ":${PAT}" \ "${GIT_URL}/commitsbatch?\$top=1000&api-version=${API_VERSION}" \ -H "Content-Type: application/json" \ -d "{ \"itemVersion\": {\"versionType\":\"branch\",\"version\":\"${BASE}\"}, \"compareVersion\": {\"versionType\":\"branch\",\"version\":\"${TARGET}\"}, \"includeWorkItems\": true }") # 1a. Formally linked work item IDs linked_ids=$(echo "$commits_json" | jq -r '[.value[].workItems[]?.id] | unique | .[]' || true) # 1b. Ticket numbers scraped from commit messages. # Strip "Merged PR NNNNN" first so PR ids aren't mistaken for tickets. scraped_ids=$(echo "$commits_json" \ | jq -r '.value[].comment' \ | sed -E 's/[Mm]erged PR [0-9]+//g' \ | grep -oE "$TICKET_REGEX" \ | sort -u || true) # Union of both sources all_ids=$(printf '%s\n%s\n' "$linked_ids" "$scraped_ids" | grep -E '^[0-9]+$' | sort -un || true) commit_count=$(echo "$commits_json" | jq '.value | length') echo "Comparing '${TARGET}' against '${BASE}' (${commit_count} commits ahead)" echo " Formally linked: $(echo "$linked_ids" | grep -c . || true)" echo " Scraped from msgs: $(echo "$scraped_ids" | grep -c . || true)" echo if [[ -z "$all_ids" ]]; then echo "No tickets found (no links and no ticket numbers in commit messages)." exit 0 fi # ---- Step 2: hydrate work item details -------------------------------------- ids_csv=$(echo "$all_ids" | paste -sd, -) http_status=$(curl -s -o /tmp/azdo_wit.json -w "%{http_code}" -u ":${PAT}" \ "${WIT_URL}?ids=${ids_csv}&fields=System.Id,System.WorkItemType,System.State,System.Title&errorPolicy=omit&api-version=${API_VERSION}") if [[ "$http_status" != "200" ]]; then echo "Warning: could not fetch work item details (HTTP ${http_status})." >&2 if [[ "$http_status" == "401" || "$http_status" == "403" ]]; then echo " Your PAT is likely missing the 'Work Items (Read)' scope." >&2 fi echo " Falling back to ticket IDs only:" >&2 echo echo "$all_ids" echo echo "View any of them at: ${COLLECTION_URL}/${PROJECT}/_workitems/edit/" exit 0 fi # errorPolicy=omit returns null entries for ids that aren't real work items; # filter those out so scraped false-positives disappear. { printf 'ID\tTYPE\tSTATE\tTITLE\n' jq -r '.value[] | select(. != null) | [ (.fields["System.Id"]|tostring), .fields["System.WorkItemType"], .fields["System.State"], (.fields["System.Title"] // "") ] | @tsv' /tmp/azdo_wit.json } | column -t -s $'\t'