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.53.16",
"version": "0.53.17",
"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 \u2014 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 standing merge-rung raises binding from the team-tracked layer only \u2014 with one named exception, where an invocation line explicitly typing both the autopilot tier keyword and the dedicated raise argument --merge c3-this-run widens that single invocation's merge authority up to C3 behind a fresh independent frontier-tier resolver, while C4-structural and C5-untrusted-provenance stay unconditionally human-merge), /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 \u2014 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 \u2014 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
8 changes: 8 additions & 0 deletions plugins/source-control/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,14 @@
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.53.17]

### Fixed

- **Branch refresh and review triggers no longer halt on self-authored human stops (#902).**
`human_stop.external_required` excludes configured self-logins from automation gates while
`human_stop.required` still blocks merge when the maintainer posts under their own login.

## [0.53.16]

### Changed
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -464,12 +464,19 @@ def classify_pr(
dispositions = dict(prev.get("feedback_dispositions") or {})
feedback = collect_feedback(pr, inline_comments, dispositions, config.feedback)
review_decision = str(pr.get("reviewDecision") or "")
self_logins = normalize_self_logins(config.self_logins)
human_changes_requested = any(
item.get("kind") == "review" and item.get("state") == "CHANGES_REQUESTED"
for item in feedback["human_blocking"]
)
external_human_blocking = [
item
for item in feedback["human_blocking"]
if not is_self_login(item.get("author"), self_logins)
]
human_stop = {
"required": bool(feedback["human_blocking"]),
"external_required": bool(external_human_blocking),
"human_changes_requested": human_changes_requested,
"human_blocking_count": len(feedback["human_blocking"]),
}
Expand Down Expand Up @@ -581,7 +588,6 @@ def classify_pr(
# gate. Filtering it there instead would silently strip the solo maintainer's
# ability to human-stop their own PR. Here it only stops re-dispatching a
# worker onto the engine's own prior output.
self_logins = normalize_self_logins(config.self_logins)
new_blocking_feedback = [
item for item in feedback["blocking"] if item["id"] not in prev_blocking_ids
]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
"body_text",
"collect_feedback",
"fetch_current_human_stop",
"human_stop_blocks_automation",
"has_blocking_severity",
"has_blocking_text",
"human_stop_state",
Expand Down Expand Up @@ -258,14 +259,27 @@ def human_stop_state(
pr: dict[str, Any],
inline_comments: list[dict[str, Any]] | None,
config: FeedbackConfig = DEFAULT_FEEDBACK_CONFIG,
*,
self_logins: frozenset[str] | None = None,
) -> dict[str, Any]:
feedback = collect_feedback(pr, inline_comments, config=config)
human_changes_requested = any(
item.get("kind") == "review" and item.get("state") == "CHANGES_REQUESTED"
for item in feedback["human_blocking"]
)
normalized_self = (
normalize_self_logins(self_logins) if self_logins is not None else frozenset()
)
external_blocking = [
item
for item in feedback["human_blocking"]
if not is_self_login(item.get("author"), normalized_self)
]
return {
"required": bool(feedback["human_blocking"]),
# Self-authored classification replies must not block freshness refresh or
# review triggers (#902); merge/triage still consult `required`.
"external_required": bool(external_blocking),
"human_changes_requested": human_changes_requested,
"human_blocking_count": len(feedback["human_blocking"]),
}
Expand All @@ -276,9 +290,24 @@ def fetch_current_human_stop(
number: int,
pr: dict[str, Any],
config: FeedbackConfig = DEFAULT_FEEDBACK_CONFIG,
*,
self_logins: frozenset[str] | None = None,
) -> dict[str, Any]:
hydrated = dict(pr)
hydrated["comments"] = fetch_issue_comments(repo, number)
rest_hydrate_reviews(hydrated, repo, number)
inline_comments = fetch_unresolved_review_comments(repo, number)
return human_stop_state(hydrated, inline_comments, config)
return human_stop_state(
hydrated, inline_comments, config, self_logins=self_logins
)


def human_stop_blocks_automation(human_stop: dict[str, Any]) -> bool:
"""Return whether automation (refresh, review trigger) must halt.

Prefer `external_required` when present (#902): self-authored lane replies
stay in `required` for merge/triage but must not block freshness refresh.
"""
if "external_required" in human_stop:
return bool(human_stop.get("external_required"))
return bool(human_stop.get("required"))
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

import babysit_lease as leases
from babysit_delta import compute_branch_freshness, head_repository_scope
from babysit_feedback import fetch_current_human_stop
from babysit_feedback import fetch_current_human_stop, human_stop_blocks_automation
from babysit_gh import (
find_open_prs_for_head_ref,
gh_json,
Expand Down Expand Up @@ -64,7 +64,7 @@ def validate_current_candidate(
raise RuntimeError("PR has a merge conflict; refusing branch refresh")
if compute_branch_freshness(current)["state"] != "behind":
raise RuntimeError("PR is no longer behind its base branch")
if fetch_current_human_stop(repo, number, current)["required"]:
if human_stop_blocks_automation(fetch_current_human_stop(repo, number, current)):
Comment thread
kyle-sexton marked this conversation as resolved.
Comment thread
kyle-sexton marked this conversation as resolved.
Comment thread
kyle-sexton marked this conversation as resolved.
raise RuntimeError("human review stop is active; refusing branch refresh")
matching_prs = find_open_prs_for_head_ref(current)
if matching_prs != [f"{repo}#{number}"]:
Expand Down Expand Up @@ -125,7 +125,7 @@ def run_locked(
"branch_write_allowed"
):
raise RuntimeError("snapshot does not allow writes to this PR head repository")
if json_object(pr_state.get("human_stop")).get("required"):
if human_stop_blocks_automation(json_object(pr_state.get("human_stop"))):
raise RuntimeError("snapshot has an active human review stop")
uniqueness = json_object(pr_state.get("head_ref_uniqueness"))
if not uniqueness.get("checked") or not uniqueness.get("unique"):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@

import babysit_lease as leases
from babysit_delta import compute_branch_freshness, head_repository_scope
from babysit_feedback import fetch_current_human_stop
from babysit_feedback import fetch_current_human_stop, human_stop_blocks_automation
from babysit_gh import (
flatten_paginated_items,
gh_json,
Expand Down Expand Up @@ -135,7 +135,7 @@ def validate_current_candidate(
reaction_signals, expected_head_sha, review_trigger
)
human_stop = fetch_current_human_stop(repo, number, current)
Comment thread
kyle-sexton marked this conversation as resolved.
Comment thread
kyle-sexton marked this conversation as resolved.
if human_stop["required"]:
if human_stop_blocks_automation(human_stop):
raise RuntimeError(
"human review stop is active; refusing to post a review trigger"
)
Expand Down Expand Up @@ -324,7 +324,7 @@ def run_locked(
raise RuntimeError(
"snapshot base repository is outside configured owners or archived"
)
if json_object(pr_state.get("human_stop")).get("required"):
if human_stop_blocks_automation(json_object(pr_state.get("human_stop"))):
raise RuntimeError("snapshot has an active human review stop")
allowed_owners = allowed_owners_from_policy(mutation_policy)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -451,6 +451,7 @@ def test_self_login_item_is_still_classified_human_blocking(self) -> None:
for item in result["feedback"]["human_blocking"]]
self.assertEqual(authors, ["solo"])
self.assertTrue(result["human_stop"]["required"])
self.assertFalse(result["human_stop"]["external_required"])
self.assertFalse(result["pr_clean_ready_for_direct_gate"])

def test_other_login_still_fires_under_same_config(self) -> None:
Expand Down