#!/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