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
108 lines
3.6 KiB
Python
108 lines
3.6 KiB
Python
"""Unit: Forge abstraction + ScopedForge (PRD forge-native-integration)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import unittest
|
|
|
|
from bot_bottle.contrib.forge.base import (
|
|
Comment,
|
|
Forge,
|
|
ForgeScopeError,
|
|
Issue,
|
|
PullRequest,
|
|
ScopedForge,
|
|
)
|
|
|
|
|
|
class _RecordingForge(Forge):
|
|
"""In-memory fake that records writes."""
|
|
|
|
def __init__(self) -> None:
|
|
self.comments: list[tuple[int, str]] = []
|
|
self.descriptions: list[tuple[int, str]] = []
|
|
|
|
def read_issue(self, number: int) -> Issue:
|
|
return Issue(number=number, title="t", body="b", state="open")
|
|
|
|
def read_pr(self, number: int) -> PullRequest:
|
|
return PullRequest(
|
|
number=number, title="pr", body="b", state="open", merged=False
|
|
)
|
|
|
|
def read_comments(self, number: int) -> list[Comment]:
|
|
return [Comment(id=1, user="alice", body="hi")]
|
|
|
|
def post_comment(self, number: int, body: str) -> None:
|
|
self.comments.append((number, body))
|
|
|
|
def update_description(self, number: int, body: str) -> None:
|
|
self.descriptions.append((number, body))
|
|
|
|
def is_org_member(self, org: str, username: str) -> bool:
|
|
return username == "member"
|
|
|
|
def get_pr_for_issue(self, number: int) -> int | None:
|
|
return 99 if number == 17 else None
|
|
|
|
def is_pr_open(self, number: int) -> bool:
|
|
return True
|
|
|
|
|
|
class TestScopedForgeReads(unittest.TestCase):
|
|
def setUp(self) -> None:
|
|
self.inner = _RecordingForge()
|
|
self.scoped = ScopedForge(self.inner, assigned_issue=17, assigned_prs=[42])
|
|
|
|
def test_reads_pass_through_to_any_number(self):
|
|
# A number well outside the writable scope still reads fine.
|
|
self.assertEqual(123, self.scoped.read_issue(123).number)
|
|
self.assertEqual("alice", self.scoped.read_comments(500)[0].user)
|
|
|
|
def test_read_pr_passes_through(self):
|
|
pr = self.scoped.read_pr(999)
|
|
self.assertIsInstance(pr, PullRequest)
|
|
self.assertEqual(999, pr.number)
|
|
self.assertFalse(pr.merged)
|
|
|
|
def test_membership_and_pr_lookups_delegate(self):
|
|
self.assertTrue(self.scoped.is_org_member("bot-bottle", "member"))
|
|
self.assertFalse(self.scoped.is_org_member("bot-bottle", "stranger"))
|
|
self.assertEqual(99, self.scoped.get_pr_for_issue(17))
|
|
self.assertTrue(self.scoped.is_pr_open(8000))
|
|
|
|
|
|
class TestScopedForgeWrites(unittest.TestCase):
|
|
def setUp(self) -> None:
|
|
self.inner = _RecordingForge()
|
|
self.scoped = ScopedForge(self.inner, assigned_issue=17, assigned_prs=[42])
|
|
|
|
def test_writable_set_is_issue_plus_prs(self):
|
|
self.assertEqual(frozenset({17, 42}), self.scoped.writable)
|
|
|
|
def test_write_to_assigned_issue_allowed(self):
|
|
self.scoped.post_comment(17, "done")
|
|
self.assertEqual([(17, "done")], self.inner.comments)
|
|
|
|
def test_write_to_assigned_pr_allowed(self):
|
|
self.scoped.update_description(42, "new body")
|
|
self.assertEqual([(42, "new body")], self.inner.descriptions)
|
|
|
|
def test_comment_outside_scope_rejected(self):
|
|
with self.assertRaises(ForgeScopeError) as ctx:
|
|
self.scoped.post_comment(500, "spam")
|
|
self.assertIn("500", str(ctx.exception))
|
|
self.assertEqual([], self.inner.comments)
|
|
|
|
def test_description_outside_scope_rejected(self):
|
|
with self.assertRaises(ForgeScopeError):
|
|
self.scoped.update_description(500, "tamper")
|
|
self.assertEqual([], self.inner.descriptions)
|
|
|
|
def test_scope_error_is_permission_error(self):
|
|
# Sidecars can catch the stdlib base type.
|
|
self.assertIn(PermissionError, ForgeScopeError.__mro__)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|