Safe body edits

madtea has no command that overwrites an issue body or pull-request body wholesale. Every edit is surgical and anchored: you change a specific span, line, or named region, and the write is rejected if the text you anchored to has drifted or the body changed underneath you. That makes silent clobbering — by an AI agent working from a stale read, or by two writers racing — structurally impossible rather than merely discouraged. (Comments are the one surface Gitea gives no version token: there comment-edit does a full-body replace guarded by an --expected-body check — required on the MCP/agent surface, optional on the CLI. See Comments.)

Why

A forge body is often carefully authored: repro steps, acceptance criteria, links, a checklist. The classic failure mode is handing that body to something that “updates the status” by replacing the whole thing with its own summary — the original content gone, no diff, no undo. madtea removes the primitive that makes this possible. There is no --body-style full replace on the editing surface; to change a body you name what changes, and the change is verified against the live text before it lands.

The protected cycle

Every body op runs the same cycle:

  1. Read the live body and its optimistic-lock version (content_version).
  2. Apply the change in memory.
  3. Write the result back under that version.

The write is rejected — and nothing changes — when:

  • drifted anchor — the text you targeted is no longer present (stale), or matches more than once when a unique match was required (ambiguous);
  • lost update (HTTP 412) — the body changed between the read and the write.

On rejection, madtea returns the current body and the reason, so you can re-author against fresh state in a single round-trip.

The ops

All four exist for issue bodies (madtea issue body <op>) and pull-request bodies (madtea pr body <op>), and as MCP actions (madt_issues action=body op=patch|append|task_toggle|section_replace, and the same on madt_prs).

patch — anchored replacements

Replace one or more exact spans. By default each --old must match exactly once; pair a replacement with --replace-all to substitute every occurrence instead.

madtea issue body patch 42 --old "Status: in review" --new "Status: shipped"
madtea issue body patch 42 --old "v0.1" --new "v0.2" --replace-all

For several replacements, repeat --old/--new (the i-th --old pairs with the i-th --new), or pass a JSON array:

madtea issue body patch 42 '[{"old":"foo","new":"bar"},{"old":"baz","new":"qux"}]'

A --old that isn’t found (drifted) or matches more than once without --replace-all (ambiguous) rejects the whole patch — nothing is written.

append — add to the end

No anchor is needed; the end of the body is unambiguous. The write still runs the protected cycle, so it is rejected on a 412 if the body changed between read and write. Existing content is never disturbed. Content comes from the inline arg, --file, or stdin.

madtea issue body append 42 "## Update\n- shipped to staging"
madtea issue body append 42 --file note.md

task-toggle — flip one checklist line

Set the single matched - [ ] / - [x] line to an explicit state. The match — a substring or issue-ref — must identify exactly one checklist line; zero or multiple matches reject (ask for more distinguishing text). The target state is explicit (--checked / --unchecked), not a blind flip, so the command is idempotent: a line already in the requested state is a no-op-with-notice, not an error. Only the checkbox marker is rewritten; the rest of the line is preserved byte-for-byte.

madtea issue body task-toggle 42 "ship the docs" --checked
madtea issue body task-toggle 42 "#1357" --unchecked

section-replace — rewrite a named region

Replace the content between named markers, which render invisibly in Gitea:

<!-- madtea:section:status -->
…content…
<!-- /madtea:section:status -->

Only the content between the markers is replaced; the markers and everything outside the region are preserved. Content comes from the inline arg, --file, or stdin.

madtea issue body section-replace 42 status "All green ✅"
madtea issue body section-replace 42 status --file status.md --ensure

Absent markers reject, unless --ensure seeds a fresh marked block (with the given content) at the end of the body first; --ensure is idempotent (a no-op when the section already exists). Unbalanced markers — duplicated, or end before start — always reject.

Comments

Comments have no server-side version token on Gitea, so comment-edit guards with a client-side check: pass the comment’s current body as --expected-body, and the edit is rejected (and the live body shown) if the comment changed since you read it.

madtea issue comment-edit 123 "Updated text" --expected-body "Original text"
madtea pr comment-edit 123 "Updated text" --expected-body "Original text"

On the MCP (agent) surface, expected_body is required — an agent cannot blindly overwrite a comment that changed since it read it. On the CLI the flag is optional; omit it for an unconditional full replace.

See also

Canonical source: docs/safety/safe-body-edits.md in the madtea repo.