|
47 | 47 | LABEL_READY = "ready" |
48 | 48 | LABEL_IN_REVIEW = "in-review" |
49 | 49 | LABEL_BLOCKED = "blocked" |
| 50 | +# A SECOND terminal edge (#47): a feature closed because it was created in error |
| 51 | +# (bad decomposition, duplicate, scope cut) — closed like `done`, but tagged so the |
| 52 | +# projection shows a distinct `cancelled` state and reconcilers/retro never mistake it |
| 53 | +# for shipped work. Preserves the one-Done-edge invariant (only record_merge → `done`). |
| 54 | +LABEL_CANCELLED = "cancelled" |
50 | 55 | # A feature others build *on*: dependents gate on its MERGE, never its review (vs a |
51 | 56 | # non-foundation blocker, which can release dependents at in_review under dep_gate: |
52 | 57 | # review). Inert under the default dep_gate: merge (then every blocker gates on merge). |
@@ -328,6 +333,24 @@ def record_merge(self, *, pr_url: str) -> dict | None: |
328 | 333 | self._run("close", f["id"], "-r", f"merged: {pr_url}") |
329 | 334 | return self.get_feature(f["id"]) |
330 | 335 |
|
| 336 | + # ── the second terminal edge: cancel (not merge) ────────────────────────── |
| 337 | + def cancel_feature(self, fid: str, reason: str = "") -> dict: |
| 338 | + """Cancel a feature created in error (bad decomposition, duplicate, scope cut). |
| 339 | +
|
| 340 | + Modeled DELIBERATELY as a second terminal edge so it doesn't break the |
| 341 | + one-Done-edge invariant: it tags the bead `cancelled` and closes it with an |
| 342 | + auditable reason (`br close -r`). The `cancelled` label makes the projection show |
| 343 | + a distinct `cancelled` state — NOT `done` — so the merge/CI reconcilers (which |
| 344 | + only touch `in_review`) and the loop-retro (which mines done/blocked) never |
| 345 | + mistake a cancel for shipped or regressed work. Audit-preserving (the bead + its |
| 346 | + history survive), vs a hard `br delete` tombstone. Clears the assignee so a |
| 347 | + revived id could be re-claimed. Idempotent-ish: re-cancelling a cancelled feature |
| 348 | + just re-closes it.""" |
| 349 | + self._require(fid) |
| 350 | + self._run("update", fid, "--add-label", LABEL_CANCELLED, "--assignee", "") |
| 351 | + self._run("close", fid, "-r", f"cancelled: {reason}" if reason else "cancelled") |
| 352 | + return self.get_feature(fid) |
| 353 | + |
331 | 354 | # ── Blocked flag (not a lane) ───────────────────────────────────────────── |
332 | 355 | def flag_blocked(self, fid: str, reason: str) -> dict: |
333 | 356 | self._require(fid) |
@@ -512,7 +535,9 @@ def board_state(bead: dict) -> str: |
512 | 535 | labels = set(bead.get("labels") or []) |
513 | 536 | status = bead.get("status") |
514 | 537 | if status == "closed": |
515 | | - return "done" |
| 538 | + # A closed bead is `done` UNLESS it was cancelled (the second terminal edge): |
| 539 | + # a cancel keeps it closed + auditable but distinct from shipped work (#47). |
| 540 | + return "cancelled" if LABEL_CANCELLED in labels else "done" |
516 | 541 | if LABEL_BLOCKED in labels: |
517 | 542 | return "blocked" |
518 | 543 | if status == "in_progress": |
@@ -552,6 +577,7 @@ def _project(self, bead: dict) -> dict: |
552 | 577 | "pr_url": bead.get("external_ref", ""), |
553 | 578 | "assignee": bead.get("assignee", ""), |
554 | 579 | "blocked": LABEL_BLOCKED in labels, |
| 580 | + "cancelled": LABEL_CANCELLED in labels, |
555 | 581 | "foundation": LABEL_FOUNDATION in labels, |
556 | 582 | "difficulty": diff, |
557 | 583 | "attempts": attempts, |
|
0 commit comments