#!/usr/bin/env bash # tools/forge-watch.sh — Testwright watcher for the Forgejo CI gate. # # Polls the Forgejo commit-status API for `main`. When the combined # status flips from a known-good state to `failure`, emits a TTS alert # via mcp__speech-synthesis__synthesize (personality: ravdess02, mandatory # per project Rail-4). Re-alerts once per run-ID to avoid spam. # # State is kept in $XDG_STATE_HOME/forge-watch/state.json (never spread # across multiple locations). The watcher only OBSERVES; it does NOT # re-run tests. # # Usage (manual): # FORGEJO_URL=http://forge.nasty.sh \ # FORGEJO_TOKEN= \ # bash tools/forge-watch.sh # # Typically run as a systemd --user unit on apricot. See: # ~/.config/systemd/user/forge-watch.service # .forgejo/RUNNER_SETUP.md § "Watcher / TTS alert" # # Environment variables: # FORGEJO_URL Base URL of the Forgejo instance (no trailing slash). # Default: http://forge.nasty.sh # FORGEJO_TOKEN Personal access token with read:repository scope. # Default: sourced from ~/.config/tokens/forgejo.sh # FORGE_REPO org/repo slug. Default: magicciv/magicciv # FORGE_BRANCH Branch to watch. Default: main # POLL_INTERVAL Seconds between polls. Default: 30 # TTS_CMD Command to invoke TTS. Default: mcp__speech-synthesis__synthesize # Override in tests. set -euo pipefail # ── Defaults ──────────────────────────────────────────────────────────────── FORGEJO_URL="${FORGEJO_URL:-http://forge.nasty.sh}" FORGE_REPO="${FORGE_REPO:-magicciv/magicciv}" FORGE_BRANCH="${FORGE_BRANCH:-main}" POLL_INTERVAL="${POLL_INTERVAL:-30}" # Source token if not already in environment if [[ -z "${FORGEJO_TOKEN:-}" ]]; then token_file="$HOME/.config/tokens/forgejo.sh" if [[ -f "$token_file" ]]; then # shellcheck source=/dev/null source "$token_file" fi fi if [[ -z "${FORGEJO_TOKEN:-}" ]]; then echo "forge-watch: FORGEJO_TOKEN is not set and ~/.config/tokens/forgejo.sh has none." >&2 exit 1 fi # ── State dir ─────────────────────────────────────────────────────────────── STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/forge-watch" STATE_FILE="$STATE_DIR/state.json" mkdir -p "$STATE_DIR" # Initialise state file if absent if [[ ! -f "$STATE_FILE" ]]; then echo '{"last_sha":"","last_status":"unknown","alerted_run_ids":[]}' > "$STATE_FILE" fi # ── Helpers ───────────────────────────────────────────────────────────────── log() { echo "[forge-watch] $(date -u +%H:%M:%SZ) $*"; } # Read a field from the state JSON state_get() { python3 -c "import json,sys; d=json.load(open('$STATE_FILE')); print(d.get('$1',''))" } # Write updated state atomically state_update() { local sha="$1" status="$2" alerted="$3" python3 - "$sha" "$status" "$alerted" <<'PY' import json, sys sha, status, alerted = sys.argv[1], sys.argv[2], json.loads(sys.argv[3]) with open('STATE_FILE_PATH') as f: d = json.load(f) d['last_sha'] = sha d['last_status'] = status d['alerted_run_ids'] = alerted with open('STATE_FILE_PATH', 'w') as f: json.dump(d, f) PY } # Replace the placeholder path in the python heredoc above state_update() { local sha="$1" status="$2" alerted_json="$3" python3 -c " import json with open('$STATE_FILE') as f: d = json.load(f) d['last_sha'] = '$sha' d['last_status'] = '$status' d['alerted_run_ids'] = $alerted_json with open('$STATE_FILE', 'w') as f: json.dump(d, f) " } # Fire TTS alert. Per Rail-4: personality MUST be ravdess02, never default. tts_alert() { local message="$1" log "ALERT: $message" # When running in a Claude agent session the MCP tool is available as a # shell command. Outside an agent session we fall back to a desktop # notification so the watcher still notifies without crashing. if command -v mcp__speech-synthesis__synthesize &>/dev/null; then mcp__speech-synthesis__synthesize \ --personality ravdess02 \ --text "$message" elif command -v notify-send &>/dev/null; then notify-send "Forge CI FAIL" "$message" else log "WARNING: no TTS/notification command available; alert message logged only." fi } # Fetch the combined commit status for the HEAD of FORGE_BRANCH. # Returns JSON like {"state":"failure","sha":"abc123","run_id":"42"} or # {"state":"pending","sha":"...","run_id":""}. fetch_head_status() { # 1. Get HEAD SHA of the branch local branch_json branch_json="$(curl -sf \ "$FORGEJO_URL/api/v1/repos/$FORGE_REPO/branches/$FORGE_BRANCH" \ -H "Authorization: token $FORGEJO_TOKEN")" local sha sha="$(echo "$branch_json" | python3 -c "import json,sys; print(json.load(sys.stdin)['commit']['id'])")" # 2. Get the combined (aggregated) status for that SHA local status_json status_json="$(curl -sf \ "$FORGEJO_URL/api/v1/repos/$FORGE_REPO/commits/$sha/status" \ -H "Authorization: token $FORGEJO_TOKEN")" local state state="$(echo "$status_json" | python3 -c "import json,sys; print(json.load(sys.stdin).get('state','unknown'))")" # 3. Extract the run_id from the first status entry (used for dedup) local run_id run_id="$(curl -sf \ "$FORGEJO_URL/api/v1/repos/$FORGE_REPO/commits/$sha/statuses?limit=1" \ -H "Authorization: token $FORGEJO_TOKEN" \ | python3 -c "import json,sys; ss=json.load(sys.stdin); print(ss[0]['target_url'].split('/')[-2] if ss else '')" 2>/dev/null || echo "")" echo "{\"sha\":\"$sha\",\"state\":\"$state\",\"run_id\":\"$run_id\"}" } # ── Main poll loop ─────────────────────────────────────────────────────────── log "Starting. Watching $FORGE_REPO @ $FORGE_BRANCH every ${POLL_INTERVAL}s." log "State file: $STATE_FILE" while true; do result="$(fetch_head_status 2>/dev/null || echo '{"sha":"","state":"error","run_id":""}')" sha="$(echo "$result" | python3 -c "import json,sys; print(json.load(sys.stdin)['sha'])")" state="$(echo "$result" | python3 -c "import json,sys; print(json.load(sys.stdin)['state'])")" run_id="$(echo "$result" | python3 -c "import json,sys; print(json.load(sys.stdin)['run_id'])")" last_sha="$(state_get last_sha)" last_status="$(state_get last_status)" alerted_json="$(python3 -c "import json; d=json.load(open('$STATE_FILE')); print(json.dumps(d.get('alerted_run_ids',[])))")" # Check whether this run_id was already alerted already_alerted="$(python3 -c "import json; ids=json.loads('$alerted_json'); print('yes' if '$run_id' in ids and '$run_id' else 'no')")" if [[ "$state" == "failure" && "$already_alerted" == "no" && -n "$run_id" ]]; then short_sha="${sha:0:8}" msg="CI FAIL on $FORGE_BRANCH at $short_sha — run $run_id. Check $FORGEJO_URL/$FORGE_REPO/actions/runs/$run_id" tts_alert "$msg" # Add run_id to alerted list alerted_json="$(python3 -c "import json; ids=json.loads('$alerted_json'); ids.append('$run_id'); print(json.dumps(ids))")" fi if [[ -n "$sha" && "$state" != "error" ]]; then state_update "$sha" "$state" "$alerted_json" if [[ "$sha" != "$last_sha" || "$state" != "$last_status" ]]; then log "sha=${sha:0:8} state=$state run_id=${run_id:-n/a}" fi else log "WARNING: could not fetch status (network/API error); retrying." fi sleep "$POLL_INTERVAL" done