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]>
272 lines
9.4 KiB
Python
272 lines
9.4 KiB
Python
"""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."
|
|
)
|
|
),
|
|
}
|