feat: add Gitea issue comment list/create MCP tools (#126)
Add gitea_list_issue_comments and gitea_create_issue_comment so discussion/design workflows can read and post issue comments through the MCP layer instead of direct API scripts. - List requires gitea.read; create requires gitea.issue.comment — gated separately from the gitea.pr.* review/merge family, fail closed. - Issue comments never touch PR review endpoints. - LLM-safe output: comment id/author/timestamps/body only; web links appear solely under the GITEA_MCP_REVEAL_ENDPOINTS admin opt-in. - Create operations are audit-logged (create_issue_comment) and errors are redacted before being raised. - Tests cover list/create success, permission blocks (including PR review permissions not granting issue comments), forbidden-overrides, empty body, missing issue with redacted error, endpoint separation, and reveal opt-in. - Document issue comments versus PR reviews in docs/gitea-execution-profiles.md. Closes #126 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+139
@@ -1364,6 +1364,145 @@ def gitea_view_issue(
|
||||
}
|
||||
|
||||
|
||||
def _issue_comment_gate(op: str) -> list[str]:
|
||||
"""Profile permission check for issue-comment tools (#126).
|
||||
|
||||
Issue discussion comments are gated separately from the gitea.pr.*
|
||||
review/merge family: listing requires ``gitea.read``, creating requires
|
||||
``gitea.issue.comment``. Returns a list of block reasons (empty = allowed);
|
||||
an unreadable profile fails closed.
|
||||
"""
|
||||
try:
|
||||
profile = get_profile()
|
||||
except Exception as exc:
|
||||
return [f"profile could not be resolved (fail closed): {_redact(str(exc))}"]
|
||||
op_ok, op_reason = gitea_config.check_operation(
|
||||
op, profile["allowed_operations"], profile["forbidden_operations"])
|
||||
if op_ok:
|
||||
return []
|
||||
if op_reason == "no-allowed-operations":
|
||||
return ["profile has no configured allowed operations (fail closed)"]
|
||||
if op_reason == "forbidden":
|
||||
return [f"profile forbids '{op}'"]
|
||||
if op_reason == "invalid-forbidden-entry":
|
||||
return ["profile has an unrecognized forbidden operation entry (fail closed)"]
|
||||
return [f"profile is not allowed to {op}"]
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def gitea_list_issue_comments(
|
||||
issue_number: int,
|
||||
limit: int = 50,
|
||||
remote: str = "dadeschools",
|
||||
host: str | None = None,
|
||||
org: str | None = None,
|
||||
repo: str | None = None,
|
||||
) -> dict:
|
||||
"""List discussion comments on a Gitea issue.
|
||||
|
||||
Read-only. Issue discussion comments are distinct from PR reviews: this
|
||||
reads the issue comment thread and never touches review endpoints. The
|
||||
profile must allow ``gitea.read`` (fail closed otherwise).
|
||||
|
||||
Normal output is LLM-safe: no endpoint URLs. Set
|
||||
GITEA_MCP_REVEAL_ENDPOINTS=1 (admin/debug opt-in) to include each
|
||||
comment's web link.
|
||||
|
||||
Args:
|
||||
issue_number: The issue number whose comments to list (required).
|
||||
limit: Max number of comments to return (default: 50).
|
||||
remote: Known instance — 'dadeschools' or 'prgs'.
|
||||
host: Override the Gitea host.
|
||||
org: Override the owner/organization.
|
||||
repo: Override the repository name.
|
||||
|
||||
Returns:
|
||||
dict with 'success', 'issue_number', and 'comments' (each with 'id',
|
||||
'author', 'created_at', 'updated_at', 'body'); on a permission block,
|
||||
'success' False and 'reasons' with no API call made.
|
||||
"""
|
||||
reasons = _issue_comment_gate("gitea.read")
|
||||
if reasons:
|
||||
return {"success": False, "issue_number": issue_number,
|
||||
"reasons": reasons}
|
||||
h, o, r = _resolve(remote, host, org, repo)
|
||||
auth = _auth(h)
|
||||
api = f"{repo_api_url(h, o, r)}/issues/{issue_number}/comments"
|
||||
comments = api_request("GET", api, auth) or []
|
||||
reveal = _reveal_endpoints()
|
||||
out = []
|
||||
for c in comments[:limit]:
|
||||
entry = {
|
||||
"id": c["id"],
|
||||
"author": (c.get("user") or {}).get("login", ""),
|
||||
"created_at": c.get("created_at"),
|
||||
"updated_at": c.get("updated_at"),
|
||||
"body": c.get("body", ""),
|
||||
}
|
||||
if reveal:
|
||||
entry["url"] = c.get("html_url")
|
||||
out.append(entry)
|
||||
return {"success": True, "issue_number": issue_number, "comments": out}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def gitea_create_issue_comment(
|
||||
issue_number: int,
|
||||
body: str,
|
||||
remote: str = "dadeschools",
|
||||
host: str | None = None,
|
||||
org: str | None = None,
|
||||
repo: str | None = None,
|
||||
) -> dict:
|
||||
"""Post a markdown comment to a Gitea issue's discussion thread.
|
||||
|
||||
Issue discussion comments are distinct from PR reviews: this posts to the
|
||||
issue comment thread only and never submits review verdicts. The profile
|
||||
must allow ``gitea.issue.comment`` — gated separately from the gitea.pr.*
|
||||
review/merge operations (fail closed otherwise). The target issue is
|
||||
always explicit; there is no inference beyond the standard remote
|
||||
defaults used by every tool.
|
||||
|
||||
Normal output is LLM-safe: comment id + issue number, no endpoint URLs.
|
||||
Set GITEA_MCP_REVEAL_ENDPOINTS=1 (admin/debug opt-in) to include the
|
||||
comment's web link. Errors are redacted before being raised.
|
||||
|
||||
Args:
|
||||
issue_number: The issue number to comment on (required).
|
||||
body: Markdown comment body (required, non-empty).
|
||||
remote: Known instance — 'dadeschools' or 'prgs'.
|
||||
host: Override the Gitea host.
|
||||
org: Override the owner/organization.
|
||||
repo: Override the repository name.
|
||||
|
||||
Returns:
|
||||
dict with 'success', 'comment_id', and 'issue_number' ('url' only
|
||||
with the reveal opt-in); on a permission block or empty body,
|
||||
'success'/'performed' False and 'reasons' with no API call made.
|
||||
"""
|
||||
reasons = _issue_comment_gate("gitea.issue.comment")
|
||||
if not (body or "").strip():
|
||||
reasons.append("comment body must be a non-empty string")
|
||||
if reasons:
|
||||
return {"success": False, "performed": False,
|
||||
"issue_number": issue_number, "reasons": reasons}
|
||||
h, o, r = _resolve(remote, host, org, repo)
|
||||
auth = _auth(h)
|
||||
api = f"{repo_api_url(h, o, r)}/issues/{issue_number}/comments"
|
||||
try:
|
||||
with _audited("create_issue_comment", host=h, remote=remote, org=o,
|
||||
repo=r, issue_number=issue_number,
|
||||
request_metadata={"body_chars": len(body)}):
|
||||
data = api_request("POST", api, auth, {"body": body})
|
||||
except Exception as exc:
|
||||
raise RuntimeError(_redact(str(exc))) from None
|
||||
result = {"success": True, "performed": True,
|
||||
"comment_id": data["id"], "issue_number": issue_number}
|
||||
if _reveal_endpoints():
|
||||
result["url"] = data.get("html_url")
|
||||
return result
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def gitea_whoami(
|
||||
remote: str = "dadeschools",
|
||||
|
||||
Reference in New Issue
Block a user