Skip to content
Merged
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
2 changes: 1 addition & 1 deletion plugins/source-control/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
"name": "source-control",
"version": "0.26.4",
"version": "0.26.5",
"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 — 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 merge-rung raises binding from the team-tracked layer only), /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 — 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 — 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",
Expand Down
14 changes: 14 additions & 0 deletions plugins/source-control/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,20 @@
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.26.5]

### Fixed

- **`babysit_delta.py` and `babysit_feedback.py` now casefold owner/repo/login identity
comparisons, matching the already-ratified `.casefold()` convention `pr_queue_snapshot.py`
uses for the identical concept (#815).** `head_repository_scope`'s base/head owner and
same-repository checks, and `latest_reviews_by_author`'s per-reviewer login key, used
`.lower()` instead. Functionally equivalent for GitHub's ASCII-only owner/repo/login
alphabet, but a straggler against the sibling scripts' shared convention. Converted to
`.casefold()` in both files; added case-insensitivity regression tests covering a
differently-cased base repo, head repository, and allowlisted owner, and a differently-cased
reviewer login collapsing to one latest review.

## [0.26.4]

### Fixed
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ def head_repository_scope(
time -- there is no ambient owner configuration to inherit.
"""
repo = str(pr.get("repo") or "")
base_owner = repo.split("/", 1)[0].lower() if "/" in repo else ""
base_owner = repo.split("/", 1)[0].casefold() if "/" in repo else ""
head_repo = pr.get("headRepository")
head_repo_name = (
str(head_repo.get("nameWithOwner") or "") if is_json_object(head_repo) else ""
Expand All @@ -179,12 +179,14 @@ def head_repository_scope(
str(head_owner_value.get("login") or "")
if is_json_object(head_owner_value)
else str(head_owner_value or "")
).lower()
).casefold()
cross_repository = pr.get("isCrossRepository")
same_repository = bool(head_repo_name) and head_repo_name.lower() == repo.lower()
same_repository = (
bool(head_repo_name) and head_repo_name.casefold() == repo.casefold()
)
if same_repository and not head_owner:
head_owner = base_owner
configured_owners = {owner.lower() for owner in allowed_owners}
configured_owners = {owner.casefold() for owner in allowed_owners}
base_repo_allowed = base_owner in configured_owners
base_repo_archived = bool(pr.get("baseRepositoryArchived"))
review_trigger_allowed = base_repo_allowed and not base_repo_archived
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ def latest_reviews_by_author(
continue
elif state == "PENDING":
continue
login = author_login(review).lower()
login = author_login(review).casefold()
if not login:
anonymous.append(review)
continue
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,24 @@ def test_owner_not_in_allowlist_is_not_allowed(self) -> None:
policy = delta.head_repository_scope(make_pr(), frozenset({"other"}))
self.assertFalse(policy["base_repo_allowed"])

def test_owner_repo_login_comparisons_are_case_insensitive(self) -> None:
"""GitHub owner/repo/login identity is case-insensitive end to end:
a differently-cased base repo, headRepository nameWithOwner, and
allowlist entry must all still resolve as matching."""
pr = make_pr(repo="Owner/Repo",
headRepository={"nameWithOwner": "OWNER/REPO"},
headRepositoryOwner={"login": "OwNeR"})
policy = delta.head_repository_scope(pr, frozenset({"Owner"}))
self.assertTrue(policy["base_repo_allowed"])
self.assertTrue(policy["branch_write_allowed"])

def test_cross_repo_head_owner_case_insensitive_allowlist_match(self) -> None:
pr = make_pr(isCrossRepository=True,
headRepository={"nameWithOwner": "Fork/repo"},
headRepositoryOwner={"login": "FORK"})
policy = delta.head_repository_scope(pr, frozenset({"owner", "fork"}))
self.assertTrue(policy["branch_write_allowed"])

def test_incomplete_head_metadata_fails_closed(self) -> None:
pr = make_pr(headRepository=None, isCrossRepository=None)
policy = delta.head_repository_scope(pr, frozenset({"owner"}))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,22 @@ def test_latest_approval_supersedes_older_changes_requested(self) -> None:
self.assertEqual(len(latest), 1)
self.assertEqual(latest[0]["state"], "APPROVED")

def test_differently_cased_login_still_collapses_to_one_latest_review(self) -> None:
"""GitHub logins are case-insensitive: 'Rev' and 'rev' are the same
actor, so their reviews must collapse under one latest-review key."""
pr = {
"latestReviews": [],
"reviews": [
{"id": 1, "author": {"login": "Rev", "__typename": "User"},
"state": "CHANGES_REQUESTED", "submittedAt": "2026-01-01T00:00:00Z"},
{"id": 2, "author": {"login": "rev", "__typename": "User"},
"state": "APPROVED", "submittedAt": "2026-01-02T00:00:00Z"},
],
}
latest = fb.latest_reviews_by_author(pr)
self.assertEqual(len(latest), 1)
self.assertEqual(latest[0]["state"], "APPROVED")

def test_commented_review_does_not_clear_decisive_changes_request(self) -> None:
pr = {
"latestReviews": [],
Expand Down