From 076140faa7bc157800f23a91229d2b83c0f7fea2 Mon Sep 17 00:00:00 2001 From: akashgit Date: Sun, 10 May 2026 00:11:10 -0400 Subject: [PATCH 1/2] feat: add --issue flag to factory ceo/run for direct issue targeting Adds the ability to point the factory at a GitHub or GitLab issue directly, fetching the issue content and using it as the build spec. - New module factory/issue.py with IssueSpec dataclass, parse_issue_ref, infer_remote, fetch_issue, and format_issue_as_spec - --issue flag on both ceo and run subparsers - Mutual exclusion with --prompt, --focus, --no-github, --mode interactive/research - Issue spec persisted to .factory/strategy/current.md (same as --prompt) - Issue number/URL passed through to _build_ceo_task for CEO directive - 25 tests covering parsing, remote inference, formatting, fetching, and CLI validation Closes #209 Co-Authored-By: Claude Opus 4.6 --- factory/cli.py | 108 +++++++++++++++++- factory/issue.py | 172 +++++++++++++++++++++++++++++ tests/test_issue.py | 259 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 537 insertions(+), 2 deletions(-) create mode 100644 factory/issue.py create mode 100644 tests/test_issue.py diff --git a/factory/cli.py b/factory/cli.py index d811be9..640cf1f 100644 --- a/factory/cli.py +++ b/factory/cli.py @@ -1323,12 +1323,23 @@ 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 + 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 mode == "interactive": if headless: print("Error: --mode interactive requires foreground mode " @@ -1344,6 +1355,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 +1367,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,9 +1402,24 @@ 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) @@ -1392,6 +1428,11 @@ def cmd_ceo(args: argparse.Namespace) -> int: model = _resolve_model(args) runner_name = _resolve_runner(args) + 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 == "research" and not research_ideation and not _has_research_target(project_path): print("Error: --mode research requires research_target in factory.md. " "Either configure research_target manually, or pass an idea string " @@ -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,28 @@ 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) + issue_ref = getattr(args, "issue", None) 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 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, + ) loop = getattr(args, "loop", False) focus = getattr(args, "focus", None) discover_only = getattr(args, "discover_only", False) @@ -2126,6 +2205,19 @@ 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 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 +2241,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 +2276,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 +2622,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 +2670,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..d12e74c --- /dev/null +++ b/tests/test_issue.py @@ -0,0 +1,259 @@ +"""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 From 50bd4f4a72ebd4e983b090b62cf86a411c502ad1 Mon Sep 17 00:00:00 2001 From: akashgit Date: Sun, 10 May 2026 00:32:37 -0400 Subject: [PATCH 2/2] fix: move --issue mutual exclusion checks before network fetch Fixes ordering bug in cmd_run where --issue validation happened after the network call to fetch the issue. Also moves --no-github check in cmd_ceo to early validation block. Adds missing cmd_run mutual exclusion tests (4 new tests). Co-Authored-By: Claude Opus 4.6 --- factory/cli.py | 56 +++++++++++++++++++++++---------------------- tests/test_issue.py | 16 +++++++++++++ 2 files changed, 45 insertions(+), 27 deletions(-) diff --git a/factory/cli.py b/factory/cli.py index 640cf1f..e9b54ea 100644 --- a/factory/cli.py +++ b/factory/cli.py @@ -1330,6 +1330,8 @@ def cmd_ceo(args: argparse.Namespace) -> int: 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) @@ -1339,6 +1341,10 @@ def cmd_ceo(args: argparse.Namespace) -> int: "--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: @@ -1421,18 +1427,12 @@ def cmd_ceo(args: argparse.Namespace) -> int: 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) model = _resolve_model(args) runner_name = _resolve_runner(args) - 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 == "research" and not research_ideation and not _has_research_target(project_path): print("Error: --mode research requires research_target in factory.md. " "Either configure research_target manually, or pass an idea string " @@ -2175,27 +2175,6 @@ def cmd_run(args: argparse.Namespace) -> int: project_path, context = _resolve_input(args.path) prompt_file = getattr(args, "prompt", None) issue_ref = getattr(args, "issue", None) - 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, - ) loop = getattr(args, "loop", False) focus = getattr(args, "focus", None) discover_only = getattr(args, "discover_only", False) @@ -2218,6 +2197,29 @@ def cmd_run(args: argparse.Namespace) -> int: 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) diff --git a/tests/test_issue.py b/tests/test_issue.py index d12e74c..7622065 100644 --- a/tests/test_issue.py +++ b/tests/test_issue.py @@ -257,3 +257,19 @@ def test_issue_research_mutual_exclusion_ceo(self) -> None: "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