Skip to content

Open-PR cap: refuse new-branch pushes at/above AUTOBOT_OPEN_PR_CAP (default 40) #17006

Description

@mrveiss

Owner decision (2026-09-18)

Sessions are opening new branches faster than PRs merge — 58 PRs were open at
the time of this decision. The owner capped open PRs, not branches: a
session must finish and merge (or close) existing work before starting new
work. CLAUDE.md:49 currently says the opposite ("There is no open-PR
limit...") and must be corrected in the same change that adds the gate.

Design

Where: the pre-push hook chain (tools/git-hooks/pre-push, installed by
scripts/install-git-hooks.sh into the shared .git/hooks/ dir used by the
main checkout and every worktree). This is where Phase 0c/Phase 6 already
enforce push-time policy, so the new gate rides the same chain rather than
adding a second, uninstalled one.

When it applies: only when the pushed ref is a refs/heads/* branch that
does not yet exist on the remote (remote_sha from git's pre-push stdin
protocol is all-zero) — i.e. a brand-new branch. A brand-new tag has a zero
remote_sha too but is exempt (a tag is never a PR head). A push to a branch
that already has an open PR is never blocked, so fixing existing work is
always possible.

What counts: open PRs in this repo whose head branch lives in this repo
(not a fork), excluding bot authors. Measured against this repo's own
dependabot history: gh's GraphQL author.login for a bot reports the
App-slug form app/dependabot (never the REST-style dependabot[bot]), with
is_bot: true — that field is what actually does the excluding; a
[bot]-suffixed-login check is kept only as a defensive fallback for a shape
not observed in this API. One gh pr list --state open --limit 1000 --json number,title,createdAt,author,isCrossRepository --jq '...' call, time-boxed,
returns both the filtered count and the oldest 10 (by createdAt) in one
round trip.

Cap: AUTOBOT_OPEN_PR_CAP, default 40. Documented in the hook's own
header comment (the precedent for a hook-only, bash-read AUTOBOT_* var is
AUTOBOT_PREPUSH_ALLOW_TIMEOUT in the same file — it is not in the Python
env_registry/ENV_VARS.md because that registry's pre-commit check
(pipeline-scripts/check_env_var_registry.py) only AST-scans .py files for
os.getenv("AUTOBOT_..."); a bash-only var has no home there).

At or above the cap: refuse the push. Message states the current count,
the cap, that pushes to existing PR branches still work, and lists the oldest
10 open PRs (number + title) to act on.

Fail closed: if gh is missing, unauthenticated, or the query fails, the
push is refused with a message saying the count could not be determined —
distinct from "under the cap" (measurement discipline: an empty/failed read
is not a true negative). No bypass flag, no env override that skips the
check — the only knob is the cap value.

Acceptance criteria

  • tools/git-hooks/pre-push refuses a new-branch push once the
    filtered open-PR count is >= AUTOBOT_OPEN_PR_CAP (default 40), with a
    message giving the count, the cap, remediation, and the oldest 10 open
    PRs.
  • A push to a branch that already exists on the remote is never
    blocked by this gate, even at/above the cap.
  • The count excludes fork PRs (isCrossRepository == true) and bot
    authors (is_bot or a [bot]-suffixed login).
  • gh missing / unauthenticated / query failure refuses the push with a
    "could not determine" message, never "allowed".
  • No env var or flag bypasses the check; AUTOBOT_OPEN_PR_CAP is the only
    configurable input.
  • The check is reachable from a clean clone via
    scripts/install-git-hooks.sh (proof it is wired into the installed
    chain, not just present in tools/git-hooks/).
  • CLAUDE.md:49's "There is no open-PR limit" line is replaced with the
    new rule; docs/developer/*.md has no remaining contradicting text.
  • Tests under repo_tests/ drive the real hook script (not a
    reimplementation) with a stubbed gh on PATH, covering: below cap
    (allowed), at cap (refused, message has count+cap), existing-branch
    push at cap (allowed), gh failure (refused, "could not determine"),
    bot-authored PRs excluded from the count.

Non-goals

  • No change to branch/worktree limits — only open PRs are capped.
  • No dispatch-side enforcement in this issue; that is a CLAUDE_BATCH.md /
    AGENT_COORDINATION.md doc note, not new code.

Activity

  1. added this to the v0.9.0 milestone on Sep 18, 2026
  2. mrveiss commented on Sep 18, 2026

    @mrveiss
    OwnerAuthor

    Design correction from PR #17008 review:

    • The bot-login examples (dependabot[bot], github-actions[bot]) were wrong. Measured against this repo's own dependabot history (45 closed PRs): gh pr list --json author reports login: "app/dependabot", is_bot: true — the App-slug form, never the REST-style [bot] suffix. is_bot is what actually excludes them; the [bot]-suffix check in the hook's jq filter is a defensive fallback only, not the real mechanism. Issue body updated.
    • Added a scope fix: the cap now also requires local_ref to match refs/heads/*, not just remote_sha == 0000...0 — a brand-new tag push also has a zero remote_sha and was incorrectly tripping the cap.
    • Added a timeout (OPEN_PR_CAP_TIMEOUT=10, fails closed, no override) around the gh pr list call.
  3. mrveiss commented on Sep 19, 2026

    @mrveiss
    OwnerAuthor

    AC verification against merged main (post #17133 vehicle merge)

    All 8 ACs verified directly in tools/git-hooks/pre-push + repo_tests/pre_push_open_pr_cap_17006_test.py:

    • Refuses at/above cap with count+cap+remediation+oldest-10 (check_open_pr_cap's message construction).
    • Existing-branch push never blocked — gated on [ "$remote_sha" = "$ZERO" ] && [[ "$local_ref" == refs/heads/* ]]; a new tag (zero remote_sha, not refs/heads/*) is explicitly exempt too.
    • Excludes fork PRs (isCrossRepository == false) and bots (is_bot primary, [bot]-suffix login fallback, with a comment citing measured dependabot behavior).
    • Fails closed on gh-missing/timeout/query-failure/unexpected-output, each with a distinct "could not determine... NOT under the cap" message.
    • No bypass — comment explicitly notes AUTOBOT_PREPUSH_ALLOW_TIMEOUT does NOT apply here; AUTOBOT_OPEN_PR_CAP is the only knob.
    • Wired into the installed chain — pre-push is in install-git-hooks.sh's MANAGED_HOOKS.
    • CLAUDE.md corrected — line 51 now states the cap; confirmed no contradicting "no open-PR limit" text remains.
    • Tests drive the real hook script with a stubbed gh, covering all 5 named scenarios plus 2 extra (new-tag-at-cap, default-cap-40).

    All 8 satisfied. Closed correctly.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

      Milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions