"""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." ) ), }