magicciv/tools/forge-watch.sh

190 lines
7.6 KiB
Bash
Raw Normal View History

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