diff --git a/CLAUDE.md b/CLAUDE.md index 9e8048c..818b18c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -134,6 +134,8 @@ factory tmux /path/to/project --loop # In detached tmux session # Focus — build exactly one thing factory ceo /path/to/project --focus "dashboard UI" # One item, one hypothesis, done +factory ceo /path/to/project --focus 42 # Target GitHub issue #42 +factory ceo /path/to/project --focus "owner/repo#42" # Target issue by shorthand # Meta — improve the factory's own agents factory ceo /path/to/project --mode meta # Improve + ACE playbook evolution @@ -158,7 +160,7 @@ factory precheck /path --score-before 0.7 --score-after 0.85 # Hard precheck ga factory review --verdict KEEP --pr 42 # Post structured review on GitHub PR ``` -`factory run` / `factory ceo` spawn the CEO agent as a subprocess using the selected runner (`claude` by default, or `bob` with `--runner bob`). The CEO owns the full workflow: state detection, agent spawning, experiment lifecycle, and mandatory archival. The `--loop` flag adds a heartbeat wrapper with configurable interval and max cycles. `--mode meta` runs the full Improve loop on the factory itself, then ACE playbook evolution for all agent roles. `--focus` activates targeted mode: builds exactly one backlog item (e.g. `--focus "eval reliability"`), generating a single hypothesis and exiting after that experiment. Works in improve and research modes; mutually exclusive with `--loop`. `--mode interactive` enters ideation mode: pass a raw idea as the positional argument (e.g. `factory ceo "distributed eval runner" --mode interactive`). The CEO researches the space via the Researcher, then iteratively refines the idea with the Distiller agent through user feedback, producing an idea.md spec before building. Incompatible with `--headless` and `--focus`. `--mode research` enters research ideation for new projects (e.g. `factory ceo "SWE-bench solver" --mode research`) — the Distiller collects research config (target metric, mutable/fixed surfaces, constraints) before building. For existing projects with `research_target` configured, runs the research improvement loop directly. Incompatible with `--headless` (for new projects) and `--prompt`. +`factory run` / `factory ceo` spawn the CEO agent as a subprocess using the selected runner (`claude` by default, or `bob` with `--runner bob`). The CEO owns the full workflow: state detection, agent spawning, experiment lifecycle, and mandatory archival. The `--loop` flag adds a heartbeat wrapper with configurable interval and max cycles. `--mode meta` runs the full Improve loop on the factory itself, then ACE playbook evolution for all agent roles. `--focus` activates targeted mode: builds exactly one item and exits. Accepts backlog names (`--focus "eval reliability"`), issue numbers (`--focus 42`), issue URLs, or `owner/repo#N` shorthand. Issue refs are auto-detected and fetched via `gh`/`glab` CLI. Works in improve and research modes; mutually exclusive with `--loop`. `--mode interactive` enters ideation mode: pass a raw idea as the positional argument (e.g. `factory ceo "distributed eval runner" --mode interactive`). The CEO researches the space via the Researcher, then iteratively refines the idea with the Distiller agent through user feedback, producing an idea.md spec before building. Incompatible with `--headless` and `--focus`. `--mode research` enters research ideation for new projects (e.g. `factory ceo "SWE-bench solver" --mode research`) — the Distiller collects research config (target metric, mutable/fixed surfaces, constraints) before building. For existing projects with `research_target` configured, runs the research improvement loop directly. Incompatible with `--headless` (for new projects) and `--prompt`. ## Observability diff --git a/factory/cli.py b/factory/cli.py index d811be9..3d0fd00 100644 --- a/factory/cli.py +++ b/factory/cli.py @@ -1329,6 +1329,8 @@ def cmd_ceo(args: argparse.Namespace) -> int: file=sys.stderr) return 1 + no_github = getattr(args, "no_github", False) + if mode == "interactive": if headless: print("Error: --mode interactive requires foreground mode " @@ -1381,11 +1383,25 @@ def cmd_ceo(args: argparse.Namespace) -> int: project_path, context = _resolve_input(raw_path) if prompt_file: context = _read_prompt_file(project_path, prompt_file) + issue_number: int | None = None + issue_url: str | None = None + if focus: + from factory.issue import is_issue_ref + if is_issue_ref(focus) and no_github: + print("Error: --focus resolved to an issue reference, but --no-github is set. " + "Issue fetching requires GitHub/GitLab CLI access.", file=sys.stderr) + return 1 + issue_resolved = _resolve_focus_issue(focus, project_path) + if issue_resolved: + title, context, issue_number, issue_url = issue_resolved + focus = f"{title} (issue #{issue_number})" force_fresh = mode == "auto-fresh" if mode in ("auto", "auto-fresh"): - mode = _auto_detect_mode(project_path, has_prompt=bool(prompt_file or context), force_fresh=force_fresh) + mode = _auto_detect_mode( + project_path, has_prompt=bool(prompt_file or context), + force_fresh=force_fresh, + ) discover_only = getattr(args, "discover_only", False) - no_github = getattr(args, "no_github", False) min_growth = getattr(args, "min_growth", None) max_new = getattr(args, "max_new", None) branch = getattr(args, "branch", None) @@ -1428,6 +1444,8 @@ def cmd_ceo(args: argparse.Namespace) -> int: interactive_idea=interactive_idea, research_ideation=research_ideation, messages=pending, + issue_number=issue_number, + issue_url=issue_url, ) standup = _run_standup(project_path, ceo_mode, model=model) @@ -1586,6 +1604,36 @@ def _read_prompt_file(project_path: Path, prompt_file: str) -> str: return content +def _resolve_focus_issue( + focus: str, project_path: Path, +) -> tuple[str, str, int, str] | None: + """If *focus* looks like an issue ref, fetch it and return (title, context, number, url). + + Returns ``None`` when *focus* is a plain backlog-item name. + Callers must check ``--no-github`` *before* calling this function. + """ + from factory.issue import is_issue_ref + + if not is_issue_ref(focus): + return None + + from factory.issue import fetch_issue, format_issue_as_spec + + issue_spec = fetch_issue(focus, project_path) + context = format_issue_as_spec(issue_spec) + + strategy_dir = project_path / ".factory" / "strategy" + strategy_dir.mkdir(parents=True, exist_ok=True) + (strategy_dir / "current.md").write_text( + f"## Project Specification\n\n{context}\n" + ) + print( + f" Issue: #{issue_spec.number} → .factory/strategy/current.md", + file=sys.stderr, + ) + return issue_spec.title, context, issue_spec.number, issue_spec.url + + def _persist_spec(project_path: Path, spec: str) -> None: """Write the project spec to .factory/strategy/current.md so all agents can read it. @@ -1877,6 +1925,8 @@ def _build_ceo_task( interactive_idea: str | None = None, research_ideation: str | None = None, messages: list[Message] | None = None, + issue_number: int | None = None, + issue_url: str | None = None, ) -> str: """Build the CEO agent task string from mode and optional context.""" task = f"Project: {project_path}\nMode: {mode}" @@ -1927,15 +1977,29 @@ def _build_ceo_task( ) if focus: + task += f"\n\n## Focus Directive (Targeted Mode)\n\nTarget: {focus}\n\n" + if issue_number: + issue_label = f"#{issue_number}" + if issue_url: + issue_label += f" ({issue_url})" + task += ( + f"This target is from issue {issue_label}. " + f"The full issue spec has been written to `.factory/strategy/current.md`. " + f"Read it for the complete requirements.\n\n" + ) task += ( - f"\n\n## Focus Directive (Targeted Mode)\n\n" - f"Target: {focus}\n\n" - f"Single-item mode. This target has been added to the backlog. " - f"The Strategist must generate exactly ONE hypothesis for this item. " - f"No other hypotheses this cycle — no additional backlog clearing, no new items.\n" - f"After this single experiment completes (keep or revert), skip to final archival. " - f"Do not loop back for more hypotheses.\n" + "Single-item mode. This target has been added to the backlog. " + "The Strategist must generate exactly ONE hypothesis for this item. " + "No other hypotheses this cycle — no additional backlog clearing, no new items.\n" + "After this single experiment completes (keep or revert), skip to final archival. " + "Do not loop back for more hypotheses.\n" ) + if issue_number: + task += ( + f"\n## Issue Tracking\n\n" + f"This cycle is working on issue #{issue_number}. " + f"When finalizing, pass `--issue {issue_number}` to `factory finalize`." + ) if branch: task += ( @@ -2066,6 +2130,8 @@ def _run_single_cycle( discover_only: bool = False, no_github: bool = False, model: str | None = None, + issue_number: int | None = None, + issue_url: str | None = None, ) -> int: """Execute a single factory run cycle via the CEO agent. Returns 0 on success, 1 on error.""" from factory.agents.runner import invoke_agent @@ -2084,6 +2150,8 @@ def _run_single_cycle( min_growth=min_growth, max_new=max_new, branch=branch, discover_only=discover_only, no_github=no_github, messages=pending, + issue_number=issue_number, + issue_url=issue_url, ) standup = _run_standup(project_path, mode, model=model) @@ -2111,12 +2179,6 @@ def cmd_run(args: argparse.Namespace) -> int: """Run factory cycle(s) via the CEO agent. Supports single-shot and heartbeat loop.""" project_path, context = _resolve_input(args.path) prompt_file = getattr(args, "prompt", None) - if prompt_file: - context = _read_prompt_file(project_path, prompt_file) - mode = getattr(args, "mode", "auto") - force_fresh = mode == "auto-fresh" - if mode in ("auto", "auto-fresh"): - mode = _auto_detect_mode(project_path, has_prompt=bool(prompt_file or context), force_fresh=force_fresh) loop = getattr(args, "loop", False) focus = getattr(args, "focus", None) discover_only = getattr(args, "discover_only", False) @@ -2126,6 +2188,28 @@ def cmd_run(args: argparse.Namespace) -> int: branch = getattr(args, "branch", None) model = _resolve_model(args) + if prompt_file: + context = _read_prompt_file(project_path, prompt_file) + issue_number: int | None = None + issue_url: str | None = None + if focus: + from factory.issue import is_issue_ref + if is_issue_ref(focus) and no_github: + print("Error: --focus resolved to an issue reference, but --no-github is set. " + "Issue fetching requires GitHub/GitLab CLI access.", file=sys.stderr) + return 1 + issue_resolved = _resolve_focus_issue(focus, project_path) + if issue_resolved: + title, context, issue_number, issue_url = issue_resolved + focus = f"{title} (issue #{issue_number})" + mode = getattr(args, "mode", "auto") + force_fresh = mode == "auto-fresh" + if mode in ("auto", "auto-fresh"): + mode = _auto_detect_mode( + project_path, has_prompt=bool(prompt_file or context), + force_fresh=force_fresh, + ) + if focus and loop: print("Error: --focus (targeted mode) and --loop are mutually exclusive. " "Targeted mode builds exactly one item and exits.", file=sys.stderr) @@ -2149,6 +2233,8 @@ def cmd_run(args: argparse.Namespace) -> int: code = _run_single_cycle( project_path, mode, context, focus=focus, prompt_file=prompt_file, discover_only=discover_only, no_github=no_github, model=model, + issue_number=issue_number, + issue_url=issue_url, **budget_kwargs, ) if code != 0: @@ -2183,6 +2269,8 @@ def _shutdown_handler(signum: int, frame: object) -> None: _run_single_cycle( project_path, mode, context, focus=focus, prompt_file=prompt_file, discover_only=discover_only, no_github=no_github, model=model, + issue_number=issue_number, + issue_url=issue_url, **budget_kwargs, ) _chain_modes( @@ -2538,7 +2626,9 @@ def build_parser() -> argparse.ArgumentParser: ) p.add_argument( "--focus", default=None, - help="Narrow improvement efforts to a specific area (e.g. 'dashboard UI', 'eval reliability')", + help="Target a specific item: backlog name ('dashboard UI'), issue number (42), " + "URL (https://github.com/o/r/issues/42), or shorthand (owner/repo#42). " + "Issue refs are auto-detected and fetched via gh/glab CLI", ) p.add_argument( "--headless", action="store_true", default=False, @@ -2580,7 +2670,9 @@ def build_parser() -> argparse.ArgumentParser: ) p.add_argument( "--focus", default=None, - help="Narrow improvement efforts to a specific area (e.g. 'dashboard UI', 'eval reliability')", + help="Target a specific item: backlog name ('dashboard UI'), issue number (42), " + "URL (https://github.com/o/r/issues/42), or shorthand (owner/repo#42). " + "Issue refs are auto-detected and fetched via gh/glab CLI", ) p.add_argument( "--discover-only", action="store_true", default=False, diff --git a/factory/issue.py b/factory/issue.py new file mode 100644 index 0000000..ec101b1 --- /dev/null +++ b/factory/issue.py @@ -0,0 +1,188 @@ +"""Fetch and format GitHub/GitLab issues as build specs.""" + +from __future__ import annotations + +import json +import re +import subprocess +from dataclasses import dataclass, field +from pathlib import Path +from typing import Literal +from urllib.parse import urlparse + +import structlog + +log = structlog.get_logger() + +Forge = Literal["github", "gitlab"] + + +@dataclass +class IssueSpec: + number: int + title: str + body: str + labels: list[str] = field(default_factory=list) + url: str = "" + forge: Forge = "github" + + +def parse_issue_ref(ref: str, project_path: Path) -> tuple[Forge, str, int]: + """Parse an issue reference into (forge, owner/repo, number). + + Handles: + - ``42`` — bare number, infer remote from git + - ``https://github.com/owner/repo/issues/42`` + - ``https://gitlab.com/owner/repo/-/issues/42`` + - ``owner/repo#42`` — GitHub shorthand + """ + ref = ref.strip() + + gh_url = re.match( + r"https?://([^/]+)/([^/]+/[^/]+)/issues/(\d+)", ref, + ) + if gh_url: + host = gh_url.group(1) + owner_repo = gh_url.group(2) + number = int(gh_url.group(3)) + forge: Forge = "gitlab" if "gitlab" in host else "github" + return forge, owner_repo, number + + gl_url = re.match( + r"https?://([^/]+)/(.+?)/-/issues/(\d+)", ref, + ) + if gl_url: + owner_repo = gl_url.group(2) + number = int(gl_url.group(3)) + return "gitlab", owner_repo, number + + shorthand = re.match(r"^([^/]+/[^#]+)#(\d+)$", ref) + if shorthand: + owner_repo = shorthand.group(1) + number = int(shorthand.group(2)) + return "github", owner_repo, number + + if ref.isdigit(): + forge, owner_repo = infer_remote(project_path) + return forge, owner_repo, int(ref) + + raise ValueError( + f"Cannot parse issue reference: {ref!r}. " + "Expected a number, URL, or owner/repo#number." + ) + + +def infer_remote(project_path: Path) -> tuple[Forge, str]: + """Infer forge and owner/repo from ``git remote get-url origin``.""" + try: + result = subprocess.run( + ["git", "-C", str(project_path), "remote", "get-url", "origin"], + capture_output=True, + text=True, + check=True, + ) + except subprocess.CalledProcessError as exc: + raise RuntimeError( + f"Cannot infer remote: git remote get-url origin failed in {project_path}" + ) from exc + + url = result.stdout.strip() + log.debug("inferred_git_remote", url=url, project=str(project_path)) + + ssh_match = re.match(r"git@([^:]+):(.+?)(?:\.git)?$", url) + if ssh_match: + host = ssh_match.group(1) + owner_repo = ssh_match.group(2) + forge: Forge = "gitlab" if "gitlab" in host else "github" + return forge, owner_repo + + parsed = urlparse(url) + if parsed.hostname: + path = parsed.path.lstrip("/").removesuffix(".git") + forge = "gitlab" if "gitlab" in parsed.hostname else "github" + return forge, path + + raise RuntimeError(f"Cannot parse git remote URL: {url!r}") + + +def fetch_issue(issue_ref: str, project_path: Path) -> IssueSpec: + """Fetch an issue from GitHub or GitLab and return an ``IssueSpec``.""" + forge, owner_repo, number = parse_issue_ref(issue_ref, project_path) + log.info("fetching_issue", forge=forge, repo=owner_repo, number=number) + + if forge == "github": + cmd = [ + "gh", "issue", "view", str(number), + "-R", owner_repo, + "--json", "title,body,labels,number,url", + ] + else: + cmd = [ + "glab", "issue", "view", str(number), + "--repo", owner_repo, + "--output", "json", + ] + + try: + result = subprocess.run(cmd, capture_output=True, text=True, check=True) + except FileNotFoundError: + cli = "gh" if forge == "github" else "glab" + raise RuntimeError( + f"{cli} CLI not found. Install it to fetch {forge} issues." + ) + except subprocess.CalledProcessError as exc: + raise RuntimeError( + f"Failed to fetch {forge} issue #{number} from {owner_repo}: " + f"{exc.stderr.strip()}" + ) from exc + + data = json.loads(result.stdout) + + if forge == "github": + labels = [lb["name"] for lb in data.get("labels", [])] + return IssueSpec( + number=data["number"], + title=data["title"], + body=data.get("body", ""), + labels=labels, + url=data.get("url", ""), + forge=forge, + ) + + return IssueSpec( + number=data.get("iid", number), + title=data.get("title", ""), + body=data.get("description", ""), + labels=data.get("labels", []), + url=data.get("web_url", ""), + forge=forge, + ) + + +def is_issue_ref(ref: str) -> bool: + """Check if *ref* looks like an issue reference without needing a project path. + + Detects URLs, ``owner/repo#N`` shorthand, and bare integers. + Does NOT validate that the issue exists — just pattern-matches. + """ + ref = ref.strip() + if ref.isdigit(): + return True + if re.match(r"https?://[^/]+/.+/issues/\d+", ref): + return True + if re.match(r"^[^#]+/[^#]+#\d+$", ref): + return True + return False + + +def format_issue_as_spec(spec: IssueSpec) -> str: + """Format an ``IssueSpec`` as a markdown build specification.""" + lines = [f"# {spec.title}", ""] + if spec.url: + lines.append(f"Issue: {spec.url}") + lines.append("") + if spec.labels: + lines.append(f"Labels: {', '.join(spec.labels)}") + lines.append("") + lines.append(spec.body) + return "\n".join(lines) diff --git a/tests/test_issue.py b/tests/test_issue.py new file mode 100644 index 0000000..9459341 --- /dev/null +++ b/tests/test_issue.py @@ -0,0 +1,438 @@ +"""Tests for factory/issue.py — issue parsing, fetching, and formatting.""" + +from __future__ import annotations + +import json +import subprocess +from pathlib import Path +from unittest.mock import patch + +import pytest + +from factory.issue import ( + IssueSpec, + fetch_issue, + format_issue_as_spec, + infer_remote, + is_issue_ref, + parse_issue_ref, +) + + +# ── is_issue_ref ──────────────────────────────────────────── + + +class TestIsIssueRef: + def test_bare_number(self) -> None: + assert is_issue_ref("42") is True + + def test_github_url(self) -> None: + assert is_issue_ref("https://github.com/owner/repo/issues/99") is True + + def test_gitlab_url(self) -> None: + assert is_issue_ref("https://gitlab.com/team/repo/-/issues/7") is True + + def test_shorthand(self) -> None: + assert is_issue_ref("owner/repo#42") is True + + def test_plain_text(self) -> None: + assert is_issue_ref("dashboard UI") is False + + def test_plain_text_with_slash(self) -> None: + assert is_issue_ref("eval/reliability") is False + + def test_whitespace_stripped(self) -> None: + assert is_issue_ref(" 42 ") is True + + def test_nested_gitlab_group(self) -> None: + assert is_issue_ref("https://gitlab.com/g/s/p/-/issues/3") is True + + +# ── parse_issue_ref ────────────────────────────────────────── + + +class TestParseIssueRef: + def test_bare_number(self, tmp_project: Path) -> None: + with patch("factory.issue.infer_remote", return_value=("github", "owner/repo")): + forge, owner_repo, number = parse_issue_ref("42", tmp_project) + assert forge == "github" + assert owner_repo == "owner/repo" + assert number == 42 + + def test_github_url(self, tmp_project: Path) -> None: + url = "https://github.com/acme/widgets/issues/99" + forge, owner_repo, number = parse_issue_ref(url, tmp_project) + assert forge == "github" + assert owner_repo == "acme/widgets" + assert number == 99 + + def test_gitlab_url(self, tmp_project: Path) -> None: + url = "https://gitlab.com/acme/widgets/-/issues/7" + forge, owner_repo, number = parse_issue_ref(url, tmp_project) + assert forge == "gitlab" + assert owner_repo == "acme/widgets" + assert number == 7 + + def test_gitlab_nested_groups(self, tmp_project: Path) -> None: + url = "https://gitlab.com/group/subgroup/project/-/issues/12" + forge, owner_repo, number = parse_issue_ref(url, tmp_project) + assert forge == "gitlab" + assert owner_repo == "group/subgroup/project" + assert number == 12 + + def test_github_shorthand(self, tmp_project: Path) -> None: + forge, owner_repo, number = parse_issue_ref("owner/repo#123", tmp_project) + assert forge == "github" + assert owner_repo == "owner/repo" + assert number == 123 + + def test_github_url_without_trailing_slash(self, tmp_project: Path) -> None: + url = "https://github.com/org/project/issues/1" + forge, owner_repo, number = parse_issue_ref(url, tmp_project) + assert forge == "github" + assert owner_repo == "org/project" + assert number == 1 + + def test_gitlab_self_hosted(self, tmp_project: Path) -> None: + url = "https://gitlab.ibm.com/team/repo/-/issues/55" + forge, owner_repo, number = parse_issue_ref(url, tmp_project) + assert forge == "gitlab" + assert owner_repo == "team/repo" + assert number == 55 + + def test_invalid_ref(self, tmp_project: Path) -> None: + with pytest.raises(ValueError, match="Cannot parse issue reference"): + parse_issue_ref("not-a-ref", tmp_project) + + def test_whitespace_stripped(self, tmp_project: Path) -> None: + url = " https://github.com/a/b/issues/3 " + forge, owner_repo, number = parse_issue_ref(url, tmp_project) + assert number == 3 + + +# ── infer_remote ───────────────────────────────────────────── + + +class TestInferRemote: + def test_https_github(self, tmp_project: Path) -> None: + subprocess.run( + ["git", "remote", "add", "origin", "https://github.com/owner/repo.git"], + cwd=tmp_project, capture_output=True, check=True, + ) + forge, owner_repo = infer_remote(tmp_project) + assert forge == "github" + assert owner_repo == "owner/repo" + + def test_ssh_github(self, tmp_project: Path) -> None: + subprocess.run( + ["git", "remote", "add", "origin", "git@github.com:owner/repo.git"], + cwd=tmp_project, capture_output=True, check=True, + ) + forge, owner_repo = infer_remote(tmp_project) + assert forge == "github" + assert owner_repo == "owner/repo" + + def test_https_gitlab(self, tmp_project: Path) -> None: + subprocess.run( + ["git", "remote", "add", "origin", "https://gitlab.com/team/project.git"], + cwd=tmp_project, capture_output=True, check=True, + ) + forge, owner_repo = infer_remote(tmp_project) + assert forge == "gitlab" + assert owner_repo == "team/project" + + def test_ssh_gitlab(self, tmp_project: Path) -> None: + subprocess.run( + ["git", "remote", "add", "origin", "git@gitlab.com:team/project.git"], + cwd=tmp_project, capture_output=True, check=True, + ) + forge, owner_repo = infer_remote(tmp_project) + assert forge == "gitlab" + assert owner_repo == "team/project" + + def test_no_remote(self, tmp_project: Path) -> None: + with pytest.raises(RuntimeError, match="Cannot infer remote"): + infer_remote(tmp_project) + + def test_https_without_dot_git(self, tmp_project: Path) -> None: + subprocess.run( + ["git", "remote", "add", "origin", "https://github.com/owner/repo"], + cwd=tmp_project, capture_output=True, check=True, + ) + forge, owner_repo = infer_remote(tmp_project) + assert forge == "github" + assert owner_repo == "owner/repo" + + def test_unparseable_url(self, tmp_project: Path) -> None: + subprocess.run( + ["git", "remote", "add", "origin", "file:///local/path"], + cwd=tmp_project, capture_output=True, check=True, + ) + with pytest.raises(RuntimeError, match="Cannot parse git remote URL"): + infer_remote(tmp_project) + + +# ── format_issue_as_spec ───────────────────────────────────── + + +class TestFormatIssueAsSpec: + def test_basic(self) -> None: + spec = IssueSpec( + number=42, + title="Add widget support", + body="We need widgets.\n\nDetails here.", + labels=["enhancement", "v2"], + url="https://github.com/org/repo/issues/42", + forge="github", + ) + result = format_issue_as_spec(spec) + assert result.startswith("# Add widget support\n") + assert "Issue: https://github.com/org/repo/issues/42" in result + assert "Labels: enhancement, v2" in result + assert "We need widgets." in result + + def test_no_labels(self) -> None: + spec = IssueSpec(number=1, title="Bug", body="Fix it.", forge="github") + result = format_issue_as_spec(spec) + assert "Labels:" not in result + assert "# Bug\n" in result + assert "Fix it." in result + + def test_no_url(self) -> None: + spec = IssueSpec(number=1, title="Bug", body="Fix it.", forge="github") + result = format_issue_as_spec(spec) + assert "Issue:" not in result + + +# ── fetch_issue ────────────────────────────────────────────── + + +class TestFetchIssue: + def test_github(self, tmp_project: Path) -> None: + gh_response = json.dumps({ + "number": 42, + "title": "Add widgets", + "body": "We need widgets.", + "labels": [{"name": "enhancement"}], + "url": "https://github.com/org/repo/issues/42", + }) + with patch("factory.issue.subprocess.run") as mock_run: + mock_run.return_value = subprocess.CompletedProcess( + args=[], returncode=0, stdout=gh_response, stderr="", + ) + spec = fetch_issue("https://github.com/org/repo/issues/42", tmp_project) + + assert spec.number == 42 + assert spec.title == "Add widgets" + assert spec.body == "We need widgets." + assert spec.labels == ["enhancement"] + assert spec.forge == "github" + mock_run.assert_called_once() + call_args = mock_run.call_args[0][0] + assert call_args[:3] == ["gh", "issue", "view"] + + def test_gitlab(self, tmp_project: Path) -> None: + gl_response = json.dumps({ + "iid": 7, + "title": "Fix login", + "description": "Login is broken.", + "labels": ["bug"], + "web_url": "https://gitlab.com/team/repo/-/issues/7", + }) + with patch("factory.issue.subprocess.run") as mock_run: + mock_run.return_value = subprocess.CompletedProcess( + args=[], returncode=0, stdout=gl_response, stderr="", + ) + spec = fetch_issue("https://gitlab.com/team/repo/-/issues/7", tmp_project) + + assert spec.number == 7 + assert spec.title == "Fix login" + assert spec.body == "Login is broken." + assert spec.labels == ["bug"] + assert spec.forge == "gitlab" + call_args = mock_run.call_args[0][0] + assert call_args[:3] == ["glab", "issue", "view"] + + def test_not_found(self, tmp_project: Path) -> None: + with patch("factory.issue.subprocess.run") as mock_run: + mock_run.side_effect = subprocess.CalledProcessError( + 1, "gh", stderr="issue not found", + ) + with pytest.raises(RuntimeError, match="Failed to fetch"): + fetch_issue("https://github.com/org/repo/issues/999", tmp_project) + + def test_cli_not_installed(self, tmp_project: Path) -> None: + with patch("factory.issue.subprocess.run") as mock_run: + mock_run.side_effect = FileNotFoundError() + with pytest.raises(RuntimeError, match="CLI not found"): + fetch_issue("https://github.com/org/repo/issues/1", tmp_project) + + +# ── CLI focus-as-issue integration ───────────────────────── + + +class TestFocusIssueIntegration: + """Test that --focus with issue refs works correctly via _resolve_focus_issue.""" + + def test_focus_plain_text_not_resolved(self) -> None: + from factory.cli import _resolve_focus_issue + result = _resolve_focus_issue("dashboard UI", Path("/tmp/fake")) + assert result is None + + def test_focus_bare_number_resolved(self) -> None: + from factory.cli import _resolve_focus_issue + + gh_response = json.dumps({ + "number": 42, + "title": "Add widgets", + "body": "Details.", + "labels": [], + "url": "https://github.com/org/repo/issues/42", + }) + with ( + patch("factory.issue.infer_remote", return_value=("github", "org/repo")), + patch("factory.issue.subprocess.run") as mock_run, + patch("pathlib.Path.mkdir"), + patch("pathlib.Path.write_text"), + ): + mock_run.return_value = subprocess.CompletedProcess( + args=[], returncode=0, stdout=gh_response, stderr="", + ) + result = _resolve_focus_issue("42", Path("/tmp/fake")) + + assert result is not None + title, context, number, url = result + assert number == 42 + assert title == "Add widgets" + assert "Add widgets" in context + + def test_focus_no_github_checked_by_caller(self) -> None: + """no_github is the caller's responsibility — _resolve_focus_issue doesn't check it.""" + import sys + from unittest.mock import patch as mock_patch + + with mock_patch.object(sys, "argv", ["factory", "ceo", "/tmp/fake", "--focus", "42", "--no-github"]): + from factory.cli import main + code = main() + assert code == 1 + + def test_focus_url_resolved(self) -> None: + from factory.cli import _resolve_focus_issue + + gh_response = json.dumps({ + "number": 99, + "title": "Fix bug", + "body": "Broken.", + "labels": [{"name": "bug"}], + "url": "https://github.com/acme/repo/issues/99", + }) + with ( + patch("factory.issue.subprocess.run") as mock_run, + patch("pathlib.Path.mkdir"), + patch("pathlib.Path.write_text"), + ): + mock_run.return_value = subprocess.CompletedProcess( + args=[], returncode=0, stdout=gh_response, stderr="", + ) + result = _resolve_focus_issue( + "https://github.com/acme/repo/issues/99", + Path("/tmp/fake"), + ) + + assert result is not None + title, context, number, url = result + assert number == 99 + assert title == "Fix bug" + assert "Fix bug" in context + + def test_focus_updates_name_with_issue_title(self) -> None: + """When --focus resolves to an issue, the focus name should include the issue title.""" + from factory.cli import _resolve_focus_issue + + gh_response = json.dumps({ + "number": 42, + "title": "Add widgets", + "body": "Details.", + "labels": [], + "url": "https://github.com/org/repo/issues/42", + }) + with ( + patch("factory.issue.infer_remote", return_value=("github", "org/repo")), + patch("factory.issue.subprocess.run") as mock_run, + patch("pathlib.Path.mkdir"), + patch("pathlib.Path.write_text"), + ): + mock_run.return_value = subprocess.CompletedProcess( + args=[], returncode=0, stdout=gh_response, stderr="", + ) + result = _resolve_focus_issue("42", Path("/tmp/fake")) + + assert result is not None + title, _context, number, _url = result + focus = f"{title} (issue #{number})" + assert focus == "Add widgets (issue #42)" + + +# ── _build_ceo_task issue embedding ───────────────────────── + + +class TestBuildCeoTaskIssue: + """Test that _build_ceo_task embeds issue metadata in the CEO task string.""" + + def test_focus_with_issue_number(self) -> None: + from factory.cli import _build_ceo_task + + task = _build_ceo_task( + Path("/tmp/fake"), "improve", + focus="Add widgets (issue #42)", + issue_number=42, + ) + assert "## Focus Directive (Targeted Mode)" in task + assert "Target: Add widgets (issue #42)" in task + assert "This target is from issue #42" in task + assert "## Issue Tracking" in task + assert "--issue 42" in task + + def test_focus_with_issue_number_and_url(self) -> None: + from factory.cli import _build_ceo_task + + task = _build_ceo_task( + Path("/tmp/fake"), "improve", + focus="Fix bug (issue #99)", + issue_number=99, + issue_url="https://github.com/acme/repo/issues/99", + ) + assert "#99 (https://github.com/acme/repo/issues/99)" in task + assert "## Issue Tracking" in task + + def test_focus_without_issue(self) -> None: + from factory.cli import _build_ceo_task + + task = _build_ceo_task( + Path("/tmp/fake"), "improve", + focus="eval reliability", + ) + assert "## Focus Directive (Targeted Mode)" in task + assert "Target: eval reliability" in task + assert "## Issue Tracking" not in task + assert "This target is from issue" not in task + + +# ── cmd_run --focus + --no-github ─────────────────────────── + + +class TestCmdRunFocusNoGithub: + """Test that cmd_run checks no_github before resolving issue refs.""" + + def test_run_focus_no_github_with_issue_ref_fails(self) -> None: + import sys + from unittest.mock import patch as mock_patch + + with mock_patch.object( + sys, "argv", + ["factory", "run", "/tmp/fake", "--focus", "42", "--no-github"], + ): + from factory.cli import main + + code = main() + assert code == 1