Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion __init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "",
Expand All @@ -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;
Expand All @@ -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)
Expand All @@ -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,
Expand Down
16 changes: 11 additions & 5 deletions store.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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:
Expand Down
92 changes: 92 additions & 0 deletions tests/test_board_updates.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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) ──


Expand Down
5 changes: 5 additions & 0 deletions tests/test_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,7 @@ def update_feature(
self,
fid,
*,
title=None,
spec=None,
acceptance_criteria=None,
design=None,
Expand All @@ -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:
Expand All @@ -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",
Expand All @@ -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
Expand Down
Loading