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:
+271
@@ -0,0 +1,271 @@
|
||||
"""Authoritative rule for editing an issue's title and body (#781).
|
||||
|
||||
The workflow documented a ``gitea_edit_issue`` tool that was never registered,
|
||||
so an authorized body correction on an issue had no sanctioned path at all: the
|
||||
only edit tool, ``gitea_edit_pr``, PATCHes the pull-request endpoint and cannot
|
||||
target an issue. This module is the rule that path is built on, kept separate
|
||||
from the pull-request edit path by construction.
|
||||
|
||||
- :func:`validate_edit_request` rejects structurally invalid requests before any
|
||||
credential, network, or profile work happens. A request that names no field,
|
||||
or names one with the wrong type, is a pure input error.
|
||||
- :func:`assess_issue_target` refuses a pull request. Gitea serves pull requests
|
||||
from the same ``/issues/{n}`` collection, so without this check the issue edit
|
||||
path would quietly become a second, ungated PR edit path.
|
||||
- :func:`plan_issue_edit` decides the exact PATCH payload from the pre-image. It
|
||||
only ever sends fields the caller named, and it reports a request that would
|
||||
change nothing as an explicit no-op rather than a silent success.
|
||||
- :func:`verify_issue_edit` is the read-after-write check. It proves the applied
|
||||
title/body match what was requested *and* that every field the caller did not
|
||||
name — state, labels, assignees, milestone — is unchanged.
|
||||
|
||||
This module performs no I/O — callers own the Gitea API calls.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Mapping
|
||||
|
||||
import issue_workflow_labels
|
||||
|
||||
#: Fields this tool is allowed to change. Anything else must be untouched.
|
||||
EDITABLE_FIELDS: tuple[str, ...] = ("title", "body")
|
||||
|
||||
#: Fields the caller never names and which must survive an edit verbatim.
|
||||
PRESERVED_FIELDS: tuple[str, ...] = ("state", "labels", "assignees", "milestone")
|
||||
|
||||
|
||||
def validate_edit_request(
|
||||
title: str | None = None,
|
||||
body: str | None = None,
|
||||
) -> dict[str, str]:
|
||||
"""Return the requested field map, failing closed on an invalid request.
|
||||
|
||||
Raises ``ValueError`` when no field is named, when a named field is not a
|
||||
string, or when a title is blank. An empty *body* is legitimate — clearing
|
||||
an issue description is a real edit — but an empty title is not, because
|
||||
Gitea has no issue without one.
|
||||
"""
|
||||
requested: dict[str, str] = {}
|
||||
|
||||
if title is not None:
|
||||
if not isinstance(title, str):
|
||||
raise ValueError(
|
||||
f"Invalid title type {type(title).__name__}: title must be a "
|
||||
"string (fail closed)."
|
||||
)
|
||||
if not title.strip():
|
||||
raise ValueError(
|
||||
"Invalid title: an issue title cannot be blank. Pass the exact "
|
||||
"replacement title, or omit title= to leave it unchanged "
|
||||
"(fail closed)."
|
||||
)
|
||||
requested["title"] = title
|
||||
|
||||
if body is not None:
|
||||
if not isinstance(body, str):
|
||||
raise ValueError(
|
||||
f"Invalid body type {type(body).__name__}: body must be a "
|
||||
"string (fail closed)."
|
||||
)
|
||||
requested["body"] = body
|
||||
|
||||
if not requested:
|
||||
raise ValueError(
|
||||
"At least one field to edit (title, body) must be provided. "
|
||||
"gitea_edit_issue never edits state, labels, assignees, or "
|
||||
"milestone (fail closed)."
|
||||
)
|
||||
|
||||
return requested
|
||||
|
||||
|
||||
def assess_issue_target(
|
||||
issue: Mapping[str, Any],
|
||||
*,
|
||||
issue_number: int,
|
||||
) -> dict[str, Any]:
|
||||
"""Confirm the fetched object is an issue and not a pull request.
|
||||
|
||||
Gitea serves pull requests from ``/issues/{n}`` as well, so a PR number
|
||||
reaches this path unchallenged. Issue and pull-request edits stay separate
|
||||
capabilities, so a PR target is refused here rather than silently PATCHed.
|
||||
"""
|
||||
is_pull_request = bool(issue.get("pull_request"))
|
||||
return {
|
||||
"is_issue": not is_pull_request,
|
||||
"is_pull_request": is_pull_request,
|
||||
"reasons": (
|
||||
[
|
||||
f"#{issue_number} is a pull request, not an issue; "
|
||||
"gitea_edit_issue never edits pull requests"
|
||||
]
|
||||
if is_pull_request
|
||||
else []
|
||||
),
|
||||
"safe_next_action": (
|
||||
f"Use gitea_edit_pr for pull request #{issue_number}."
|
||||
if is_pull_request
|
||||
else ""
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def preserved_snapshot(issue: Mapping[str, Any]) -> dict[str, Any]:
|
||||
"""Capture the fields an edit must leave alone, in a comparable shape."""
|
||||
return {
|
||||
"state": issue.get("state"),
|
||||
"labels": issue_workflow_labels.label_names(issue),
|
||||
"assignees": _assignee_names(issue),
|
||||
"milestone": _milestone_key(issue),
|
||||
}
|
||||
|
||||
|
||||
def _assignee_names(issue: Mapping[str, Any]) -> list[str]:
|
||||
names: list[str] = []
|
||||
for entry in issue.get("assignees") or []:
|
||||
if isinstance(entry, Mapping):
|
||||
login = entry.get("login") or entry.get("username")
|
||||
else:
|
||||
login = entry
|
||||
if login:
|
||||
names.append(str(login))
|
||||
return names
|
||||
|
||||
|
||||
def _milestone_key(issue: Mapping[str, Any]) -> str | None:
|
||||
milestone = issue.get("milestone")
|
||||
if not milestone:
|
||||
return None
|
||||
if isinstance(milestone, Mapping):
|
||||
key = milestone.get("title") or milestone.get("id")
|
||||
return None if key is None else str(key)
|
||||
return str(milestone)
|
||||
|
||||
|
||||
def plan_issue_edit(
|
||||
current: Mapping[str, Any],
|
||||
*,
|
||||
title: str | None = None,
|
||||
body: str | None = None,
|
||||
issue_number: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Plan the PATCH payload for an issue edit against its pre-image.
|
||||
|
||||
Only fields the caller named are ever put in the payload, so unspecified
|
||||
fields cannot be overwritten with a stale read. A request whose named fields
|
||||
already hold the requested values is reported as a no-op with an actionable
|
||||
reason instead of being sent and reported as a success.
|
||||
"""
|
||||
requested = validate_edit_request(title=title, body=body)
|
||||
number = issue_number if issue_number is not None else current.get("number")
|
||||
|
||||
changes: dict[str, dict[str, Any]] = {}
|
||||
unchanged: list[str] = []
|
||||
for field, value in requested.items():
|
||||
before = current.get(field)
|
||||
if field == "body":
|
||||
before = before or ""
|
||||
if before == value:
|
||||
unchanged.append(field)
|
||||
else:
|
||||
changes[field] = {"before": before, "after": value}
|
||||
|
||||
no_op = not changes
|
||||
payload = {field: requested[field] for field in changes}
|
||||
|
||||
return {
|
||||
"issue_number": number,
|
||||
"requested_fields": sorted(requested),
|
||||
"requested": dict(requested),
|
||||
"payload": payload,
|
||||
"changes": changes,
|
||||
"unchanged_fields": sorted(unchanged),
|
||||
"no_op": no_op,
|
||||
"preserved_before": preserved_snapshot(current),
|
||||
"reasons": (
|
||||
[
|
||||
"requested "
|
||||
+ ", ".join(sorted(unchanged))
|
||||
+ " already match the issue's current content; no edit was sent"
|
||||
]
|
||||
if no_op
|
||||
else []
|
||||
),
|
||||
"safe_next_action": (
|
||||
(
|
||||
f"Re-read issue #{number} and call gitea_edit_issue only with "
|
||||
"content that differs, or drop the call if the issue is already "
|
||||
"correct."
|
||||
)
|
||||
if no_op
|
||||
else ""
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def verify_issue_edit(
|
||||
observed: Mapping[str, Any],
|
||||
*,
|
||||
plan: Mapping[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""Read-after-write proof for an applied issue edit.
|
||||
|
||||
Fails closed on two distinct defects: an edited field whose stored value is
|
||||
not what was requested, and an untouched field that moved anyway.
|
||||
"""
|
||||
requested = dict(plan.get("requested") or {})
|
||||
number = plan.get("issue_number")
|
||||
|
||||
applied: dict[str, Any] = {}
|
||||
mismatches: list[dict[str, Any]] = []
|
||||
for field, expected in requested.items():
|
||||
actual = observed.get(field)
|
||||
if field == "body":
|
||||
actual = actual or ""
|
||||
applied[field] = actual
|
||||
if actual != expected:
|
||||
mismatches.append(
|
||||
{"field": field, "expected": expected, "observed": actual}
|
||||
)
|
||||
|
||||
before = dict(plan.get("preserved_before") or {})
|
||||
after = preserved_snapshot(observed)
|
||||
preserved_changed: list[dict[str, Any]] = [
|
||||
{"field": field, "before": before.get(field), "after": after.get(field)}
|
||||
for field in PRESERVED_FIELDS
|
||||
if before.get(field) != after.get(field)
|
||||
]
|
||||
|
||||
reasons: list[str] = []
|
||||
for entry in mismatches:
|
||||
reasons.append(
|
||||
f"{entry['field']} was not applied: requested "
|
||||
f"{entry['expected']!r} but the issue stores {entry['observed']!r}"
|
||||
)
|
||||
for entry in preserved_changed:
|
||||
reasons.append(
|
||||
f"{entry['field']} changed during the edit: {entry['before']!r} "
|
||||
f"became {entry['after']!r}; gitea_edit_issue must leave it alone"
|
||||
)
|
||||
|
||||
verified = not reasons
|
||||
return {
|
||||
"verified": verified,
|
||||
"applied": applied,
|
||||
"mismatches": mismatches,
|
||||
"preserved_before": before,
|
||||
"preserved_after": after,
|
||||
"preserved_changed": preserved_changed,
|
||||
"preserved_intact": not preserved_changed,
|
||||
"reasons": reasons,
|
||||
"safe_next_action": (
|
||||
""
|
||||
if verified
|
||||
else (
|
||||
f"Re-read issue #{number} with gitea_view_issue and reconcile it "
|
||||
"before treating the edit as applied. Do not retry blindly — the "
|
||||
"stored content does not match what was requested."
|
||||
)
|
||||
),
|
||||
}
|
||||
Reference in New Issue
Block a user