From 8bc08d2bd20917d0bee72d459980402696a6c61b Mon Sep 17 00:00:00 2001 From: bingb0t5 Date: Tue, 25 Aug 2026 09:42:10 +0700 Subject: [PATCH 1/7] Add fleet-wide PR merge conflict watcher with routed wakes. Detect newly conflicted open PRs from registry-derived repos, dedupe by head SHA, poll lazy GitHub mergeability safely, and arm through the standard registered check path. --- bin/fm-pr-conflict-watch.sh | 652 +++++++++++++++++++++++++++++ docs/configuration.md | 27 ++ tests/fm-pr-conflict-watch.test.sh | 249 +++++++++++ 3 files changed, 928 insertions(+) create mode 100755 bin/fm-pr-conflict-watch.sh create mode 100755 tests/fm-pr-conflict-watch.test.sh diff --git a/bin/fm-pr-conflict-watch.sh b/bin/fm-pr-conflict-watch.sh new file mode 100755 index 00000000000..5c1335530a0 --- /dev/null +++ b/bin/fm-pr-conflict-watch.sh @@ -0,0 +1,652 @@ +#!/usr/bin/env bash +# fm-pr-conflict-watch.sh - detect open PR merge conflicts across the fleet. +# +# Usage: +# fm-pr-conflict-watch.sh [check] +# fm-pr-conflict-watch.sh arm +# fm-pr-conflict-watch.sh disarm +# fm-pr-conflict-watch.sh --help +# +# `check` prints one line when a newly conflicted open PR is found and prints +# nothing otherwise. `arm` writes state/pr-conflict-watch.check.sh and binds +# its bytes with fm-check-register.sh so the watcher polls on its normal +# cadence. `disarm` removes the shim, trust binding, and dedupe record. +# +# Repositories come from data/projects.md project clones under projects/, plus +# this firstmate checkout's own origin remote. Owning targets come from +# data/secondmates.md project lists, with the firstmate repository mapped to +# main. Nothing is hardcoded to a fleet-specific name. +# +# Dedupe keys are owner/repo, PR number, and head SHA. A force-updated head that +# conflicts again is a new event; the same conflict on the same head stays silent +# after the first wake. +# +# GitHub computes mergeability lazily. A single read that returns UNKNOWN is +# never treated as clean or conflicted; the check polls with short waits until +# the state settles or stays unknown. +# +# Detection and routing only: this script never resolves conflicts. +set -u +export LC_ALL=C +export GH_PROMPT_DISABLED=1 +export GH_NO_UPDATE_NOTIFIER=1 +export GIT_TERMINAL_PROMPT=0 + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" +FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" +DATA="${FM_DATA_OVERRIDE:-$FM_HOME/data}" +PROJECTS="${FM_PROJECTS_OVERRIDE:-$FM_HOME/projects}" +STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" +RECORD="$STATE/.pr-conflict-watch" +CHECK_ID=pr-conflict-watch +CHECK_SHIM="$STATE/$CHECK_ID.check.sh" +CHECK_TRUST="$STATE/$CHECK_ID.check-trust" +REGISTER_BIN="$SCRIPT_DIR/fm-check-register.sh" +RECORD_SCHEMA=fm-pr-conflict-watch-v1 +GH_AXI=${FM_PR_CONFLICT_GH_AXI:-gh-axi} +MAX_LINE=1000 +MAIN_OWNER=main + +# shellcheck source=bin/fm-timeout-lib.sh +. "$SCRIPT_DIR/fm-timeout-lib.sh" +# shellcheck source=bin/fm-pr-lib.sh +. "$SCRIPT_DIR/fm-pr-lib.sh" +# shellcheck source=bin/fm-line-cap-lib.sh +. "$SCRIPT_DIR/fm-line-cap-lib.sh" +# shellcheck source=bin/fm-check-lib.sh +. "$SCRIPT_DIR/fm-check-lib.sh" +# shellcheck source=bin/fm-secondmate-registry-lib.sh +. "$SCRIPT_DIR/fm-secondmate-registry-lib.sh" + +usage() { + cat <<'EOF' +Usage: + fm-pr-conflict-watch.sh [check] report newly conflicted open PRs (silent when clear) + fm-pr-conflict-watch.sh arm write and register state/pr-conflict-watch.check.sh + fm-pr-conflict-watch.sh disarm remove the check shim, trust binding, and record + fm-pr-conflict-watch.sh --help print this help + +Repositories are derived from data/projects.md clones under projects/ plus this +firstmate checkout's origin remote. Owning targets are derived from +data/secondmates.md project lists, with the firstmate repository mapped to main. + +Arm once per home. The watcher polls on its normal FM_CHECK_INTERVAL cadence. +See docs/configuration.md for tuning knobs. +EOF +} + +die_usage() { + printf 'fm-pr-conflict-watch: %s\n' "$1" >&2 + usage >&2 + exit 2 +} + +INTERVAL=${FM_PR_CONFLICT_INTERVAL:-300} +case "$INTERVAL" in + ''|*[!0-9]*) + printf 'fm-pr-conflict-watch: FM_PR_CONFLICT_INTERVAL must be 0 or a whole number from 60 to 86400\n' >&2 + exit 2 + ;; +esac +if [ "$INTERVAL" -ne 0 ] && { [ "$INTERVAL" -lt 60 ] || [ "$INTERVAL" -gt 86400 ]; }; then + printf 'fm-pr-conflict-watch: FM_PR_CONFLICT_INTERVAL must be 0 or a whole number from 60 to 86400\n' >&2 + exit 2 +fi + +PROBE_SECS=${FM_PR_CONFLICT_PROBE_SECS:-5} +case "$PROBE_SECS" in + ''|*[!0-9]*|0) + printf 'fm-pr-conflict-watch: FM_PR_CONFLICT_PROBE_SECS must be a whole number from 1 to 30\n' >&2 + exit 2 + ;; +esac +if [ "$PROBE_SECS" -gt 30 ]; then + printf 'fm-pr-conflict-watch: FM_PR_CONFLICT_PROBE_SECS must be a whole number from 1 to 30\n' >&2 + exit 2 +fi + +BUDGET_SECS=${FM_PR_CONFLICT_BUDGET_SECS:-20} +case "$BUDGET_SECS" in + ''|*[!0-9]*|0) + printf 'fm-pr-conflict-watch: FM_PR_CONFLICT_BUDGET_SECS must be a whole number from 1 to 120\n' >&2 + exit 2 + ;; +esac +if [ "$BUDGET_SECS" -gt 120 ]; then + printf 'fm-pr-conflict-watch: FM_PR_CONFLICT_BUDGET_SECS must be a whole number from 1 to 120\n' >&2 + exit 2 +fi + +UNKNOWN_ATTEMPTS=${FM_PR_CONFLICT_UNKNOWN_ATTEMPTS:-3} +case "$UNKNOWN_ATTEMPTS" in + ''|*[!0-9]*|0) + printf 'fm-pr-conflict-watch: FM_PR_CONFLICT_UNKNOWN_ATTEMPTS must be a whole number from 1 to 10\n' >&2 + exit 2 + ;; +esac +if [ "$UNKNOWN_ATTEMPTS" -gt 10 ]; then + printf 'fm-pr-conflict-watch: FM_PR_CONFLICT_UNKNOWN_ATTEMPTS must be a whole number from 1 to 10\n' >&2 + exit 2 +fi + +UNKNOWN_WAIT=${FM_PR_CONFLICT_UNKNOWN_WAIT:-1} +case "$UNKNOWN_WAIT" in + ''|*[!0-9]*) + printf 'fm-pr-conflict-watch: FM_PR_CONFLICT_UNKNOWN_WAIT must be a whole number from 0 to 5\n' >&2 + exit 2 + ;; +esac +if [ "$UNKNOWN_WAIT" -gt 5 ]; then + printf 'fm-pr-conflict-watch: FM_PR_CONFLICT_UNKNOWN_WAIT must be a whole number from 0 to 5\n' >&2 + exit 2 +fi + +PR_LIMIT=${FM_PR_CONFLICT_PR_LIMIT:-30} +case "$PR_LIMIT" in + ''|*[!0-9]*|0) + printf 'fm-pr-conflict-watch: FM_PR_CONFLICT_PR_LIMIT must be a whole number from 1 to 100\n' >&2 + exit 2 + ;; +esac +if [ "$PR_LIMIT" -gt 100 ]; then + printf 'fm-pr-conflict-watch: FM_PR_CONFLICT_PR_LIMIT must be a whole number from 1 to 100\n' >&2 + exit 2 +fi + +PROBE_MIN_SECS=1 +CLOCK_ROUNDING_SECS=1 +KILL_GRACE_SECS=1 + +CHECK_TIMEOUT=${FM_CHECK_TIMEOUT:-30} +case "$CHECK_TIMEOUT" in + ''|*[!0-9]*|0) CHECK_TIMEOUT=30 ;; +esac +BUDGET_MAX=$((CHECK_TIMEOUT - PROBE_MIN_SECS - CLOCK_ROUNDING_SECS - KILL_GRACE_SECS)) +[ "$BUDGET_MAX" -ge 1 ] || BUDGET_MAX=1 +BUDGET_CUT_FROM= +if [ "$BUDGET_SECS" -gt "$BUDGET_MAX" ]; then + BUDGET_CUT_FROM=$BUDGET_SECS + BUDGET_SECS=$BUDGET_MAX +fi + +record_epoch_now() { + case "${FM_PR_CONFLICT_NOW:-}" in + ''|*[!0-9]*) date +%s ;; + *) printf '%s\n' "$FM_PR_CONFLICT_NOW" ;; + esac +} + +real_epoch() { date +%s; } + +DEADLINE=0 +REPORTED_KEYS= +RECORD_EPOCH=0 +FINDINGS= + +emit_finding() { + local text + text=$(printf '%s' "$1" | tr '\t\r\n' ' ') + if [ -z "$FINDINGS" ]; then + FINDINGS=$text + else + FINDINGS="$FINDINGS; $text" + fi +} + +budget_exhausted() { + [ "$(real_epoch)" -ge "$DEADLINE" ] +} + +probe_bound() { + local left + left=$((DEADLINE - $(real_epoch))) + if [ "$left" -lt "$PROBE_MIN_SECS" ]; then + printf '%s\n' "$PROBE_MIN_SECS" + elif [ "$left" -lt "$PROBE_SECS" ]; then + printf '%s\n' "$left" + else + printf '%s\n' "$PROBE_SECS" + fi +} + +gh_bounded() { + fm_run_timed "$(probe_bound)" env GH_PROMPT_DISABLED=1 GH_NO_UPDATE_NOTIFIER=1 "$GH_AXI" "$@" +} + +repo_slug() { + printf '%s' "$1" | sed -n 's#.*github\.com[:/]\([^/]*/[^/]*\)#\1#p' | sed 's#\.git$##; s#/pull/.*$##; s#/$##' +} + +firstmate_repo_slug() { + local url + url=$(git -C "$FM_ROOT" remote get-url origin 2>/dev/null) || return 1 + repo_slug "$url" +} + +project_names() { + [ -f "$DATA/projects.md" ] || return 0 + awk '$1 == "-" && $2 != "" { print $2 }' "$DATA/projects.md" +} + +resolve_project_repo() { + local project=$1 dir url slug + dir="$PROJECTS/$project" + [ -d "$dir" ] || return 1 + url=$(git -C "$dir" remote get-url origin 2>/dev/null) || return 1 + slug=$(repo_slug "$url") + [ -n "$slug" ] || return 1 + printf '%s\n' "$slug" +} + +discover_repos() { + local project slug repos='' fm_slug + while IFS= read -r project; do + [ -n "$project" ] || continue + slug=$(resolve_project_repo "$project" 2>/dev/null) || continue + case " $repos " in + *" $slug "*) ;; + *) repos="$repos $slug" ;; + esac + done < <(project_names) + fm_slug=$(firstmate_repo_slug 2>/dev/null) || fm_slug= + if [ -n "$fm_slug" ]; then + case " $repos " in + *" $fm_slug "*) ;; + *) repos="$repos $fm_slug" ;; + esac + fi + for slug in $repos; do + [ -n "$slug" ] || continue + printf '%s\n' "$slug" + done +} + +trim_field() { + local value=$1 + value=${value#"${value%%[![:space:]]*}"} + value=${value%"${value##*[![:space:]]}"} + printf '%s\n' "$value" +} + +owner_for_project() { + local project=$1 reg line id projects part + reg="$DATA/secondmates.md" + [ -f "$reg" ] || return 1 + while IFS= read -r line || [ -n "$line" ]; do + case "$line" in + "- "*) + secondmate_registry_parse_line "$line" || continue + id=$SECONDMATE_REGISTRY_ID + projects=$SECONDMATE_REGISTRY_PROJECTS + IFS=',' read -r -a _parts <<< "$projects" + for part in "${_parts[@]}"; do + part=$(trim_field "$part") + [ "$part" = "$project" ] || continue + printf '%s\n' "$id" + return 0 + done + ;; + esac + done < "$reg" + return 1 +} + +owner_for_repo() { + local repo=$1 project slug owner fm_slug + fm_slug=$(firstmate_repo_slug 2>/dev/null) || fm_slug= + if [ -n "$fm_slug" ] && [ "$repo" = "$fm_slug" ]; then + printf '%s\n' "$MAIN_OWNER" + return 0 + fi + while IFS= read -r project; do + [ -n "$project" ] || continue + slug=$(resolve_project_repo "$project" 2>/dev/null) || continue + [ "$slug" = "$repo" ] || continue + owner=$(owner_for_project "$project" 2>/dev/null) || owner= + if [ -n "$owner" ]; then + printf '%s\n' "$owner" + else + printf '%s\n' "$MAIN_OWNER" + fi + return 0 + done < <(project_names) + printf '%s\n' "$MAIN_OWNER" +} + +conflict_key() { + local repo=$1 number=$2 head=$3 + printf '%s#%s#%s' "$repo" "$number" "$head" +} + +record_read() { + local line first=1 + RECORD_EPOCH=0 + REPORTED_KEYS= + [ -f "$RECORD" ] || return 0 + while IFS= read -r line; do + if [ "$first" = 1 ]; then + first=0 + [ "$line" = "$RECORD_SCHEMA" ] || return 0 + continue + fi + case "$line" in + epoch=*) + line=${line#epoch=} + case "$line" in + ''|*[!0-9]*) RECORD_EPOCH=0 ;; + *) RECORD_EPOCH=$line ;; + esac + ;; + reported=*) REPORTED_KEYS=${line#reported=} ;; + esac + done < "$RECORD" +} + +record_write() { + local reported=$1 tmp + tmp=$(mktemp "$RECORD.XXXXXX" 2>/dev/null) || return 1 + chmod 0600 "$tmp" 2>/dev/null || { rm -f -- "$tmp"; return 1; } + { + printf '%s\n' "$RECORD_SCHEMA" + printf 'epoch=%s\n' "$(record_epoch_now)" + printf 'reported=%s\n' "$reported" + } > "$tmp" || { rm -f -- "$tmp"; return 1; } + mv -f -- "$tmp" "$RECORD" || { rm -f -- "$tmp"; return 1; } +} + +record_has_key() { + local key=$1 + case ";$REPORTED_KEYS;" in + *";$key;"*) return 0 ;; + esac + return 1 +} + +record_add_key() { + local key=$1 + record_has_key "$key" && return 0 + if [ -z "$REPORTED_KEYS" ]; then + REPORTED_KEYS=$key + else + REPORTED_KEYS="$REPORTED_KEYS;$key" + fi +} + +record_remove_key() { + local key=$1 out='' part + IFS=';' read -r -a _parts <<< "$REPORTED_KEYS" + for part in "${_parts[@]}"; do + [ -n "$part" ] || continue + [ "$part" = "$key" ] && continue + if [ -z "$out" ]; then + out=$part + else + out="$out;$part" + fi + done + REPORTED_KEYS=$out +} + +json_field() { + local json=$1 query=$2 + command -v jq >/dev/null 2>&1 || return 1 + printf '%s' "$json" | jq -er "$query" 2>/dev/null +} + +pr_mergeable_resolved() { + local repo=$1 number=$2 mergeable='' attempt=0 json + while :; do + json=$(gh_bounded pr view "$number" --repo "$repo" \ + --json mergeable,mergeStateStatus,number,title,url,headRefOid,isDraft 2>/dev/null) || return 2 + mergeable=$(json_field "$json" '.mergeable // "UNKNOWN"') || return 2 + case "$mergeable" in + MERGEABLE) printf 'clean\n'; return 0 ;; + CONFLICTING) printf 'conflicted\n'; return 0 ;; + UNKNOWN) + attempt=$((attempt + 1)) + if [ "$attempt" -ge "$UNKNOWN_ATTEMPTS" ]; then + printf 'unknown\n' + return 0 + fi + [ "$UNKNOWN_WAIT" -eq 0 ] || sleep "$UNKNOWN_WAIT" + ;; + *) printf 'unknown\n'; return 0 ;; + esac + done +} + +format_finding() { + local owner_team=$1 repo=$2 number=$3 head=$4 draft=$5 url=$6 title=$7 + local draft_label=no + [ "$draft" = true ] && draft_label=yes + title=$(printf '%s' "$title" | tr '\t\r\n' ' ') + printf 'owner-team=%s repo=%s number=%s head=%s draft=%s url=%s title=%s' \ + "$owner_team" "$repo" "$number" "$head" "$draft_label" "$url" "$title" +} + +evaluate_repo() { + local repo=$1 owner_team=$2 json count i row number title head draft url mergeable key resolved + budget_exhausted && return 1 + json=$(gh_bounded pr list --repo "$repo" --state open --limit "$PR_LIMIT" \ + --json number,title,url,headRefOid,isDraft,mergeable 2>/dev/null) || return 0 + [ -n "$json" ] || json='[]' + count=$(json_field "$json" 'length' 2>/dev/null) || return 0 + i=0 + while [ "$i" -lt "$count" ]; do + budget_exhausted && return 1 + row=$(json_field "$json" ".[$i]" 2>/dev/null) || { i=$((i + 1)); continue; } + number=$(json_field "$row" '.number') || { i=$((i + 1)); continue; } + title=$(json_field "$row" '.title // ""') || title= + url=$(json_field "$row" '.url // ""') || url= + head=$(json_field "$row" '.headRefOid // ""') || head= + draft=$(json_field "$row" '.isDraft // false') || draft=false + mergeable=$(json_field "$row" '.mergeable // "UNKNOWN"') || mergeable=UNKNOWN + i=$((i + 1)) + [ -n "$head" ] || continue + key=$(conflict_key "$repo" "$number" "$head") + case "$mergeable" in + MERGEABLE) + record_remove_key "$key" + continue + ;; + CONFLICTING) ;; + UNKNOWN) + resolved=$(pr_mergeable_resolved "$repo" "$number") || continue + case "$resolved" in + conflicted) ;; + clean) + record_remove_key "$key" + continue + ;; + *) continue ;; + esac + ;; + *) continue ;; + esac + record_has_key "$key" && continue + emit_finding "$(format_finding "$owner_team" "$repo" "$number" "$head" "$draft" "$url" "$title")" + record_add_key "$key" + done + return 0 +} + +action_check() { + local repo owner_team line now + if ! command -v jq >/dev/null 2>&1; then + return 0 + fi + if ! command -v "$GH_AXI" >/dev/null 2>&1; then + return 0 + fi + + record_read + now=$(record_epoch_now) + if [ "$INTERVAL" -ne 0 ] && [ "$RECORD_EPOCH" -gt 0 ] \ + && [ "$now" -ge "$RECORD_EPOCH" ] && [ $((now - RECORD_EPOCH)) -lt "$INTERVAL" ]; then + return 0 + fi + + DEADLINE=$(($(real_epoch) + BUDGET_SECS)) + + if [ -n "$BUDGET_CUT_FROM" ]; then + emit_finding "sweep budget ${BUDGET_CUT_FROM}s cut to ${BUDGET_SECS}s to stay inside the watcher check timeout of ${CHECK_TIMEOUT}s" + fi + + while IFS= read -r repo; do + [ -n "$repo" ] || continue + budget_exhausted && break + owner_team=$(owner_for_repo "$repo") + evaluate_repo "$repo" "$owner_team" || break + done < <(discover_repos) + + line= + if [ -n "$FINDINGS" ]; then + fm_cap_line_var "pr-conflict: $FINDINGS" "$MAX_LINE" + line=$FM_LINE_CAP_LINE + fi + + if [ -n "$line" ]; then + printf '%s\n' "$line" + fi + record_write "$REPORTED_KEYS" || true + return 0 +} + +shim_content() { + local home=$1 + printf '%s\n' \ + '#!/usr/bin/env bash' \ + '# Auto-generated by fm-pr-conflict-watch.sh - PR merge conflict poll shim.' \ + '# The watcher validates these bytes, then dispatches the trusted check script.' \ + "export FM_HOME=$(printf '%q' "$home")" \ + "exec $(printf '%q' "$SCRIPT_DIR/fm-pr-conflict-watch.sh") check" +} + +SHIM_WRITE_TMP= + +shim_write() { + local want=$1 device tmp + [ -d "$STATE" ] && [ ! -L "$STATE" ] || return 1 + device=$(fm_pr_file_device "$STATE") || return 1 + [ -n "$device" ] || return 1 + fm_pr_regular_destination_on_device_or_absent "$CHECK_SHIM" "$device" || return 1 + if [ -e "$CHECK_SHIM" ] && [ "$(fm_pr_file_mode "$CHECK_SHIM")" = 700 ] \ + && [ "$(cat "$CHECK_SHIM" 2>/dev/null)" = "$want" ]; then + return 0 + fi + tmp=$(umask 077; mktemp "$STATE/.fm-pr-conflict-watch-check.XXXXXX" 2>/dev/null) || return 1 + SHIM_WRITE_TMP=$tmp + if ! printf '%s\n' "$want" > "$tmp" \ + || ! chmod 0700 "$tmp" \ + || ! fm_pr_private_file_valid "$tmp" 700 "$device"; then + rm -f -- "$tmp" + SHIM_WRITE_TMP= + return 1 + fi + if ! fm_pr_regular_destination_on_device_or_absent "$CHECK_SHIM" "$device" \ + || ! mv -f -- "$tmp" "$CHECK_SHIM"; then + rm -f -- "$tmp" + SHIM_WRITE_TMP= + return 1 + fi + SHIM_WRITE_TMP= + fm_pr_private_file_valid "$CHECK_SHIM" 700 "$device" +} + +shim_backup() { + local device tmp + device=$(fm_pr_file_device "$STATE") || return 1 + [ -n "$device" ] || return 1 + tmp=$(umask 077; mktemp "$STATE/.fm-pr-conflict-watch-check.XXXXXX" 2>/dev/null) || return 1 + if ! cat "$CHECK_SHIM" > "$tmp" 2>/dev/null \ + || ! chmod 0700 "$tmp" \ + || ! fm_pr_private_file_valid "$tmp" 700 "$device"; then + rm -f -- "$tmp" + return 1 + fi + printf '%s\n' "$tmp" +} + +ARM_BACKUP= + +arm_rollback() { + [ -z "$SHIM_WRITE_TMP" ] || rm -f -- "$SHIM_WRITE_TMP" + SHIM_WRITE_TMP= + if [ -n "$ARM_BACKUP" ]; then + mv -f -- "$ARM_BACKUP" "$CHECK_SHIM" 2>/dev/null || rm -f -- "$ARM_BACKUP" + ARM_BACKUP= + if fm_custom_check_registered "$STATE" "$CHECK_ID"; then + return 0 + fi + fi + rm -f -- "$CHECK_SHIM" +} + +arm_interrupted() { + arm_rollback + printf 'fm-pr-conflict-watch: arming was interrupted, so state/%s.check.sh is not armed\n' "$CHECK_ID" >&2 + exit 1 +} + +action_arm() { + local want home + if ! command -v jq >/dev/null 2>&1; then + printf 'fm-pr-conflict-watch: jq is required\n' >&2 + return 1 + fi + if ! command -v "$GH_AXI" >/dev/null 2>&1; then + printf 'fm-pr-conflict-watch: %s is required\n' "$GH_AXI" >&2 + return 1 + fi + mkdir -p "$STATE" || return 1 + case "$FM_HOME" in + /*) home=$FM_HOME ;; + *) + home=$(CDPATH='' cd -- "$FM_HOME" 2>/dev/null && pwd -P) || { + printf 'fm-pr-conflict-watch: cannot resolve FM_HOME %s\n' "$FM_HOME" >&2 + return 1 + } + ;; + esac + want=$(shim_content "$home") + ARM_BACKUP= + if [ -f "$CHECK_SHIM" ] && [ ! -L "$CHECK_SHIM" ]; then + ARM_BACKUP=$(shim_backup) || { + printf 'fm-pr-conflict-watch: could not save the existing %s\n' "$CHECK_SHIM" >&2 + return 1 + } + fi + trap arm_interrupted HUP INT TERM + if ! shim_write "$want"; then + trap - HUP INT TERM + arm_rollback + printf 'fm-pr-conflict-watch: could not write %s\n' "$CHECK_SHIM" >&2 + return 1 + fi + if ! FM_HOME="$home" "$REGISTER_BIN" "$CHECK_ID" >/dev/null; then + trap - HUP INT TERM + arm_rollback + printf 'fm-pr-conflict-watch: could not register %s\n' "$CHECK_SHIM" >&2 + return 1 + fi + trap - HUP INT TERM + [ -z "$ARM_BACKUP" ] || rm -f -- "$ARM_BACKUP" + ARM_BACKUP= + printf 'armed: state/%s.check.sh\n' "$CHECK_ID" + return 0 +} + +action_disarm() { + rm -f -- "$CHECK_SHIM" "$CHECK_TRUST" "$RECORD" + printf 'disarmed: state/%s.check.sh\n' "$CHECK_ID" + return 0 +} + +case "${1:-check}" in + check) action_check ;; + arm) action_arm ;; + disarm) action_disarm ;; + -h|--help) usage ;; + *) die_usage "unknown action: $1" ;; +esac diff --git a/docs/configuration.md b/docs/configuration.md index df27bcbda26..47d80055bae 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -419,6 +419,33 @@ The sweep must finish inside `FM_CHECK_TIMEOUT` (default 30), because a run the So a budget larger than that timeout allows is cut down to what fits instead of being refused, and the cut is reported in the report line. A budget that is not a whole number from 1 to 120 is still refused outright. +## PR merge conflict watch + +`bin/fm-pr-conflict-watch.sh` polls open GitHub pull requests for merge conflicts across the repositories this home works in. +It is detection and routing only: it never resolves conflicts, rebases branches, or calls GitHub's update-branch API. + +Repositories are derived from `data/projects.md` by reading each registered project's clone under `projects/` and parsing its `origin` remote into an `owner/repo` slug. +The firstmate checkout's own `origin` remote is included too, so the captain's firstmate fork is covered without hardcoding its name. +Owning targets come from `data/secondmates.md`: each secondmate's `projects:` list maps registered project names to that secondmate's id, and the firstmate repository maps to `main`. +Unmapped projects fall back to `main`. + +Arm once per home with `bin/fm-pr-conflict-watch.sh arm`. +That writes `state/pr-conflict-watch.check.sh` and binds its bytes with `bin/fm-check-register.sh`, so the watcher polls on its normal cadence and turns a newly conflicted pull request into one `check:` wake line. +`bin/fm-pr-conflict-watch.sh disarm` removes the shim, trust binding, and dedupe record. +The check prints nothing when no new conflict exists. +Draft pull requests are included; a conflicted draft is reported with `draft=yes`. + +Each wake line begins with `pr-conflict:` and carries `owner-team`, `repo`, `number`, `head`, `draft`, `url`, and `title` so firstmate can route without re-deriving ownership. +Dedupe keys are repository, pull request number, and head SHA: the same conflict on the same head is reported once, while a force-updated head that conflicts again is a new event. +GitHub computes mergeability lazily; the check polls short rereads when `mergeable` is `UNKNOWN` and treats a persistent `UNKNOWN` as unknown rather than clean or conflicted. + +This is a safety net, not a cure: conflicts happen because pull requests wait unmerged while the default branch moves underneath them. + +`FM_PR_CONFLICT_INTERVAL` (default 300 seconds, `0` to probe on every run) sets how often sweeps run, `FM_PR_CONFLICT_PROBE_SECS` (default 5) bounds one GitHub call, and `FM_PR_CONFLICT_BUDGET_SECS` (default 20) bounds a whole sweep. +`FM_PR_CONFLICT_UNKNOWN_ATTEMPTS` (default 3) and `FM_PR_CONFLICT_UNKNOWN_WAIT` (default 1 second) control lazy mergeability polling. +`FM_PR_CONFLICT_PR_LIMIT` (default 30) caps open pull requests read per repository. +The sweep must finish inside `FM_CHECK_TIMEOUT` (default 30); a larger budget is cut to fit rather than refused. + ## Relay (.env) Relay lets a firstmate instance answer public mentions and act on normal reversible mention requests through firstmate's normal lifecycle. diff --git a/tests/fm-pr-conflict-watch.test.sh b/tests/fm-pr-conflict-watch.test.sh new file mode 100755 index 00000000000..f9d45aecc3f --- /dev/null +++ b/tests/fm-pr-conflict-watch.test.sh @@ -0,0 +1,249 @@ +#!/usr/bin/env bash +# Tests for fm-pr-conflict-watch.sh merge-conflict detection and dedupe. +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +WATCH="$ROOT/bin/fm-pr-conflict-watch.sh" +TMP_ROOT=$(fm_test_tmproot fm-pr-conflict-watch) +BASE_PATH=$PATH +JQ_BIN=$(command -v jq) || fail "jq is required for these tests" + +fm_git_identity fmtest fmtest@example.invalid + +REPO_A=acme/alpha +REPO_B=acme/beta +HEAD_ONE=1111111111111111111111111111111111111111 +HEAD_TWO=2222222222222222222222222222222222222222 + +make_home() { + local name=$1 home fakebin fixture + home="$TMP_ROOT/$name" + fixture="$home/fixture" + fakebin="$home/fakebin" + mkdir -p "$home/state" "$home/data" "$home/projects/alpha" "$home/projects/beta" \ + "$fixture/lists" "$fixture/views" "$fakebin" + cat > "$home/data/projects.md" <<'MD' +# Projects + +- alpha - alpha repo (added 2026-08-25) +- beta - beta repo (added 2026-08-25) +MD + cat > "$home/data/secondmates.md" <<'MD' +# Second mates + +- team-a - owns alpha (home: /tmp/team-a; scope: alpha; projects: alpha; added 2026-08-25) +- team-b - owns beta (home: /tmp/team-b; scope: beta; projects: beta; added 2026-08-25) +MD + git -C "$home/projects/alpha" init -q + git -C "$home/projects/alpha" remote add origin "https://github.com/$REPO_A.git" + git -C "$home/projects/beta" init -q + git -C "$home/projects/beta" remote add origin "https://github.com/$REPO_B.git" + fm_slug=$(git -C "$ROOT" remote get-url origin 2>/dev/null | sed -n 's#.*github\.com[:/]\([^/]*/[^/]*\)#\1#p' | sed 's#\.git$##') + cat > "$fakebin/gh-axi" <<'SH' +#!/usr/bin/env bash +set -u +fixture="$FM_TEST_PR_CONFLICT_FIXTURE" +repo= +number= +mode= +while [ "$#" -gt 0 ]; do + case "$1" in + pr) + mode=$2 + shift 2 + ;; + --repo) + repo=$2 + shift 2 + ;; + --repo=*) + repo=${1#--repo=} + shift + ;; + --json|--state) + shift + ;; + --limit) + shift 2 + ;; + [0-9]*) + number=$1 + shift + ;; + *) + shift + ;; + esac +done +slug=${repo//\//__} +case "$mode" in + list) + file="$fixture/lists/${slug}.json" + if [ -f "$file" ]; then + cat "$file" + else + printf '[]\n' + fi + exit 0 + ;; + view) + file="$fixture/views/${slug}-${number}.json" + seqfile="$fixture/views/${slug}-${number}.seq" + if [ -f "$seqfile" ]; then + idx=$(cat "$seqfile") + idx=$((idx + 1)) + printf '%s\n' "$idx" > "$seqfile" + jq -c ".[$((idx - 1))]" "$file" + exit 0 + fi + if [ -f "$file" ]; then + cat "$file" + fi + exit 0 + ;; +esac +exit 0 +SH + chmod +x "$fakebin/gh-axi" + ln -sf "$JQ_BIN" "$fakebin/jq" + if [ -n "$fm_slug" ]; then + write_list "$home" "$fm_slug" '[]' + fi + printf '%s\n' "$home" +} + +write_list() { + local home=$1 repo=$2 json=$3 + slug=${repo//\//__} + printf '%s\n' "$json" > "$home/fixture/lists/${slug}.json" +} + +write_view() { + local home=$1 repo=$2 number=$3 json + slug=${repo//\//__} + printf '%s\n' "$json" > "$home/fixture/views/${slug}-${number}.json" +} + +write_view_sequence() { + local home=$1 repo=$2 number=$3 + slug=${repo//\//__} + shift 3 + printf '%s\n' "$@" > "$home/fixture/views/${slug}-${number}.json" + printf '0\n' > "$home/fixture/views/${slug}-${number}.seq" +} + +run_check() { + local home=$1 out=$2 + shift 2 + local status=0 + env FM_CHECK_TIMEOUT=30 FM_PR_CONFLICT_INTERVAL=0 FM_PR_CONFLICT_UNKNOWN_WAIT=0 \ + FM_HOME="$home" FM_ROOT="$ROOT" FM_DATA_OVERRIDE="$home/data" \ + FM_PROJECTS_OVERRIDE="$home/projects" FM_STATE_OVERRIDE="$home/state" \ + FM_TEST_PR_CONFLICT_FIXTURE="$home/fixture" \ + PATH="$home/fakebin:$BASE_PATH" "$@" "$WATCH" >"$out" 2>&1 || status=$? + expect_code 0 "$status" "check exit" +} + +test_newly_conflicted_wakes() { + local home out + home=$(make_home wake-new) + write_list "$home" "$REPO_A" "[{\"number\":7,\"title\":\"Broken\",\"url\":\"https://github.com/$REPO_A/pull/7\",\"headRefOid\":\"$HEAD_ONE\",\"isDraft\":false,\"mergeable\":\"CONFLICTING\"}]" + out="$home/out.txt" + run_check "$home" "$out" + assert_contains "$(cat "$out")" "pr-conflict:" "missing wake prefix" + assert_contains "$(cat "$out")" "owner-team=team-a" "missing owner team" + assert_contains "$(cat "$out")" "repo=$REPO_A" "missing repo" + assert_contains "$(cat "$out")" "number=7" "missing number" + assert_contains "$(cat "$out")" "head=$HEAD_ONE" "missing head" + assert_contains "$(cat "$out")" "draft=no" "missing draft flag" + assert_contains "$(cat "$out")" "title=Broken" "missing title" + [ "$(wc -l < "$out" | tr -d '[:space:]')" = 1 ] || fail "wake must be one line" + pass "newly conflicted PR wakes once with routing fields" +} + +test_same_head_stays_silent() { + local home out + home=$(make_home wake-silent) + write_list "$home" "$REPO_A" "[{\"number\":7,\"title\":\"Broken\",\"url\":\"https://github.com/$REPO_A/pull/7\",\"headRefOid\":\"$HEAD_ONE\",\"isDraft\":false,\"mergeable\":\"CONFLICTING\"}]" + out="$home/out.txt" + run_check "$home" "$out" + [ -s "$out" ] || fail "first poll should wake" + : > "$out" + run_check "$home" "$out" + [ ! -s "$out" ] || fail "second poll with same head should stay silent: $(cat "$out")" + pass "same conflicted head stays silent on the next poll" +} + +test_new_head_after_force_update_wakes_again() { + local home out + home=$(make_home wake-new-head) + write_list "$home" "$REPO_A" "[{\"number\":7,\"title\":\"Broken\",\"url\":\"https://github.com/$REPO_A/pull/7\",\"headRefOid\":\"$HEAD_ONE\",\"isDraft\":false,\"mergeable\":\"CONFLICTING\"}]" + out="$home/out.txt" + run_check "$home" "$out" + [ -s "$out" ] || fail "first head should wake" + write_list "$home" "$REPO_A" "[{\"number\":7,\"title\":\"Broken again\",\"url\":\"https://github.com/$REPO_A/pull/7\",\"headRefOid\":\"$HEAD_TWO\",\"isDraft\":false,\"mergeable\":\"CONFLICTING\"}]" + : > "$out" + run_check "$home" "$out" + assert_contains "$(cat "$out")" "head=$HEAD_TWO" "force-updated head should wake again" + pass "new head after force-update wakes again" +} + +test_unknown_never_reported() { + local home out + home=$(make_home wake-unknown) + write_list "$home" "$REPO_A" "[{\"number\":7,\"title\":\"Maybe\",\"url\":\"https://github.com/$REPO_A/pull/7\",\"headRefOid\":\"$HEAD_ONE\",\"isDraft\":false,\"mergeable\":\"UNKNOWN\"}]" + write_view_sequence "$home" "$REPO_A" 7 \ + '{"mergeable":"UNKNOWN","mergeStateStatus":"UNKNOWN","number":7,"title":"Maybe","url":"https://github.com/acme/alpha/pull/7","headRefOid":"1111111111111111111111111111111111111111","isDraft":false}' \ + '{"mergeable":"UNKNOWN","mergeStateStatus":"UNKNOWN","number":7,"title":"Maybe","url":"https://github.com/acme/alpha/pull/7","headRefOid":"1111111111111111111111111111111111111111","isDraft":false}' \ + '{"mergeable":"UNKNOWN","mergeStateStatus":"UNKNOWN","number":7,"title":"Maybe","url":"https://github.com/acme/alpha/pull/7","headRefOid":"1111111111111111111111111111111111111111","isDraft":false}' + out="$home/out.txt" + run_check "$home" "$out" env FM_PR_CONFLICT_UNKNOWN_ATTEMPTS=3 + [ ! -s "$out" ] || fail "persistent UNKNOWN must stay silent: $(cat "$out")" + pass "persistent UNKNOWN is never reported as conflicted or clean" +} + +test_clean_fleet_is_silent() { + local home out + home=$(make_home wake-clean) + write_list "$home" "$REPO_A" "[{\"number\":7,\"title\":\"Fine\",\"url\":\"https://github.com/$REPO_A/pull/7\",\"headRefOid\":\"$HEAD_ONE\",\"isDraft\":false,\"mergeable\":\"MERGEABLE\"}]" + write_list "$home" "$REPO_B" '[]' + out="$home/out.txt" + run_check "$home" "$out" + [ ! -s "$out" ] || fail "clean fleet should print nothing: $(cat "$out")" + pass "clean fleet output is empty" +} + +test_draft_conflicts_are_reported() { + local home out + home=$(make_home wake-draft) + write_list "$home" "$REPO_B" "[{\"number\":3,\"title\":\"Draft broken\",\"url\":\"https://github.com/$REPO_B/pull/3\",\"headRefOid\":\"$HEAD_ONE\",\"isDraft\":true,\"mergeable\":\"CONFLICTING\"}]" + out="$home/out.txt" + run_check "$home" "$out" + assert_contains "$(cat "$out")" "draft=yes" "draft conflict must be marked" + assert_contains "$(cat "$out")" "owner-team=team-b" "draft repo routes to owning team" + pass "conflicted draft PRs are reported" +} + +test_arm_registers_check() { + local home out status=0 + home=$(make_home arm) + env FM_HOME="$home" FM_ROOT="$ROOT" FM_DATA_OVERRIDE="$home/data" \ + FM_PROJECTS_OVERRIDE="$home/projects" FM_STATE_OVERRIDE="$home/state" \ + PATH="$home/fakebin:$BASE_PATH" "$WATCH" arm >"$home/arm.out" 2>&1 || status=$? + expect_code 0 "$status" "arm exit" + assert_present "$home/state/pr-conflict-watch.check.sh" "arm must write check shim" + assert_present "$home/state/pr-conflict-watch.check-trust" "arm must bind trust" + FM_STATE_OVERRIDE="$home/state" "$ROOT/bin/fm-check-register.sh" pr-conflict-watch >/dev/null \ + || fail "trust binding must match shim bytes" + pass "arm writes and registers the watcher check" +} + +test_newly_conflicted_wakes +test_same_head_stays_silent +test_new_head_after_force_update_wakes_again +test_unknown_never_reported +test_clean_fleet_is_silent +test_draft_conflicts_are_reported +test_arm_registers_check From 5015b4e33f55a53e2150d449cfa29c05dbcd2664 Mon Sep 17 00:00:00 2001 From: bingb0t5 Date: Tue, 25 Aug 2026 10:06:15 +0700 Subject: [PATCH 2/7] no-mistakes(review): fix PR conflict watch cap suppression, budget overrun, record pruning --- AGENTS.md | 1 + bin/fm-bearings-snapshot.sh | 12 +- bin/fm-pr-conflict-watch.sh | 400 +++++++++++++++++++++-------- bin/fm-repo-slug-lib.sh | 19 ++ docs/configuration.md | 5 +- docs/scripts.md | 2 + tests/fm-pr-conflict-watch.test.sh | 134 +++++++++- 7 files changed, 458 insertions(+), 115 deletions(-) create mode 100644 bin/fm-repo-slug-lib.sh diff --git a/AGENTS.md b/AGENTS.md index a50f6afe5cf..5e8939bdf48 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -111,6 +111,7 @@ state/ runtime records and signals; gitignored .pr-check-migration-scan-v1 private marker proving the non-executing scan disabled every unsafe legacy check; .pr-check-migration-v1 separately records completed private repairs x-watch.check.sh generated Relay poll shim; present only when opted in (section 14) tool-updates.check.sh generated watched-tool update poll shim and its .check-trust binding; present only after bin/fm-tool-update-check.sh arm; its report record .tool-updates is what keeps one pending update from being reported on every poll + pr-conflict-watch.check.sh generated open-PR merge-conflict poll shim and its .check-trust binding; present only after bin/fm-pr-conflict-watch.sh arm; its dedupe record .pr-conflict-watch is what keeps one conflicted head from waking on every poll, and it is cut back to the conflicts each sweep still observes pending-replies/ parent-owned secondmate pending-reply records (correlation id, delivery vs reply, recovery, escalation); fm-pending-reply-lib.sh procevent/ registered process-to-event sources, one private record per canonical source id; written only by bin/fm-procevent.sh, and their presence alone keeps supervision required (section 13) procevent-inbox/ private captured results and their durable handled-acknowledgement markers; source output lives here and never in an event line diff --git a/bin/fm-bearings-snapshot.sh b/bin/fm-bearings-snapshot.sh index c64f4226dbb..1f5509e8c03 100755 --- a/bin/fm-bearings-snapshot.sh +++ b/bin/fm-bearings-snapshot.sh @@ -69,6 +69,9 @@ FLEET="$SCRIPT_DIR/fm-fleet-snapshot.sh" # shellcheck source=bin/fm-timeout-lib.sh # shellcheck disable=SC1091 . "$SCRIPT_DIR/fm-timeout-lib.sh" +# shellcheck source=bin/fm-repo-slug-lib.sh +# shellcheck disable=SC1091 +. "$SCRIPT_DIR/fm-repo-slug-lib.sh" # Bounds (overridable for tests / large fleets). FM_BEARINGS_LANDED=${FM_BEARINGS_LANDED:-6} @@ -193,11 +196,6 @@ PR_REPOS_SHOWN=0 PR_ROWS_CAPPED=0 PR_ROWS_MIN_TOTAL=0 -# Parse owner/repo from an https or ssh GitHub remote/PR URL; empty if not GitHub. -repo_slug() { # - printf '%s' "$1" | sed -n 's#.*github\.com[:/]\([^/]*/[^/]*\)#\1#p' | sed 's#\.git$##; s#/pull/.*$##; s#/$##' -} - # Bounded gh call; prints stdout, non-zero on timeout/failure. gh only. # bin/fm-timeout-lib.sh owns the bound itself. gh_bounded() { # @@ -213,7 +211,7 @@ if [ "$INCLUDE_PRS" = 1 ]; then repos="" while IFS= read -r u; do [ -n "$u" ] || continue - s=$(repo_slug "$u"); [ -n "$s" ] || continue + s=$(fm_repo_slug "$u"); [ -n "$s" ] || continue case " $repos " in *" $s "*) : ;; *) repos="$repos $s" ;; esac done </dev/null) || continue - s=$(repo_slug "$u"); [ -n "$s" ] || continue + s=$(fm_repo_slug "$u"); [ -n "$s" ] || continue case " $repos " in *" $s "*) : ;; *) repos="$repos $s" ;; esac done < characters short of the cap, and +# collect the keys of exactly the findings that fit. The first finding is always +# taken, so a single over-long finding still reports something - the title is +# the only unbounded field and it is last, so the final cut takes that tail - +# rather than leaving the sweep permanently silent. +build_finding_line() { + local reserve=$1 total i text key candidate room + FINDING_LINE= + FINDING_LINE_KEYS= + FINDING_OMITTED=0 + total=${#FINDING_TEXTS[@]} + room=$((MAX_LINE - reserve)) + i=0 + while [ "$i" -lt "$total" ]; do + text=${FINDING_TEXTS[$i]} + key=${FINDING_KEYS[$i]} + if [ -z "$FINDING_LINE" ]; then + candidate="$FINDING_PREFIX$text" + else + candidate="$FINDING_LINE; $text" + fi + if [ "$i" -eq 0 ] || [ "${#candidate}" -le "$room" ]; then + FINDING_LINE=$candidate + [ -z "$key" ] || FINDING_LINE_KEYS="${FINDING_LINE_KEYS:+$FINDING_LINE_KEYS;}$key" + else + FINDING_OMITTED=$((FINDING_OMITTED + 1)) + fi + i=$((i + 1)) + done } budget_exhausted() { @@ -214,14 +284,10 @@ gh_bounded() { fm_run_timed "$(probe_bound)" env GH_PROMPT_DISABLED=1 GH_NO_UPDATE_NOTIFIER=1 "$GH_AXI" "$@" } -repo_slug() { - printf '%s' "$1" | sed -n 's#.*github\.com[:/]\([^/]*/[^/]*\)#\1#p' | sed 's#\.git$##; s#/pull/.*$##; s#/$##' -} - firstmate_repo_slug() { local url url=$(git -C "$FM_ROOT" remote get-url origin 2>/dev/null) || return 1 - repo_slug "$url" + fm_repo_slug "$url" } project_names() { @@ -234,34 +300,11 @@ resolve_project_repo() { dir="$PROJECTS/$project" [ -d "$dir" ] || return 1 url=$(git -C "$dir" remote get-url origin 2>/dev/null) || return 1 - slug=$(repo_slug "$url") + slug=$(fm_repo_slug "$url") [ -n "$slug" ] || return 1 printf '%s\n' "$slug" } -discover_repos() { - local project slug repos='' fm_slug - while IFS= read -r project; do - [ -n "$project" ] || continue - slug=$(resolve_project_repo "$project" 2>/dev/null) || continue - case " $repos " in - *" $slug "*) ;; - *) repos="$repos $slug" ;; - esac - done < <(project_names) - fm_slug=$(firstmate_repo_slug 2>/dev/null) || fm_slug= - if [ -n "$fm_slug" ]; then - case " $repos " in - *" $fm_slug "*) ;; - *) repos="$repos $fm_slug" ;; - esac - fi - for slug in $repos; do - [ -n "$slug" ] || continue - printf '%s\n' "$slug" - done -} - trim_field() { local value=$1 value=${value#"${value%%[![:space:]]*}"} @@ -269,49 +312,95 @@ trim_field() { printf '%s\n' "$value" } -owner_for_project() { - local project=$1 reg line id projects part +# The registry is read once per sweep into a project -> owning secondmate map. +# First registration wins, which is the order a linear scan of the file would +# have answered in. +load_project_owners() { + local reg line id rest part + PROJECT_OWNER_MAP= reg="$DATA/secondmates.md" - [ -f "$reg" ] || return 1 + [ -f "$reg" ] || return 0 while IFS= read -r line || [ -n "$line" ]; do case "$line" in - "- "*) - secondmate_registry_parse_line "$line" || continue - id=$SECONDMATE_REGISTRY_ID - projects=$SECONDMATE_REGISTRY_PROJECTS - IFS=',' read -r -a _parts <<< "$projects" - for part in "${_parts[@]}"; do - part=$(trim_field "$part") - [ "$part" = "$project" ] || continue - printf '%s\n' "$id" - return 0 - done - ;; + "- "*) ;; + *) continue ;; esac + secondmate_registry_parse_line "$line" || continue + id=$SECONDMATE_REGISTRY_ID + rest=$SECONDMATE_REGISTRY_PROJECTS + while IFS= read -r part; do + part=$(trim_field "$part") + [ -n "$part" ] || continue + case "$part" in *[[:space:]]*) continue ;; esac + case " +$PROJECT_OWNER_MAP" in + *" +$part "*) continue ;; + esac + PROJECT_OWNER_MAP="$PROJECT_OWNER_MAP$part $id +" + done < <(list_parts "$rest" ',') done < "$reg" - return 1 } -owner_for_repo() { - local repo=$1 project slug owner fm_slug - fm_slug=$(firstmate_repo_slug 2>/dev/null) || fm_slug= - if [ -n "$fm_slug" ] && [ "$repo" = "$fm_slug" ]; then - printf '%s\n' "$MAIN_OWNER" +map_lookup() { + local map=$1 key=$2 line + while IFS= read -r line; do + [ "${line%% *}" = "$key" ] || continue + printf '%s\n' "${line#* }" return 0 - fi + done </dev/null) || FIRSTMATE_SLUG= while IFS= read -r project; do [ -n "$project" ] || continue slug=$(resolve_project_repo "$project" 2>/dev/null) || continue - [ "$slug" = "$repo" ] || continue - owner=$(owner_for_project "$project" 2>/dev/null) || owner= - if [ -n "$owner" ]; then - printf '%s\n' "$owner" + case " $DISCOVERED_REPOS " in + *" $slug "*) continue ;; + esac + if [ -n "$FIRSTMATE_SLUG" ] && [ "$slug" = "$FIRSTMATE_SLUG" ]; then + owner=$MAIN_OWNER else - printf '%s\n' "$MAIN_OWNER" + owner=$(owner_for_project "$project") || owner= + [ -n "$owner" ] || owner=$MAIN_OWNER fi - return 0 + DISCOVERED_REPOS="$DISCOVERED_REPOS $slug" + REPO_OWNER_MAP="$REPO_OWNER_MAP$slug $owner +" done < <(project_names) - printf '%s\n' "$MAIN_OWNER" + if [ -n "$FIRSTMATE_SLUG" ]; then + case " $DISCOVERED_REPOS " in + *" $FIRSTMATE_SLUG "*) ;; + *) + DISCOVERED_REPOS="$DISCOVERED_REPOS $FIRSTMATE_SLUG" + REPO_OWNER_MAP="$REPO_OWNER_MAP$FIRSTMATE_SLUG $MAIN_OWNER +" + ;; + esac + fi +} + +owner_for_repo() { + local owner + owner=$(map_lookup "$REPO_OWNER_MAP" "$1") || owner= + [ -n "$owner" ] || owner=$MAIN_OWNER + printf '%s\n' "$owner" } conflict_key() { @@ -373,18 +462,59 @@ record_add_key() { fi } +record_add_keys() { + local part + while IFS= read -r part; do + record_add_key "$part" + done < <(list_parts "$1" ';') +} + record_remove_key() { local key=$1 out='' part - IFS=';' read -r -a _parts <<< "$REPORTED_KEYS" - for part in "${_parts[@]}"; do - [ -n "$part" ] || continue + while IFS= read -r part; do [ "$part" = "$key" ] && continue - if [ -z "$out" ]; then - out=$part - else - out="$out;$part" - fi - done + out="${out:+$out;}$part" + done < <(list_parts "$REPORTED_KEYS" ';') + REPORTED_KEYS=$out +} + +# Every open pull request this sweep read is remembered, so the record can be +# cut back to the live conflict set instead of accumulating one key per head +# forever. A repository whose whole open-PR page was read keeps only the keys +# that page carried; a repository the sweep did not finish keeps all of its +# keys, because absence there means unread rather than resolved. Keys for +# repositories this fleet no longer works in are dropped only when the sweep +# reached every discovered repository. +seen_key() { + local key=$1 + case ";$SEEN_KEYS;" in + *";$key;"*) return 0 ;; + esac + SEEN_KEYS="${SEEN_KEYS:+$SEEN_KEYS;}$key" +} + +record_prune() { + local out='' part repo + while IFS= read -r part; do + repo=${part%%#*} + case " $SWEPT_REPOS " in + *" $repo "*) + case ";$SEEN_KEYS;" in + *";$part;"*) ;; + *) continue ;; + esac + ;; + *) + if [ "$SWEEP_COMPLETE" -eq 1 ]; then + case " $DISCOVERED_REPOS " in + *" $repo "*) ;; + *) continue ;; + esac + fi + ;; + esac + out="${out:+$out;}$part" + done < <(list_parts "$REPORTED_KEYS" ';') REPORTED_KEYS=$out } @@ -394,24 +524,57 @@ json_field() { printf '%s' "$json" | jq -er "$query" 2>/dev/null } +# Resolve one pull request's lazily computed mergeability into RESOLVED_STATE, +# with the head RESOLVED_STATE describes in RESOLVED_HEAD - the read that +# settled the state is also what names the head it was settled for, so a head +# force-updated between the list and this read is reported under the SHA that +# was actually judged. +# +# The retry loop is bounded by the sweep deadline as well as by the attempt +# count: overrunning FM_CHECK_TIMEOUT gets the process group killed, and this +# check prints and records only at the end, so an overrun sweep loses every +# finding it made and repeats that loss on every poll. An unsettled read stays +# unknown, which is silent, and never becomes clean or conflicted. pr_mergeable_resolved() { - local repo=$1 number=$2 mergeable='' attempt=0 json + local repo=$1 number=$2 mergeable='' attempt=0 json head + RESOLVED_STATE=unknown + RESOLVED_HEAD= while :; do + if budget_exhausted; then + RESOLVED_STATE=unknown + return 0 + fi json=$(gh_bounded pr view "$number" --repo "$repo" \ --json mergeable,mergeStateStatus,number,title,url,headRefOid,isDraft 2>/dev/null) || return 2 mergeable=$(json_field "$json" '.mergeable // "UNKNOWN"') || return 2 + head=$(json_field "$json" '.headRefOid // ""') || head= case "$mergeable" in - MERGEABLE) printf 'clean\n'; return 0 ;; - CONFLICTING) printf 'conflicted\n'; return 0 ;; + MERGEABLE) + RESOLVED_STATE=clean + RESOLVED_HEAD=$head + return 0 + ;; + CONFLICTING) + RESOLVED_STATE=conflicted + RESOLVED_HEAD=$head + return 0 + ;; UNKNOWN) attempt=$((attempt + 1)) if [ "$attempt" -ge "$UNKNOWN_ATTEMPTS" ]; then - printf 'unknown\n' + RESOLVED_STATE=unknown + return 0 + fi + if [ $((DEADLINE - $(real_epoch))) -le $((UNKNOWN_WAIT + PROBE_MIN_SECS)) ]; then + RESOLVED_STATE=unknown return 0 fi [ "$UNKNOWN_WAIT" -eq 0 ] || sleep "$UNKNOWN_WAIT" ;; - *) printf 'unknown\n'; return 0 ;; + *) + RESOLVED_STATE=unknown + return 0 + ;; esac done } @@ -426,7 +589,7 @@ format_finding() { } evaluate_repo() { - local repo=$1 owner_team=$2 json count i row number title head draft url mergeable key resolved + local repo=$1 owner_team=$2 json count i row number title head draft url mergeable key state budget_exhausted && return 1 json=$(gh_bounded pr list --repo "$repo" --state open --limit "$PR_LIMIT" \ --json number,title,url,headRefOid,isDraft,mergeable 2>/dev/null) || return 0 @@ -444,35 +607,43 @@ evaluate_repo() { mergeable=$(json_field "$row" '.mergeable // "UNKNOWN"') || mergeable=UNKNOWN i=$((i + 1)) [ -n "$head" ] || continue - key=$(conflict_key "$repo" "$number" "$head") + seen_key "$(conflict_key "$repo" "$number" "$head")" case "$mergeable" in - MERGEABLE) + MERGEABLE) state=clean ;; + CONFLICTING) state=conflicted ;; + UNKNOWN) + pr_mergeable_resolved "$repo" "$number" || continue + state=$RESOLVED_STATE + if [ -n "$RESOLVED_HEAD" ] && [ "$RESOLVED_HEAD" != "$head" ]; then + record_remove_key "$(conflict_key "$repo" "$number" "$head")" + head=$RESOLVED_HEAD + seen_key "$(conflict_key "$repo" "$number" "$head")" + fi + ;; + *) continue ;; + esac + key=$(conflict_key "$repo" "$number" "$head") + case "$state" in + conflicted) ;; + clean) record_remove_key "$key" continue ;; - CONFLICTING) ;; - UNKNOWN) - resolved=$(pr_mergeable_resolved "$repo" "$number") || continue - case "$resolved" in - conflicted) ;; - clean) - record_remove_key "$key" - continue - ;; - *) continue ;; - esac - ;; *) continue ;; esac record_has_key "$key" && continue - emit_finding "$(format_finding "$owner_team" "$repo" "$number" "$head" "$draft" "$url" "$title")" - record_add_key "$key" + queue_finding "$(format_finding "$owner_team" "$repo" "$number" "$head" "$draft" "$url" "$title")" "$key" done + # Only a page that was not itself cut short by PR_LIMIT can be read as the + # repository's whole open set, which is what pruning its stale keys relies on. + if [ "$count" -lt "$PR_LIMIT" ]; then + SWEPT_REPOS="$SWEPT_REPOS $repo" + fi return 0 } action_check() { - local repo owner_team line now + local repo owner_team line now notice if ! command -v jq >/dev/null 2>&1; then return 0 fi @@ -490,22 +661,41 @@ action_check() { DEADLINE=$(($(real_epoch) + BUDGET_SECS)) if [ -n "$BUDGET_CUT_FROM" ]; then - emit_finding "sweep budget ${BUDGET_CUT_FROM}s cut to ${BUDGET_SECS}s to stay inside the watcher check timeout of ${CHECK_TIMEOUT}s" + queue_finding "sweep budget ${BUDGET_CUT_FROM}s cut to ${BUDGET_SECS}s to stay inside the watcher check timeout of ${CHECK_TIMEOUT}s" fi - while IFS= read -r repo; do + discover_repos + for repo in $DISCOVERED_REPOS; do [ -n "$repo" ] || continue - budget_exhausted && break + if budget_exhausted; then + SWEEP_COMPLETE=0 + break + fi owner_team=$(owner_for_repo "$repo") - evaluate_repo "$repo" "$owner_team" || break - done < <(discover_repos) + evaluate_repo "$repo" "$owner_team" || { SWEEP_COMPLETE=0; break; } + done line= - if [ -n "$FINDINGS" ]; then - fm_cap_line_var "pr-conflict: $FINDINGS" "$MAX_LINE" + if [ "${#FINDING_TEXTS[@]}" -gt 0 ]; then + build_finding_line 0 + if [ "$FINDING_OMITTED" -gt 0 ]; then + # Reserve the widest the disclosure can be, so the second pass cannot be + # invalidated by the count it is making room for. + notice="; ${#FINDING_TEXTS[@]} more omitted (line cap)" + build_finding_line "${#notice}" + if [ "$FINDING_OMITTED" -gt 0 ]; then + FINDING_LINE="$FINDING_LINE; $FINDING_OMITTED more omitted (line cap)" + fi + fi + # A last cut through the shared rule, so an over-long single finding ends + # with the same visible truncation marker the digests use. + fm_cap_line_var "$FINDING_LINE" "$MAX_LINE" line=$FM_LINE_CAP_LINE fi + record_prune + record_add_keys "$FINDING_LINE_KEYS" + if [ -n "$line" ]; then printf '%s\n' "$line" fi diff --git a/bin/fm-repo-slug-lib.sh b/bin/fm-repo-slug-lib.sh new file mode 100644 index 00000000000..ff8589a0b25 --- /dev/null +++ b/bin/fm-repo-slug-lib.sh @@ -0,0 +1,19 @@ +# shellcheck shell=bash +# Shared GitHub owner/repo parser. +# Usage: . bin/fm-repo-slug-lib.sh; fm_repo_slug "" +# +# ONE OWNER for turning a GitHub remote or PR URL into its owner/repo slug. +# Two callers need the same answer from the same inputs: the bearings snapshot +# derives candidate repositories from recorded pr= URLs and live worktree +# origins (bin/fm-bearings-snapshot.sh), and the PR conflict watcher derives +# them from project clone origins (bin/fm-pr-conflict-watch.sh). A repository +# that one of them names differently than the other would route the same pull +# request two ways, so the parse lives here rather than in each caller. +# +# Both the https and ssh spellings are accepted, a trailing .git, a trailing +# slash, and a /pull/ tail are dropped, and a URL that is not GitHub yields +# the empty string rather than a guess. + +fm_repo_slug() { # + printf '%s' "$1" | sed -n 's#.*github\.com[:/]\([^/]*/[^/]*\)#\1#p' | sed 's#\.git$##; s#/pull/.*$##; s#/$##' +} diff --git a/docs/configuration.md b/docs/configuration.md index 47d80055bae..7b0abced06f 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -438,13 +438,16 @@ Draft pull requests are included; a conflicted draft is reported with `draft=yes Each wake line begins with `pr-conflict:` and carries `owner-team`, `repo`, `number`, `head`, `draft`, `url`, and `title` so firstmate can route without re-deriving ownership. Dedupe keys are repository, pull request number, and head SHA: the same conflict on the same head is reported once, while a force-updated head that conflicts again is a new event. GitHub computes mergeability lazily; the check polls short rereads when `mergeable` is `UNKNOWN` and treats a persistent `UNKNOWN` as unknown rather than clean or conflicted. +A reread that settles the state also names the head it settled for, so a branch force-updated between the listing and the reread is reported and deduped under the SHA that was actually judged. +A sweep that finds more conflicts than one line can carry reports the ones that fit and discloses the rest as `N more omitted (line cap)`; an omitted conflict is not marked as reported, so it wakes on a later sweep instead of being lost. +The dedupe record is cut back to the conflicts each sweep still observes, so it stays the size of the live conflict set rather than growing one entry per head forever. This is a safety net, not a cure: conflicts happen because pull requests wait unmerged while the default branch moves underneath them. `FM_PR_CONFLICT_INTERVAL` (default 300 seconds, `0` to probe on every run) sets how often sweeps run, `FM_PR_CONFLICT_PROBE_SECS` (default 5) bounds one GitHub call, and `FM_PR_CONFLICT_BUDGET_SECS` (default 20) bounds a whole sweep. `FM_PR_CONFLICT_UNKNOWN_ATTEMPTS` (default 3) and `FM_PR_CONFLICT_UNKNOWN_WAIT` (default 1 second) control lazy mergeability polling. `FM_PR_CONFLICT_PR_LIMIT` (default 30) caps open pull requests read per repository. -The sweep must finish inside `FM_CHECK_TIMEOUT` (default 30); a larger budget is cut to fit rather than refused. +The sweep must finish inside `FM_CHECK_TIMEOUT` (default 30); a larger budget is cut to fit rather than refused, and the `UNKNOWN` reread loop stops at the sweep deadline too, because a run the watcher kills prints and records nothing at all. ## Relay (.env) diff --git a/docs/scripts.md b/docs/scripts.md index 5408ce683d7..cbd310a6b4e 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -84,6 +84,7 @@ The shared no-mistakes gate refusal for fleet lifecycle entrypoints is summarize | `fm-nm-run-lib.sh` | Shared branch-and-code-identity attribution for no-mistakes runs | | `fm-tangle-lib.sh` | Shared default-branch resolution and primary-checkout tangle classification | | `fm-timeout-lib.sh` | Single owner of hard-bounded command execution and its fallback watchdog | +| `fm-repo-slug-lib.sh` | Single owner of the GitHub remote/PR URL to `owner/repo` parse shared by bearings and the PR conflict watch | | `fm-timing-lib.sh` | Single owner of the deferred network stage's per-step elapsed-time records, inert unless a run asks for them | | `fm-supervision-lib.sh` | Shared in-flight-work-without-fresh-watcher-beacon predicate | | `fm-ff-lib.sh` | Shared guarded fast-forward helper for origin pulls and local secondmate syncs | @@ -109,6 +110,7 @@ The shared no-mistakes gate refusal for fleet lifecycle entrypoints is summarize | `fm-check-register.sh` | Bind an intentional custom watcher check to its current bytes | | `fm-check-lib.sh` | Validate custom-check registrations and prepare private execution snapshots | | `fm-tool-update-check.sh` | Report watched tooling with an update available, and updates installed but left inert by PATH order | +| `fm-pr-conflict-watch.sh` | Report newly conflicted open pull requests across the fleet's repositories, routed to their owning target | | `fm-pr-lib.sh` | Own canonical task and PR validation plus private atomic PR-poll publication and identity-bound retirement | | `fm-pr-poll.sh` | Provide the byte-static watcher program for validated PR/MR-poll sidecars | | `fm-pr-check-migrate.sh` | Quarantine older task polls without execution and rebuild only canonical polls | diff --git a/tests/fm-pr-conflict-watch.test.sh b/tests/fm-pr-conflict-watch.test.sh index f9d45aecc3f..072e7e95ca1 100755 --- a/tests/fm-pr-conflict-watch.test.sh +++ b/tests/fm-pr-conflict-watch.test.sh @@ -121,16 +121,18 @@ write_list() { } write_view() { - local home=$1 repo=$2 number=$3 json + local home=$1 repo=$2 number=$3 json=$4 slug=${repo//\//__} printf '%s\n' "$json" > "$home/fixture/views/${slug}-${number}.json" } +# The fake gh-axi reads a scripted view sequence by array index, so the payloads +# are stored as one JSON array rather than as a stream of objects. write_view_sequence() { local home=$1 repo=$2 number=$3 slug=${repo//\//__} shift 3 - printf '%s\n' "$@" > "$home/fixture/views/${slug}-${number}.json" + printf '%s\n' "$@" | jq -s '.' > "$home/fixture/views/${slug}-${number}.json" printf '0\n' > "$home/fixture/views/${slug}-${number}.seq" } @@ -201,9 +203,132 @@ test_unknown_never_reported() { out="$home/out.txt" run_check "$home" "$out" env FM_PR_CONFLICT_UNKNOWN_ATTEMPTS=3 [ ! -s "$out" ] || fail "persistent UNKNOWN must stay silent: $(cat "$out")" + # The fake's cursor is how many rereads the check actually made, so a silent + # run that never reread is not mistaken for UNKNOWN being handled. + [ "$(cat "$home/fixture/views/acme__alpha-7.seq")" = 3 ] \ + || fail "UNKNOWN should be reread up to the attempt limit, got $(cat "$home/fixture/views/acme__alpha-7.seq")" pass "persistent UNKNOWN is never reported as conflicted or clean" } +test_unknown_resolving_to_conflicting_wakes() { + local home out + home=$(make_home wake-unknown-conflict) + write_list "$home" "$REPO_A" "[{\"number\":7,\"title\":\"Maybe\",\"url\":\"https://github.com/$REPO_A/pull/7\",\"headRefOid\":\"$HEAD_ONE\",\"isDraft\":false,\"mergeable\":\"UNKNOWN\"}]" + write_view_sequence "$home" "$REPO_A" 7 \ + "{\"mergeable\":\"UNKNOWN\",\"number\":7,\"title\":\"Maybe\",\"url\":\"https://github.com/$REPO_A/pull/7\",\"headRefOid\":\"$HEAD_ONE\",\"isDraft\":false}" \ + "{\"mergeable\":\"CONFLICTING\",\"number\":7,\"title\":\"Maybe\",\"url\":\"https://github.com/$REPO_A/pull/7\",\"headRefOid\":\"$HEAD_ONE\",\"isDraft\":false}" + out="$home/out.txt" + run_check "$home" "$out" env FM_PR_CONFLICT_UNKNOWN_ATTEMPTS=3 + assert_contains "$(cat "$out")" "number=7" "settled CONFLICTING must wake" + assert_contains "$(cat "$out")" "head=$HEAD_ONE" "settled wake carries the head" + : > "$out" + run_check "$home" "$out" env FM_PR_CONFLICT_UNKNOWN_ATTEMPTS=3 + [ ! -s "$out" ] || fail "a settled conflict must stay silent on the next poll: $(cat "$out")" + pass "UNKNOWN that settles on CONFLICTING wakes once" +} + +test_unknown_wake_uses_the_head_the_reread_judged() { + local home out + home=$(make_home wake-unknown-head) + write_list "$home" "$REPO_A" "[{\"number\":7,\"title\":\"Maybe\",\"url\":\"https://github.com/$REPO_A/pull/7\",\"headRefOid\":\"$HEAD_ONE\",\"isDraft\":false,\"mergeable\":\"UNKNOWN\"}]" + write_view "$home" "$REPO_A" 7 \ + "{\"mergeable\":\"CONFLICTING\",\"number\":7,\"title\":\"Maybe\",\"url\":\"https://github.com/$REPO_A/pull/7\",\"headRefOid\":\"$HEAD_TWO\",\"isDraft\":false}" + out="$home/out.txt" + run_check "$home" "$out" + assert_contains "$(cat "$out")" "head=$HEAD_TWO" "the wake must name the head the reread judged" + assert_not_contains "$(cat "$out")" "head=$HEAD_ONE" "the stale listed head must not label the wake" + # The next sweep sees the force-updated head as the listed one, and it is the + # same conflict that already woke, so it must not wake a second time. + write_list "$home" "$REPO_A" "[{\"number\":7,\"title\":\"Maybe\",\"url\":\"https://github.com/$REPO_A/pull/7\",\"headRefOid\":\"$HEAD_TWO\",\"isDraft\":false,\"mergeable\":\"CONFLICTING\"}]" + : > "$out" + run_check "$home" "$out" + [ ! -s "$out" ] || fail "the reread head must be what was recorded: $(cat "$out")" + pass "a reread wake is labelled and deduped by the head it judged" +} + +# A sweep that finds more conflicts than one line can carry must not record the +# ones it never printed: their heads do not change, so nothing would ever +# re-trigger them and they would be lost for good. +test_conflicts_past_the_line_cap_wake_on_a_later_sweep() { + local home out i rows='' seen='' round total=12 number + home=$(make_home wake-cap) + i=1 + while [ "$i" -le "$total" ]; do + [ -z "$rows" ] || rows="$rows," + rows="$rows{\"number\":$i,\"title\":\"Conflicted branch $i\",\"url\":\"https://github.com/$REPO_A/pull/$i\",\"headRefOid\":\"$HEAD_ONE\",\"isDraft\":false,\"mergeable\":\"CONFLICTING\"}" + i=$((i + 1)) + done + write_list "$home" "$REPO_A" "[$rows]" + out="$home/out.txt" + round=0 + while [ "$round" -lt 6 ]; do + : > "$out" + run_check "$home" "$out" + [ -s "$out" ] || break + [ "$(wc -l < "$out" | tr -d '[:space:]')" = 1 ] || fail "every wake must be one line" + while IFS= read -r number; do + seen="$seen $number" + done < <(tr ' ' '\n' < "$out" | sed -n 's/^number=\([0-9][0-9]*\)$/\1/p') + round=$((round + 1)) + done + [ "$round" -gt 1 ] || fail "12 conflicts cannot fit in one capped line; expected more than one wake" + i=1 + while [ "$i" -le "$total" ]; do + case " $seen " in + *" $i "*) ;; + *) fail "conflict $i was never reported across $round wakes" ;; + esac + i=$((i + 1)) + done + [ ! -s "$out" ] || fail "the fleet should fall silent once every conflict has woken" + pass "conflicts cut by the line cap wake on a later sweep instead of being lost" +} + +# The dedupe record is not accumulate-only: a conflict the sweep no longer +# observes is dropped, so the record stays the size of the live conflict set. +# The proof is behavioural - a dropped key cannot suppress the same conflict if +# it comes back. +test_resolved_conflicts_leave_the_record() { + local home out + home=$(make_home record-prune) + write_list "$home" "$REPO_A" "[{\"number\":7,\"title\":\"Broken\",\"url\":\"https://github.com/$REPO_A/pull/7\",\"headRefOid\":\"$HEAD_ONE\",\"isDraft\":false,\"mergeable\":\"CONFLICTING\"}]" + out="$home/out.txt" + run_check "$home" "$out" + [ -s "$out" ] || fail "first poll should wake" + write_list "$home" "$REPO_A" '[]' + : > "$out" + run_check "$home" "$out" + [ ! -s "$out" ] || fail "a closed pull request must not wake: $(cat "$out")" + # state/.pr-conflict-watch is this script's own persisted record; its + # fm-pr-conflict-watch-v1 shape is the contract being asserted. + assert_not_contains "$(cat "$home/state/.pr-conflict-watch")" "$HEAD_ONE" \ + "the record must not keep a key for a pull request it no longer sees" + write_list "$home" "$REPO_A" "[{\"number\":7,\"title\":\"Broken\",\"url\":\"https://github.com/$REPO_A/pull/7\",\"headRefOid\":\"$HEAD_ONE\",\"isDraft\":false,\"mergeable\":\"CONFLICTING\"}]" + : > "$out" + run_check "$home" "$out" + assert_contains "$(cat "$out")" "number=7" "a conflict that comes back must wake again" + pass "keys for conflicts the sweep no longer observes leave the record" +} + +# A run the watcher kills prints and records nothing, so the reread loop has to +# end at the sweep deadline rather than at its attempt count. +test_unknown_rereads_stop_at_the_sweep_deadline() { + local home out started elapsed + home=$(make_home unknown-deadline) + write_list "$home" "$REPO_A" "[{\"number\":7,\"title\":\"Maybe\",\"url\":\"https://github.com/$REPO_A/pull/7\",\"headRefOid\":\"$HEAD_ONE\",\"isDraft\":false,\"mergeable\":\"UNKNOWN\"}]" + write_view "$home" "$REPO_A" 7 \ + "{\"mergeable\":\"UNKNOWN\",\"number\":7,\"title\":\"Maybe\",\"url\":\"https://github.com/$REPO_A/pull/7\",\"headRefOid\":\"$HEAD_ONE\",\"isDraft\":false}" + out="$home/out.txt" + started=$(date +%s) + # Ten attempts five seconds apart is 55s of rereads on a one second budget. + run_check "$home" "$out" env FM_PR_CONFLICT_BUDGET_SECS=1 \ + FM_PR_CONFLICT_UNKNOWN_ATTEMPTS=10 FM_PR_CONFLICT_UNKNOWN_WAIT=5 + elapsed=$(( $(date +%s) - started )) + [ "$elapsed" -lt 15 ] || fail "the reread loop ran past the sweep deadline: ${elapsed}s" + [ ! -s "$out" ] || fail "an unsettled pull request must stay silent: $(cat "$out")" + pass "UNKNOWN rereads stop at the sweep deadline" +} + test_clean_fleet_is_silent() { local home out home=$(make_home wake-clean) @@ -244,6 +369,11 @@ test_newly_conflicted_wakes test_same_head_stays_silent test_new_head_after_force_update_wakes_again test_unknown_never_reported +test_unknown_resolving_to_conflicting_wakes +test_unknown_wake_uses_the_head_the_reread_judged +test_conflicts_past_the_line_cap_wake_on_a_later_sweep +test_resolved_conflicts_leave_the_record +test_unknown_rereads_stop_at_the_sweep_deadline test_clean_fleet_is_silent test_draft_conflicts_are_reported test_arm_registers_check From fdc681b6894663fd0f1aaa8e3130530895e15d95 Mon Sep 17 00:00:00 2001 From: bingb0t5 Date: Tue, 25 Aug 2026 10:31:05 +0700 Subject: [PATCH 3/7] no-mistakes(document): document PR conflict watch routing, silence, and record limits --- docs/configuration.md | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 7b0abced06f..0a099d4d809 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -426,13 +426,13 @@ It is detection and routing only: it never resolves conflicts, rebases branches, Repositories are derived from `data/projects.md` by reading each registered project's clone under `projects/` and parsing its `origin` remote into an `owner/repo` slug. The firstmate checkout's own `origin` remote is included too, so the captain's firstmate fork is covered without hardcoding its name. -Owning targets come from `data/secondmates.md`: each secondmate's `projects:` list maps registered project names to that secondmate's id, and the firstmate repository maps to `main`. -Unmapped projects fall back to `main`. +The `owner-team` a wake is routed to comes from `data/secondmates.md`: a registered project named in a secondmate's `projects:` list routes to that secondmate's id, the firstmate repository routes to `main`, and anything unmapped falls back to `main`. +That list stays the non-exclusive clone list the [`secondmate-provisioning` skill](../.agents/skills/secondmate-provisioning/SKILL.md#routing-table) defines; it decides where a conflict wake is delivered first, not who exclusively owns the work. Arm once per home with `bin/fm-pr-conflict-watch.sh arm`. That writes `state/pr-conflict-watch.check.sh` and binds its bytes with `bin/fm-check-register.sh`, so the watcher polls on its normal cadence and turns a newly conflicted pull request into one `check:` wake line. `bin/fm-pr-conflict-watch.sh disarm` removes the shim, trust binding, and dedupe record. -The check prints nothing when no new conflict exists. +The check prints nothing when no new conflict exists, apart from the cut-budget disclosure described below. Draft pull requests are included; a conflicted draft is reported with `draft=yes`. Each wake line begins with `pr-conflict:` and carries `owner-team`, `repo`, `number`, `head`, `draft`, `url`, and `title` so firstmate can route without re-deriving ownership. @@ -441,13 +441,18 @@ GitHub computes mergeability lazily; the check polls short rereads when `mergeab A reread that settles the state also names the head it settled for, so a branch force-updated between the listing and the reread is reported and deduped under the SHA that was actually judged. A sweep that finds more conflicts than one line can carry reports the ones that fit and discloses the rest as `N more omitted (line cap)`; an omitted conflict is not marked as reported, so it wakes on a later sweep instead of being lost. The dedupe record is cut back to the conflicts each sweep still observes, so it stays the size of the live conflict set rather than growing one entry per head forever. +A repository the sweep never reached, or whose open list was cut short by `FM_PR_CONFLICT_PR_LIMIT`, keeps its recorded keys instead: unread is not the same as resolved, and dropping those keys would re-report every one of them. This is a safety net, not a cure: conflicts happen because pull requests wait unmerged while the default branch moves underneath them. +Silence is not a proof that every repository was checked, either. +A sweep that runs out of budget stops where it is and says nothing about the repositories it did not reach, and the sweep order is fixed, so a fleet too large for one budget leaves the same tail of repositories unswept on every poll. +Raise `FM_PR_CONFLICT_BUDGET_SECS` with `FM_CHECK_TIMEOUT` when the fleet outgrows one sweep. `FM_PR_CONFLICT_INTERVAL` (default 300 seconds, `0` to probe on every run) sets how often sweeps run, `FM_PR_CONFLICT_PROBE_SECS` (default 5) bounds one GitHub call, and `FM_PR_CONFLICT_BUDGET_SECS` (default 20) bounds a whole sweep. `FM_PR_CONFLICT_UNKNOWN_ATTEMPTS` (default 3) and `FM_PR_CONFLICT_UNKNOWN_WAIT` (default 1 second) control lazy mergeability polling. `FM_PR_CONFLICT_PR_LIMIT` (default 30) caps open pull requests read per repository. The sweep must finish inside `FM_CHECK_TIMEOUT` (default 30); a larger budget is cut to fit rather than refused, and the `UNKNOWN` reread loop stops at the sweep deadline too, because a run the watcher kills prints and records nothing at all. +A cut budget is disclosed at the head of the line, and it is the one thing this check reports without a conflict behind it; because it describes a setting rather than an event, it repeats on every sweep until the two settings agree. ## Relay (.env) From 7d167df99e000741a548090ebd4fa823dd169859 Mon Sep 17 00:00:00 2001 From: bingb0t5 Date: Tue, 25 Aug 2026 10:40:00 +0700 Subject: [PATCH 4/7] no-mistakes: apply CI fixes --- bin/fm-pr-conflict-watch.sh | 6 ++++-- docs/configuration.md | 1 + tests/fm-pr-conflict-watch.test.sh | 24 ++++++++++++++++++++++++ 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/bin/fm-pr-conflict-watch.sh b/bin/fm-pr-conflict-watch.sh index fc74674c5f8..50fbe17eaea 100755 --- a/bin/fm-pr-conflict-watch.sh +++ b/bin/fm-pr-conflict-watch.sh @@ -484,7 +484,9 @@ record_remove_key() { # that page carried; a repository the sweep did not finish keeps all of its # keys, because absence there means unread rather than resolved. Keys for # repositories this fleet no longer works in are dropped only when the sweep -# reached every discovered repository. +# reached every discovered repository and discovery named at least one: a sweep +# that discovered nothing inspected nothing, so its empty repository set is a +# failed read rather than proof the fleet stopped working in those repositories. seen_key() { local key=$1 case ";$SEEN_KEYS;" in @@ -505,7 +507,7 @@ record_prune() { esac ;; *) - if [ "$SWEEP_COMPLETE" -eq 1 ]; then + if [ "$SWEEP_COMPLETE" -eq 1 ] && [ -n "$DISCOVERED_REPOS" ]; then case " $DISCOVERED_REPOS " in *" $repo "*) ;; *) continue ;; diff --git a/docs/configuration.md b/docs/configuration.md index 0a099d4d809..ed052634a77 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -442,6 +442,7 @@ A reread that settles the state also names the head it settled for, so a branch A sweep that finds more conflicts than one line can carry reports the ones that fit and discloses the rest as `N more omitted (line cap)`; an omitted conflict is not marked as reported, so it wakes on a later sweep instead of being lost. The dedupe record is cut back to the conflicts each sweep still observes, so it stays the size of the live conflict set rather than growing one entry per head forever. A repository the sweep never reached, or whose open list was cut short by `FM_PR_CONFLICT_PR_LIMIT`, keeps its recorded keys instead: unread is not the same as resolved, and dropping those keys would re-report every one of them. +A sweep that discovers no repositories at all keeps the whole record for the same reason: an empty repository set means discovery failed to read anything, not that this home stopped working in those repositories. This is a safety net, not a cure: conflicts happen because pull requests wait unmerged while the default branch moves underneath them. Silence is not a proof that every repository was checked, either. diff --git a/tests/fm-pr-conflict-watch.test.sh b/tests/fm-pr-conflict-watch.test.sh index 072e7e95ca1..6f2a006f64c 100755 --- a/tests/fm-pr-conflict-watch.test.sh +++ b/tests/fm-pr-conflict-watch.test.sh @@ -310,6 +310,29 @@ test_resolved_conflicts_leave_the_record() { pass "keys for conflicts the sweep no longer observes leave the record" } +# A sweep that discovers no repositories at all read nothing, so it must not be +# taken as proof that the fleet stopped working in the repositories the record +# names. Erasing the record there would re-wake every unchanged conflict as soon +# as discovery recovered. +test_empty_discovery_keeps_the_record() { + local home out + home=$(make_home discovery-outage) + write_list "$home" "$REPO_A" "[{\"number\":7,\"title\":\"Broken\",\"url\":\"https://github.com/$REPO_A/pull/7\",\"headRefOid\":\"$HEAD_ONE\",\"isDraft\":false,\"mergeable\":\"CONFLICTING\"}]" + out="$home/out.txt" + run_check "$home" "$out" + [ -s "$out" ] || fail "first poll should wake" + # Discovery yields nothing: no projects.md to read and no firstmate checkout + # to take an origin from. Both reads fail, exactly as a transient outage would. + : > "$out" + run_check "$home" "$out" env FM_DATA_OVERRIDE="$home/data-gone" \ + FM_ROOT_OVERRIDE="$home/root-gone" + [ ! -s "$out" ] || fail "a sweep that discovered nothing must stay silent: $(cat "$out")" + : > "$out" + run_check "$home" "$out" + [ ! -s "$out" ] || fail "the unchanged conflict must not wake again after discovery recovers: $(cat "$out")" + pass "an empty discovery sweep does not erase the dedupe record" +} + # A run the watcher kills prints and records nothing, so the reread loop has to # end at the sweep deadline rather than at its attempt count. test_unknown_rereads_stop_at_the_sweep_deadline() { @@ -373,6 +396,7 @@ test_unknown_resolving_to_conflicting_wakes test_unknown_wake_uses_the_head_the_reread_judged test_conflicts_past_the_line_cap_wake_on_a_later_sweep test_resolved_conflicts_leave_the_record +test_empty_discovery_keeps_the_record test_unknown_rereads_stop_at_the_sweep_deadline test_clean_fleet_is_silent test_draft_conflicts_are_reported From a1bf474a61d973b9d9d5e289f7b1cdbb2eb82f03 Mon Sep 17 00:00:00 2001 From: bingb0t5 Date: Tue, 25 Aug 2026 10:52:08 +0700 Subject: [PATCH 5/7] no-mistakes: apply CI fixes --- tests/fm-pr-conflict-watch.test.sh | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/tests/fm-pr-conflict-watch.test.sh b/tests/fm-pr-conflict-watch.test.sh index 6f2a006f64c..e4273d0fff4 100755 --- a/tests/fm-pr-conflict-watch.test.sh +++ b/tests/fm-pr-conflict-watch.test.sh @@ -148,6 +148,33 @@ run_check() { expect_code 0 "$status" "check exit" } +# The watcher and the bearings snapshot must name the same repository from the +# same remote, so the shared parser they both source is exercised directly here: +# a slug that differed between them would route one pull request two ways. +test_repo_slug_parses_github_remotes() { + local got + # shellcheck source=bin/fm-repo-slug-lib.sh + # shellcheck disable=SC1091 + . "$ROOT/bin/fm-repo-slug-lib.sh" + local url expected + while IFS='|' read -r url expected; do + [ -n "$url" ] || continue + got=$(fm_repo_slug "$url") + [ "$got" = "$expected" ] \ + || fail "fm_repo_slug '$url' expected '$expected', got '$got'" + done <<'CASES' +https://github.com/acme/alpha.git|acme/alpha +https://github.com/acme/alpha|acme/alpha +https://github.com/acme/alpha/|acme/alpha +https://github.com/acme/alpha/pull/12|acme/alpha +git@github.com:acme/alpha.git|acme/alpha +ssh://git@github.com/acme/alpha.git|acme/alpha +https://gitlab.com/acme/alpha.git| +/home/example/projects/alpha| +CASES + pass "the shared remote parser resolves GitHub slugs and refuses to guess" +} + test_newly_conflicted_wakes() { local home out home=$(make_home wake-new) @@ -388,6 +415,7 @@ test_arm_registers_check() { pass "arm writes and registers the watcher check" } +test_repo_slug_parses_github_remotes test_newly_conflicted_wakes test_same_head_stays_silent test_new_head_after_force_update_wakes_again From e898b9500dac40cd4e1257792d59f0649e5a850d Mon Sep 17 00:00:00 2001 From: bingb0t5 Date: Tue, 25 Aug 2026 11:04:24 +0700 Subject: [PATCH 6/7] no-mistakes: apply CI fixes --- bin/fm-pr-conflict-watch.sh | 22 +++++++--- docs/configuration.md | 3 +- tests/fm-pr-conflict-watch.test.sh | 65 ++++++++++++++++++++++++++++++ 3 files changed, 83 insertions(+), 7 deletions(-) diff --git a/bin/fm-pr-conflict-watch.sh b/bin/fm-pr-conflict-watch.sh index 50fbe17eaea..0f1e953f5b8 100755 --- a/bin/fm-pr-conflict-watch.sh +++ b/bin/fm-pr-conflict-watch.sh @@ -200,6 +200,7 @@ FIRSTMATE_SLUG= SWEPT_REPOS= SEEN_KEYS= SWEEP_COMPLETE=1 +DISCOVERY_COMPLETE=1 RESOLVED_STATE= RESOLVED_HEAD= @@ -366,11 +367,16 @@ discover_repos() { local project slug owner DISCOVERED_REPOS= REPO_OWNER_MAP= + DISCOVERY_COMPLETE=1 load_project_owners FIRSTMATE_SLUG=$(firstmate_repo_slug 2>/dev/null) || FIRSTMATE_SLUG= + [ -n "$FIRSTMATE_SLUG" ] || DISCOVERY_COMPLETE=0 while IFS= read -r project; do [ -n "$project" ] || continue - slug=$(resolve_project_repo "$project" 2>/dev/null) || continue + # A project whose clone or origin cannot be read names no slug, so the sweep + # cannot tell which repository went unread. That leaves the whole discovery + # partial rather than this one entry skippable. + slug=$(resolve_project_repo "$project" 2>/dev/null) || { DISCOVERY_COMPLETE=0; continue; } case " $DISCOVERED_REPOS " in *" $slug "*) continue ;; esac @@ -483,10 +489,13 @@ record_remove_key() { # forever. A repository whose whole open-PR page was read keeps only the keys # that page carried; a repository the sweep did not finish keeps all of its # keys, because absence there means unread rather than resolved. Keys for -# repositories this fleet no longer works in are dropped only when the sweep -# reached every discovered repository and discovery named at least one: a sweep -# that discovered nothing inspected nothing, so its empty repository set is a -# failed read rather than proof the fleet stopped working in those repositories. +# repositories this fleet no longer works in are dropped only when discovery +# resolved every project it enumerated and the sweep then reached every +# repository discovery named. A discovery that skipped a project whose clone or +# origin could not be read - including the degenerate case where it resolved +# nothing at all - left behind an unread repository it cannot even name, so +# absence from its repository set is a failed read rather than proof the fleet +# stopped working in those repositories. seen_key() { local key=$1 case ";$SEEN_KEYS;" in @@ -507,7 +516,8 @@ record_prune() { esac ;; *) - if [ "$SWEEP_COMPLETE" -eq 1 ] && [ -n "$DISCOVERED_REPOS" ]; then + if [ "$SWEEP_COMPLETE" -eq 1 ] && [ "$DISCOVERY_COMPLETE" -eq 1 ] \ + && [ -n "$DISCOVERED_REPOS" ]; then case " $DISCOVERED_REPOS " in *" $repo "*) ;; *) continue ;; diff --git a/docs/configuration.md b/docs/configuration.md index ed052634a77..085fc671f41 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -442,7 +442,8 @@ A reread that settles the state also names the head it settled for, so a branch A sweep that finds more conflicts than one line can carry reports the ones that fit and discloses the rest as `N more omitted (line cap)`; an omitted conflict is not marked as reported, so it wakes on a later sweep instead of being lost. The dedupe record is cut back to the conflicts each sweep still observes, so it stays the size of the live conflict set rather than growing one entry per head forever. A repository the sweep never reached, or whose open list was cut short by `FM_PR_CONFLICT_PR_LIMIT`, keeps its recorded keys instead: unread is not the same as resolved, and dropping those keys would re-report every one of them. -A sweep that discovers no repositories at all keeps the whole record for the same reason: an empty repository set means discovery failed to read anything, not that this home stopped working in those repositories. +A sweep whose repository discovery was incomplete keeps the whole record for the same reason: a registered project whose clone or `origin` remote could not be read this sweep names no repository at all, so the repositories discovery did name are not the full set this home works in. +That covers the degenerate case where discovery reads nothing as well as the partial case where it reads some clones and fails on others. This is a safety net, not a cure: conflicts happen because pull requests wait unmerged while the default branch moves underneath them. Silence is not a proof that every repository was checked, either. diff --git a/tests/fm-pr-conflict-watch.test.sh b/tests/fm-pr-conflict-watch.test.sh index e4273d0fff4..82e8c957ae7 100755 --- a/tests/fm-pr-conflict-watch.test.sh +++ b/tests/fm-pr-conflict-watch.test.sh @@ -360,6 +360,69 @@ test_empty_discovery_keeps_the_record() { pass "an empty discovery sweep does not erase the dedupe record" } +# Discovery that reads some repositories and fails on others is partial, not +# empty: a clone whose origin cannot be read this sweep was never inspected, so +# its absence from the discovered set says nothing about whether the fleet still +# works in it. Pruning its keys there would re-wake every unchanged conflict it +# holds as soon as the clone resolves again. +test_partial_discovery_keeps_the_record() { + local home out + home=$(make_home discovery-partial) + write_list "$home" "$REPO_A" "[{\"number\":7,\"title\":\"Broken A\",\"url\":\"https://github.com/$REPO_A/pull/7\",\"headRefOid\":\"$HEAD_ONE\",\"isDraft\":false,\"mergeable\":\"CONFLICTING\"}]" + write_list "$home" "$REPO_B" "[{\"number\":9,\"title\":\"Broken B\",\"url\":\"https://github.com/$REPO_B/pull/9\",\"headRefOid\":\"$HEAD_TWO\",\"isDraft\":false,\"mergeable\":\"CONFLICTING\"}]" + out="$home/out.txt" + run_check "$home" "$out" + assert_contains "$(cat "$out")" "repo=$REPO_A" "first poll should wake for alpha" + assert_contains "$(cat "$out")" "repo=$REPO_B" "first poll should wake for beta" + # The beta clone goes away while the alpha clone still resolves, exactly as a + # transient unreadable checkout would leave it. + mv "$home/projects/beta" "$home/projects/beta.away" + : > "$out" + run_check "$home" "$out" + [ ! -s "$out" ] || fail "a partial discovery sweep must stay silent: $(cat "$out")" + mv "$home/projects/beta.away" "$home/projects/beta" + : > "$out" + run_check "$home" "$out" + [ ! -s "$out" ] || fail "the unchanged beta conflict must not wake again after its clone resolves: $(cat "$out")" + pass "a partial discovery sweep does not erase the unread repository's record" +} + +# The other side of the same rule: when discovery resolved every project it +# enumerated, a repository missing from the result is one this home genuinely +# stopped working in, and its keys are dropped so the record stays the size of +# the live conflict set. Retaining them for an unread repository must not turn +# into retaining them forever. +test_deregistered_project_leaves_the_record() { + local home out + home=$(make_home discovery-deregistered) + write_list "$home" "$REPO_A" "[{\"number\":7,\"title\":\"Broken A\",\"url\":\"https://github.com/$REPO_A/pull/7\",\"headRefOid\":\"$HEAD_ONE\",\"isDraft\":false,\"mergeable\":\"CONFLICTING\"}]" + write_list "$home" "$REPO_B" "[{\"number\":9,\"title\":\"Broken B\",\"url\":\"https://github.com/$REPO_B/pull/9\",\"headRefOid\":\"$HEAD_TWO\",\"isDraft\":false,\"mergeable\":\"CONFLICTING\"}]" + out="$home/out.txt" + run_check "$home" "$out" + assert_contains "$(cat "$out")" "repo=$REPO_B" "first poll should wake for beta" + # beta is deregistered while every project still listed resolves, so discovery + # is complete and beta is genuinely gone rather than unread. + cat > "$home/data/projects.md" <<'MD' +# Projects + +- alpha - alpha repo (added 2026-08-25) +MD + : > "$out" + run_check "$home" "$out" + [ ! -s "$out" ] || fail "a deregistered project must not wake: $(cat "$out")" + cat > "$home/data/projects.md" <<'MD' +# Projects + +- alpha - alpha repo (added 2026-08-25) +- beta - beta repo (added 2026-08-25) +MD + : > "$out" + run_check "$home" "$out" + assert_contains "$(cat "$out")" "repo=$REPO_B" \ + "a re-registered project's conflict is a fresh event and must wake again" + pass "keys for a repository this home stopped working in leave the record" +} + # A run the watcher kills prints and records nothing, so the reread loop has to # end at the sweep deadline rather than at its attempt count. test_unknown_rereads_stop_at_the_sweep_deadline() { @@ -425,6 +488,8 @@ test_unknown_wake_uses_the_head_the_reread_judged test_conflicts_past_the_line_cap_wake_on_a_later_sweep test_resolved_conflicts_leave_the_record test_empty_discovery_keeps_the_record +test_partial_discovery_keeps_the_record +test_deregistered_project_leaves_the_record test_unknown_rereads_stop_at_the_sweep_deadline test_clean_fleet_is_silent test_draft_conflicts_are_reported From c382a2d9fd3ddc4ad591bc066e3c2fe2ea4b5ddb Mon Sep 17 00:00:00 2001 From: bingb0t5 Date: Tue, 25 Aug 2026 11:35:55 +0700 Subject: [PATCH 7/7] no-mistakes: apply CI fixes --- bin/fm-pr-conflict-watch.sh | 27 ++++++++++++++++++--------- docs/configuration.md | 2 +- tests/fm-pr-conflict-watch.test.sh | 26 ++++++++++++++++++++++++++ 3 files changed, 45 insertions(+), 10 deletions(-) diff --git a/bin/fm-pr-conflict-watch.sh b/bin/fm-pr-conflict-watch.sh index 0f1e953f5b8..189d3a52296 100755 --- a/bin/fm-pr-conflict-watch.sh +++ b/bin/fm-pr-conflict-watch.sh @@ -291,8 +291,11 @@ firstmate_repo_slug() { fm_repo_slug "$url" } +# A registry that cannot be read enumerates nothing, which reads exactly like a +# home that registered no projects. The difference matters to record pruning, so +# the unreadable case is reported as a failure rather than as an empty list. project_names() { - [ -f "$DATA/projects.md" ] || return 0 + [ -r "$DATA/projects.md" ] || return 1 awk '$1 == "-" && $2 != "" { print $2 }' "$DATA/projects.md" } @@ -364,13 +367,16 @@ owner_for_project() { # neither the origin remotes nor the registry are re-read per repository while # the same budget is paying for GitHub probes. discover_repos() { - local project slug owner + local project slug owner names DISCOVERED_REPOS= REPO_OWNER_MAP= DISCOVERY_COMPLETE=1 load_project_owners FIRSTMATE_SLUG=$(firstmate_repo_slug 2>/dev/null) || FIRSTMATE_SLUG= [ -n "$FIRSTMATE_SLUG" ] || DISCOVERY_COMPLETE=0 + # An unread registry leaves every project repository unnamed, so the firstmate + # origin still resolving does not make this discovery complete. + names=$(project_names 2>/dev/null) || { names=; DISCOVERY_COMPLETE=0; } while IFS= read -r project; do [ -n "$project" ] || continue # A project whose clone or origin cannot be read names no slug, so the sweep @@ -389,7 +395,9 @@ discover_repos() { DISCOVERED_REPOS="$DISCOVERED_REPOS $slug" REPO_OWNER_MAP="$REPO_OWNER_MAP$slug $owner " - done < <(project_names) + done < "$out" + run_check "$home" "$out" + [ ! -s "$out" ] || fail "a sweep with no readable registry must stay silent: $(cat "$out")" + mv "$home/data/projects.md.away" "$home/data/projects.md" + : > "$out" + run_check "$home" "$out" + [ ! -s "$out" ] || fail "the unchanged alpha conflict must not wake again after the registry returns: $(cat "$out")" + pass "a sweep with an unreadable project registry does not erase the record" +} + # The other side of the same rule: when discovery resolved every project it # enumerated, a repository missing from the result is one this home genuinely # stopped working in, and its keys are dropped so the record stays the size of @@ -489,6 +514,7 @@ test_conflicts_past_the_line_cap_wake_on_a_later_sweep test_resolved_conflicts_leave_the_record test_empty_discovery_keeps_the_record test_partial_discovery_keeps_the_record +test_unreadable_registry_keeps_the_record test_deregistered_project_leaves_the_record test_unknown_rereads_stop_at_the_sweep_deadline test_clean_fleet_is_silent