From fee74c78a1efdb17db0e2b0fd486c7d124f38a2c Mon Sep 17 00:00:00 2001 From: Josh Mabry Date: Thu, 23 Jul 2026 04:28:15 -0700 Subject: [PATCH] feat: Fix source_issue write path (persist off-label) --- __init__.py | 10 ++- loop.py | 5 +- store.py | 107 ++++++++++++++++++--------- tests/test_source_issue_roundtrip.py | 66 +++++++++++++++++ tests/test_store.py | 79 +++++++++++++++----- tests/test_tools.py | 13 ++-- 6 files changed, 217 insertions(+), 63 deletions(-) create mode 100644 tests/test_source_issue_roundtrip.py diff --git a/__init__.py b/__init__.py index 9994213..63b0e2a 100644 --- a/__init__.py +++ b/__init__.py @@ -184,8 +184,9 @@ def board_create_feature( comma-separated list of blocking feature ids; set `foundation=True` for a feature others build on (dependents gate on its merge, never its review). `source_issue` names the ORIGINATING GitHub issue — a full issue URL or - `owner/repo#N`, stored normalized as `owner/repo#N` — so the feature's PR - gets a `Fixes #N` line and the issue auto-closes on merge. + `owner/repo#N`, stored normalized as `owner/repo#N` (off-label, in the bead's + notes metadata — a label can't carry `/`/`#`) — so the feature's PR gets a + `Fixes #N` line and the issue auto-closes on merge. DEDUP: refuses to create when a feature with the same title is already OPEN on this board (backlog/ready/in_progress/in_review/blocked) — calling this @@ -262,8 +263,9 @@ def board_update_feature( edges, and `foundation=True` restores the foundation flag — the repairs for dependencies/foundation dropped by a create-time failure (False = leave as-is; this tool never removes the flag). `source_issue` (a full GitHub issue URL or - `owner/repo#N`, stored normalized) sets/replaces the originating issue the - feature's PR will reference as `Fixes #N`. Inputs are + `owner/repo#N`, stored normalized off-label in the bead's notes metadata) + sets/replaces the originating issue the feature's PR will reference as + `Fixes #N`. Inputs are stripped of any literal wrapping double quotes before storage (same hygiene as board_create_feature).""" try: diff --git a/loop.py b/loop.py index 0cb440b..4d599a1 100644 --- a/loop.py +++ b/loop.py @@ -256,7 +256,10 @@ def _source_issue(feature: dict) -> tuple[str, int] | None: ``n`` the issue number — or ``None`` when the feature names no source issue. Precedence: an explicit ``source_issue`` field (a full URL, ``owner/repo#n``, or a - bare ``#n``/``n``) wins; otherwise the FIRST GitHub issue URL in the feature text.""" + bare ``#n``/``n``) wins; otherwise the FIRST GitHub issue URL in the feature text. + The explicit field is the store's projection of the bead-notes ``source-issue:`` + metadata line (#101 — beads' label charset can't carry ``/``/``#``, so the record + lives off-label in `notes`); this reader only ever sees the projected value.""" raw = str(feature.get("source_issue") or "").strip() if raw: m = _ISSUE_URL_RE.search(raw) diff --git a/store.py b/store.py index 95c7e0e..d27c720 100644 --- a/store.py +++ b/store.py @@ -98,12 +98,16 @@ # written post-promote), so the sha is the only piece that must ride the label; the full # {branch, sha, worktree} triple lands in a comment for the audit trail. LABEL_VERIFIED_PREFIX = "verified:" -# The ORIGINATING GitHub issue (#97) — `source:owner/repo#N`, a single replaced label -# (the `gens:`/`verified:` pattern). Set through create/update's `source_issue`, -# projected back as the `source_issue` field the loop's PR opener reads to stamp -# `Fixes #N` (same-repo) / `Refs ` (cross-repo) on the PR body; absent, the -# opener falls back to scanning the feature text for an issue URL. -LABEL_SOURCE_PREFIX = "source:" +# The ORIGINATING GitHub issue (#97) — a structured `source-issue: owner/repo#N` +# metadata line in the bead `notes` field, beside the files_to_modify path lines. +# NOT a label: beads' label validator only allows alphanumeric/hyphen/underscore/ +# colon, so the original `source:owner/repo#N` label died with VALIDATION_FAILED +# on every real write (#101) — `/` and `#` can never ride a label. Set through +# create/update's `source_issue`, projected back as the `source_issue` field the +# loop's PR opener reads to stamp `Fixes #N` (same-repo) / `Refs ` +# (cross-repo) on the PR body; absent, the opener falls back to scanning the +# feature text for an issue URL. +NOTES_SOURCE_PREFIX = "source-issue:" # What `source_issue` accepts: a full GitHub issue URL, or the `owner/repo#N` # shorthand it normalizes to. Anything else (a bare number, a PR url, free text) is # rejected with a named error — the field is explicit provenance, so a value that @@ -183,6 +187,34 @@ def normalize_source_issue(raw) -> str: ) +def _render_notes(files, source_issue: str = "") -> str: + """Serialize the bead `notes` field: one files_to_modify path per line, plus a + trailing `source-issue: owner/repo#N` metadata line when set — the single + shared home for both (labels can't carry `/`/`#`, see NOTES_SOURCE_PREFIX).""" + lines = [str(p).strip() for p in files or () if str(p).strip()] + if source_issue: + lines.append(f"{NOTES_SOURCE_PREFIX} {source_issue}") + return "\n".join(lines) + + +def _split_notes(notes) -> tuple[list[str], str]: + """Parse the bead `notes` field back into ``(files_to_modify, source_issue)`` + — the inverse of ``_render_notes``. Any non-blank line that isn't the + `source-issue:` metadata line is a file path; the FIRST metadata line wins + (the field is single-valued — the replaced-label convention, kept).""" + files: list[str] = [] + src = "" + for line in str(notes or "").splitlines(): + s = line.strip() + if not s: + continue + if s.startswith(NOTES_SOURCE_PREFIX): + src = src or s[len(NOTES_SOURCE_PREFIX) :].strip() + continue + files.append(s) + return files, src + + class BeadsBoard: """Wraps the `br` CLI. One process-wide instance (the loop, API, and tools share it). `br` auto-discovers `.beads/*.db`; pass ``db`` to pin a workspace.""" @@ -346,11 +378,16 @@ def create_feature( if design: upd += [f"--design={design}"] enriched.append("design") - if files_to_modify: - # files_to_modify lives in the bead `notes` field, one path per line. - notes = "\n".join(str(p).strip() for p in files_to_modify if str(p).strip()) - upd += [f"--notes={notes}"] - enriched.append("files_to_modify") + files = [str(p).strip() for p in files_to_modify or () if str(p).strip()] + if files or src: + # files_to_modify + the source-issue record SHARE the bead `notes` field + # (one path per line, the metadata line last — see _render_notes): the + # source can't be a label (its `/`/`#` fail beads' label validator, #101). + upd += [f"--notes={_render_notes(files, src)}"] + if files: + enriched.append("files_to_modify") + if src: + enriched.append("source_issue") diff = difficulty.strip().lower() if diff: # normalize first, then guard: a whitespace-only difficulty must NOT stamp a @@ -360,9 +397,6 @@ def create_feature( if foundation: upd += ["--add-label", LABEL_FOUNDATION] enriched.append("foundation") - if src: - upd += ["--add-label", f"{LABEL_SOURCE_PREFIX}{src}"] - enriched.append("source_issue") # Dependency edges are independent of the enrichment `br update` — wire them # FIRST so an enrichment failure can never silently drop them (QA panel on # #88: the early success-with-warning return below used to skip the dep loop, @@ -612,10 +646,23 @@ def update_feature( args += [f"--acceptance-criteria={acceptance_criteria}"] if design is not None: args += [f"--design={design}"] - if files_to_modify is not None: - # files_to_modify lives in the bead `notes` field, one path per line. - notes = "\n".join(str(p).strip() for p in files_to_modify if str(p).strip()) - args += [f"--notes={notes}"] + set_source = source_issue is not None and str(source_issue).strip() + if files_to_modify is not None or set_source: + # files_to_modify + source_issue SHARE the bead `notes` field (labels + # can't carry the source's `/`/`#`, #101), and `br update --notes` + # replaces the whole field — so rewrite it with the untouched half + # carried forward from the current projection: a files-only update + # must never drop the source-issue line, nor the reverse. An invalid + # source_issue raises the named error BEFORE `br update` runs, so it + # never half-applies a mixed update; whitespace-only = no-op (the + # difficulty convention). + files = ( + [str(p).strip() for p in files_to_modify if str(p).strip()] + if files_to_modify is not None + else f.get("files_to_modify") or [] + ) + src = normalize_source_issue(source_issue) if set_source else str(f.get("source_issue") or "") + args += [f"--notes={_render_notes(files, src)}"] if difficulty is not None: # difficulty rides as a single `diff:` label — replace any stale one (the # same single-label-replaced pattern record_gens_spent uses for `gens:`). @@ -632,14 +679,6 @@ def update_feature( # create can be restored here (QA panel on #88, round 4 — same undeliverable- # promise class as depends_on). None/False = leave the label untouched. args += ["--add-label", LABEL_FOUNDATION] - if source_issue is not None and str(source_issue).strip(): - # An invalid value raises the named error BEFORE `br update` runs, so a bad - # source_issue never half-applies a mixed update. Whitespace-only = no-op - # (the difficulty convention). Single replaced label — the `diff:` pattern. - src = normalize_source_issue(source_issue) - for stale in [l for l in f.get("labels") or [] if l.startswith(LABEL_SOURCE_PREFIX)]: - args += ["--remove-label", stale] - args += ["--add-label", f"{LABEL_SOURCE_PREFIX}{src}"] if len(args) > 2: # something to write beyond the bare `update ` self._run(*args) # Same partial-failure contract as create_feature (panel round 7): one bad id @@ -1141,6 +1180,12 @@ def _project(self, bead: dict) -> dict: (l[len(LABEL_VERIFIED_PREFIX) :] for l in labels if l.startswith(LABEL_VERIFIED_PREFIX)), "", ) + # The bead `notes` field carries files_to_modify (one path per line) AND the + # originating-issue record (#97) as a `source-issue: owner/repo#N` metadata + # line — split them apart so the source line never leaks into the file list. + # source_issue is "" when unset (the loop's PR opener then falls back to + # scanning the feature text for an issue URL). + files_to_modify, source_issue = _split_notes(bead.get("notes")) # `dag_blocked`: marked `ready` but a `blocks` dependency is still open, so # the puller won't claim it. Only `br show` carries dependencies (`br list` # doesn't); list_features patches this by cross-referencing the puller. @@ -1164,7 +1209,7 @@ def _project(self, bead: dict) -> dict: "spec": bead.get("description", ""), "acceptance_criteria": bead.get("acceptance_criteria", ""), "design": bead.get("design", ""), - "files_to_modify": [l.strip() for l in (bead.get("notes") or "").splitlines() if l.strip()], + "files_to_modify": files_to_modify, "priority": bead.get("priority", 2), "issue_type": bead.get("issue_type", ""), "parent": bead.get("parent", ""), @@ -1179,13 +1224,7 @@ def _project(self, bead: dict) -> dict: "attempts": attempts, "gens_spent": gens_spent, "verified_sha": verified_sha, - # The originating issue (#97): normalized `owner/repo#N` from the single - # replaced `source:` label — "" when unset (the loop's PR opener then falls - # back to scanning the feature text for an issue URL). - "source_issue": next( - (l[len(LABEL_SOURCE_PREFIX) :] for l in labels if l.startswith(LABEL_SOURCE_PREFIX)), - "", - ), + "source_issue": source_issue, "labels": labels, "repo": self.repo, "base_branch": self.base_branch, diff --git a/tests/test_source_issue_roundtrip.py b/tests/test_source_issue_roundtrip.py new file mode 100644 index 0000000..f20058f --- /dev/null +++ b/tests/test_source_issue_roundtrip.py @@ -0,0 +1,66 @@ +"""Integration: source_issue round-trips through a REAL `br` store (#101). + +The original write path stored source_issue as a `source:owner/repo#N` label, but +beads' label validator only allows [alphanumeric - _ :] — so every REAL write died +with VALIDATION_FAILED while the fake-`_run` unit tests (which accept any label) +kept passing. That class of bug can only be caught against the actual CLI: these +tests run `br` in a throwaway workspace (``_ensure_workspace`` inits ``.beads/`` +in the tmp repo) and assert the value survives create → get_feature. Skipped when +the `br` binary isn't on PATH — everything else in the suite stays CLI-free. +""" + +from __future__ import annotations + +import shutil + +import pytest + +from project_board import store as store_mod +from project_board.loop import _source_issue +from project_board.store import BeadsBoard + +pytestmark = pytest.mark.skipif( + shutil.which(store_mod.BR) is None, + reason="real `br` (beads) CLI not on PATH — integration round-trip needs it", +) + + +@pytest.fixture +def board(tmp_path): + """A BeadsBoard pinned to a fresh tmp workspace — the real `br`, a real db.""" + return BeadsBoard(repo=str(tmp_path), actor="test") + + +def test_source_issue_survives_create_then_get_feature(board): + f = board.create_feature( + "Round-trip", + spec="s", + acceptance_criteria="WHEN x THE SYSTEM SHALL y", + files_to_modify=["a.py", "pkg/b.py"], + source_issue="https://github.com/acme/widgets/issues/97", + ) + # THE #101 regression signature: the label write failed validation, so create + # returned success-with-warning with source_issue in missing_fields. + assert not f.get("enrichment_failed"), f.get("warning") + g = board.get_feature(f["id"]) + assert g["source_issue"] == "acme/widgets#97" + # the metadata line shares the notes field but never leaks into the file list. + assert g["files_to_modify"] == ["a.py", "pkg/b.py"] + + +def test_update_paths_preserve_each_others_half_of_notes(board): + f = board.create_feature("U", spec="s", files_to_modify=["x.py"], source_issue="acme/widgets#8") + fid = f["id"] + g = board.update_feature(fid, source_issue="https://github.com/acme/widgets/issues/101") + assert g["source_issue"] == "acme/widgets#101" # replaced + assert g["files_to_modify"] == ["x.py"] # untouched + g = board.update_feature(fid, files_to_modify=["y.py", "z.py"]) + assert g["files_to_modify"] == ["y.py", "z.py"] # replaced + assert g["source_issue"] == "acme/widgets#101" # untouched + + +def test_roundtripped_source_issue_feeds_the_pr_fixes_line(board): + """End to end: real store → projection → loop._source_issue resolves the + (slug, n) the PR opener stamps as `Fixes #N`.""" + f = board.create_feature("F", spec="s", source_issue="acme/widgets#97") + assert _source_issue(board.get_feature(f["id"])) == ("acme/widgets", 97) diff --git a/tests/test_store.py b/tests/test_store.py index b7ac86d..11ecdbd 100644 --- a/tests/test_store.py +++ b/tests/test_store.py @@ -1052,10 +1052,13 @@ def test_requeue_preserves_the_open_pr_and_clears_the_assignee(make_board, monke BeadsBoard._resolve_plan_dep(-5, {0: "bd-1"}, {}) -# ── source_issue: normalize + store the originating GitHub issue (#97) ─────────── -# The bead carries a single replaced `source:owner/repo#N` label (the gens:/verified: -# pattern); the projection exposes it as `source_issue`, which the loop's PR opener -# reads to stamp `Fixes #N` on the feature's PR. +# ── source_issue: normalize + store the originating GitHub issue (#97/#101) ────── +# The bead carries a single `source-issue: owner/repo#N` metadata line in its `notes` +# field, beside the files_to_modify lines — NOT a label: beads' label validator only +# allows [alphanumeric - _ :], so the original `source:owner/repo#N` label failed +# VALIDATION_FAILED on every real write (#101). The projection splits notes back into +# `files_to_modify` + `source_issue`, which the loop's PR opener reads to stamp +# `Fixes #N` on the feature's PR. @pytest.mark.parametrize( @@ -1089,12 +1092,14 @@ def test_normalize_source_issue_rejects_invalid_with_a_named_error(bad): store.normalize_source_issue(bad) -def test_create_feature_stores_the_normalized_source_label(make_board): +def test_create_feature_stores_the_normalized_source_issue_in_notes(make_board): calls = [] b = make_board(_enrich_run(calls=calls)) b.create_feature("T", spec="s", source_issue="https://github.com/acme/widgets/issues/97") update = next(c for c in calls if c and c[0] == "update") - assert "--add-label" in update and "source:acme/widgets#97" in update + assert "--notes=source-issue: acme/widgets#97" in update + # NEVER a label — beads' label charset rejects `/` and `#` (#101). + assert "--add-label" not in update def test_create_feature_passes_a_canonical_slug_through_unchanged(make_board): @@ -1102,7 +1107,17 @@ def test_create_feature_passes_a_canonical_slug_through_unchanged(make_board): b = make_board(_enrich_run(calls=calls)) b.create_feature("T", spec="s", source_issue="acme/widgets#8") update = next(c for c in calls if c and c[0] == "update") - assert "source:acme/widgets#8" in update + assert "--notes=source-issue: acme/widgets#8" in update + + +def test_create_feature_files_and_source_share_one_notes_write(make_board): + """files_to_modify + source_issue land in the SAME `--notes=` payload: paths + first (one per line), the metadata line last.""" + calls = [] + b = make_board(_enrich_run(calls=calls)) + b.create_feature("T", spec="s", files_to_modify=["a.py", "b.py"], source_issue="acme/widgets#8") + update = next(c for c in calls if c and c[0] == "update") + assert "--notes=a.py\nb.py\nsource-issue: acme/widgets#8" in update def test_create_feature_invalid_source_issue_rejects_before_minting_a_bead(make_board): @@ -1115,22 +1130,45 @@ def test_create_feature_invalid_source_issue_rejects_before_minting_a_bead(make_ assert not any(c and c[0] == "create" for c in calls) # no orphan -def test_create_feature_without_source_issue_adds_no_source_label(make_board): +def test_create_feature_without_source_issue_writes_no_source_line(make_board): calls = [] b = make_board(_enrich_run(calls=calls)) b.create_feature("T", spec="s", acceptance_criteria="a", files_to_modify=["a.py"]) + update = next(c for c in calls if c and c[0] == "update") + assert "--notes=a.py" in update for c in calls: - assert not any(str(tok).startswith("source:") for tok in c) + assert not any("source-issue:" in str(tok) for tok in c) -def test_update_feature_replaces_a_stale_source_label(make_board, monkeypatch): +def test_update_feature_replaces_a_stale_source_line_and_keeps_files(make_board, monkeypatch): + """`--notes` replaces the whole field, so the rewrite must carry the current + files_to_modify forward while swapping in the new source-issue line.""" br = Br() b = make_board(br) - monkeypatch.setattr(b, "_require", lambda fid: {"id": fid, "labels": ["source:old/repo#1", "ready"]}) + monkeypatch.setattr( + b, + "_require", + lambda fid: {"id": fid, "files_to_modify": ["a.py"], "source_issue": "old/repo#1", "labels": ["ready"]}, + ) monkeypatch.setattr(b, "get_feature", lambda fid: {"id": fid, "labels": []}) b.update_feature("bd-1", source_issue="https://github.com/acme/widgets/issues/9") (call,) = br.cmds("update") - assert call == ("update", "bd-1", "--remove-label", "source:old/repo#1", "--add-label", "source:acme/widgets#9") + assert call == ("update", "bd-1", "--notes=a.py\nsource-issue: acme/widgets#9") + + +def test_update_feature_files_update_preserves_the_source_line(make_board, monkeypatch): + """The mirror image: a files-only update must never drop the stored source.""" + br = Br() + b = make_board(br) + monkeypatch.setattr( + b, + "_require", + lambda fid: {"id": fid, "files_to_modify": ["a.py"], "source_issue": "acme/widgets#8", "labels": []}, + ) + monkeypatch.setattr(b, "get_feature", lambda fid: {"id": fid, "labels": []}) + b.update_feature("bd-1", files_to_modify=["x.py", "y.py"]) + (call,) = br.cmds("update") + assert call == ("update", "bd-1", "--notes=x.py\ny.py\nsource-issue: acme/widgets#8") def test_update_feature_invalid_source_issue_raises_and_writes_nothing(make_board, monkeypatch): @@ -1146,16 +1184,18 @@ def test_update_feature_invalid_source_issue_raises_and_writes_nothing(make_boar def test_update_feature_whitespace_source_issue_is_a_noop(make_board, monkeypatch): br = Br() b = make_board(br) - monkeypatch.setattr(b, "_require", lambda fid: {"id": fid, "labels": ["source:old/repo#1"]}) + monkeypatch.setattr(b, "_require", lambda fid: {"id": fid, "source_issue": "old/repo#1", "labels": []}) monkeypatch.setattr(b, "get_feature", lambda fid: {"id": fid, "labels": []}) b.update_feature("bd-1", source_issue=" ") assert br.cmds("update") == [] # the difficulty convention: blank = leave untouched -def test_project_exposes_source_issue_from_the_label(make_board): +def test_project_splits_notes_into_files_and_source_issue(make_board): b = make_board(Br()) - bead = {"id": "x", "status": "open", "labels": ["source:acme/widgets#8", "ready"]} - assert b._project(bead)["source_issue"] == "acme/widgets#8" + bead = {"id": "x", "status": "open", "labels": ["ready"], "notes": "a.py\nsource-issue: acme/widgets#8"} + f = b._project(bead) + assert f["source_issue"] == "acme/widgets#8" + assert f["files_to_modify"] == ["a.py"] # the metadata line never leaks into the file list assert b._project({"id": "y", "status": "open", "labels": []})["source_issue"] == "" @@ -1165,7 +1205,7 @@ def test_projected_source_issue_feeds_the_loops_fixes_line(make_board): from project_board.loop import _source_issue b = make_board(Br()) - f = b._project({"id": "x", "status": "open", "labels": ["source:acme/widgets#8"]}) + f = b._project({"id": "x", "status": "open", "labels": [], "notes": "source-issue: acme/widgets#8"}) assert _source_issue(f) == ("acme/widgets", 8) # absent → the description-URL fallback still works, unchanged f = b._project( @@ -1180,7 +1220,7 @@ def test_create_from_plan_passes_source_issue_through(make_board, monkeypatch): [{"title": "F", "spec": "s", "files": "a.py", "source_issue": "https://github.com/acme/widgets/issues/97"}] ) assert out["summary"]["created"] == 1 - assert any("--add-label" in u and "source:acme/widgets#97" in u for u in br.cmds("update")) + assert any("--notes=a.py\nsource-issue: acme/widgets#97" in u for u in br.cmds("update")) def test_create_from_plan_invalid_source_issue_fails_that_item_not_the_batch(make_board, monkeypatch): @@ -1195,4 +1235,5 @@ def test_create_from_plan_invalid_source_issue_fails_that_item_not_the_batch(mak assert out["created_ids"] == ["bd-1"] # the invalid item never minted a bead bad = next(r for r in out["items"] if not r["created"]) assert bad["title"] == "Bad" and "invalid source_issue" in bad["error"] - assert any("source:acme/widgets#5" in u for u in br.cmds("update")) # the good item still landed + # the good item still landed (its source rides the notes metadata line) + assert any("--notes=a.py\nsource-issue: acme/widgets#5" in u for u in br.cmds("update")) diff --git a/tests/test_tools.py b/tests/test_tools.py index bc952d3..36578bc 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -80,7 +80,7 @@ def test_create_tool_defaults_source_issue_to_empty(monkeypatch): create.invoke({"title": "T", "spec": "s"}) - assert fake.created["source_issue"] == "" # unset — the store adds no source label + assert fake.created["source_issue"] == "" # unset — the store writes no source-issue line # ── board_update_feature forwards source_issue to the store ───────────────────────── @@ -150,9 +150,11 @@ def test_update_tool_surfaces_the_named_invalid_source_issue_error(make_board, m assert not any(c and c[0] == "update" for c in calls) # nothing written -def test_create_tool_lands_the_normalized_source_label_end_to_end(make_board, monkeypatch): - """Tool → store → bead: a full issue URL goes in, the single `source:owner/repo#N` - label comes out on the `br update` — the value _source_issue() reads back.""" +def test_create_tool_lands_the_normalized_source_note_end_to_end(make_board, monkeypatch): + """Tool → store → bead: a full issue URL goes in, the single `source-issue: + owner/repo#N` notes line comes out on the `br update` — the value + _source_issue() reads back. Never a label: beads' label validator rejects the + `/` and `#` the slug needs (#101).""" _b, calls = _stateful_board(make_board, monkeypatch) create = _get_tool("board_create_feature") @@ -160,7 +162,8 @@ def test_create_tool_lands_the_normalized_source_label_end_to_end(make_board, mo assert out["id"] == "bd-1" update = next(c for c in calls if c and c[0] == "update") - assert "--add-label" in update and "source:o/r#12" in update + assert "--notes=source-issue: o/r#12" in update + assert "--add-label" not in update # ── board_get_feature: the read half of a read-modify-write (bd-171) ─────────────