From 4fb514db54e6210fc0606f0dfa8d9033f3e1f6f5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 13:55:39 +0900 Subject: [PATCH 1/3] test(scheduler): reproduce case-sensitive owned-head routing --- tests/test_pr_review_merge_scheduler.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/test_pr_review_merge_scheduler.py b/tests/test_pr_review_merge_scheduler.py index cb3421a1dd..7a6c325700 100644 --- a/tests/test_pr_review_merge_scheduler.py +++ b/tests/test_pr_review_merge_scheduler.py @@ -10902,3 +10902,14 @@ def test_draft_pr_cannot_reach_merge_mutations(monkeypatch): mutation("owner/repo", draft_pr, dry_run=False) assert calls == [] + + +def test_same_repository_identity_is_case_insensitive(): + """GitHub casing drift cannot route an owned branch through the fork path.""" + pull_request = make_pr( + headRefName="feature", + headRepository={"nameWithOwner": "Owner/Repo"}, + ) + + assert sched.same_repository_head("owner/repo", pull_request) + assert sched.compare_ref_for_pr_head("owner/repo", pull_request) == "feature" From 75e9d67cba611fcdefb156e2bb8a3f3c0574c79b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 13:56:08 +0900 Subject: [PATCH 2/3] fix(scheduler): casefold repository identity --- CHANGELOG.md | 4 +++ .../case-insensitive-owned-head-identity.md | 33 +++++++++++++++++++ docs/product-technical-gap-baseline.md | 12 +++++++ scripts/ci/pr_review_merge_scheduler_core.py | 4 +-- 4 files changed, 51 insertions(+), 2 deletions(-) create mode 100644 docs/doctoring/case-insensitive-owned-head-identity.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 515e3ed893..c595cf0eef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -162,6 +162,10 @@ ## Proposed +- Compare GitHub repository identities case-insensitively at owned-head and + compare-ref boundaries so canonical casing drift cannot misroute an + organization-owned branch through external-fork restrictions. + - Reject Draft pull requests again at both direct-merge and auto-merge mutation functions. This defense-in-depth boundary prevents a stale caller decision from reaching guarded GitHub mutations after PR lifecycle changes. diff --git a/docs/doctoring/case-insensitive-owned-head-identity.md b/docs/doctoring/case-insensitive-owned-head-identity.md new file mode 100644 index 0000000000..e6b04cf7ae --- /dev/null +++ b/docs/doctoring/case-insensitive-owned-head-identity.md @@ -0,0 +1,33 @@ +# Case-insensitive owned-head identity + +Decision date: **2026-09-07** + +## Problem + +GitHub treats repository names case-insensitively, but the scheduler compared +`headRepository.nameWithOwner` with the configured target using exact string +equality. Casing drift could classify an organization-owned branch as an +external fork and build the wrong compare ref. + +## Decision + +Case-fold both repository identifiers in `same_repository_head` and +`compare_ref_for_pr_head`. No permission or ownership inference changes; only +names GitHub already considers identical are unified. + +## Failure scenes + +- `Owner/Repo` versus `owner/repo`: classify as the same repository. +- A genuinely different repository: retain external-head handling. +- Missing head repository metadata: preserve the existing compare fallback and + fail-closed mutation eligibility. + +## Evidence and follow-up + +RED commit: `4fb514db54e6210fc0606f0dfa8d9033f3e1f6f5`. +Fresh exact-head hosted checks and independent review remain required. + +## Reference + +GitHub. (2026). *REST API endpoints for repositories*. +https://docs.github.com/en/rest/repos/repos diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 7dbae89a11..132e57e842 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -3429,3 +3429,15 @@ same name in another file can carry the opposite safety property.** - **Evidence:** RED commit `2d140a84203a0df0cb86cd6b6ab31fc37bbdbda2`; fresh exact-head hosted checks remain required before integration. + + +### Case-insensitive owned-head identity + +- **Status:** Proposed +- **Owner:** `ContextualWisdomLab/.github` +- **Problem:** Same-repository head and compare-ref checks used case-sensitive + repository strings even though GitHub repository identity is case-insensitive. +- **Action:** Case-fold both sides at the two owned-head routing boundaries. +- **Evidence:** RED commit + `4fb514db54e6210fc0606f0dfa8d9033f3e1f6f5`; fresh exact-head hosted checks + remain required before integration. diff --git a/scripts/ci/pr_review_merge_scheduler_core.py b/scripts/ci/pr_review_merge_scheduler_core.py index cd8550f211..d6331d6bdc 100644 --- a/scripts/ci/pr_review_merge_scheduler_core.py +++ b/scripts/ci/pr_review_merge_scheduler_core.py @@ -1552,7 +1552,7 @@ def compare_ref_for_pr_head(repo: str, pr: dict[str, Any]) -> str: """Return the compare-API head ref for a PR branch.""" head_ref = pr.get("headRefName") or "HEAD" head_repo = (pr.get("headRepository") or {}).get("nameWithOwner") - if not head_repo or head_repo == repo: + if not head_repo or head_repo.casefold() == repo.casefold(): return head_ref head_owner, _ = split_repo(head_repo) return f"{head_owner}:{head_ref}" @@ -2965,7 +2965,7 @@ def post_update_branch_followup( def same_repository_head(repo: str, pr: dict[str, Any]) -> bool: """Return whether the PR head branch belongs to the repository being scanned.""" head_repo = (pr.get("headRepository") or {}).get("nameWithOwner") - return head_repo == repo + return bool(head_repo) and head_repo.casefold() == repo.casefold() def can_update_pr_head(repo: str, pr: dict[str, Any]) -> bool: From 30748196cb6d8ab1f208351c2580253661b1f3b3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 11:10:20 +0900 Subject: [PATCH 3/3] test(scheduler): inherit proven credential fixtures --- tests/test_pr_review_merge_scheduler.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/tests/test_pr_review_merge_scheduler.py b/tests/test_pr_review_merge_scheduler.py index 7a6c325700..b5a504721c 100644 --- a/tests/test_pr_review_merge_scheduler.py +++ b/tests/test_pr_review_merge_scheduler.py @@ -34,6 +34,8 @@ def workflow_starting_mutation_credential(monkeypatch): workflow-starting credential exactly like the scheduler workflow does. """ monkeypatch.setenv("SCHEDULER_MUTATION_TOKEN_SOURCE", "PR_REVIEW_MERGE_TOKEN") + monkeypatch.setenv("GH_TOKEN", "selected-mutation-token") + monkeypatch.setenv("SCHEDULER_WORKFLOW_TOKEN", "workflow-runner-token") @pytest.fixture(autouse=True) @@ -1785,7 +1787,11 @@ def map(self, func, items): ), ) cancelled = [] - monkeypatch.setattr(sched, "run_github_actions", cancelled.append) + monkeypatch.setattr( + sched, + "run_github_actions", + lambda args, stdin=None: cancelled.append(args), + ) monkeypatch.setattr(sched, "require_github_actions_control_actor", lambda x: None) run_ids = sched.cancel_stale_opencode_runs("owner/repo", "workflow", make_pr(), dry_run=False) @@ -1796,7 +1802,7 @@ def map(self, func, items): def test_force_cancel_failure_logs_reason_and_does_not_raise(monkeypatch, capsys): - def fail_cancel(args): + def fail_cancel(args, stdin=None): raise RuntimeError( "Command failed (1): gh api -X POST " "repos/owner/repo/actions/runs/29263154177/force-cancel; " @@ -1821,7 +1827,7 @@ def fail_cancel(args): def test_force_cancel_multiple_runs_reports_only_failures(monkeypatch): - def maybe_fail(args): + def maybe_fail(args, stdin=None): if "runs/2/force-cancel" in " ".join(args): raise RuntimeError("GitHub returned HTTP 500") return ""