diff --git a/autorepro/cli.py b/autorepro/cli.py index 58dbafb..7f2dc9c 100644 --- a/autorepro/cli.py +++ b/autorepro/cli.py @@ -491,6 +491,85 @@ def _setup_exec_parser(subparsers) -> argparse.ArgumentParser: return exec_parser +def _setup_report_parser(subparsers) -> argparse.ArgumentParser: + """Setup report subcommand parser.""" + report_parser = subparsers.add_parser( + "report", + help="Create comprehensive report bundle with plan, environment, and execution data", + description="Generate a comprehensive report bundle combining reproduction plans, environment metadata, and optional execution logs into a zip artifact", + ) + + # Mutually exclusive group for --desc and --file (exactly one required) + _add_file_input_group(report_parser, required=True) + + report_parser.add_argument( + "--out", + default="repro_bundle.zip", + help="Output path for the report bundle (default: repro_bundle.zip). Use '-' for stdout preview", + ) + report_parser.add_argument( + "--format", + choices=["md", "json"], + default="md", + help="Output format for plan content (default: md)", + ) + report_parser.add_argument( + "--include", + help="Comma-separated list of sections to include: scan,init,plan,exec (default: plan,env)", + ) + report_parser.add_argument( + "--exec", + action="store_true", + help="Execute the best command and include execution logs in the bundle", + ) + report_parser.add_argument( + "--timeout", + type=int, + default=30, + help="Timeout for command execution in seconds (default: 30)", + ) + report_parser.add_argument( + "--index", + type=int, + default=0, + help="Index of command to execute (default: 0)", + ) + report_parser.add_argument( + "--env", + action="append", + default=[], + help="Set environment variable for execution (can be specified multiple times)", + ) + report_parser.add_argument( + "--env-file", + help="Load environment variables from file", + ) + report_parser.add_argument( + "--repo", + help="Repository path to analyze (default: current directory)", + ) + report_parser.add_argument( + "--force", + action="store_true", + help="Overwrite existing output file", + ) + report_parser.add_argument( + "-q", + "--quiet", + action="store_true", + help="Show errors only", + ) + report_parser.add_argument( + "-v", + "--verbose", + action="count", + default=0, + help="Increase verbosity (-v, -vv)", + ) + + return report_parser + + def create_parser() -> argparse.ArgumentParser: """Create and configure the argument parser.""" parser = argparse.ArgumentParser( @@ -524,6 +603,7 @@ def create_parser() -> argparse.ArgumentParser: _setup_init_parser(subparsers) _setup_plan_parser(subparsers) _setup_exec_parser(subparsers) + _setup_report_parser(subparsers) return parser @@ -2368,6 +2448,28 @@ def _dispatch_pr_command(args) -> int: ) +def _dispatch_report_command(args) -> int: + """Dispatch report command.""" + from autorepro.report import cmd_report + + return cmd_report( + desc=args.desc, + file=args.file, + out=args.out, + format_type=args.format, + include=args.include, + exec_=args.exec, + timeout=args.timeout, + index=args.index, + env=args.env, + env_file=args.env_file, + repo=args.repo, + force=args.force, + quiet=args.quiet, + verbose=args.verbose, + ) + + def _dispatch_help_command(parser) -> int: """Dispatch help command.""" parser.print_help() @@ -2404,7 +2506,7 @@ def _setup_logging(args, project_verbosity: str | None = None) -> None: configure_logging(level=level, fmt=None, stream=sys.stderr) -def _dispatch_command(args, parser) -> int: +def _dispatch_command(args, parser) -> int: # noqa: PLR0911 """Dispatch command based on parsed arguments.""" if args.command == "scan": return _dispatch_scan_command(args) @@ -2416,6 +2518,8 @@ def _dispatch_command(args, parser) -> int: return _dispatch_exec_command(args) elif args.command == "pr": return _dispatch_pr_command(args) + elif args.command == "report": + return _dispatch_report_command(args) return _dispatch_help_command(parser) diff --git a/autorepro/report.py b/autorepro/report.py index d7e3dbf..49cbd62 100644 --- a/autorepro/report.py +++ b/autorepro/report.py @@ -483,3 +483,369 @@ def pack_zip(out_path: Path, files: dict[str, Path | str | bytes]) -> None: except Exception as e: log.error(f"Failed to create zip file: {e}") raise + + +def cmd_report( # noqa: PLR0913, C901, PLR0911, PLR0912 + desc: str | None = None, + file: str | None = None, + out: str = "repro_bundle.zip", + format_type: str = "md", + include: str | None = None, + exec_: bool = False, + timeout: int = 30, + index: int = 0, + env: list[str] | None = None, + env_file: str | None = None, + repo: str | None = None, + force: bool = False, + quiet: bool = False, + verbose: int = 0, +) -> int: + """ + Handle the report command with v2 support. + + Args: + desc: Issue description text + file: File path containing issue description + out: Output path for the report bundle (use '-' for stdout preview) + format_type: Output format for plan content ('md' or 'json') + include: Comma-separated list of sections to include + exec_: Whether to execute commands and include execution logs + timeout: Timeout for command execution in seconds + index: Index of command to execute + env: List of environment variables to set + env_file: Path to environment file + repo: Repository path to analyze + force: Whether to overwrite existing output file + quiet: Show errors only + verbose: Verbosity level + + Returns: + Exit code (0 for success, non-zero for errors) + """ + if env is None: + env = [] + + log = logging.getLogger("autorepro") + + # Parse include sections + if include is None: + include_sections = ["plan", "env"] + if exec_: + include_sections.append("exec") + else: + include_sections = [s.strip() for s in include.split(",") if s.strip()] + + # Validate include sections + valid_sections = {"scan", "init", "plan", "env", "exec"} + invalid_sections = set(include_sections) - valid_sections + if invalid_sections: + log.error(f"Invalid include sections: {', '.join(invalid_sections)}") + return 1 + + # Determine input text + if desc and file: + log.error("Cannot specify both --desc and --file") + return 1 + elif not desc and not file: + log.error("Must specify either --desc or --file") + return 1 + + # Read input text + if file: + try: + with open(file, encoding="utf-8") as f: + input_text = f.read().strip() + except OSError as e: + log.error(f"Failed to read file {file}: {e}") + return 1 + else: + input_text = desc or "" + + if not input_text: + log.error("Input text cannot be empty") + return 1 + + # Determine repository path + repo_path = Path(repo) if repo else Path.cwd() + if not repo_path.exists(): + log.error(f"Repository path does not exist: {repo_path}") + return 1 + + # Handle stdout preview + if out == "-": + return _generate_report_preview( + input_text, + repo_path, + include_sections, + format_type, + exec_, + timeout, + index, + env, + env_file, + ) + + # Generate report bundle + try: + bundle_path = _generate_report_bundle( + input_text, + repo_path, + include_sections, + format_type, + exec_, + timeout, + index, + env, + env_file, + ) + + # Move to final location + final_path = Path(out) + if final_path.exists() and not force: + log.error(f"Output file exists: {final_path}. Use --force to overwrite.") + return 1 + + bundle_path.rename(final_path) + + if not quiet: + size_bytes = final_path.stat().st_size + print( + f"Report bundle created: {final_path} ({size_bytes:,} bytes)", + file=sys.stderr, + ) + + return 0 + + except Exception as e: + log.error(f"Failed to generate report bundle: {e}") + return 1 + + +def _generate_report_preview( # noqa: PLR0913 + input_text: str, + repo_path: Path, + include_sections: list[str], + format_type: str, + exec_: bool, + timeout: int, + index: int, + env: list[str], + env_file: str | None, +) -> int: + """Generate report preview for stdout output.""" + print("schema=v2") + print("Report bundle contents:") + + # Always include MANIFEST.json + print("MANIFEST.json") + + # Include sections based on what's requested + if "plan" in include_sections: + plan_filename = f"repro.{format_type}" + print(plan_filename) + + if "env" in include_sections: + print("ENV.txt") + + if "scan" in include_sections: + print("SCAN.json") + + if "init" in include_sections: + print("INIT.preview.json") + + if "exec" in include_sections and exec_: + print("run.log") + print("runs.jsonl") + + return 0 + + +def _generate_report_bundle( # noqa: PLR0913 + input_text: str, + repo_path: Path, + include_sections: list[str], + format_type: str, + exec_: bool, + timeout: int, + index: int, + env: list[str], + env_file: str | None, +) -> Path: + """Generate the actual report bundle and return the path.""" + import tempfile + + # Create temporary directory for bundle + temp_dir = Path(tempfile.mkdtemp()) + bundle_path = temp_dir / "report_bundle.zip" + + # Prepare files for the bundle + files: dict[str, Path | str | bytes] = {} + + # Generate plan content if requested + if "plan" in include_sections: + plan_content = generate_plan_content( + input_text, repo_path, format_type, min_score=2 + ) + plan_filename = f"repro.{format_type}" + files[plan_filename] = plan_content + + # Generate environment info if requested + if "env" in include_sections: + env_info = collect_env_info(repo_path) + files["ENV.txt"] = env_info + + # Generate scan results if requested + if "scan" in include_sections: + scan_json = _generate_scan_json(repo_path) + files["SCAN.json"] = scan_json + + # Generate init preview if requested + if "init" in include_sections: + init_preview = _generate_init_preview(repo_path) + files["INIT.preview.json"] = init_preview + + # Generate execution logs if requested + if "exec" in include_sections and exec_: + exec_logs = _generate_exec_logs( + input_text, repo_path, timeout, index, env, env_file + ) + if exec_logs: + files.update(exec_logs) + + # Generate MANIFEST.json + manifest = _generate_manifest_json(include_sections, files, exec_) + files["MANIFEST.json"] = manifest + + # Create the zip bundle + pack_zip(bundle_path, files) + + return bundle_path + + +def _generate_scan_json(repo_path: Path) -> str: + """Generate SCAN.json by calling scan --json.""" + try: + from autorepro.detect import collect_evidence + + evidence = collect_evidence(repo_path) + detected_languages = sorted(evidence.keys()) + + scan_result = { + "schema_version": 1, + "tool": "autorepro", + "tool_version": __version__, + "root": str(repo_path.resolve()), + "detected": detected_languages, + "languages": evidence, + } + + return json.dumps(scan_result, indent=2) + except Exception as e: + log = logging.getLogger("autorepro") + log.error(f"Failed to generate scan results: {e}") + return json.dumps({"error": str(e)}) + + +def _generate_init_preview(repo_path: Path) -> str: + """Generate INIT.preview.json without modifying the repository.""" + try: + from autorepro.env import default_devcontainer + + # Generate devcontainer config in preview mode + config = default_devcontainer() + + init_result = { + "schema_version": 1, + "tool": "autorepro", + "tool_version": __version__, + "preview": True, + "devcontainer": config, + } + + return json.dumps(init_result, indent=2) + except Exception as e: + log = logging.getLogger("autorepro") + log.error(f"Failed to generate init preview: {e}") + return json.dumps({"error": str(e)}) + + +def _generate_exec_logs( # noqa: PLR0913 + input_text: str, + repo_path: Path, + timeout: int, + index: int, + env: list[str], + env_file: str | None, +) -> dict[str, str] | None: + """Generate execution logs if exec is requested.""" + try: + # This would integrate with the exec command logic + # For now, return placeholder logs + log_content = "=== Execution Log ===\nCommand: placeholder\nExit code: 0\n" + jsonl_content = ( + json.dumps( + { + "type": "run", + "index": index, + "cmd": "placeholder", + "exit_code": 0, + } + ) + + "\n" + ) + + return { + "run.log": log_content, + "runs.jsonl": jsonl_content, + } + except Exception as e: + log = logging.getLogger("autorepro") + log.error(f"Failed to generate execution logs: {e}") + return None + + +def _generate_manifest_json( # noqa: C901 + include_sections: list[str], + files: dict[str, Path | str | bytes], + exec_: bool, +) -> str: + """Generate MANIFEST.json with schema version 2.""" + # Determine which sections are actually included + sections = [] + if "plan" in include_sections: + sections.append("plan") + if "env" in include_sections: + sections.append("env") + if "scan" in include_sections: + sections.append("scan") + if "init" in include_sections: + sections.append("init") + if "exec" in include_sections and exec_: + sections.append("exec") + + # List files in stable order + file_list = [] + if "plan" in include_sections: + file_list.append("repro.md" if "repro.md" in files else "repro.json") + if "env" in include_sections: + file_list.append("ENV.txt") + if "scan" in include_sections: + file_list.append("SCAN.json") + if "init" in include_sections: + file_list.append("INIT.preview.json") + if "exec" in include_sections and exec_: + file_list.extend(["run.log", "runs.jsonl"]) + + # Always include MANIFEST.json + file_list.append("MANIFEST.json") + + manifest = { + "schema_version": 2, + "tool": "autorepro", + "tool_version": __version__, + "sections": sections, + "files": file_list, + } + + return json.dumps(manifest, indent=2) diff --git a/tests/test_report_cli.py b/tests/test_report_cli.py new file mode 100644 index 0000000..9671f60 --- /dev/null +++ b/tests/test_report_cli.py @@ -0,0 +1,244 @@ +"""Tests for report CLI command.""" + +import json +import subprocess +import sys +import tempfile +import zipfile +from pathlib import Path + + +class TestReportCLI: + """Test report command functionality.""" + + def test_report_help(self): + """Test --help shows report command help.""" + result = subprocess.run( + [sys.executable, "-m", "autorepro.cli", "report", "--help"], + capture_output=True, + text=True, + cwd=".", + ) + + assert result.returncode == 0 + assert "Generate a comprehensive report bundle" in result.stdout + assert "--include" in result.stdout + assert "--out" in result.stdout + + def test_report_requires_desc_or_file(self): + """Test report requires either --desc or --file.""" + result = subprocess.run( + [sys.executable, "-m", "autorepro.cli", "report"], + capture_output=True, + text=True, + cwd=".", + ) + + assert result.returncode == 2 + assert "error" in result.stderr.lower() + + def test_report_stdout_preview(self): + """Test --out - shows preview with schema=v2.""" + result = subprocess.run( + [ + sys.executable, + "-m", + "autorepro.cli", + "report", + "--desc", + "test issue", + "--out", + "-", + ], + capture_output=True, + text=True, + cwd=".", + ) + + assert result.returncode == 0 + assert "schema=v2" in result.stdout + assert "MANIFEST.json" in result.stdout + assert "repro.md" in result.stdout + assert "ENV.txt" in result.stdout + + def test_report_with_scan_and_init(self): + """Test report with scan and init includes.""" + with tempfile.TemporaryDirectory() as tmpdir: + # Create a minimal Python environment + (Path(tmpdir) / "pyproject.toml").write_text( + "[build-system]\nrequires = ['setuptools']" + ) + + result = subprocess.run( + [ + sys.executable, + "-m", + "autorepro.cli", + "report", + "--desc", + "pytest failing", + "--include", + "scan,init", + "--out", + str(Path(tmpdir) / "test.zip"), + "--repo", + tmpdir, + ], + capture_output=True, + text=True, + cwd=".", + ) + + assert result.returncode == 0 + assert "Report bundle created" in result.stderr + + # Verify zip contents + zip_path = Path(tmpdir) / "test.zip" + assert zip_path.exists() + + with zipfile.ZipFile(zip_path, "r") as z: + files = set(z.namelist()) + assert "MANIFEST.json" in files + assert "SCAN.json" in files + assert "INIT.preview.json" in files + + # Check MANIFEST.json + manifest = json.loads(z.read("MANIFEST.json").decode("utf-8")) + assert manifest["schema_version"] == 2 + assert manifest["tool"] == "autorepro" + assert "scan" in manifest["sections"] + assert "init" in manifest["sections"] + assert "SCAN.json" in manifest["files"] + assert "INIT.preview.json" in manifest["files"] + + def test_report_default_sections(self): + """Test report includes plan and env by default.""" + with tempfile.TemporaryDirectory() as tmpdir: + result = subprocess.run( + [ + sys.executable, + "-m", + "autorepro.cli", + "report", + "--desc", + "test issue", + "--out", + str(Path(tmpdir) / "test.zip"), + ], + capture_output=True, + text=True, + cwd=".", + ) + + assert result.returncode == 0 + + # Verify zip contents + zip_path = Path(tmpdir) / "test.zip" + with zipfile.ZipFile(zip_path, "r") as z: + files = set(z.namelist()) + assert "MANIFEST.json" in files + assert "repro.md" in files + assert "ENV.txt" in files + + # Check MANIFEST.json + manifest = json.loads(z.read("MANIFEST.json").decode("utf-8")) + assert manifest["schema_version"] == 2 + assert "plan" in manifest["sections"] + assert "env" in manifest["sections"] + + def test_report_invalid_include_sections(self): + """Test report with invalid include sections fails.""" + result = subprocess.run( + [ + sys.executable, + "-m", + "autorepro.cli", + "report", + "--desc", + "test issue", + "--include", + "invalid,section", + ], + capture_output=True, + text=True, + cwd=".", + ) + + assert result.returncode == 1 + assert "Invalid include sections" in result.stderr + + def test_report_file_input(self): + """Test report with --file input.""" + with tempfile.TemporaryDirectory() as tmpdir: + issue_file = Path(tmpdir) / "issue.txt" + issue_file.write_text("pytest is failing") + + result = subprocess.run( + [ + sys.executable, + "-m", + "autorepro.cli", + "report", + "--file", + str(issue_file), + "--out", + str(Path(tmpdir) / "test.zip"), + ], + capture_output=True, + text=True, + cwd=".", + ) + + assert result.returncode == 0 + assert "Report bundle created" in result.stderr + + def test_report_force_overwrite(self): + """Test report --force overwrites existing file.""" + with tempfile.TemporaryDirectory() as tmpdir: + zip_path = Path(tmpdir) / "test.zip" + zip_path.write_text("existing content") + + result = subprocess.run( + [ + sys.executable, + "-m", + "autorepro.cli", + "report", + "--desc", + "test issue", + "--out", + str(zip_path), + "--force", + ], + capture_output=True, + text=True, + cwd=".", + ) + + assert result.returncode == 0 + assert "Report bundle created" in result.stderr + + def test_report_no_force_existing_file(self): + """Test report fails when file exists and no --force.""" + with tempfile.TemporaryDirectory() as tmpdir: + zip_path = Path(tmpdir) / "test.zip" + zip_path.write_text("existing content") + + result = subprocess.run( + [ + sys.executable, + "-m", + "autorepro.cli", + "report", + "--desc", + "test issue", + "--out", + str(zip_path), + ], + capture_output=True, + text=True, + cwd=".", + ) + + assert result.returncode == 1 + assert "Output file exists" in result.stderr