diff --git a/AGENTS.md b/AGENTS.md index 5d4da3d..94743a5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -75,6 +75,8 @@ state/ volatile runtime signals; gitignored .watch.lock .wake-queue.lock watcher singleton and queue serialization locks .hash-* .count-* .stale-* .seen-* .last-* .heartbeat-streak watcher internals; never touch .last-watcher-beat watcher liveness beacon, touched every poll; fm-guard.sh reads it + .github-watch-config fm-github-watch.sh filter/contributor config (key=value); never touch unless driving that tool + .github-watch-seen/ fm-github-watch.sh per-PR seen state (high-water marks); owned by that script .no-mistakes/ local validation state and evidence; gitignored ``` diff --git a/README.md b/README.md index 5f0df7f..02c8b66 100644 --- a/README.md +++ b/README.md @@ -140,6 +140,7 @@ The first mate drives these; you rarely need to, but they work by hand too. | `fm-merge-local.sh` | Fast-forward a `local-only` project's local default branch after approval | | `fm-review-diff.sh` | Review a crewmate branch against the authoritative base, with optional `--stat` output | | `fm-watch.sh` | Singleton-safe one-shot watcher; blocks until supervision work is due, queues it durably, then exits with one reason line | +| `fm-github-watch.sh` | Poll the contributor's open PRs for new comments/CI/reviews/merges; toggle filters via `filter on\|off` | | `fm-wake-drain.sh` | Atomically drain queued watcher wakes before handling supervision work | | `fm-send.sh` | Send one literal line (or `--key Escape`) to a crewmate window | | `fm-peek.sh` | Print a bounded tail of a crewmate pane | @@ -168,6 +169,9 @@ FM_SIGNAL_GRACE=30 # seconds to coalesce nearby status and turn-end signals FM_FLEET_SYNC_BOOTSTRAP_TIMEOUT=20 # seconds allowed for bootstrap's best-effort clone refresh FM_FLEET_PRUNE=1 # set to 0 to skip pruning local branches whose upstream is gone FM_BUSY_REGEX='esc (to )?interrupt|Working\.\.\.' # busy-pane signatures, extend per harness +FM_GH_CONTRIBUTOR= # GitHub user whose open PRs fm-github-watch.sh polls; unset = derive from `gh auth` +FM_GH_POLL_SECS=300 # seconds between polls when fm-github-watch.sh runs as --daemon +FM_GH_CLOSE_REPROBE_SECS=7200 # seconds after a PR closes to keep re-probing for a reopen->merge; bounds API cost ``` ## Development diff --git a/bin/fm-github-watch.sh b/bin/fm-github-watch.sh new file mode 100755 index 0000000..db3be98 --- /dev/null +++ b/bin/fm-github-watch.sh @@ -0,0 +1,665 @@ +#!/usr/bin/env bash +# fm-github-watch.sh — GitHub events watcher for the fleet's open PRs. +# +# Discovers all of a contributor's open PRs and surfaces new comments (from +# maintainers, reviewers, or bots), CI status changes, reviews, and +# merge/close transitions as one-line events on stdout. Built to run as a +# watcher check script: it prints iff firstmate should wake, and stays +# silent otherwise. +# +# Wire it in with a check script the existing watcher already sweeps, e.g.: +# ln -s ../bin/fm-github-watch.sh state/github-events.check.sh +# bin/fm-watch.sh runs state/*.check.sh every FM_CHECK_INTERVAL (default +# 300s); any stdout is captured, classified as a `check` wake, escalated. +# A full poll issues up to 5 gh calls per open PR, but PRs are polled +# concurrently (bounded by FM_GH_CONCURRENCY, default 8) so a sweep across the +# fleet finishes in well under the watcher's 30s check-script timeout. Events +# emit per-PR (not all-at-end), so a timeout still surfaces partial progress. +# +# Usage: +# fm-github-watch.sh # one poll cycle (same as --once) +# fm-github-watch.sh --once # one poll cycle +# fm-github-watch.sh --daemon # loop, polling every poll_interval +# fm-github-watch.sh filter list # show active filters +# fm-github-watch.sh filter on|off +# fm-github-watch.sh contributor # show configured contributor +# fm-github-watch.sh contributor +# fm-github-watch.sh status # show config + seen-state summary +# +# Filter names: comments, ci, reviews, merge. +# Config: state/.github-watch-config (key=value lines). +# Seen: state/.github-watch-seen/-- (key=value lines). +# +# The ci filter rolls the Checks API (check-runs) up to a single overall state +# per PR (green/failure/pending) and fires one event only when that state flips, +# not once per check landing — so a PR whose many checks trickle in reports a +# single transition, not a burst. CI providers that report only via the legacy +# commit status API (some older Travis/Coveralls setups) are not covered; use +# `gh pr checks` directly for a unified view. +# Comment, review, and check-run counts fetch up to 100 items per type per PR +# (per_page=100, no pagination); a single PR with >100 of one kind would cap. +# +# Losslessness: for each PR, events are emitted BEFORE its seen marker advances +# (and bash's builtin printf write()s to the capture pipe immediately, so an +# emitted event survives even a SIGKILL). A crash between the print and the seen +# write at worst causes a redundant re-detect next cycle, never a permanent +# swallow. A failing seen write leaves the old marker in place, so the same +# event fires again next cycle. PRs are polled concurrently but each worker +# owns its own per-PR seen file, so this ordering holds per-worker exactly. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" +STATE="${FM_STATE_OVERRIDE:-$FM_ROOT/state}" +CONFIG="$STATE/.github-watch-config" +SEEN_DIR="$STATE/.github-watch-seen" +ALL_FILTERS="comments,ci,reviews,merge" +DEFAULT_POLL_SECS="${FM_GH_POLL_SECS:-300}" +# How long after a PR closes to keep re-probing it for a close->reopen->merge. +# Bounds API cost: a closed PR is re-checked only within this window, then +# treated as settled. ~2h at the default 300s poll. +CLOSE_REPROBE_SECS="${FM_GH_CLOSE_REPROBE_SECS:-7200}" +# Max number of PRs polled concurrently in a single sweep. Bounded so a large +# fleet can't burst GitHub's rate limit or hammer the API. ~88 calls/sweep at +# the captain's ~22 PRs is well under the 5000/hr ceiling even at 12 sweeps/hr. +# Set FM_GH_CONCURRENCY to tune (>=1; 0/non-numeric falls back to the default 8). +DEFAULT_CONCURRENCY=8 +# Seen-state schema version. Bump when a stored field's meaning or the field set +# changes in a way that would make a prior value miscompare (e.g. the ci roll-up +# changed `ci` from a multiset signature to a single state). On a schema mismatch +# the first poll silently re-baselines: it writes the new seen state and emits no +# event, so deploying a schema change never floods once as every PR appears to +# "transition" off the old format. Only subsequent real transitions fire. +SEEN_SCHEMA=2 +# Regex (Oniguruma) of check-run NAMES to drop from the CI roll-up before it is +# computed. Default: the known fork-routing signature gap #293 ("PR must be +# raised via no-mistakes"), which fails on kunchenguid fork-PRs even though the +# PR's real checks pass. With it excluded such PRs roll up to green when their +# real checks pass, instead of a false failure. Set FM_GH_IGNORE_CHECKS to a +# custom regex, or to empty to disable filtering entirely. Only the CI roll-up +# applies this; the raw check list and the other filters are unchanged. +IGNORE_CHECKS="${FM_GH_IGNORE_CHECKS-PR must be raised via no-mistakes}" + +mkdir -p "$STATE" "$SEEN_DIR" + +# ---- small helpers ---- + +is_int() { case "${1:-}" in ''|*[!0-9]*) return 1 ;; *) return 0 ;; esac; } + +valid_filter() { + case "$1" in comments|ci|reviews|merge) return 0 ;; *) return 1 ;; esac +} + +# A GitHub REST API error body is a JSON object carrying top-level "message" and +# "documentation_url" (e.g. {"message":"Bad credentials","documentation_url":"...","status":"401"}). +# On a transient API failure (401, 5xx, rate limit) gh writes that body to stdout +# — bypassing any --jq template — and exits non-zero. Every successful probe +# output is a scalar/number/TSV, never this shape, so the pair is a safe signal. +is_gh_error() { + case "$1" in + *'"message"'*) + case "$1" in *'"documentation_url"'*) return 0 ;; esac + ;; + esac + return 1 +} + +# Run gh, capturing its stdout. Returns non-zero if gh exited non-zero OR its +# output is a GitHub API error body; in either case the body is suppressed so a +# caller that ignores the exit status can never parse an error response as data +# (the bug: a 401 body reached stdout and was parsed as CI state, firing a bogus +# "CI: ... -> { \"message\": ... }" event). Probe callers treat a non-zero return +# as "skip this PR this cycle" so a transient blip never surfaces as an event. +# stderr is always swallowed so a missing gh or a transient failure never spams +# the watcher's own capture pipe. +ghc() { + local out rc + out=$(command gh "$@" 2>/dev/null); rc=$? + if [ "$rc" -ne 0 ] || is_gh_error "$out"; then + return 1 + fi + printf '%s' "$out" +} + +# cfg_read -> prints value (empty if missing/unset) +cfg_read() { + local key=$1 + [ -f "$CONFIG" ] || return 0 + awk -F= -v k="$key" '$1==k { sub(/^[^=]*=/, ""); print; exit }' "$CONFIG" +} + +# cfg_has -> 0 if the key exists in the config (distinguishes a configured +# empty value, e.g. `filters=`, from a missing key so "all filters off" sticks). +cfg_has() { + local key=$1 + [ -f "$CONFIG" ] && grep -q "^${key}=" "$CONFIG" +} + +# cfg_write (upsert a single key=value line) +cfg_write() { + local key=$1 val=$2 tmp + val=$(printf '%s' "$val" | tr '\n' ' ') + if [ -f "$CONFIG" ] && grep -q "^${key}=" "$CONFIG"; then + tmp="${CONFIG}.tmp.$$" + awk -F= -v k="$key" -v v="$val" '$1==k { print k"="v; next } { print }' \ + "$CONFIG" > "$tmp" && mv "$tmp" "$CONFIG" + else + printf '%s=%s\n' "$key" "$val" >> "$CONFIG" + fi +} + +get_contributor() { + # Precedence: configured value > FM_GH_CONTRIBUTOR env > authenticated gh user. + # No hardcoded default: a shared tool should poll whoever is logged in. + local v + v=$(cfg_read contributor) + if [ -n "$v" ]; then printf '%s' "$v"; return; fi + if [ -n "${FM_GH_CONTRIBUTOR:-}" ]; then printf '%s' "$FM_GH_CONTRIBUTOR"; return; fi + ghc api user -q .login | tr -d '\n' || true +} + +get_filters() { + # A configured value (even empty = all filters off) is respected; only a + # never-configured key falls back to the full default set. + if cfg_has filters; then + cfg_read filters + else + printf '%s' "$ALL_FILTERS" + fi +} + +filter_enabled() { + case ",$(get_filters)," in *",$1,"*) return 0 ;; *) return 1 ;; esac +} + +get_poll() { + local v + v=$(cfg_read poll_interval) + case "${v:-}" in ''|*[!0-9]*) printf '%s' "$DEFAULT_POLL_SECS" ;; *) printf '%s' "$v" ;; esac +} + +# Max concurrent per-PR workers in a sweep. FM_GH_CONCURRENCY overrides; a +# missing, empty, non-numeric, or zero value falls back to the sane default. +get_concurrency() { + local v="${FM_GH_CONCURRENCY:-}" + case "$v" in ''|*[!0-9]*|0) printf '%s' "$DEFAULT_CONCURRENCY" ;; *) printf '%s' "$v" ;; esac +} + +# seen_file -> path to that PR's seen-state file +seen_file() { printf '%s/%s-%s-%s\n' "$SEEN_DIR" "$1" "$2" "$3"; } + +# seen_get -> value (empty if missing) +seen_get() { + local f=$1 key=$2 + [ -f "$f" ] || return 0 + awk -F= -v k="$key" '$1==k { sub(/^[^=]*=/, ""); print; exit }' "$f" +} + +# ---- comma-list set ops ---- + +# list_contains "a,b,c" "b" -> 0 if present +list_contains() { + case ",$1," in *",$2,"*) return 0 ;; *) return 1 ;; esac +} + +# list_add "a,b,c" "d" -> "a,b,c,d" (dedup; preserves order) +list_add() { + local list=$1 item=$2 + if list_contains "$list" "$item"; then + printf '%s' "$list" + else + if [ -z "$list" ]; then printf '%s' "$item"; else printf '%s,%s' "$list" "$item"; fi + fi +} + +# list_remove "a,b,c" "b" -> "a,c" +list_remove() { + local list=$1 item=$2 out="" i + local IFS=, + for i in $list; do + [ "$i" = "$item" ] && continue + if [ -z "$out" ]; then out=$i; else out="$out,$i"; fi + done + printf '%s' "$out" +} + +# ---- discovery + per-PR probes (each fails open: empty output, no crash) ---- + +# Prints "owner/reponumber" per open PR by the contributor. +discover_prs() { + local contributor + contributor=$(get_contributor) + # An empty contributor (gh missing/unauthed) must NOT pass --author="" to the + # search: GitHub treats an empty author qualifier as no filter, which would + # match open PRs across every repo and flood the seen state. + [ -n "$contributor" ] || return 0 + ghc search prs --author="$contributor" --state=open --limit 1000 \ + --json repository,number \ + --jq '.[] | [.repository.nameWithOwner, .number] | @tsv' +} + +# count_comments +count_comments() { + CONTRIB_WATCH="$4" ghc api "repos/$1/$2/issues/$3/comments?per_page=100" \ + --jq '[.[] | select(.user.login != env.CONTRIB_WATCH)] | length' +} + +# count_reviews +# Excludes the contributor's own reviews (self-reviews) but keeps maintainer and +# bot reviews (Greptile, coderabbit, etc. have distinct logins). +count_reviews() { + CONTRIB_WATCH="$4" ghc api "repos/$1/$2/pulls/$3/reviews?per_page=100" \ + --jq '[.[] | select(.user.login != env.CONTRIB_WATCH)] | length' +} + +# pr_state -> OPEN|MERGED|CLOSED (empty on failure) +pr_state() { + ghc pr view "$3" -R "$1/$2" --json state -q .state +} + +# head_sha +head_sha() { + ghc pr view "$3" -R "$1/$2" --json headRefOid -q .headRefOid +} + +# ci_state -> the commit's rolled-up overall CI state: +# success every non-neutral check passed (conclusion success/skipped), none still running +# failure at least one non-neutral check failed (failure/timed_out/cancelled/action_required/stale) +# pending at least one non-neutral check is still queued/in_progress (no conclusion yet) +# neutral only neutral check-runs are present +# (empty) no check-runs reported yet; the caller carries forward the prior state +# Rolled up from the Checks API so a PR with many staggered checks surfaces a +# single green/red transition instead of one event per check landing. Failure +# beats pending (a red check already settles the PR's outcome), matching +# GitHub's own combined-status precedence. Check-runs whose NAME matches the +# IGNORE_CHECKS regex (default: the known fork-routing gap #293) are dropped +# before the roll-up, so a PR that fails ONLY that signature check still rolls +# up to green when its real checks pass. The regex is embedded into the jq +# program (escaped for a JSON string literal) because `gh api` has no --arg +# binding for its --jq filter; a malformed regex fails open to empty (carried +# forward), never crashing the poll. +ci_state() { + [ -n "$3" ] || return 0 + local ignore_escaped jq_filter + ignore_escaped=${IGNORE_CHECKS//\\/\\\\} + ignore_escaped=${ignore_escaped//\"/\\\"} + # The regex is embedded into the jq program (escaped for a JSON string + # literal) because `gh api` has no --arg binding for its --jq filter. Every jq + # binding ($ignore/$raw/$all/$rel) is backslash-escaped so the heredoc leaves + # it literal; only $ignore_escaped expands. + # shellcheck disable=SC2016 + jq_filter=$(cat < -> the word printed in a CI event line (success -> green). +ci_label() { + case "${1:-}" in + success) printf 'green' ;; + *) printf '%s' "${1:-unknown}" ;; + esac +} + +# ---- the poll ---- + +# atomic_write — write seen state via temp + rename so a crash +# or a read-only state dir can never leave a partial file. On any failure the +# prior file is left untouched, so the event re-fires next cycle (lossless). +# The temp lives in a hidden .tmp subdir of the seen dir (same filesystem, so +# the rename is atomic) so a crash-leaked temp never matches detect_left_open's +# `"$SEEN_DIR"/*` glob and cause a double-fire. +atomic_write() { + local file=$1 content=$2 tmp stagedir + stagedir="$SEEN_DIR/.tmp" + tmp="$stagedir/$(basename "$file").$$" + mkdir -p "$stagedir" 2>/dev/null || true + # Redirect fd 2 to /dev/null BEFORE the output redirect so a failure to open + # the temp (read-only dir) is reported to /dev/null, not the terminal. + if printf '%s\n' "$content" 2>/dev/null > "$tmp"; then + mv -f "$tmp" "$file" 2>/dev/null || rm -f "$tmp" 2>/dev/null + else + rm -f "$tmp" 2>/dev/null + fi +} + +# build_seen +# Compose the seen-state block: high-water marks for counts, current value for +# ci/state. Fields with no fresh value this cycle are carried forward from the +# prior block, so toggling a filter off never wipes its remembered high-water. +# CI is the rolled-up overall state; it is carried forward across a transiently +# empty fetch (a new commit whose check-runs have not populated yet) so a later +# state transition still fires. +build_seen() { + local sf=$1 owner=$2 repo=$3 pr=$4 c_count=$5 r_count=$6 ci_st=$7 sha=$8 p_state=$9 + local seen_c seen_r seen_ci seen_state new_c new_r ci_val state_val block + seen_c=$(seen_get "$sf" comments) + seen_r=$(seen_get "$sf" reviews) + seen_ci=$(seen_get "$sf" ci) + seen_state=$(seen_get "$sf" state) + new_c=$seen_c; new_r=$seen_r + if is_int "$c_count"; then + if is_int "$seen_c"; then new_c=$((seen_c > c_count ? seen_c : c_count)); else new_c=$c_count; fi + fi + if is_int "$r_count"; then + if is_int "$seen_r"; then new_r=$((seen_r > r_count ? seen_r : r_count)); else new_r=$r_count; fi + fi + ci_val=$ci_st + [ -n "$ci_val" ] || ci_val=$seen_ci + state_val=$p_state + [ -n "$state_val" ] || state_val=$seen_state + block=$(printf 'owner=%s\nrepo=%s\npr=%s\nschema=%s\ninitialized=1' "$owner" "$repo" "$pr" "$SEEN_SCHEMA") + is_int "$new_c" && block=$(printf '%s\ncomments=%s' "$block" "$new_c") + is_int "$new_r" && block=$(printf '%s\nreviews=%s' "$block" "$new_r") + [ -n "$ci_val" ] && block=$(printf '%s\nci=%s' "$block" "$ci_val") + [ -n "$sha" ] && block=$(printf '%s\nsha=%s' "$block" "$sha") + [ -n "$state_val" ] && block=$(printf '%s\nstate=%s' "$block" "$state_val") + printf '%s' "$block" +} + +# process_pr +# Gather fresh data for the enabled filters, EMIT any new events for this PR, +# then advance this PR's seen marker. Per-PR ordering (print before seen) plus +# bash's immediate write() to the capture pipe make this lossless even if the +# poll is killed mid-cycle: an emitted event is already in the pipe, and a PR +# whose marker never advanced simply re-fires next cycle. Runs one worker per +# PR under poll_once's bounded concurrency; each worker writes only this PR's +# own seen file, so concurrent workers never contend on seen state. +process_pr() { + local owner=$1 repo=$2 pr=$3 contributor=$4 + local sf c_count r_count p_state sha ci_st + local initialized seen_c seen_r seen_state seen_ci ev="" + sf=$(seen_file "$owner" "$repo" "$pr") + + local api_err=0 + c_count="" r_count="" p_state="" sha="" ci_st="" + if filter_enabled comments; then c_count=$(count_comments "$owner" "$repo" "$pr" "$contributor") || api_err=1; fi + if filter_enabled reviews; then r_count=$(count_reviews "$owner" "$repo" "$pr" "$contributor") || api_err=1; fi + if filter_enabled merge; then p_state=$(pr_state "$owner" "$repo" "$pr") || api_err=1; fi + if filter_enabled ci; then + sha=$(head_sha "$owner" "$repo" "$pr") || api_err=1 + if [ -n "$sha" ]; then ci_st=$(ci_state "$owner" "$repo" "$sha") || api_err=1; fi + fi + # If any enabled probe hit a GitHub API error this cycle, skip the whole PR: + # emit nothing and do not advance seen, so a transient blip can never surface + # as an event (e.g. an error JSON parsed as CI data). The next cycle + # re-evaluates from the same baseline — lossless, never a permanent swallow. + if [ "$api_err" -ne 0 ]; then + printf 'fm-github-watch: skipping %s/%s#%s this cycle (GitHub API error)\n' \ + "$owner" "$repo" "$pr" >&2 + return 0 + fi + + initialized=$(seen_get "$sf" initialized) + # A prior seen file whose schema does not match the current version is treated + # as a first-run baseline: emit nothing this cycle (so deploying a schema + # change never floods as every PR appears to "transition" off the old format) + # and let build_seen rewrite it at the current schema with carried-forward + # values. Only subsequent real transitions fire. + if [ -n "$initialized" ] && [ "$(seen_get "$sf" schema)" = "$SEEN_SCHEMA" ]; then + seen_c=$(seen_get "$sf" comments) + seen_r=$(seen_get "$sf" reviews) + seen_state=$(seen_get "$sf" state) + seen_ci=$(seen_get "$sf" ci) + + # comments (high-water): event on increase only. + if is_int "$c_count" && is_int "$seen_c" && [ "$c_count" -gt "$seen_c" ]; then + ev="${ev}COMMENT: ${owner}/${repo}#${pr} has $((c_count - seen_c)) new comment(s) +" + fi + # reviews (high-water): event on increase only. + if is_int "$r_count" && is_int "$seen_r" && [ "$r_count" -gt "$seen_r" ]; then + ev="${ev}REVIEW: ${owner}/${repo}#${pr} has $((r_count - seen_r)) new review(s) +" + fi + # ci: event on overall-state transition only (debounced). A PR with many + # staggered checks surfaces one event per green/red/pending flip, not one + # per check landing. No event while the rolled-up state is unchanged. + if [ -n "$ci_st" ] && [ -n "$seen_ci" ] && [ "$seen_ci" != "$ci_st" ]; then + ev="${ev}CI: ${owner}/${repo}#${pr} -> $(ci_label "$ci_st") +" + fi + # merge: event on open -> merged/closed transition. + if [ -n "$p_state" ] && [ "$p_state" != "$seen_state" ]; then + case "$p_state" in + MERGED) [ "${seen_state:-OPEN}" = "OPEN" ] && ev="${ev}MERGED: ${owner}/${repo}#${pr} +" ;; + CLOSED) [ "${seen_state:-OPEN}" = "OPEN" ] && ev="${ev}CLOSED: ${owner}/${repo}#${pr} +" ;; + esac + fi + fi + + # --- LOSSLESSNESS BOUNDARY (per-PR) --- + # Emit this PR's events first (bash's printf write()s to the pipe at once), + # then advance its seen marker. A crash between the two leaves the event + # delivered and the marker stale -> a redundant re-detect, never a swallow. + [ -n "$ev" ] && printf '%s' "$ev" + local block + block=$(build_seen "$sf" "$owner" "$repo" "$pr" "$c_count" "$r_count" "$ci_st" "$sha" "$p_state") + atomic_write "$sf" "$block" +} + +# Emit one poll cycle. +poll_once() { + local contributor prs fullname pr owner repo basename + local open_basenames=" " + local max_jobs running + max_jobs=$(get_concurrency) + running=0 + contributor=$(get_contributor) + # If discovery itself failed (transient API blip), abort the cycle: an empty + # result would otherwise make detect_left_open think every open PR merged. + prs=$(discover_prs) || { + printf 'fm-github-watch: PR discovery failed this cycle; skipping\n' >&2 + return 0 + } + + # Parallel per-PR polling. Each worker is a subshell running process_pr; each + # owns its own seen file (seen_file is keyed by owner/repo/pr), so concurrent + # seen writes never collide. Concurrency is bounded by FM_GH_CONCURRENCY + # (default 8) via a counting semaphore so a large fleet can't burst the GitHub + # rate limit. Each worker prints its whole event block in a single printf + # (one write() of a few hundred bytes, atomic under PIPE_BUF, so lines never + # interleave), and only then advances its own seen marker — the losslessness + # invariant (print before seen) holds per-worker exactly as in the serial + # model: a crash/timeout mid-sweep at worst re-detects, never swallows. + while IFS=$'\t' read -r fullname pr; do + [ -n "${fullname:-}" ] || continue + owner=${fullname%%/*} + repo=${fullname#*/} + if [ -z "$owner" ] || [ -z "$repo" ] || [ "$owner" = "$fullname" ] || [ -z "${pr:-}" ]; then + continue + fi + basename=$(seen_file "$owner" "$repo" "$pr"); basename=${basename##*/} + open_basenames="${open_basenames}${basename} " + + # Throttle: at capacity, wait for one worker to finish before launching the + # next. wait -n (bash >= 4.3) blocks until any child exits; the decrement + # keeps the running count honest (it can only under-count finished workers, + # which is conservative — concurrency never exceeds the cap). + while [ "$running" -ge "$max_jobs" ]; do + wait -n 2>/dev/null || wait + running=$((running - 1)) + done + + # reopen->merge still fires, without an unbounded per-cycle API cost as +# closed PRs accumulate. detect_left_open (space-padded: +# " key1 key2 " so the last entry matches too). +detect_left_open() { + local open_basenames=$1 f base owner repo pr seen_state p_state block closed_at now + filter_enabled merge || return 0 + [ -d "$SEEN_DIR" ] || return 0 + now=$(date +%s) + for f in "$SEEN_DIR"/*; do + [ -e "$f" ] || continue + base=${f##*/} + case "$base" in *.tmp.*) continue ;; esac + case "$open_basenames" in *" $base "*) continue ;; esac + [ -n "$(seen_get "$f" initialized)" ] || continue + seen_state=$(seen_get "$f" state) + [ "$seen_state" = "MERGED" ] && continue # merged is the only terminal state + # A CLOSED PR older than the re-probe window is settled: skip the API call + # so accumulated closed PRs cannot push the fleet past the rate limit. + if [ "$seen_state" = "CLOSED" ]; then + closed_at=$(seen_get "$f" closed_at) + if [ -n "$closed_at" ] && [ $((now - closed_at)) -ge "$CLOSE_REPROBE_SECS" ]; then + continue + fi + fi + owner=$(seen_get "$f" owner) + repo=$(seen_get "$f" repo) + pr=$(seen_get "$f" pr) + if [ -z "$owner" ] || [ -z "$repo" ] || [ -z "$pr" ]; then continue; fi + p_state=$(pr_state "$owner" "$repo" "$pr") || continue + [ -n "$p_state" ] || continue # transient gh failure: leave seen state untouched + # Migration: a prior seen file whose schema does not match the current + # version is silently re-baselined — stamp the current schema + observed + # state, emit nothing — so a schema change never floods as every PR appears + # to "transition" off the old format. All other fields (closed_at, counts, + # ci) are preserved; only schema/state are re-stamped. + if [ "$(seen_get "$f" schema)" != "$SEEN_SCHEMA" ]; then + block=$(awk -F= -v sch="$SEEN_SCHEMA" -v s="$p_state" \ + '$1 != "schema" && $1 != "state" { print } END { print "schema=" sch; print "state=" s }' "$f") + atomic_write "$f" "$block" + continue + fi + [ "$p_state" = "$seen_state" ] && continue # unchanged: no event, no rewrite + case "$p_state" in + MERGED|CLOSED) + # Emit, then advance state (same per-PR losslessness ordering). + printf '%s: %s/%s#%s\n' "$p_state" "$owner" "$repo" "$pr" + ;; + *) + # Reopened back to OPEN (or unknown): no event, but track the new state + # so a later merge still fires from the right baseline. + ;; + esac + # Rewrite state; stamp closed_at when entering CLOSED so the re-probe window + # can age it out, and clear it on any other transition. + local cat="" + [ "$p_state" = "CLOSED" ] && cat=$now + block=$(awk -F= -v s="$p_state" -v cat="$cat" \ + '$1!="state" && $1!="closed_at" { print } END { print "state=" s; if (cat != "") print "closed_at=" cat }' "$f") + atomic_write "$f" "$block" + done +} + +# ---- daemon ---- + +poll_daemon() { + local interval + interval=$(get_poll) + trap 'exit 0' INT TERM + while :; do + poll_once + sleep "$interval" + done +} + +# ---- CLI subcommands ---- + +cmd_filter() { + # filter list -> show active filters + # filter on|off -> toggle a filter + local name="${1:-}" state="${2:-}" + if [ -z "$name" ] || [ "$name" = "list" ]; then + local IFS=, + for f in $(get_filters); do printf '%s\n' "$f"; done + return + fi + valid_filter "$name" || { echo "error: unknown filter '$name' (comments|ci|reviews|merge)" >&2; exit 2; } + case "$state" in + on|off) ;; + *) echo "usage: fm-github-watch.sh filter [list | on|off]" >&2; exit 2 ;; + esac + local cur new + cur=$(get_filters) + if [ "$state" = "on" ]; then + new=$(list_add "$cur" "$name") + else + new=$(list_remove "$cur" "$name") + fi + cfg_write filters "$new" + echo "filters=$new" +} + +cmd_contributor() { + if [ "$#" -gt 0 ]; then + cfg_write contributor "$1" + echo "contributor=$1" + else + get_contributor + fi +} + +cmd_status() { + local contributor filters f on seen_count + contributor=$(get_contributor) + filters=$(get_filters) + printf 'contributor: %s\n' "$contributor" + printf 'filters:\n' + for f in comments ci reviews merge; do + if list_contains "$filters" "$f"; then on=on; else on=off; fi + printf ' %s: %s\n' "$f" "$on" + done + printf 'poll interval: %ss\n' "$(get_poll)" + seen_count=0 + if [ -d "$SEEN_DIR" ]; then + # Exclude the .tmp staging subdir so leaked temps never inflate the count. + seen_count=$(find "$SEEN_DIR" -type f -not -path '*/.tmp/*' 2>/dev/null | wc -l | tr -d ' ') + fi + printf 'seen PRs: %s\n' "$seen_count" +} + +usage() { + # Print the leading `#` header comment (lines 2..) up to the first non-comment + # line, stripping the `# ` prefix. Stops before `set -u` so no code leaks. + awk 'NR==1 { next } /^#/ { sub(/^# ?/, ""); print; next } { exit }' "$0" +} + +# ---- entry ---- + +case "${1:-}" in + --help|-h) usage; exit 0 ;; + --once|"") poll_once ;; + --daemon) poll_daemon ;; + filter) shift; cmd_filter "$@" ;; + contributor) shift; cmd_contributor "$@" ;; + status) cmd_status ;; + *) + echo "error: unknown command '${1:-}'" >&2 + usage >&2 + exit 2 + ;; +esac diff --git a/tests/fm-github-watch.test.sh b/tests/fm-github-watch.test.sh new file mode 100755 index 0000000..40247fe --- /dev/null +++ b/tests/fm-github-watch.test.sh @@ -0,0 +1,833 @@ +#!/usr/bin/env bash +# Behavior tests for fm-github-watch.sh. +# A fake `gh` on PATH serves canned, file-driven responses so each test can +# mutate fixture state between poll cycles and assert on emitted events. +set -u + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +GH_WATCH="$ROOT/bin/fm-github-watch.sh" + +fail() { + printf 'not ok - %s\n' "$1" >&2 + exit 1 +} + +pass() { + printf 'ok - %s\n' "$1" +} + +TMP_ROOT= +cleanup() { + [ -n "${TMP_ROOT:-}" ] && rm -rf "$TMP_ROOT" +} +trap cleanup EXIT + +TMP_ROOT=$(mktemp -d "${TMPDIR:-/tmp}/fm-ghwatch-tests.XXXXXX") + +# Build an isolated case dir with its own state/ + fakebin/gh, and echo its root. +# The fake gh reads fixtures from $GH_FIXTURE (one PR's data per set of files). +make_case() { + local name=$1 dir fakebin + dir="$TMP_ROOT/$name" + fakebin="$dir/fakebin" + mkdir -p "$dir/state" "$dir/fixture" "$fakebin" + cat > "$fakebin/gh" <<'GH' +#!/usr/bin/env bash +# Minimal, file-driven gh stand-in for fm-github-watch tests. +set -u +FX="${GH_FIXTURE:?no fixture}" +emit_default() { :; } # most commands print nothing by default + +sub="${1:-}" +shift || true + +case "$sub" in + search) + # gh search prs ... : print "owner/reponum" lines. + [ -f "$FX/prs" ] && cat "$FX/prs" + exit 0 + ;; + api) + # Injectable transient API error: when $FX/api-error exists, emit a GitHub + # error body to stdout and exit non-zero — exactly how real gh behaves on a + # 401/5xx (the --jq template is bypassed on error responses). This is the + # bug surface: the raw error JSON reached stdout and was parsed as CI data. + if [ -f "$FX/api-error" ]; then + printf '{"message":"Bad credentials","documentation_url":"https://docs.github.com/rest","status":"401"}\n' + exit 1 + fi + # gh api --jq ... : find the repos/... path argument. + path="" + for a in "$@"; do + case "$a" in repos/*) path=$a ;; esac + done + path="${path%%\?*}" # strip any ?per_page=... query before matching + # repos/OWNER/REPO/issues/NUM/comments -> comments-OWNER-REPO-NUM + # repos/OWNER/REPO/pulls/NUM/reviews -> reviews-OWNER-REPO-NUM + # repos/OWNER/REPO/commits/SHA/check-runs -> ci-SHA + case "$path" in + */issues/*/comments) + rest=${path#repos/} # OWNER/REPO/issues/NUM/comments + owner=${rest%%/*}; rest=${rest#*/} + repo=${rest%%/*}; rest=${rest#*/} + num=${rest#issues/}; num=${num%/comments} + f="$FX/comments-$owner-$repo-$num" + [ -f "$f" ] && { cat "$f"; exit 0; } + echo 0; exit 0 + ;; + */pulls/*/reviews) + rest=${path#repos/} + owner=${rest%%/*}; rest=${rest#*/} + repo=${rest%%/*}; rest=${rest#*/} + num=${rest#pulls/}; num=${num%/reviews} + f="$FX/reviews-$owner-$repo-$num" + [ -f "$f" ] && { cat "$f"; exit 0; } + echo 0; exit 0 + ;; + */commits/*/check-runs) + sha=${path##*/commits/}; sha=${sha%/check-runs} + f="$FX/ci-$sha" + [ -f "$f" ] || exit 0 + # The watcher passes --jq to roll check-runs up into a single overall + # state; run that same filter against the JSON fixture so the real + # roll-up logic (success/failure/pending/neutral) is exercised, not + # just the comparison. Falls back to cat for any caller without --jq. + jq_expr="" + prev="" + for a in "$@"; do + if [ "$prev" = "--jq" ]; then jq_expr=$a; fi + prev=$a + done + if [ -n "$jq_expr" ]; then + jq -r "$jq_expr" "$f" + else + cat "$f" + fi + exit 0 + ;; + esac + exit 0 + ;; + pr) + # gh pr view -R owner/repo --json -q ... + num=""; repo=""; field="" + prev="" + for a in "$@"; do + if [ "$prev" = "-R" ]; then repo=$a; fi + if [ "$prev" = "--json" ]; then field=$a; fi + case "$a" in [0-9]*) num=$a ;; esac + prev=$a + done + owner=${repo%%/*}; rn=${repo#*/} + case "$field" in + state) + f="$FX/state-$owner-$rn-$num" + [ -f "$f" ] && { cat "$f"; exit 0; } + echo "OPEN"; exit 0 + ;; + headRefOid) + f="$FX/sha-$owner-$rn-$num" + [ -f "$f" ] && { cat "$f"; exit 0; } + echo "deadbeef"; exit 0 + ;; + esac + exit 0 + ;; +esac +exit 0 +GH + chmod +x "$fakebin/gh" + printf '%s\n' "$dir" +} + +# run_poll : invoke one poll cycle with the fake gh on PATH. +# A known contributor is pinned via env so discovery proceeds even though the +# fake gh does not implement `api user`. +run_poll() { + local dir=$1 + PATH="$dir/fakebin:$PATH" GH_FIXTURE="$dir/fixture" \ + FM_GH_CONTRIBUTOR=e-jung \ + FM_STATE_OVERRIDE="$dir/state" \ + bash "$GH_WATCH" --once +} + +# Seed the open-PR list a fake gh search returns. +seed_prs() { + local dir=$1 + shift + : > "$dir/fixture/prs" + local ln + for ln in "$@"; do printf '%s\n' "$ln" >> "$dir/fixture/prs"; done +} + +# seed_ci -> write a JSON check-runs fixture the +# fake gh feeds through the watcher's real --jq roll-up. Each conclusion arg is +# a Checks-API value ("success","failure","neutral","skipped","timed_out",...) +# or the literal "pending" for a still-running check (status in_progress, +# conclusion null). The fake gh runs the watcher's --jq filter on this JSON, so +# the actual roll-up logic (not just the comparison) is what the tests exercise. +seed_ci() { + local f="$1/fixture/ci-$2" + shift 2 + printf '%s' '{"check_runs":[' > "$f" + local first=1 c status conclusion + for c in "$@"; do + [ "$first" = 1 ] || printf ',' >> "$f" + first=0 + if [ "$c" = "pending" ]; then + status="in_progress"; conclusion="null" + else + status="completed"; conclusion="\"$c\"" + fi + printf '{"status":"%s","conclusion":%s}' "$status" "$conclusion" >> "$f" + done + printf '%s' ']}' >> "$f" +} + +# seed_ci_named ... +# Like seed_ci but each check-run carries a name, so name-based ignore filters +# (FM_GH_IGNORE_CHECKS) can be exercised through the real --jq roll-up. The +# literal "pending" still means a running check (conclusion null). A name is +# embedded as a JSON string literal (backslash and double-quote escaped). +seed_ci_named() { + local f="$1/fixture/ci-$2" + shift 2 + printf '%s' '{"check_runs":[' > "$f" + local first=1 arg name c status conclusion esc + for arg in "$@"; do + name=${arg%%=*}; c=${arg#*=} + [ "$first" = 1 ] || printf ',' >> "$f" + first=0 + if [ "$c" = "pending" ]; then status="in_progress"; conclusion="null"; else status="completed"; conclusion="\"$c\""; fi + esc=${name//\\/\\\\}; esc=${esc//\"/\\\"} + printf '{"status":"%s","conclusion":%s,"name":"%s"}' "$status" "$conclusion" "$esc" >> "$f" + done + printf '%s' ']}' >> "$f" +} + +test_filter_toggling() { + local dir + dir=$(make_case filter-toggle) + local cfg="$dir/state/.github-watch-config" + + run_poll "$dir" >/dev/null 2>&1 # ensure default config materializes + # Default: all four filters active. + FM_STATE_OVERRIDE="$dir/state" bash "$GH_WATCH" filter list > "$dir/list.out" + grep -Fxq comments "$dir/list.out" || fail "comments filter not on by default" + grep -Fxq ci "$dir/list.out" || fail "ci filter not on by default" + grep -Fxq merge "$dir/list.out" || fail "merge filter not on by default" + + # Turn comments off -> persisted in config, absent from list. + FM_STATE_OVERRIDE="$dir/state" bash "$GH_WATCH" filter comments off > "$dir/off.out" + grep -Eq '^filters=ci,reviews,merge$' "$dir/off.out" || fail "turning comments off gave unexpected result" + ! awk -F= '/^filters=/{print $2}' "$cfg" | grep -qw comments \ + || fail "comments should be absent from filters= when toggled off" + + # Turn comments back on. + FM_STATE_OVERRIDE="$dir/state" bash "$GH_WATCH" filter comments on > "$dir/on.out" + grep -Eq '^filters=ci,reviews,merge,comments$' "$dir/on.out" || fail "turning comments on gave unexpected result" + + # Disabling then re-enabling is idempotent (no dupes). + FM_STATE_OVERRIDE="$dir/state" bash "$GH_WATCH" filter ci off >/dev/null + FM_STATE_OVERRIDE="$dir/state" bash "$GH_WATCH" filter ci on >/dev/null + FM_STATE_OVERRIDE="$dir/state" bash "$GH_WATCH" filter list > "$dir/list2.out" + [ "$(grep -Fc ci "$dir/list2.out")" -eq 1 ] || fail "filter toggling duplicated the ci filter" + + pass "filter on/off toggles persist in config without duplicates" +} + +test_first_run_baselines_silently() { + local dir out + dir=$(make_case baseline) + seed_prs "$dir" $'kunchenguid/firstmate\t30' + printf '5\n' > "$dir/fixture/comments-kunchenguid-firstmate-30" + printf '2\n' > "$dir/fixture/reviews-kunchenguid-firstmate-30" + + out=$(run_poll "$dir") + [ -z "$out" ] || fail "first poll should baseline silently, but printed: $out" + # Seen file exists with the baselined high-water marks. + local sf="$dir/state/.github-watch-seen/kunchenguid-firstmate-30" + [ -f "$sf" ] || fail "baseline seen file was not written" + grep -Fxq "comments=5" "$sf" || fail "comments high-water not baselined" + grep -Fxq "reviews=2" "$sf" || fail "reviews high-water not baselined" + grep -Fxq "initialized=1" "$sf" || fail "initialized marker missing" + + pass "first run for a PR baselines silently with no event" +} + +test_comment_detection_advances_seen_after_print() { + local dir out sf + dir=$(make_case comment) + seed_prs "$dir" $'kunchenguid/firstmate\t30' + printf '5\n' > "$dir/fixture/comments-kunchenguid-firstmate-30" + sf="$dir/state/.github-watch-seen/kunchenguid-firstmate-30" + + # Cycle 1: baseline. + run_poll "$dir" >/dev/null + grep -Fxq "comments=5" "$sf" || fail "baseline comments not set" + + # Cycle 2: two new comments. + printf '7\n' > "$dir/fixture/comments-kunchenguid-firstmate-30" + out=$(run_poll "$dir") + printf '%s\n' "$out" | grep -Fq "COMMENT: kunchenguid/firstmate#30 has 2 new comment(s)" \ + || fail "comment increase did not emit event; got: $out" + # Seen marker advanced to the new high-water (after the print). + grep -Fxq "comments=7" "$sf" || fail "seen marker not advanced after event" + + # Cycle 3: no change -> silence. + out=$(run_poll "$dir") + [ -z "$out" ] || fail "steady-state poll should be silent; got: $out" + + pass "comment increase emits event and advances seen after the print" +} + +test_losslessness_redetects_when_seen_write_fails() { + local dir out sf + dir=$(make_case lossless) + seed_prs "$dir" $'kunchenguid/firstmate\t30' + printf '5\n' > "$dir/fixture/comments-kunchenguid-firstmate-30" + sf="$dir/state/.github-watch-seen/kunchenguid-firstmate-30" + + # Cycle 1: baseline (writes the seen file while dir is writable). + run_poll "$dir" >/dev/null + grep -Fxq "comments=5" "$sf" || fail "baseline did not write seen" + + # New comment arrives. + printf '7\n' > "$dir/fixture/comments-kunchenguid-firstmate-30" + + # Simulate a failing seen write: make the seen dir read-only so atomic_write + # cannot advance the marker. The event must STILL print this cycle (print + # happens before the seen write). + chmod a-w "$dir/state/.github-watch-seen" + out=$(run_poll "$dir") + chmod u+w "$dir/state/.github-watch-seen" + printf '%s\n' "$out" | grep -Fq "COMMENT: kunchenguid/firstmate#30 has 2 new comment(s)" \ + || fail "event did not print when seen write failed; got: $out" + # Marker must NOT have advanced (the whole point). + grep -Fxq "comments=5" "$sf" || fail "seen marker advanced despite failing write (permanent swallow)" + + # Next cycle (writable again) re-detects the same event: lossless. + out=$(run_poll "$dir") + printf '%s\n' "$out" | grep -Fq "COMMENT: kunchenguid/firstmate#30 has 2 new comment(s)" \ + || fail "event was not re-detected after failed seen write; got: $out" + + pass "failed seen write leaves the event re-detectable (lossless)" +} + +test_merge_detection_on_left_open() { + local dir out sf + dir=$(make_case merge) + seed_prs "$dir" $'kunchenguid/firstmate\t42' + printf 'OPEN\n' > "$dir/fixture/state-kunchenguid-firstmate-42" + sf="$dir/state/.github-watch-seen/kunchenguid-firstmate-42" + + # Cycle 1: baseline the open PR. + run_poll "$dir" >/dev/null + grep -Fxq "state=OPEN" "$sf" || fail "baseline state not recorded as OPEN" + + # PR merges: it leaves the open search, and its state becomes MERGED. + : > "$dir/fixture/prs" # no longer open + printf 'MERGED\n' > "$dir/fixture/state-kunchenguid-firstmate-42" + + out=$(run_poll "$dir") + printf '%s\n' "$out" | grep -Fq "MERGED: kunchenguid/firstmate#42" \ + || fail "open->merged transition did not emit event; got: $out" + grep -Fxq "state=MERGED" "$sf" || fail "seen state not advanced to MERGED" + + # A later cycle does not re-report the merge (state no longer OPEN). + out=$(run_poll "$dir") + if printf '%s\n' "$out" | grep -Fq "MERGED"; then fail "merge event re-reported after settling"; fi + + pass "PR leaving the open set as merged emits MERGED once" +} + +test_closed_then_merged_is_not_swallowed() { + local dir out sf + dir=$(make_case close-merge) + seed_prs "$dir" $'kunchenguid/firstmate\t42' + printf 'OPEN\n' > "$dir/fixture/state-kunchenguid-firstmate-42" + sf="$dir/state/.github-watch-seen/kunchenguid-firstmate-42" + run_poll "$dir" >/dev/null # baseline OPEN + + # PR is closed (leaves the open set): emit CLOSED once. + : > "$dir/fixture/prs" + printf 'CLOSED\n' > "$dir/fixture/state-kunchenguid-firstmate-42" + out=$(run_poll "$dir") + printf '%s\n' "$out" | grep -Fq "CLOSED: kunchenguid/firstmate#42" \ + || fail "open->closed did not emit; got: $out" + + # Steady closed: must NOT re-emit CLOSED every cycle. + out=$(run_poll "$dir") + if printf '%s\n' "$out" | grep -Fq "CLOSED"; then fail "CLOSED re-emitted while settled"; fi + + # Closed -> reopened -> merged all between polls: MERGED must still fire + # (CLOSED is not terminal; the watcher re-probes it). + printf 'MERGED\n' > "$dir/fixture/state-kunchenguid-firstmate-42" + out=$(run_poll "$dir") + printf '%s\n' "$out" | grep -Fq "MERGED: kunchenguid/firstmate#42" \ + || fail "close->merge transition was swallowed; got: $out" + + pass "CLOSED is treated as non-terminal: close->merge still emits MERGED" +} + +test_closed_pr_reprobe_window_is_bounded() { + # A closed PR is re-probed only within CLOSE_REPROBE_SECS of closing, so + # accumulated closed PRs cannot push the fleet past the rate limit. With a + # zero window the PR is settled immediately: a later merge is intentionally + # not re-detected (the cost-bound tradeoff). The default window is generous. + local dir out sf + dir=$(make_case close-window) + seed_prs "$dir" $'kunchenguid/firstmate\t42' + printf 'OPEN\n' > "$dir/fixture/state-kunchenguid-firstmate-42" + sf="$dir/state/.github-watch-seen/kunchenguid-firstmate-42" + run_poll "$dir" >/dev/null # baseline OPEN + : > "$dir/fixture/prs" + printf 'CLOSED\n' > "$dir/fixture/state-kunchenguid-firstmate-42" + out=$(run_poll "$dir") # emits CLOSED, stamps closed_at + printf '%s\n' "$out" | grep -Fq "CLOSED: kunchenguid/firstmate#42" || fail "close not emitted" + grep -Fq "closed_at=" "$sf" || fail "closed_at not stamped on close" + + # Zero window: the aged-out CLOSED PR is not re-probed, so a merge is missed. + printf 'MERGED\n' > "$dir/fixture/state-kunchenguid-firstmate-42" + out=$(PATH="$dir/fakebin:$PATH" GH_FIXTURE="$dir/fixture" FM_GH_CONTRIBUTOR=e-jung \ + FM_GH_CLOSE_REPROBE_SECS=0 FM_STATE_OVERRIDE="$dir/state" bash "$GH_WATCH" --once) + if printf '%s\n' "$out" | grep -Fq "MERGED"; then + fail "aged-out CLOSED PR was re-probed (cost not bounded)" + fi + pass "closed PR past the re-probe window stops consuming an API call" +} + +test_config_roundtrip() { + local dir + dir=$(make_case config) + FM_STATE_OVERRIDE="$dir/state" bash "$GH_WATCH" contributor captain-ej >/dev/null + FM_STATE_OVERRIDE="$dir/state" bash "$GH_WATCH" filter reviews off >/dev/null + FM_STATE_OVERRIDE="$dir/state" bash "$GH_WATCH" filter ci off >/dev/null + FM_STATE_OVERRIDE="$dir/state" bash "$GH_WATCH" contributor > "$dir/c.out" + [ "$(cat "$dir/c.out")" = "captain-ej" ] || fail "contributor did not roundtrip" + + FM_STATE_OVERRIDE="$dir/state" bash "$GH_WATCH" filter list > "$dir/f.out" + # comments + merge remain; ci + reviews disabled. + grep -Fxq comments "$dir/f.out" || fail "comments should remain on" + grep -Fxq merge "$dir/f.out" || fail "merge should remain on" + ! grep -Fxq ci "$dir/f.out" || fail "ci should be off" + ! grep -Fxq reviews "$dir/f.out" || fail "reviews should be off" + + # status reflects the persisted config. + FM_STATE_OVERRIDE="$dir/state" bash "$GH_WATCH" status > "$dir/s.out" + grep -Eq '^contributor: captain-ej$' "$dir/s.out" || fail "status did not show contributor" + grep -Eq '^ ci: off$' "$dir/s.out" || fail "status did not show ci off" + + pass "config writes round-trip across contributor + filter subcommands" +} + +test_review_detection() { + local dir out sf + dir=$(make_case review) + seed_prs "$dir" $'kunchenguid/no-mistakes\t310' + printf '1\n' > "$dir/fixture/reviews-kunchenguid-no-mistakes-310" + sf="$dir/state/.github-watch-seen/kunchenguid-no-mistakes-310" + + run_poll "$dir" >/dev/null + grep -Fxq "reviews=1" "$sf" || fail "baseline reviews not set" + + printf '3\n' > "$dir/fixture/reviews-kunchenguid-no-mistakes-310" + out=$(run_poll "$dir") + printf '%s\n' "$out" | grep -Fq "REVIEW: kunchenguid/no-mistakes#310 has 2 new review(s)" \ + || fail "review increase did not emit event; got: $out" + grep -Fxq "reviews=3" "$sf" || fail "review high-water not advanced" + + pass "review count increase emits REVIEW event" +} + +test_ci_detection() { + local dir out sf + dir=$(make_case ci) + seed_prs "$dir" $'kunchenguid/no-mistakes\t310' + printf 'abcdef1\n' > "$dir/fixture/sha-kunchenguid-no-mistakes-310" + seed_ci "$dir" abcdef1 success success success + sf="$dir/state/.github-watch-seen/kunchenguid-no-mistakes-310" + + run_poll "$dir" >/dev/null + grep -Fxq "ci=success" "$sf" || fail "baseline ci state not rolled up to success" + + # One check goes red: the overall state flips success -> failure (one event). + seed_ci "$dir" abcdef1 failure success success + out=$(run_poll "$dir") + printf '%s\n' "$out" | grep -Fq "CI: kunchenguid/no-mistakes#310 -> failure" \ + || fail "ci state change did not emit event; got: $out" + grep -Fxq "ci=failure" "$sf" || fail "ci state not advanced to failure" + + # Steady state again: silence. + out=$(run_poll "$dir") + [ -z "$out" ] || fail "steady-state ci poll should be silent; got: $out" + + pass "overall CI state change emits a single CI event" +} + +test_merge_filter_suppresses_merge_event() { + local dir out + dir=$(make_case merge-off) + seed_prs "$dir" $'kunchenguid/firstmate\t42' + printf 'OPEN\n' > "$dir/fixture/state-kunchenguid-firstmate-42" + run_poll "$dir" >/dev/null # baseline + + # Disable the merge filter; the PR then merges (leaves the open set). + FM_STATE_OVERRIDE="$dir/state" bash "$GH_WATCH" filter merge off >/dev/null + : > "$dir/fixture/prs" + printf 'MERGED\n' > "$dir/fixture/state-kunchenguid-firstmate-42" + out=$(run_poll "$dir") + if printf '%s\n' "$out" | grep -Fq "MERGED"; then + fail "merge event fired despite merge filter being off; got: $out" + fi + pass "merge filter off suppresses merge/close events" +} + +test_ci_carry_forward_across_empty_window() { + local dir out sf + dir=$(make_case ci-carry) + seed_prs "$dir" $'kunchenguid/no-mistakes\t310' + printf 'sha1\n' > "$dir/fixture/sha-kunchenguid-no-mistakes-310" + seed_ci "$dir" sha1 success success + sf="$dir/state/.github-watch-seen/kunchenguid-no-mistakes-310" + + # Baseline: CI passing for sha1 (rolled up to success). + run_poll "$dir" >/dev/null + grep -Fxq "ci=success" "$sf" || fail "baseline ci state not recorded" + + # New commit: sha changes, check-runs not populated yet (empty ci_state). + printf 'sha2\n' > "$dir/fixture/sha-kunchenguid-no-mistakes-310" + rm -f "$dir/fixture/ci-sha1" + # No ci-sha2 fixture yet -> ci_state returns empty. + out=$(run_poll "$dir") + [ -z "$out" ] || fail "transient empty ci window should be silent; got: $out" + # seen_ci must be carried forward (not dropped) so a later change still fires. + grep -Fxq "ci=success" "$sf" || fail "ci state was dropped during empty window" + + # CI completes for sha2 and FAILS: state differs from carried-forward success. + seed_ci "$dir" sha2 failure success + out=$(run_poll "$dir") + printf '%s\n' "$out" | grep -Fq "CI: kunchenguid/no-mistakes#310 -> failure" \ + || fail "ci completion after empty window did not fire; got: $out" + + pass "overall CI state carries forward across an empty window and fires on change" +} + +test_all_filters_off_mutes_watcher() { + local dir out + dir=$(make_case all-off) + seed_prs "$dir" $'kunchenguid/firstmate\t30' + printf '5\n' > "$dir/fixture/comments-kunchenguid-firstmate-30" + run_poll "$dir" >/dev/null # baseline + + # Turn every filter off; the persisted config must keep filters empty (not + # fall back to defaults). + for f in comments ci reviews merge; do + FM_STATE_OVERRIDE="$dir/state" bash "$GH_WATCH" filter "$f" off >/dev/null + done + grep -Fxq 'filters=' "$dir/state/.github-watch-config" || fail "all-off should write filters= (empty), not default" + + # A new comment must NOT fire (every filter is muted). + printf '9\n' > "$dir/fixture/comments-kunchenguid-firstmate-30" + out=$(run_poll "$dir") + [ -z "$out" ] || fail "muted watcher emitted events; got: $out" + pass "all filters off (empty filters=) mutes the watcher instead of resetting to defaults" +} + +test_parallel_poll_is_lossless_and_does_not_cross_contaminate() { + # With PRs polled concurrently (bounded by FM_GH_CONCURRENCY), the per-PR + # losslessness invariant (print before seen write) and per-PR seen-file + # independence must both hold. Seed many PRs across distinct repos so several + # parallel waves run, each worker owning its own seen file. + local dir out i sf n=12 + dir=$(make_case parallel) + + local pr_lines=() + for i in $(seq 1 "$n"); do + pr_lines+=( "$(printf 'org/r%d\t1' "$i")" ) + printf '5\n' > "$dir/fixture/comments-org-r$i-1" + done + seed_prs "$dir" "${pr_lines[@]}" + run_poll "$dir" >/dev/null # baseline all n PRs (comments=5 each) + + # Each PR gains a DISTINCT count (PR i -> 5+i) so a worker that crossed wires + # would stamp another PR's count into the wrong seen file. + for i in $(seq 1 "$n"); do + printf '%d\n' "$((5 + i))" > "$dir/fixture/comments-org-r$i-1" + done + + # Losslessness under concurrency: make the seen dir read-only so every + # worker's seen write fails, then poll with concurrency well below n. Every + # PR's event must STILL print this cycle (each worker prints before its seen + # write, independent of the other workers). + chmod a-w "$dir/state/.github-watch-seen" + out=$(FM_GH_CONCURRENCY=4 run_poll "$dir") + chmod u+w "$dir/state/.github-watch-seen" + for i in $(seq 1 "$n"); do + printf '%s\n' "$out" | grep -Fq "COMMENT: org/r$i#1 has $i new comment(s)" \ + || fail "parallel poll did not emit PR r$i before its seen write; out: $out" + done + + # No cross-contamination: after a writable concurrent poll, each PR's seen + # file holds its OWN advanced count and its own identity (never another PR's + # values), even though workers ran concurrently with a shared .tmp stage. + out=$(FM_GH_CONCURRENCY=4 run_poll "$dir") + for i in $(seq 1 "$n"); do + sf="$dir/state/.github-watch-seen/org-r$i-1" + grep -Fxq "comments=$((5 + i))" "$sf" \ + || fail "r$i seen file has wrong count (cross-contamination?): $(cat "$sf")" + grep -Fxq "owner=org" "$sf" || fail "r$i seen file lost owner identity" + grep -Fxq "repo=r$i" "$sf" || fail "r$i seen file has wrong repo (cross-contamination?)" + grep -Fxq "pr=1" "$sf" || fail "r$i seen file lost pr identity" + done + + pass "parallel poll emits before each seen write and never cross-contaminates seen files" +} + +test_ci_debounces_staggered_checks() { + # Reproduces the no-mistakes#312 chatter: a PR whose many check-runs complete + # at staggered times. Under the old per-multiset logic each completion changed + # the signature and fired (one event per check). The roll-up keeps the state + # at "pending" while ANY check is still running, then flips to green exactly + # once when the last one completes. + local dir out sf finished i + dir=$(make_case ci-debounce) + seed_prs "$dir" $'kunchenguid/no-mistakes\t312' + printf 'sha7\n' > "$dir/fixture/sha-kunchenguid-no-mistakes-312" + sf="$dir/state/.github-watch-seen/kunchenguid-no-mistakes-312" + + # Cycle 1: 7 checks, all pending -> baseline (no event, first run). + seed_ci "$dir" sha7 pending pending pending pending pending pending pending + run_poll "$dir" >/dev/null + grep -Fxq "ci=pending" "$sf" || fail "baseline should roll 7 pending checks up to pending" + + # Checks complete a few at a time: state stays pending, so every one of these + # cycles must stay silent (under the old logic each would have fired). + for finished in 1 3 6; do + local args=() + for i in $(seq 1 7); do + if [ "$i" -le "$finished" ]; then args+=(success); else args+=(pending); fi + done + seed_ci "$dir" sha7 "${args[@]}" + out=$(run_poll "$dir") + if printf '%s\n' "$out" | grep -Fq "CI:"; then + fail "fired while still pending after $finished/7 checks done; got: $out" + fi + done + + # Last check completes: pending -> green fires exactly once. + seed_ci "$dir" sha7 success success success success success success success + out=$(run_poll "$dir") + printf '%s\n' "$out" | grep -Fq "CI: kunchenguid/no-mistakes#312 -> green" \ + || fail "pending->success transition did not fire once; got: $out" + # No second fire on the next (steady) cycle. + out=$(run_poll "$dir") + if printf '%s\n' "$out" | grep -Fq "CI:"; then + fail "steady success re-fired; got: $out" + fi + + pass "staggered checks debounce to a single overall-state transition" +} + +test_ci_state_transitions() { + # The three transitions the captain cares about, each firing exactly once: + # pending->green, green->green (silent), green->failure. + local dir out sf + dir=$(make_case ci-trans) + seed_prs "$dir" $'kunchenguid/no-mistakes\t320' + printf 'shat\n' > "$dir/fixture/sha-kunchenguid-no-mistakes-320" + sf="$dir/state/.github-watch-seen/kunchenguid-no-mistakes-320" + + seed_ci "$dir" shat pending + run_poll "$dir" >/dev/null # baseline pending + + # pending -> green fires once. + seed_ci "$dir" shat success + out=$(run_poll "$dir") + printf '%s\n' "$out" | grep -Fq "CI: kunchenguid/no-mistakes#320 -> green" \ + || fail "pending->success did not fire; got: $out" + grep -Fxq "ci=success" "$sf" || fail "state not advanced to success" + + # green -> green does not fire. + out=$(run_poll "$dir") + if printf '%s\n' "$out" | grep -Fq "CI:"; then fail "success->success re-fired; got: $out"; fi + + # green -> failure fires once. + seed_ci "$dir" shat success failure + out=$(run_poll "$dir") + printf '%s\n' "$out" | grep -Fq "CI: kunchenguid/no-mistakes#320 -> failure" \ + || fail "success->failure did not fire; got: $out" + grep -Fxq "ci=failure" "$sf" || fail "state not advanced to failure" + + pass "pending->green fires once, green->green is silent, green->failure fires once" +} + +test_ci_rollup_precedence() { + # The rolled-up state follows GitHub's combined-status precedence: a red check + # settles failure even while others are still pending; neutral checks are + # ignored entirely (never red, never green, never block). + local dir out sf + dir=$(make_case ci-rollup) + seed_prs "$dir" $'kunchenguid/no-mistakes\t321' + printf 'shar\n' > "$dir/fixture/sha-kunchenguid-no-mistakes-321" + sf="$dir/state/.github-watch-seen/kunchenguid-no-mistakes-321" + + # Baseline: a passing check plus a neutral informational check rolls up to + # success (neutral ignored). + seed_ci "$dir" shar success neutral + run_poll "$dir" >/dev/null + grep -Fxq "ci=success" "$sf" || fail "success+neutral should roll up to success" + + # A failure landing while another check is still pending settles failure + # immediately (no transient pending event). + seed_ci "$dir" shar success failure pending + out=$(run_poll "$dir") + printf '%s\n' "$out" | grep -Fq "CI: kunchenguid/no-mistakes#321 -> failure" \ + || fail "failure+pending should roll straight up to failure; got: $out" + grep -Fxq "ci=failure" "$sf" || fail "state not advanced to failure" + + # The pending check then succeeds: state stays failure (no second fire). + seed_ci "$dir" shar success failure success + out=$(run_poll "$dir") + if printf '%s\n' "$out" | grep -Fq "CI:"; then fail "failure->failure re-fired; got: $out"; fi + + pass "roll-up precedence: failure beats pending, neutral checks are ignored" +} + +test_silent_baseline_on_schema_migration() { + # Reproduces the debounce deploy flood: a seen file written by an OLDER + # watcher version (here, an old per-multiset ci signature, no schema= field). + # Without the schema guard, the first poll under the new code sees + # seen_ci="success:success:failure" != ci_st="success" and fires a spurious + # CI transition for EVERY migrated PR at once. The guard treats a schema + # mismatch as a silent re-baseline: write the new schema + correct values, + # emit nothing. A subsequent REAL transition still fires. + local dir out sf + dir=$(make_case ci-migrate) + seed_prs "$dir" $'kunchenguid/no-mistakes\t330' + printf 'sham\n' > "$dir/fixture/sha-kunchenguid-no-mistakes-330" + printf 'OPEN\n' > "$dir/fixture/state-kunchenguid-no-mistakes-330" + sf="$dir/state/.github-watch-seen/kunchenguid-no-mistakes-330" + mkdir -p "$(dirname "$sf")" + + # An old-format seen file: initialized but no schema=, and a stale ci value + # that the new roll-up would read as "different" from the fresh success. + cat > "$sf" <<'OLD' +owner=kunchenguid +repo=no-mistakes +pr=330 +initialized=1 +ci=success:success:failure +state=OPEN +OLD + + # Fresh roll-up is plain success; under the old code this != the stale sig. + seed_ci "$dir" sham success success success + + # First poll after migration: SILENT (no flood), seen rewritten to new schema. + out=$(run_poll "$dir") + [ -z "$out" ] || fail "schema migration should baseline silently; got: $out" + grep -Fxq "schema=2" "$sf" || fail "seen file not stamped with current schema" + grep -Fxq "ci=success" "$sf" || fail "ci not re-baselined to the rolled-up success" + + # A subsequent REAL transition still fires (migration only silenced once). + seed_ci "$dir" sham success failure success + out=$(run_poll "$dir") + printf '%s\n' "$out" | grep -Fq "CI: kunchenguid/no-mistakes#330 -> failure" \ + || fail "post-migration real transition did not fire; got: $out" + + pass "schema mismatch is silently re-baselined; real transitions still fire" +} + +test_ci_ignore_excludes_known_gap_check() { + # A kunchenguid fork-PR whose ONLY failing check is the known fork-routing + # signature gap (#293: "PR must be raised via no-mistakes") must roll up to + # green when its real checks pass, not a false failure. The default + # FM_GH_IGNORE_CHECKS regex drops that name from the roll-up. A REAL failure + # (different name) must still roll up to failure, so the filter is not just + # disabling failure detection. + local dir out sf + dir=$(make_case ci-ignore) + seed_prs "$dir" $'kunchenguid/firstmate\t38' + printf 'sha38\n' > "$dir/fixture/sha-kunchenguid-firstmate-38" + sf="$dir/state/.github-watch-seen/kunchenguid-firstmate-38" + + # 3 real checks pass; the gap check fails by name. run_poll uses the default + # FM_GH_IGNORE_CHECKS, so the gap name is excluded -> rolls up to success. + seed_ci_named "$dir" sha38 \ + "build=success" "test=success" "lint=success" \ + "PR must be raised via no-mistakes=failure" + + run_poll "$dir" >/dev/null # baseline: gap excluded -> success, not failure + grep -Fxq "ci=success" "$sf" \ + || fail "gap-excluded PR should roll up to success, got: $(cat "$sf")" + + # A REAL check failing (different name) must still surface failure despite the + # gap check also failing: the ignore list is not a blanket failure suppressor. + seed_ci_named "$dir" sha38 \ + "build=success" "test=failure" "lint=success" \ + "PR must be raised via no-mistakes=failure" + out=$(run_poll "$dir") + printf '%s\n' "$out" | grep -Fq "CI: kunchenguid/firstmate#38 -> failure" \ + || fail "real check failure should still roll up to failure; got: $out" + + pass "known fork-routing gap check excluded from roll-up; real failures still surface" +} + +test_api_error_skips_pr_without_event() { + # Reproduces the bug: a transient 401 makes `gh api` write the error body + # {"message":"Bad credentials",...} to stdout (bypassing --jq). The old ghc() + # swallowed stderr + the exit code, so the watcher parsed that JSON as CI state + # and fired a bogus "CI: ... -> { \"message\": ... }" event. The fix detects the + # API error (non-zero exit OR an error-body shape) and skips the PR for the + # cycle: no event, no crash, seen left untouched so the next (recovered) cycle + # still fires the real transition (lossless). + local dir out sf + dir=$(make_case api-error) + seed_prs "$dir" $'kunchenguid/no-mistakes\t500' + printf 'sha500\n' > "$dir/fixture/sha-kunchenguid-no-mistakes-500" + seed_ci "$dir" sha500 success + sf="$dir/state/.github-watch-seen/kunchenguid-no-mistakes-500" + + # Baseline: CI green. + run_poll "$dir" >/dev/null + grep -Fxq "ci=success" "$sf" || fail "baseline ci not recorded as success" + + # Inject a transient 401 on every `gh api` call this cycle. + : > "$dir/fixture/api-error" + out=$(run_poll "$dir" 2>/dev/null) + [ -z "$out" ] || fail "transient API error must not surface as an event; got: $out" + # seen must be untouched (ci still the prior success, not the error JSON). + grep -Fxq "ci=success" "$sf" \ + || fail "seen state was clobbered during API error: $(cat "$sf")" + + # Recover: remove the blip and flip CI to failure. The real transition fires. + rm -f "$dir/fixture/api-error" + seed_ci "$dir" sha500 failure + out=$(run_poll "$dir") + printf '%s\n' "$out" | grep -Fq "CI: kunchenguid/no-mistakes#500 -> failure" \ + || fail "post-blip real transition did not fire; got: $out" + + pass "transient GitHub API error skips the PR without emitting an event" +} + +test_filter_toggling +test_first_run_baselines_silently +test_comment_detection_advances_seen_after_print +test_losslessness_redetects_when_seen_write_fails +test_merge_detection_on_left_open +test_closed_then_merged_is_not_swallowed +test_closed_pr_reprobe_window_is_bounded +test_config_roundtrip +test_review_detection +test_ci_detection +test_merge_filter_suppresses_merge_event +test_ci_carry_forward_across_empty_window +test_ci_debounces_staggered_checks +test_ci_state_transitions +test_ci_rollup_precedence +test_all_filters_off_mutes_watcher +test_parallel_poll_is_lossless_and_does_not_cross_contaminate +test_silent_baseline_on_schema_migration +test_ci_ignore_excludes_known_gap_check +test_api_error_skips_pr_without_event