From c83656976c26d860c8c59dd9d59342eb4a330a53 Mon Sep 17 00:00:00 2001 From: Josh Mabry Date: Thu, 23 Jul 2026 04:52:56 -0700 Subject: [PATCH] feat: Add title to repair contract (board_update_feature) --- __init__.py | 8 +++- store.py | 16 +++++-- tests/test_board_updates.py | 92 +++++++++++++++++++++++++++++++++++++ tests/test_tools.py | 5 ++ 4 files changed, 115 insertions(+), 6 deletions(-) diff --git a/__init__.py b/__init__.py index 63b0e2a..fef8e7e 100644 --- a/__init__.py +++ b/__init__.py @@ -245,6 +245,7 @@ def board_create_feature( @tool def board_update_feature( feature_id: str, + title: str = "", spec: str = "", acceptance_criteria: str = "", files_to_modify: str = "", @@ -258,7 +259,8 @@ def board_update_feature( gate rejects. Only the non-empty arguments are written; every other field is left as-is. Use it to fill a missing `spec`, `acceptance_criteria`, or `files_to_modify` (comma-separated paths) on a feature `board_mark_ready` refused, then mark it ready - again — no need to cancel and recreate the bead. `difficulty` (small|medium|large) + again — no need to cancel and recreate the bead. `title` renames the feature + (empty = leave unchanged). `difficulty` (small|medium|large) re-seeds the model tier. `depends_on` (comma-separated feature ids) ADDS blocking edges, and `foundation=True` restores the foundation flag — the repairs for dependencies/foundation dropped by a create-time failure (False = leave as-is; @@ -270,6 +272,7 @@ def board_update_feature( board_create_feature).""" try: store = get_store(**store_kw) + title = _strip_wrapping_quotes(title) spec = _strip_wrapping_quotes(spec) acceptance_criteria = _strip_wrapping_quotes(acceptance_criteria) files_to_modify = _strip_wrapping_quotes(files_to_modify) @@ -281,6 +284,9 @@ def board_update_feature( deps = _split_list(depends_on) f = store.update_feature( feature_id, + # strip BEFORE the truthiness check (the difficulty convention below) so a + # whitespace-only title is a no-op (None), never a "set the title" signal. + title=title.strip() or None, spec=spec or None, acceptance_criteria=acceptance_criteria or None, design=design or None, diff --git a/store.py b/store.py index d27c720..0b3d864 100644 --- a/store.py +++ b/store.py @@ -615,6 +615,7 @@ def update_feature( self, fid: str, *, + title: str | None = None, spec: str | None = None, acceptance_criteria: str | None = None, design: str | None = None, @@ -629,17 +630,22 @@ def update_feature( left untouched. This is the escape from the 'unrepairable bead' trap: a feature the Ready gate rejects for a missing `spec` / `acceptance_criteria` / `files_to_modify` can be fixed IN PLACE and re-marked ready, instead of being - cancelled and recreated from scratch. ``depends_on`` ADDS blocking edges and - ``foundation=True`` adds the foundation label (None/False = untouched) — the - repair half of create's success-with-warning contract. ``source_issue`` (a - full GitHub issue URL or ``owner/repo#N``) sets/replaces the originating-issue - record the PR opener stamps as ``Fixes #N`` (#97).""" + cancelled and recreated from scratch. ``title`` renames the feature (`br + update --title`); a whitespace-only value collapses to empty → untouched (the + difficulty convention — a bead can never carry a blank title). ``depends_on`` + ADDS blocking edges and ``foundation=True`` adds the foundation label + (None/False = untouched) — the repair half of create's success-with-warning + contract. ``source_issue`` (a full GitHub issue URL or ``owner/repo#N``) + sets/replaces the originating-issue record the PR opener stamps as + ``Fixes #N`` (#97).""" f = self._require(fid) args = ["update", fid] # Free-text VALUES ride in `--flag=value` form so a value STARTING WITH '-' (a # markdown bullet, a leading-dash path) can't be mis-parsed as a CLI option and # fail the update (#85) — the same hardening as create_feature's enrichment. Labels # never start with '-', so `--add/remove-label` stay in the plain form below. + if title is not None and title.strip(): + args += [f"--title={title.strip()}"] if spec is not None: args += [f"--description={spec}"] if acceptance_criteria is not None: diff --git a/tests/test_board_updates.py b/tests/test_board_updates.py index 45dcf52..d2254cc 100644 --- a/tests/test_board_updates.py +++ b/tests/test_board_updates.py @@ -30,6 +30,7 @@ class _StatefulBr: so the emitted ``br`` args can be asserted.""" _FIELD_FLAGS = { + "--title": "title", "--description": "spec", "--acceptance-criteria": "acceptance_criteria", "--design": "design", @@ -211,6 +212,97 @@ def test_update_feature_with_nothing_to_change_makes_no_update_call(make_board, assert br.cmds("update") == [] +# ── title: rename in place, same partial-update contract as every other field ─────── + + +def test_update_feature_title_round_trips_and_leaves_other_fields_untouched(make_board, monkeypatch): + state = { + "id": "bd-1", + "title": "Old title", + "board_state": "backlog", + "spec": "do the thing", + "acceptance_criteria": "AC", + "labels": ["diff:small"], + } + br = _StatefulBr(state) + b = make_board(br) + monkeypatch.setattr(b, "get_feature", lambda fid: state) + + f = b.update_feature("bd-1", title="New title") + + # value fields ride in the leading-dash-safe `--flag=value` form (#85) + (call,) = br.cmds("update") + assert call == ("update", "bd-1", "--title=New title") + # round-trip: the rename landed and is readable back off the projection … + assert f["title"] == "New title" + assert state["title"] == "New title" + # … and every other field survived untouched. + assert state["spec"] == "do the thing" + assert state["acceptance_criteria"] == "AC" + assert state["labels"] == ["diff:small"] + + +def test_update_feature_trims_the_title_before_writing(make_board, monkeypatch): + state = {"id": "bd-1", "title": "Old", "board_state": "backlog", "labels": []} + br = _StatefulBr(state) + b = make_board(br) + monkeypatch.setattr(b, "get_feature", lambda fid: state) + + b.update_feature("bd-1", title=" New title ") + + (call,) = br.cmds("update") + assert call == ("update", "bd-1", "--title=New title") + + +def test_update_feature_with_whitespace_only_title_is_a_noop(make_board, monkeypatch): + state = {"id": "bd-1", "title": "Old title", "board_state": "backlog", "labels": []} + br = _StatefulBr(state) + b = make_board(br) + monkeypatch.setattr(b, "get_feature", lambda fid: state) + + b.update_feature("bd-1", title=" ") + + # whitespace collapses to empty → no update call at all; the old title survives. + assert br.cmds("update") == [] + assert state["title"] == "Old title" + + +def test_update_feature_without_a_title_leaves_it_untouched(make_board, monkeypatch): + state = {"id": "bd-1", "title": "Old title", "board_state": "backlog", "labels": []} + br = _StatefulBr(state) + b = make_board(br) + monkeypatch.setattr(b, "get_feature", lambda fid: state) + + b.update_feature("bd-1", spec="new spec") + + (call,) = br.cmds("update") + assert not any(a.startswith("--title") for a in call) + assert state["title"] == "Old title" + + +def test_board_update_feature_tool_forwards_a_dequoted_title(monkeypatch): + fake = _UpdateRecordingStore() + monkeypatch.setattr("project_board.store.get_store", lambda **_kw: fake) + update = _get_tool("board_update_feature") + + update.invoke({"feature_id": "bd-1", "title": '"New title"'}) + + # wrapping quotes are peeled before the store sees the value (create-tool hygiene). + assert fake.updated["title"] == "New title" + + +def test_board_update_feature_tool_treats_empty_and_whitespace_title_as_none(monkeypatch): + fake = _UpdateRecordingStore() + monkeypatch.setattr("project_board.store.get_store", lambda **_kw: fake) + update = _get_tool("board_update_feature") + + update.invoke({"feature_id": "bd-1", "spec": "s"}) + assert fake.updated["title"] is None # omitted → untouched + + update.invoke({"feature_id": "bd-1", "title": " "}) + assert fake.updated["title"] is None # whitespace-only → untouched, never "set it" + + # ── whitespace-only difficulty must never stamp a malformed `diff:` label (#79/#80) ── diff --git a/tests/test_tools.py b/tests/test_tools.py index 36578bc..c3b23fb 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -197,6 +197,7 @@ def update_feature( self, fid, *, + title=None, spec=None, acceptance_criteria=None, design=None, @@ -206,6 +207,8 @@ def update_feature( foundation=None, source_issue=None, ): + if title is not None: + self.f["title"] = title if spec is not None: self.f["spec"] = spec if acceptance_criteria is not None: @@ -231,6 +234,7 @@ def test_get_feature_round_trips_values_written_by_update(monkeypatch): update.invoke( { "feature_id": "bd-1", + "title": "Renamed feature", "spec": "the new spec", "acceptance_criteria": "WHEN x THE SYSTEM SHALL y", "files_to_modify": "a.py, b.py", @@ -239,6 +243,7 @@ def test_get_feature_round_trips_values_written_by_update(monkeypatch): ) out = json.loads(get.invoke({"feature_id": "bd-1"})) + assert out["title"] == "Renamed feature" assert out["spec"] == "the new spec" assert out["acceptance_criteria"] == "WHEN x THE SYSTEM SHALL y" assert out["files_to_modify"] == ["a.py", "b.py"] # tool split → stored → read back