464 lines
15 KiB
Bash
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
|
|
}
|