mirror of
https://github.com/github/spec-kit.git
synced 2026-03-18 03:13:07 +00:00
Add Azure DevOps work item synchronization with handoffs system
This commit is contained in:
469
scripts/bash/create-ado-workitems.sh
Normal file
469
scripts/bash/create-ado-workitems.sh
Normal file
@@ -0,0 +1,469 @@
|
||||
#!/bin/bash
|
||||
# Create Azure DevOps work items using Azure CLI with OAuth (no PAT required)
|
||||
# Requires: Azure CLI with devops extension
|
||||
|
||||
set -e
|
||||
|
||||
# Parse arguments
|
||||
SPEC_FILE=""
|
||||
ORGANIZATION=""
|
||||
PROJECT=""
|
||||
STORIES="all"
|
||||
AREA_PATH=""
|
||||
FROM_TASKS=false
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
--spec-file)
|
||||
SPEC_FILE="$2"
|
||||
shift 2
|
||||
;;
|
||||
--organization)
|
||||
ORGANIZATION="$2"
|
||||
shift 2
|
||||
;;
|
||||
--project)
|
||||
PROJECT="$2"
|
||||
shift 2
|
||||
;;
|
||||
--stories)
|
||||
STORIES="$2"
|
||||
shift 2
|
||||
;;
|
||||
--area-path)
|
||||
AREA_PATH="$2"
|
||||
shift 2
|
||||
;;
|
||||
--from-tasks)
|
||||
FROM_TASKS=true
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $1"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Validate required arguments
|
||||
if [[ -z "$SPEC_FILE" ]]; then
|
||||
echo "Error: --spec-file is required"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if Azure CLI is installed
|
||||
if ! command -v az &> /dev/null; then
|
||||
echo "Error: Azure CLI not found. Install from: https://docs.microsoft.com/cli/azure/install-azure-cli"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if devops extension is installed
|
||||
if ! az extension list --output json | grep -q "azure-devops"; then
|
||||
echo "Installing Azure DevOps extension for Azure CLI..."
|
||||
az extension add --name azure-devops
|
||||
fi
|
||||
|
||||
# Check authentication
|
||||
echo "Checking Azure authentication..."
|
||||
if ! az account show &> /dev/null; then
|
||||
echo "Not authenticated. Running 'az login' with OAuth..."
|
||||
az login --use-device-code
|
||||
fi
|
||||
|
||||
# Config file path
|
||||
CONFIG_DIR="$HOME/.speckit"
|
||||
CONFIG_FILE="$CONFIG_DIR/ado-config.json"
|
||||
|
||||
# Load saved config if exists
|
||||
if [[ -f "$CONFIG_FILE" ]]; then
|
||||
SAVED_ORG=$(jq -r '.Organization // empty' "$CONFIG_FILE")
|
||||
SAVED_PROJECT=$(jq -r '.Project // empty' "$CONFIG_FILE")
|
||||
SAVED_AREA=$(jq -r '.AreaPath // empty' "$CONFIG_FILE")
|
||||
fi
|
||||
|
||||
# Get organization and project from command-line args, environment, or saved config
|
||||
if [[ -z "$ORGANIZATION" ]]; then
|
||||
ORGANIZATION="${AZURE_DEVOPS_ORG}"
|
||||
if [[ -z "$ORGANIZATION" ]] && [[ -n "$SAVED_ORG" ]]; then
|
||||
ORGANIZATION="$SAVED_ORG"
|
||||
fi
|
||||
fi
|
||||
if [[ -z "$PROJECT" ]]; then
|
||||
PROJECT="${AZURE_DEVOPS_PROJECT}"
|
||||
if [[ -z "$PROJECT" ]] && [[ -n "$SAVED_PROJECT" ]]; then
|
||||
PROJECT="$SAVED_PROJECT"
|
||||
fi
|
||||
fi
|
||||
if [[ -z "$AREA_PATH" ]] && [[ -n "$SAVED_AREA" ]]; then
|
||||
AREA_PATH="$SAVED_AREA"
|
||||
fi
|
||||
|
||||
# Validate required parameters
|
||||
if [[ -z "$ORGANIZATION" ]]; then
|
||||
echo "Error: Organization parameter is required. Please provide --organization parameter."
|
||||
exit 1
|
||||
fi
|
||||
if [[ -z "$PROJECT" ]]; then
|
||||
echo "Error: Project parameter is required. Please provide --project parameter."
|
||||
exit 1
|
||||
fi
|
||||
if [[ -z "$AREA_PATH" ]]; then
|
||||
echo "Error: AreaPath parameter is required. Please provide --area-path parameter."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Save configuration for future reference
|
||||
CONFIG_DIR="$HOME/.speckit"
|
||||
CONFIG_FILE="$CONFIG_DIR/ado-config.json"
|
||||
|
||||
# Escape backslashes for JSON
|
||||
AREA_PATH_ESCAPED="${AREA_PATH//\\/\\\\}"
|
||||
|
||||
mkdir -p "$CONFIG_DIR"
|
||||
cat > "$CONFIG_FILE" <<EOF
|
||||
{
|
||||
"Organization": "$ORGANIZATION",
|
||||
"Project": "$PROJECT",
|
||||
"AreaPath": "$AREA_PATH_ESCAPED",
|
||||
"LastUpdated": "$(date '+%Y-%m-%d %H:%M:%S')"
|
||||
}
|
||||
EOF
|
||||
|
||||
echo "Using Azure DevOps configuration:"
|
||||
echo " Organization: $ORGANIZATION"
|
||||
echo " Project: $PROJECT"
|
||||
echo " Area Path: $AREA_PATH"
|
||||
echo ""
|
||||
|
||||
# Parse user stories from spec.md
|
||||
parse_user_stories() {
|
||||
local file="$1"
|
||||
local story_count=0
|
||||
|
||||
# Extract all user stories using grep and awk
|
||||
while IFS= read -r line; do
|
||||
if [[ $line =~ ^###[[:space:]]+User[[:space:]]+Story[[:space:]]+([0-9]+)[[:space:]]*-[[:space:]]*(.+)[[:space:]]*\(Priority:[[:space:]]*P([0-9]+)\) ]]; then
|
||||
story_count=$((story_count + 1))
|
||||
|
||||
STORY_NUMBERS+=("${BASH_REMATCH[1]}")
|
||||
STORY_TITLES+=("${BASH_REMATCH[2]}")
|
||||
STORY_PRIORITIES+=("${BASH_REMATCH[3]}")
|
||||
|
||||
# Extract story content until next ### or ## section
|
||||
local start_line=$(grep -n "### User Story ${BASH_REMATCH[1]}" "$file" | cut -d: -f1)
|
||||
local end_line=$(tail -n +$((start_line + 1)) "$file" | grep -n -E "^(###|##)[[:space:]]" | head -1 | cut -d: -f1)
|
||||
|
||||
if [[ -z "$end_line" ]]; then
|
||||
end_line=$(wc -l < "$file")
|
||||
else
|
||||
end_line=$((start_line + end_line - 1))
|
||||
fi
|
||||
|
||||
local content=$(sed -n "${start_line},${end_line}p" "$file")
|
||||
|
||||
# Extract description (text after priority line until "**Why")
|
||||
local desc=$(echo "$content" | sed -n '/Priority: P[0-9]\+)/,/\*\*Why this priority/p' | sed '1d;$d' | tr '\n' ' ' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
|
||||
STORY_DESCRIPTIONS+=("$desc")
|
||||
|
||||
# Extract acceptance criteria
|
||||
local accept=$(echo "$content" | sed -n '/\*\*Acceptance Scenarios\*\*:/,/^##/p' | sed '1d;$d' | tr '\n' ' ' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
|
||||
STORY_ACCEPTANCE+=("$accept")
|
||||
fi
|
||||
done < "$file"
|
||||
}
|
||||
|
||||
# Parse tasks from tasks.md
|
||||
parse_tasks() {
|
||||
local file="$1"
|
||||
local task_count=0
|
||||
|
||||
# Extract all tasks matching the pattern: - [ ] T### [P?] [US#?] Description
|
||||
while IFS= read -r line; do
|
||||
if [[ $line =~ ^-[[:space:]]\[[[:space:]]\][[:space:]]+T([0-9]+)[[:space:]]+(\[P\][[:space:]]+)?(\[US([0-9]+)\][[:space:]]+)?(.+)$ ]]; then
|
||||
task_count=$((task_count + 1))
|
||||
|
||||
TASK_NUMBERS+=("${BASH_REMATCH[1]}")
|
||||
TASK_PARALLEL+=("${BASH_REMATCH[2]}")
|
||||
TASK_STORY+=("${BASH_REMATCH[4]}") # User story number
|
||||
TASK_DESCRIPTIONS+=("${BASH_REMATCH[5]}")
|
||||
fi
|
||||
done < "$file"
|
||||
}
|
||||
|
||||
# Arrays to store story/task data
|
||||
declare -a STORY_NUMBERS
|
||||
declare -a STORY_TITLES
|
||||
declare -a STORY_PRIORITIES
|
||||
declare -a STORY_DESCRIPTIONS
|
||||
declare -a STORY_ACCEPTANCE
|
||||
declare -a TASK_NUMBERS
|
||||
declare -a TASK_PARALLEL
|
||||
declare -a TASK_STORY
|
||||
declare -a TASK_DESCRIPTIONS
|
||||
|
||||
# Parse stories or tasks based on mode
|
||||
FEATURE_NAME=$(basename $(dirname "$SPEC_FILE"))
|
||||
|
||||
if [[ "$FROM_TASKS" == true ]]; then
|
||||
parse_tasks "$SPEC_FILE"
|
||||
STORY_COUNT="${#TASK_NUMBERS[@]}"
|
||||
echo "Found $STORY_COUNT tasks"
|
||||
else
|
||||
parse_user_stories "$SPEC_FILE"
|
||||
STORY_COUNT="${#STORY_NUMBERS[@]}"
|
||||
echo "Found $STORY_COUNT user stories"
|
||||
fi
|
||||
|
||||
# Filter stories/tasks based on selection
|
||||
if [[ "$STORIES" == "all" ]]; then
|
||||
if [[ "$FROM_TASKS" == true ]]; then
|
||||
SELECTED_STORIES=("${TASK_NUMBERS[@]}")
|
||||
else
|
||||
SELECTED_STORIES=("${STORY_NUMBERS[@]}")
|
||||
fi
|
||||
else
|
||||
IFS=',' read -ra SELECTED_STORIES <<< "$STORIES"
|
||||
fi
|
||||
|
||||
if [[ "$FROM_TASKS" == true ]]; then
|
||||
echo "Syncing ${#SELECTED_STORIES[@]} tasks"
|
||||
else
|
||||
echo "Syncing ${#SELECTED_STORIES[@]} user stories"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Create work items
|
||||
declare -a CREATED_IDS
|
||||
declare -a CREATED_URLS
|
||||
declare -a CREATED_STORY_REFS
|
||||
|
||||
# Load parent story mappings if in FROM_TASKS mode
|
||||
declare -A PARENT_MAPPING
|
||||
if [[ "$FROM_TASKS" == true ]]; then
|
||||
MAPPING_FILE="$(dirname "$SPEC_FILE")/.speckit/azure-devops-mapping.json"
|
||||
if [[ -f "$MAPPING_FILE" ]]; then
|
||||
echo "Loading parent user story mappings..."
|
||||
while IFS= read -r line; do
|
||||
story_num=$(echo "$line" | jq -r '.StoryNumber')
|
||||
work_item_id=$(echo "$line" | jq -r '.WorkItemId')
|
||||
PARENT_MAPPING[$story_num]=$work_item_id
|
||||
done < <(jq -c '.workItems[]' "$MAPPING_FILE")
|
||||
echo "Loaded ${#PARENT_MAPPING[@]} parent stories"
|
||||
echo ""
|
||||
fi
|
||||
fi
|
||||
|
||||
for selected in "${SELECTED_STORIES[@]}"; do
|
||||
if [[ "$FROM_TASKS" == true ]]; then
|
||||
# Handle task creation
|
||||
# Normalize selected number to remove leading zeros for comparison
|
||||
normalized_selected=$((10#$selected))
|
||||
|
||||
for i in "${!TASK_NUMBERS[@]}"; do
|
||||
# Normalize task number to remove leading zeros
|
||||
normalized_task=$((10#${TASK_NUMBERS[$i]}))
|
||||
|
||||
if [[ "$normalized_task" == "$normalized_selected" ]]; then
|
||||
num="${TASK_NUMBERS[$i]}"
|
||||
desc="${TASK_DESCRIPTIONS[$i]}"
|
||||
story_ref="${TASK_STORY[$i]}"
|
||||
|
||||
work_item_title="$desc"
|
||||
item_type="Task"
|
||||
|
||||
# Clean field values
|
||||
clean_title="${work_item_title//\"/\"\"}"
|
||||
clean_desc=$(echo "$desc" | tr '\n' ' ' | sed 's/"/\\"/g')
|
||||
|
||||
tags="spec-kit;$FEATURE_NAME;task"
|
||||
if [[ -n "$story_ref" ]]; then
|
||||
tags="$tags;US$story_ref"
|
||||
fi
|
||||
|
||||
echo "Creating Task $num: ${desc:0:60}..."
|
||||
|
||||
# Build az command
|
||||
result=$(az boards work-item create \
|
||||
--type "Task" \
|
||||
--title "$clean_title" \
|
||||
--organization "https://dev.azure.com/$ORGANIZATION" \
|
||||
--project "$PROJECT" \
|
||||
--fields \
|
||||
"System.Description=$clean_desc" \
|
||||
"System.Tags=$tags" \
|
||||
"System.AssignedTo=" \
|
||||
"Microsoft.VSTS.Scheduling.OriginalEstimate=0" \
|
||||
${AREA_PATH:+"System.AreaPath=$AREA_PATH"} \
|
||||
--output json 2>&1)
|
||||
|
||||
if [[ $? -eq 0 ]] && [[ ! "$result" =~ ERROR ]]; then
|
||||
work_item_id=$(echo "$result" | jq -r '.id')
|
||||
work_item_url="https://dev.azure.com/$ORGANIZATION/$PROJECT/_workitems/edit/$work_item_id"
|
||||
|
||||
echo " [OK] Created work item #$work_item_id"
|
||||
echo " -> $work_item_url"
|
||||
echo ""
|
||||
|
||||
CREATED_IDS+=("$work_item_id")
|
||||
CREATED_URLS+=("$work_item_url")
|
||||
CREATED_STORY_REFS+=("$story_ref")
|
||||
else
|
||||
echo " [FAIL] Failed to create work item"
|
||||
echo " Error: $result"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
break
|
||||
fi
|
||||
done
|
||||
else
|
||||
# Handle user story creation (original logic)
|
||||
for i in "${!STORY_NUMBERS[@]}"; do
|
||||
if [[ "${STORY_NUMBERS[$i]}" == "$selected" ]]; then
|
||||
num="${STORY_NUMBERS[$i]}"
|
||||
title="${STORY_TITLES[$i]}"
|
||||
priority="${STORY_PRIORITIES[$i]}"
|
||||
desc="${STORY_DESCRIPTIONS[$i]}"
|
||||
accept="${STORY_ACCEPTANCE[$i]}"
|
||||
|
||||
work_item_title="User Story $num - $title"
|
||||
item_type="User Story"
|
||||
|
||||
# Clean field values (remove newlines and escape quotes)
|
||||
# For title: double quotes for Azure CLI
|
||||
clean_title="${work_item_title//\"/\"\"}"
|
||||
clean_desc=$(echo "$desc" | tr '\n' ' ' | sed 's/"/\\"/g')
|
||||
clean_accept=$(echo "$accept" | tr '\n' ' ' | sed 's/"/\\"/g')
|
||||
|
||||
tags="spec-kit;$FEATURE_NAME;user-story"
|
||||
|
||||
echo "Creating User Story $num: $title..."
|
||||
|
||||
# Build az command
|
||||
result=$(az boards work-item create \
|
||||
--type "User Story" \
|
||||
--title "$clean_title" \
|
||||
--organization "https://dev.azure.com/$ORGANIZATION" \
|
||||
--project "$PROJECT" \
|
||||
--fields \
|
||||
"System.Description=$clean_desc" \
|
||||
"Microsoft.VSTS.Common.Priority=$priority" \
|
||||
"System.Tags=$tags" \
|
||||
"Microsoft.VSTS.Common.AcceptanceCriteria=$clean_accept" \
|
||||
"System.AssignedTo=" \
|
||||
${AREA_PATH:+"System.AreaPath=$AREA_PATH"} \
|
||||
--output json 2>&1)
|
||||
|
||||
if [[ $? -eq 0 ]] && [[ ! "$result" =~ ERROR ]]; then
|
||||
work_item_id=$(echo "$result" | jq -r '.id')
|
||||
work_item_url="https://dev.azure.com/$ORGANIZATION/$PROJECT/_workitems/edit/$work_item_id"
|
||||
|
||||
echo " [OK] Created work item #$work_item_id"
|
||||
echo " -> $work_item_url"
|
||||
echo ""
|
||||
|
||||
CREATED_IDS+=("$work_item_id")
|
||||
CREATED_URLS+=("$work_item_url")
|
||||
else
|
||||
echo " [FAIL] Failed to create work item"
|
||||
echo " Error: $result"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
done
|
||||
|
||||
# Link tasks to parent user stories if in FROM_TASKS mode
|
||||
if [[ "$FROM_TASKS" == true ]] && [[ ${#PARENT_MAPPING[@]} -gt 0 ]] && [[ ${#CREATED_IDS[@]} -gt 0 ]]; then
|
||||
echo "Linking tasks to parent user stories..."
|
||||
echo ""
|
||||
|
||||
for i in "${!CREATED_IDS[@]}"; do
|
||||
story_ref="${CREATED_STORY_REFS[$i]}"
|
||||
if [[ -n "$story_ref" ]] && [[ -n "${PARENT_MAPPING[$story_ref]}" ]]; then
|
||||
parent_id="${PARENT_MAPPING[$story_ref]}"
|
||||
task_id="${CREATED_IDS[$i]}"
|
||||
|
||||
echo -n " Linking Task #$task_id -> User Story #$parent_id..."
|
||||
|
||||
link_result=$(az boards work-item relation add \
|
||||
--id "$task_id" \
|
||||
--relation-type "Parent" \
|
||||
--target-id "$parent_id" \
|
||||
--organization "https://dev.azure.com/$ORGANIZATION" \
|
||||
--output json 2>&1)
|
||||
|
||||
if [[ $? -eq 0 ]]; then
|
||||
echo " [OK]"
|
||||
else
|
||||
echo " [FAIL]"
|
||||
echo " Error: $link_result"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# Summary
|
||||
if [[ ${#CREATED_IDS[@]} -gt 0 ]]; then
|
||||
echo ""
|
||||
echo "=============================================="
|
||||
echo "[SUCCESS] Azure DevOps Sync Complete"
|
||||
echo "=============================================="
|
||||
echo ""
|
||||
echo "Organization: $ORGANIZATION"
|
||||
echo "Project: $PROJECT"
|
||||
echo "Feature: $FEATURE_NAME"
|
||||
|
||||
if [[ "$FROM_TASKS" == true ]]; then
|
||||
echo "Created: ${#CREATED_IDS[@]} of ${#SELECTED_STORIES[@]} tasks"
|
||||
else
|
||||
echo "Created: ${#CREATED_IDS[@]} of ${#SELECTED_STORIES[@]} user stories"
|
||||
fi
|
||||
echo ""
|
||||
echo "Created Work Items:"
|
||||
echo ""
|
||||
|
||||
for i in "${!CREATED_IDS[@]}"; do
|
||||
idx=$((i))
|
||||
echo " [${SELECTED_STORIES[$idx]}] ${STORY_TITLES[$idx]} (P${STORY_PRIORITIES[$idx]})"
|
||||
echo " Work Item: #${CREATED_IDS[$i]}"
|
||||
echo " Link: ${CREATED_URLS[$i]}"
|
||||
echo ""
|
||||
done
|
||||
|
||||
echo "View in Azure DevOps:"
|
||||
echo " Boards: https://dev.azure.com/$ORGANIZATION/$PROJECT/_boards"
|
||||
echo " Work Items: https://dev.azure.com/$ORGANIZATION/$PROJECT/_workitems"
|
||||
echo ""
|
||||
|
||||
# Save mapping
|
||||
SPEC_DIR=$(dirname "$SPEC_FILE")
|
||||
SPECKIT_DIR="$SPEC_DIR/.speckit"
|
||||
mkdir -p "$SPECKIT_DIR"
|
||||
|
||||
MAPPING_FILE="$SPECKIT_DIR/azure-devops-mapping.json"
|
||||
echo "{" > "$MAPPING_FILE"
|
||||
echo " \"organization\": \"$ORGANIZATION\"," >> "$MAPPING_FILE"
|
||||
echo " \"project\": \"$PROJECT\"," >> "$MAPPING_FILE"
|
||||
echo " \"feature\": \"$FEATURE_NAME\"," >> "$MAPPING_FILE"
|
||||
echo " \"workItems\": [" >> "$MAPPING_FILE"
|
||||
|
||||
for i in "${!CREATED_IDS[@]}"; do
|
||||
comma=""
|
||||
[[ $i -lt $((${#CREATED_IDS[@]} - 1)) ]] && comma=","
|
||||
echo " {" >> "$MAPPING_FILE"
|
||||
echo " \"storyNumber\": ${SELECTED_STORIES[$i]}," >> "$MAPPING_FILE"
|
||||
echo " \"workItemId\": ${CREATED_IDS[$i]}," >> "$MAPPING_FILE"
|
||||
echo " \"url\": \"${CREATED_URLS[$i]}\"" >> "$MAPPING_FILE"
|
||||
echo " }$comma" >> "$MAPPING_FILE"
|
||||
done
|
||||
|
||||
echo " ]" >> "$MAPPING_FILE"
|
||||
echo "}" >> "$MAPPING_FILE"
|
||||
|
||||
echo "Mapping saved: $MAPPING_FILE"
|
||||
fi
|
||||
574
scripts/powershell/create-ado-workitems.ps1
Normal file
574
scripts/powershell/create-ado-workitems.ps1
Normal file
@@ -0,0 +1,574 @@
|
||||
#!/usr/bin/env pwsh
|
||||
# Create Azure DevOps work items using Azure CLI with OAuth (no PAT required)
|
||||
# Requires: Azure CLI with devops extension
|
||||
|
||||
param(
|
||||
[Parameter(Mandatory=$true)]
|
||||
[string]$SpecFile,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$Organization = "",
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$Project = "",
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$Stories = "all",
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$AreaPath = "",
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[switch]$FromTasks = $false
|
||||
)
|
||||
|
||||
# Check if Azure CLI is installed
|
||||
if (-not (Get-Command az -ErrorAction SilentlyContinue)) {
|
||||
Write-Error "Azure CLI not found. Please install from: https://aka.ms/installazurecliwindows"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Check if devops extension is installed
|
||||
$extensions = az extension list --output json | ConvertFrom-Json
|
||||
if (-not ($extensions | Where-Object { $_.name -eq "azure-devops" })) {
|
||||
Write-Host "Installing Azure DevOps extension for Azure CLI..."
|
||||
az extension add --name azure-devops
|
||||
}
|
||||
|
||||
# Check authentication
|
||||
Write-Host "Checking Azure authentication..."
|
||||
$account = az account show 2>$null | ConvertFrom-Json
|
||||
if (-not $account) {
|
||||
Write-Host "Not authenticated. Running 'az login' with OAuth..."
|
||||
az login --use-device-code
|
||||
}
|
||||
|
||||
# Validate required parameters
|
||||
if ([string]::IsNullOrEmpty($Organization)) {
|
||||
Write-Error "Organization parameter is required. Please provide -Organization parameter."
|
||||
exit 1
|
||||
}
|
||||
if ([string]::IsNullOrEmpty($Project)) {
|
||||
Write-Error "Project parameter is required. Please provide -Project parameter."
|
||||
exit 1
|
||||
}
|
||||
if ([string]::IsNullOrEmpty($AreaPath)) {
|
||||
Write-Error "AreaPath parameter is required. Please provide -AreaPath parameter."
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Save configuration for future reference
|
||||
$configDir = Join-Path $env:USERPROFILE ".speckit"
|
||||
$configFile = Join-Path $configDir "ado-config.json"
|
||||
|
||||
if (-not (Test-Path $configDir)) {
|
||||
New-Item -ItemType Directory -Path $configDir -Force | Out-Null
|
||||
}
|
||||
$config = @{
|
||||
Organization = $Organization
|
||||
Project = $Project
|
||||
AreaPath = $AreaPath
|
||||
LastUpdated = (Get-Date).ToString("yyyy-MM-dd HH:mm:ss")
|
||||
}
|
||||
$config | ConvertTo-Json | Set-Content $configFile
|
||||
|
||||
Write-Host "Using Azure DevOps configuration:" -ForegroundColor Cyan
|
||||
Write-Host " Organization: $Organization" -ForegroundColor Yellow
|
||||
Write-Host " Project: $Project" -ForegroundColor Yellow
|
||||
Write-Host " Area Path: $AreaPath" -ForegroundColor Yellow
|
||||
Write-Host ""
|
||||
|
||||
# Set defaults for Azure CLI
|
||||
az devops configure --defaults organization="https://dev.azure.com/$Organization" project="$Project"
|
||||
|
||||
# Parse user stories from spec.md
|
||||
function Parse-UserStories {
|
||||
param([string]$FilePath)
|
||||
|
||||
if (-not (Test-Path $FilePath)) {
|
||||
Write-Error "Spec file not found: $FilePath"
|
||||
exit 1
|
||||
}
|
||||
|
||||
$content = Get-Content -Path $FilePath -Raw
|
||||
$parsedStories = [System.Collections.ArrayList]::new()
|
||||
|
||||
# Match: ### User Story X - Title (Priority: PX)
|
||||
$pattern = '###\s+User\s+Story\s+(\d+)\s*-\s*([^\(]+)\s*\(Priority:\s*P(\d+)\)'
|
||||
$matches = [regex]::Matches($content, $pattern)
|
||||
|
||||
foreach ($match in $matches) {
|
||||
$storyNum = $match.Groups[1].Value
|
||||
$title = $match.Groups[2].Value.Trim()
|
||||
$priority = $match.Groups[3].Value
|
||||
|
||||
# Extract story content (everything until next ### or ## section)
|
||||
$startPos = $match.Index
|
||||
$nextStoryPattern = '###\s+User\s+Story\s+\d+'
|
||||
$nextMatch = [regex]::Match($content.Substring($startPos + 1), $nextStoryPattern)
|
||||
|
||||
if ($nextMatch.Success) {
|
||||
$endPos = $startPos + $nextMatch.Index + 1
|
||||
$storyContent = $content.Substring($startPos, $endPos - $startPos)
|
||||
} else {
|
||||
# Find next ## level section (Edge Cases, Requirements, etc.)
|
||||
$endMatch = [regex]::Match($content.Substring($startPos), '\n##\s+(Edge Cases|Requirements|Success Criteria|Assumptions|Out of Scope)')
|
||||
if ($endMatch.Success) {
|
||||
$storyContent = $content.Substring($startPos, $endMatch.Index)
|
||||
} else {
|
||||
$storyContent = $content.Substring($startPos)
|
||||
}
|
||||
}
|
||||
|
||||
# Extract sections
|
||||
$description = ""
|
||||
if ($storyContent -match '(?s)Priority: P\d+\)\s*\n\s*\n(.+?)(?=\*\*Why this priority|###|##\s+|$)') {
|
||||
$description = $Matches[1].Trim()
|
||||
}
|
||||
|
||||
$whyPriority = ""
|
||||
if ($storyContent -match '\*\*Why this priority\*\*:\s*(.+?)(?=\n\n|\*\*Independent Test|###|$)') {
|
||||
$whyPriority = $Matches[1].Trim()
|
||||
}
|
||||
|
||||
$independentTest = ""
|
||||
if ($storyContent -match '\*\*Independent Test\*\*:\s*(.+?)(?=\n\n|\*\*Acceptance|###|$)') {
|
||||
$independentTest = $Matches[1].Trim()
|
||||
}
|
||||
|
||||
$acceptanceCriteria = ""
|
||||
if ($storyContent -match '(?s)\*\*Acceptance Scenarios\*\*:\s*\n\s*\n(.+?)(?=###|##\s+Edge Cases|##\s+Requirements|$)') {
|
||||
$acceptanceCriteria = $Matches[1].Trim()
|
||||
}
|
||||
|
||||
[void]$parsedStories.Add([PSCustomObject]@{
|
||||
Number = $storyNum
|
||||
Title = $title
|
||||
Priority = $priority
|
||||
Description = $description
|
||||
Why = $whyPriority
|
||||
Test = $independentTest
|
||||
Acceptance = $acceptanceCriteria
|
||||
})
|
||||
}
|
||||
|
||||
return ,$parsedStories
|
||||
}
|
||||
|
||||
# Parse tasks from tasks.md file
|
||||
function Parse-Tasks {
|
||||
param([string]$FilePath)
|
||||
|
||||
if (-not (Test-Path $FilePath)) {
|
||||
Write-Error "Tasks file not found: $FilePath"
|
||||
exit 1
|
||||
}
|
||||
|
||||
$content = Get-Content -Path $FilePath -Raw
|
||||
$parsedTasks = [System.Collections.ArrayList]::new()
|
||||
|
||||
# Match: - [ ] TXXX [P] [Story] Description
|
||||
# Format: - [ ] T001 [P] [US1] Description or - [ ] T001 Description
|
||||
$pattern = '-\s*\[\s*\]\s+T(\d+)\s+(?:\[P\]\s+)?(?:\[([^\]]+)\]\s+)?(.+)'
|
||||
$matches = [regex]::Matches($content, $pattern)
|
||||
|
||||
Write-Verbose "Found $($matches.Count) task matches in tasks file"
|
||||
|
||||
foreach ($match in $matches) {
|
||||
$taskNum = $match.Groups[1].Value
|
||||
$story = $match.Groups[2].Value.Trim()
|
||||
$description = $match.Groups[3].Value.Trim()
|
||||
|
||||
# Default priority to 2 (medium) for tasks
|
||||
$priority = 2
|
||||
|
||||
# If story tag exists, extract priority from it (US1=P1, US2=P2, etc.)
|
||||
if ($story -match 'US(\d+)') {
|
||||
$priority = [int]$Matches[1]
|
||||
if ($priority -gt 3) { $priority = 3 }
|
||||
}
|
||||
|
||||
# Set title as task number + description (truncate if too long)
|
||||
$title = "T$taskNum - $description"
|
||||
if ($title.Length -gt 100) {
|
||||
$title = $title.Substring(0, 97) + "..."
|
||||
}
|
||||
|
||||
$whyPriority = ""
|
||||
if ($storyContent -match '\*\*Why this priority\*\*:\s*(.+?)(?=\n\n|\*\*Independent Test|###|$)') {
|
||||
$whyPriority = $Matches[1].Trim()
|
||||
}
|
||||
|
||||
$independentTest = ""
|
||||
if ($storyContent -match '\*\*Independent Test\*\*:\s*(.+?)(?=\n\n|\*\*Acceptance|###|$)') {
|
||||
$independentTest = $Matches[1].Trim()
|
||||
}
|
||||
|
||||
$acceptanceCriteria = ""
|
||||
if ($storyContent -match '(?s)\*\*Acceptance Scenarios\*\*:\s*\n\s*\n(.+?)(?=###|##\s+Edge Cases|##\s+Requirements|$)') {
|
||||
$acceptanceCriteria = $Matches[1].Trim()
|
||||
}
|
||||
|
||||
[void]$parsedStories.Add([PSCustomObject]@{
|
||||
Number = $storyNum
|
||||
Title = $title
|
||||
Priority = $priority
|
||||
Description = $description
|
||||
Why = $whyPriority
|
||||
Test = $independentTest
|
||||
Acceptance = $acceptanceCriteria
|
||||
})
|
||||
}
|
||||
|
||||
return ,$parsedStories # Force return as array
|
||||
}
|
||||
|
||||
# Parse tasks from tasks.md file
|
||||
function Parse-Tasks {
|
||||
param([string]$FilePath)
|
||||
|
||||
if (-not (Test-Path $FilePath)) {
|
||||
Write-Error "Tasks file not found: $FilePath"
|
||||
exit 1
|
||||
}
|
||||
|
||||
$content = Get-Content -Path $FilePath -Raw
|
||||
$parsedTasks = [System.Collections.ArrayList]::new()
|
||||
|
||||
# Match: - [ ] TXXX [P] [Story] Description
|
||||
$pattern = '-\s*\[\s*\]\s+T(\d+)\s+(?:\[P\]\s+)?(?:\[([^\]]+)\]\s+)?(.+)'
|
||||
$matches = [regex]::Matches($content, $pattern)
|
||||
|
||||
Write-Verbose "Found $($matches.Count) task matches in tasks file"
|
||||
|
||||
foreach ($match in $matches) {
|
||||
$taskNum = $match.Groups[1].Value
|
||||
$story = $match.Groups[2].Value.Trim()
|
||||
$description = $match.Groups[3].Value.Trim()
|
||||
|
||||
# Default priority to 2 (medium) for tasks
|
||||
$priority = 2
|
||||
|
||||
# If story tag exists, extract priority (US1=P1, etc.)
|
||||
if ($story -match 'US(\d+)') {
|
||||
$priority = [int]$Matches[1]
|
||||
if ($priority -gt 4) { $priority = 4 }
|
||||
}
|
||||
|
||||
# Title as task number + description (truncate if too long)
|
||||
$title = "T$taskNum - $description"
|
||||
if ($title.Length -gt 100) {
|
||||
$title = $title.Substring(0, 97) + "..."
|
||||
}
|
||||
|
||||
# Full description includes story tag
|
||||
$fullDescription = $description
|
||||
if (-not [string]::IsNullOrEmpty($story)) {
|
||||
$fullDescription = "[$story] $description"
|
||||
}
|
||||
|
||||
[void]$parsedTasks.Add([PSCustomObject]@{
|
||||
Number = $taskNum
|
||||
Title = $title
|
||||
Priority = $priority
|
||||
Description = $fullDescription
|
||||
Story = $story
|
||||
})
|
||||
}
|
||||
|
||||
return ,$parsedTasks
|
||||
}
|
||||
|
||||
# Filter stories based on selection
|
||||
function Get-SelectedStories {
|
||||
param([array]$AllStories, [string]$Selection)
|
||||
|
||||
if ($Selection -eq "all" -or [string]::IsNullOrEmpty($Selection)) {
|
||||
return $AllStories
|
||||
}
|
||||
|
||||
$selectedNumbers = @()
|
||||
$parts = $Selection -split ','
|
||||
|
||||
foreach ($part in $parts) {
|
||||
$part = $part.Trim()
|
||||
if ($part -match '^(\d+)-(\d+)$') {
|
||||
$start = [int]$Matches[1]
|
||||
$end = [int]$Matches[2]
|
||||
$selectedNumbers += $start..$end
|
||||
}
|
||||
elseif ($part -match '^\d+$') {
|
||||
$selectedNumbers += [int]$part
|
||||
}
|
||||
}
|
||||
|
||||
return $AllStories | Where-Object { $selectedNumbers -contains [int]$_.Number }
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "=============================================="
|
||||
if ($FromTasks) {
|
||||
Write-Host "Azure DevOps Work Items from Tasks"
|
||||
} else {
|
||||
Write-Host "Azure DevOps Work Item Creation (OAuth)"
|
||||
}
|
||||
Write-Host "=============================================="
|
||||
Write-Host "Organization: $Organization"
|
||||
Write-Host "Project: $Project"
|
||||
Write-Host "File: $SpecFile"
|
||||
Write-Host ""
|
||||
|
||||
$featureName = Split-Path (Split-Path $SpecFile -Parent) -Leaf
|
||||
|
||||
# Parse and filter items (tasks or stories)
|
||||
if ($FromTasks) {
|
||||
$allStories = Parse-Tasks -FilePath $SpecFile
|
||||
$itemType = "Task"
|
||||
$itemLabel = "tasks"
|
||||
} else {
|
||||
$allStories = Parse-UserStories -FilePath $SpecFile
|
||||
$itemType = "User Story"
|
||||
$itemLabel = "user stories"
|
||||
}
|
||||
|
||||
$selectedStories = Get-SelectedStories -AllStories $allStories -Selection $Stories
|
||||
|
||||
Write-Host "Found $($allStories.Count) $itemLabel"
|
||||
Write-Host "Syncing $($selectedStories.Count) $itemLabel"
|
||||
Write-Host ""
|
||||
|
||||
# Show preview of items to be created
|
||||
Write-Host "Items to be created:" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
foreach ($story in $selectedStories) {
|
||||
Write-Host " [$($story.Number)] P$($story.Priority) - $($story.Title)" -ForegroundColor Yellow
|
||||
if (-not $FromTasks) {
|
||||
$desc = $story.Description.Substring(0, [Math]::Min(80, $story.Description.Length))
|
||||
if ($story.Description.Length -gt 80) { $desc += "..." }
|
||||
Write-Host " $desc" -ForegroundColor Gray
|
||||
} else {
|
||||
Write-Host " Story: $($story.StoryNumber)" -ForegroundColor Gray
|
||||
}
|
||||
}
|
||||
Write-Host ""
|
||||
|
||||
$createdItems = @()
|
||||
|
||||
# Load parent user story mapping for tasks
|
||||
$parentMapping = @{}
|
||||
if ($FromTasks) {
|
||||
$mappingFile = Join-Path (Split-Path $SpecFile -Parent) ".speckit\azure-devops-mapping.json"
|
||||
if (Test-Path $mappingFile) {
|
||||
$mapping = Get-Content $mappingFile -Raw | ConvertFrom-Json
|
||||
foreach ($item in $mapping.workItems) {
|
||||
# Map story number to work item ID (e.g., "1" -> workItemId)
|
||||
if ($item.StoryNumber -match '^\d+$') {
|
||||
$parentMapping[$item.StoryNumber] = $item.WorkItemId
|
||||
}
|
||||
}
|
||||
Write-Host "Loaded parent user story mappings: $($parentMapping.Count) stories" -ForegroundColor Green
|
||||
Write-Host ""
|
||||
} else {
|
||||
Write-Host "Warning: No user story mapping found. Tasks will be created without parent links." -ForegroundColor Yellow
|
||||
Write-Host "Run the script on spec.md first to create user stories, then create tasks." -ForegroundColor Yellow
|
||||
Write-Host ""
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($story in $selectedStories) {
|
||||
if ($FromTasks) {
|
||||
$workItemTitle = $story.Title
|
||||
$fullDescription = $story.Description
|
||||
$tags = "spec-kit;$featureName;task"
|
||||
if ($story.Story) {
|
||||
$tags += ";$($story.Story)"
|
||||
}
|
||||
Write-Host "Creating Task $($story.Number): $($story.Description.Substring(0, [Math]::Min(60, $story.Description.Length)))..."
|
||||
} else {
|
||||
$workItemTitle = "User Story $($story.Number) - $($story.Title)"
|
||||
$fullDescription = $story.Description
|
||||
|
||||
if ($story.Why) {
|
||||
$fullDescription += "`n`n**Why this priority**: $($story.Why)"
|
||||
}
|
||||
if ($story.Test) {
|
||||
$fullDescription += "`n`n**Independent Test**: $($story.Test)"
|
||||
}
|
||||
|
||||
$tags = "spec-kit;$featureName;user-story"
|
||||
Write-Host "Creating User Story $($story.Number): $($story.Title)..."
|
||||
}
|
||||
|
||||
|
||||
# Create work item using Azure CLI
|
||||
try {
|
||||
# Escape special characters in field values
|
||||
# For title: escape quotes by doubling them for Azure CLI
|
||||
$cleanTitle = $workItemTitle -replace '"', '""'
|
||||
$cleanDesc = $fullDescription -replace '"', '\"' -replace '\r?\n', ' '
|
||||
|
||||
# Build field arguments
|
||||
$fieldArgs = @(
|
||||
"System.Description=$cleanDesc"
|
||||
"Microsoft.VSTS.Common.Priority=$($story.Priority)"
|
||||
"System.Tags=$tags"
|
||||
"System.AssignedTo=" # Explicitly leave unassigned
|
||||
)
|
||||
|
||||
# Add Original Estimate for Tasks (required field in Azure DevOps)
|
||||
if ($FromTasks) {
|
||||
$fieldArgs += "Microsoft.VSTS.Scheduling.OriginalEstimate=0"
|
||||
}
|
||||
|
||||
# Add acceptance criteria only for user stories
|
||||
if (-not $FromTasks -and $story.Acceptance) {
|
||||
$cleanAcceptance = $story.Acceptance -replace '"', '\"' -replace '\r?\n', ' '
|
||||
$fieldArgs += "Microsoft.VSTS.Common.AcceptanceCriteria=$cleanAcceptance"
|
||||
}
|
||||
|
||||
if ($AreaPath) {
|
||||
$fieldArgs += "System.AreaPath=$AreaPath"
|
||||
}
|
||||
|
||||
# Build complete command arguments array
|
||||
$azArgs = @(
|
||||
'boards', 'work-item', 'create'
|
||||
'--type', $itemType
|
||||
'--title', $cleanTitle
|
||||
'--organization', "https://dev.azure.com/$Organization"
|
||||
'--project', $Project
|
||||
'--fields'
|
||||
) + $fieldArgs + @('--output', 'json')
|
||||
|
||||
Write-Verbose "Total args: $($azArgs.Count)"
|
||||
Write-Verbose "Args: $($azArgs -join ' | ')"
|
||||
|
||||
# Execute command
|
||||
$result = & az @azArgs 2>&1
|
||||
$resultString = $result | Out-String
|
||||
|
||||
if ($LASTEXITCODE -eq 0 -and $resultString -notmatch "ERROR") {
|
||||
try {
|
||||
$workItem = $resultString | ConvertFrom-Json
|
||||
} catch {
|
||||
Write-Host " [FAIL] Failed to parse response"
|
||||
Write-Host " Error: $_"
|
||||
Write-Host ""
|
||||
continue
|
||||
}
|
||||
$workItemId = $workItem.id
|
||||
$workItemUrl = "https://dev.azure.com/$Organization/$Project/_workitems/edit/$workItemId"
|
||||
|
||||
Write-Host " [OK] Created work item #$workItemId"
|
||||
Write-Host " -> $workItemUrl"
|
||||
Write-Host ""
|
||||
|
||||
$createdItems += [PSCustomObject]@{
|
||||
StoryNumber = $story.Number
|
||||
Title = $story.Title
|
||||
Priority = "P$($story.Priority)"
|
||||
WorkItemId = $workItemId
|
||||
WorkItemUrl = $workItemUrl
|
||||
ParentStoryNumber = if ($FromTasks) { $story.Story } else { $null }
|
||||
Status = "Created"
|
||||
}
|
||||
} else {
|
||||
Write-Host " [FAIL] Failed to create work item"
|
||||
Write-Host " Error: $resultString"
|
||||
Write-Host ""
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-Host " [ERROR] Error: $_"
|
||||
Write-Host ""
|
||||
}
|
||||
}
|
||||
|
||||
# Display summary
|
||||
if ($createdItems.Count -gt 0) {
|
||||
Write-Host ""
|
||||
Write-Host "=============================================="
|
||||
Write-Host "[SUCCESS] Azure DevOps Sync Complete"
|
||||
Write-Host "=============================================="
|
||||
Write-Host ""
|
||||
Write-Host "Organization: $Organization"
|
||||
Write-Host "Project: $Project"
|
||||
Write-Host "Feature: $featureName"
|
||||
Write-Host "Created: $($createdItems.Count) of $($stories.Count) user stories"
|
||||
Write-Host ""
|
||||
Write-Host "Created Work Items:"
|
||||
Write-Host ""
|
||||
|
||||
foreach ($item in $createdItems) {
|
||||
Write-Host " [$($item.StoryNumber)] $($item.Title) ($($item.Priority))"
|
||||
Write-Host " Work Item: #$($item.WorkItemId)"
|
||||
Write-Host " Link: $($item.WorkItemUrl)"
|
||||
Write-Host ""
|
||||
}
|
||||
|
||||
Write-Host "View in Azure DevOps:"
|
||||
Write-Host " Boards: https://dev.azure.com/$Organization/$Project/_boards"
|
||||
Write-Host " Work Items: https://dev.azure.com/$Organization/$Project/_workitems"
|
||||
Write-Host ""
|
||||
|
||||
# Link tasks to parent user stories if FromTasks mode
|
||||
if ($FromTasks -and $parentMapping.Count -gt 0) {
|
||||
Write-Host "Linking tasks to parent user stories..." -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
|
||||
foreach ($item in $createdItems) {
|
||||
if ($item.ParentStoryNumber) {
|
||||
# Extract story number from "US1" format
|
||||
$storyNum = $null
|
||||
if ($item.ParentStoryNumber -match 'US(\d+)') {
|
||||
$storyNum = $Matches[1]
|
||||
} elseif ($item.ParentStoryNumber -match '^\d+$') {
|
||||
$storyNum = $item.ParentStoryNumber
|
||||
}
|
||||
|
||||
if ($storyNum -and $parentMapping.ContainsKey($storyNum)) {
|
||||
$parentId = $parentMapping[$storyNum]
|
||||
Write-Host " Linking Task #$($item.WorkItemId) -> User Story #$parentId..." -NoNewline
|
||||
|
||||
$linkArgs = @(
|
||||
'boards', 'work-item', 'relation', 'add'
|
||||
'--id', $item.WorkItemId
|
||||
'--relation-type', 'Parent'
|
||||
'--target-id', $parentId
|
||||
'--organization', "https://dev.azure.com/$Organization"
|
||||
'--output', 'json'
|
||||
)
|
||||
$linkResult = & az @linkArgs 2>&1 | Out-String
|
||||
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
Write-Host " [OK]" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host " [FAIL]" -ForegroundColor Yellow
|
||||
Write-Host " Error: $linkResult" -ForegroundColor Gray
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Write-Host ""
|
||||
}
|
||||
Write-Host ""
|
||||
|
||||
# Save mapping
|
||||
$mappingDir = Join-Path (Split-Path $SpecFile -Parent) ".speckit"
|
||||
if (-not (Test-Path $mappingDir)) {
|
||||
New-Item -ItemType Directory -Path $mappingDir -Force | Out-Null
|
||||
}
|
||||
|
||||
$mappingFile = Join-Path $mappingDir "azure-devops-mapping.json"
|
||||
$mapping = @{
|
||||
feature = $featureName
|
||||
organization = $Organization
|
||||
project = $Project
|
||||
syncDate = Get-Date -Format "o"
|
||||
workItems = $createdItems
|
||||
}
|
||||
|
||||
$mapping | ConvertTo-Json -Depth 10 | Out-File -FilePath $mappingFile -Encoding UTF8
|
||||
Write-Host "Mapping saved: $mappingFile"
|
||||
Write-Host ""
|
||||
}
|
||||
Reference in New Issue
Block a user