feat(robomp): integrated linting amends and commit message sanitization

- Updated `_run_pre_publish_bun_fix` to amend `bun run fix` output into HEAD instead of creating standalone `style:` commits.
- Added `_repair_commit_message_escapes` to detect and rewrite commit messages containing shell-literal `\n` sequences into real newlines.
- Enforced safety checks during `bun run fix` to ensure HEAD is mutable and locally authored before amending.
- Improved documentation in prompts and README regarding commit message formatting and formatter workflow changes.
This commit is contained in:
can1357
2026-07-02 22:55:07 +02:00
parent 6d2e9f8f7d
commit 22e9224ae8
4 changed files with 177 additions and 30 deletions
+5 -2
View File
@@ -148,9 +148,12 @@ The integration test spawns a real `omp --mode rpc` against an
- Pre-push gates (`gh_push_branch`): branch matches the workspace
branch, working tree clean, every commit on
`origin/<default>..HEAD` carries `ROBOMP_GIT_AUTHOR_NAME` +
`ROBOMP_GIT_AUTHOR_EMAIL`.
`ROBOMP_GIT_AUTHOR_EMAIL`. Commit messages carrying shell-literal
`\n` escapes (agents quoting `git commit -m 'a\n\nb'`) are rewritten
to real newlines — message-only, trees/identities/dates preserved.
- Pre-PR gates (`gh_open_pr`): when the repo defines them, `bun run fix`
runs first (any diff auto-committed as `style: bun run fix`) and then
runs first (any diff amended into the agent's HEAD commit — no
standalone `style:` noise commits) and then
`bun check`. A failing `bun check` returns to the agent as
`RpcCommandError` for iteration.
- `gh_open_pr` validates `## Repro` / `## Cause` / `## Fix` /
+167 -23
View File
@@ -56,7 +56,6 @@ _AGENT_HOME = Path("/srv/agent-home")
_PRE_PR_FIX_TIMEOUT_SECONDS = 600.0
_PRE_PR_CHECK_TIMEOUT_SECONDS = 600.0
_PRE_PR_CHECK_MAX_OUTPUT = 12_000
_PRE_PR_FIX_COMMIT_SUBJECT = "style: bun run fix"
@dataclass(slots=True)
@@ -228,8 +227,12 @@ def _run_repo_command(
cmd: list[str] | tuple[str, ...],
*,
timeout: float | None = None,
extra_env: Mapping[str, str] | None = None,
) -> subprocess.CompletedProcess[str]:
"""Run a repo-local command with agent-equivalent permissions and env."""
env = _repo_command_env(bindings)
if extra_env:
env.update(extra_env)
return subprocess.run(
list(cmd),
cwd=str(bindings.workspace.repo_dir),
@@ -237,7 +240,7 @@ def _run_repo_command(
capture_output=True,
text=True,
timeout=timeout,
env=_repo_command_env(bindings),
env=env,
**_slot_subprocess_kwargs(bindings.slot_uid),
)
@@ -347,12 +350,18 @@ def _run_pre_publish_bun_fix(
stage: str,
skip_checks: bool = False,
) -> None:
"""Run `bun run fix` then commit any working-tree diff as the bot.
"""Run `bun run fix` then amend any working-tree diff into HEAD.
Silently no-ops when the repository does not define a `scripts.fix`
entry. Anything the formatter touches gets folded into a fresh
`style: bun run fix` commit so the downstream cleanliness gate sees a
pristine worktree.
entry. Anything the formatter touches gets amended into the agent's HEAD
commit so the downstream cleanliness gate sees a pristine worktree
without littering PR history with standalone `style:` commits. Amending
an already-pushed HEAD is safe: the push transport uses
`--force-with-lease`, which exists precisely to recover from local
history rewrites. When there is no commit that may safely absorb the
diff — HEAD sits on `origin/<base>` (pre-existing formatter drift) or is
foreign-authored — the tool refuses with instructions instead of
guessing.
`tool_name` is the host tool calling this (audit attribution).
`stage` is the human-readable verb used in error wording — "open PR"
@@ -367,7 +376,7 @@ def _run_pre_publish_bun_fix(
if not _has_bun_script(bindings.workspace.repo_dir, "fix"):
return
# Dirty-tree gate BEFORE the formatter so any pre-existing uncommitted
# edit isn't silently swept into the `style: bun run fix` commit by the
# edit isn't silently swept into the formatter amend by the
# `git add -A` below. The agent owns the worktree end-to-end; any diff
# not already in a commit is a workflow bug it must resolve before we
# mutate the tree further.
@@ -378,8 +387,8 @@ def _run_pre_publish_bun_fix(
f"refusing to {stage}: dirty worktree before `bun run fix`.\n "
f"{dirty}\n"
"Commit (or `git stash`) every change before invoking the formatter — "
"anything left uncommitted would be folded into the `style: bun run fix` "
"commit and silently land in the PR."
"anything left uncommitted would be amended into your HEAD commit "
"and silently land in the PR."
)
_audit(bindings, tool_name, args, error=msg)
_raise_command(msg)
@@ -422,28 +431,48 @@ def _run_pre_publish_bun_fix(
if not status.stdout.strip():
return
# The formatter produced a diff. Fold it into the agent's HEAD commit —
# but only when HEAD is a bot-authored commit not already on the base
# branch. Amending a commit `origin/<base>` contains would rewrite
# shared history; amending a foreign-authored commit would bury our
# diff in someone else's work.
base = bindings.repo.default_branch
ahead = _run_repo_command(bindings, ["git", "rev-list", "-n", "1", f"origin/{base}..HEAD"])
if ahead.returncode != 0 or not ahead.stdout.strip():
msg = (
f"refusing to {stage}: `bun run fix` changed files, but there is no commit of "
f"yours to fold them into — the checkout matches `origin/{base}`, so the "
f"formatter drift pre-exists on `{base}`. Inspect with `git status` / `git diff`; "
"either commit the formatter output yourself or discard it "
"(`git checkout -- . && git clean -fd`) and retry with `skip_checks=true`, "
"documenting the bypass."
)
_audit(bindings, tool_name, args, error=msg)
_raise_command(msg)
head_identity = _run_repo_command(bindings, ["git", "log", "-1", "--format=%an%x1f%ae", "HEAD"])
if head_identity.returncode != 0 or head_identity.stdout.strip("\n").split("\x1f") != [
bindings.author_name,
bindings.author_email,
]:
author = head_identity.stdout.strip("\n").replace("\x1f", " <") + ">"
msg = (
f"refusing to {stage}: `bun run fix` changed files, but HEAD is authored by "
f"{author} — refusing to fold the formatter diff into a foreign commit. "
"Fix the identity first (`git commit --amend --reset-author --no-edit`) and retry."
)
_audit(bindings, tool_name, args, error=msg)
_raise_command(msg)
add = _run_repo_command(bindings, ["git", "add", "-A"])
if add.returncode != 0:
err = (add.stderr or add.stdout).strip()
msg = f"refusing to {stage}: `git add -A` failed after `bun run fix`: {err}"
_audit(bindings, tool_name, args, error=msg)
_raise_command(msg)
commit = _run_repo_command(
bindings,
[
"git",
"-c",
f"user.email={bindings.author_email}",
"-c",
f"user.name={bindings.author_name}",
"commit",
"-m",
_PRE_PR_FIX_COMMIT_SUBJECT,
],
)
commit = _run_repo_command(bindings, ["git", "commit", "--amend", "--no-edit"])
if commit.returncode != 0:
err = (commit.stderr or commit.stdout).strip()
msg = f"refusing to {stage}: failed to commit `bun run fix` changes: {err}"
msg = f"refusing to {stage}: failed to amend `bun run fix` changes into HEAD: {err}"
_audit(bindings, tool_name, args, error=msg)
_raise_command(msg)
@@ -610,6 +639,118 @@ def _build_post_comment(bindings: ToolBindings) -> HostTool[Any, Any]:
)
def _repair_message_escapes(message: str) -> str | None:
"""Convert shell-literal ``\\n`` escapes in a commit message to newlines.
Agents regularly run ``git commit -m 'subject\\n\\nbody'`` with single
quotes, recording the two-character backslash-n sequence instead of a
newline — the message then renders as one line of ``\\n``-littered text on
GitHub. Escapes inside backtick code spans (`` `\\n` ``) are genuine
content and are preserved.
Returns the repaired message, or ``None`` when nothing needs repair.
"""
if "\\n" not in message:
return None
parts = message.split("`")
changed = False
for i in range(0, len(parts), 2): # even indexes sit outside code spans
fixed = parts[i].replace("\\r\\n", "\n").replace("\\n", "\n")
if fixed != parts[i]:
parts[i] = fixed
changed = True
return "`".join(parts) if changed else None
def _repair_commit_message_escapes(bindings: ToolBindings, args: Mapping[str, Any], *, tool_name: str) -> None:
"""Rewrite unpushed commits whose messages carry literal ``\\n`` escapes.
Rebuilds ``origin/<base>..HEAD`` with ``git commit-tree``, preserving
every tree, parent topology, identity, and date — only messages change.
Safe against already-pushed commits: the push transport uses
``--force-with-lease``. Best effort: any git failure leaves the branch
untouched (the push proceeds with the ugly message rather than being
blocked on cosmetics), and the branch ref only moves via a compare-and-
swap ``update-ref`` at the very end.
"""
base = bindings.repo.default_branch
rev_list = _run_repo_command(bindings, ["git", "rev-list", "--reverse", f"origin/{base}..HEAD"])
if rev_list.returncode != 0:
return
shas = rev_list.stdout.split()
if not shas:
return
messages: dict[str, str] = {}
repaired: list[str] = []
for sha in shas:
show = _run_repo_command(bindings, ["git", "log", "-1", "--format=%B", sha])
if show.returncode != 0:
return
message = show.stdout
fixed = _repair_message_escapes(message)
if fixed is not None:
message = fixed
repaired.append(sha)
messages[sha] = message
if not repaired:
return
needs_fix = set(repaired)
rewritten: dict[str, str] = {}
for sha in shas:
meta = _run_repo_command(
bindings,
["git", "log", "-1", "--format=%T%x1f%P%x1f%an%x1f%ae%x1f%aI%x1f%cn%x1f%ce%x1f%cI", sha],
)
if meta.returncode != 0:
return
fields = meta.stdout.strip("\n").split("\x1f")
if len(fields) != 8:
return
tree, parents_raw, a_name, a_email, a_date, c_name, c_email, c_date = fields
parents_old = parents_raw.split()
parents_new = [rewritten.get(p, p) for p in parents_old]
if sha not in needs_fix and parents_new == parents_old:
rewritten[sha] = sha
continue
cmd = ["git", "commit-tree", tree]
for parent in parents_new:
cmd += ["-p", parent]
cmd += ["-m", messages[sha].rstrip("\n")]
made = _run_repo_command(
bindings,
cmd,
extra_env={
"GIT_AUTHOR_NAME": a_name,
"GIT_AUTHOR_EMAIL": a_email,
"GIT_AUTHOR_DATE": a_date,
"GIT_COMMITTER_NAME": c_name,
"GIT_COMMITTER_EMAIL": c_email,
"GIT_COMMITTER_DATE": c_date,
},
)
if made.returncode != 0 or not made.stdout.strip():
return
rewritten[sha] = made.stdout.strip()
old_head, new_head = shas[-1], rewritten[shas[-1]]
update = _run_repo_command(
bindings,
["git", "update-ref", "-m", "robomp: repaired commit message escapes", "HEAD", new_head, old_head],
)
if update.returncode != 0:
log.warning(
"commit message escape repair failed at update-ref",
extra={"issue": bindings.issue_key, "err": (update.stderr or update.stdout).strip()},
)
return
_audit(bindings, tool_name, args, result={"repaired_commit_messages": [sha[:12] for sha in repaired]})
log.info(
"repaired commit message escapes",
extra={"issue": bindings.issue_key, "commits": [sha[:12] for sha in repaired]},
)
def _guarded_push_branch(bindings: ToolBindings, args: Mapping[str, Any], tool_name: str, branch: str) -> str:
if bindings.review_mode:
msg = "refusing to push: PR review worktrees are read-only."
@@ -622,6 +763,9 @@ def _guarded_push_branch(bindings: ToolBindings, args: Mapping[str, Any], tool_n
# Re-pin the configured identity right before push (cheap; idempotent).
_run_repo_command(bindings, ["git", "config", "user.email", bindings.author_email])
_run_repo_command(bindings, ["git", "config", "user.name", bindings.author_name])
# Cosmetic repair BEFORE the head snapshot: commits whose messages carry
# shell-literal `\n` escapes are rewritten in place (message-only).
_repair_commit_message_escapes(bindings, args, tool_name=tool_name)
repo_dir_path = bindings.workspace.repo_dir
head_proc = _run_repo_command(bindings, ["git", "rev-parse", "HEAD"])
if head_proc.returncode != 0:
+2 -2
View File
@@ -37,14 +37,14 @@ body = "Markdown comment body."
number = "Optional issue/PR override. Defaults to the inbound thread."
[gh_push_branch]
description = "Push the workspace branch to origin. Pre-publish gate (when the repo defines them): `bun run fix` → auto-commit any formatter diff as `style: bun run fix` → `bun check`. On `bun check` failure, fix the cause and retry. Pre-existing breakage on `main` against the same paths NOT caused by your diff → retry with `skip_checks=true` and document the bypass in the follow-up comment. Dirty-tree gate runs unconditionally."
description = "Push the workspace branch to origin. Pre-publish gate (when the repo defines them): `bun run fix` → amend any formatter diff into your HEAD commit → `bun check`. On `bun check` failure, fix the cause and retry. Pre-existing breakage on `main` against the same paths NOT caused by your diff → retry with `skip_checks=true` and document the bypass in the follow-up comment. Dirty-tree gate runs unconditionally. Commit messages carrying shell-literal `\\n` escapes are rewritten to real newlines before the push."
[gh_push_branch.parameters]
branch = "Optional branch override; defaults to the workspace branch."
skip_checks = "Bypass `bun run fix` + `bun check`. Use ONLY after verifying (e.g. `git diff origin/<default>..HEAD` against the failing paths) the failure exists on `main` and is NOT caused by your diff. Dirty-tree gate still runs — commit everything first."
[gh_open_pr]
description = "Open a PR from the workspace branch using the four-section body template. Same pre-publish gate as `gh_push_branch`: `bun run fix` → auto-commit formatter diff as `style: bun run fix` → `bun check`. On failure, fix and retry. Pre-existing `main` breakage NOT caused by your diff → `skip_checks=true` and document the bypass in the PR's `## Verification` section."
description = "Open a PR from the workspace branch using the four-section body template. Same pre-publish gate as `gh_push_branch`: `bun run fix` → amend formatter diff into HEAD → `bun check`. On failure, fix and retry. Pre-existing `main` breakage NOT caused by your diff → `skip_checks=true` and document the bypass in the PR's `## Verification` section."
[gh_open_pr.parameters]
body = "Markdown body. MUST contain the four template sections in order: `## Repro`, `## Cause`, `## Fix`, `## Verification`."
+3 -3
View File
@@ -41,9 +41,9 @@ NEVER apply `provider` or `platform` speculatively. They REQUIRE explicit eviden
4. **Diagnose.** Locate the offending code; name the cause concretely.
5. **Fix.** Smallest diff that addresses the cause. Add or update tests that would have caught the regression. For `documentation`, the doc IS the artifact; re-read the diff as the "test".
6. **Test.** Run affected tests; iterate until green.
7. **Polish (MAY).** Run the repo formatter before committing for clean per-commit diffs. `gh_push_branch` and `gh_open_pr` also run `bun run fix` and fold remaining diff into a `style:` commit, so skipping is safe.
8. **Commit.** Conventional subject (`fix(scope): …` / `docs: …`). End the body with `Fixes #{{issue.number}}` so reviewers see the linkage at commit level.
9. **Publish.** Call `gh_push_branch`, then `gh_open_pr`. Both deterministically run `bun run fix` (auto-committing as `style: bun run fix`) then `bun check` before touching the remote. The same gate runs on every follow-up `gh_push_branch`. The tools also refuse dirty trees and commit-author mismatches.
7. **Polish (MAY).** Run the repo formatter before committing for clean per-commit diffs. `gh_push_branch` and `gh_open_pr` also run `bun run fix` and amend any remaining diff into your HEAD commit, so skipping is safe.
8. **Commit.** Conventional subject (`fix(scope): …` / `docs: …`). Write the body with REAL newlines — use multiple `-m` flags or `git commit -F <file>`; a quoted `\n` inside `-m '…'` lands on GitHub as literal backslash-n. End the body with `Fixes #{{issue.number}}` so reviewers see the linkage at commit level.
9. **Publish.** Call `gh_push_branch`, then `gh_open_pr`. Both deterministically run `bun run fix` (amending any formatter diff into your HEAD commit) then `bun check` before touching the remote. The same gate runs on every follow-up `gh_push_branch`. The tools also refuse dirty trees and commit-author mismatches.
- `bun check` failed? Fix at the source, commit, call again.
- **Escape hatch — `skip_checks=true`.** ONLY for breakage you have VERIFIED is pre-existing on the default branch. Verify by running the same command against the same paths on a clean checkout of the default branch and confirming the identical failure. NEVER use it to bypass a failure your diff introduced, and NEVER for transient or unclear failures. Document the bypass in the PR's `## Verification` section, one sentence: ``bun check` fails on `main` for unrelated reason X; skipped pre-publish gate.`
- **NEVER tamper with git internals.** No editing `.git`/`gitdir:` pointers, no chown/chmod on worktree files, no `safe.directory` overrides, no pointing HEAD at a fabricated commit. Push refused for reasons you cannot resolve? Ask the maintainer via `gh_post_comment`. Environmental/orchestrator defect that's not the reporter's problem (broken permissions, corrupted git metadata, missing tools)? Call `abort_task` with the diagnosis — silent abandonment, no comment leaked to the reporter. NEVER improvise.