190 lines
7.6 KiB
Bash
190 lines
7.6 KiB
Bash
|
|
#!/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
|