#!/bin/bash # ============================================================================ # Post Living Docs Update Hook - Azure DevOps Sync # ============================================================================ # # Triggered after living docs are updated to sync with Azure DevOps. # CRITICAL: External tool status ALWAYS wins in conflicts! # # Triggers: # 1. After /specweave:done (increment completion) # 2. After /specweave:sync-docs update # 3. After manual spec edits # 4. After webhook from ADO # # ============================================================================ set +e # EMERGENCY FIX: Prevents Claude Code crashes # EMERGENCY KILL SWITCH if [[ "${SPECWEAVE_DISABLE_HOOKS:-0}" == "1" ]]; then exit 0 fi # Configuration SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" LIVING_DOCS_DIR="$PROJECT_ROOT/.specweave/docs/internal/specs" LOG_FILE="$PROJECT_ROOT/.specweave/logs/ado-sync.log" DEBUG=${DEBUG:-0} # Ensure log directory exists mkdir -p "$(dirname "$LOG_FILE")" # ============================================================================ # Logging # ============================================================================ log() { local level=$1 shift local message="$@" local timestamp=$(date '+%Y-%m-%d %H:%M:%S') echo "[$timestamp] [$level] $message" >> "$LOG_FILE" [ "$DEBUG" -eq 1 ] && echo "[$level] $message" >&2 } log_info() { log "INFO" "$@" } log_error() { log "ERROR" "$@" } log_debug() { [ "$DEBUG" -eq 1 ] && log "DEBUG" "$@" } # ============================================================================ # External Tool Detection # ============================================================================ detect_external_tool() { local spec_path=$1 # Check for external links in spec metadata if grep -q "externalLinks:" "$spec_path"; then if grep -q "ado:" "$spec_path"; then echo "ado" elif grep -q "jira:" "$spec_path"; then echo "jira" elif grep -q "github:" "$spec_path"; then echo "github" fi fi } # ============================================================================ # Status Mapping # ============================================================================ map_ado_status_to_local() { local ado_status=$1 case "$ado_status" in "New") echo "draft" ;; "Active") echo "in-progress" ;; "Resolved") echo "implemented" ;; "Closed") echo "complete" ;; "In Review"|"In QA") echo "in-qa" ;; *) echo "unknown" ;; esac } map_local_status_to_ado() { local local_status=$1 case "$local_status" in "draft") echo "New" ;; "in-progress") echo "Active" ;; "implemented") echo "Resolved" ;; "complete") echo "Closed" ;; "in-qa") echo "In Review" ;; *) echo "Active" ;; esac } # ============================================================================ # ADO API Functions # ============================================================================ get_ado_work_item_status() { local work_item_id=$1 local org="${AZURE_DEVOPS_ORG}" local project="${AZURE_DEVOPS_PROJECT}" local pat="${AZURE_DEVOPS_PAT}" if [ -z "$org" ] || [ -z "$pat" ]; then log_error "ADO credentials not configured" return 1 fi local api_url="https://dev.azure.com/${org}/${project}/_apis/wit/workitems/${work_item_id}?api-version=7.0" log_debug "Fetching ADO work item $work_item_id status" local response=$(curl -s -u ":${pat}" \ -H "Content-Type: application/json" \ "$api_url") if [ $? -ne 0 ]; then log_error "Failed to fetch ADO work item status" return 1 fi # Extract status from response local status=$(echo "$response" | jq -r '.fields["System.State"]') if [ "$status" = "null" ] || [ -z "$status" ]; then log_error "Could not extract status from ADO response" return 1 fi echo "$status" } update_ado_work_item() { local work_item_id=$1 local spec_content=$2 local org="${AZURE_DEVOPS_ORG}" local project="${AZURE_DEVOPS_PROJECT}" local pat="${AZURE_DEVOPS_PAT}" if [ -z "$org" ] || [ -z "$pat" ]; then log_error "ADO credentials not configured" return 1 fi # Extract current status from spec local local_status=$(echo "$spec_content" | grep "^status:" | cut -d: -f2 | tr -d ' ') local ado_status=$(map_local_status_to_ado "$local_status") local api_url="https://dev.azure.com/${org}/${project}/_apis/wit/workitems/${work_item_id}?api-version=7.0" # Create update payload local payload=$(cat < /dev/null if [ $? -ne 0 ]; then log_error "Failed to update ADO work item" return 1 fi log_info "Updated ADO work item $work_item_id" } # ============================================================================ # Conflict Resolution - CRITICAL: External Wins! # ============================================================================ resolve_status_conflict() { local spec_path=$1 local local_status=$2 local external_status=$3 local mapped_external=$(map_ado_status_to_local "$external_status") if [ "$local_status" != "$mapped_external" ]; then log_info "Status conflict detected:" log_info " Local: $local_status" log_info " External: $external_status (mapped: $mapped_external)" log_info " Resolution: EXTERNAL WINS - applying $mapped_external" # Update local spec with external status sed -i.bak "s/^status: .*/status: $mapped_external/" "$spec_path" # Add sync metadata local timestamp=$(date -u '+%Y-%m-%dT%H:%M:%SZ') # Check if syncedAt exists, update or add if grep -q "syncedAt:" "$spec_path"; then sed -i.bak "s/syncedAt: .*/syncedAt: \"$timestamp\"/" "$spec_path" else # Add after externalLinks section sed -i.bak "/externalLinks:/a\\ syncedAt: \"$timestamp\"" "$spec_path" fi # Clean up backup files rm -f "${spec_path}.bak" log_info "Local spec updated with external status: $mapped_external" return 0 else log_debug "No status conflict - local and external match: $local_status" return 0 fi } # ============================================================================ # Main Sync Function # ============================================================================ sync_spec_with_ado() { local spec_path=$1 if [ ! -f "$spec_path" ]; then log_error "Spec file not found: $spec_path" return 1 fi local spec_name=$(basename "$spec_path") log_info "Syncing spec: $spec_name" # Read spec content local spec_content=$(cat "$spec_path") # Extract ADO work item ID from metadata local work_item_id=$(echo "$spec_content" | grep -A5 "externalLinks:" | grep -A3 "ado:" | grep "featureId:" | cut -d: -f2 | tr -d ' ') if [ -z "$work_item_id" ]; then log_debug "No ADO work item linked to spec, skipping sync" return 0 fi log_info "Found ADO work item ID: $work_item_id" # Step 1: Push updates to ADO (content changes) update_ado_work_item "$work_item_id" "$spec_content" # Step 2: CRITICAL - Pull status from ADO (external wins!) local external_status=$(get_ado_work_item_status "$work_item_id") if [ -z "$external_status" ]; then log_error "Could not fetch ADO status" return 1 fi log_info "ADO status: $external_status" # Step 3: Extract local status local local_status=$(echo "$spec_content" | grep "^status:" | cut -d: -f2 | tr -d ' ') log_info "Local status: $local_status" # Step 4: Resolve conflicts - EXTERNAL WINS resolve_status_conflict "$spec_path" "$local_status" "$external_status" log_info "Sync completed for $spec_name" } # ============================================================================ # Entry Point # ============================================================================ main() { log_info "=== Post Living Docs Update Hook Started ===" # Get the spec path from arguments or environment local spec_path="${1:-$SPECWEAVE_UPDATED_SPEC}" if [ -z "$spec_path" ]; then log_error "No spec path provided" exit 1 fi # Detect external tool local tool=$(detect_external_tool "$spec_path") if [ "$tool" != "ado" ]; then log_debug "Not an ADO-linked spec, skipping" exit 0 fi log_info "Detected ADO integration for spec" # Perform sync sync_spec_with_ado "$spec_path" local exit_code=$? if [ $exit_code -eq 0 ]; then log_info "=== Sync completed successfully ===" else log_error "=== Sync failed with exit code: $exit_code ===" fi exit $exit_code } # Run main function main "$@" # ALWAYS exit 0 - NEVER let hook errors crash Claude Code exit 0