fix(mcp): add sanctioned gitea_edit_issue and a doc/registry drift guard (Closes #781)
The gitea-workflow documentation named a gitea_edit_issue tool that no
namespace had ever registered. There was no sanctioned MCP path to change an
issue title or body at all: the only edit tool, gitea_edit_pr, PATCHes the
pull-request endpoint. An authorized body correction on issue #780 therefore
had to be recorded as a discussion comment instead.
Add edit_issue.py as the authoritative rule and gitea_edit_issue as the tool
built on it:
- Only the fields the caller names are sent, so labels, state, assignee, and
milestone cannot be overwritten from a stale read.
- A pull-request number is refused. Gitea serves pull requests from the same
/issues/{n} collection, so without that check the issue path would quietly
become a second, ungated PR edit path. gitea_edit_pr stays PR-only.
- Structurally invalid requests raise before any credential or network work;
a request that would change nothing is reported as an explicit no-op with a
next action rather than a silent success.
- Read-after-write proves the applied title/body and proves that state,
labels, assignees, and milestone did not move. Transport failures on the
pre-read, the PATCH, and the read-back are each reported, redacted, with
the correct performed/verified state.
Gates match every other issue mutation: profile permission via the shared
capability map (resolver task edit_issue, gitea.issue.comment), preflight
purity, branches worktree validation, anti-stomp inventory membership, and
audited mutation.
Fix the drift that hid this. docs/mcp-tool-inventory.md is now the canonical
registered-tool list, and mcp_tool_inventory.py compares it to the live
registry in both directions, plus checks that every tool named under skills/
is registered. The new guard immediately found a second instance of the same
defect: gitea_record_pre_review_command had lost its @mcp.tool() decorator
while the canonical review workflow still instructed reviewers to call it, so
that registration is restored.
Validation:
PASSED: venv/bin/python -m pytest tests/test_issue_781_edit_issue_tool.py -s -q
— 50 passed
FAILED: venv/bin/python -m pytest -s -q — 4095 passed, 11 failed, 6 skipped.
The identical 11 tests fail on clean master 8e149e6 with no changes applied
(254 passed, 11 failed across those five files), so they are pre-existing
and proven by a baseline run rather than asserted.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
@@ -2123,6 +2123,7 @@ def _seed_session_context(
|
||||
import issue_work_duplicate_gate # noqa: E402
|
||||
import issue_workflow_labels # noqa: E402
|
||||
import terminal_pr_label_cleanup # noqa: E402 # #780 status:pr-open terminal rule
|
||||
import edit_issue # noqa: E402 # #781 issue title/body edit rule
|
||||
import reviewer_pr_lease # noqa: E402
|
||||
import post_merge_moot_lease_gate # noqa: E402 # #745 reconciler cleanup gate
|
||||
import merger_lease_adoption # noqa: E402
|
||||
@@ -11764,6 +11765,175 @@ def gitea_close_issue(
|
||||
}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def gitea_edit_issue(
|
||||
issue_number: int,
|
||||
title: str | None = None,
|
||||
body: str | None = None,
|
||||
remote: str = "dadeschools",
|
||||
host: str | None = None,
|
||||
org: str | None = None,
|
||||
repo: str | None = None,
|
||||
worktree_path: str | None = None,
|
||||
) -> dict:
|
||||
"""Edit an issue's title, body, or both through the sanctioned path (#781).
|
||||
|
||||
This is the only MCP path that changes issue content. It PATCHes the issue
|
||||
endpoint and never the pull-request endpoint: ``gitea_edit_pr`` stays
|
||||
pull-request-only, and a pull-request number passed here is refused rather
|
||||
than edited, even though Gitea serves pull requests from ``/issues/{n}``.
|
||||
|
||||
Only the fields named in the call are sent, so labels, state, assignee, and
|
||||
milestone cannot be overwritten by a stale read. The edit is proven by
|
||||
read-after-write: the tool re-reads the issue and fails closed if the stored
|
||||
title/body differ from what was requested, or if any unspecified field moved.
|
||||
|
||||
Args:
|
||||
issue_number: The issue number to edit.
|
||||
title: New issue title. Omit to leave the title unchanged.
|
||||
body: New issue body. Omit to leave the body unchanged; pass ''
|
||||
deliberately to clear it.
|
||||
remote: Known instance — 'dadeschools' or 'prgs'.
|
||||
host: Override the Gitea host.
|
||||
org: Override the owner/organization.
|
||||
repo: Override the repository name.
|
||||
worktree_path: Optional branches worktree to verify before mutation.
|
||||
|
||||
Returns:
|
||||
dict with 'success', 'performed', the 'applied' content, and a
|
||||
'read_after_write' proof carrying the preserved-field comparison. A
|
||||
no-op, a pull-request target, a permission block, or a failed
|
||||
verification all return 'performed'/'success' False with 'reasons' and
|
||||
a 'safe_next_action'. Invalid input raises ValueError before any
|
||||
credential or network work.
|
||||
"""
|
||||
# Validate BEFORE auth/profile/API setup: a malformed or empty request is a
|
||||
# pure input error and must not depend on credentials or configuration.
|
||||
edit_issue.validate_edit_request(title=title, body=body)
|
||||
|
||||
blocked = _profile_permission_block(
|
||||
task_capability_map.required_permission("edit_issue"),
|
||||
issue_number=issue_number,
|
||||
remote=remote,
|
||||
host=host,
|
||||
org=org,
|
||||
repo=repo,
|
||||
org_explicit=org is not None,
|
||||
repo_explicit=repo is not None,
|
||||
)
|
||||
if blocked:
|
||||
return blocked
|
||||
verify_preflight_purity(
|
||||
remote,
|
||||
worktree_path=worktree_path,
|
||||
task="edit_issue",
|
||||
org=org,
|
||||
repo=repo,
|
||||
)
|
||||
|
||||
h, o, r = _resolve(remote, host, org, repo)
|
||||
auth = _auth(h)
|
||||
url = f"{repo_api_url(h, o, r)}/issues/{issue_number}"
|
||||
|
||||
try:
|
||||
current = api_request("GET", url, auth)
|
||||
except Exception as exc: # noqa: BLE001 — redact before surfacing
|
||||
return {
|
||||
"success": False,
|
||||
"performed": False,
|
||||
"issue_number": issue_number,
|
||||
"reasons": [
|
||||
f"could not read issue #{issue_number} before editing: "
|
||||
f"{_redact(str(exc))}"
|
||||
],
|
||||
"safe_next_action": (
|
||||
f"Confirm issue #{issue_number} exists in {o}/{r} with "
|
||||
"gitea_view_issue, then retry the edit."
|
||||
),
|
||||
}
|
||||
|
||||
target = edit_issue.assess_issue_target(current, issue_number=issue_number)
|
||||
if not target["is_issue"]:
|
||||
return {
|
||||
"success": False,
|
||||
"performed": False,
|
||||
"blocked": True,
|
||||
"issue_number": issue_number,
|
||||
"target": target,
|
||||
"reasons": target["reasons"],
|
||||
"safe_next_action": target["safe_next_action"],
|
||||
}
|
||||
|
||||
plan = edit_issue.plan_issue_edit(
|
||||
current, title=title, body=body, issue_number=issue_number
|
||||
)
|
||||
if plan["no_op"]:
|
||||
return {
|
||||
"success": False,
|
||||
"performed": False,
|
||||
"no_op": True,
|
||||
"issue_number": issue_number,
|
||||
"requested_fields": plan["requested_fields"],
|
||||
"unchanged_fields": plan["unchanged_fields"],
|
||||
"reasons": plan["reasons"],
|
||||
"safe_next_action": plan["safe_next_action"],
|
||||
}
|
||||
|
||||
try:
|
||||
with _audited("edit_issue", host=h, remote=remote, org=o, repo=r,
|
||||
issue_number=issue_number,
|
||||
request_metadata={"fields": plan["requested_fields"]}):
|
||||
api_request("PATCH", url, auth, plan["payload"])
|
||||
except Exception as exc: # noqa: BLE001 — redact before surfacing
|
||||
return {
|
||||
"success": False,
|
||||
"performed": False,
|
||||
"issue_number": issue_number,
|
||||
"requested_fields": plan["requested_fields"],
|
||||
"reasons": [f"issue edit failed: {_redact(str(exc))}"],
|
||||
"safe_next_action": (
|
||||
f"Re-read issue #{issue_number} with gitea_view_issue to confirm "
|
||||
"nothing was applied, then retry the edit."
|
||||
),
|
||||
}
|
||||
|
||||
# Read-after-write (#781): the PATCH response is not proof. Re-read the
|
||||
# issue so an unapplied edit, or a preserved field that moved, fails closed.
|
||||
try:
|
||||
observed = api_request("GET", url, auth)
|
||||
except Exception as exc: # noqa: BLE001 — redact before surfacing
|
||||
return {
|
||||
"success": False,
|
||||
"performed": True,
|
||||
"verified": False,
|
||||
"issue_number": issue_number,
|
||||
"requested_fields": plan["requested_fields"],
|
||||
"reasons": [
|
||||
f"issue edit read-back failed: {_redact(str(exc))}"
|
||||
],
|
||||
"safe_next_action": (
|
||||
f"Re-read issue #{issue_number} with gitea_view_issue and confirm "
|
||||
"the applied content before relying on this edit."
|
||||
),
|
||||
}
|
||||
|
||||
verification = edit_issue.verify_issue_edit(observed, plan=plan)
|
||||
result = {
|
||||
"success": verification["verified"],
|
||||
"performed": True,
|
||||
"verified": verification["verified"],
|
||||
"issue_number": issue_number,
|
||||
"requested_fields": plan["requested_fields"],
|
||||
"changed_fields": sorted(plan["changes"]),
|
||||
"applied": verification["applied"],
|
||||
"read_after_write": verification,
|
||||
"preserved_intact": verification["preserved_intact"],
|
||||
"reasons": verification["reasons"],
|
||||
"safe_next_action": verification["safe_next_action"],
|
||||
}
|
||||
return _with_optional_url(result, observed.get("html_url"))
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def gitea_cleanup_terminal_pr_labels(
|
||||
issue_numbers: list[int],
|
||||
@@ -16002,6 +16172,12 @@ def gitea_assess_master_parity(
|
||||
if parity["stale"] and enforced:
|
||||
out["report"] = master_parity_gate.parity_report(parity)
|
||||
return out
|
||||
|
||||
|
||||
# #781: documented in the canonical review workflow as a tool reviewers call
|
||||
# before workflow load, but the registration decorator had been lost, so the
|
||||
# documented inventory named something no namespace could reach.
|
||||
@mcp.tool()
|
||||
def gitea_record_pre_review_command(
|
||||
command: str,
|
||||
cwd: str | None = None,
|
||||
|
||||
Reference in New Issue
Block a user