42004d37fd
Addresses the five review comments on PR #318: - Split PullRequest from Issue and add a dedicated read_pr method on Forge/ScopedForge/GiteaForge (a PR carries merge state an issue does not); is_pr_open now derives from read_pr. - Replace the JSON-file forge state with a thin swappable CRUD interface (ForgeStateStore) backed by SQLite (SqliteForgeStateStore) at ~/.bot-bottle/bot-bottle.db. - Remove the provenance footer (provenance.py + its test): a mutable, unsigned PR comment is not an audit record. - Reword the PRD: provenance is exposed via an API, not surfaced in the PR; document the Issue/PullRequest split and the SQLite store. pyright clean (whole repo), pylint 10/10, 38 forge/resume unit tests pass; no remaining refs to the removed provenance module or old JSON state API. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WL77TgFxKbs3cidGMG9dz7
146 lines
5.8 KiB
Python
146 lines
5.8 KiB
Python
"""Unit: GiteaClient + GiteaForge (PRD forge-native-integration)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import unittest
|
|
import urllib.error
|
|
from io import BytesIO
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
from bot_bottle.contrib.gitea.client import GiteaClient, GiteaForge
|
|
|
|
|
|
def _client() -> GiteaClient:
|
|
return GiteaClient(
|
|
api_url="https://gitea.example.com/api/v1",
|
|
owner="didericis",
|
|
repo="bot-bottle",
|
|
token="test-token",
|
|
)
|
|
|
|
|
|
def _resp(body: object, status: int = 200) -> MagicMock:
|
|
resp = MagicMock()
|
|
resp.read.return_value = json.dumps(body).encode() if body is not None else b""
|
|
resp.status = status
|
|
resp.__enter__ = lambda s: s # type: ignore
|
|
resp.__exit__ = MagicMock(return_value=False)
|
|
return resp
|
|
|
|
|
|
def _http_error(code: int, body: str = "") -> urllib.error.HTTPError:
|
|
return urllib.error.HTTPError(
|
|
url="http://x", code=code, msg="err", hdrs=None, # type: ignore[arg-type]
|
|
fp=BytesIO(body.encode()),
|
|
)
|
|
|
|
|
|
_URLOPEN = "bot_bottle.contrib.gitea.client.urllib.request.urlopen"
|
|
|
|
|
|
class TestOrgMembership(unittest.TestCase):
|
|
def test_member_returns_true_on_2xx(self):
|
|
with patch(_URLOPEN, return_value=_resp(None, 204)) as m:
|
|
self.assertTrue(_client().is_org_member("bot-bottle", "alice"))
|
|
req = m.call_args.args[0]
|
|
self.assertIn("/orgs/bot-bottle/members/alice", req.full_url)
|
|
|
|
def test_nonmember_returns_false_on_404(self):
|
|
with patch(_URLOPEN, side_effect=_http_error(404)):
|
|
self.assertFalse(_client().is_org_member("bot-bottle", "stranger"))
|
|
|
|
def test_other_http_error_raises(self):
|
|
with patch(_URLOPEN, side_effect=_http_error(403, "forbidden")):
|
|
with self.assertRaises(RuntimeError) as ctx:
|
|
_client().is_org_member("bot-bottle", "alice")
|
|
self.assertIn("403", str(ctx.exception))
|
|
|
|
|
|
class TestForgeReads(unittest.TestCase):
|
|
def test_read_issue_maps_fields(self):
|
|
raw = {"number": 17, "title": "Bug", "body": "broken", "state": "open"}
|
|
with patch(_URLOPEN, return_value=_resp(raw)) as m:
|
|
issue = GiteaForge(_client()).read_issue(17)
|
|
self.assertEqual((17, "Bug", "broken", "open"),
|
|
(issue.number, issue.title, issue.body, issue.state))
|
|
self.assertIn("/repos/didericis/bot-bottle/issues/17",
|
|
m.call_args.args[0].full_url)
|
|
|
|
def test_read_issue_tolerates_null_body(self):
|
|
raw = {"number": 17, "title": "T", "body": None, "state": "open"}
|
|
with patch(_URLOPEN, return_value=_resp(raw)):
|
|
self.assertEqual("", GiteaForge(_client()).read_issue(17).body)
|
|
|
|
def test_read_comments_maps_user_login(self):
|
|
raw = [
|
|
{"id": 1, "user": {"login": "alice"}, "body": "hi"},
|
|
{"id": 2, "user": {"login": "bob"}, "body": "yo"},
|
|
]
|
|
with patch(_URLOPEN, return_value=_resp(raw)):
|
|
comments = GiteaForge(_client()).read_comments(17)
|
|
self.assertEqual(["alice", "bob"], [c.user for c in comments])
|
|
self.assertEqual([1, 2], [c.id for c in comments])
|
|
|
|
|
|
class TestForgeWrites(unittest.TestCase):
|
|
def test_post_comment_payload_and_url(self):
|
|
with patch(_URLOPEN, return_value=_resp(None, 201)) as m:
|
|
GiteaForge(_client()).post_comment(17, "done ✓")
|
|
req = m.call_args.args[0]
|
|
self.assertEqual("POST", req.method)
|
|
self.assertIn("/repos/didericis/bot-bottle/issues/17/comments", req.full_url)
|
|
self.assertEqual("done ✓", json.loads(req.data)["body"])
|
|
|
|
def test_update_description_patches_issue(self):
|
|
with patch(_URLOPEN, return_value=_resp(None, 200)) as m:
|
|
GiteaForge(_client()).update_description(17, "edited")
|
|
req = m.call_args.args[0]
|
|
self.assertEqual("PATCH", req.method)
|
|
self.assertTrue(req.full_url.endswith("/issues/17"))
|
|
self.assertEqual("edited", json.loads(req.data)["body"])
|
|
|
|
def test_auth_header_sent(self):
|
|
with patch(_URLOPEN, return_value=_resp(None, 201)) as m:
|
|
GiteaForge(_client()).post_comment(17, "x")
|
|
self.assertEqual("token test-token",
|
|
m.call_args.args[0].headers["Authorization"])
|
|
|
|
|
|
class TestPRHelpers(unittest.TestCase):
|
|
def test_get_pr_for_issue_returns_number_when_issue_is_pr(self):
|
|
raw = {"number": 18, "pull_request": {"merged": False}}
|
|
with patch(_URLOPEN, return_value=_resp(raw)):
|
|
self.assertEqual(18, GiteaForge(_client()).get_pr_for_issue(18))
|
|
|
|
def test_get_pr_for_issue_none_for_plain_issue(self):
|
|
raw = {"number": 17, "pull_request": None}
|
|
with patch(_URLOPEN, return_value=_resp(raw)):
|
|
self.assertIsNone(GiteaForge(_client()).get_pr_for_issue(17))
|
|
|
|
def test_is_pr_open_true_when_state_open(self):
|
|
with patch(_URLOPEN, return_value=_resp({"state": "open"})):
|
|
self.assertTrue(GiteaForge(_client()).is_pr_open(18))
|
|
|
|
def test_is_pr_open_false_when_closed(self):
|
|
with patch(_URLOPEN, return_value=_resp({"state": "closed"})):
|
|
self.assertFalse(GiteaForge(_client()).is_pr_open(18))
|
|
|
|
def test_read_pr_maps_fields_including_merged(self):
|
|
raw = {"number": 18, "title": "Fix", "body": "patch",
|
|
"state": "closed", "merged": True}
|
|
with patch(_URLOPEN, return_value=_resp(raw)) as m:
|
|
pr = GiteaForge(_client()).read_pr(18)
|
|
self.assertEqual((18, "Fix", "patch", "closed", True),
|
|
(pr.number, pr.title, pr.body, pr.state, pr.merged))
|
|
self.assertIn("/repos/didericis/bot-bottle/pulls/18",
|
|
m.call_args.args[0].full_url)
|
|
|
|
def test_read_pr_merged_defaults_false(self):
|
|
with patch(_URLOPEN, return_value=_resp({"number": 18, "state": "open"})):
|
|
self.assertFalse(GiteaForge(_client()).read_pr(18).merged)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|