Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
33 changes: 33 additions & 0 deletions docs/doctoring/case-insensitive-owned-head-identity.md
Original file line number Diff line number Diff line change
@@ -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
18 changes: 15 additions & 3 deletions docs/product-technical-gap-baseline.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
4 changes: 2 additions & 2 deletions scripts/ci/pr_review_merge_scheduler_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Expand Down Expand Up @@ -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:
Expand Down
9 changes: 9 additions & 0 deletions tests/test_pr_review_merge_scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
Loading