Skip to content

Commit 3e9bbe7

Browse files
mabry1985claude
andauthored
feat(store,api): hard-delete a feature alongside cancel — the tombstone edge (v0.23.0) (#55)
Follow-up to #47: that shipped cancel-with-audit (close + `cancelled` lane); this adds the hard-delete sibling the issue's "and/or" also called for, so the operator chooses per-case instead of the maintainer baking one in. • store.delete_feature(fid, reason) → `br delete --reason "deleted: …"` — a tombstone in the JSONL (recoverable), run THROUGH the board so board ↔ JSONL stay in step (vs the raw `br` reach-around the issue warned desyncs the board). Refuses via `br`'s non-zero exit when the feature has dependents (deleting would orphan them) — cancel or re-point first. Returns the pre-delete snapshot for the API echo. • DELETE /features/{fid} (the route the issue proposed; reason optional, body or none). cancel stays the recommended default (visible, reopenable audit lane); delete is for a feature that should leave no trace. Verified `br delete` tombstones + the dependent guard against real `br` 0.1.23; unit-tested both methods + the route. Ref #47 Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 9332762 commit 3e9bbe7

6 files changed

Lines changed: 59 additions & 2 deletions

File tree

api.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,4 +230,12 @@ async def _cancel(fid: str, body: dict = Body(default={})):
230230
being deleted out-of-band (which desyncs the board ↔ JSONL)."""
231231
return _guard(lambda: store().cancel_feature(fid, str((body or {}).get("reason", ""))))
232232

233+
@router.delete("/features/{fid}")
234+
async def _delete(fid: str, body: dict = Body(default={})):
235+
"""Hard-delete a feature created in error — a `br` tombstone (the harder sibling
236+
of POST …/cancel). Goes through the board so board ↔ JSONL stay consistent;
237+
refuses (400) if the feature has dependents (deleting would orphan them). Prefer
238+
cancel to keep a visible, reopenable audit lane; use delete to leave no trace."""
239+
return _guard(lambda: store().delete_feature(fid, str((body or {}).get("reason", ""))))
240+
233241
return router

protoagent.plugin.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
id: project_board
22
name: Project Board (coding orchestration)
3-
version: 0.22.0
3+
version: 0.23.0
44
description: >-
55
A board-driven coding-orchestration plugin: a lean 6-state board (backlog → ready
66
→ in_progress → in_review → done, + a blocked flag) backed by **beads** (`br`), an

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "project-board"
3-
version = "0.22.0"
3+
version = "0.23.0"
44
description = "Board-driven coding-orchestration plugin for protoAgent (beads board + ACP spawn loop + planning layer + console view)."
55
requires-python = ">=3.11"
66

store.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -351,6 +351,19 @@ def cancel_feature(self, fid: str, reason: str = "") -> dict:
351351
self._run("close", fid, "-r", f"cancelled: {reason}" if reason else "cancelled")
352352
return self.get_feature(fid)
353353

354+
def delete_feature(self, fid: str, reason: str = "") -> dict:
355+
"""Hard-delete a feature (a `br` tombstone) — the harder sibling of
356+
``cancel_feature``. For a feature that should leave NO trace on the board (a pure
357+
mistake / duplicate), vs a cancel which keeps a visible, reopenable `cancelled`
358+
lane. Still goes THROUGH the board (not a raw `br` reach-around) so board ↔ JSONL
359+
stay in step; `br delete` tombstones in the JSONL (recoverable) rather than
360+
nuking history. Refuses (BoardError, via `br`'s non-zero exit) when the feature
361+
has dependents — deleting it would orphan them; cancel or re-point them first.
362+
Returns the deleted feature's last projection (the API echo)."""
363+
f = self._require(fid)
364+
self._run("delete", fid, "--reason", f"deleted: {reason}" if reason else "deleted")
365+
return f
366+
354367
# ── Blocked flag (not a lane) ─────────────────────────────────────────────
355368
def flag_blocked(self, fid: str, reason: str) -> dict:
356369
self._require(fid)

tests/test_api.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,9 @@ def clear_blocked(self, fid):
7474
def cancel_feature(self, fid, reason=""):
7575
return self._rec("cancel_feature", fid, reason)
7676

77+
def delete_feature(self, fid, reason=""):
78+
return self._rec("delete_feature", fid, reason)
79+
7780
def bounce_ci_fail(self, fid, reason):
7881
return self._rec("bounce_ci_fail", fid, reason)
7982

@@ -170,6 +173,19 @@ def test_cancel_route_calls_cancel_feature_with_reason(monkeypatch):
170173
assert ("cancel_feature", ("bd-8", ""), {}) in store.calls
171174

172175

176+
def test_delete_route_calls_delete_feature(monkeypatch):
177+
"""DELETE /features/{fid} — the hard-delete sibling of cancel (#47). Carries an
178+
optional reason; works with no body too."""
179+
store = FakeStore()
180+
c = _client(monkeypatch, store)
181+
r = c.request("DELETE", "/api/plugins/project_board/features/bd-7", json={"reason": "mistake"})
182+
assert r.status_code == 200
183+
assert ("delete_feature", ("bd-7", "mistake"), {}) in store.calls
184+
r2 = c.delete("/api/plugins/project_board/features/bd-8")
185+
assert r2.status_code == 200
186+
assert ("delete_feature", ("bd-8", ""), {}) in store.calls
187+
188+
173189
# ── the single Done edge: the merge webhook ─────────────────────────────────────
174190

175191

tests/test_store.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,26 @@ def test_cancel_feature_unknown_id_raises(make_board, monkeypatch):
253253
b.cancel_feature("nope")
254254

255255

256+
def test_delete_feature_tombstones_with_reason(make_board, monkeypatch):
257+
"""The harder sibling of cancel: `br delete` (tombstone) with an audit reason, run
258+
THROUGH the board so board↔JSONL stay in step. Returns the pre-delete snapshot."""
259+
br = Br()
260+
b = make_board(br)
261+
snapshot = {"id": "bd-9", "board_state": "backlog", "title": "oops"}
262+
monkeypatch.setattr(b, "get_feature", lambda fid: snapshot)
263+
f = b.delete_feature("bd-9", "duplicate")
264+
delete = next(c for c in br.calls if c[0] == "delete")
265+
assert delete[:2] == ("delete", "bd-9") and "--reason" in delete and "deleted: duplicate" in delete
266+
assert f == snapshot # the API echoes what was removed
267+
268+
269+
def test_delete_feature_unknown_id_raises(make_board, monkeypatch):
270+
b = make_board(Br())
271+
monkeypatch.setattr(b, "get_feature", lambda fid: None)
272+
with pytest.raises(BoardError, match="unknown feature"):
273+
b.delete_feature("nope")
274+
275+
256276
def test_mark_ready_rejects_a_feature_already_past_backlog(make_board, monkeypatch):
257277
b = make_board(Br())
258278
monkeypatch.setattr(b, "get_feature", lambda fid: {"id": fid, "board_state": "in_progress"})

0 commit comments

Comments
 (0)