diff --git a/src/forge_loop/cli.py b/src/forge_loop/cli.py index 8938c8c..01d58fa 100644 --- a/src/forge_loop/cli.py +++ b/src/forge_loop/cli.py @@ -636,6 +636,165 @@ def _cmd_init(args: SimpleNamespace) -> int: return 0 +def _brainstormer_factory(repo_path: Path, owner: str, repo: str) -> Any: + """Construct the default Brainstormer. Tests monkeypatch this. + + Kept as a module-level callable so ``monkeypatch.setattr(cli, + "_brainstormer_factory", lambda *a, **k: fake)`` works in tests + without threading args through Typer's option layer. + """ + from forge_loop.brainstormer import Brainstormer + + return Brainstormer(repo_path=repo_path, owner=owner, repo=repo) + + +def _gh_client_factory() -> Any: + """Construct the default GhClient. Tests monkeypatch this.""" + from forge_loop.gh_client import GithubkitClient + + return GithubkitClient() + + +def _cmd_brainstorm(args: SimpleNamespace) -> int: + """`forge-loop brainstorm` — dry-run by default, files issues with --apply. + + Contract (issue #124): + * Default (no flags): load ProductVision, run Brainstormer, print + the BrainstormReport as YAML to stdout. Exit 0. No GitHub calls. + * --apply: file each proposed epic first, then each ticket with + ``Parent: #`` cross-link in the body. + * Missing/invalid vision → exit 2 (no partial state). + * Partial failure during --apply → exit 1 with per-title reporting. + """ + import yaml + + from forge_loop.brainstormer import ( + Brainstormer, + BrainstormReport, + ProposedEpic, + ProposedTicket, + ) + from forge_loop.gh_client import MockGhClient + from forge_loop.product_vision import MissingVisionError, discover + + # 1. Resolve repo path + GitHub coordinates from the existing config + # accessor — same pattern as ``_cmd_init`` / ``_cmd_run``. + repo_path = Path.cwd() + owner = "" + repo_name = "" + try: + cfg = load() + repo_path = Path(cfg.repo).resolve() if getattr(cfg, "repo", None) else repo_path + gh_repo = getattr(cfg, "github_repo", "") or "" + if "/" in gh_repo: + owner, repo_name = gh_repo.split("/", 1) + except Exception: # noqa: BLE001 — config-independent: vision discovery still runs + pass + + # 2. Discover ProductVision. Missing/invalid is a hard exit-2. + try: + vision = discover(repo_path) + except MissingVisionError as exc: + typer.echo(f"brainstorm: {exc}", err=True) + return 2 + except Exception as exc: # noqa: BLE001 — unexpected validator failure + typer.echo(f"brainstorm: failed to load product vision: {exc}", err=True) + return 2 + + # 3. Run the brainstormer. Tests monkeypatch ``cli._brainstormer_factory`` + # to inject a stub that skips the real SDK session. + brainstormer = _brainstormer_factory(repo_path, owner, repo_name) + try: + report: BrainstormReport = brainstormer.run(vision) + except Exception as exc: # noqa: BLE001 — propagate as runtime error to operator + typer.echo(f"brainstorm: brainstormer run failed: {exc}", err=True) + return 1 + + # 4. Dry-run path: YAML-dump the report; never touch GitHub. + if not args.apply: + payload = report.model_dump(mode="json") + typer.echo(yaml.safe_dump(payload, sort_keys=False).rstrip()) + return 0 + + # 5. --apply path: epics first, then tickets cross-linked to the epic + # that was just filed in *this* run. + try: + gh_client = _gh_client_factory() + except Exception as exc: # noqa: BLE001 + typer.echo( + f"brainstorm: cannot construct GhClient ({exc}); set GH_TOKEN or monkeypatch _gh_client_factory.", + err=True, + ) + return 1 + + if not owner or not repo_name: + typer.echo( + "brainstorm: --apply requires a configured GitHub repo (owner/name).", + err=True, + ) + return 2 + + if not report.proposed_epics and not report.proposed_tickets: + typer.echo("brainstorm: no proposals — nothing to file.") + return 0 + + epic_axis_to_number: dict[str, int] = {} + succeeded: list[tuple[str, int]] = [] + failed: list[tuple[str, str]] = [] + + def _render_epic_body(epic: ProposedEpic) -> str: + parts = [epic.body.strip()] if epic.body else [] + if epic.customer_story: + parts.append(f"\n## Customer story\n\n{epic.customer_story.strip()}") + return "\n\n".join(p for p in parts if p) or epic.title + + def _render_ticket_body(ticket: ProposedTicket, parent: int | None) -> str: + parts: list[str] = [] + if parent is not None: + parts.append(f"Parent: #{parent}") + if ticket.body: + parts.append(ticket.body.strip()) + if ticket.customer_story: + parts.append(f"\n## Customer story\n\n{ticket.customer_story.strip()}") + return "\n\n".join(parts) or ticket.title + + # Epics first — their numbers are threaded into ticket bodies. + for epic in report.proposed_epics: + labels = [f"axis:{epic.axis}", "epic"] + body = _render_epic_body(epic) + try: + issue = gh_client.create_issue( + owner=owner, repo=repo_name, title=epic.title, body=body, labels=labels, + ) + epic_axis_to_number[epic.axis] = issue.number + succeeded.append((epic.title, issue.number)) + except Exception as exc: # noqa: BLE001 + failed.append((epic.title, str(exc))) + + # Tickets — cross-link to the same-axis epic that was just filed. + for ticket in report.proposed_tickets: + labels = [f"axis:{ticket.axis}", "loop:ready"] + parent = epic_axis_to_number.get(ticket.axis) + body = _render_ticket_body(ticket, parent) + try: + issue = gh_client.create_issue( + owner=owner, repo=repo_name, title=ticket.title, body=body, labels=labels, + ) + succeeded.append((ticket.title, issue.number)) + except Exception as exc: # noqa: BLE001 + failed.append((ticket.title, str(exc))) + + typer.echo("brainstorm: filed:") + for title, number in succeeded: + typer.echo(f" + #{number}: {title}") + if failed: + typer.echo("brainstorm: failed:", err=True) + for title, err in failed: + typer.echo(f" ! {title}: {err}", err=True) + return 1 + return 0 + + def _cmd_record_session(args: SimpleNamespace) -> int: from forge_loop._testing.recorder import SessionRecorder from forge_loop.worker import make_brief @@ -1325,6 +1484,13 @@ def cmd_init( ) +@app.command("brainstorm", help="Propose axis-aligned epics/tickets from product vision (dry-run by default; --apply files them on GitHub).") +def cmd_brainstorm( + apply: bool = typer.Option(False, "--apply", help="Actually file the proposed epics + tickets on GitHub."), +) -> None: + _exit(_cmd_brainstorm(SimpleNamespace(apply=apply))) + + @app.command("record-session", help="Record a real SDK session to a JSONL fixture.") def cmd_record_session( issue: int | None = typer.Option(None, "--issue"), diff --git a/src/forge_loop/gh_client.py b/src/forge_loop/gh_client.py index 8bca24a..79ef87a 100644 --- a/src/forge_loop/gh_client.py +++ b/src/forge_loop/gh_client.py @@ -110,6 +110,11 @@ def remove_label(self, owner: str, repo: str, number: int, label: str) -> None: def get_pull(self, owner: str, repo: str, number: int) -> PullRequest | None: ... + def create_issue( + self, owner: str, repo: str, title: str, body: str, labels: list[str] + ) -> Issue: + ... + # --------------------------------------------------------------------------- # Auth resolution — small, explicit, documented. @@ -210,6 +215,22 @@ def remove_label(self, owner: str, repo: str, number: int, label: str) -> None: if e.status != 404: raise + def create_issue( + self, owner: str, repo: str, title: str, body: str, labels: list[str] + ) -> Issue: + resp = self._gh.rest.issues.create( + owner=owner, repo=repo, title=title, body=body, labels=list(labels), + ) + self._raise_if_error(f"create_issue({title!r})", resp) + item = resp.parsed_data + return Issue( + number=item.number, + title=item.title or "", + body=item.body or "", + state=str(item.state), + labels=[lab.name for lab in (item.labels or []) if hasattr(lab, "name")], + ) + def get_pull(self, owner: str, repo: str, number: int) -> PullRequest | None: try: resp = self._gh.rest.pulls.get(owner=owner, repo=repo, pull_number=number) @@ -251,6 +272,9 @@ class MockGhClient: pulls: dict[tuple[str, str, int], PullRequest] = field(default_factory=dict) issues_by_label_response: list[Issue] = field(default_factory=list) raise_on: dict[str, GhError] = field(default_factory=dict) + raise_on_create_titles: dict[str, Exception] = field(default_factory=dict) + create_issue_responses: list[int] = field(default_factory=list) + next_issue_number: int | None = None calls: list[tuple[str, dict]] = field(default_factory=list) def _record(self, method: str, **kwargs: Any) -> None: @@ -279,6 +303,34 @@ def get_pull(self, owner: str, repo: str, number: int) -> PullRequest | None: self._record("get_pull", owner=owner, repo=repo, number=number) return self.pulls.get((owner, repo, number)) + # ``create_issue`` counters: tests inspect ``next_issue_number`` to + # pre-stage numbers (epic-first ordering), and ``create_issue_responses`` + # can override per-call returns. By default we auto-assign monotonically. + def create_issue( + self, owner: str, repo: str, title: str, body: str, labels: list[str] + ) -> Issue: + self._record( + "create_issue", owner=owner, repo=repo, title=title, body=body, labels=list(labels), + ) + if title in self.raise_on_create_titles: + raise self.raise_on_create_titles[title] + responses = self.create_issue_responses + if responses: + n = responses.pop(0) + else: + n = self._next_number() + issue = Issue(number=n, title=title, body=body, state="open", labels=list(labels)) + self.issues[(owner, repo, n)] = issue + return issue + + def _next_number(self) -> int: + existing = [n for (_, _, n) in self.issues.keys()] + seed = self.next_issue_number + if seed is not None and not existing: + self.next_issue_number = seed + 1 + return seed + return (max(existing) if existing else (seed or 1000)) + 1 + # --------------------------------------------------------------------------- # Backlog helper — used by the brainstormer (issue #123). Lives here so diff --git a/tests/test_cli.py b/tests/test_cli.py index 361ff57..c2fd532 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -56,7 +56,7 @@ def test_help_lists_every_subcommand(runner: CliRunner) -> None: assert result.exit_code == 0 for cmd in ( "run", "status", "doctor", "events", "pause", "resume", "stop", - "retry", "dashboard", "init", "record-session", "brief", + "retry", "dashboard", "init", "brainstorm", "record-session", "brief", "config", "pipeline", "repos", "mcp", "replay", "roles", "cluster", ): assert cmd in result.stdout, f"missing {cmd} in help" diff --git a/tests/test_cli_brainstorm.py b/tests/test_cli_brainstorm.py new file mode 100644 index 0000000..28f65cf --- /dev/null +++ b/tests/test_cli_brainstorm.py @@ -0,0 +1,314 @@ +"""Tests for `forge-loop brainstorm` (issue #124). + +Covers the full acceptance matrix from issue #124: + * dry-run (no flags) prints YAML, never touches GitHub. + * `--apply` files epics first, threads epic#s into ticket bodies. + * Label contract: `axis:` + `epic` for epics; `axis:` + + `loop:ready` for tickets. + * Missing / invalid ProductVision → exit 2. + * Partial failure during `--apply` → exit 1 with per-title reporting. + * Empty BrainstormReport → exit 0, zero `create_issue` calls. + +All tests use `typer.testing.CliRunner` against a real `.forge/` scaffold +in a tmp_path and a `MockGhClient` from `forge_loop.gh_client`. +""" + +from __future__ import annotations + +import os +from pathlib import Path +from typing import Any + +import pytest +import yaml +from typer.testing import CliRunner + +from forge_loop import cli +from forge_loop import settings as _fl_settings +from forge_loop.brainstormer import BrainstormReport, ProposedEpic, ProposedTicket +from forge_loop.gh_client import GhError, MockGhClient + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def runner() -> CliRunner: + try: + return CliRunner(mix_stderr=False) # type: ignore[call-arg] + except TypeError: + return CliRunner() + + +def _write_vision(repo: Path, *, axes: list[dict[str, Any]] | None = None) -> None: + forge = repo / ".forge" + forge.mkdir(parents=True, exist_ok=True) + (forge / "product-vision.md").write_text( + "# Vision\n\nBuild the loop.\n", encoding="utf-8" + ) + if axes is None: + axes = [ + { + "name": "billing", + "customer": "operator", + "valuable_means": "operator can bill", + "acceptable_work": ["payment integration"], + "rejected_as_cosmetic": [], + } + ] + (forge / "axes.yaml").write_text(yaml.safe_dump({"axes": axes}), encoding="utf-8") + + +@pytest.fixture +def cwd_repo(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """Switch into a tmp dir with a valid `.forge/` scaffold.""" + _write_vision(tmp_path) + monkeypatch.chdir(tmp_path) + # Stub `cli.load()` so brainstorm CLI gets a stable (owner, repo) + + # repo_path without depending on the operator's environment / cached + # pydantic-settings. + from types import SimpleNamespace as _NS + monkeypatch.setattr( + cli, "load", lambda: _NS(repo=tmp_path, github_repo="acme/widgets") + ) + return tmp_path + + +class _StubBrainstormer: + def __init__(self, report: BrainstormReport) -> None: + self._report = report + self.calls = 0 + + def run(self, _vision: Any) -> BrainstormReport: + self.calls += 1 + return self._report + + +def _install_stub( + monkeypatch: pytest.MonkeyPatch, + report: BrainstormReport, + mock_gh: MockGhClient | None = None, +) -> tuple[_StubBrainstormer, MockGhClient]: + stub = _StubBrainstormer(report) + gh = mock_gh or MockGhClient() + monkeypatch.setattr(cli, "_brainstormer_factory", lambda *a, **k: stub) + monkeypatch.setattr(cli, "_gh_client_factory", lambda: gh) + return stub, gh + + +def _fixed_report() -> BrainstormReport: + return BrainstormReport( + proposed_epics=[ + ProposedEpic( + title="Billing epic", + body="Enable payments", + axis="billing", + customer_story="Operator wants invoicing", + ), + ], + proposed_tickets=[ + ProposedTicket( + title="Wire Stripe SDK", + body="Add stripe-python", + axis="billing", + customer_story="Operator wants invoicing", + ), + ProposedTicket( + title="Add receipt endpoint", + body="GET /receipts", + axis="billing", + customer_story="Operator wants receipts", + ), + ], + ) + + +# --------------------------------------------------------------------------- +# Dry-run path (no --apply) +# --------------------------------------------------------------------------- + + +def test_brainstorm_dry_run_prints_yaml( + runner: CliRunner, cwd_repo: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + report = _fixed_report() + _, gh = _install_stub(monkeypatch, report) + + result = runner.invoke(cli.app, ["brainstorm"]) + + assert result.exit_code == 0, result.stdout + result.stderr + parsed = yaml.safe_load(result.stdout) + assert isinstance(parsed, dict) + assert {e["title"] for e in parsed["proposed_epics"]} == {"Billing epic"} + assert {t["title"] for t in parsed["proposed_tickets"]} == { + "Wire Stripe SDK", + "Add receipt endpoint", + } + # No GitHub calls in dry-run mode. + assert not any(c[0] == "create_issue" for c in gh.calls) + + +# --------------------------------------------------------------------------- +# --apply path +# --------------------------------------------------------------------------- + + +def test_brainstorm_apply_files_epics_first( + runner: CliRunner, cwd_repo: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Epic must be filed before tickets, and ticket bodies must cite the + just-returned epic number with ``Parent: #``.""" + gh = MockGhClient(create_issue_responses=[501, 502, 503]) + report = _fixed_report() + _install_stub(monkeypatch, report, mock_gh=gh) + + result = runner.invoke(cli.app, ["brainstorm", "--apply"]) + + assert result.exit_code == 0, result.stdout + result.stderr + create_calls = [c for c in gh.calls if c[0] == "create_issue"] + # Order: epic (501), ticket1 (502), ticket2 (503). + assert [c[1]["title"] for c in create_calls] == [ + "Billing epic", "Wire Stripe SDK", "Add receipt endpoint", + ] + # Both ticket bodies cross-link to the epic. + assert "Parent: #501" in create_calls[1][1]["body"] + assert "Parent: #501" in create_calls[2][1]["body"] + + +def test_brainstorm_apply_labels_epic( + runner: CliRunner, cwd_repo: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + gh = MockGhClient(create_issue_responses=[10, 11, 12]) + _install_stub(monkeypatch, _fixed_report(), mock_gh=gh) + + result = runner.invoke(cli.app, ["brainstorm", "--apply"]) + assert result.exit_code == 0 + create_calls = [c for c in gh.calls if c[0] == "create_issue"] + epic_labels = create_calls[0][1]["labels"] + assert "axis:billing" in epic_labels + assert "epic" in epic_labels + + +def test_brainstorm_apply_labels_ticket( + runner: CliRunner, cwd_repo: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + gh = MockGhClient(create_issue_responses=[20, 21, 22]) + _install_stub(monkeypatch, _fixed_report(), mock_gh=gh) + + result = runner.invoke(cli.app, ["brainstorm", "--apply"]) + assert result.exit_code == 0 + create_calls = [c for c in gh.calls if c[0] == "create_issue"] + ticket_labels = create_calls[1][1]["labels"] + assert "axis:billing" in ticket_labels + assert "loop:ready" in ticket_labels + assert "epic" not in ticket_labels + + +# --------------------------------------------------------------------------- +# Sad paths — vision discovery +# --------------------------------------------------------------------------- + + +def test_brainstorm_missing_vision_exits_2( + runner: CliRunner, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """No `.forge/` at all → exit 2; brainstormer/gh are never invoked.""" + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("LOOP_GH_REPO", "acme/widgets") + gh = MockGhClient() + + def _explode(*_a: Any, **_k: Any) -> Any: + raise AssertionError("brainstormer must not run on missing vision") + + monkeypatch.setattr(cli, "_brainstormer_factory", _explode) + monkeypatch.setattr(cli, "_gh_client_factory", lambda: gh) + + result = runner.invoke(cli.app, ["brainstorm"]) + assert result.exit_code == 2, result.stdout + result.stderr + assert "vision" in (result.stderr + result.stdout).lower() + assert not any(c[0] == "create_issue" for c in gh.calls) + + +def test_brainstorm_invalid_vision_exits_2( + runner: CliRunner, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Empty axes list → schema-invalid → exit 2.""" + forge = tmp_path / ".forge" + forge.mkdir() + (forge / "product-vision.md").write_text("# Vision\n\nx\n") + (forge / "axes.yaml").write_text(yaml.safe_dump({"axes": []})) + monkeypatch.chdir(tmp_path) + gh = MockGhClient() + monkeypatch.setattr(cli, "_gh_client_factory", lambda: gh) + + result = runner.invoke(cli.app, ["brainstorm"]) + assert result.exit_code == 2 + assert not any(c[0] == "create_issue" for c in gh.calls) + + +# --------------------------------------------------------------------------- +# Adversarial — partial failure on --apply +# --------------------------------------------------------------------------- + + +def test_brainstorm_partial_failure_exits_1( + runner: CliRunner, cwd_repo: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """If the 2nd ticket's create_issue raises, the epic + first ticket + are still filed (no rollback), exit code is 1, stdout reports the + successes and stderr reports the failing title + error.""" + gh = MockGhClient(create_issue_responses=[700, 701]) + gh.raise_on_create_titles = { + "Add receipt endpoint": GhError("create_issue", 422, "rate-limited"), + } + _install_stub(monkeypatch, _fixed_report(), mock_gh=gh) + + result = runner.invoke(cli.app, ["brainstorm", "--apply"]) + assert result.exit_code == 1, result.stdout + result.stderr + # Epic + first ticket DID get filed. + titles_created = [ + c[1]["title"] for c in gh.calls if c[0] == "create_issue" + ] + assert "Billing epic" in titles_created + assert "Wire Stripe SDK" in titles_created + # Reporting: successes on stdout, failure on stderr. + assert "#700" in result.stdout and "Billing epic" in result.stdout + assert "#701" in result.stdout and "Wire Stripe SDK" in result.stdout + assert "Add receipt endpoint" in result.stderr + assert "rate-limited" in result.stderr or "422" in result.stderr + + +def test_brainstorm_apply_no_proposals( + runner: CliRunner, cwd_repo: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + gh = MockGhClient() + empty = BrainstormReport(proposed_epics=[], proposed_tickets=[]) + _install_stub(monkeypatch, empty, mock_gh=gh) + + result = runner.invoke(cli.app, ["brainstorm", "--apply"]) + assert result.exit_code == 0 + assert not any(c[0] == "create_issue" for c in gh.calls) + assert "no proposals" in result.stdout.lower() + + +# --------------------------------------------------------------------------- +# Default safety — --apply is opt-in +# --------------------------------------------------------------------------- + + +def test_brainstorm_default_never_writes( + runner: CliRunner, cwd_repo: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """T2 manifesto: explicit adversarial test that the opt-in flag is + NOT default. A regression that flips the default would file issues + against a real GitHub repo without operator consent.""" + gh = MockGhClient() + _install_stub(monkeypatch, _fixed_report(), mock_gh=gh) + + result = runner.invoke(cli.app, ["brainstorm"]) + assert result.exit_code == 0 + # Confirm zero write-shaped calls of ANY kind. + write_methods = {"create_issue", "add_comment", "add_labels", "remove_label"} + assert not any(c[0] in write_methods for c in gh.calls)