From 82df86cfd3ba8771ef747413ec0c193dc59f3ee6 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Sun, 25 Oct 2026 10:51:58 -0400 Subject: [PATCH 1/2] fix(source-control): read the split task-list at the default branch resolve-pr.mjs reads admission's task-list from main's checkout, whatever the pull request's base. The pr-merge gate read admission-rules.json at the Reader's branch, the pull request's base, so a feature-branch base could judge a split sub-issue by a different prefix. Read it at the repository's default branch, keyed in the run cache by that branch; a failed default-branch read refuses verify:unreadable. Co-Authored-By: Claude Opus 5.5 --- ...split-task-list-default-branch-9b4d27e6.md | 7 +++ docs/conventions/pr-pipeline/README.md | 2 +- .../scripts/babysit_verify_evidence.py | 15 ++++-- .../tests/test_babysit_split_child_trust.py | 48 +++++++++++++++++++ 4 files changed, 66 insertions(+), 6 deletions(-) create mode 100644 .changes/source-control/fix-split-task-list-default-branch-9b4d27e6.md diff --git a/.changes/source-control/fix-split-task-list-default-branch-9b4d27e6.md b/.changes/source-control/fix-split-task-list-default-branch-9b4d27e6.md new file mode 100644 index 0000000000..43e87b9958 --- /dev/null +++ b/.changes/source-control/fix-split-task-list-default-branch-9b4d27e6.md @@ -0,0 +1,7 @@ +--- +bump: patch +--- + +### Fixed + +- The pr-merge gate reads admission's `task-list` for a size-split sub-issue from `admission-rules.json` at the repository's default branch, as pr-verify reads it from main's checkout, instead of at the pull request's base branch. A failed read of the default branch refuses `verify:unreadable`. diff --git a/docs/conventions/pr-pipeline/README.md b/docs/conventions/pr-pipeline/README.md index 54ff678f4b..733eda2520 100644 --- a/docs/conventions/pr-pipeline/README.md +++ b/docs/conventions/pr-pipeline/README.md @@ -1568,7 +1568,7 @@ head: opens with the split header for that parent, every line after the header is one of the parent's trusted task-list lines (a line of the parent's listed-human body or comments that starts with admission's `task-list`, "- [ ] " with its trailing space, from - `.github/actions/resolve-config/admission-rules.json` at the base branch; a missing or empty `task-list` counts nothing), and either nobody edited it or a + `.github/actions/resolve-config/admission-rules.json` at the default branch, as resolve-pr reads it from main's checkout, whatever the pull request's base; a missing or empty `task-list` counts nothing), and either nobody edited it or a listed human last edited it. A header whose third line is `` also allows that refinement comment's task-list lines, never its prose, heading or marker lines, and counts only while that comment is diff --git a/plugins/source-control/skills/babysit-prs/scripts/babysit_verify_evidence.py b/plugins/source-control/skills/babysit-prs/scripts/babysit_verify_evidence.py index ecebf46328..4f1a885d21 100755 --- a/plugins/source-control/skills/babysit-prs/scripts/babysit_verify_evidence.py +++ b/plugins/source-control/skills/babysit-prs/scripts/babysit_verify_evidence.py @@ -1129,11 +1129,16 @@ def _lane_app_id(self) -> int | None: return app_id if type(app_id) is int else None def _task_list(self) -> str | None: - """Admission's `task-list` at the branch, as resolve-pr.mjs reads it - from main's checkout, shared by the run's gate processes; None when - the rules hold no string there. A failed read raises `_Unreadable`.""" - key = f"{self.repo}@{self.branch}" + """Admission's `task-list` at the repository's default branch, as + resolve-pr.mjs reads it from main's checkout whatever the pull + request's base, shared by the run's gate processes; None when the + rules hold no string there. A failed read raises `_Unreadable`.""" if not self._task_list_read: + try: + default = merge_policy._default_branch(self.repo, shared=True) + except merge_policy.PolicyReadError as exc: + raise _Unreadable(str(exc)) from exc + key = f"{self.repo}@{default}" shared = run_cache.get_dated("task-list", key, run_cache.MAX_AGE) if shared is not None: self._task_list_value, at = shared @@ -1145,7 +1150,7 @@ def _task_list(self) -> str | None: "api", "-H", "Accept: application/vnd.github.raw", - f"repos/{self.repo}/contents/{ADMISSION_RULES_PATH}?ref={self.branch}", + f"repos/{self.repo}/contents/{ADMISSION_RULES_PATH}?ref={default}", ], "admission rules", ) diff --git a/plugins/source-control/skills/babysit-prs/scripts/tests/test_babysit_split_child_trust.py b/plugins/source-control/skills/babysit-prs/scripts/tests/test_babysit_split_child_trust.py index b62a740d7f..db429479d2 100644 --- a/plugins/source-control/skills/babysit-prs/scripts/tests/test_babysit_split_child_trust.py +++ b/plugins/source-control/skills/babysit-prs/scripts/tests/test_babysit_split_child_trust.py @@ -118,8 +118,10 @@ def __init__( self.actors = {"version": 1, "actors": actors} self.rules = {} if task_list is None else {"task-list": task_list} self.issue = issue + self.default_branch = "main" self.fail = False self.rules_fail = False + self.default_fail = False self.calls: list[tuple[list[str], Any]] = [] def graphql_calls(self) -> int: @@ -132,10 +134,23 @@ def rules_calls(self) -> int: if any(ve.ADMISSION_RULES_PATH in a for a in args) ) + def rules_refs(self) -> list[str]: + return [ + a.rpartition("?ref=")[2] + for args, _ in self.calls + for a in args + if ve.ADMISSION_RULES_PATH in a + ] + def __call__( self, args: list[str], **kwargs: Any ) -> subprocess.CompletedProcess[str]: self.calls.append((args, kwargs.get("env_overrides"))) + if args == ["api", f"repos/{REPO}"]: + if self.default_fail: + return subprocess.CompletedProcess(args, 1, "", "gh: boom (HTTP 502)") + answer = {"default_branch": self.default_branch} + return subprocess.CompletedProcess(args, 0, json.dumps(answer), "") if args[:2] == ["api", "graphql"]: assert any(a == f"query={ve.SPLIT_CHILD_QUERY}" for a in args), args if self.fail: @@ -163,6 +178,7 @@ def judge( with ( mock.patch.dict("os.environ", {}, clear=False) as env, mock.patch.object(postmerge, "gh_capture", side_effect=fake), + mock.patch.object(ve.merge_policy, "gh_capture", side_effect=fake), ): env.pop("BABYSIT_RUN_CACHE", None) found = ve.Reader(REPO, "main", agent_identity=identity, **reader) @@ -275,6 +291,12 @@ def test_unreadable_rules_are_unreadable(self) -> None: with self.assertRaises(ve._Unreadable): judge(fake, linked(self.case["child"]), LANES_LOGIN) + def test_an_unreadable_default_branch_is_unreadable(self) -> None: + fake = self.fake() + fake.default_fail = True + with self.assertRaises(ve._Unreadable): + judge(fake, linked(self.case["child"]), LANES_LOGIN) + def test_unreadable_comments(self) -> None: # More comments than the first 100 the query reads. fake = self.fake() @@ -288,6 +310,30 @@ def test_a_missing_or_empty_task_list_counts_nothing(self) -> None: self.assertFalse(judge(fake, linked(self.case["child"]), LANES_LOGIN)) +class TheRulesComeFromTheDefaultBranch(unittest.TestCase): + """resolve-pr.mjs reads admission's rules from main's checkout, so the + gate reads them at the default branch whatever the pull request's base.""" + + def test_a_feature_base_reads_the_rules_at_the_default_branch(self) -> None: + data = json.loads(VECTORS.read_text(encoding="utf-8")) + case = next(c for c in data["cases"] if c["name"] == "the trusted child") + fake = FakeGitHub( + data["humans"], data["app_id"], graphql_issue(case, data["parents"]) + ) + fake.default_branch = "trunk" + with ( + mock.patch.dict("os.environ", {}, clear=False) as env, + mock.patch.object(postmerge, "gh_capture", side_effect=fake), + mock.patch.object(ve.merge_policy, "gh_capture", side_effect=fake), + ): + env.pop("BABYSIT_RUN_CACHE", None) + reader = ve.Reader(REPO, "feature-x", agent_identity=LANES_LOGIN) + self.assertTrue( + reader._trusted_spec({"linked_issues": [linked(case["child"])]}) + ) + self.assertEqual(fake.rules_refs(), ["trunk"]) + + class OneReadPerChild(unittest.TestCase): def setUp(self) -> None: self.data = json.loads(VECTORS.read_text(encoding="utf-8")) @@ -303,6 +349,7 @@ def test_a_child_judged_twice_is_read_once_under_the_actions_token(self) -> None with ( mock.patch.dict("os.environ", {}, clear=False) as env, mock.patch.object(postmerge, "gh_capture", side_effect=self.fake), + mock.patch.object(ve.merge_policy, "gh_capture", side_effect=self.fake), ): env.pop("BABYSIT_RUN_CACHE", None) reader = ve.Reader( @@ -323,6 +370,7 @@ def test_a_reused_answer_notes_when_it_was_read(self) -> None: with ( mock.patch.dict("os.environ", {}, clear=False) as env, mock.patch.object(postmerge, "gh_capture", side_effect=self.fake), + mock.patch.object(ve.merge_policy, "gh_capture", side_effect=self.fake), mock.patch.object(ve.run_cache, "now", side_effect=lambda: next(ticks)), ): env.pop("BABYSIT_RUN_CACHE", None) From fe22ec46ba9e6a42962ade89a9b9e7024c9f1f52 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Sun, 25 Oct 2026 11:19:19 -0400 Subject: [PATCH 2/2] chore(pr-pipeline): add the changelog fragment for the task-list source change Co-Authored-By: Claude Opus 5.5 --- .../fix-split-task-list-default-branch-4c1e7a92.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changes/pr-pipeline/fix-split-task-list-default-branch-4c1e7a92.md diff --git a/.changes/pr-pipeline/fix-split-task-list-default-branch-4c1e7a92.md b/.changes/pr-pipeline/fix-split-task-list-default-branch-4c1e7a92.md new file mode 100644 index 0000000000..2c9f1e28df --- /dev/null +++ b/.changes/pr-pipeline/fix-split-task-list-default-branch-4c1e7a92.md @@ -0,0 +1,7 @@ +--- +bump: patch +--- + +### Fixed + +- The pr-pipeline convention names the repository's default branch, not the pull request's base, as where the merge gate reads admission's `task-list` for a size-split sub-issue.