refactor(forge): address PR #318 review — PR/Issue split, sqlite state, drop footer
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
This commit is contained in:
@@ -9,6 +9,7 @@ from bot_bottle.contrib.forge.base import (
|
||||
Forge,
|
||||
ForgeScopeError,
|
||||
Issue,
|
||||
PullRequest,
|
||||
ScopedForge,
|
||||
)
|
||||
|
||||
@@ -23,6 +24,11 @@ class _RecordingForge(Forge):
|
||||
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")]
|
||||
|
||||
@@ -52,6 +58,12 @@ class TestScopedForgeReads(unittest.TestCase):
|
||||
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"))
|
||||
|
||||
@@ -126,6 +126,20 @@ class TestPRHelpers(unittest.TestCase):
|
||||
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()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Unit: forge state persistence (PRD forge-native-integration)."""
|
||||
"""Unit: SQLite forge state store (PRD forge-native-integration)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -6,13 +6,12 @@ import tempfile
|
||||
import unittest
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from bot_bottle.contrib.gitea import forge_state as fs
|
||||
from bot_bottle.contrib.gitea.forge_state import (
|
||||
STATUS_FROZEN,
|
||||
STATUS_RUNNING,
|
||||
ForgeState,
|
||||
SqliteForgeStateStore,
|
||||
)
|
||||
|
||||
|
||||
@@ -33,71 +32,68 @@ def _state(**over: object) -> ForgeState:
|
||||
return replace(base, **over)
|
||||
|
||||
|
||||
class ForgeStateTest(unittest.TestCase):
|
||||
class ForgeStateStoreTest(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
# enterContext handles cleanup; pylint doesn't recognize it as CM-aware.
|
||||
root = Path(self.enterContext( # pylint: disable=consider-using-with
|
||||
tempfile.TemporaryDirectory()))
|
||||
patcher = patch.object(fs, "bot_bottle_root", return_value=root)
|
||||
patcher.start()
|
||||
self.addCleanup(patcher.stop)
|
||||
tmp = Path(self.enterContext(tempfile.TemporaryDirectory())) # pylint: disable=consider-using-with
|
||||
self.store = SqliteForgeStateStore(tmp / "sub" / "bot-bottle.db")
|
||||
|
||||
def test_round_trip(self):
|
||||
fs.write_forge_state(_state())
|
||||
got = fs.read_forge_state("didericis", "bot-bottle", 17)
|
||||
self.assertEqual(_state(), got)
|
||||
self.store.upsert(_state())
|
||||
self.assertEqual(_state(), self.store.get("didericis", "bot-bottle", 17))
|
||||
|
||||
def test_missing_returns_none(self):
|
||||
self.assertIsNone(fs.read_forge_state("nobody", "nope", 1))
|
||||
self.assertIsNone(self.store.get("nobody", "nope", 1))
|
||||
|
||||
def test_path_layout(self):
|
||||
path = fs.forge_state_path("didericis", "bot-bottle", 17)
|
||||
self.assertTrue(str(path).endswith("forge/didericis/bot-bottle/issue-17.json"))
|
||||
def test_creates_db_parent_dirs(self):
|
||||
# setUp pointed at a non-existent 'sub/' dir; init must create it.
|
||||
self.assertIsNone(self.store.get("x", "y", 1)) # no raise
|
||||
|
||||
def test_write_is_atomic_no_tmp_left(self):
|
||||
fs.write_forge_state(_state())
|
||||
path = fs.forge_state_path("didericis", "bot-bottle", 17)
|
||||
self.assertFalse(path.with_suffix(".json.tmp").exists())
|
||||
self.assertTrue(path.exists())
|
||||
|
||||
def test_update_overwrites(self):
|
||||
fs.write_forge_state(_state(status=STATUS_RUNNING))
|
||||
fs.write_forge_state(_state(status=STATUS_FROZEN))
|
||||
got = fs.read_forge_state("didericis", "bot-bottle", 17)
|
||||
def test_upsert_replaces(self):
|
||||
self.store.upsert(_state(status=STATUS_RUNNING))
|
||||
self.store.upsert(_state(status=STATUS_FROZEN))
|
||||
got = self.store.get("didericis", "bot-bottle", 17)
|
||||
assert got is not None
|
||||
self.assertEqual(STATUS_FROZEN, got.status)
|
||||
# Still one row, not two.
|
||||
self.assertEqual(1, len(self.store.all()))
|
||||
|
||||
def test_delete_is_idempotent(self):
|
||||
fs.write_forge_state(_state())
|
||||
fs.delete_forge_state("didericis", "bot-bottle", 17)
|
||||
fs.delete_forge_state("didericis", "bot-bottle", 17) # no raise
|
||||
self.assertIsNone(fs.read_forge_state("didericis", "bot-bottle", 17))
|
||||
self.store.upsert(_state())
|
||||
self.store.delete("didericis", "bot-bottle", 17)
|
||||
self.store.delete("didericis", "bot-bottle", 17) # no raise
|
||||
self.assertIsNone(self.store.get("didericis", "bot-bottle", 17))
|
||||
|
||||
def test_all_forge_states_lists_across_repos(self):
|
||||
fs.write_forge_state(_state(issue_number=17))
|
||||
fs.write_forge_state(_state(issue_number=18, slug="other"))
|
||||
fs.write_forge_state(_state(owner="acme", repo="widget", issue_number=3))
|
||||
states = fs.all_forge_states()
|
||||
def test_all_lists_across_repos_sorted(self):
|
||||
self.store.upsert(_state(issue_number=18, slug="other"))
|
||||
self.store.upsert(_state(issue_number=17))
|
||||
self.store.upsert(_state(owner="acme", repo="widget", issue_number=3))
|
||||
states = self.store.all()
|
||||
self.assertEqual(3, len(states))
|
||||
self.assertEqual({17, 18, 3}, {s.issue_number for s in states})
|
||||
self.assertEqual(
|
||||
[("acme", 3), ("didericis", 17), ("didericis", 18)],
|
||||
[(s.owner, s.issue_number) for s in states],
|
||||
)
|
||||
|
||||
def test_all_forge_states_empty_when_no_dir(self):
|
||||
self.assertEqual([], fs.all_forge_states())
|
||||
def test_all_empty(self):
|
||||
self.assertEqual([], self.store.all())
|
||||
|
||||
def test_from_dict_ignores_unknown_keys(self):
|
||||
st = ForgeState.from_dict({
|
||||
"owner": "o", "repo": "r", "issue_number": 1, "slug": "s",
|
||||
"agent_name": "a", "future_field": "ignored",
|
||||
})
|
||||
self.assertEqual("o", st.owner)
|
||||
self.assertIsNone(st.pr_number)
|
||||
def test_bottle_names_list_preserved(self):
|
||||
self.store.upsert(_state(bottle_names=["claude", "dev"]))
|
||||
got = self.store.get("didericis", "bot-bottle", 17)
|
||||
assert got is not None
|
||||
self.assertEqual(["claude", "dev"], got.bottle_names)
|
||||
|
||||
def test_pr_number_optional(self):
|
||||
fs.write_forge_state(_state(pr_number=None))
|
||||
got = fs.read_forge_state("didericis", "bot-bottle", 17)
|
||||
def test_pr_number_nullable(self):
|
||||
self.store.upsert(_state(pr_number=None))
|
||||
got = self.store.get("didericis", "bot-bottle", 17)
|
||||
assert got is not None
|
||||
self.assertIsNone(got.pr_number)
|
||||
|
||||
def test_persists_across_store_instances(self):
|
||||
self.store.upsert(_state())
|
||||
reopened = SqliteForgeStateStore(self.store._db_path) # pylint: disable=protected-access
|
||||
self.assertEqual(_state(), reopened.get("didericis", "bot-bottle", 17))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
"""Unit: provenance footer (PRD forge-native-integration)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from bot_bottle.contrib.gitea.provenance import build_provenance_footer
|
||||
|
||||
|
||||
def _footer(
|
||||
slug: str = "implementer-abc12",
|
||||
*,
|
||||
agent_name: str = "implementer",
|
||||
bottle_names: tuple[str, ...] = ("claude",),
|
||||
started_at: str = "2026-06-29T12:00:00-04:00",
|
||||
finished_at: str = "2026-06-29T12:04:12-04:00",
|
||||
exit_code: int = 0,
|
||||
watchdog_fired: bool = False,
|
||||
gitleaks_clean: bool | None = None,
|
||||
egress_routes: list[str] | None = None,
|
||||
) -> str:
|
||||
return build_provenance_footer(
|
||||
slug,
|
||||
agent_name=agent_name,
|
||||
bottle_names=bottle_names,
|
||||
started_at=started_at,
|
||||
finished_at=finished_at,
|
||||
exit_code=exit_code,
|
||||
watchdog_fired=watchdog_fired,
|
||||
gitleaks_clean=gitleaks_clean,
|
||||
egress_routes=egress_routes,
|
||||
)
|
||||
|
||||
|
||||
class ProvenanceTest(unittest.TestCase):
|
||||
def test_required_fields_present(self):
|
||||
out = _footer()
|
||||
for token in ("Run provenance", "`implementer`", "`claude`",
|
||||
"`implementer-abc12`", "| exit | 0 ✓ |"):
|
||||
self.assertIn(token, out)
|
||||
|
||||
def test_collapsed_details_block(self):
|
||||
out = _footer()
|
||||
self.assertTrue(out.startswith("<details>"))
|
||||
self.assertIn("</details>", out)
|
||||
|
||||
def test_duration_minutes_seconds(self):
|
||||
self.assertIn("| duration | 4m 12s |", _footer())
|
||||
|
||||
def test_duration_under_a_minute(self):
|
||||
out = _footer(finished_at="2026-06-29T12:00:30-04:00")
|
||||
self.assertIn("| duration | 30s |", out)
|
||||
|
||||
def test_duration_unknown_on_bad_timestamp(self):
|
||||
out = _footer(finished_at="not-a-time")
|
||||
self.assertIn("| duration | unknown |", out)
|
||||
|
||||
def test_nonzero_exit_marked(self):
|
||||
self.assertIn("| exit | 1 ✗ |", _footer(exit_code=1))
|
||||
|
||||
def test_watchdog_changes_done_signal_row(self):
|
||||
normal = _footer()
|
||||
self.assertIn("sidecar `signal_done`", normal)
|
||||
fired = _footer(watchdog_fired=True)
|
||||
self.assertIn("watchdog — agent did not signal", fired)
|
||||
self.assertNotIn("sidecar `signal_done`", fired)
|
||||
|
||||
def test_gitleaks_states(self):
|
||||
self.assertIn("not run", _footer())
|
||||
self.assertIn("✓ no secrets detected", _footer(gitleaks_clean=True))
|
||||
self.assertIn("✗ secrets detected", _footer(gitleaks_clean=False))
|
||||
|
||||
def test_egress_omitted_when_absent(self):
|
||||
self.assertNotIn("**Egress**", _footer())
|
||||
|
||||
def test_egress_rendered_when_present(self):
|
||||
out = _footer(egress_routes=[
|
||||
"`api.anthropic.com` — Bearer auth",
|
||||
"`pypi.org` — unauthenticated",
|
||||
])
|
||||
self.assertIn("**Egress** (deny-by-default; 2 routes allowed)", out)
|
||||
self.assertIn("- `api.anthropic.com` — Bearer auth", out)
|
||||
|
||||
def test_egress_singular_route(self):
|
||||
out = _footer(egress_routes=["`api.anthropic.com` — Bearer auth"])
|
||||
self.assertIn("1 route allowed", out)
|
||||
|
||||
def test_multiple_bottles_listed(self):
|
||||
out = _footer(bottle_names=("claude", "dev"))
|
||||
self.assertIn("`claude`, `dev`", out)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user