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:
@@ -31,6 +31,8 @@ from mcp_server import ( # noqa: E402
|
||||
gitea_get_profile,
|
||||
gitea_check_pr_eligibility,
|
||||
gitea_submit_pr_review,
|
||||
gitea_list_issue_comments,
|
||||
gitea_create_issue_comment,
|
||||
)
|
||||
from gitea_auth import get_profile # noqa: E402
|
||||
|
||||
@@ -1903,3 +1905,199 @@ class TestEndpointRedaction(unittest.TestCase):
|
||||
from mcp_server import gitea_audit_config
|
||||
result = gitea_audit_config()
|
||||
self.assertFalse(result["configured"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Issue comment tools (#126)
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestIssueCommentTools(unittest.TestCase):
|
||||
"""gitea_list_issue_comments / gitea_create_issue_comment (#126).
|
||||
|
||||
Issue discussion comments are distinct from PR reviews: they hit the
|
||||
/issues/{n}/comments endpoint, are gated on gitea.read (list) and
|
||||
gitea.issue.comment (create) — never on the gitea.pr.* review/merge
|
||||
family — and their normal output is LLM-safe (no endpoint URLs; the
|
||||
GITEA_MCP_REVEAL_ENDPOINTS opt-in restores links for local diagnostics).
|
||||
"""
|
||||
|
||||
AUTHOR_ENV = {
|
||||
"GITEA_PROFILE_NAME": "gitea-author",
|
||||
"GITEA_ALLOWED_OPERATIONS": "gitea.read,gitea.issue.comment",
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _comment(cid=101, login="alice", body="hello world"):
|
||||
return {
|
||||
"id": cid,
|
||||
"user": {"login": login},
|
||||
"body": body,
|
||||
"created_at": "2026-07-03T00:00:00Z",
|
||||
"updated_at": "2026-07-03T01:00:00Z",
|
||||
"html_url": (
|
||||
"https://gitea.example.com/o/r/issues/9#issuecomment-%d" % cid
|
||||
),
|
||||
}
|
||||
|
||||
# -- list ----------------------------------------------------------------
|
||||
|
||||
@patch("mcp_server.api_request")
|
||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||
def test_lists_comments(self, _auth, mock_api):
|
||||
mock_api.return_value = [self._comment(101), self._comment(102, "bob")]
|
||||
with patch.dict(os.environ, self.AUTHOR_ENV, clear=True):
|
||||
result = gitea_list_issue_comments(issue_number=9, remote="prgs")
|
||||
self.assertTrue(result["success"])
|
||||
self.assertEqual(result["issue_number"], 9)
|
||||
self.assertEqual(len(result["comments"]), 2)
|
||||
first = result["comments"][0]
|
||||
self.assertEqual(first["id"], 101)
|
||||
self.assertEqual(first["author"], "alice")
|
||||
self.assertEqual(first["body"], "hello world")
|
||||
self.assertEqual(first["created_at"], "2026-07-03T00:00:00Z")
|
||||
self.assertEqual(first["updated_at"], "2026-07-03T01:00:00Z")
|
||||
url = mock_api.call_args[0][1]
|
||||
self.assertIn("/issues/9/comments", url)
|
||||
self.assertEqual(mock_api.call_args[0][0], "GET")
|
||||
|
||||
@patch("mcp_server.api_request")
|
||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||
def test_list_output_has_no_urls_by_default(self, _auth, mock_api):
|
||||
mock_api.return_value = [self._comment()]
|
||||
with patch.dict(os.environ, self.AUTHOR_ENV, clear=True):
|
||||
result = gitea_list_issue_comments(issue_number=9, remote="prgs")
|
||||
blob = json.dumps(result)
|
||||
self.assertNotIn("https://", blob)
|
||||
self.assertNotIn("http://", blob)
|
||||
self.assertNotIn("url", blob)
|
||||
|
||||
@patch("mcp_server.api_request")
|
||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||
def test_list_reveal_opt_in_includes_url(self, _auth, mock_api):
|
||||
mock_api.return_value = [self._comment()]
|
||||
env = dict(self.AUTHOR_ENV, GITEA_MCP_REVEAL_ENDPOINTS="1")
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
result = gitea_list_issue_comments(issue_number=9, remote="prgs")
|
||||
self.assertIn("issuecomment-101", result["comments"][0]["url"])
|
||||
|
||||
@patch("mcp_server.api_request")
|
||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||
def test_list_blocked_without_read_permission(self, _auth, mock_api):
|
||||
env = {"GITEA_PROFILE_NAME": "gitea-writer-only",
|
||||
"GITEA_ALLOWED_OPERATIONS": "gitea.issue.comment"}
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
result = gitea_list_issue_comments(issue_number=9, remote="prgs")
|
||||
self.assertFalse(result["success"])
|
||||
self.assertTrue(result["reasons"])
|
||||
mock_api.assert_not_called()
|
||||
|
||||
def test_list_unknown_remote_raises(self):
|
||||
with patch.dict(os.environ, self.AUTHOR_ENV, clear=True):
|
||||
with self.assertRaises(ValueError):
|
||||
gitea_list_issue_comments(issue_number=9, remote="nope")
|
||||
|
||||
# -- create ----------------------------------------------------------------
|
||||
|
||||
@patch("mcp_server.api_request")
|
||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||
def test_creates_comment(self, _auth, mock_api):
|
||||
mock_api.return_value = self._comment(555, "gitea-author", "posted")
|
||||
with patch.dict(os.environ, self.AUTHOR_ENV, clear=True):
|
||||
result = gitea_create_issue_comment(
|
||||
issue_number=9, body="posted", remote="prgs")
|
||||
self.assertTrue(result["success"])
|
||||
self.assertEqual(result["comment_id"], 555)
|
||||
self.assertEqual(result["issue_number"], 9)
|
||||
self.assertEqual(mock_api.call_args[0][0], "POST")
|
||||
url = mock_api.call_args[0][1]
|
||||
self.assertIn("/issues/9/comments", url)
|
||||
self.assertIn("gitea.prgs.cc", url)
|
||||
self.assertIn("Scaled-Tech-Consulting", url)
|
||||
self.assertEqual(mock_api.call_args[0][3], {"body": "posted"})
|
||||
|
||||
@patch("mcp_server.api_request")
|
||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||
def test_create_output_has_no_urls_by_default(self, _auth, mock_api):
|
||||
mock_api.return_value = self._comment(555)
|
||||
with patch.dict(os.environ, self.AUTHOR_ENV, clear=True):
|
||||
result = gitea_create_issue_comment(
|
||||
issue_number=9, body="posted", remote="prgs")
|
||||
blob = json.dumps(result)
|
||||
self.assertNotIn("https://", blob)
|
||||
self.assertNotIn("http://", blob)
|
||||
self.assertNotIn("url", blob)
|
||||
|
||||
@patch("mcp_server.api_request")
|
||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||
def test_create_reveal_opt_in_includes_url(self, _auth, mock_api):
|
||||
mock_api.return_value = self._comment(555)
|
||||
env = dict(self.AUTHOR_ENV, GITEA_MCP_REVEAL_ENDPOINTS="1")
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
result = gitea_create_issue_comment(
|
||||
issue_number=9, body="posted", remote="prgs")
|
||||
self.assertIn("issuecomment-555", result["url"])
|
||||
|
||||
@patch("mcp_server.api_request")
|
||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||
def test_review_permissions_do_not_grant_issue_comments(self, _auth, mock_api):
|
||||
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
||||
"GITEA_ALLOWED_OPERATIONS":
|
||||
"gitea.read,gitea.pr.review,gitea.pr.comment,"
|
||||
"gitea.pr.approve,gitea.pr.merge"}
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
result = gitea_create_issue_comment(
|
||||
issue_number=9, body="posted", remote="prgs")
|
||||
self.assertFalse(result["success"])
|
||||
self.assertFalse(result["performed"])
|
||||
self.assertTrue(result["reasons"])
|
||||
mock_api.assert_not_called()
|
||||
|
||||
@patch("mcp_server.api_request")
|
||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||
def test_create_forbidden_overrides_allowed(self, _auth, mock_api):
|
||||
env = {"GITEA_PROFILE_NAME": "gitea-author",
|
||||
"GITEA_ALLOWED_OPERATIONS": "gitea.read,gitea.issue.comment",
|
||||
"GITEA_FORBIDDEN_OPERATIONS": "gitea.issue.comment"}
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
result = gitea_create_issue_comment(
|
||||
issue_number=9, body="posted", remote="prgs")
|
||||
self.assertFalse(result["success"])
|
||||
mock_api.assert_not_called()
|
||||
|
||||
@patch("mcp_server.api_request")
|
||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||
def test_create_empty_body_blocked(self, _auth, mock_api):
|
||||
with patch.dict(os.environ, self.AUTHOR_ENV, clear=True):
|
||||
result = gitea_create_issue_comment(
|
||||
issue_number=9, body=" ", remote="prgs")
|
||||
self.assertFalse(result["success"])
|
||||
mock_api.assert_not_called()
|
||||
|
||||
@patch("mcp_server.api_request")
|
||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||
def test_create_missing_issue_error_is_redacted(self, _auth, mock_api):
|
||||
mock_api.side_effect = RuntimeError(
|
||||
"Gitea API error 404 for issue (auth was token abc123secret)")
|
||||
with patch.dict(os.environ, self.AUTHOR_ENV, clear=True):
|
||||
with self.assertRaises(RuntimeError) as cm:
|
||||
gitea_create_issue_comment(
|
||||
issue_number=99999, body="posted", remote="prgs")
|
||||
msg = str(cm.exception)
|
||||
self.assertNotIn("abc123secret", msg)
|
||||
self.assertIn("404", msg)
|
||||
|
||||
@patch("mcp_server.api_request")
|
||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||
def test_create_never_touches_review_endpoints(self, _auth, mock_api):
|
||||
mock_api.return_value = self._comment(555)
|
||||
with patch.dict(os.environ, self.AUTHOR_ENV, clear=True):
|
||||
gitea_create_issue_comment(
|
||||
issue_number=9, body="posted", remote="prgs")
|
||||
for call in mock_api.call_args_list:
|
||||
self.assertNotIn("reviews", call[0][1])
|
||||
self.assertNotIn("/pulls/", call[0][1])
|
||||
|
||||
def test_create_unknown_remote_raises(self):
|
||||
with patch.dict(os.environ, self.AUTHOR_ENV, clear=True):
|
||||
with self.assertRaises(ValueError):
|
||||
gitea_create_issue_comment(
|
||||
issue_number=9, body="x", remote="nope")
|
||||
|
||||
Reference in New Issue
Block a user