From 39ac7e48d6f3bfb6d26536a0c7d524e9091a10cb Mon Sep 17 00:00:00 2001 From: pragya247 Date: Tue, 3 Mar 2026 00:27:38 +0530 Subject: [PATCH 1/6] Add Azure DevOps work item synchronization with handoffs system --- scripts/bash/create-ado-workitems.sh | 469 ++++++++++++++++ scripts/powershell/create-ado-workitems.ps1 | 574 ++++++++++++++++++++ templates/commands/adosync.md | 496 +++++++++++++++++ templates/commands/plan.md | 9 + templates/commands/specify.md | 14 +- templates/commands/tasks.md | 4 + tests/test_ai_skills.py | 266 +++++++++ tests/test_extensions.py | 302 ++++++++++ 8 files changed, 2133 insertions(+), 1 deletion(-) create mode 100644 scripts/bash/create-ado-workitems.sh create mode 100644 scripts/powershell/create-ado-workitems.ps1 create mode 100644 templates/commands/adosync.md diff --git a/scripts/bash/create-ado-workitems.sh b/scripts/bash/create-ado-workitems.sh new file mode 100644 index 0000000000..e35ea5a285 --- /dev/null +++ b/scripts/bash/create-ado-workitems.sh @@ -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" <&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 diff --git a/scripts/powershell/create-ado-workitems.ps1 b/scripts/powershell/create-ado-workitems.ps1 new file mode 100644 index 0000000000..d7a9ef0cac --- /dev/null +++ b/scripts/powershell/create-ado-workitems.ps1 @@ -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 "" +} diff --git a/templates/commands/adosync.md b/templates/commands/adosync.md new file mode 100644 index 0000000000..5d8670989d --- /dev/null +++ b/templates/commands/adosync.md @@ -0,0 +1,496 @@ +--- +description: Sync selected user stories or tasks to Azure DevOps +scripts: + sh: scripts/bash/create-ado-workitems.sh + ps: scripts/powershell/create-ado-workitems.ps1 +--- + +## User Input + +```text +$ARGUMENTS +``` + +You **MUST** consider the user input before proceeding (if not empty). + +## Prerequisites + +**CRITICAL**: Before executing this command, verify: + +1. Azure CLI is installed (`az --version`) +2. Azure DevOps extension is installed (`az extension list | grep azure-devops`) +3. User has authenticated with Azure CLI (`az account show`) + +If Azure CLI is not installed, show error and installation link: +If DevOps extension is missing, auto-install it: `az extension add --name azure-devops` +If not authenticated, prompt: `az login --use-device-code` + +## Outline + +**CRITICAL WORKFLOW - Follow these steps IN ORDER:** + +This command syncs user stories from spec.md OR tasks from tasks.md to Azure DevOps as work items using Azure CLI with OAuth authentication (no PAT tokens required). + +**Two modes:** + +1. **User Story Mode** (default): Syncs user stories from spec.md as User Story work items +2. **Task Mode** (with `-FromTasks` flag): Syncs tasks from tasks.md as Task work items linked to parent User Stories + +### Step 1: Collect Azure DevOps Configuration (ASK USER IN CHAT FIRST) + +**DO THIS BEFORE ANYTHING ELSE**: Ask the user for these configuration details **in the chat**: + +1. **Check for saved configuration** first: + - Look for `~/.speckit/ado-config.json` (Windows: `C:\Users\\.speckit\ado-config.json`) + - If file exists, read and display the saved values + +2. **If configuration exists**, ask user: + + ```text + I found your saved Azure DevOps configuration: + - Organization: + - Project: + - Area Path: + + Would you like to use these settings? (yes/no) + ``` + +3. **If no configuration OR user says no**, ask these questions **ONE BY ONE** in chat: + + ```text + What is your Azure DevOps Organization name? + (e.g., "MSFTDEVICES" from https://dev.azure.com/MSFTDEVICES) + ``` + + **Wait for response, then ask:** + + ```text + What is your Azure DevOps Project name? + (e.g., "Devices") + ``` + + **Wait for response, then ask:** + + ```text + What is your Area Path? + (e.g., "Devices\SW\ASPX\CE\Portals and Services") + ``` + +4. **Store the responses** as variables for later use + +### Step 2: Locate and Parse Spec File + +**If User Story Mode (default):** + +1. Find the current feature directory (look for nearest `spec.md` in workspace) +2. Read `spec.md` and extract all user stories using pattern: + + ```markdown + ### User Story - (Priority: P<N>) + ``` + +3. **Display found stories in chat** like this: + + ```text + Found 5 user stories in spec.md: + + [1] User Story 1 - User Authentication (P1) + [2] User Story 2 - Profile Management (P1) + [3] User Story 3 - Password Reset (P2) + [4] User Story 4 - Session Management (P2) + [5] User Story 5 - Account Deletion (P3) + ``` + +**If Task Mode (with `-FromTasks` argument):** + +1. Find the current feature directory (look for nearest `tasks.md` in workspace) +2. Read `tasks.md` and extract all tasks using pattern: + + ```markdown + - [ ] T001 [P] [US1] Task description + ``` + +3. **Display found tasks grouped by User Story** in chat: + + ```text + Found 25 tasks in tasks.md: + + User Story 1 (8 tasks): + [1] T001 - Setup authentication service + [2] T002 - Create login endpoint + [3] T003 - Implement password validation + ... + + User Story 2 (12 tasks): + [8] T010 - Design user profile schema + [9] T011 - Create profile API + ... + + No parent (5 unlinked tasks): + [20] T050 - Update documentation + ... + ``` + +### Step 3: Ask User Which Items to Sync + +**CRITICAL: You MUST ask the user which items to sync. DO NOT skip this step!** + +**If User Story Mode:** + +**Ask user in chat**: + +```text +Which user stories would you like to sync to Azure DevOps? + +Options: + • all - Sync all user stories + • 1,2,3 - Sync specific stories (comma-separated) + • 1-5 - Sync a range of stories + +Your selection: +``` + +**Wait for user response**, then parse selection: + +- "all" → select all stories +- "1,3,5" → select stories 1, 3, and 5 +- "1-5" → select stories 1 through 5 +- Empty/invalid → prompt again + +**If Task Mode (-FromTasks):** + +**Ask user in chat**: + +```text +Which tasks would you like to sync to Azure DevOps? + +You can select by: + • all - Sync all tasks + • us1 - All tasks for User Story 1 + • us1,us2 - All tasks for multiple User Stories + • 1,2,3 - Specific task numbers (comma-separated) + • 1-10 - Range of task numbers + +Your selection: +``` + +**Wait for user response**, then parse selection: + +- "all" → select all tasks +- "us1" → select all tasks linked to User Story 1 +- "us1,us3" → select all tasks linked to User Story 1 and 3 +- "1,3,5" → select tasks 1, 3, and 5 +- "1-10" → select tasks 1 through 10 +- Empty/invalid → prompt again + +### Step 4: Show Confirmation + +**After getting selection, show what will be created**: + +```text +You selected X tasks to sync: + +User Story 1 (3 tasks): + - T001 - Setup authentication service + - T002 - Create login endpoint + - T003 - Implement password validation + +User Story 2 (2 tasks): + - T005 - Design user profile schema + - T006 - Create profile API + +Is this correct? (yes/no) +``` + +### Step 5a: Execute Script with Collected Parameters + +Now run the PowerShell script with all the parameters collected from chat: + +```powershell +.\scripts\powershell\create-ado-workitems-oauth.ps1 ` + -SpecFile "<path-to-spec.md>" ` + -Organization "$orgName" ` + -Project "$projectName" ` + -AreaPath "$areaPath" ` + -Stories "<selection>" ` + -NoConfirm +``` + +**Note**: Use `-NoConfirm` flag since we already confirmed with the user in chat. + +The script will: + +1. ✅ Check Azure CLI installation +2. ✅ Verify/install Azure DevOps extension +3. ✅ Authenticate via `az login` (OAuth) if needed +4. ✅ Create work items using `az boards work-item create` +5. ✅ Return work item IDs and URLs +6. ✅ Save mapping to `.speckit/azure-devops-mapping.json` +7. ✅ Update configuration file `~/.speckit/ado-config.json` + +### Step 6a: Display Results + +Show the script output which includes: + +- Real-time progress for each story +- Created work item IDs and URLs +- Summary table +- Links to Azure DevOps boards + +### Step 5b: Prepare Work Items + +For each selected user story, prepare work item data: + +```javascript +{ + type: "User Story", + title: `User Story ${storyNumber} - ${storyTitle}`, + fields: { + "System.Description": `${description}\n\n**Why this priority**: ${whyPriority}\n\n**Independent Test**: ${independentTest}`, + "Microsoft.VSTS.Common.AcceptanceCriteria": formatAcceptanceCriteria(scenarios), + "Microsoft.VSTS.Common.Priority": convertPriority(priority), // P1→1, P2→2, P3→3 + "System.Tags": `spec-kit; ${featureName}; user-story`, + "System.AreaPath": areaPath || `${project}`, + "System.IterationPath": `${project}` // Can be enhanced to detect current sprint + } +} +``` + +**Acceptance Criteria Formatting**: + +```text +Scenario 1: +Given: <given> +When: <when> +Then: <then> + +Scenario 2: +Given: <given> +When: <when> +Then: <then> +``` + +### Step 5c: Execute Script with Collected Parameters + +Now run the PowerShell/Bash script with all the parameters collected from chat: + +**PowerShell**: + +```powershell +.\scripts\powershell\create-ado-workitems-oauth.ps1 ` + -SpecFile "<path-to-spec.md or tasks.md>" ` + -Organization "$orgName" ` + -Project "$projectName" ` + -AreaPath "$areaPath" ` + -Stories "<selection>" ` + -FromTasks # Only if syncing tasks +``` + +**Bash**: + +```bash +./scripts/bash/create-ado-workitems-oauth.sh \ + --spec-file "<path-to-spec.md or tasks.md>" \ + --organization "$orgName" \ + --project "$projectName" \ + --area-path "$areaPath" \ + --stories "<selection>" \ + --from-tasks # Only if syncing tasks +``` + +The script will: + +1. ✅ Check Azure CLI installation +2. ✅ Verify/install Azure DevOps extension +3. ✅ Authenticate via `az login` (OAuth) if needed +4. ✅ Create work items using `az boards work-item create` +5. ✅ Return work item IDs and URLs +6. ✅ Save mapping to `.speckit/azure-devops-mapping.json` +7. ✅ Update configuration file `~/.speckit/ado-config.json` + +### Step 6b: Display Results + +Show the script output which includes: + +- Real-time progress for each story/task +- Created work item IDs and URLs +- Summary table +- Links to Azure DevOps boards + +1. **Error handling**: + - **Authentication failed** → Show re-authentication instructions + - **Permission denied** → Explain required Azure DevOps permissions (Contributor or higher) + - **Extension not found** → Guide user to install `ms-daw-tca.ado-productivity-copilot` + - **Network error** → Show error and suggest retry + - **Invalid field** → Show error and continue with remaining stories + +2. **Real-time feedback**: Display status as each work item is created: + + ```text + Creating User Story 1 of 3... + ✓ Created User Story 1: Display Success Notifications (#12345) + + Creating User Story 2 of 3... + ✓ Created User Story 2: Edit Notifications (#12346) + + Creating User Story 3 of 3... + ✗ Failed User Story 3: Delete Notifications (Permission denied) + ``` + +### Step 6c: Display Results + +Show summary table: + +```markdown +## ✅ Azure DevOps Sync Complete + +**Organization**: MSFTDEVICES +**Project**: Devices +**Feature**: photo-album-management +**Synced**: 3 of 4 user stories + +### Created Work Items + +| Story | Title | Priority | Work Item | Status | +|-------|-------|----------|-----------|--------| +| 1 | Create Photo Albums | P1 | [#12345](https://dev.azure.com/MSFTDEVICES/Devices/_workitems/edit/12345) | ✅ Created | +| 2 | Add Photos to Albums | P1 | [#12346](https://dev.azure.com/MSFTDEVICES/Devices/_workitems/edit/12346) | ✅ Created | +| 3 | Delete Albums | P2 | [#12347](https://dev.azure.com/MSFTDEVICES/Devices/_workitems/edit/12347) | ✅ Created | + +### View in Azure DevOps + +- **Boards**: [https://dev.azure.com/MSFTDEVICES/Devices/_boards](https://dev.azure.com/MSFTDEVICES/Devices/_boards) +- **Work Items**: [https://dev.azure.com/MSFTDEVICES/Devices/_workitems](https://dev.azure.com/MSFTDEVICES/Devices/_workitems) +- **Backlog**: [https://dev.azure.com/MSFTDEVICES/Devices/_backlogs/backlog](https://dev.azure.com/MSFTDEVICES/Devices/_backlogs/backlog) + +### Tracking + +Saved mapping to: `.speckit/azure-devops-mapping.json` + +### Next Steps + +Now that your user stories are in Azure DevOps, continue with implementation planning: + +1. **Create technical plan**: `/speckit.plan` - Generate implementation plan with research and design artifacts +2. **Generate tasks**: `/speckit.tasks` - Break down the plan into actionable tasks +3. **Sync tasks to Azure DevOps**: `/speckit.adosync -FromTasks` - Create Task work items linked to User Stories + +Or you can: +- Review work items in Azure DevOps: [View Boards](https://dev.azure.com/{organization}/{project}/_boards) +- Assign work items to team members +- Add to current sprint/iteration +``` + +**If any failures occurred**, also show: + +```markdown +### ⚠️ Errors + +| Story | Title | Error | +|-------|-------|-------| +| 4 | Share Albums | Authentication failed - please re-authenticate with Azure DevOps | +``` + +### Step 7: Save Mapping + +Save work item mapping to `.speckit/azure-devops-mapping.json`: + +```json +{ + "feature": "photo-album-management", + "organization": "MSFTDEVICES", + "project": "Devices", + "syncDate": "2026-02-27T10:30:00Z", + "workItems": [ + { + "storyNumber": 1, + "storyTitle": "Create Photo Albums", + "workItemId": 12345, + "workItemUrl": "https://dev.azure.com/MSFTDEVICES/Devices/_workitems/edit/12345", + "priority": "P1", + "status": "created" + } + ] +} +``` + +This mapping file allows: + +- Tracking which stories have been synced +- Preventing duplicate syncs +- Updating existing work items (future enhancement) + +## Error Handling + +### Authentication Required + +```text +❌ Azure CLI Not Authenticated + +You need to authenticate with Azure CLI to create work items. + +To authenticate: +1. Run: az login --use-device-code +2. Follow the prompts in your browser +3. Return to the terminal and run this command again + +The script will automatically prompt for authentication if needed. +``` + +### No Spec File Found + +```text +❌ No Spec File Found + +This command requires a spec.md file in your feature directory. + +To create a spec file, use: + /specify <your feature description> + +Example: + /specify Add photo album management with create, edit, and delete capabilities +``` + +### Invalid Story Selection + +```text +❌ Invalid Story Selection + +Valid formats: + • all - Select all user stories + • 1,2,3 - Comma-separated story numbers + • 1-5 - Range of story numbers + +Your input: "abc" + +Please try again with a valid selection. +``` + +## Key Rules + +- Check Azure CLI installed, auto-install DevOps extension if missing +- Use OAuth (`az login`) - no PAT tokens +- Save org/project/area to `~/.speckit/ado-config.json` for reuse +- Title format: User Stories = "User Story {#} - {title}", Tasks = "T{#} - {desc}" +- Priority mapping: P1→1, P2→2, P3→3, P4→4 +- Auto-link tasks to parent user stories via `[US#]` references +- Continue on failure, report all errors at end +- Save mapping to `.speckit/azure-devops-mapping.json` + +## Example Usage + +```bash +# Sync user stories from spec.md +# Agent will prompt for org/project/area interactively +/speckit.adosync + +# Sync tasks from tasks.md +/speckit.adosync -FromTasks + +# The agent will: +# 1. Ask for Azure DevOps configuration (org, project, area) +# 2. Display found user stories or tasks +# 3. Ask which ones to sync +# 4. Create work items via Azure CLI +# 5. Display results with work item IDs and URLs +``` diff --git a/templates/commands/plan.md b/templates/commands/plan.md index 00e83eabd0..57d0d13e0d 100644 --- a/templates/commands/plan.md +++ b/templates/commands/plan.md @@ -5,6 +5,15 @@ handoffs: agent: speckit.tasks prompt: Break the plan into tasks send: true + - label: Sync Tasks to Azure DevOps + agent: speckit.adosync + prompt: | + Read the tasks.md file and show me all the tasks that will be created in Azure DevOps. + Ask me which tasks I want to sync (I can say "all", specific numbers like "1,2,3", or ranges like "1-10"). + Then use the create-ado-workitems-oauth.ps1 script with the -FromTasks flag to create Task work items in Azure DevOps. + The script will automatically link tasks to their parent User Stories based on the [US#] references in the task descriptions. + Make sure to show me a preview before creating the work items. + send: true - label: Create Checklist agent: speckit.checklist prompt: Create a checklist for the following domain... diff --git a/templates/commands/specify.md b/templates/commands/specify.md index 5fd4489eee..53d51e2a51 100644 --- a/templates/commands/specify.md +++ b/templates/commands/specify.md @@ -1,6 +1,18 @@ --- description: Create or update the feature specification from a natural language feature description. handoffs: + - label: Sync to Azure DevOps + agent: speckit.adosync + prompt: | + Sync user stories from the spec.md we just created to Azure DevOps. + + The spec file path is: {spec_file_path} + + Please: + 1. Show me the list of user stories found + 2. Ask which ones I want to sync (or suggest 'all') + 3. Create the work items in Azure DevOps + send: true - label: Build Technical Plan agent: speckit.plan prompt: Create a plan for the spec. I am building with... @@ -193,7 +205,7 @@ Given that feature description, do this: d. **Update Checklist**: After each validation iteration, update the checklist file with current pass/fail status -7. Report completion with branch name, spec file path, checklist results, and readiness for the next phase (`/speckit.clarify` or `/speckit.plan`). +7. Report completion with branch name, spec file path, checklist results, and readiness for the next phase (`/speckit.clarify` or `/speckit.adosync` or `/speckit.plan`). **NOTE:** The script creates and checks out the new branch and initializes the spec file before writing. diff --git a/templates/commands/tasks.md b/templates/commands/tasks.md index 7320b6f305..56e0dcd133 100644 --- a/templates/commands/tasks.md +++ b/templates/commands/tasks.md @@ -1,6 +1,10 @@ --- description: Generate an actionable, dependency-ordered tasks.md for the feature based on available design artifacts. handoffs: + - label: Sync to Azure DevOps + agent: speckit.adosync + prompt: Sync user stories to Azure DevOps + send: false - label: Analyze For Consistency agent: speckit.analyze prompt: Run a project analysis for consistency diff --git a/tests/test_ai_skills.py b/tests/test_ai_skills.py index 3eec4a419c..1753a437ff 100644 --- a/tests/test_ai_skills.py +++ b/tests/test_ai_skills.py @@ -632,6 +632,272 @@ def test_ai_skills_flag_appears_in_help(self): assert "agent skills" in plain.lower() +class TestHandoffsFieldInSkills: + """Test handling of handoffs field in command templates for AI skills (ADO sync feature).""" + + def test_skill_generation_with_handoffs_in_template(self, project_dir): + """Skills should generate successfully from templates containing handoffs field.""" + # Create template with handoffs + cmds_dir = project_dir / ".claude" / "commands" + cmds_dir.mkdir(parents=True) + + (cmds_dir / "specify.md").write_text( + "---\n" + "description: Create specification\n" + "handoffs:\n" + " - label: Sync to Azure DevOps\n" + " agent: speckit.adosync\n" + " prompt: Sync user stories to ADO\n" + " send: true\n" + " - label: Build Plan\n" + " agent: speckit.plan\n" + " send: false\n" + "---\n" + "\n" + "# Specify Command\n" + "\n" + "Create specs.\n", + encoding="utf-8", + ) + + result = install_ai_skills(project_dir, "claude") + + assert result is True + skill_file = project_dir / ".claude" / "skills" / "speckit-specify" / "SKILL.md" + assert skill_file.exists() + content = skill_file.read_text() + + # Verify skill has valid structure + assert "name: speckit-specify" in content + assert "description:" in content + # Body content should be preserved + assert "Create specs." in content + + def test_skill_generation_with_multiline_handoffs_prompt(self, project_dir): + """Skills should generate successfully from templates with multiline handoffs prompts.""" + cmds_dir = project_dir / ".claude" / "commands" + cmds_dir.mkdir(parents=True) + + (cmds_dir / "plan.md").write_text( + "---\n" + "description: Create plan\n" + "handoffs:\n" + " - label: Sync Tasks\n" + " agent: speckit.adosync\n" + " prompt: |\n" + " Read the tasks.md file and show me all the tasks.\n" + " Ask me which tasks I want to sync.\n" + " Then create Task work items in Azure DevOps.\n" + " send: true\n" + "---\n" + "\n" + "# Plan\n" + "\n" + "Plan body.\n", + encoding="utf-8", + ) + + result = install_ai_skills(project_dir, "claude") + + assert result is True + skill_file = project_dir / ".claude" / "skills" / "speckit-plan" / "SKILL.md" + content = skill_file.read_text() + + # Verify skill was generated successfully + assert "name: speckit-plan" in content + assert "Plan body." in content + + def test_handoffs_field_parseable_in_generated_skill(self, project_dir): + """Generated SKILL.md should have valid parseable YAML regardless of source frontmatter.""" + cmds_dir = project_dir / ".claude" / "commands" + cmds_dir.mkdir(parents=True) + + (cmds_dir / "tasks.md").write_text( + "---\n" + "description: Generate tasks\n" + "handoffs:\n" + " - label: Sync to ADO\n" + " agent: speckit.adosync\n" + " prompt: Sync tasks to Azure DevOps\n" + "---\n" + "\n" + "# Tasks\n" + "\n" + "Task content.\n", + encoding="utf-8", + ) + + install_ai_skills(project_dir, "claude") + + skill_file = project_dir / ".claude" / "skills" / "speckit-tasks" / "SKILL.md" + content = skill_file.read_text() + + # Extract and parse frontmatter to verify it's valid YAML + parts = content.split("---", 2) + assert len(parts) >= 3 + parsed = yaml.safe_load(parts[1]) + + # The generated SKILL.md should have agentskills.io compliant frontmatter + assert isinstance(parsed, dict) + assert "name" in parsed + assert parsed["name"] == "speckit-tasks" + assert "description" in parsed + assert "compatibility" in parsed + + # Body should be preserved + assert "Task content." in content + + def test_templates_with_handoffs_and_scripts_fields(self, project_dir): + """Skills should generate from templates with multiple complex fields like handoffs and scripts.""" + cmds_dir = project_dir / ".claude" / "commands" + cmds_dir.mkdir(parents=True) + + (cmds_dir / "specify.md").write_text( + "---\n" + "description: Spec command\n" + "handoffs:\n" + " - label: Sync to ADO\n" + " agent: speckit.adosync\n" + " prompt: |\n" + " Sync user stories from spec.md.\n" + " The spec file path is: {spec_file_path}\n" + "scripts:\n" + " sh: scripts/bash/create-new-feature.sh\n" + " ps: scripts/powershell/create-new-feature.ps1\n" + "---\n" + "\n" + "# Specify\n" + "\n" + "Command body.\n", + encoding="utf-8", + ) + + install_ai_skills(project_dir, "claude") + + skill_file = project_dir / ".claude" / "skills" / "speckit-specify" / "SKILL.md" + content = skill_file.read_text() + + # Skill should be generated successfully + assert "name: speckit-specify" in content + assert "Command body." in content + + def test_multiple_handoffs_dont_break_skill_generation(self, project_dir): + """Templates with multiple handoffs should generate skills without errors.""" + cmds_dir = project_dir / ".claude" / "commands" + cmds_dir.mkdir(parents=True) + + (cmds_dir / "plan.md").write_text( + "---\n" + "description: Plan command\n" + "handoffs:\n" + " - label: Sync User Stories\n" + " agent: speckit.adosync\n" + " prompt: Sync user stories\n" + " send: true\n" + " - label: Sync Tasks\n" + " agent: speckit.adosync\n" + " prompt: Sync tasks with -FromTasks\n" + " send: false\n" + " - label: Create Checklist\n" + " agent: speckit.checklist\n" + " send: true\n" + "---\n" + "\n" + "# Plan\n" + "\n" + "Planning content.\n", + encoding="utf-8", + ) + + result = install_ai_skills(project_dir, "claude") + + assert result is True + skill_file = project_dir / ".claude" / "skills" / "speckit-plan" / "SKILL.md" + content = skill_file.read_text() + + # Skill should be generated with valid structure + assert "name: speckit-plan" in content + assert "Planning content." in content + + def test_handoffs_field_optional_in_skills(self, project_dir): + """Commands without handoffs should still generate valid skills.""" + cmds_dir = project_dir / ".claude" / "commands" + cmds_dir.mkdir(parents=True) + + (cmds_dir / "legacy.md").write_text( + "---\n" + "description: Legacy command without handoffs\n" + "---\n" + "\n" + "# Legacy Command\n", + encoding="utf-8", + ) + + result = install_ai_skills(project_dir, "claude") + + assert result is True + skill_file = project_dir / ".claude" / "skills" / "speckit-legacy" / "SKILL.md" + assert skill_file.exists() + content = skill_file.read_text() + + # Should have valid structure without handoffs + assert "name: speckit-legacy" in content + assert "Legacy command without handoffs" in content + + def test_empty_handoffs_array_in_skills(self, project_dir): + """Commands with empty handoffs array should generate valid skills.""" + cmds_dir = project_dir / ".claude" / "commands" + cmds_dir.mkdir(parents=True) + + (cmds_dir / "test.md").write_text( + "---\n" + "description: Test command\n" + "handoffs: []\n" + "---\n" + "\n" + "# Test\n", + encoding="utf-8", + ) + + result = install_ai_skills(project_dir, "claude") + + assert result is True + skill_file = project_dir / ".claude" / "skills" / "speckit-test" / "SKILL.md" + content = skill_file.read_text() + + # Should handle empty handoffs gracefully + assert "name: speckit-test" in content + + def test_adosync_command_generates_skill(self, project_dir): + """The adosync command itself should generate a valid skill.""" + cmds_dir = project_dir / ".claude" / "commands" + cmds_dir.mkdir(parents=True) + + (cmds_dir / "adosync.md").write_text( + "---\n" + "description: Sync selected user stories or tasks to Azure DevOps\n" + "scripts:\n" + " sh: scripts/bash/create-ado-workitems.sh\n" + " ps: scripts/powershell/create-ado-workitems.ps1\n" + "---\n" + "\n" + "# ADO Sync Command\n" + "\n" + "Sync to Azure DevOps.\n", + encoding="utf-8", + ) + + result = install_ai_skills(project_dir, "claude") + + assert result is True + skill_file = project_dir / ".claude" / "skills" / "speckit-adosync" / "SKILL.md" + assert skill_file.exists() + content = skill_file.read_text() + + assert "name: speckit-adosync" in content + assert "Azure DevOps" in content + + class TestParameterOrderingIssue: """Test fix for GitHub issue #1641: parameter ordering issues.""" diff --git a/tests/test_extensions.py b/tests/test_extensions.py index a2c4121ed4..ecb49a6b0d 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -987,3 +987,305 @@ def test_clear_cache(self, temp_dir): assert not catalog.cache_file.exists() assert not catalog.cache_metadata_file.exists() + + +# ===== Handoffs Field Tests (ADO Sync) ===== + +class TestHandoffsField: + """Test parsing and handling of handoffs field in command frontmatter (ADO sync feature).""" + + def test_parse_frontmatter_with_handoffs(self): + """Test parsing frontmatter containing handoffs field.""" + content = """--- +description: "Test command with handoffs" +handoffs: + - label: Sync to Azure DevOps + agent: speckit.adosync + prompt: Sync user stories to Azure DevOps + send: true + - label: Create Tasks + agent: speckit.tasks + prompt: Break down into tasks + send: false +--- + +# Command content +$ARGUMENTS +""" + registrar = CommandRegistrar() + frontmatter, body = registrar.parse_frontmatter(content) + + assert "handoffs" in frontmatter + assert len(frontmatter["handoffs"]) == 2 + + # Verify first handoff + assert frontmatter["handoffs"][0]["label"] == "Sync to Azure DevOps" + assert frontmatter["handoffs"][0]["agent"] == "speckit.adosync" + assert frontmatter["handoffs"][0]["prompt"] == "Sync user stories to Azure DevOps" + assert frontmatter["handoffs"][0]["send"] is True + + # Verify second handoff + assert frontmatter["handoffs"][1]["label"] == "Create Tasks" + assert frontmatter["handoffs"][1]["agent"] == "speckit.tasks" + assert frontmatter["handoffs"][1]["send"] is False + + def test_parse_frontmatter_with_multiline_handoff_prompt(self): + """Test parsing handoffs with multiline prompts.""" + content = """--- +description: "Test command" +handoffs: + - label: Sync Tasks to ADO + agent: speckit.adosync + prompt: | + Read the tasks.md file and show me all the tasks. + Ask me which tasks I want to sync (I can say "all", specific numbers like "1,2,3", or ranges like "1-10"). + Then create Task work items in Azure DevOps. + send: true +--- + +# Command +$ARGUMENTS +""" + registrar = CommandRegistrar() + frontmatter, body = registrar.parse_frontmatter(content) + + assert "handoffs" in frontmatter + assert len(frontmatter["handoffs"]) == 1 + assert "Read the tasks.md file" in frontmatter["handoffs"][0]["prompt"] + assert "Ask me which tasks" in frontmatter["handoffs"][0]["prompt"] + + def test_parse_frontmatter_with_handoffs_missing_optional_fields(self): + """Test parsing handoffs with only required fields.""" + content = """--- +description: "Minimal handoff" +handoffs: + - label: Next Step + agent: speckit.plan +--- + +# Command +$ARGUMENTS +""" + registrar = CommandRegistrar() + frontmatter, body = registrar.parse_frontmatter(content) + + assert "handoffs" in frontmatter + assert len(frontmatter["handoffs"]) == 1 + assert frontmatter["handoffs"][0]["label"] == "Next Step" + assert frontmatter["handoffs"][0]["agent"] == "speckit.plan" + assert "prompt" not in frontmatter["handoffs"][0] + assert "send" not in frontmatter["handoffs"][0] + + def test_handoffs_field_preserved_in_rendered_markdown(self): + """Test that handoffs field is preserved when rendering commands.""" + frontmatter = { + "description": "Create specification", + "handoffs": [ + { + "label": "Sync to Azure DevOps", + "agent": "speckit.adosync", + "prompt": "Sync user stories from the spec.md", + "send": True + } + ] + } + body = "# Specify Command\n\n$ARGUMENTS" + + registrar = CommandRegistrar() + rendered = registrar._render_markdown_command(frontmatter, body, "test-ext") + + # Verify handoffs is in the frontmatter + assert "handoffs:" in rendered + assert "agent: speckit.adosync" in rendered + assert "Sync user stories from the spec.md" in rendered + assert "send: true" in rendered + + def test_handoffs_field_preserved_in_rendered_toml(self): + """Test that handoffs field is NOT included in TOML format (unsupported).""" + frontmatter = { + "description": "Create specification", + "handoffs": [ + { + "label": "Sync to ADO", + "agent": "speckit.adosync", + "send": True + } + ] + } + body = "# Command\n\n{{args}}" + + registrar = CommandRegistrar() + rendered = registrar._render_toml_command(frontmatter, body, "test-ext") + + # TOML format only extracts description, not complex structures like handoffs + assert 'description = "Create specification"' in rendered + # Handoffs should not appear in TOML (it only supports simple fields) + assert "handoffs" not in rendered + + def test_register_command_with_handoffs_to_claude(self, temp_dir, project_dir): + """Test registering command with handoffs field for Claude.""" + import yaml + + # Create extension with handoffs in command + ext_dir = temp_dir / "ext-handoffs" + ext_dir.mkdir() + + manifest_data = { + "schema_version": "1.0", + "extension": { + "id": "ext-handoffs", + "name": "Extension with Handoffs", + "version": "1.0.0", + "description": "Test handoffs", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "commands": [ + { + "name": "speckit.handoffs.specify", + "file": "commands/specify.md", + } + ] + }, + } + + with open(ext_dir / "extension.yml", 'w') as f: + yaml.dump(manifest_data, f) + + (ext_dir / "commands").mkdir() + cmd_content = """--- +description: Create spec with handoffs +handoffs: + - label: Sync to ADO + agent: speckit.adosync + prompt: Sync to Azure DevOps + send: true +--- + +# Specify + +$ARGUMENTS +""" + (ext_dir / "commands" / "specify.md").write_text(cmd_content) + + # Register command + claude_dir = project_dir / ".claude" / "commands" + claude_dir.mkdir(parents=True) + + manifest = ExtensionManifest(ext_dir / "extension.yml") + registrar = CommandRegistrar() + registered = registrar.register_commands_for_claude(manifest, ext_dir, project_dir) + + # Verify registration + assert len(registered) == 1 + cmd_file = claude_dir / "speckit.handoffs.specify.md" + assert cmd_file.exists() + + # Verify handoffs field is preserved + content = cmd_file.read_text() + assert "handoffs:" in content + assert "agent: speckit.adosync" in content + assert "Sync to Azure DevOps" in content + + def test_handoffs_agent_field_format_validation(self): + """Test that agent field in handoffs uses correct format.""" + content = """--- +description: "Test" +handoffs: + - label: Invalid Agent Format + agent: invalid-agent-name +--- + +# Command +""" + registrar = CommandRegistrar() + frontmatter, body = registrar.parse_frontmatter(content) + + # Should parse successfully (validation happens elsewhere) + assert "handoffs" in frontmatter + assert frontmatter["handoffs"][0]["agent"] == "invalid-agent-name" + + def test_multiple_handoffs_with_same_agent(self): + """Test command with multiple handoffs referencing the same agent.""" + content = """--- +description: "Multiple handoffs" +handoffs: + - label: Sync User Stories + agent: speckit.adosync + prompt: Sync user stories + send: true + - label: Sync Tasks + agent: speckit.adosync + prompt: Sync tasks with -FromTasks flag + send: false +--- + +# Command +$ARGUMENTS +""" + registrar = CommandRegistrar() + frontmatter, body = registrar.parse_frontmatter(content) + + assert len(frontmatter["handoffs"]) == 2 + assert frontmatter["handoffs"][0]["agent"] == "speckit.adosync" + assert frontmatter["handoffs"][1]["agent"] == "speckit.adosync" + assert frontmatter["handoffs"][0]["label"] != frontmatter["handoffs"][1]["label"] + + def test_handoffs_with_interpolation_placeholders(self): + """Test handoffs with prompt containing variable placeholders.""" + content = """--- +description: "Command with variable interpolation" +handoffs: + - label: Sync to ADO + agent: speckit.adosync + prompt: | + Sync user stories from the spec.md we just created. + + The spec file path is: {spec_file_path} + + Please: + 1. Show me the list of user stories found + 2. Ask which ones I want to sync (or suggest 'all') + 3. Create the work items in Azure DevOps + send: true +--- + +# Command +$ARGUMENTS +""" + registrar = CommandRegistrar() + frontmatter, body = registrar.parse_frontmatter(content) + + assert "handoffs" in frontmatter + assert "{spec_file_path}" in frontmatter["handoffs"][0]["prompt"] + + def test_empty_handoffs_array(self): + """Test command with empty handoffs array.""" + content = """--- +description: "No handoffs" +handoffs: [] +--- + +# Command +$ARGUMENTS +""" + registrar = CommandRegistrar() + frontmatter, body = registrar.parse_frontmatter(content) + + assert "handoffs" in frontmatter + assert len(frontmatter["handoffs"]) == 0 + + def test_handoffs_field_not_present(self): + """Test command without handoffs field (backwards compatibility).""" + content = """--- +description: "Legacy command without handoffs" +--- + +# Command +$ARGUMENTS +""" + registrar = CommandRegistrar() + frontmatter, body = registrar.parse_frontmatter(content) + + # Should not have handoffs field + assert "handoffs" not in frontmatter From 8a82c7bb7b6d2a3cbd39fc5a44305839f79178ef Mon Sep 17 00:00:00 2001 From: pragya247 <pragya@microsoft.com> Date: Tue, 3 Mar 2026 10:12:12 +0530 Subject: [PATCH 2/6] Resolving the comments --- scripts/bash/create-ado-workitems.sh | 48 ++++++++-- scripts/powershell/create-ado-workitems.ps1 | 92 +++---------------- .../.speckit/azure-devops-mapping.json | 26 ++++++ specs/001-hello-world/spec.md | 90 ++++++++++++++++++ templates/commands/adosync.md | 11 +-- templates/commands/plan.md | 2 +- templates/commands/tasks.md | 2 +- 7 files changed, 173 insertions(+), 98 deletions(-) create mode 100644 specs/001-hello-world/.speckit/azure-devops-mapping.json create mode 100644 specs/001-hello-world/spec.md diff --git a/scripts/bash/create-ado-workitems.sh b/scripts/bash/create-ado-workitems.sh index e35ea5a285..de53748835 100644 --- a/scripts/bash/create-ado-workitems.sh +++ b/scripts/bash/create-ado-workitems.sh @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash # Create Azure DevOps work items using Azure CLI with OAuth (no PAT required) # Requires: Azure CLI with devops extension @@ -57,6 +57,16 @@ if ! command -v az &> /dev/null; then exit 1 fi +# Check if jq is installed +if ! command -v jq &> /dev/null; then + echo "Error: jq not found. This script requires jq for JSON parsing." + echo "Install jq:" + echo " - Ubuntu/Debian: sudo apt-get install jq" + echo " - macOS: brew install jq" + echo " - More info: https://stedolan.github.io/jq/download/" + 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..." @@ -236,6 +246,8 @@ echo "" declare -a CREATED_IDS declare -a CREATED_URLS declare -a CREATED_STORY_REFS +declare -a CREATED_TITLES +declare -a CREATED_PRIORITIES # Load parent story mappings if in FROM_TASKS mode declare -A PARENT_MAPPING @@ -244,8 +256,8 @@ if [[ "$FROM_TASKS" == true ]]; then 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') + 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" @@ -268,7 +280,7 @@ for selected in "${SELECTED_STORIES[@]}"; do desc="${TASK_DESCRIPTIONS[$i]}" story_ref="${TASK_STORY[$i]}" - work_item_title="$desc" + work_item_title="T${num} - $desc" item_type="Task" # Clean field values @@ -282,7 +294,8 @@ for selected in "${SELECTED_STORIES[@]}"; do echo "Creating Task $num: ${desc:0:60}..." - # Build az command + # Build az command (temporarily disable set -e for error handling) + set +e result=$(az boards work-item create \ --type "Task" \ --title "$clean_title" \ @@ -295,8 +308,10 @@ for selected in "${SELECTED_STORIES[@]}"; do "Microsoft.VSTS.Scheduling.OriginalEstimate=0" \ ${AREA_PATH:+"System.AreaPath=$AREA_PATH"} \ --output json 2>&1) + exit_code=$? + set -e - if [[ $? -eq 0 ]] && [[ ! "$result" =~ ERROR ]]; then + if [[ $exit_code -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" @@ -307,6 +322,8 @@ for selected in "${SELECTED_STORIES[@]}"; do CREATED_IDS+=("$work_item_id") CREATED_URLS+=("$work_item_url") CREATED_STORY_REFS+=("$story_ref") + CREATED_TITLES+=("$desc") + CREATED_PRIORITIES+=("N/A") else echo " [FAIL] Failed to create work item" echo " Error: $result" @@ -339,7 +356,8 @@ for selected in "${SELECTED_STORIES[@]}"; do echo "Creating User Story $num: $title..." - # Build az command + # Build az command (temporarily disable set -e for error handling) + set +e result=$(az boards work-item create \ --type "User Story" \ --title "$clean_title" \ @@ -353,8 +371,10 @@ for selected in "${SELECTED_STORIES[@]}"; do "System.AssignedTo=" \ ${AREA_PATH:+"System.AreaPath=$AREA_PATH"} \ --output json 2>&1) + exit_code=$? + set -e - if [[ $? -eq 0 ]] && [[ ! "$result" =~ ERROR ]]; then + if [[ $exit_code -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" @@ -364,6 +384,8 @@ for selected in "${SELECTED_STORIES[@]}"; do CREATED_IDS+=("$work_item_id") CREATED_URLS+=("$work_item_url") + CREATED_TITLES+=("$title") + CREATED_PRIORITIES+=("$priority") else echo " [FAIL] Failed to create work item" echo " Error: $result" @@ -428,8 +450,14 @@ if [[ ${#CREATED_IDS[@]} -gt 0 ]]; then echo "" for i in "${!CREATED_IDS[@]}"; do - idx=$((i)) - echo " [${SELECTED_STORIES[$idx]}] ${STORY_TITLES[$idx]} (P${STORY_PRIORITIES[$idx]})" + if [[ "$FROM_TASKS" == true ]]; then + echo " Task [${SELECTED_STORIES[$i]}]: ${CREATED_TITLES[$i]}" + if [[ -n "${CREATED_STORY_REFS[$i]}" ]]; then + echo " Parent: US${CREATED_STORY_REFS[$i]}" + fi + else + echo " [${SELECTED_STORIES[$i]}] ${CREATED_TITLES[$i]} (P${CREATED_PRIORITIES[$i]})" + fi echo " Work Item: #${CREATED_IDS[$i]}" echo " Link: ${CREATED_URLS[$i]}" echo "" diff --git a/scripts/powershell/create-ado-workitems.ps1 b/scripts/powershell/create-ado-workitems.ps1 index d7a9ef0cac..ac0b7d3a84 100644 --- a/scripts/powershell/create-ado-workitems.ps1 +++ b/scripts/powershell/create-ado-workitems.ps1 @@ -82,7 +82,7 @@ Write-Host "" az devops configure --defaults organization="https://dev.azure.com/$Organization" project="$Project" # Parse user stories from spec.md -function Parse-UserStories { +function Get-UserStories { param([string]$FilePath) if (-not (Test-Path $FilePath)) { @@ -95,9 +95,9 @@ function Parse-UserStories { # 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) + $regexMatches = [regex]::Matches($content, $pattern) - foreach ($match in $matches) { + foreach ($match in $regexMatches) { $storyNum = $match.Groups[1].Value $title = $match.Groups[2].Value.Trim() $priority = $match.Groups[3].Value @@ -156,7 +156,7 @@ function Parse-UserStories { } # Parse tasks from tasks.md file -function Parse-Tasks { +function Get-Tasks { param([string]$FilePath) if (-not (Test-Path $FilePath)) { @@ -168,80 +168,12 @@ function Parse-Tasks { $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) + $regexMatches = [regex]::Matches($content, $pattern) - Write-Verbose "Found $($matches.Count) task matches in tasks file" + Write-Verbose "Found $($regexMatches.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) { + foreach ($match in $regexMatches) { $taskNum = $match.Groups[1].Value $story = $match.Groups[2].Value.Trim() $description = $match.Groups[3].Value.Trim() @@ -322,11 +254,11 @@ $featureName = Split-Path (Split-Path $SpecFile -Parent) -Leaf # Parse and filter items (tasks or stories) if ($FromTasks) { - $allStories = Parse-Tasks -FilePath $SpecFile + $allStories = Get-Tasks -FilePath $SpecFile $itemType = "Task" $itemLabel = "tasks" } else { - $allStories = Parse-UserStories -FilePath $SpecFile + $allStories = Get-UserStories -FilePath $SpecFile $itemType = "User Story" $itemLabel = "user stories" } @@ -347,7 +279,7 @@ foreach ($story in $selectedStories) { if ($story.Description.Length -gt 80) { $desc += "..." } Write-Host " $desc" -ForegroundColor Gray } else { - Write-Host " Story: $($story.StoryNumber)" -ForegroundColor Gray + Write-Host " Story: $($story.Story)" -ForegroundColor Gray } } Write-Host "" @@ -494,7 +426,9 @@ if ($createdItems.Count -gt 0) { Write-Host "Organization: $Organization" Write-Host "Project: $Project" Write-Host "Feature: $featureName" - Write-Host "Created: $($createdItems.Count) of $($stories.Count) user stories" + $selectionLabel = if ($FromTasks) { "tasks" } else { "user stories" } + $selectedCount = if ($null -ne $selectedStories) { $selectedStories.Count } else { 0 } + Write-Host "Created: $($createdItems.Count) of $selectedCount $selectionLabel" Write-Host "" Write-Host "Created Work Items:" Write-Host "" diff --git a/specs/001-hello-world/.speckit/azure-devops-mapping.json b/specs/001-hello-world/.speckit/azure-devops-mapping.json new file mode 100644 index 0000000000..d0e9c84a99 --- /dev/null +++ b/specs/001-hello-world/.speckit/azure-devops-mapping.json @@ -0,0 +1,26 @@ +{ + "syncDate": "2026-03-03T10:06:25.3985315+05:30", + "organization": "MSFTDEVICES", + "project": "Devices", + "workItems": [ + { + "StoryNumber": "1", + "Title": "Display Welcome Message", + "Priority": "P1", + "WorkItemId": 5388270, + "WorkItemUrl": "https://dev.azure.com/MSFTDEVICES/Devices/_workitems/edit/5388270", + "ParentStoryNumber": null, + "Status": "Created" + }, + { + "StoryNumber": "2", + "Title": "Simple Documentation Page", + "Priority": "P2", + "WorkItemId": 5388271, + "WorkItemUrl": "https://dev.azure.com/MSFTDEVICES/Devices/_workitems/edit/5388271", + "ParentStoryNumber": null, + "Status": "Created" + } + ], + "feature": "001-hello-world" +} diff --git a/specs/001-hello-world/spec.md b/specs/001-hello-world/spec.md new file mode 100644 index 0000000000..f224a4d55e --- /dev/null +++ b/specs/001-hello-world/spec.md @@ -0,0 +1,90 @@ +# Feature Specification: Hello World Documentation + +**Feature Branch**: `001-hello-world` +**Created**: March 3, 2026 +**Status**: Draft +**Input**: User description: "create hello world doc" + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Display Welcome Message (Priority: P1) + +New users visiting the application should see a friendly "Hello World" message that confirms the application is running correctly. + +**Why this priority**: This is the most fundamental piece - it demonstrates the application is working and provides immediate feedback to users. + +**Independent Test**: Can be fully tested by launching the application and verifying the "Hello World" message appears on screen, delivering immediate confirmation of application functionality. + +**Acceptance Scenarios**: + +1. **Given** the application is not running, **When** the user starts the application, **Then** a "Hello World" message is displayed prominently +2. **Given** the application displays the message, **When** the user refreshes or reloads, **Then** the "Hello World" message remains visible +3. **Given** the application is running, **When** the user accesses the main page, **Then** the message appears within 1 second + +--- + +### User Story 2 - Simple Documentation Page (Priority: P2) + +Users should be able to access a simple documentation page that explains what the Hello World application demonstrates. + +**Why this priority**: Provides contextual information and helps users understand the purpose of the example. + +**Independent Test**: Can be tested by navigating to the documentation page and verifying that explanatory text about the Hello World application is displayed. + +**Acceptance Scenarios**: + +1. **Given** the user is on the main page, **When** they click a "Documentation" link, **Then** they are taken to a page explaining the Hello World application +2. **Given** the user is on the documentation page, **When** they read the content, **Then** they find clear explanations of what Hello World demonstrates +3. **Given** the documentation page is displayed, **When** the user wants to return, **Then** a "Back" or "Home" link is available + +--- + +### User Story 3 - Customizable Greeting (Priority: P3) + +Users should be able to customize the greeting message to personalize their experience. + +**Why this priority**: Adds interactivity and demonstrates basic user input handling, but not essential for the core functionality. + +**Independent Test**: Can be tested by providing a custom name and verifying that the message changes from "Hello World" to "Hello [Name]". + +**Acceptance Scenarios**: + +1. **Given** the user is on the main page, **When** they enter their name in an input field and submit, **Then** the message changes to "Hello [Name]" +2. **Given** the user has customized the greeting, **When** they clear the input, **Then** the message reverts to "Hello World" +3. **Given** the user enters special characters or very long names, **When** they submit, **Then** the application handles input gracefully without errors + +--- + +### Edge Cases + +- What happens when the application is accessed on different devices (mobile, tablet, desktop)? +- How does the system handle multiple simultaneous users? +- What happens if JavaScript is disabled in the browser? +- How does the application behave with different character sets or internationalization? + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: System MUST display "Hello World" message on the main page +- **FR-002**: System MUST render the message in a clearly visible format (large, centered text) +- **FR-003**: System MUST load and display the message within 1 second of page load +- **FR-004**: Users MUST be able to access documentation explaining the Hello World application +- **FR-005**: System MUST support optional customization of the greeting message +- **FR-006**: System MUST handle empty or invalid input gracefully without crashing +- **FR-007**: System MUST be accessible on modern web browsers (Chrome, Firefox, Safari, Edge) + +### Key Entities *(include if feature involves data)* + +- **Greeting**: The message displayed to users, defaults to "Hello World", can be customized +- **User Input**: Optional name or text provided by users to personalize the greeting + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: New users can see the "Hello World" message immediately upon accessing the application (within 1 second) +- **SC-002**: 100% of users successfully see the greeting on first visit across all supported browsers +- **SC-003**: Documentation page loads within 1 second and contains at least 100 words of explanatory content +- **SC-004**: Users can customize the greeting and see their changes reflected within 500ms of submission +- **SC-005**: Application maintains 99.9% uptime and handles at least 100 concurrent users without degradation diff --git a/templates/commands/adosync.md b/templates/commands/adosync.md index 5d8670989d..ddd6851ace 100644 --- a/templates/commands/adosync.md +++ b/templates/commands/adosync.md @@ -207,17 +207,14 @@ Is this correct? (yes/no) Now run the PowerShell script with all the parameters collected from chat: ```powershell -.\scripts\powershell\create-ado-workitems-oauth.ps1 ` +.\scripts\powershell\create-ado-workitems.ps1 ` -SpecFile "<path-to-spec.md>" ` -Organization "$orgName" ` -Project "$projectName" ` -AreaPath "$areaPath" ` - -Stories "<selection>" ` - -NoConfirm + -Stories "<selection>" ``` -**Note**: Use `-NoConfirm` flag since we already confirmed with the user in chat. - The script will: 1. ✅ Check Azure CLI installation @@ -277,7 +274,7 @@ Now run the PowerShell/Bash script with all the parameters collected from chat: **PowerShell**: ```powershell -.\scripts\powershell\create-ado-workitems-oauth.ps1 ` +.\scripts\powershell\create-ado-workitems.ps1 ` -SpecFile "<path-to-spec.md or tasks.md>" ` -Organization "$orgName" ` -Project "$projectName" ` @@ -289,7 +286,7 @@ Now run the PowerShell/Bash script with all the parameters collected from chat: **Bash**: ```bash -./scripts/bash/create-ado-workitems-oauth.sh \ +./scripts/bash/create-ado-workitems.sh \ --spec-file "<path-to-spec.md or tasks.md>" \ --organization "$orgName" \ --project "$projectName" \ diff --git a/templates/commands/plan.md b/templates/commands/plan.md index 57d0d13e0d..fd3a7475fd 100644 --- a/templates/commands/plan.md +++ b/templates/commands/plan.md @@ -10,7 +10,7 @@ handoffs: prompt: | Read the tasks.md file and show me all the tasks that will be created in Azure DevOps. Ask me which tasks I want to sync (I can say "all", specific numbers like "1,2,3", or ranges like "1-10"). - Then use the create-ado-workitems-oauth.ps1 script with the -FromTasks flag to create Task work items in Azure DevOps. + Then use the scripts/powershell/create-ado-workitems.ps1 script with the -FromTasks flag to create Task work items in Azure DevOps. The script will automatically link tasks to their parent User Stories based on the [US#] references in the task descriptions. Make sure to show me a preview before creating the work items. send: true diff --git a/templates/commands/tasks.md b/templates/commands/tasks.md index 56e0dcd133..38f0008154 100644 --- a/templates/commands/tasks.md +++ b/templates/commands/tasks.md @@ -3,7 +3,7 @@ description: Generate an actionable, dependency-ordered tasks.md for the feature handoffs: - label: Sync to Azure DevOps agent: speckit.adosync - prompt: Sync user stories to Azure DevOps + prompt: Sync generated tasks (tasks.md) to Azure DevOps send: false - label: Analyze For Consistency agent: speckit.analyze From 60d89523227c30c7b087b012ec5672128425339d Mon Sep 17 00:00:00 2001 From: pragya247 <pragya@microsoft.com> Date: Thu, 5 Mar 2026 13:03:07 +0530 Subject: [PATCH 3/6] Added details of Azure DevOps extention --- extensions/README.md | 1 + extensions/catalog.community.json | 35 ++++++++++++++++++++++++++++++- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/extensions/README.md b/extensions/README.md index 574144a4d1..6bbdbbabc7 100644 --- a/extensions/README.md +++ b/extensions/README.md @@ -74,6 +74,7 @@ The following community-contributed extensions are available in [`catalog.commun |-----------|---------|-----| | V-Model Extension Pack | Enforces V-Model paired generation of development specs and test specs with full traceability | [spec-kit-v-model](https://github.com/leocamello/spec-kit-v-model) | | Cleanup Extension | Post-implementation quality gate that reviews changes, fixes small issues (scout rule), creates tasks for medium issues, and generates analysis for large issues | [spec-kit-cleanup](https://github.com/dsrednicki/spec-kit-cleanup) | +| Azure DevOps Integration | Sync user stories and tasks to Azure DevOps work items using OAuth authentication | [spec-kit-azure-devops](https://github.com/pragya247/spec-kit-azure-devops) | ## Adding Your Extension diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index 4ab408b7c5..4c77e66c98 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -1,8 +1,41 @@ { "schema_version": "1.0", - "updated_at": "2026-02-24T00:00:00Z", + "updated_at": "2026-03-03T00:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json", "extensions": { + "azure-devops": { + "name": "Azure DevOps Integration", + "id": "azure-devops", + "description": "Sync user stories and tasks to Azure DevOps work items using OAuth authentication", + "author": "pragya247", + "version": "1.0.0", + "download_url": "https://github.com/pragya247/spec-kit-azure-devops/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/pragya247/spec-kit-azure-devops", + "homepage": "https://github.com/pragya247/spec-kit-azure-devops", + "documentation": "https://github.com/pragya247/spec-kit-azure-devops/blob/main/README.md", + "changelog": "https://github.com/pragya247/spec-kit-azure-devops/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.1.0", + "tools": [ + { + "name": "az", + "version": ">=2.0.0", + "required": true + } + ] + }, + "provides": { + "commands": 1, + "hooks": 1 + }, + "tags": ["azure", "devops", "project-management", "work-items", "issue-tracking"], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-03-03T00:00:00Z", + "updated_at": "2026-03-03T00:00:00Z" + }, "cleanup": { "name": "Cleanup Extension", "id": "cleanup", From 1b3454a0ac2689e2df01bffb62b783a9177e26f1 Mon Sep 17 00:00:00 2001 From: pragya247 <pragya@microsoft.com> Date: Thu, 5 Mar 2026 13:09:25 +0530 Subject: [PATCH 4/6] t status t revert --abort Revert "Add Azure DevOps work item synchronization with handoffs system" This reverts commit 39ac7e48d6f3bfb6d26536a0c7d524e9091a10cb. --- scripts/bash/create-ado-workitems.sh | 497 ----------------- scripts/powershell/create-ado-workitems.ps1 | 508 ------------------ .../.speckit/azure-devops-mapping.json | 26 - specs/001-hello-world/spec.md | 90 ---- templates/commands/adosync.md | 493 ----------------- templates/commands/plan.md | 9 - templates/commands/specify.md | 14 +- templates/commands/tasks.md | 4 - tests/test_ai_skills.py | 266 --------- tests/test_extensions.py | 302 ----------- 10 files changed, 1 insertion(+), 2208 deletions(-) delete mode 100644 scripts/bash/create-ado-workitems.sh delete mode 100644 scripts/powershell/create-ado-workitems.ps1 delete mode 100644 specs/001-hello-world/.speckit/azure-devops-mapping.json delete mode 100644 specs/001-hello-world/spec.md delete mode 100644 templates/commands/adosync.md diff --git a/scripts/bash/create-ado-workitems.sh b/scripts/bash/create-ado-workitems.sh deleted file mode 100644 index de53748835..0000000000 --- a/scripts/bash/create-ado-workitems.sh +++ /dev/null @@ -1,497 +0,0 @@ -#!/usr/bin/env 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 jq is installed -if ! command -v jq &> /dev/null; then - echo "Error: jq not found. This script requires jq for JSON parsing." - echo "Install jq:" - echo " - Ubuntu/Debian: sudo apt-get install jq" - echo " - macOS: brew install jq" - echo " - More info: https://stedolan.github.io/jq/download/" - 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 -declare -a CREATED_TITLES -declare -a CREATED_PRIORITIES - -# 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="T${num} - $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 (temporarily disable set -e for error handling) - set +e - 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) - exit_code=$? - set -e - - if [[ $exit_code -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") - CREATED_TITLES+=("$desc") - CREATED_PRIORITIES+=("N/A") - 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 (temporarily disable set -e for error handling) - set +e - 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) - exit_code=$? - set -e - - if [[ $exit_code -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_TITLES+=("$title") - CREATED_PRIORITIES+=("$priority") - 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 - if [[ "$FROM_TASKS" == true ]]; then - echo " Task [${SELECTED_STORIES[$i]}]: ${CREATED_TITLES[$i]}" - if [[ -n "${CREATED_STORY_REFS[$i]}" ]]; then - echo " Parent: US${CREATED_STORY_REFS[$i]}" - fi - else - echo " [${SELECTED_STORIES[$i]}] ${CREATED_TITLES[$i]} (P${CREATED_PRIORITIES[$i]})" - fi - 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 diff --git a/scripts/powershell/create-ado-workitems.ps1 b/scripts/powershell/create-ado-workitems.ps1 deleted file mode 100644 index ac0b7d3a84..0000000000 --- a/scripts/powershell/create-ado-workitems.ps1 +++ /dev/null @@ -1,508 +0,0 @@ -#!/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 Get-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+)\)' - $regexMatches = [regex]::Matches($content, $pattern) - - foreach ($match in $regexMatches) { - $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 Get-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+)?(.+)' - $regexMatches = [regex]::Matches($content, $pattern) - - Write-Verbose "Found $($regexMatches.Count) task matches in tasks file" - - foreach ($match in $regexMatches) { - $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 = Get-Tasks -FilePath $SpecFile - $itemType = "Task" - $itemLabel = "tasks" -} else { - $allStories = Get-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.Story)" -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" - $selectionLabel = if ($FromTasks) { "tasks" } else { "user stories" } - $selectedCount = if ($null -ne $selectedStories) { $selectedStories.Count } else { 0 } - Write-Host "Created: $($createdItems.Count) of $selectedCount $selectionLabel" - 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 "" -} diff --git a/specs/001-hello-world/.speckit/azure-devops-mapping.json b/specs/001-hello-world/.speckit/azure-devops-mapping.json deleted file mode 100644 index d0e9c84a99..0000000000 --- a/specs/001-hello-world/.speckit/azure-devops-mapping.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "syncDate": "2026-03-03T10:06:25.3985315+05:30", - "organization": "MSFTDEVICES", - "project": "Devices", - "workItems": [ - { - "StoryNumber": "1", - "Title": "Display Welcome Message", - "Priority": "P1", - "WorkItemId": 5388270, - "WorkItemUrl": "https://dev.azure.com/MSFTDEVICES/Devices/_workitems/edit/5388270", - "ParentStoryNumber": null, - "Status": "Created" - }, - { - "StoryNumber": "2", - "Title": "Simple Documentation Page", - "Priority": "P2", - "WorkItemId": 5388271, - "WorkItemUrl": "https://dev.azure.com/MSFTDEVICES/Devices/_workitems/edit/5388271", - "ParentStoryNumber": null, - "Status": "Created" - } - ], - "feature": "001-hello-world" -} diff --git a/specs/001-hello-world/spec.md b/specs/001-hello-world/spec.md deleted file mode 100644 index f224a4d55e..0000000000 --- a/specs/001-hello-world/spec.md +++ /dev/null @@ -1,90 +0,0 @@ -# Feature Specification: Hello World Documentation - -**Feature Branch**: `001-hello-world` -**Created**: March 3, 2026 -**Status**: Draft -**Input**: User description: "create hello world doc" - -## User Scenarios & Testing *(mandatory)* - -### User Story 1 - Display Welcome Message (Priority: P1) - -New users visiting the application should see a friendly "Hello World" message that confirms the application is running correctly. - -**Why this priority**: This is the most fundamental piece - it demonstrates the application is working and provides immediate feedback to users. - -**Independent Test**: Can be fully tested by launching the application and verifying the "Hello World" message appears on screen, delivering immediate confirmation of application functionality. - -**Acceptance Scenarios**: - -1. **Given** the application is not running, **When** the user starts the application, **Then** a "Hello World" message is displayed prominently -2. **Given** the application displays the message, **When** the user refreshes or reloads, **Then** the "Hello World" message remains visible -3. **Given** the application is running, **When** the user accesses the main page, **Then** the message appears within 1 second - ---- - -### User Story 2 - Simple Documentation Page (Priority: P2) - -Users should be able to access a simple documentation page that explains what the Hello World application demonstrates. - -**Why this priority**: Provides contextual information and helps users understand the purpose of the example. - -**Independent Test**: Can be tested by navigating to the documentation page and verifying that explanatory text about the Hello World application is displayed. - -**Acceptance Scenarios**: - -1. **Given** the user is on the main page, **When** they click a "Documentation" link, **Then** they are taken to a page explaining the Hello World application -2. **Given** the user is on the documentation page, **When** they read the content, **Then** they find clear explanations of what Hello World demonstrates -3. **Given** the documentation page is displayed, **When** the user wants to return, **Then** a "Back" or "Home" link is available - ---- - -### User Story 3 - Customizable Greeting (Priority: P3) - -Users should be able to customize the greeting message to personalize their experience. - -**Why this priority**: Adds interactivity and demonstrates basic user input handling, but not essential for the core functionality. - -**Independent Test**: Can be tested by providing a custom name and verifying that the message changes from "Hello World" to "Hello [Name]". - -**Acceptance Scenarios**: - -1. **Given** the user is on the main page, **When** they enter their name in an input field and submit, **Then** the message changes to "Hello [Name]" -2. **Given** the user has customized the greeting, **When** they clear the input, **Then** the message reverts to "Hello World" -3. **Given** the user enters special characters or very long names, **When** they submit, **Then** the application handles input gracefully without errors - ---- - -### Edge Cases - -- What happens when the application is accessed on different devices (mobile, tablet, desktop)? -- How does the system handle multiple simultaneous users? -- What happens if JavaScript is disabled in the browser? -- How does the application behave with different character sets or internationalization? - -## Requirements *(mandatory)* - -### Functional Requirements - -- **FR-001**: System MUST display "Hello World" message on the main page -- **FR-002**: System MUST render the message in a clearly visible format (large, centered text) -- **FR-003**: System MUST load and display the message within 1 second of page load -- **FR-004**: Users MUST be able to access documentation explaining the Hello World application -- **FR-005**: System MUST support optional customization of the greeting message -- **FR-006**: System MUST handle empty or invalid input gracefully without crashing -- **FR-007**: System MUST be accessible on modern web browsers (Chrome, Firefox, Safari, Edge) - -### Key Entities *(include if feature involves data)* - -- **Greeting**: The message displayed to users, defaults to "Hello World", can be customized -- **User Input**: Optional name or text provided by users to personalize the greeting - -## Success Criteria *(mandatory)* - -### Measurable Outcomes - -- **SC-001**: New users can see the "Hello World" message immediately upon accessing the application (within 1 second) -- **SC-002**: 100% of users successfully see the greeting on first visit across all supported browsers -- **SC-003**: Documentation page loads within 1 second and contains at least 100 words of explanatory content -- **SC-004**: Users can customize the greeting and see their changes reflected within 500ms of submission -- **SC-005**: Application maintains 99.9% uptime and handles at least 100 concurrent users without degradation diff --git a/templates/commands/adosync.md b/templates/commands/adosync.md deleted file mode 100644 index ddd6851ace..0000000000 --- a/templates/commands/adosync.md +++ /dev/null @@ -1,493 +0,0 @@ ---- -description: Sync selected user stories or tasks to Azure DevOps -scripts: - sh: scripts/bash/create-ado-workitems.sh - ps: scripts/powershell/create-ado-workitems.ps1 ---- - -## User Input - -```text -$ARGUMENTS -``` - -You **MUST** consider the user input before proceeding (if not empty). - -## Prerequisites - -**CRITICAL**: Before executing this command, verify: - -1. Azure CLI is installed (`az --version`) -2. Azure DevOps extension is installed (`az extension list | grep azure-devops`) -3. User has authenticated with Azure CLI (`az account show`) - -If Azure CLI is not installed, show error and installation link: <https://aka.ms/installazurecliwindows> -If DevOps extension is missing, auto-install it: `az extension add --name azure-devops` -If not authenticated, prompt: `az login --use-device-code` - -## Outline - -**CRITICAL WORKFLOW - Follow these steps IN ORDER:** - -This command syncs user stories from spec.md OR tasks from tasks.md to Azure DevOps as work items using Azure CLI with OAuth authentication (no PAT tokens required). - -**Two modes:** - -1. **User Story Mode** (default): Syncs user stories from spec.md as User Story work items -2. **Task Mode** (with `-FromTasks` flag): Syncs tasks from tasks.md as Task work items linked to parent User Stories - -### Step 1: Collect Azure DevOps Configuration (ASK USER IN CHAT FIRST) - -**DO THIS BEFORE ANYTHING ELSE**: Ask the user for these configuration details **in the chat**: - -1. **Check for saved configuration** first: - - Look for `~/.speckit/ado-config.json` (Windows: `C:\Users\<username>\.speckit\ado-config.json`) - - If file exists, read and display the saved values - -2. **If configuration exists**, ask user: - - ```text - I found your saved Azure DevOps configuration: - - Organization: <saved-org> - - Project: <saved-project> - - Area Path: <saved-area-path> - - Would you like to use these settings? (yes/no) - ``` - -3. **If no configuration OR user says no**, ask these questions **ONE BY ONE** in chat: - - ```text - What is your Azure DevOps Organization name? - (e.g., "MSFTDEVICES" from https://dev.azure.com/MSFTDEVICES) - ``` - - **Wait for response, then ask:** - - ```text - What is your Azure DevOps Project name? - (e.g., "Devices") - ``` - - **Wait for response, then ask:** - - ```text - What is your Area Path? - (e.g., "Devices\SW\ASPX\CE\Portals and Services") - ``` - -4. **Store the responses** as variables for later use - -### Step 2: Locate and Parse Spec File - -**If User Story Mode (default):** - -1. Find the current feature directory (look for nearest `spec.md` in workspace) -2. Read `spec.md` and extract all user stories using pattern: - - ```markdown - ### User Story <N> - <Title> (Priority: P<N>) - ``` - -3. **Display found stories in chat** like this: - - ```text - Found 5 user stories in spec.md: - - [1] User Story 1 - User Authentication (P1) - [2] User Story 2 - Profile Management (P1) - [3] User Story 3 - Password Reset (P2) - [4] User Story 4 - Session Management (P2) - [5] User Story 5 - Account Deletion (P3) - ``` - -**If Task Mode (with `-FromTasks` argument):** - -1. Find the current feature directory (look for nearest `tasks.md` in workspace) -2. Read `tasks.md` and extract all tasks using pattern: - - ```markdown - - [ ] T001 [P] [US1] Task description - ``` - -3. **Display found tasks grouped by User Story** in chat: - - ```text - Found 25 tasks in tasks.md: - - User Story 1 (8 tasks): - [1] T001 - Setup authentication service - [2] T002 - Create login endpoint - [3] T003 - Implement password validation - ... - - User Story 2 (12 tasks): - [8] T010 - Design user profile schema - [9] T011 - Create profile API - ... - - No parent (5 unlinked tasks): - [20] T050 - Update documentation - ... - ``` - -### Step 3: Ask User Which Items to Sync - -**CRITICAL: You MUST ask the user which items to sync. DO NOT skip this step!** - -**If User Story Mode:** - -**Ask user in chat**: - -```text -Which user stories would you like to sync to Azure DevOps? - -Options: - • all - Sync all user stories - • 1,2,3 - Sync specific stories (comma-separated) - • 1-5 - Sync a range of stories - -Your selection: -``` - -**Wait for user response**, then parse selection: - -- "all" → select all stories -- "1,3,5" → select stories 1, 3, and 5 -- "1-5" → select stories 1 through 5 -- Empty/invalid → prompt again - -**If Task Mode (-FromTasks):** - -**Ask user in chat**: - -```text -Which tasks would you like to sync to Azure DevOps? - -You can select by: - • all - Sync all tasks - • us1 - All tasks for User Story 1 - • us1,us2 - All tasks for multiple User Stories - • 1,2,3 - Specific task numbers (comma-separated) - • 1-10 - Range of task numbers - -Your selection: -``` - -**Wait for user response**, then parse selection: - -- "all" → select all tasks -- "us1" → select all tasks linked to User Story 1 -- "us1,us3" → select all tasks linked to User Story 1 and 3 -- "1,3,5" → select tasks 1, 3, and 5 -- "1-10" → select tasks 1 through 10 -- Empty/invalid → prompt again - -### Step 4: Show Confirmation - -**After getting selection, show what will be created**: - -```text -You selected X tasks to sync: - -User Story 1 (3 tasks): - - T001 - Setup authentication service - - T002 - Create login endpoint - - T003 - Implement password validation - -User Story 2 (2 tasks): - - T005 - Design user profile schema - - T006 - Create profile API - -Is this correct? (yes/no) -``` - -### Step 5a: Execute Script with Collected Parameters - -Now run the PowerShell script with all the parameters collected from chat: - -```powershell -.\scripts\powershell\create-ado-workitems.ps1 ` - -SpecFile "<path-to-spec.md>" ` - -Organization "$orgName" ` - -Project "$projectName" ` - -AreaPath "$areaPath" ` - -Stories "<selection>" -``` - -The script will: - -1. ✅ Check Azure CLI installation -2. ✅ Verify/install Azure DevOps extension -3. ✅ Authenticate via `az login` (OAuth) if needed -4. ✅ Create work items using `az boards work-item create` -5. ✅ Return work item IDs and URLs -6. ✅ Save mapping to `.speckit/azure-devops-mapping.json` -7. ✅ Update configuration file `~/.speckit/ado-config.json` - -### Step 6a: Display Results - -Show the script output which includes: - -- Real-time progress for each story -- Created work item IDs and URLs -- Summary table -- Links to Azure DevOps boards - -### Step 5b: Prepare Work Items - -For each selected user story, prepare work item data: - -```javascript -{ - type: "User Story", - title: `User Story ${storyNumber} - ${storyTitle}`, - fields: { - "System.Description": `${description}\n\n**Why this priority**: ${whyPriority}\n\n**Independent Test**: ${independentTest}`, - "Microsoft.VSTS.Common.AcceptanceCriteria": formatAcceptanceCriteria(scenarios), - "Microsoft.VSTS.Common.Priority": convertPriority(priority), // P1→1, P2→2, P3→3 - "System.Tags": `spec-kit; ${featureName}; user-story`, - "System.AreaPath": areaPath || `${project}`, - "System.IterationPath": `${project}` // Can be enhanced to detect current sprint - } -} -``` - -**Acceptance Criteria Formatting**: - -```text -Scenario 1: -Given: <given> -When: <when> -Then: <then> - -Scenario 2: -Given: <given> -When: <when> -Then: <then> -``` - -### Step 5c: Execute Script with Collected Parameters - -Now run the PowerShell/Bash script with all the parameters collected from chat: - -**PowerShell**: - -```powershell -.\scripts\powershell\create-ado-workitems.ps1 ` - -SpecFile "<path-to-spec.md or tasks.md>" ` - -Organization "$orgName" ` - -Project "$projectName" ` - -AreaPath "$areaPath" ` - -Stories "<selection>" ` - -FromTasks # Only if syncing tasks -``` - -**Bash**: - -```bash -./scripts/bash/create-ado-workitems.sh \ - --spec-file "<path-to-spec.md or tasks.md>" \ - --organization "$orgName" \ - --project "$projectName" \ - --area-path "$areaPath" \ - --stories "<selection>" \ - --from-tasks # Only if syncing tasks -``` - -The script will: - -1. ✅ Check Azure CLI installation -2. ✅ Verify/install Azure DevOps extension -3. ✅ Authenticate via `az login` (OAuth) if needed -4. ✅ Create work items using `az boards work-item create` -5. ✅ Return work item IDs and URLs -6. ✅ Save mapping to `.speckit/azure-devops-mapping.json` -7. ✅ Update configuration file `~/.speckit/ado-config.json` - -### Step 6b: Display Results - -Show the script output which includes: - -- Real-time progress for each story/task -- Created work item IDs and URLs -- Summary table -- Links to Azure DevOps boards - -1. **Error handling**: - - **Authentication failed** → Show re-authentication instructions - - **Permission denied** → Explain required Azure DevOps permissions (Contributor or higher) - - **Extension not found** → Guide user to install `ms-daw-tca.ado-productivity-copilot` - - **Network error** → Show error and suggest retry - - **Invalid field** → Show error and continue with remaining stories - -2. **Real-time feedback**: Display status as each work item is created: - - ```text - Creating User Story 1 of 3... - ✓ Created User Story 1: Display Success Notifications (#12345) - - Creating User Story 2 of 3... - ✓ Created User Story 2: Edit Notifications (#12346) - - Creating User Story 3 of 3... - ✗ Failed User Story 3: Delete Notifications (Permission denied) - ``` - -### Step 6c: Display Results - -Show summary table: - -```markdown -## ✅ Azure DevOps Sync Complete - -**Organization**: MSFTDEVICES -**Project**: Devices -**Feature**: photo-album-management -**Synced**: 3 of 4 user stories - -### Created Work Items - -| Story | Title | Priority | Work Item | Status | -|-------|-------|----------|-----------|--------| -| 1 | Create Photo Albums | P1 | [#12345](https://dev.azure.com/MSFTDEVICES/Devices/_workitems/edit/12345) | ✅ Created | -| 2 | Add Photos to Albums | P1 | [#12346](https://dev.azure.com/MSFTDEVICES/Devices/_workitems/edit/12346) | ✅ Created | -| 3 | Delete Albums | P2 | [#12347](https://dev.azure.com/MSFTDEVICES/Devices/_workitems/edit/12347) | ✅ Created | - -### View in Azure DevOps - -- **Boards**: [https://dev.azure.com/MSFTDEVICES/Devices/_boards](https://dev.azure.com/MSFTDEVICES/Devices/_boards) -- **Work Items**: [https://dev.azure.com/MSFTDEVICES/Devices/_workitems](https://dev.azure.com/MSFTDEVICES/Devices/_workitems) -- **Backlog**: [https://dev.azure.com/MSFTDEVICES/Devices/_backlogs/backlog](https://dev.azure.com/MSFTDEVICES/Devices/_backlogs/backlog) - -### Tracking - -Saved mapping to: `.speckit/azure-devops-mapping.json` - -### Next Steps - -Now that your user stories are in Azure DevOps, continue with implementation planning: - -1. **Create technical plan**: `/speckit.plan` - Generate implementation plan with research and design artifacts -2. **Generate tasks**: `/speckit.tasks` - Break down the plan into actionable tasks -3. **Sync tasks to Azure DevOps**: `/speckit.adosync -FromTasks` - Create Task work items linked to User Stories - -Or you can: -- Review work items in Azure DevOps: [View Boards](https://dev.azure.com/{organization}/{project}/_boards) -- Assign work items to team members -- Add to current sprint/iteration -``` - -**If any failures occurred**, also show: - -```markdown -### ⚠️ Errors - -| Story | Title | Error | -|-------|-------|-------| -| 4 | Share Albums | Authentication failed - please re-authenticate with Azure DevOps | -``` - -### Step 7: Save Mapping - -Save work item mapping to `.speckit/azure-devops-mapping.json`: - -```json -{ - "feature": "photo-album-management", - "organization": "MSFTDEVICES", - "project": "Devices", - "syncDate": "2026-02-27T10:30:00Z", - "workItems": [ - { - "storyNumber": 1, - "storyTitle": "Create Photo Albums", - "workItemId": 12345, - "workItemUrl": "https://dev.azure.com/MSFTDEVICES/Devices/_workitems/edit/12345", - "priority": "P1", - "status": "created" - } - ] -} -``` - -This mapping file allows: - -- Tracking which stories have been synced -- Preventing duplicate syncs -- Updating existing work items (future enhancement) - -## Error Handling - -### Authentication Required - -```text -❌ Azure CLI Not Authenticated - -You need to authenticate with Azure CLI to create work items. - -To authenticate: -1. Run: az login --use-device-code -2. Follow the prompts in your browser -3. Return to the terminal and run this command again - -The script will automatically prompt for authentication if needed. -``` - -### No Spec File Found - -```text -❌ No Spec File Found - -This command requires a spec.md file in your feature directory. - -To create a spec file, use: - /specify <your feature description> - -Example: - /specify Add photo album management with create, edit, and delete capabilities -``` - -### Invalid Story Selection - -```text -❌ Invalid Story Selection - -Valid formats: - • all - Select all user stories - • 1,2,3 - Comma-separated story numbers - • 1-5 - Range of story numbers - -Your input: "abc" - -Please try again with a valid selection. -``` - -## Key Rules - -- Check Azure CLI installed, auto-install DevOps extension if missing -- Use OAuth (`az login`) - no PAT tokens -- Save org/project/area to `~/.speckit/ado-config.json` for reuse -- Title format: User Stories = "User Story {#} - {title}", Tasks = "T{#} - {desc}" -- Priority mapping: P1→1, P2→2, P3→3, P4→4 -- Auto-link tasks to parent user stories via `[US#]` references -- Continue on failure, report all errors at end -- Save mapping to `.speckit/azure-devops-mapping.json` - -## Example Usage - -```bash -# Sync user stories from spec.md -# Agent will prompt for org/project/area interactively -/speckit.adosync - -# Sync tasks from tasks.md -/speckit.adosync -FromTasks - -# The agent will: -# 1. Ask for Azure DevOps configuration (org, project, area) -# 2. Display found user stories or tasks -# 3. Ask which ones to sync -# 4. Create work items via Azure CLI -# 5. Display results with work item IDs and URLs -``` diff --git a/templates/commands/plan.md b/templates/commands/plan.md index fd3a7475fd..00e83eabd0 100644 --- a/templates/commands/plan.md +++ b/templates/commands/plan.md @@ -5,15 +5,6 @@ handoffs: agent: speckit.tasks prompt: Break the plan into tasks send: true - - label: Sync Tasks to Azure DevOps - agent: speckit.adosync - prompt: | - Read the tasks.md file and show me all the tasks that will be created in Azure DevOps. - Ask me which tasks I want to sync (I can say "all", specific numbers like "1,2,3", or ranges like "1-10"). - Then use the scripts/powershell/create-ado-workitems.ps1 script with the -FromTasks flag to create Task work items in Azure DevOps. - The script will automatically link tasks to their parent User Stories based on the [US#] references in the task descriptions. - Make sure to show me a preview before creating the work items. - send: true - label: Create Checklist agent: speckit.checklist prompt: Create a checklist for the following domain... diff --git a/templates/commands/specify.md b/templates/commands/specify.md index 53d51e2a51..5fd4489eee 100644 --- a/templates/commands/specify.md +++ b/templates/commands/specify.md @@ -1,18 +1,6 @@ --- description: Create or update the feature specification from a natural language feature description. handoffs: - - label: Sync to Azure DevOps - agent: speckit.adosync - prompt: | - Sync user stories from the spec.md we just created to Azure DevOps. - - The spec file path is: {spec_file_path} - - Please: - 1. Show me the list of user stories found - 2. Ask which ones I want to sync (or suggest 'all') - 3. Create the work items in Azure DevOps - send: true - label: Build Technical Plan agent: speckit.plan prompt: Create a plan for the spec. I am building with... @@ -205,7 +193,7 @@ Given that feature description, do this: d. **Update Checklist**: After each validation iteration, update the checklist file with current pass/fail status -7. Report completion with branch name, spec file path, checklist results, and readiness for the next phase (`/speckit.clarify` or `/speckit.adosync` or `/speckit.plan`). +7. Report completion with branch name, spec file path, checklist results, and readiness for the next phase (`/speckit.clarify` or `/speckit.plan`). **NOTE:** The script creates and checks out the new branch and initializes the spec file before writing. diff --git a/templates/commands/tasks.md b/templates/commands/tasks.md index 38f0008154..7320b6f305 100644 --- a/templates/commands/tasks.md +++ b/templates/commands/tasks.md @@ -1,10 +1,6 @@ --- description: Generate an actionable, dependency-ordered tasks.md for the feature based on available design artifacts. handoffs: - - label: Sync to Azure DevOps - agent: speckit.adosync - prompt: Sync generated tasks (tasks.md) to Azure DevOps - send: false - label: Analyze For Consistency agent: speckit.analyze prompt: Run a project analysis for consistency diff --git a/tests/test_ai_skills.py b/tests/test_ai_skills.py index 1753a437ff..3eec4a419c 100644 --- a/tests/test_ai_skills.py +++ b/tests/test_ai_skills.py @@ -632,272 +632,6 @@ def test_ai_skills_flag_appears_in_help(self): assert "agent skills" in plain.lower() -class TestHandoffsFieldInSkills: - """Test handling of handoffs field in command templates for AI skills (ADO sync feature).""" - - def test_skill_generation_with_handoffs_in_template(self, project_dir): - """Skills should generate successfully from templates containing handoffs field.""" - # Create template with handoffs - cmds_dir = project_dir / ".claude" / "commands" - cmds_dir.mkdir(parents=True) - - (cmds_dir / "specify.md").write_text( - "---\n" - "description: Create specification\n" - "handoffs:\n" - " - label: Sync to Azure DevOps\n" - " agent: speckit.adosync\n" - " prompt: Sync user stories to ADO\n" - " send: true\n" - " - label: Build Plan\n" - " agent: speckit.plan\n" - " send: false\n" - "---\n" - "\n" - "# Specify Command\n" - "\n" - "Create specs.\n", - encoding="utf-8", - ) - - result = install_ai_skills(project_dir, "claude") - - assert result is True - skill_file = project_dir / ".claude" / "skills" / "speckit-specify" / "SKILL.md" - assert skill_file.exists() - content = skill_file.read_text() - - # Verify skill has valid structure - assert "name: speckit-specify" in content - assert "description:" in content - # Body content should be preserved - assert "Create specs." in content - - def test_skill_generation_with_multiline_handoffs_prompt(self, project_dir): - """Skills should generate successfully from templates with multiline handoffs prompts.""" - cmds_dir = project_dir / ".claude" / "commands" - cmds_dir.mkdir(parents=True) - - (cmds_dir / "plan.md").write_text( - "---\n" - "description: Create plan\n" - "handoffs:\n" - " - label: Sync Tasks\n" - " agent: speckit.adosync\n" - " prompt: |\n" - " Read the tasks.md file and show me all the tasks.\n" - " Ask me which tasks I want to sync.\n" - " Then create Task work items in Azure DevOps.\n" - " send: true\n" - "---\n" - "\n" - "# Plan\n" - "\n" - "Plan body.\n", - encoding="utf-8", - ) - - result = install_ai_skills(project_dir, "claude") - - assert result is True - skill_file = project_dir / ".claude" / "skills" / "speckit-plan" / "SKILL.md" - content = skill_file.read_text() - - # Verify skill was generated successfully - assert "name: speckit-plan" in content - assert "Plan body." in content - - def test_handoffs_field_parseable_in_generated_skill(self, project_dir): - """Generated SKILL.md should have valid parseable YAML regardless of source frontmatter.""" - cmds_dir = project_dir / ".claude" / "commands" - cmds_dir.mkdir(parents=True) - - (cmds_dir / "tasks.md").write_text( - "---\n" - "description: Generate tasks\n" - "handoffs:\n" - " - label: Sync to ADO\n" - " agent: speckit.adosync\n" - " prompt: Sync tasks to Azure DevOps\n" - "---\n" - "\n" - "# Tasks\n" - "\n" - "Task content.\n", - encoding="utf-8", - ) - - install_ai_skills(project_dir, "claude") - - skill_file = project_dir / ".claude" / "skills" / "speckit-tasks" / "SKILL.md" - content = skill_file.read_text() - - # Extract and parse frontmatter to verify it's valid YAML - parts = content.split("---", 2) - assert len(parts) >= 3 - parsed = yaml.safe_load(parts[1]) - - # The generated SKILL.md should have agentskills.io compliant frontmatter - assert isinstance(parsed, dict) - assert "name" in parsed - assert parsed["name"] == "speckit-tasks" - assert "description" in parsed - assert "compatibility" in parsed - - # Body should be preserved - assert "Task content." in content - - def test_templates_with_handoffs_and_scripts_fields(self, project_dir): - """Skills should generate from templates with multiple complex fields like handoffs and scripts.""" - cmds_dir = project_dir / ".claude" / "commands" - cmds_dir.mkdir(parents=True) - - (cmds_dir / "specify.md").write_text( - "---\n" - "description: Spec command\n" - "handoffs:\n" - " - label: Sync to ADO\n" - " agent: speckit.adosync\n" - " prompt: |\n" - " Sync user stories from spec.md.\n" - " The spec file path is: {spec_file_path}\n" - "scripts:\n" - " sh: scripts/bash/create-new-feature.sh\n" - " ps: scripts/powershell/create-new-feature.ps1\n" - "---\n" - "\n" - "# Specify\n" - "\n" - "Command body.\n", - encoding="utf-8", - ) - - install_ai_skills(project_dir, "claude") - - skill_file = project_dir / ".claude" / "skills" / "speckit-specify" / "SKILL.md" - content = skill_file.read_text() - - # Skill should be generated successfully - assert "name: speckit-specify" in content - assert "Command body." in content - - def test_multiple_handoffs_dont_break_skill_generation(self, project_dir): - """Templates with multiple handoffs should generate skills without errors.""" - cmds_dir = project_dir / ".claude" / "commands" - cmds_dir.mkdir(parents=True) - - (cmds_dir / "plan.md").write_text( - "---\n" - "description: Plan command\n" - "handoffs:\n" - " - label: Sync User Stories\n" - " agent: speckit.adosync\n" - " prompt: Sync user stories\n" - " send: true\n" - " - label: Sync Tasks\n" - " agent: speckit.adosync\n" - " prompt: Sync tasks with -FromTasks\n" - " send: false\n" - " - label: Create Checklist\n" - " agent: speckit.checklist\n" - " send: true\n" - "---\n" - "\n" - "# Plan\n" - "\n" - "Planning content.\n", - encoding="utf-8", - ) - - result = install_ai_skills(project_dir, "claude") - - assert result is True - skill_file = project_dir / ".claude" / "skills" / "speckit-plan" / "SKILL.md" - content = skill_file.read_text() - - # Skill should be generated with valid structure - assert "name: speckit-plan" in content - assert "Planning content." in content - - def test_handoffs_field_optional_in_skills(self, project_dir): - """Commands without handoffs should still generate valid skills.""" - cmds_dir = project_dir / ".claude" / "commands" - cmds_dir.mkdir(parents=True) - - (cmds_dir / "legacy.md").write_text( - "---\n" - "description: Legacy command without handoffs\n" - "---\n" - "\n" - "# Legacy Command\n", - encoding="utf-8", - ) - - result = install_ai_skills(project_dir, "claude") - - assert result is True - skill_file = project_dir / ".claude" / "skills" / "speckit-legacy" / "SKILL.md" - assert skill_file.exists() - content = skill_file.read_text() - - # Should have valid structure without handoffs - assert "name: speckit-legacy" in content - assert "Legacy command without handoffs" in content - - def test_empty_handoffs_array_in_skills(self, project_dir): - """Commands with empty handoffs array should generate valid skills.""" - cmds_dir = project_dir / ".claude" / "commands" - cmds_dir.mkdir(parents=True) - - (cmds_dir / "test.md").write_text( - "---\n" - "description: Test command\n" - "handoffs: []\n" - "---\n" - "\n" - "# Test\n", - encoding="utf-8", - ) - - result = install_ai_skills(project_dir, "claude") - - assert result is True - skill_file = project_dir / ".claude" / "skills" / "speckit-test" / "SKILL.md" - content = skill_file.read_text() - - # Should handle empty handoffs gracefully - assert "name: speckit-test" in content - - def test_adosync_command_generates_skill(self, project_dir): - """The adosync command itself should generate a valid skill.""" - cmds_dir = project_dir / ".claude" / "commands" - cmds_dir.mkdir(parents=True) - - (cmds_dir / "adosync.md").write_text( - "---\n" - "description: Sync selected user stories or tasks to Azure DevOps\n" - "scripts:\n" - " sh: scripts/bash/create-ado-workitems.sh\n" - " ps: scripts/powershell/create-ado-workitems.ps1\n" - "---\n" - "\n" - "# ADO Sync Command\n" - "\n" - "Sync to Azure DevOps.\n", - encoding="utf-8", - ) - - result = install_ai_skills(project_dir, "claude") - - assert result is True - skill_file = project_dir / ".claude" / "skills" / "speckit-adosync" / "SKILL.md" - assert skill_file.exists() - content = skill_file.read_text() - - assert "name: speckit-adosync" in content - assert "Azure DevOps" in content - - class TestParameterOrderingIssue: """Test fix for GitHub issue #1641: parameter ordering issues.""" diff --git a/tests/test_extensions.py b/tests/test_extensions.py index ecb49a6b0d..a2c4121ed4 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -987,305 +987,3 @@ def test_clear_cache(self, temp_dir): assert not catalog.cache_file.exists() assert not catalog.cache_metadata_file.exists() - - -# ===== Handoffs Field Tests (ADO Sync) ===== - -class TestHandoffsField: - """Test parsing and handling of handoffs field in command frontmatter (ADO sync feature).""" - - def test_parse_frontmatter_with_handoffs(self): - """Test parsing frontmatter containing handoffs field.""" - content = """--- -description: "Test command with handoffs" -handoffs: - - label: Sync to Azure DevOps - agent: speckit.adosync - prompt: Sync user stories to Azure DevOps - send: true - - label: Create Tasks - agent: speckit.tasks - prompt: Break down into tasks - send: false ---- - -# Command content -$ARGUMENTS -""" - registrar = CommandRegistrar() - frontmatter, body = registrar.parse_frontmatter(content) - - assert "handoffs" in frontmatter - assert len(frontmatter["handoffs"]) == 2 - - # Verify first handoff - assert frontmatter["handoffs"][0]["label"] == "Sync to Azure DevOps" - assert frontmatter["handoffs"][0]["agent"] == "speckit.adosync" - assert frontmatter["handoffs"][0]["prompt"] == "Sync user stories to Azure DevOps" - assert frontmatter["handoffs"][0]["send"] is True - - # Verify second handoff - assert frontmatter["handoffs"][1]["label"] == "Create Tasks" - assert frontmatter["handoffs"][1]["agent"] == "speckit.tasks" - assert frontmatter["handoffs"][1]["send"] is False - - def test_parse_frontmatter_with_multiline_handoff_prompt(self): - """Test parsing handoffs with multiline prompts.""" - content = """--- -description: "Test command" -handoffs: - - label: Sync Tasks to ADO - agent: speckit.adosync - prompt: | - Read the tasks.md file and show me all the tasks. - Ask me which tasks I want to sync (I can say "all", specific numbers like "1,2,3", or ranges like "1-10"). - Then create Task work items in Azure DevOps. - send: true ---- - -# Command -$ARGUMENTS -""" - registrar = CommandRegistrar() - frontmatter, body = registrar.parse_frontmatter(content) - - assert "handoffs" in frontmatter - assert len(frontmatter["handoffs"]) == 1 - assert "Read the tasks.md file" in frontmatter["handoffs"][0]["prompt"] - assert "Ask me which tasks" in frontmatter["handoffs"][0]["prompt"] - - def test_parse_frontmatter_with_handoffs_missing_optional_fields(self): - """Test parsing handoffs with only required fields.""" - content = """--- -description: "Minimal handoff" -handoffs: - - label: Next Step - agent: speckit.plan ---- - -# Command -$ARGUMENTS -""" - registrar = CommandRegistrar() - frontmatter, body = registrar.parse_frontmatter(content) - - assert "handoffs" in frontmatter - assert len(frontmatter["handoffs"]) == 1 - assert frontmatter["handoffs"][0]["label"] == "Next Step" - assert frontmatter["handoffs"][0]["agent"] == "speckit.plan" - assert "prompt" not in frontmatter["handoffs"][0] - assert "send" not in frontmatter["handoffs"][0] - - def test_handoffs_field_preserved_in_rendered_markdown(self): - """Test that handoffs field is preserved when rendering commands.""" - frontmatter = { - "description": "Create specification", - "handoffs": [ - { - "label": "Sync to Azure DevOps", - "agent": "speckit.adosync", - "prompt": "Sync user stories from the spec.md", - "send": True - } - ] - } - body = "# Specify Command\n\n$ARGUMENTS" - - registrar = CommandRegistrar() - rendered = registrar._render_markdown_command(frontmatter, body, "test-ext") - - # Verify handoffs is in the frontmatter - assert "handoffs:" in rendered - assert "agent: speckit.adosync" in rendered - assert "Sync user stories from the spec.md" in rendered - assert "send: true" in rendered - - def test_handoffs_field_preserved_in_rendered_toml(self): - """Test that handoffs field is NOT included in TOML format (unsupported).""" - frontmatter = { - "description": "Create specification", - "handoffs": [ - { - "label": "Sync to ADO", - "agent": "speckit.adosync", - "send": True - } - ] - } - body = "# Command\n\n{{args}}" - - registrar = CommandRegistrar() - rendered = registrar._render_toml_command(frontmatter, body, "test-ext") - - # TOML format only extracts description, not complex structures like handoffs - assert 'description = "Create specification"' in rendered - # Handoffs should not appear in TOML (it only supports simple fields) - assert "handoffs" not in rendered - - def test_register_command_with_handoffs_to_claude(self, temp_dir, project_dir): - """Test registering command with handoffs field for Claude.""" - import yaml - - # Create extension with handoffs in command - ext_dir = temp_dir / "ext-handoffs" - ext_dir.mkdir() - - manifest_data = { - "schema_version": "1.0", - "extension": { - "id": "ext-handoffs", - "name": "Extension with Handoffs", - "version": "1.0.0", - "description": "Test handoffs", - }, - "requires": {"speckit_version": ">=0.1.0"}, - "provides": { - "commands": [ - { - "name": "speckit.handoffs.specify", - "file": "commands/specify.md", - } - ] - }, - } - - with open(ext_dir / "extension.yml", 'w') as f: - yaml.dump(manifest_data, f) - - (ext_dir / "commands").mkdir() - cmd_content = """--- -description: Create spec with handoffs -handoffs: - - label: Sync to ADO - agent: speckit.adosync - prompt: Sync to Azure DevOps - send: true ---- - -# Specify - -$ARGUMENTS -""" - (ext_dir / "commands" / "specify.md").write_text(cmd_content) - - # Register command - claude_dir = project_dir / ".claude" / "commands" - claude_dir.mkdir(parents=True) - - manifest = ExtensionManifest(ext_dir / "extension.yml") - registrar = CommandRegistrar() - registered = registrar.register_commands_for_claude(manifest, ext_dir, project_dir) - - # Verify registration - assert len(registered) == 1 - cmd_file = claude_dir / "speckit.handoffs.specify.md" - assert cmd_file.exists() - - # Verify handoffs field is preserved - content = cmd_file.read_text() - assert "handoffs:" in content - assert "agent: speckit.adosync" in content - assert "Sync to Azure DevOps" in content - - def test_handoffs_agent_field_format_validation(self): - """Test that agent field in handoffs uses correct format.""" - content = """--- -description: "Test" -handoffs: - - label: Invalid Agent Format - agent: invalid-agent-name ---- - -# Command -""" - registrar = CommandRegistrar() - frontmatter, body = registrar.parse_frontmatter(content) - - # Should parse successfully (validation happens elsewhere) - assert "handoffs" in frontmatter - assert frontmatter["handoffs"][0]["agent"] == "invalid-agent-name" - - def test_multiple_handoffs_with_same_agent(self): - """Test command with multiple handoffs referencing the same agent.""" - content = """--- -description: "Multiple handoffs" -handoffs: - - label: Sync User Stories - agent: speckit.adosync - prompt: Sync user stories - send: true - - label: Sync Tasks - agent: speckit.adosync - prompt: Sync tasks with -FromTasks flag - send: false ---- - -# Command -$ARGUMENTS -""" - registrar = CommandRegistrar() - frontmatter, body = registrar.parse_frontmatter(content) - - assert len(frontmatter["handoffs"]) == 2 - assert frontmatter["handoffs"][0]["agent"] == "speckit.adosync" - assert frontmatter["handoffs"][1]["agent"] == "speckit.adosync" - assert frontmatter["handoffs"][0]["label"] != frontmatter["handoffs"][1]["label"] - - def test_handoffs_with_interpolation_placeholders(self): - """Test handoffs with prompt containing variable placeholders.""" - content = """--- -description: "Command with variable interpolation" -handoffs: - - label: Sync to ADO - agent: speckit.adosync - prompt: | - Sync user stories from the spec.md we just created. - - The spec file path is: {spec_file_path} - - Please: - 1. Show me the list of user stories found - 2. Ask which ones I want to sync (or suggest 'all') - 3. Create the work items in Azure DevOps - send: true ---- - -# Command -$ARGUMENTS -""" - registrar = CommandRegistrar() - frontmatter, body = registrar.parse_frontmatter(content) - - assert "handoffs" in frontmatter - assert "{spec_file_path}" in frontmatter["handoffs"][0]["prompt"] - - def test_empty_handoffs_array(self): - """Test command with empty handoffs array.""" - content = """--- -description: "No handoffs" -handoffs: [] ---- - -# Command -$ARGUMENTS -""" - registrar = CommandRegistrar() - frontmatter, body = registrar.parse_frontmatter(content) - - assert "handoffs" in frontmatter - assert len(frontmatter["handoffs"]) == 0 - - def test_handoffs_field_not_present(self): - """Test command without handoffs field (backwards compatibility).""" - content = """--- -description: "Legacy command without handoffs" ---- - -# Command -$ARGUMENTS -""" - registrar = CommandRegistrar() - frontmatter, body = registrar.parse_frontmatter(content) - - # Should not have handoffs field - assert "handoffs" not in frontmatter From 57279cb9ba7d939ae5923b8fed8f130da20712b4 Mon Sep 17 00:00:00 2001 From: Pragya Chaurasia <87864723+pragya247@users.noreply.github.com> Date: Thu, 5 Mar 2026 13:16:23 +0530 Subject: [PATCH 5/6] Update extensions/README.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- extensions/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extensions/README.md b/extensions/README.md index f523d7b380..af03c2985c 100644 --- a/extensions/README.md +++ b/extensions/README.md @@ -72,8 +72,8 @@ The following community-contributed extensions are available in [`catalog.commun | Extension | Purpose | URL | |-----------|---------|-----| -| Cleanup Extension | Post-implementation quality gate that reviews changes, fixes small issues (scout rule), creates tasks for medium issues, and generates analysis for large issues | [spec-kit-cleanup](https://github.com/dsrednicki/spec-kit-cleanup) | | Azure DevOps Integration | Sync user stories and tasks to Azure DevOps work items using OAuth authentication | [spec-kit-azure-devops](https://github.com/pragya247/spec-kit-azure-devops) | +| Cleanup Extension | Post-implementation quality gate that reviews changes, fixes small issues (scout rule), creates tasks for medium issues, and generates analysis for large issues | [spec-kit-cleanup](https://github.com/dsrednicki/spec-kit-cleanup) | | Retrospective Extension | Post-implementation retrospective with spec adherence scoring, drift analysis, and human-gated spec updates | [spec-kit-retrospective](https://github.com/emi-dm/spec-kit-retrospective) | | Spec Sync | Detect and resolve drift between specs and implementation. AI-assisted resolution with human approval | [spec-kit-sync](https://github.com/bgervin/spec-kit-sync) | | V-Model Extension Pack | Enforces V-Model paired generation of development specs and test specs with full traceability | [spec-kit-v-model](https://github.com/leocamello/spec-kit-v-model) | From 8b2f5c7d4e9377498b7212213a0343638765f400 Mon Sep 17 00:00:00 2001 From: Pragya Chaurasia <87864723+pragya247@users.noreply.github.com> Date: Thu, 5 Mar 2026 13:28:14 +0530 Subject: [PATCH 6/6] Update extensions/catalog.community.json Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- extensions/catalog.community.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index 00e87d7503..2f89ce243a 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -6,7 +6,7 @@ "azure-devops": { "name": "Azure DevOps Integration", "id": "azure-devops", - "description": "Sync user stories and tasks to Azure DevOps work items using OAuth authentication", + "description": "Sync user stories and tasks to Azure DevOps work items using OAuth authentication.", "author": "pragya247", "version": "1.0.0", "download_url": "https://github.com/pragya247/spec-kit-azure-devops/archive/refs/tags/v1.0.0.zip",