From 39ac7e48d6f3bfb6d26536a0c7d524e9091a10cb Mon Sep 17 00:00:00 2001 From: pragya247 Date: Tue, 3 Mar 2026 00:27:38 +0530 Subject: [PATCH 1/2] 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/2] 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