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
10 changes: 6 additions & 4 deletions __init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
5 changes: 4 additions & 1 deletion loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
107 changes: 73 additions & 34 deletions store.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <url>` (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 <url>`
# (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
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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:`).
Expand All @@ -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 <fid>`
self._run(*args)
# Same partial-failure contract as create_feature (panel round 7): one bad id
Expand Down Expand Up @@ -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.
Expand All @@ -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", ""),
Expand All @@ -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,
Expand Down
66 changes: 66 additions & 0 deletions tests/test_source_issue_roundtrip.py
Original file line number Diff line number Diff line change
@@ -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)
Loading
Loading