Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 115 additions & 0 deletions .claude/scripts/loop-census.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
#!/usr/bin/env bash
# loop-census.sh — one-shot STEP 0 census for the PR loop (base and self-hosted).
# Prints, as stable key=value telemetry, everything a tick needs to decide
# whether it can ACT — so the actionability check is a single pre-approvable
# command instead of a discipline the tick can silently skip:
#
# open_prs=N open PRs against the adapter's base branch
# feedback_prs=N bot PRs with unaddressed CHANGES_REQUESTED (pr-feedback.sh)
# planned_issues=N open issues labelled `planned` AND one of the
# adapter's module:* labels, one detail line each:
# issue=<n> branch=<feat/issue-n-* or none> title=<title>
# in_flight=<n> one line PER planned issue that has a
# feat/issue-n-* branch (local or remote) but NO
# open PR for it yet — i.e. work has started but
# hasn't reached PR stage. A tick uses this to
# avoid double-spawning an orchestrator for an
# issue that already has a worktree in progress.
# advance_ready=<n|none> lowest-numbered planned issue with no branch,
# only when open_prs=0 (the ADVANCE precondition)
# cadence=FAST|WATCH|IDLE cron=<expr> desired cadence per the loop policy
#
# The module label set is derived from $GATES_FILE (default .claude/gates.json)
# → modules[].name, so the same script serves the self-hosted loop
# (GATES_FILE=.claude/self/gates.json) and downstream adopters.
#
# WHY THIS EXISTS (issue: loop stalled 13h with two planned issues): ticks that
# "optimized" STEP 0 away — or piped the cursor-advancing notify-poll.sh through
# `tail -1` — reported "No actionable activity" while ADVANCE work sat ready.
# A tick may claim "No actionable activity" ONLY when this census prints zeros.
#
# Repo derived from the git remote; override with $1. Bot login via $BOT_LOGIN.
# Invoke as `bash .claude/scripts/loop-census.sh` (pre-approve that exact
# command). Read-only: advances no cursor, mutates nothing — safe to re-run.
set -euo pipefail

# Two-root derivation (issue #63): script_dir = sibling scripts, root = consumer project.
# shellcheck source=resolve-roots.sh
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/resolve-roots.sh"
# Route EVERY gh call through the bot identity (see bot-gh.sh).
gh() { bash "$script_dir/bot-gh.sh" "$@"; }
repo="${1:-$(gh repo view --json nameWithOwner -q .nameWithOwner)}"

gates_rel="${GATES_FILE:-.claude/gates.json}"
case "$gates_rel" in /*) gates="$gates_rel" ;; *) gates="$root/$gates_rel" ;; esac

# Adapter-derived facts: base branch + the module:* label set.
base=$(node -e 'const g=require(process.argv[1]); console.log((g.merge&&g.merge.baseBranch)||"main")' "$gates")
module_labels=$(node -e 'const g=require(process.argv[1]); console.log(g.modules.map(m=>"module:"+m.name).join("\n"))' "$gates")

open_prs=$(gh pr list -R "$repo" --state open --base "$base" --json number --jq 'length')
echo "open_prs=$open_prs"

# Head branch names of every open PR (against base) — used below to tell
# in_flight (branch exists, no PR yet) apart from already-at-PR-stage.
open_pr_branches=$(gh pr list -R "$repo" --state open --base "$base" --json headRefName --jq '.[].headRefName')

feedback_prs=$(bash "$script_dir/pr-feedback.sh" "$repo" | grep -c . || true)
echo "feedback_prs=$feedback_prs"

# Open `planned` issues carrying any of the adapter's module labels, ascending.
planned=$(gh issue list -R "$repo" --state open --label planned --json number,title,labels \
--jq '.[] | [.number, ([.labels[].name]|join(",")), .title] | @tsv' | sort -n)

planned_count=0
advance_ready="none"
detail=""
in_flight=""
while IFS=$'\t' read -r num labels title; do
[ -z "${num:-}" ] && continue
hit=0
while IFS= read -r ml; do
case ",$labels," in *",$ml,"*) hit=1; break;; esac
done <<< "$module_labels"
[ "$hit" -eq 1 ] || continue
planned_count=$((planned_count + 1))
# Existing feat/issue-<n>-* branch (local or remote) means it's already in flight.
# NOTE: `| head -1` can make `git` see SIGPIPE (exit 141) if head closes the
# pipe before git finishes writing; under `set -euo pipefail` that would abort
# this whole script. `|| true` on the assignment absorbs that non-fatal
# pipeline failure — the captured output (head's one line) is unaffected.
branch=$(git -C "$root" branch -a --list "*feat/issue-$num-*" | head -1 | sed 's/^[* ]*//;s|^remotes/||') || true
[ -n "$branch" ] || branch="none"
detail+="issue=$num branch=$branch title=$title"$'\n'
if [ "$advance_ready" = "none" ] && [ "$branch" = "none" ] && [ "$open_prs" -eq 0 ]; then
advance_ready="$num"
fi
# in_flight: a branch exists for this issue but no open PR carries it yet
# (branch may be printed with a "origin/" remote prefix above; strip it —
# or match it as a "/"-suffix — before comparing against headRefName, which
# is always the bare branch name).
if [ "$branch" != "none" ]; then
has_open_pr=0
while IFS= read -r b; do
[ -z "$b" ] && continue
case "$branch" in
"$b"|*"/$b") has_open_pr=1; break ;;
esac
done <<< "$open_pr_branches"
[ "$has_open_pr" -eq 1 ] || in_flight+="in_flight=$num"$'\n'
fi
done <<< "$planned"

echo "planned_issues=$planned_count"
[ -n "$detail" ] && printf '%s' "$detail"
[ -n "$in_flight" ] && printf '%s' "$in_flight"
echo "advance_ready=$advance_ready"

# Desired cadence per the loop policy: FAST only when the loop can ACT now.
if [ "$feedback_prs" -ge 1 ] || { [ "$open_prs" -eq 0 ] && [ "$planned_count" -ge 1 ]; }; then
echo 'cadence=FAST cron=* * * * *'
elif [ "$open_prs" -ge 1 ]; then
echo 'cadence=WATCH cron=*/5 * * * *'
else
echo 'cadence=IDLE cron=*/15 * * * *'
fi
174 changes: 174 additions & 0 deletions .claude/scripts/loop-census.test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
#!/usr/bin/env bash
# loop-census.test.sh — offline smoke test for loop-census.sh's in_flight
# detection (issue #81 re-review, finding 5).
#
# loop-tick.test.sh exercises loop-tick.sh against a FAKE loop-census.sh that
# just echoes canned `in_flight=N` lines — it never runs loop-census.sh's own
# branch-detection algorithm. This test closes that gap: it runs the REAL
# loop-census.sh (+ real resolve-roots.sh) against a REAL git repo with real
# local and remote-tracking branches, stubbing only `gh` (via a fake
# bot-gh.sh) and pr-feedback.sh (no network, no gh CLI required), and asserts
# on the actual `in_flight=`/`branch=` lines the real algorithm prints.
#
# Specifically covers the two failure modes called out in re-review:
#
# - PREFIX COLLISION: issue 4 has NO branch of its own, while issue 42 and
# issue 43 DO (as "issue-4" is a literal prefix of "issue-42"/"issue-43").
# A glob without the trailing "-" (`*feat/issue-4*` instead of
# `*feat/issue-4-*`) would make `git branch -a --list` for issue 4 also
# match issue 42's/43's branches; since issue 4 has no LOCAL branch of
# its own to sort first, `head -1` would then wrongly attribute one of
# THEIR branches to issue 4. Asserted directly: issue 4 must come back
# branch=none despite 42/43 existing.
#
# - "remotes/origin/" HANDLING: issue 42's and 43's branches exist ONLY as
# remote-tracking refs (pushed, then the local branch deleted), so
# `git branch -a` reports them as "remotes/origin/feat/issue-4N-*".
# Issue 43 additionally already has an open PR under its BARE branch
# name (`feat/issue-43-z`, no "origin/" prefix, matching a real
# `headRefName`) — that must still register as "already has a PR" (not
# in_flight) via the "*/<bare>" suffix rule, not just an exact-string
# match; issue 100's LOCAL (non-remote) branch with an open PR is the
# control for the exact-match path.
#
# Exit 0 on success, non-zero if any assertion fails. Runnable bare:
# bash .claude/scripts/loop-census.test.sh
set -uo pipefail

script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
census_src="$script_dir/loop-census.sh"
resolve_roots_src="$script_dir/resolve-roots.sh"

work="$(mktemp -d "${TMPDIR:-/tmp}/loop-census-test.XXXXXX")"
trap 'rm -rf "$work"' EXIT

fail=0
ok=0
check() {
local desc="$1"; shift
if "$@"; then
ok=$((ok + 1))
echo "ok - $desc"
else
fail=1
echo "FAIL - $desc"
fi
}

# ---------------------------------------------------------------------------
# Build one fixture: a real git repo (fixture root = census's $root) with:
# - issue 4: NO branch at all -> branch=none. Must not be fooled by
# issue 42's/43's branches, whose names have "issue-4" as a
# literal prefix.
# - issue 42: a REMOTE-tracking-only branch feat/issue-42-y (pushed, local
# copy deleted), no open PR for it -> MUST be in_flight.
# - issue 43: a REMOTE-tracking-only branch feat/issue-43-z, which
# ALREADY has an open PR under its bare name -> must NOT be
# in_flight (the "remotes/origin/" strip + "*/<bare>" suffix
# match on the POSITIVE path).
# - issue 100: a LOCAL branch feat/issue-100-w, which ALREADY has an open
# PR under its bare (exact, no prefix) name -> must NOT be
# in_flight (the plain exact-match control case).
# ---------------------------------------------------------------------------
fixture="$work/fixture1"
scripts_dir="$fixture/.claude/scripts"
mkdir -p "$scripts_dir"
cp "$census_src" "$scripts_dir/loop-census.sh"
cp "$resolve_roots_src" "$scripts_dir/resolve-roots.sh"

# Minimal adapter: one module, so "module:test" is the only label census cares
# about; base branch is "main" to match the repo below.
cat > "$fixture/.claude/gates.json" <<'EOF'
{
"modules": [{ "name": "test", "path": ".", "description": "", "owner": "" }],
"merge": { "baseBranch": "main" }
}
EOF

# pr-feedback.sh is exercised by its own test (loop-tick.test.sh); here it's
# just a no-op stub so census's feedback_prs line is deterministic (0).
cat > "$scripts_dir/pr-feedback.sh" <<'EOF'
#!/usr/bin/env bash
exit 0
EOF

# Fake bot-gh.sh: no network, no real `gh` — dispatches on the subcommand and
# a `--json` marker to canned, fixture-appropriate output.
# - `pr list ... --json headRefName ...`: bare branch names of open PRs —
# issues 43 and 100 already have one; issue 42 does not (issue 4 has no
# branch, so it can't have a PR either).
# - `pr list ... --json number ...`: open PR count (2, matching above).
# - `issue list ...`: TSV `num<TAB>labels<TAB>title`
# for the four planned+module:test issues.
cat > "$scripts_dir/bot-gh.sh" <<'EOF'
#!/usr/bin/env bash
case "$1" in
repo) echo "acme/repo" ;;
pr)
if printf '%s\n' "$*" | grep -q 'headRefName'; then
printf '%s\n' "feat/issue-43-z"
printf '%s\n' "feat/issue-100-w"
else
echo 2
fi
;;
issue)
printf '4\tplanned,module:test\tIssue four\n'
printf '42\tplanned,module:test\tIssue forty two\n'
printf '43\tplanned,module:test\tIssue forty three\n'
printf '100\tplanned,module:test\tIssue one hundred\n'
;;
*) echo "fake-bot-gh.sh: unhandled args: $*" >&2; exit 1 ;;
esac
EOF
chmod +x "$scripts_dir"/*.sh

# Real git repo at the fixture root (census does `git -C "$root" branch -a`).
git -C "$fixture" init -q -b main
git -C "$fixture" -c user.email=t@e.st -c user.name=t commit -q --allow-empty -m init

# Bare "remote" so `git branch -a` prints genuine "remotes/origin/..." lines.
remote="$work/remote.git"
git init -q --bare "$remote"
git -C "$fixture" remote add origin "$remote"

# issue 4: deliberately NO branch at all (see the prefix-collision note above).

# issue 42 and 43: pushed to origin, then the LOCAL copy is deleted so only
# the "remotes/origin/..." remote-tracking ref remains — this is the case
# census's "strip remotes/ or match as /-suffix" logic exists for.
git -C "$fixture" branch feat/issue-42-y main >/dev/null
git -C "$fixture" push -q origin feat/issue-42-y >/dev/null 2>&1
git -C "$fixture" branch -D feat/issue-42-y >/dev/null

git -C "$fixture" branch feat/issue-43-z main >/dev/null
git -C "$fixture" push -q origin feat/issue-43-z >/dev/null 2>&1
git -C "$fixture" branch -D feat/issue-43-z >/dev/null

# issue 100: LOCAL-only branch (never pushed) — exact-match control.
git -C "$fixture" branch feat/issue-100-w main >/dev/null

# Unset GATES_FILE explicitly: loop-census.sh reads it straight from the
# environment, and this test may itself be run from inside a gate invocation
# that exports GATES_FILE=.claude/self/gates.json for the OUTER repo — which
# would leak in here and make census look for a gates.json this fixture never
# created. Force it back to the fixture's own default-relative gates.json.
out="$(env -u GATES_FILE bash "$scripts_dir/loop-census.sh" "acme/repo")"

check "issue 4 (no branch at all) reports branch=none" bash -c 'printf "%s\n" "$1" | grep -q "^issue=4 branch=none"' _ "$out"
check "issue 4 is NOT in_flight (no branch to be in flight with)" bash -c '! printf "%s\n" "$1" | grep -qx "in_flight=4"' _ "$out"
check "issue 42 (remote-only branch, no open PR) IS in_flight" bash -c 'printf "%s\n" "$1" | grep -qx "in_flight=42"' _ "$out"
check "issue 43 (remote-only branch, already has an open PR via origin/ strip+suffix match) is NOT in_flight" bash -c '! printf "%s\n" "$1" | grep -qx "in_flight=43"' _ "$out"
check "issue 100 (local branch, already has an open PR, exact-match control) is NOT in_flight" bash -c '! printf "%s\n" "$1" | grep -qx "in_flight=100"' _ "$out"
check "exactly one in_flight line total (only issue 42 qualifies)" bash -c '[ "$(printf "%s\n" "$1" | grep -c "^in_flight=")" -eq 1 ]' _ "$out"
check "planned_issues=4 counted" bash -c 'printf "%s\n" "$1" | grep -qx "planned_issues=4"' _ "$out"
check "issue=42 branch line shows the origin-prefixed remote-tracking name" bash -c 'printf "%s\n" "$1" | grep -q "^issue=42 branch=origin/feat/issue-42-y"' _ "$out"

echo ""
if [ "$fail" -eq 0 ]; then
echo "loop-census.test.sh: PASS ($ok checks)"
exit 0
else
echo "loop-census.test.sh: FAIL (see FAIL lines above)"
exit 1
fi
Loading
Loading