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
166 changes: 166 additions & 0 deletions src/forge_loop/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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: #<epic-number>`` 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
Expand Down Expand Up @@ -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"),
Expand Down
52 changes: 52 additions & 0 deletions src/forge_loop/gh_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading
Loading