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
5 changes: 5 additions & 0 deletions code-mower-package-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,11 @@
"source": "src/code_mower/bootstrap.py",
"target": "src/code_mower/bootstrap.py"
},
{
"kind": "core",
"source": "src/code_mower/branch_policy.py",
"target": "src/code_mower/branch_policy.py"
},
{
"kind": "core",
"source": "src/code_mower/builder_experiment.py",
Expand Down
9 changes: 7 additions & 2 deletions docs/devin-work-orders.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,16 @@ from code_mower.devin_work_orders import DevinWorkOrders, WorkOrder
from code_mower.github_builder_evidence import GitHubBuilderEvidence

# Inputs below come from the authenticated dispatcher, not a provider response.
# The loaded Code Mower config (or an explicit BranchPolicy) is required so a
# configured repositories[].delivery_policy cannot be skipped by omission.
policy = WorkOrder.repository_policy(config, "owner/repo")
branch = WorkOrder.resolve_branch(policy, lane="devin", issue=907,
work_item=tracker_key_or_empty, slug=issue_title)
order = WorkOrder.from_manifest(
approved_manifest, approved_markdown,
repository="owner/repo", issue=907, branch="devin/907", base="main",
repository="owner/repo", issue=907, branch=branch, base="main",
author_id=expected_github_user_id, author_login=expected_github_login,
acu_limit=5,
acu_limit=5, config=config,
)
builder = DevinWorkOrders.hosted(
private_state_root,
Expand Down
45 changes: 45 additions & 0 deletions docs/github-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -669,6 +669,51 @@ default-branch definitions. Record the same token's expiry date in
`owner_surface.dispatch_token_expires_var` so `doctor --github` can report the
rotation countdown.

### Repository branch-name policy

Provenance and accepted branch names are separate contracts. Without
configuration, builders open provider-prefixed branches (`codex/907-slug`,
`muse/MB-9506-slug`) and `builder_identity.branch_prefixes` infers the lane from
that prefix. When a target repository only accepts its own naming scheme, set a
delivery policy on that repository; the branch is then resolved and validated
before any provider run, push, or PR open, and a nonconforming branch fails with
the expected pattern and one valid example instead of a closed-and-reopened PR:

```yaml
repositories:
- slug: owner/example
default_branch: main
delivery_policy:
branch_template: "fix/{issue_key}-{slug}" # MB-9506 -> fix/MB-9506-nv-accessible-label
```

Allowed template variables are `{lane}`, `{issue_key}`, `{issue_number}`,
`{slug}`, `{work_type}`, and `{repo_name}`; a template must include
`{issue_key}` or `{issue_number}`. `{issue_key}` is the tracker key when the
work item is bound to one and the GitHub issue number otherwise; an empty
`{slug}` drops itself and its leading separator. A policy branch such as
`fix/MB-9506-…` does not encode the builder, so provenance stays with the
`builder:<lane>` label, the authenticated PR author, the PR marker, and the
builder-run sidecar (`muse_cli` runs remain `builder:muse`). Repositories
without `delivery_policy` keep the provider-prefix convention unchanged.

Because a policy branch is shared naming space, an existing branch is writable
only when exactly one same-repository PR carries the current lane's provenance
and its `headRefOid` equals the branch's current remote head. The runner pins
that observed head, or pins the branch as absent for a first delivery, in the
pre-push guard. A concurrent create or advance then fails before it can be
overwritten. Recovery handoff may transfer ownership of a policy-conforming
target, but it never overrides the repository's branch-name policy.
For issue delivery, an existing PR's exact closing-issue relationship is the
stable lookup key: the runner enumerates open PRs to a declared completeness
bound and matches both repository and issue number from GitHub's structured
reference, including Development-sidebar links that have no body mention. If
the issue title changes, the runner reuses that PR's policy-conforming branch
instead of deriving a second slug. Incomplete enumeration, multiple matches,
foreign ownership, or a nonconforming existing branch fail closed. Each guard
installation also atomically replaces its private, within-run pushed-head
ledger before recording new authority.

Branch protection should require the `code-mower/gate` commit status from **Any
source**, alongside normal CI, before autonomous merge is trusted. Do not select
the GitHub Actions source for `code-mower/gate` in the branch-protection UI:
Expand Down
229 changes: 229 additions & 0 deletions src/code_mower/branch_policy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
"""Repository branch-name policy for builder delivery.

A repository's accepted branch names and a builder's provenance are distinct
contracts. ``builder_identity.branch_prefixes`` maps observed prefixes to lanes
for provenance inference; ``repositories[].delivery_policy.branch_template``
says which branch a builder may open for a work item. When a repository policy
cannot encode the provider (``fix/…``), provenance stays with ``builder:<lane>``
labels, the authenticated PR author, PR markers, and builder-run sidecars.

Only the documented template variables are accepted. Resolution and validation
use repository slug, issue key, work type, lane, and branch-name metadata only.
"""
from __future__ import annotations

import re
from dataclasses import dataclass
from typing import Any, Mapping

DEFAULT_TEMPLATE = "{lane}/{issue_key}-{slug}"
TEMPLATE_VARIABLES: Mapping[str, str] = {
"lane": r"[a-z0-9][a-z0-9-]*",
"issue_key": r"[A-Za-z0-9][A-Za-z0-9_-]*",
"issue_number": r"[0-9]+",
"slug": r"[a-z0-9][a-z0-9-]*",
"work_type": r"[a-z][a-z0-9-]*",
"repo_name": r"[A-Za-z0-9._-]+",
}
EXAMPLE_VALUES: Mapping[str, str] = {
"lane": "codex",
"issue_key": "ABC-123",
"issue_number": "123",
"slug": "short-description",
"work_type": "fix",
"repo_name": "repo",
}
MAX_BRANCH_LENGTH = 200
MAX_SLUG_LENGTH = 48
_VARIABLE_RE = re.compile(r"\{([^{}]*)\}")
_OPTIONAL_SLUG_RE = re.compile(r"([-_/.])\{slug\}")


class BranchPolicyError(ValueError):
"""A template, variable, or proposed branch violates the delivery policy."""


@dataclass(frozen=True)
class BranchPolicy:
template: str
pattern: str
example: str
configured: bool

def describe(self) -> dict[str, str]:
return {"template": self.template, "pattern": self.pattern, "example": self.example}


def is_valid_ref(branch: Any) -> bool:
"""A conservative subset of git-check-ref-format for branch names."""
return (isinstance(branch, str) and 0 < len(branch) <= MAX_BRANCH_LENGTH
and re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9/_.-]*", branch) is not None
and not any(x in branch for x in ("..", "//", "@{"))
and all(not p.startswith(".") and not p.endswith((".", ".lock"))
for p in branch.split("/")) and not branch.endswith("/"))


def slugify(text: Any, *, limit: int = MAX_SLUG_LENGTH) -> str:
"""Lowercase ASCII words joined by ``-``; empty when nothing survives."""
if not isinstance(text, str):
return ""
slug = re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-")
slug = slug[:limit].rstrip("-")
return slug


def _variables(template: str) -> list[str]:
names = _VARIABLE_RE.findall(template)
unknown = [name for name in names if name not in TEMPLATE_VARIABLES]
if unknown:
raise BranchPolicyError(
f"unknown template variable {{{unknown[0]}}}; allowed variables are "
+ ", ".join(f"{{{name}}}" for name in TEMPLATE_VARIABLES)
)
return names


def compile_template(template: Any, *, configured: bool = True) -> BranchPolicy:
"""Validate a template and derive its accepted pattern and one example."""
if not isinstance(template, str) or not template.strip():
raise BranchPolicyError("branch_template must be a non-empty string")
if template != template.strip() or "{" in _VARIABLE_RE.sub("", template) \
or "}" in _VARIABLE_RE.sub("", template):
raise BranchPolicyError("branch_template has unbalanced braces or surrounding whitespace")
names = _variables(template)
if "issue_key" not in names and "issue_number" not in names:
raise BranchPolicyError(
"branch_template must include {issue_key} or {issue_number} so each work item "
"resolves to its own branch"
)
pattern = _pattern(template)
example = render(template, EXAMPLE_VALUES)
Comment on lines +86 to +100

@gitar-bot gitar-bot Bot Sep 13, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Edge Case: Leading {slug} in a branch_template silently breaks on empty slugs

compile_template() always renders its validation example with EXAMPLE_VALUES["slug"] non-empty (branch_policy.py:100, 28-35), so a template like {slug}-{issue_key} or {slug}/{issue_key} (with no character before {slug}) passes config validation. _OPTIONAL_SLUG_RE (branch_policy.py:39) only strips a preceding separator, so at runtime, when a real issue title slugifies to empty (e.g. an emoji-only title), render() produces a branch starting with - (e.g. -ABC-1), which is_valid_ref then rejects, failing the whole delivery for that issue. It fails closed, but the admin's config was accepted as valid and the failure only surfaces later on specific issue titles. Consider having compile_template also render once with an empty slug to catch this class of template at config-validation time, or document that {slug} must not be the first token.

Validate the template with both an empty and non-empty slug during compile_template:

pattern = _pattern(template)
example = render(template, EXAMPLE_VALUES)
if not is_valid_ref(example) or re.fullmatch(pattern, example) is None:
    raise BranchPolicyError(
        f"branch_template {template!r} does not render to a valid git branch name"
    )
empty_slug_values = {**EXAMPLE_VALUES, "slug": ""}
empty_slug_example = render(template, empty_slug_values)
if not is_valid_ref(empty_slug_example):
    raise BranchPolicyError(
        f"branch_template {template!r} does not render to a valid git branch name when "
        "the slug is empty; {slug} must be preceded by -, _, /, or . in the template"
    )

Was this helpful? React with 👍 / 👎

if not is_valid_ref(example) or re.fullmatch(pattern, example) is None:
raise BranchPolicyError(
f"branch_template {template!r} does not render to a valid git branch name"
)
return BranchPolicy(template, pattern, example, configured)


def _pattern(template: str) -> str:
parts: list[str] = []
index = 0
for match in _VARIABLE_RE.finditer(template):
literal = template[index:match.start()]
name = match.group(1)
if name == "slug" and literal and literal[-1] in "-_/.":
parts.append(re.escape(literal[:-1]))
parts.append(f"(?:{re.escape(literal[-1])}{TEMPLATE_VARIABLES[name]})?")
else:
parts.append(re.escape(literal))
parts.append(TEMPLATE_VARIABLES[name])
index = match.end()
parts.append(re.escape(template[index:]))
return "".join(parts)


def render(template: str, values: Mapping[str, str]) -> str:
"""Substitute variables; an empty slug drops itself and its leading separator."""
if not values.get("slug"):
template = _OPTIONAL_SLUG_RE.sub("", template)
return _VARIABLE_RE.sub(lambda m: values.get(m.group(1), ""), template)


def default_policy() -> BranchPolicy:
"""The provider-prefix convention used when no delivery policy is configured."""
return compile_template(DEFAULT_TEMPLATE, configured=False)


def policy_for_repository(config: Mapping[str, Any], repository: str) -> BranchPolicy:
"""The delivery policy of ``repository`` in ``config``, or the default."""
for repo in config.get("repositories") or ():
if not isinstance(repo, Mapping):
continue
slug = repo.get("slug")
if not isinstance(slug, str) or slug.lower() != repository.lower():
continue
delivery = repo.get("delivery_policy")
if isinstance(delivery, Mapping) and delivery.get("branch_template") is not None:
return compile_template(delivery["branch_template"])
break
return default_policy()


def policies_by_repository(config: Mapping[str, Any]) -> dict[str, BranchPolicy]:
"""Lower-cased repository slug to its configured policy.

Lookup is case-insensitive, so two repository entries that differ only by
case would otherwise let one policy silently shadow the other; that is an
error here rather than a lookup-order accident.
"""
policies: dict[str, BranchPolicy] = {}
seen: set[str] = set()
for repo in config.get("repositories") or ():
if not isinstance(repo, Mapping) or not isinstance(repo.get("slug"), str):
continue
slug = repo["slug"].lower()
if slug in seen:
raise BranchPolicyError(f"duplicate repository {repo['slug']!r} (slugs compare case-insensitively)")
seen.add(slug)
delivery = repo.get("delivery_policy")
if isinstance(delivery, Mapping) and delivery.get("branch_template") is not None:
policies[slug] = compile_template(delivery["branch_template"])
return policies


def configured_policies(config: Mapping[str, Any]) -> dict[str, dict[str, str]]:
"""Lower-cased repository slug to pattern/example for configured policies only."""
return {slug: policy.describe() for slug, policy in policies_by_repository(config).items()}


def resolve_branch(policy: BranchPolicy, *, lane: str, issue_number: Any = None,
issue_key: str = "", slug: str = "", work_type: str = "fix",
repository: str = "") -> str:
"""Render the branch for one work item and validate it against ``policy``.

``{issue_key}`` is the tracker key when bound, otherwise the GitHub issue
number; a template that needs either fails when both are missing.
"""
number = "" if issue_number is None else str(issue_number)
if issue_key and not re.fullmatch(TEMPLATE_VARIABLES["issue_key"], issue_key):
raise BranchPolicyError("issue_key must be alphanumeric with - or _ separators")
if number and not number.isdigit():
raise BranchPolicyError("issue_number must be a positive integer")
key = issue_key or number
names = _variables(policy.template)
if ("issue_key" in names and not key) or ("issue_number" in names and not number):
raise BranchPolicyError(
f"branch_template {policy.template!r} needs an issue key or number, and the "
"work item has neither"
)
values = {
"lane": slugify(lane),
"issue_key": key,
"issue_number": number,
"slug": slugify(slug),
"work_type": slugify(work_type),
"repo_name": repository.rsplit("/", 1)[-1] if repository else "",
}
for name in names:
if name not in ("slug", "issue_key", "issue_number") and not values[name]:
raise BranchPolicyError(f"branch_template needs {{{name}}}, which is unavailable")
branch = render(policy.template, values)
validate_branch(policy, branch)
return branch


def validate_branch(policy: BranchPolicy, branch: Any) -> str:
"""Reject a proposed branch before any create, push, or PR open."""
if not is_valid_ref(branch):
raise BranchPolicyError(
f"proposed branch {branch!r} is not a valid git branch name; "
f"expected one matching {policy.pattern} (for example {policy.example})"
)
if re.fullmatch(policy.pattern, branch) is None:
source = "repository delivery_policy.branch_template" if policy.configured \
else "the provider-prefix convention"
raise BranchPolicyError(
f"proposed branch {branch!r} does not match {source} {policy.template!r}; "
f"expected {policy.pattern} (for example {policy.example})"
)
return branch
25 changes: 23 additions & 2 deletions src/code_mower/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,12 @@
import audit_limits # type: ignore
import tracker_contract # type: ignore
import context_contract # type: ignore
import branch_policy # type: ignore
else: # pragma: no cover - exercised after package extraction.
from . import audit_limits
from . import tracker_contract
from . import context_contract
from . import branch_policy


ALLOWED_LANE_TYPES = {"audit", "review"}
Expand Down Expand Up @@ -495,6 +497,20 @@ def _validate_tracker(tracker: Any, issues: list[ConfigIssue]) -> None:
)


def _validate_delivery_policy(value: Any, path: str, issues: list[ConfigIssue]) -> None:
policy_map = _as_mapping(value, path, issues)
for key in policy_map:
if key != "branch_template":
issues.append(ConfigIssue(f"{path}.{key}", "must be branch_template"))
if "branch_template" not in policy_map:
issues.append(ConfigIssue(f"{path}.branch_template", "is required"))
return
try:
branch_policy.compile_template(policy_map.get("branch_template"))
except branch_policy.BranchPolicyError as exc:
issues.append(ConfigIssue(f"{path}.branch_template", str(exc)))


def validate_config(config: Mapping[str, Any]) -> list[ConfigIssue]:
issues: list[ConfigIssue] = []
from .participants import configured_participants, configured_transports
Expand Down Expand Up @@ -522,10 +538,15 @@ def validate_config(config: Mapping[str, Any]) -> list[ConfigIssue]:
repo_map = _as_mapping(repo, path, issues)
slug = _require_string(repo_map.get("slug"), f"{path}.slug", issues)
_require_string(repo_map.get("default_branch"), f"{path}.default_branch", issues)
if slug and slug in seen_repos:
# Policy lookup compares slugs case-insensitively, so duplicates must too.
if slug and slug.lower() in seen_repos:
issues.append(ConfigIssue(f"{path}.slug", f"duplicate repository {slug}"))
if slug:
seen_repos.add(slug)
seen_repos.add(slug.lower())
if repo_map.get("delivery_policy") is not None:
_validate_delivery_policy(
repo_map.get("delivery_policy"), f"{path}.delivery_policy", issues
)

lanes = _as_mapping(config.get("lanes"), "lanes", issues)
if (
Expand Down
Loading
Loading