diff --git a/factory/cli.py b/factory/cli.py index d811be9..e9b54ea 100644 --- a/factory/cli.py +++ b/factory/cli.py @@ -1323,12 +1323,29 @@ def cmd_ceo(args: argparse.Namespace) -> int: headless = getattr(args, "headless", False) prompt_file = getattr(args, "prompt", None) focus = getattr(args, "focus", None) + issue_ref = getattr(args, "issue", None) if not raw_path: print("Error: provide a project path, GitHub URL, idea file, or prompt", file=sys.stderr) return 1 + no_github = getattr(args, "no_github", False) + + if issue_ref and prompt_file: + print("Error: --issue and --prompt are mutually exclusive. " + "Both provide a build spec.", file=sys.stderr) + return 1 + if issue_ref and focus: + print("Error: --issue and --focus are mutually exclusive. " + "--issue fetches a spec from a tracker; --focus targets a backlog item.", + file=sys.stderr) + return 1 + if issue_ref and no_github: + print("Error: --issue and --no-github are mutually exclusive. " + "Issue fetching requires GitHub/GitLab CLI access.", file=sys.stderr) + return 1 + if mode == "interactive": if headless: print("Error: --mode interactive requires foreground mode " @@ -1344,6 +1361,11 @@ def cmd_ceo(args: argparse.Namespace) -> int: "Interactive mode is for new ideas; --focus targets existing " "backlog items.", file=sys.stderr) return 1 + if issue_ref: + print("Error: --mode interactive and --issue are mutually exclusive. " + "Interactive mode generates the spec; --issue provides one.", + file=sys.stderr) + return 1 if mode == "research": if prompt_file: @@ -1351,6 +1373,11 @@ def cmd_ceo(args: argparse.Namespace) -> int: "Research ideation generates the spec; --prompt provides one.", file=sys.stderr) return 1 + if issue_ref: + print("Error: --mode research and --issue are mutually exclusive. " + "Research ideation generates the spec; --issue provides one.", + file=sys.stderr) + return 1 interactive_idea: str | None = None research_ideation: str | None = None @@ -1381,11 +1408,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 issue_ref: + from factory.issue import fetch_issue, format_issue_as_spec + issue_spec = fetch_issue(issue_ref, project_path) + context = format_issue_as_spec(issue_spec) + issue_number = issue_spec.number + issue_url = issue_spec.url + 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) 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 issue_ref 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 +1469,7 @@ 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) @@ -1877,6 +1919,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}" @@ -1926,6 +1970,22 @@ def _build_ceo_task( f"execute exactly what it describes. Do not infer or improvise beyond what the prompt asks for." ) + if issue_number: + issue_label = f"#{issue_number}" + if issue_url: + issue_label += f" ({issue_url})" + task += ( + f"\n\n## Directive\n\n" + f"The user has pointed the factory at issue {issue_label}. " + f"The issue spec has been written to `.factory/strategy/current.md`. " + f"Read it and execute exactly what it describes." + ) + task += ( + f"\n\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 focus: task += ( f"\n\n## Focus Directive (Targeted Mode)\n\n" @@ -2066,6 +2126,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 +2146,7 @@ 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 +2174,7 @@ 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) + issue_ref = getattr(args, "issue", None) loop = getattr(args, "loop", False) focus = getattr(args, "focus", None) discover_only = getattr(args, "discover_only", False) @@ -2126,6 +2184,42 @@ def cmd_run(args: argparse.Namespace) -> int: branch = getattr(args, "branch", None) model = _resolve_model(args) + if issue_ref and prompt_file: + print("Error: --issue and --prompt are mutually exclusive. " + "Both provide a build spec.", file=sys.stderr) + return 1 + if issue_ref and focus: + print("Error: --issue and --focus are mutually exclusive. " + "--issue fetches a spec from a tracker; --focus targets a backlog item.", + file=sys.stderr) + return 1 + if issue_ref and no_github: + print("Error: --issue and --no-github are mutually exclusive. " + "Issue fetching requires GitHub/GitLab CLI access.", file=sys.stderr) + return 1 + + if prompt_file: + context = _read_prompt_file(project_path, prompt_file) + issue_number: int | None = None + issue_url: str | None = None + if issue_ref: + from factory.issue import fetch_issue, format_issue_as_spec + issue_spec = fetch_issue(issue_ref, project_path) + context = format_issue_as_spec(issue_spec) + issue_number = issue_spec.number + issue_url = issue_spec.url + 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) + 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 issue_ref 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 +2243,7 @@ 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 +2278,7 @@ 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( @@ -2528,6 +2624,11 @@ def build_parser() -> argparse.ArgumentParser: help="Path to a prompt/spec file (absolute or relative to project). " "Loaded as the build spec into .factory/strategy/current.md", ) + p.add_argument( + "--issue", default=None, + help="GitHub/GitLab issue number or URL. Fetches issue title+body as the build spec. " + "If bare number, infers remote from git remote. If URL, auto-detects forge.", + ) p.add_argument( "--mode", choices=["auto", "auto-fresh", "build", "discover", "improve", "meta", "interactive", "research"], @@ -2571,6 +2672,11 @@ def build_parser() -> argparse.ArgumentParser: help="Path to a prompt/spec file (absolute or relative to project). " "Loaded as the build spec into .factory/strategy/current.md", ) + p.add_argument( + "--issue", default=None, + help="GitHub/GitLab issue number or URL. Fetches issue title+body as the build spec. " + "If bare number, infers remote from git remote. If URL, auto-detects forge.", + ) p.add_argument( "--mode", choices=["auto", "auto-fresh", "build", "discover", "improve", "meta", "research"], diff --git a/factory/issue.py b/factory/issue.py new file mode 100644 index 0000000..abf641f --- /dev/null +++ b/factory/issue.py @@ -0,0 +1,172 @@ +"""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 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..7622065 --- /dev/null +++ b/tests/test_issue.py @@ -0,0 +1,275 @@ +"""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, + parse_issue_ref, +) + + +# ── 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_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" + + +# ── 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 mutual exclusion ──────────────────────────────────── + + +class TestCLIMutualExclusion: + """Test that --issue is mutually exclusive with --prompt, --focus, --no-github.""" + + def _parse_args(self, *argv: str) -> int: + """Run main() with given argv and return exit code.""" + import sys + from unittest.mock import patch as mock_patch + + with mock_patch.object(sys, "argv", ["factory", *argv]): + from factory.cli import main + return main() + + def test_issue_prompt_mutual_exclusion_ceo(self) -> None: + code = self._parse_args("ceo", "/tmp/fake", "--issue", "42", "--prompt", "foo.md") + assert code == 1 + + def test_issue_focus_mutual_exclusion_ceo(self) -> None: + code = self._parse_args("ceo", "/tmp/fake", "--issue", "42", "--focus", "bar") + assert code == 1 + + def test_issue_interactive_mutual_exclusion_ceo(self) -> None: + code = self._parse_args( + "ceo", "some idea", "--issue", "42", "--mode", "interactive", + ) + assert code == 1 + + def test_issue_research_mutual_exclusion_ceo(self) -> None: + code = self._parse_args( + "ceo", "some idea", "--issue", "42", "--mode", "research", + ) + assert code == 1 + + def test_issue_no_github_mutual_exclusion_ceo(self) -> None: + code = self._parse_args("ceo", "/tmp/fake", "--issue", "42", "--no-github") + assert code == 1 + + def test_issue_prompt_mutual_exclusion_run(self) -> None: + code = self._parse_args("run", "/tmp/fake", "--issue", "42", "--prompt", "foo.md") + assert code == 1 + + def test_issue_focus_mutual_exclusion_run(self) -> None: + code = self._parse_args("run", "/tmp/fake", "--issue", "42", "--focus", "bar") + assert code == 1 + + def test_issue_no_github_mutual_exclusion_run(self) -> None: + code = self._parse_args("run", "/tmp/fake", "--issue", "42", "--no-github") + assert code == 1