diff --git a/CHANGELOG.md b/CHANGELOG.md index 29a1a88eec..398bac05a2 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. + - Re-fetch authoritative open/Draft state and exact head immediately before both direct-merge and auto-merge mutations. A caller's stale Ready snapshot, a closed or unavailable PR, or a moved head now fails closed before any 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 0d7db93ec6..a1642acfa6 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -3435,6 +3435,18 @@ same name in another file can carry the opposite safety property.** - **Evidence:** The original RED `2d140a84203a0df0cb86cd6b6ab31fc37bbdbda2` covered only an already-Draft caller. Corrective RED `897c7e6505a4c5dc203471109e425996f91fb9c9` - exercises both mutation entrypoints for a - same-head Ready→Draft race, moved head, missing live PR, and exact-ready - control. Fresh exact-head hosted checks remain required before integration. + exercises both mutation entrypoints for a same-head Ready→Draft race, moved + head, missing live PR, and exact-ready control. 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 105428bdcb..76fabc7713 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}" @@ -2988,7 +2988,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: diff --git a/tests/test_pr_review_merge_scheduler.py b/tests/test_pr_review_merge_scheduler.py index 4568a020f1..7635647843 100644 --- a/tests/test_pr_review_merge_scheduler.py +++ b/tests/test_pr_review_merge_scheduler.py @@ -10900,6 +10900,15 @@ def test_draft_pr_cannot_reach_merge_mutations(monkeypatch): 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" @pytest.mark.parametrize("mutation", (sched.enable_auto_merge, sched.merge_pr)) def test_merge_mutation_rechecks_live_draft_state(monkeypatch, mutation): """A Ready snapshot cannot mutate after the live PR becomes Draft."""