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