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
10 changes: 10 additions & 0 deletions .changes/pr-pipeline/feat-pr-merge-cost-hold-9b3f2d61.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
bump: minor
---

### Added

- pr-merge holds a pull request that could add or raise a charge (IaC files, new workflow
schedules, billed runner labels, new secrets references, new model legs, raised model-leg
timeouts, pipeline config changes) for a human unless its body links an owner
`**Decision recorded.** Cost:` comment on its closing issue or the pull request itself.
16 changes: 16 additions & 0 deletions .changes/source-control/feat-pr-merge-cost-hold-5c1e8a27.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
bump: minor
---

### Added

- The unattended merge scorer holds a pull request that could add or raise a charge as HIGH
`cost-review-required`, in both lanes, whatever `high-path-floor` says and whatever an adopted
author's own paths leave off the floor: Pulumi, Terraform or Bicep files, the multi-agent role
map and anything under a Pulumi project; a file renamed into a workflow or `pr-pipeline.yaml`;
a workflow that adds a schedule, a `--model` or `--max-turns` flag, any new `secrets`
reference, a new Claude Code action, image or package run, a raised timeout on a model leg, or
a runner that is not a free standard label (any runner off a public repository); and any
`pr-pipeline.yaml` change beyond comments, blank lines, `description:` and `title:`. The hold
clears only when the body links an owner's unedited `**Decision recorded.** Cost:` comment on
the pull request, or on the closing issue when created after the pull request.
46 changes: 46 additions & 0 deletions docs/conventions/pr-pipeline/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -711,6 +711,52 @@ and its queued entries leave; two different identities, or an unreadable head co
`plugins/**/scripts/**` entries, `floor-paths` globs that end in a `scripts` directory (such as
`plugins/x/scripts/**`), or `floor-script-scopes`; every other floor entry, every other
`floor-paths` glob and `denied-paths` still hold it.
- **Cost hold.** A change that could add or raise a charge needs the owner (owner rule,
2026-10-22). It scores HIGH as `cost-review-required` in both lanes, whatever `high-path-floor`
says and whatever an adopted author's own paths leave off the floor, and waits for a human like
any other HIGH reason. It catches:
- any changed `Pulumi.yaml`, `Pulumi.*.yaml`, `*.tf`, `*.tfvars`, `*.bicep` or `*.bicepparam`
file, `plugins/multi-agent/reference/defaults.yaml` (the model and effort per role), and any
file under a directory that holds a `Pulumi.yaml` at the base. A base tree the gate cannot
list whole holds every file;
- a file renamed or copied into `.github/workflows/` or a `pr-pipeline.yaml` path from outside;
- a workflow patch that adds a `cron:` key in any YAML or JSON spelling (a schedule can spend
model credits) or a `--model` or `--max-turns` flag; or that gains any `secrets` reference
(`secrets.NAME`, `secrets['NAME']`, `toJSON(secrets)`, `secrets: inherit`), a `uses:` of
`run-claude-cli` or any `claude-code*` action or image (matched ignoring case), or a run line
invoking `@anthropic-ai/claude-code`, each counted against the lines it removes so a moved
line or a repin is not new;
- a `timeout-minutes` or `leg-timeout-minutes` the patch adds above every one it removes, in a
workflow whose head text uses one of those actions or could not be read;
- a runner value the workflow's head text gains over its base text, or an added inline
`runs-on:` value: on a public repository only one that is not a free literal (GitHub's
standard `ubuntu-*`, `windows-*` and `macos-*` labels, the self-hosted managed labels, or a
list holding `self-hosted`), so an expression, a `group:` and a larger runner hold, since
[GitHub bills larger runners even on public repositories](https://docs.github.com/en/billing/concepts/product-billing/github-actions);
on any other repository every one, and any added `windows` or `macos` line;
- any `pr-pipeline.yaml` change except one whose added and removed lines are all blank,
comment-only, or a `description:` or `title:` value, or that only edits trailing comments
on the same lines in place.
Flow style, quoted keys and new list items hold;
- a workflow or pipeline file with no patch.

The hold clears only when the pull request body links an owner decision that covers it: a
comment on the pull request's closing issue or on the pull request itself, in this repository,
whose first line starts `**Decision recorded.** Cost:`, written by a User with admin permission
who is not the agent identity or one of the gate's own logins, not posted through an App, and
never edited; one on the closing issue must also be created after the pull request was. A link
inside an HTML comment in the body counts for nothing. A push after the decision is an accepted
residual: the decision is not bound to a head. The gate reads the linked comments only for a
pull request the hold catches, and a failed read leaves the hold. A decision
clears only this reason: a workflow or `docs/conventions/pr-pipeline.yaml` change still sits on
the HIGH path floor here and waits for a person to merge it. Cite the decision before the
human-gated label lands; a body edit does not remove that label (only a new head or a person
does). Not detected: budgets, billing settings and new paid services outside these files, and
the shapes [#9241](https://github.com/melodic-software/claude-code-plugins/issues/9241) lists.
azure-iac's Pulumi program is C#, and its deploy gate
([azure-iac#75](https://github.com/melodic-software/azure-iac/issues/75)) stops any Azure change
for the owner at deploy time. The review lanes and the verifier judge the rest. Dependabot pull
requests are not scored, so the hold does not apply to them.
- **Merge queue.** The wrapper enqueues with `--match-head-commit`, never `--auto`, and only when
every required check is green. An armed auto-merge request on the pull request is a refusal, and
the wrapper disarms it.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1044,7 +1044,10 @@ any refusal is the blocker `unattended merge refused: <reason>`:
`manifest-review-required`, as does a listed key whose value has the wrong type. A test file
under a plugin `scripts/` directory, a `floor-paths` glob ending in a `scripts` directory or a
`floor-script-scopes` glob, named by no other changed file, takes no HIGH path floor hold for
that reason alone; the rule is in `babysit_risk.py`'s docstring. A C3 pull request also needs a
that reason alone; the rule is in `babysit_risk.py`'s docstring. A change that could add or
raise a charge scores HIGH as `cost-review-required` in both lanes, floor or not, until the body
links an owner `**Decision recorded.** Cost:` comment on the closing issue or the pull request
(`cost_decision`); which changes count is in that docstring. A C3 pull request also needs a
LOW blast-radius rating, which nothing supplies
yet, so it scores at least MED (`blast-radius-missing`) until a C3 rung adds that source.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3057,8 +3057,11 @@ def score_unattended(
if eligible.adoption
else frozenset()
)
if config.high_path_floor:
# The capability check compares base and head text (the floor only).
if config.high_path_floor or any(
babysit_risk.is_workflow_path(f.path) for f in files
):
# The capability check compares base and head text (the floor only);
# the cost hold reads a workflow's head text for its model legs.
files = merge_policy.with_capability_contents(repo, pr, files)
# The pr-merge lane's switches (owner decision Q624); the local lane
# keeps HIGH_FLOOR, both caps and the C2 contradictions.
Expand Down Expand Up @@ -3099,27 +3102,42 @@ def score_unattended(
and any(p and babysit_risk.eval_case_dirs(p) for p in changed)
else None
)
risk = babysit_risk.score(
babysit_risk.RiskInput(
work_class=eligible.work_class,
rung=babysit_risk.Rung[inputs["rung"].upper()],
files=files,
files_complete=complete,
max_changed_lines=config.max_changed_lines,
high_paths=config.denied_paths,
floor_paths=config.floor_paths,
floor_script_scopes=config.floor_script_scopes,
# The floor additions follow the switch in every lane.
floor_extensions=config.high_path_floor,
declarations=declarations,
agents=agents,
eval_cases=eval_cases,
lanes=lanes,
blast_radius=None,
floor_exempt=own,
**lane_switches,
)
private = json_object(json_object(pr.get("base")).get("repo")).get("private")
risk_input = babysit_risk.RiskInput(
work_class=eligible.work_class,
rung=babysit_risk.Rung[inputs["rung"].upper()],
files=files,
files_complete=complete,
max_changed_lines=config.max_changed_lines,
high_paths=config.denied_paths,
floor_paths=config.floor_paths,
floor_script_scopes=config.floor_script_scopes,
# The floor additions follow the switch in every lane.
floor_extensions=config.high_path_floor,
declarations=declarations,
agents=agents,
eval_cases=eval_cases,
lanes=lanes,
blast_radius=None,
floor_exempt=own,
public=True if private is False else None,
pulumi_dirs=merge_policy.pulumi_dirs_at(repo, pr),
**lane_switches,
)
risk = babysit_risk.score(risk_input)
if any(r.code == babysit_risk.COST_CODE for r in risk.reasons):
# Read only for a cost hold: an owner decision the body cites clears it.
decision = merge_policy.cost_decision(
repo,
pr,
eligible.issue,
permission=inputs["permission"],
agent_identity=inputs.get("identity"),
self_logins=self_logins,
)
if decision:
details["costDecision"] = decision
risk = babysit_risk.score(replace(risk_input, cost_decision=decision))
details |= {
"risk": {
"score": risk.score.name,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,13 +66,15 @@
glob_match,
is_capability_path,
is_case_file,
is_workflow_path,
is_manifest_path,
)
from babysit_util import (
is_json_array,
is_json_object,
json_array,
json_object,
parse_timestamp,
run_command,
)

Expand Down Expand Up @@ -1835,6 +1837,92 @@ def _closing_issue(pr: dict[str, Any], repo: str) -> tuple[dict[str, Any], str |
return issue, None


# --- Cost decision ------------------------------------------------------------------------

# A comment link in a pull request body, and the first line that makes a
# comment a decision (as select-trusted-text.mjs `DECISION` reads it).
COST_DECISION_LINK = re.compile(
r"https://github\.com/([A-Za-z0-9-]+)/([A-Za-z0-9._-]+)/(?:issues|pull)/"
r"([0-9]+)#issuecomment-([0-9]+)"
)
# A cost decision's first line: the decision marker, then `Cost:`, so a
# decision recorded for something else clears no cost hold.
DECISION_LINE = re.compile(r"^\*\*Decision recorded\.\*\*[ \t]+Cost:")
# An HTML comment in the body, or an unclosed one to the end: a link inside
# one is hidden from a reader, so it cites nothing.
HTML_COMMENT = re.compile(r"<!--.*?(?:-->|\Z)", re.DOTALL)
COMMENT_EDITED = "query PolicyCommentEdited($id:ID!){node(id:$id){... on IssueComment{lastEditedAt}}}"


def _after(value: Any, opened: datetime | None) -> bool:
made = parse_timestamp(value)
return made is not None and opened is not None and made > opened


def cost_decision(
repo: str,
pr: Mapping[str, Any],
issue: int | None,
*,
permission: Permission,
agent_identity: str | None,
self_logins: Collection[str] = frozenset(),
) -> str | None:
"""The first comment link in the body that is an owner decision covering
this pull request, else None: a comment on its closing issue or on the
pull request itself in `repo`, by a User with admin permission who is not
the agent identity, not posted through an App, never edited, and whose
first line starts `**Decision recorded.** Cost:`. One on the closing issue
counts only when created after the pull request was, so an older decision
on the issue cannot cover new work (an author can forge commit dates, not
these); one on the pull request postdates it already. A link inside an HTML
comment, and a comment by a `self_logins` account, count for nothing. A
read that fails clears nothing."""
owner, name = _split_repo(repo)
covered = {pr.get("number"), issue} - {None}
opened = parse_timestamp(pr.get("created_at"))
excluded = {_login_key(agent_identity)} | {_login_key(x) for x in self_logins}
body = HTML_COMMENT.sub("", str(pr.get("body") or ""))
for link in COST_DECISION_LINK.finditer(body):
if f"{link[1]}/{link[2]}".casefold() != repo.casefold():
continue
if int(link[3]) not in covered:
continue
status, comment = _get(f"repos/{owner}/{name}/issues/comments/{link[4]}")
if status != 200 or not is_json_object(comment):
continue
user = json_object(comment.get("user"))
login = user.get("login")
if (
not str(comment.get("issue_url") or "").endswith(f"/issues/{link[3]}")
or user.get("type") != "User"
or comment.get("performed_via_github_app") is not None
or not isinstance(login, str)
or _login_key(login) in excluded
or not DECISION_LINE.match(str(comment.get("body") or "").lstrip())
or (
int(link[3]) != pr.get("number")
and not _after(comment.get("created_at"), opened)
)
):
continue
try:
if permission(login) != "admin":
continue
node = _graphql(
COMMENT_EDITED, "cost decision", id=str(comment.get("node_id"))
).get("node")
except PolicyReadError:
continue
if (
is_json_object(node)
and "lastEditedAt" in node
and node["lastEditedAt"] is None
):
return link[0]
return None


def _foreign_refusal(pr: dict[str, Any], owner: str) -> str | None:
"""`fork` for a head outside this repository, `dependency-manager` for a
dependency manager's pull request, else None."""
Expand Down Expand Up @@ -3204,6 +3292,32 @@ def merged_since_floor(
# --- Changed files with git modes -------------------------------------------------------------


def pulumi_dirs_at(repo: str, pr: Mapping[str, Any]) -> tuple[str, ...] | None:
"""`babysit_risk.RiskInput.pulumi_dirs`: each directory holding a
`Pulumi.yaml` at the base ("" for the root); None when the base tree
cannot be listed whole."""
owner, name = _split_repo(repo)
base = str(json_object(pr.get("base")).get("sha"))
try:
if not SHA_RE.fullmatch(base):
raise PolicyReadError("the pull request record names no base SHA")
modes = _tree_modes(owner, name, base)
except (PolicyReadError, ValueError) as exc:
print(
f"Pulumi projects at the base: {type(exc).__name__}: {exc}", file=sys.stderr
)
return None
if modes is None:
return None
return tuple(
sorted(
path.rpartition("/")[0]
for path in modes
if path.rpartition("/")[2].casefold() == "pulumi.yaml"
)
)


def _tree_modes(owner: str, name: str, sha: str) -> dict[str, str] | None:
"""Each path's git mode at `sha`, or None when the listing is truncated:
from a local clone holding `sha` (`_clones_holding`), else the trees API."""
Expand Down Expand Up @@ -3419,7 +3533,7 @@ def _text_at(owner: str, name: str, path: str, sha: str) -> str | None:
def with_capability_contents(
repo: str, pr: Mapping[str, Any], files: tuple[ChangedFile, ...]
) -> tuple[ChangedFile, ...]:
"""Each changed capability file, manifest or eval case.yaml (not removed) with its base
"""Each changed capability file, manifest, eval case.yaml or workflow (not removed) with its base
text at the base SHA, from its previous path on a rename or copy, and its head text
at the head SHA, or the reason either could not be read."""
owner, name = _split_repo(repo)
Expand All @@ -3431,6 +3545,7 @@ def with_capability_contents(
is_capability_path(changed.path)
or is_manifest_path(changed.path)
or is_case_file(changed.path)
or is_workflow_path(changed.path)
):
read.append(changed)
continue
Expand Down
Loading
Loading