BMAD-METHOD/scripts/epic-execute-lib/utils.sh

464 lines
15 KiB
Bash

#!/bin/bash
#
# BMAD Epic Execute - Utility Functions Module
#
# Provides shared utility functions for reliability and cross-platform support:
# - M1: Retry logic with exponential backoff
# - M2: yq version validation
# - M3: Fuzzy completion signal detection
# - M4: Cross-platform sed
# - M5: Branch protection check
#
# Usage: Sourced by epic-execute.sh
#
# =============================================================================
# M1: Retry Logic with Exponential Backoff
# =============================================================================
# Default retry configuration
RETRY_MAX_ATTEMPTS="${RETRY_MAX_ATTEMPTS:-3}"
RETRY_INITIAL_DELAY="${RETRY_INITIAL_DELAY:-5}"
RETRY_MAX_DELAY="${RETRY_MAX_DELAY:-60}"
# Execute a command with retry logic and exponential backoff
# Arguments:
# $1 - max attempts (optional, default: RETRY_MAX_ATTEMPTS)
# $2 - initial delay in seconds (optional, default: RETRY_INITIAL_DELAY)
# $@ - command and arguments to execute
# Returns: Exit code of the command, or 1 if all retries failed
execute_with_retry() {
local max_attempts="${1:-$RETRY_MAX_ATTEMPTS}"
local delay="${2:-$RETRY_INITIAL_DELAY}"
shift 2
local attempt=1
local result=""
local exit_code=0
while [ $attempt -le $max_attempts ]; do
# Execute the command and capture result
result=$("$@" 2>&1)
exit_code=$?
if [ $exit_code -eq 0 ]; then
echo "$result"
return 0
fi
# Check if this is a retryable error (transient failures)
local is_retryable=false
case "$result" in
*"rate limit"*|*"Rate limit"*|*"429"*)
is_retryable=true
[ "$VERBOSE" = true ] && log_warn "Rate limited, retrying..."
;;
*"timeout"*|*"Timeout"*|*"ETIMEDOUT"*)
is_retryable=true
[ "$VERBOSE" = true ] && log_warn "Timeout, retrying..."
;;
*"connection"*|*"Connection"*|*"ECONNREFUSED"*|*"ECONNRESET"*)
is_retryable=true
[ "$VERBOSE" = true ] && log_warn "Connection error, retrying..."
;;
*"temporarily unavailable"*|*"503"*|*"502"*)
is_retryable=true
[ "$VERBOSE" = true ] && log_warn "Service temporarily unavailable, retrying..."
;;
esac
if [ "$is_retryable" = false ]; then
# Non-retryable error, return immediately
echo "$result"
return $exit_code
fi
if [ $attempt -lt $max_attempts ]; then
log_warn "Attempt $attempt/$max_attempts failed. Retrying in ${delay}s..."
sleep "$delay"
# Exponential backoff with cap
delay=$((delay * 2))
if [ $delay -gt $RETRY_MAX_DELAY ]; then
delay=$RETRY_MAX_DELAY
fi
fi
((attempt++))
done
log_error "All $max_attempts attempts failed"
echo "$result"
return 1
}
# Execute Claude prompt with retry logic
# Arguments:
# $1 - prompt
# $2 - optional timeout (default: CLAUDE_TIMEOUT)
# Returns: Claude's response or error
execute_claude_with_retry() {
local prompt="$1"
local timeout="${2:-${CLAUDE_TIMEOUT:-600}}"
# Wrapper function for retry
_claude_invoke() {
timeout "$timeout" claude --dangerously-skip-permissions -p "$1" 2>&1
local code=$?
if [ $code -eq 124 ]; then
echo "TIMEOUT: Claude invocation timed out after ${timeout}s"
return 124
fi
return $code
}
execute_with_retry "$RETRY_MAX_ATTEMPTS" "$RETRY_INITIAL_DELAY" _claude_invoke "$prompt"
}
# =============================================================================
# M2: yq Version Validation
# =============================================================================
# Global flag for yq availability and version
YQ_AVAILABLE=false
YQ_VERSION=""
# Validate yq installation and version
# Returns: 0 if valid yq (mikefarah Go version), 1 otherwise
validate_yq() {
if ! command -v yq >/dev/null 2>&1; then
log_warn "yq not installed - YAML updates will use sed fallback"
return 1
fi
local version_output
version_output=$(yq --version 2>&1 || echo "")
# Check if it's the Go version (mikefarah/yq) which we expect
if echo "$version_output" | grep -qE "(mikefarah|version.*v4|version.*4\.)"; then
YQ_VERSION="go"
YQ_AVAILABLE=true
return 0
fi
# Python yq has different syntax (kislyuk/yq)
if echo "$version_output" | grep -qE "(jq wrapper|kislyuk)"; then
log_warn "Python yq detected (kislyuk/yq) - using sed fallback"
log_warn "For full YAML support, install: brew install yq (macOS) or go install github.com/mikefarah/yq/v4@latest"
YQ_VERSION="python"
return 1
fi
# Unknown version
log_warn "Unknown yq version - YAML updates may fail"
log_warn "Version output: $version_output"
YQ_VERSION="unknown"
return 1
}
# Safe yq operation with fallback
# Arguments:
# $1 - yq operation (e.g., ".field = value")
# $2 - file path
# Returns: 0 on success, 1 on failure
safe_yq() {
local operation="$1"
local file="$2"
if [ "$YQ_AVAILABLE" = true ]; then
yq -i "$operation" "$file" 2>/dev/null && return 0
fi
# yq not available or failed, return 1 to indicate fallback needed
return 1
}
# =============================================================================
# M3: Fuzzy Completion Signal Detection
# =============================================================================
# Check phase completion with fuzzy matching
# Arguments:
# $1 - Full Claude output
# $2 - Phase type (dev, review, fix, arch, test_quality, trace, uat)
# $3 - Story ID (for legacy text matching)
# Returns: 0 if complete/passed, 1 if failed/blocked, 2 if unclear
check_phase_completion_fuzzy() {
local output="$1"
local phase_type="$2"
local story_id="$3"
# Try JSON parsing first (unless legacy mode)
if [ "$USE_LEGACY_OUTPUT" != true ]; then
local json_result
json_result=$(extract_json_result "$output" 2>/dev/null || echo "")
if [ -n "$json_result" ]; then
local status
status=$(get_result_status "$json_result" 2>/dev/null || echo "")
# Normalize status to uppercase
status=$(echo "$status" | tr '[:lower:]' '[:upper:]')
case "$status" in
COMPLETE|PASSED|COMPLIANT|APPROVED|SUCCESS|DONE|OK)
return 0
;;
BLOCKED|FAILED|VIOLATIONS|CONCERNS|ERROR|INCOMPLETE|REJECTED)
return 1
;;
esac
fi
fi
# Fuzzy text matching fallback (case-insensitive)
# Convert output to lowercase for matching
local output_lower
output_lower=$(echo "$output" | tr '[:upper:]' '[:lower:]')
case "$phase_type" in
dev)
# Success patterns
if echo "$output_lower" | grep -qE "(implementation|dev(elopment)?|story).*(complete|done|finish|success|implement)"; then
return 0
fi
# Failure patterns
if echo "$output_lower" | grep -qE "(implementation|dev(elopment)?).*(block|fail|error|cannot|unable|halt)"; then
return 1
fi
# Explicit legacy signals (case-sensitive)
if echo "$output" | grep -q "IMPLEMENTATION COMPLETE"; then
return 0
fi
if echo "$output" | grep -q "IMPLEMENTATION BLOCKED"; then
return 1
fi
;;
review)
# Success patterns
if echo "$output_lower" | grep -qE "review.*(pass|approv|success|complete|clean|good|lgtm)"; then
return 0
fi
# Failure patterns
if echo "$output_lower" | grep -qE "review.*(fail|reject|issue|problem|concern|block)"; then
return 1
fi
# Explicit legacy signals
if echo "$output" | grep -q "REVIEW PASSED"; then
return 0
fi
if echo "$output" | grep -q "REVIEW FAILED"; then
return 1
fi
;;
fix)
# Success patterns
if echo "$output_lower" | grep -qE "(fix|repair|resolve).*(complete|done|success|all|finish)"; then
return 0
fi
# Failure patterns
if echo "$output_lower" | grep -qE "(fix|repair).*(fail|incomplete|partial|cannot|unable|remain)"; then
return 1
fi
# Explicit legacy signals
if echo "$output" | grep -q "FIX COMPLETE"; then
return 0
fi
if echo "$output" | grep -q "FIX INCOMPLETE"; then
return 1
fi
;;
arch)
# Success patterns
if echo "$output_lower" | grep -qE "(arch|architecture).*(compliant|pass|conform|valid|ok|good)"; then
return 0
fi
# Failure patterns
if echo "$output_lower" | grep -qE "(arch|architecture).*(violation|fail|non-compliant|issue|problem)"; then
return 1
fi
# Explicit legacy signals
if echo "$output" | grep -q "ARCH COMPLIANT"; then
return 0
fi
if echo "$output" | grep -q "ARCH VIOLATIONS"; then
return 1
fi
;;
test_quality)
# Success patterns (including concerns which don't block)
if echo "$output_lower" | grep -qE "test.*quality.*(approv|pass|good|accept|meets)"; then
return 0
fi
if echo "$output" | grep -qE "TEST QUALITY (APPROVED|CONCERNS)"; then
return 0
fi
# Failure patterns
if echo "$output_lower" | grep -qE "test.*quality.*(fail|reject|below|poor|unaccept)"; then
return 1
fi
if echo "$output" | grep -q "TEST QUALITY FAILED"; then
return 1
fi
;;
trace|traceability)
# Success patterns (including concerns which don't block)
if echo "$output_lower" | grep -qE "trace.*((pass|complete|valid|good|100%)|concerns?)"; then
return 0
fi
if echo "$output" | grep -qE "TRACEABILITY (PASS|CONCERNS)"; then
return 0
fi
# Failure patterns
if echo "$output_lower" | grep -qE "trace.*(fail|gap|missing|incomplete)"; then
return 1
fi
if echo "$output" | grep -q "TRACEABILITY FAIL"; then
return 1
fi
;;
uat)
# Success patterns
if echo "$output_lower" | grep -qE "uat.*(generat|creat|complete|success|done)"; then
return 0
fi
if echo "$output" | grep -q "UAT GENERATED"; then
return 0
fi
;;
test_gen)
# Success patterns
if echo "$output_lower" | grep -qE "test.*(generat|creat).*(complete|success|done)"; then
return 0
fi
if echo "$output" | grep -q "TEST GENERATION COMPLETE"; then
return 0
fi
# Partial failure
if echo "$output" | grep -q "TEST GENERATION PARTIAL"; then
return 1
fi
;;
esac
# Unclear result
return 2
}
# =============================================================================
# M4: Cross-Platform sed -i
# =============================================================================
# Cross-platform sed in-place edit
# Handles macOS (BSD sed) vs Linux (GNU sed) differences
# Arguments:
# $1 - sed pattern
# $2 - file path
# Returns: 0 on success, non-zero on failure
sed_inplace() {
local pattern="$1"
local file="$2"
if [ ! -f "$file" ]; then
log_error "sed_inplace: File not found: $file"
return 1
fi
if [[ "$OSTYPE" == "darwin"* ]]; then
# macOS/BSD sed requires '' after -i for no backup
sed -i '' "$pattern" "$file"
else
# GNU sed (Linux)
sed -i "$pattern" "$file"
fi
}
# Cross-platform sed in-place with backup
# Arguments:
# $1 - sed pattern
# $2 - file path
# $3 - backup extension (optional, default: .bak)
# Returns: 0 on success, non-zero on failure
sed_inplace_backup() {
local pattern="$1"
local file="$2"
local backup_ext="${3:-.bak}"
if [ ! -f "$file" ]; then
log_error "sed_inplace_backup: File not found: $file"
return 1
fi
if [[ "$OSTYPE" == "darwin"* ]]; then
# macOS/BSD sed
sed -i "$backup_ext" "$pattern" "$file"
else
# GNU sed (Linux)
sed -i"$backup_ext" "$pattern" "$file"
fi
}
# =============================================================================
# M5: Branch Protection Check
# =============================================================================
# List of protected branches (can be overridden via environment)
PROTECTED_BRANCHES="${PROTECTED_BRANCHES:-main master}"
# Check if current branch is protected
# Returns: 0 if safe to commit, 1 if protected branch
check_branch_protection() {
if [ ! -d "$PROJECT_ROOT/.git" ]; then
# Not a git repo, nothing to check
return 0
fi
local current_branch
current_branch=$(git -C "$PROJECT_ROOT" branch --show-current 2>/dev/null || \
git -C "$PROJECT_ROOT" rev-parse --abbrev-ref HEAD 2>/dev/null || \
echo "")
if [ -z "$current_branch" ]; then
log_warn "Cannot determine current branch - proceeding with caution"
return 0
fi
# Check against protected branches
for protected in $PROTECTED_BRANCHES; do
if [ "$current_branch" = "$protected" ]; then
log_error "Cannot commit directly to protected branch: $current_branch"
log_error "Create a feature branch first:"
log_error " git checkout -b epic-${EPIC_ID:-new}"
log_error ""
log_error "Or bypass protection with: PROTECTED_BRANCHES='' $0 ..."
return 1
fi
done
log "Working on branch: $current_branch"
return 0
}
# Get current branch name
get_current_branch() {
git -C "$PROJECT_ROOT" branch --show-current 2>/dev/null || \
git -C "$PROJECT_ROOT" rev-parse --abbrev-ref HEAD 2>/dev/null || \
echo ""
}
# =============================================================================
# Initialization
# =============================================================================
# Initialize utilities when sourced
init_utils() {
# Validate yq
validate_yq || true
# Log platform info in verbose mode
if [ "$VERBOSE" = true ]; then
log "Platform: $OSTYPE"
log "yq available: $YQ_AVAILABLE (version: ${YQ_VERSION:-none})"
log "Protected branches: $PROTECTED_BRANCHES"
fi
}