diff --git a/plugins/source-control/.claude-plugin/plugin.json b/plugins/source-control/.claude-plugin/plugin.json index ed79d601c1..53d6bda277 100644 --- a/plugins/source-control/.claude-plugin/plugin.json +++ b/plugins/source-control/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "source-control", - "version": "0.53.2", + "version": "0.53.3", "description": "Git and GitHub delivery workflow: /commit (Conventional Commits + Co-Authored-By trailer via safe heredoc mechanics), /pull-request (prep, create, CI monitoring, review-comment triage, merge, CI-log fetch), /babysit-prs (self-pacing fleet loop \u2014 safe by default; opt-in worker/autopilot tiers add gate-checked merge and thread resolution behind a deterministic Python engine), /babysit-loop (the loop-lane merge lane: a standing or drain loop that invokes babysit-prs per cycle, configured through repo-scoped babysit_loop_* keys on the layered source-control.md seam, with merge authority human-only until the target repo's tracked config adopts the lane, a gate-proven C2-mechanical baseline once adopted, and standing merge-rung raises binding from the team-tracked layer only \u2014 with one named exception, where an invocation line explicitly typing both the autopilot tier keyword and the dedicated raise argument --merge c3-this-run widens that single invocation's merge authority up to C3 behind a fresh independent frontier-tier resolver, while C4-structural and C5-untrusted-provenance stay unconditionally human-merge), /worktree (create, status, cleanup, audit for parallel-session isolation), /setup (check the effective commit-subject / PR-title convention merged across its config layers and the babysit-prs config, or apply \u2014 interview the repo and write the convention config to a chosen layer), and /resolve-conflicts (intent-first merge/rebase conflict resolution with a semantic-conflict sweep \u2014 never --abort). The commit-subject / PR-title convention is configurable via a source-control.md config written by a re-runnable setup skill, layered across a ~/.claude user-global file, the tracked team file, and a gitignored .claude/source-control.local.md personal overlay merged per key; Conventional Commits is the default when no convention is declared.", "author": { "name": "Melodic Software", diff --git a/plugins/source-control/CHANGELOG.md b/plugins/source-control/CHANGELOG.md index 92548d5b6b..0d35994f71 100644 --- a/plugins/source-control/CHANGELOG.md +++ b/plugins/source-control/CHANGELOG.md @@ -3,6 +3,12 @@ All notable changes to the `source-control` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.53.3] + +### Fixed + +- **`fetch_pull_request_commits` fails closed when the pull-commits walk is shorter than the PR's `commits` count (#2387).** GitHub caps that endpoint at 250 commits regardless of pagination. + ## [0.53.2] ### Fixed diff --git a/plugins/source-control/skills/babysit-prs/scripts/babysit_gh.py b/plugins/source-control/skills/babysit-prs/scripts/babysit_gh.py index 3cb02512d3..b7f4a088a6 100755 --- a/plugins/source-control/skills/babysit-prs/scripts/babysit_gh.py +++ b/plugins/source-control/skills/babysit-prs/scripts/babysit_gh.py @@ -481,11 +481,23 @@ def fetch_pull_request_commits(repo: str, number: int) -> list[dict[str, Any]]: whose verification block is missing is reported unverified with reason `unreadable` rather than skipped: the caller enforcing a signature rule may only ever over-report. + + GitHub's pull-commits endpoint returns at most 250 commits regardless of + pagination; when the PR's `commits` count exceeds the walked rows, this + raises rather than returning a silently truncated list (#2387). """ + pr_commits = gh_json(["api", f"repos/{repo}/pulls/{number}", "-q", ".commits"]) + expected = int(pr_commits) if pr_commits is not None else 0 rows = fetch_paginated_api( f"repos/{repo}/pulls/{number}/commits?per_page=100", f"{repo}#{number} commits", ) + walked = len(rows) + if expected > walked: + raise RuntimeError( + "commit list walked " + f"{walked} of {expected} (pull-commits endpoint caps at 250)" + ) out: list[dict[str, Any]] = [] for row in rows: commit = row.get("commit") diff --git a/plugins/source-control/skills/babysit-prs/scripts/tests/test_babysit_gh.py b/plugins/source-control/skills/babysit-prs/scripts/tests/test_babysit_gh.py index b9b8277da9..2f0bbaa158 100644 --- a/plugins/source-control/skills/babysit-prs/scripts/tests/test_babysit_gh.py +++ b/plugins/source-control/skills/babysit-prs/scripts/tests/test_babysit_gh.py @@ -288,7 +288,13 @@ def test_verification_is_projected_per_commit(self) -> None: "commit": {"verification": {"verified": False, "reason": "no_user"}}, }, ] - with mock.patch.object(gh, "gh_json", return_value=rows) as gh_json: + + def gh_side_effect(args: list[str]) -> Any: + if "-q" in args and args[1].endswith("/pulls/5"): + return 2 + return [rows] + + with mock.patch.object(gh, "gh_json", side_effect=gh_side_effect): out = gh.fetch_pull_request_commits("owner/repo", 5) self.assertEqual( out, @@ -297,11 +303,18 @@ def test_verification_is_projected_per_commit(self) -> None: {"sha": "b" * 40, "verified": False, "reason": "no_user"}, ], ) - [call] = gh_json.call_args_list - endpoint = call.args[0][1] - self.assertIn("repos/owner/repo/pulls/5/commits", endpoint) - self.assertIn("per_page=100", endpoint) - self.assertIn("--paginate", call.args[0]) + + def test_truncated_commit_list_raises(self) -> None: + rows = [{"sha": "a" * 40, "commit": {"verification": {"verified": True}}}] + + def gh_side_effect(args: list[str]) -> Any: + if "-q" in args and args[1].endswith("/pulls/99"): + return 251 + return [rows] + + with mock.patch.object(gh, "gh_json", side_effect=gh_side_effect): + with self.assertRaisesRegex(RuntimeError, "walked 1 of 251"): + gh.fetch_pull_request_commits("owner/repo", 99) def test_missing_verification_reads_unverified_unreadable(self) -> None: rows = [ @@ -309,7 +322,13 @@ def test_missing_verification_reads_unverified_unreadable(self) -> None: {"sha": "d" * 40}, {"sha": "e" * 40, "commit": {"verification": {"verified": True}}}, ] - with mock.patch.object(gh, "gh_json", return_value=rows): + + def gh_side_effect(args: list[str]) -> Any: + if "-q" in args and args[1].endswith("/pulls/5"): + return 3 + return [rows] + + with mock.patch.object(gh, "gh_json", side_effect=gh_side_effect): out = gh.fetch_pull_request_commits("owner/repo", 5) self.assertEqual( out,