Files
EVOLV/.claude/skills/ship-it/loop.sh

190 lines
6.7 KiB
Bash
Raw Normal View History

#!/usr/bin/env bash
# ship-it AFK loop — works through every ready issue end-to-end.
# See SKILL.md for design. Ctrl-C to stop; partial work is preserved on disk.
set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null)" || { echo "not in a git repo"; exit 1; }
# ---- config (env-overridable) ----
MAX_ITERATIONS="${SHIP_IT_MAX:-50}"
MAX_CONSECUTIVE_FAILURES="${SHIP_IT_MAX_FAIL:-3}"
TRUNK_BRANCH="${SHIP_IT_TRUNK:-main}"
ITERATION_TIMEOUT="${SHIP_IT_TIMEOUT:-30m}" # per-issue cap
LOG_DIR="${SHIP_IT_LOG_DIR:-$REPO_ROOT/.ship-it-logs}"
mkdir -p "$LOG_DIR"
RUN_ID="$(date -u +%Y%m%dT%H%M%SZ)"
LOG_FILE="$LOG_DIR/run-$RUN_ID.log"
# ---- logging ----
log() {
local ts; ts="$(date -u +%H:%M:%S)"
printf '[%s] %s\n' "$ts" "$*" | tee -a "$LOG_FILE"
}
die() { log "FATAL: $*"; exit 1; }
# ---- graceful interrupt ----
INTERRUPTED=0
on_interrupt() {
INTERRUPTED=1
log ""
log "interrupt received — finishing current step cleanly, then stopping"
}
trap on_interrupt INT
# ---- tracker detection ----
ORIGIN_URL="$(git -C "$REPO_ROOT" remote get-url origin 2>/dev/null || true)"
if [[ "$ORIGIN_URL" == *"github.com"* ]]; then
TRACKER_CLI="gh"
command -v gh >/dev/null || die "gh CLI not installed"
gh auth status >/dev/null 2>&1 || die "gh not authenticated (run: gh auth login)"
list_ready_issues() {
gh issue list --state open --label slice --limit 100 \
--json number,title,labels \
--jq '[.[] | select(.labels | map(.name) | (contains(["blocked"]) or contains(["needs-decision"]) or contains(["ci-failed"])) | not)] | sort_by(.number)'
}
elif [[ "$ORIGIN_URL" == *"gitea"* ]]; then
TRACKER_CLI="tea"
command -v tea >/dev/null || die "tea CLI not installed (Gitea repo detected) — install tea or switch to a GitHub remote"
list_ready_issues() {
tea issues list --state open --output json 2>/dev/null \
| jq '[.[] | select((.labels // []) | map(.name) | (contains(["blocked"]) or contains(["needs-decision"]) or contains(["ci-failed"])) | not) | select((.labels // []) | map(.name) | contains(["slice"]))] | sort_by(.index)'
}
else
die "unknown tracker for origin: '$ORIGIN_URL' (need github.com or gitea.*)"
fi
# ---- preflight ----
cd "$REPO_ROOT"
[[ -z "$(git status --porcelain)" ]] || die "git tree is dirty — commit or stash before starting"
CURRENT_BRANCH="$(git branch --show-current)"
[[ "$CURRENT_BRANCH" == "$TRUNK_BRANCH" ]] || die "not on $TRUNK_BRANCH (on '$CURRENT_BRANCH')"
git fetch origin "$TRUNK_BRANCH" >/dev/null 2>&1 || die "git fetch failed"
LOCAL_SHA="$(git rev-parse HEAD)"
REMOTE_SHA="$(git rev-parse "origin/$TRUNK_BRANCH")"
[[ "$LOCAL_SHA" == "$REMOTE_SHA" ]] || die "$TRUNK_BRANCH not up-to-date with origin (pull first)"
command -v claude >/dev/null || die "claude CLI not on PATH"
# ---- banner ----
log "ship-it run $RUN_ID"
log " tracker: $TRACKER_CLI ($ORIGIN_URL)"
log " trunk: $TRUNK_BRANCH @ ${LOCAL_SHA:0:8}"
log " log: $LOG_FILE"
log " config: max_iter=$MAX_ITERATIONS, max_fail=$MAX_CONSECUTIVE_FAILURES, timeout=$ITERATION_TIMEOUT"
log ""
ITERATE_PROMPT_TEMPLATE="$(cat "$SCRIPT_DIR/iterate.md")"
SHIPPED=()
FAILED=()
NEEDS_DECISION=()
CONSECUTIVE_FAILURES=0
ITERATION=0
# ---- main loop ----
while (( ITERATION < MAX_ITERATIONS )); do
(( INTERRUPTED )) && break
ITERATION=$((ITERATION + 1))
READY_JSON="$(list_ready_issues 2>/dev/null || echo '[]')"
READY_COUNT="$(echo "$READY_JSON" | jq 'length' 2>/dev/null || echo 0)"
if (( READY_COUNT == 0 )); then
log "backlog empty — stopping"
break
fi
ISSUE_NUM="$(echo "$READY_JSON" | jq -r '.[0].number // .[0].index')"
ISSUE_TITLE="$(echo "$READY_JSON" | jq -r '.[0].title')"
log "─────────────────────────────────────────────────────────────"
log "iter $ITERATION | #$ISSUE_NUM \"$ISSUE_TITLE\" ($READY_COUNT ready) → starting"
ITER_LOG="$LOG_DIR/iter-$RUN_ID-$ISSUE_NUM.log"
PROMPT="$ITERATE_PROMPT_TEMPLATE
## Variables for this iteration
- ISSUE_NUMBER=$ISSUE_NUM
- TRACKER_CLI=$TRACKER_CLI
- TRUNK_BRANCH=$TRUNK_BRANCH
- REPO_ROOT=$REPO_ROOT
Begin."
ITER_START="$(date +%s)"
set +e
timeout "$ITERATION_TIMEOUT" claude -p "$PROMPT" \
--allowed-tools "Bash,Edit,Write,Read,Grep,Glob,WebFetch" \
--output-format text \
>"$ITER_LOG" 2>&1
CLAUDE_EXIT=$?
set -e
ITER_END="$(date +%s)"
ITER_DURATION=$((ITER_END - ITER_START))
RESULT_LINE="$(grep -E '^ITERATION_RESULT:' "$ITER_LOG" | tail -1 || true)"
STATUS="$(echo "$RESULT_LINE" | sed -n 's/.*status=\([^ ]*\).*/\1/p')"
PR_FIELD="$(echo "$RESULT_LINE" | sed -n 's/.*pr=\([^ ]*\).*/\1/p')"
REASON="$(echo "$RESULT_LINE" | sed -n 's/.*reason=\(.*\)/\1/p')"
if (( CLAUDE_EXIT == 124 )); then
STATUS="failed"
REASON="timeout after $ITERATION_TIMEOUT"
fi
case "$STATUS" in
shipped)
log "iter $ITERATION | #$ISSUE_NUM ✓ shipped → PR $PR_FIELD (${ITER_DURATION}s)"
SHIPPED+=("#$ISSUE_NUM$PR_FIELD")
CONSECUTIVE_FAILURES=0
;;
failed)
log "iter $ITERATION | #$ISSUE_NUM ✗ failed: $REASON (${ITER_DURATION}s, see $ITER_LOG)"
FAILED+=("#$ISSUE_NUM ($REASON)")
CONSECUTIVE_FAILURES=$((CONSECUTIVE_FAILURES + 1))
;;
needs-decision)
log "iter $ITERATION | #$ISSUE_NUM ? needs-decision: $REASON (${ITER_DURATION}s)"
NEEDS_DECISION+=("#$ISSUE_NUM ($REASON)")
CONSECUTIVE_FAILURES=0
;;
*)
log "iter $ITERATION | #$ISSUE_NUM ! unknown outcome (claude exit=$CLAUDE_EXIT, ${ITER_DURATION}s) — see $ITER_LOG"
FAILED+=("#$ISSUE_NUM (unknown outcome)")
CONSECUTIVE_FAILURES=$((CONSECUTIVE_FAILURES + 1))
;;
esac
if (( CONSECUTIVE_FAILURES >= MAX_CONSECUTIVE_FAILURES )); then
log "$MAX_CONSECUTIVE_FAILURES consecutive failures — stopping for human review"
break
fi
# back to trunk for next iteration
if [[ "$(git branch --show-current)" != "$TRUNK_BRANCH" ]]; then
git switch "$TRUNK_BRANCH" >/dev/null 2>&1 || log " warn: could not return to $TRUNK_BRANCH"
fi
git pull --ff-only origin "$TRUNK_BRANCH" >/dev/null 2>&1 || log " warn: could not fast-forward $TRUNK_BRANCH"
done
# ---- summary ----
log ""
log "==== ship-it summary ===="
log "iterations: $ITERATION"
log "shipped: ${#SHIPPED[@]} ${SHIPPED[*]:-}"
log "failed: ${#FAILED[@]} ${FAILED[*]:-}"
log "needs-decision: ${#NEEDS_DECISION[@]} ${NEEDS_DECISION[*]:-}"
log "log: $LOG_FILE"
if (( INTERRUPTED )); then
log "stop reason: user-interrupt"
exit 130
elif (( CONSECUTIVE_FAILURES >= MAX_CONSECUTIVE_FAILURES )); then
log "stop reason: consecutive-failures"
exit 1
else
log "stop reason: backlog-empty"
exit 0
fi