veza/run-audit.sh

503 lines
17 KiB
Bash
Raw Permalink Normal View History

#!/usr/bin/env bash
#
# VEZA — Script d'audit automatique overnight
# Lance Claude Code sur chaque prompt d'audit, avec reprise sur erreur.
#
# Usage:
# ./run-audit.sh # Lance depuis le début (ou reprend)
# ./run-audit.sh --reset # Repart de zéro
# ./run-audit.sh --from 15 # Force le démarrage au prompt 15
# ./run-audit.sh --only 38 # N'exécute qu'un seul prompt
# ./run-audit.sh --dry-run # Affiche ce qui serait fait sans exécuter
# ./run-audit.sh --status # Affiche l'état actuel
# ./run-audit.sh --model sonnet # Utilise Sonnet au lieu d'Opus (moins cher)
#
# Coût estimé (Opus): ~$150400 pour les 58 prompts
# Coût estimé (Sonnet): ~$3080 pour les 58 prompts
# Durée estimée: 48 heures selon le modèle et la charge API
#
set -euo pipefail
# ─────────────────────────────────────────────
# Configuration
# ─────────────────────────────────────────────
REPO_ROOT="$(cd "$(dirname "$0")" && pwd)"
PROMPTS_DIR="$REPO_ROOT/prompts"
STATE_DIR="$REPO_ROOT/.audit-state"
STATE_FILE="$STATE_DIR/progress.json"
LOG_DIR="$STATE_DIR/logs"
BRANCH_NAME="audit/full-page-audit-$(date +%Y%m%d)"
# Claude CLI settings
MODEL="${VEZA_AUDIT_MODEL:-opus}"
MAX_BUDGET_PER_PROMPT="${VEZA_AUDIT_BUDGET_PER_PROMPT:-8}" # USD par prompt
MAX_RETRIES=3
RETRY_BACKOFF_BASE=60 # secondes, doublé à chaque retry
COOLDOWN_BETWEEN_PROMPTS=10 # secondes entre chaque prompt
RATE_LIMIT_PAUSE=300 # 5 min de pause si rate limit détecté
# Couleurs
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
NC='\033[0m'
# ─────────────────────────────────────────────
# Fonctions utilitaires
# ─────────────────────────────────────────────
log() { echo -e "${CYAN}[$(date '+%H:%M:%S')]${NC} $*"; }
info() { echo -e "${GREEN}[$(date '+%H:%M:%S')] ✓${NC} $*"; }
warn() { echo -e "${YELLOW}[$(date '+%H:%M:%S')] ⚠${NC} $*"; }
error() { echo -e "${RED}[$(date '+%H:%M:%S')] ✗${NC} $*"; }
die() { error "$*"; exit 1; }
# ─────────────────────────────────────────────
# State management (fichier JSON simple via jq)
# ─────────────────────────────────────────────
ensure_deps() {
command -v claude >/dev/null 2>&1 || die "claude CLI non trouvé. Installe-le d'abord."
command -v jq >/dev/null 2>&1 || die "jq non trouvé. Installe: sudo dnf install jq"
command -v git >/dev/null 2>&1 || die "git non trouvé."
}
init_state() {
mkdir -p "$STATE_DIR" "$LOG_DIR"
if [[ ! -f "$STATE_FILE" ]]; then
cat > "$STATE_FILE" <<'ENDJSON'
{
"started_at": null,
"branch": null,
"prompts": {},
"current": null,
"total_cost_estimate_usd": 0,
"total_prompts": 0,
"completed": 0,
"failed": 0,
"skipped": 0
}
ENDJSON
log "State file initialisé: $STATE_FILE"
fi
}
state_get() {
jq -r "$1" "$STATE_FILE"
}
state_set() {
local tmp
tmp=$(mktemp)
jq "$1" "$STATE_FILE" > "$tmp" && mv "$tmp" "$STATE_FILE"
}
get_prompt_status() {
local num="$1"
jq -r ".prompts[\"$num\"].status // \"pending\"" "$STATE_FILE"
}
set_prompt_status() {
local num="$1" status="$2" detail="${3:-}"
state_set ".prompts[\"$num\"] = {
\"status\": \"$status\",
\"updated_at\": \"$(date -Iseconds)\",
\"detail\": \"$detail\"
}"
# Update counters
local completed failed
completed=$(jq '[.prompts[] | select(.status == "done")] | length' "$STATE_FILE")
failed=$(jq '[.prompts[] | select(.status == "failed")] | length' "$STATE_FILE")
state_set ".completed = $completed | .failed = $failed"
}
# ─────────────────────────────────────────────
# Git helpers
# ─────────────────────────────────────────────
setup_branch() {
cd "$REPO_ROOT"
local current_branch
current_branch=$(git branch --show-current)
local existing_branch
existing_branch=$(state_get '.branch // empty')
if [[ -n "$existing_branch" ]] && git rev-parse --verify "$existing_branch" >/dev/null 2>&1; then
# Reprendre sur la branche existante
if [[ "$current_branch" != "$existing_branch" ]]; then
log "Reprise sur branche existante: $existing_branch"
git checkout "$existing_branch"
fi
BRANCH_NAME="$existing_branch"
else
# Créer nouvelle branche depuis l'état actuel
if git rev-parse --verify "$BRANCH_NAME" >/dev/null 2>&1; then
BRANCH_NAME="${BRANCH_NAME}-$(date +%H%M%S)"
fi
log "Création branche: $BRANCH_NAME"
git checkout -b "$BRANCH_NAME"
state_set ".branch = \"$BRANCH_NAME\""
fi
state_set ".started_at = \"$(date -Iseconds)\""
}
commit_prompt_changes() {
local num="$1" name="$2" path="$3"
cd "$REPO_ROOT"
# Vérifier s'il y a des changements
if git diff --quiet && git diff --cached --quiet && [[ -z "$(git ls-files --others --exclude-standard)" ]]; then
warn "Prompt $num ($path): aucun changement à committer"
return 0
fi
# Stage tous les changements (sauf les fichiers d'état de l'audit)
git add -A -- ':!.audit-state'
# Vérifier à nouveau après staging
if git diff --cached --quiet; then
warn "Prompt $num ($path): rien dans le staging"
return 0
fi
git commit -m "$(cat <<EOF
audit($path): fix bugs and add e2e tests — $name
Automated audit via Claude Code + Playwright MCP.
- Full page audit: loading, features, a11y, i18n, responsive, security
- Bug fixes applied
- E2E regression tests added
Prompt: prompts/$(printf '%02d' "$num")-*.md
EOF
)"
info "Commit créé pour prompt $num ($path)"
}
# ─────────────────────────────────────────────
# Exécution d'un prompt
# ─────────────────────────────────────────────
run_single_prompt() {
local prompt_file="$1"
local num
num=$(basename "$prompt_file" | grep -oP '^\d+')
# Enlever le zéro devant pour le numéro
num=$((10#$num))
local log_file="$LOG_DIR/prompt-$(printf '%02d' "$num").log"
local route_name
route_name=$(basename "$prompt_file" .md | sed 's/^[0-9]*-//')
log "━━━ Prompt $num/58: $route_name ━━━"
local status
status=$(get_prompt_status "$num")
if [[ "$status" == "done" ]]; then
info "Déjà complété, skip"
return 0
fi
set_prompt_status "$num" "running"
state_set ".current = $num"
local prompt_content
prompt_content=$(cat "$prompt_file")
local attempt=0
local success=false
while (( attempt < MAX_RETRIES )); do
attempt=$((attempt + 1))
log "Tentative $attempt/$MAX_RETRIES..."
local exit_code=0
# Exécuter Claude Code
# --print: mode non-interactif
# --dangerously-skip-permissions: aucune interaction
# --max-budget-usd: garde-fou coût par prompt
timeout 1800 claude \
-p \
--dangerously-skip-permissions \
--model "$MODEL" \
--max-budget-usd "$MAX_BUDGET_PER_PROMPT" \
"$prompt_content" \
> "$log_file" 2>&1 \
|| exit_code=$?
if [[ $exit_code -eq 0 ]]; then
success=true
break
fi
# Analyser l'erreur
local last_lines
last_lines=$(tail -20 "$log_file" 2>/dev/null || echo "")
if echo "$last_lines" | grep -qi "rate.limit\|429\|too many requests\|overloaded"; then
warn "Rate limit détecté. Pause de ${RATE_LIMIT_PAUSE}s..."
sleep "$RATE_LIMIT_PAUSE"
elif echo "$last_lines" | grep -qi "context.window\|token.limit\|max.*token\|session.*limit"; then
warn "Limite de session/contexte atteinte. Prompt trop lourd, on continue."
break
elif echo "$last_lines" | grep -qi "network\|connection\|ECONNRESET\|ETIMEDOUT\|fetch failed"; then
local backoff=$((RETRY_BACKOFF_BASE * (2 ** (attempt - 1))))
warn "Erreur réseau. Backoff ${backoff}s..."
sleep "$backoff"
elif [[ $exit_code -eq 124 ]]; then
warn "Timeout (30min). Le prompt a pris trop de temps."
break
else
local backoff=$((RETRY_BACKOFF_BASE * (2 ** (attempt - 1))))
warn "Erreur inconnue (code $exit_code). Backoff ${backoff}s..."
sleep "$backoff"
fi
done
if $success; then
# Commit les changements
commit_prompt_changes "$num" "$route_name" "$(head -1 "$prompt_file" | grep -oP '/[a-z/:-]+')" || true
set_prompt_status "$num" "done"
info "Prompt $num terminé avec succès"
else
set_prompt_status "$num" "failed" "after $attempt attempts, exit code $exit_code"
error "Prompt $num échoué après $attempt tentatives"
# On continue quand même avec le suivant
fi
# Cooldown entre les prompts
if (( COOLDOWN_BETWEEN_PROMPTS > 0 )); then
log "Cooldown ${COOLDOWN_BETWEEN_PROMPTS}s..."
sleep "$COOLDOWN_BETWEEN_PROMPTS"
fi
}
# ─────────────────────────────────────────────
# Collecte des prompts
# ─────────────────────────────────────────────
get_prompt_files() {
find "$PROMPTS_DIR" -name '*.md' -not -name 'ALL-PROMPTS.md' | sort
}
# ─────────────────────────────────────────────
# Commandes principales
# ─────────────────────────────────────────────
cmd_status() {
if [[ ! -f "$STATE_FILE" ]]; then
log "Aucun audit en cours."
return
fi
echo ""
echo -e "${CYAN}═══ VEZA Audit Status ═══${NC}"
echo ""
echo -e " Branche: $(state_get '.branch // "non démarrée"')"
echo -e " Démarré: $(state_get '.started_at // "—"')"
echo -e " Complétés: ${GREEN}$(state_get '.completed')${NC}"
echo -e " Échoués: ${RED}$(state_get '.failed')${NC}"
echo -e " En cours: $(state_get '.current // "—"')"
echo ""
# Détail par prompt
local total=0 done=0 failed=0 pending=0
for f in $(get_prompt_files); do
total=$((total + 1))
local num
num=$((10#$(basename "$f" | grep -oP '^\d+')))
local st
st=$(get_prompt_status "$num")
local icon="⏳"
case "$st" in
done) icon="✅"; done=$((done + 1)) ;;
failed) icon="❌"; failed=$((failed + 1)) ;;
running) icon="🔄" ;;
*) pending=$((pending + 1)) ;;
esac
printf " %s %02d. %s\n" "$icon" "$num" "$(basename "$f" .md | sed 's/^[0-9]*-//')"
done
echo ""
echo -e " Total: $total | ✅ $done | ❌ $failed | ⏳ $pending"
echo ""
}
cmd_reset() {
warn "Reset de l'état d'audit..."
rm -rf "$STATE_DIR"
info "État supprimé. Relance ./run-audit.sh pour recommencer."
}
cmd_run() {
local start_from="${1:-0}"
local only="${2:-0}"
ensure_deps
init_state
local prompt_files
mapfile -t prompt_files < <(get_prompt_files)
local total=${#prompt_files[@]}
state_set ".total_prompts = $total"
log "═══════════════════════════════════════════════════════"
log " VEZA — Audit automatique de $total pages"
log " Modèle: $MODEL"
log " Budget max par prompt: \$${MAX_BUDGET_PER_PROMPT}"
log " Budget max total estimé: \$$(echo "$total * $MAX_BUDGET_PER_PROMPT" | bc)"
log "═══════════════════════════════════════════════════════"
setup_branch
for prompt_file in "${prompt_files[@]}"; do
local num
num=$((10#$(basename "$prompt_file" | grep -oP '^\d+')))
# --from: sauter les prompts avant
if (( start_from > 0 && num < start_from )); then
continue
fi
# --only: n'exécuter qu'un seul
if (( only > 0 && num != only )); then
continue
fi
run_single_prompt "$prompt_file"
done
state_set ".current = null"
# Résumé final
echo ""
log "═══════════════════════════════════════════════════════"
log " AUDIT TERMINÉ"
log "═══════════════════════════════════════════════════════"
cmd_status
local failed_count
failed_count=$(state_get '.failed')
if (( failed_count > 0 )); then
warn "$failed_count prompts ont échoué. Relance pour retenter uniquement les échecs."
warn "Les logs sont dans: $LOG_DIR/"
fi
info "Branche: $BRANCH_NAME"
info "Commits: $(git log --oneline "$BRANCH_NAME" --not main | wc -l) commits"
info "Logs: $LOG_DIR/"
}
cmd_dry_run() {
init_state
local prompt_files
mapfile -t prompt_files < <(get_prompt_files)
echo ""
log "═══ DRY RUN — Voici ce qui serait exécuté ═══"
echo ""
for prompt_file in "${prompt_files[@]}"; do
local num
num=$((10#$(basename "$prompt_file" | grep -oP '^\d+')))
local st
st=$(get_prompt_status "$num")
local action="EXÉCUTER"
[[ "$st" == "done" ]] && action="SKIP (déjà fait)"
printf " %02d. %-40s → %s\n" "$num" "$(basename "$prompt_file")" "$action"
done
local pending
pending=$(jq '[.prompts[] | select(.status == "done")] | length' "$STATE_FILE" 2>/dev/null || echo 0)
local total=${#prompt_files[@]}
local to_run=$((total - pending))
echo ""
echo " À exécuter: $to_run / $total prompts"
echo " Modèle: $MODEL"
echo " Budget max: \$$(echo "$to_run * $MAX_BUDGET_PER_PROMPT" | bc)"
echo " Durée estimée: $(echo "$to_run * 8" | bc) min (≈$(echo "$to_run * 8 / 60" | bc)h)"
echo ""
}
# ─────────────────────────────────────────────
# CLI parsing
# ─────────────────────────────────────────────
main() {
cd "$REPO_ROOT"
local start_from=0
local only=0
local dry_run=false
while [[ $# -gt 0 ]]; do
case "$1" in
--reset)
cmd_reset
exit 0
;;
--status)
cmd_status
exit 0
;;
--dry-run)
dry_run=true
shift
;;
--from)
start_from="$2"
shift 2
;;
--only)
only="$2"
shift 2
;;
--model)
MODEL="$2"
shift 2
;;
--budget)
MAX_BUDGET_PER_PROMPT="$2"
shift 2
;;
-h|--help)
echo "Usage: $0 [options]"
echo ""
echo "Options:"
echo " --reset Supprime l'état et repart de zéro"
echo " --status Affiche la progression actuelle"
echo " --dry-run Montre ce qui serait fait sans exécuter"
echo " --from N Commence au prompt N"
echo " --only N N'exécute que le prompt N"
echo " --model MODEL Modèle Claude (opus|sonnet|haiku) [défaut: opus]"
echo " --budget N Budget max USD par prompt [défaut: 8]"
echo ""
echo "Variables d'environnement:"
echo " VEZA_AUDIT_MODEL Modèle par défaut"
echo " VEZA_AUDIT_BUDGET_PER_PROMPT Budget par prompt"
exit 0
;;
*)
die "Option inconnue: $1 (utilise --help)"
;;
esac
done
if $dry_run; then
cmd_dry_run
else
cmd_run "$start_from" "$only"
fi
}
main "$@"