diff --git a/config/repo_config.py b/config/repo_config.py index 80a83764a7..f9cfca3fc7 100644 --- a/config/repo_config.py +++ b/config/repo_config.py @@ -299,6 +299,49 @@ def should_disable_auto_fix(repo: str) -> bool: return get_repo_setting(repo, "disable_auto_fix", False) +try: + from egg_config.validators import validate_checks +except ImportError: + + def validate_checks(checks: list) -> list[dict[str, str]]: # type: ignore[misc] + """Validate and normalize a list of check command entries. + + Filters out malformed entries and coerces values to strings. + + Args: + checks: Raw list of check entries (e.g. from YAML or JSON). + + Returns: + List of {"name": "...", "command": "..."} dicts with only + valid entries retained. + """ + if not isinstance(checks, list): + return [] + return [ + {"name": str(c["name"]), "command": str(c["command"])} + for c in checks + if isinstance(c, dict) and "name" in c and "command" in c + ] + + +def get_repo_checks(repo: str) -> list[dict[str, str]]: + """Get configured check commands for a repository. + + These are the commands to run during the SDLC pipeline implement phase + checker step. Each check has a "name" (display label) and "command" + (shell command to execute). They run sequentially. + + Args: + repo: Repository in "owner/repo" format + + Returns: + List of {"name": "...", "command": "..."} dicts, + or empty list if no checks configured. + """ + checks = get_repo_setting(repo, "checks", []) + return validate_checks(checks) + + def get_auth_mode(repo: str) -> str: """ Get the authentication mode for a repository. diff --git a/config/repositories.yaml.example b/config/repositories.yaml.example index 95f9bb7641..eb10fa619c 100644 --- a/config/repositories.yaml.example +++ b/config/repositories.yaml.example @@ -73,13 +73,28 @@ readable_repos: # - auth_mode: Authentication mode for this repo # "bot" (default): Use GitHub App bot identity # "user": Use personal access token with user identity +# - checks: List of check commands for the SDLC pipeline implement phase +# Each entry has "name" (display label) and "command" (shell command) +# These run sequentially during the checker step repo_settings: # Example: # YOUR_USERNAME/egg: # restrict_to_configured_users: true # disable_auto_fix: true + # checks: + # - name: lint + # command: make lint + # - name: test + # command: make test # some-org/external-repo: # auth_mode: user # Use personal identity for this repo + # checks: + # - name: install + # command: npm install + # - name: lint + # command: npm run lint + # - name: test + # command: npm test # User mode configuration (optional) # When auth_mode is set to "user" for a repo, operations will be diff --git a/docker-compose.yml b/docker-compose.yml index 76b23bee6a..f5ed17f7d0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -117,6 +117,9 @@ services: # Host repo map for sandbox volume mounts (Docker socket sees host paths) # JSON mapping of repo_name -> host_path, auto-generated from repositories.yaml - EGG_HOST_REPO_MAP=${EGG_HOST_REPO_MAP:-{}} + # Per-repo check commands for SDLC pipeline implement phase + # JSON mapping of owner/repo -> [{name, command}, ...], from repositories.yaml + - EGG_REPO_CHECKS=${EGG_REPO_CHECKS:-{}} # Sandbox image for spawned containers - EGG_SANDBOX_IMAGE=${EGG_SANDBOX_IMAGE:-egg:latest} # Launcher secret for gateway session registration diff --git a/integration_tests/local_pipeline/conftest.py b/integration_tests/local_pipeline/conftest.py index e8c91fa583..b155d43937 100644 --- a/integration_tests/local_pipeline/conftest.py +++ b/integration_tests/local_pipeline/conftest.py @@ -50,6 +50,11 @@ def _write_test_config(config_dir: str, launcher_secret: str) -> None: repo_settings: test-owner/test-repo: auth_mode: bot + checks: + - name: lint + command: "echo 'lint ok'" + - name: test + command: "echo 'test ok'" user_mode: github_user: test-user @@ -200,6 +205,14 @@ def local_pipeline_stack() -> Generator[LocalPipelineStack, None, None]: "EGG_LAUNCHER_SECRET": launcher_secret, "EGG_CONFIG_DIR": config_dir, "EGG_HOST_REPO_MAP": json.dumps({repo_name: repos_dir}), + "EGG_REPO_CHECKS": json.dumps( + { + "test-owner/test-repo": [ + {"name": "lint", "command": "echo 'lint ok'"}, + {"name": "test", "command": "echo 'test ok'"}, + ] + } + ), "HOST_UID": str(os.getuid()), "HOST_GID": str(os.getgid()), "GATEWAY_PORT": "0", diff --git a/orchestrator/routes/pipelines.py b/orchestrator/routes/pipelines.py index 729a51572a..dc4ad8177e 100644 --- a/orchestrator/routes/pipelines.py +++ b/orchestrator/routes/pipelines.py @@ -67,6 +67,19 @@ def get_logger(name: str, **kwargs) -> logging.Logger: # type: ignore[misc] ORCHESTRATOR_EXTERNAL_IP = "172.33.0.3" ORCHESTRATOR_PORT = 9849 +try: + from egg_config.validators import validate_checks +except ImportError: + + def validate_checks(checks: list) -> list[dict[str, str]]: # type: ignore[misc] + if not isinstance(checks, list): + return [] + return [ + {"name": str(c["name"]), "command": str(c["command"])} + for c in checks + if isinstance(c, dict) and "name" in c and "command" in c + ] + pipelines_bp = Blueprint("pipelines", __name__, url_prefix="/api/v1/pipelines") @@ -1469,44 +1482,88 @@ def _spawn_and_wait( _HITL_GATE_PHASES = {"refine", "plan"} -def _build_checker_prompt(pipeline_id: str, pipeline_mode: str) -> str: +def _build_checker_prompt( + pipeline_id: str, + pipeline_mode: str, + repo: str | None = None, + repo_checks: list[dict] | None = None, +) -> str: """Build a prompt for the checker agent that runs tests/lint. The checker discovers and runs project test/lint commands, then writes structured results to .egg-state/checks/implement-results.json. + + Args: + pipeline_id: Pipeline identifier. + pipeline_mode: Pipeline mode (e.g. "local", "issue"). + repo: Target repository in "owner/repo" format. + repo_checks: Pre-configured check commands from repositories.yaml. """ - return ( - "You are the **checker** for the SDLC pipeline implement phase.\n\n" - f"Pipeline ID: {pipeline_id}\n" - f"Mode: {pipeline_mode}\n\n" - "## Your Task\n\n" - "Discover and run all project test and lint commands, then write results.\n\n" - "1. **Discover commands**: Look for Makefile, pyproject.toml, package.json, " - "setup.cfg, tox.ini, or similar build/test configuration files\n" - "2. **Run tests**: Execute the project's test suite (pytest, jest, go test, etc.)\n" - "3. **Run linting**: Execute linters (ruff, eslint, golangci-lint, etc.)\n" - "4. **Write results**: Create `.egg-state/checks/implement-results.json` with:\n\n" - "```json\n" - "{\n" - ' "all_passed": true/false,\n' - ' "checks": [\n' - ' {"name": "pytest", "passed": true/false, "output": "summary of output"},\n' - ' {"name": "lint", "passed": true/false, "output": "summary of output"}\n' - " ]\n" - "}\n" - "```\n\n" - "5. Commit the results file\n\n" - "## Important\n\n" - "- Always exit 0 regardless of check results (results are informational)\n" - "- Write the results file even if all checks pass\n" - "- If you cannot find any test/lint commands, write all_passed: true\n" + lines = [ + "You are the **checker** for the SDLC pipeline implement phase.\n", + f"Pipeline ID: {pipeline_id}", + f"Mode: {pipeline_mode}", + ] + if repo: + repo_name = repo.split("/")[-1] + lines.append(f"Repository: {repo}") + lines.append(f"Working directory: ~/repos/{repo_name}") + lines.append("") + + lines.append("## Your Task\n") + + if repo_checks: + # Use explicitly configured check commands + lines.append("Run the following check commands in order, then write results.\n") + if repo: + repo_name = repo.split("/")[-1] + lines.append(f"First, `cd ~/repos/{repo_name}`.\n") + for i, check in enumerate(repo_checks, 1): + lines.append(f"{i}. **{check['name']}**: `{check['command']}`") + lines.append("") + else: + # Fall back to discovery mode + lines.append("Discover and run all project test and lint commands, then write results.\n") + if repo: + repo_name = repo.split("/")[-1] + lines.append(f"Work in the `~/repos/{repo_name}` directory.\n") + lines.extend( + [ + "1. **Discover commands**: Look for Makefile, pyproject.toml, package.json, " + "setup.cfg, tox.ini, or similar build/test configuration files", + "2. **Run tests**: Execute the project's test suite (pytest, jest, go test, etc.)", + "3. **Run linting**: Execute linters (ruff, eslint, golangci-lint, etc.)", + "", + ] + ) + + lines.extend( + [ + "After running checks, **write results** to `.egg-state/checks/implement-results.json`:\n", + "```json", + "{", + ' "all_passed": true/false,', + ' "checks": [', + ' {"name": "pytest", "passed": true/false, "output": "summary of output"},', + ' {"name": "lint", "passed": true/false, "output": "summary of output"}', + " ]", + "}", + "```\n", + "Then commit the results file.\n", + "## Important\n", + "- Always exit 0 regardless of check results (results are informational)", + "- Write the results file even if all checks pass", + "- If you cannot find any test/lint commands, write all_passed: true", + ] ) + return "\n".join(lines) def _build_autofix_prompt( pipeline_id: str, pipeline_mode: str, check_results: dict, + repo: str | None = None, ) -> str: """Build a prompt for the autofixer agent. @@ -1522,30 +1579,48 @@ def _build_autofix_prompt( failure_summary = "\n".join(failures) if failures else "No specific failures recorded." - return ( - "You are the **autofixer** for the SDLC pipeline implement phase.\n\n" - f"Pipeline ID: {pipeline_id}\n" - f"Mode: {pipeline_mode}\n\n" - "## Check Failures\n\n" - f"{failure_summary}\n\n" - "## Your Task\n\n" - "**Fix ALL auto-fixable issues in a single pass.**\n\n" - "1. **Read the check results** at `.egg-state/checks/implement-results.json`\n" - "2. **Investigate all failures**: Examine test output, lint errors, etc.\n" - "3. **Fix without committing yet**: For each auto-fixable issue " - "(lint errors, formatting, simple type errors, obvious test fixes), make the fix\n" - "4. **Verify locally**: Run the same checks again to confirm fixes work\n" - "5. **Commit all fixes together** with a descriptive message\n\n" - "## Auto-fixable vs Report-only\n\n" - "**Auto-fixable (commit fixes directly):**\n" - "- Lint errors (formatting, import order, code style)\n" - "- Type errors with clear fixes\n" - "- Simple test failures with obvious fixes\n\n" - "**Report only (note in commit message):**\n" - "- Complex logic errors requiring design decisions\n" - "- Security issues requiring architectural changes\n" - "- Test failures from unclear requirements\n" + lines = [ + "You are the **autofixer** for the SDLC pipeline implement phase.\n", + f"Pipeline ID: {pipeline_id}", + f"Mode: {pipeline_mode}", + ] + if repo: + repo_name = repo.split("/")[-1] + lines.append(f"Repository: {repo}") + lines.append(f"Working directory: ~/repos/{repo_name}") + lines.extend( + [ + "", + "## Check Failures\n", + failure_summary, + "", + "## Your Task\n", + "**Fix ALL auto-fixable issues in a single pass.**\n", + ] ) + if repo: + repo_name = repo.split("/")[-1] + lines.append(f"Work in the `~/repos/{repo_name}` directory.\n") + lines.extend( + [ + "1. **Read the check results** at `.egg-state/checks/implement-results.json`", + "2. **Investigate all failures**: Examine test output, lint errors, etc.", + "3. **Fix without committing yet**: For each auto-fixable issue " + "(lint errors, formatting, simple type errors, obvious test fixes), make the fix", + "4. **Verify locally**: Run the same checks again to confirm fixes work", + "5. **Commit all fixes together** with a descriptive message\n", + "## Auto-fixable vs Report-only\n", + "**Auto-fixable (commit fixes directly):**", + "- Lint errors (formatting, import order, code style)", + "- Type errors with clear fixes", + "- Simple test failures with obvious fixes\n", + "**Report only (note in commit message):**", + "- Complex logic errors requiring design decisions", + "- Security issues requiring architectural changes", + "- Test failures from unclear requirements", + ] + ) + return "\n".join(lines) def _read_check_results(repo_path: Path) -> dict | None: @@ -1935,6 +2010,21 @@ def _run_pipeline(pipeline_id: str, repo_path: Path) -> None: # 2. Checker + autofix loop (implement phase only) if current_phase.value == "implement": + # Look up configured check commands for this repo + repo_checks: list[dict] | None = None + if pipeline.repo: + try: + all_repo_checks = json.loads(os.environ.get("EGG_REPO_CHECKS", "{}")) + except json.JSONDecodeError: + all_repo_checks = {} + # Case-insensitive lookup + repo_lower = pipeline.repo.lower() + for cfg_repo, cfg_checks in all_repo_checks.items(): + if cfg_repo.lower() == repo_lower: + if isinstance(cfg_checks, list): + repo_checks = validate_checks(cfg_checks) or None + break + max_autofix = 3 for autofix_attempt in range(max_autofix): logger.info( @@ -1943,7 +2033,12 @@ def _run_pipeline(pipeline_id: str, repo_path: Path) -> None: autofix_attempt=autofix_attempt + 1, ) - checker_prompt = _build_checker_prompt(pipeline_id, pipeline_mode) + checker_prompt = _build_checker_prompt( + pipeline_id, + pipeline_mode, + repo=pipeline.repo, + repo_checks=repo_checks, + ) checker_command = [ "claude", "--dangerously-skip-permissions", @@ -2009,7 +2104,10 @@ def _run_pipeline(pipeline_id: str, repo_path: Path) -> None: ) autofix_prompt = _build_autofix_prompt( - pipeline_id, pipeline_mode, check_results + pipeline_id, + pipeline_mode, + check_results, + repo=pipeline.repo, ) autofix_command = [ "claude", diff --git a/orchestrator/tests/test_pipeline_prompts.py b/orchestrator/tests/test_pipeline_prompts.py new file mode 100644 index 0000000000..844078f3f4 --- /dev/null +++ b/orchestrator/tests/test_pipeline_prompts.py @@ -0,0 +1,101 @@ +""" +Tests for pipeline prompt builder functions (_build_checker_prompt, _build_autofix_prompt). +""" + +import sys +from unittest.mock import MagicMock + +# Mock heavy dependencies that pipelines.py imports at module level +_docker_mock = MagicMock() +sys.modules.setdefault("docker", _docker_mock) +sys.modules.setdefault("docker.errors", _docker_mock.errors) +sys.modules.setdefault("docker.types", _docker_mock.types) + +from routes.pipelines import _build_autofix_prompt, _build_checker_prompt + + +class TestBuildCheckerPrompt: + """Tests for _build_checker_prompt with repo_checks parameter.""" + + def test_discovery_mode_without_repo_checks(self): + """Without repo_checks, prompt uses discovery instructions.""" + result = _build_checker_prompt("pid-1", "local") + assert "Discover and run all project test and lint commands" in result + assert "Makefile" in result + + def test_discovery_mode_with_repo(self): + """Discovery mode includes repo working directory.""" + result = _build_checker_prompt("pid-1", "local", repo="acme/web-app") + assert "Repository: acme/web-app" in result + assert "~/repos/web-app" in result + assert "Discover and run all" in result + + def test_explicit_checks_mode(self): + """With repo_checks, prompt lists explicit commands instead of discovery.""" + checks = [ + {"name": "lint", "command": "make lint"}, + {"name": "test", "command": "make test"}, + ] + result = _build_checker_prompt("pid-1", "local", repo="acme/web-app", repo_checks=checks) + assert "make lint" in result + assert "make test" in result + assert "**lint**" in result + assert "**test**" in result + # Should NOT contain discovery instructions + assert "Discover and run all" not in result + assert "Makefile" not in result + + def test_explicit_checks_without_repo(self): + """Explicit checks work even without a repo specified.""" + checks = [{"name": "build", "command": "npm run build"}] + result = _build_checker_prompt("pid-1", "issue", repo_checks=checks) + assert "npm run build" in result + assert "Repository:" not in result + + def test_always_includes_results_format(self): + """Both modes include the results JSON format.""" + checks = [{"name": "test", "command": "pytest"}] + for prompt in [ + _build_checker_prompt("pid-1", "local"), + _build_checker_prompt("pid-1", "local", repo_checks=checks), + ]: + assert "implement-results.json" in prompt + assert "all_passed" in prompt + + def test_includes_pipeline_metadata(self): + """Prompt always includes pipeline ID and mode.""" + result = _build_checker_prompt("pid-42", "issue") + assert "pid-42" in result + assert "issue" in result + + +class TestBuildAutofixPrompt: + """Tests for _build_autofix_prompt with repo parameter.""" + + def test_without_repo(self): + """Basic autofix prompt without repo context.""" + results = {"checks": [{"name": "lint", "passed": False, "output": "3 errors"}]} + result = _build_autofix_prompt("pid-1", "local", results) + assert "**lint**" in result + assert "3 errors" in result + assert "Repository:" not in result + + def test_with_repo(self): + """Autofix prompt includes repo working directory.""" + results = {"checks": [{"name": "test", "passed": False, "output": "1 failure"}]} + result = _build_autofix_prompt("pid-1", "local", results, repo="acme/web-app") + assert "Repository: acme/web-app" in result + assert "~/repos/web-app" in result + + def test_no_failures(self): + """Prompt handles case with no failing checks.""" + results = {"checks": [{"name": "lint", "passed": True, "output": "ok"}]} + result = _build_autofix_prompt("pid-1", "local", results) + assert "No specific failures recorded" in result + + def test_includes_pipeline_metadata(self): + """Prompt includes pipeline ID and mode.""" + results = {"checks": []} + result = _build_autofix_prompt("pid-99", "issue", results) + assert "pid-99" in result + assert "issue" in result diff --git a/sandbox/egg_lib/compose.py b/sandbox/egg_lib/compose.py index 08a4132aa8..a000169a18 100644 --- a/sandbox/egg_lib/compose.py +++ b/sandbox/egg_lib/compose.py @@ -131,7 +131,7 @@ def _generate_env_file(compose_file: Path) -> bool: if key in secrets_dict: env_vars[key] = secrets_dict[key] - # Git identity from repositories.yaml + # Git identity and per-repo checks from repositories.yaml if config_file.exists(): git_name, git_email = _get_user_git_config(config_file) if git_name: @@ -139,6 +139,24 @@ def _generate_env_file(compose_file: Path) -> bool: if git_email: env_vars["EGG_USER_GIT_EMAIL"] = git_email + # Build per-repo checks map for the orchestrator + try: + import yaml + from egg_config.validators import validate_checks + + with config_file.open() as f: + cfg = yaml.safe_load(f) or {} + repo_checks: dict[str, list[dict[str, str]]] = {} + for repo_name, settings in (cfg.get("repo_settings") or {}).items(): + checks = settings.get("checks") if isinstance(settings, dict) else None + if checks and isinstance(checks, list): + valid = validate_checks(checks) + if valid: + repo_checks[repo_name] = valid + env_vars["EGG_REPO_CHECKS"] = json.dumps(repo_checks) + except Exception: + env_vars["EGG_REPO_CHECKS"] = "{}" + # Write .env file env_file = compose_file.parent / ".env" lines = [ diff --git a/sandbox/egg_lib/setup_flow.py b/sandbox/egg_lib/setup_flow.py index f5b7bd4013..0638f8dc4d 100644 --- a/sandbox/egg_lib/setup_flow.py +++ b/sandbox/egg_lib/setup_flow.py @@ -277,6 +277,67 @@ def _create_launcher_secret() -> None: info("Generated launcher secret for gateway authentication") +def _configure_repo_checks(writable_repos: list[str]) -> dict: + """Prompt for per-repo check commands (test/lint) for the SDLC pipeline. + + For each writable repo, asks whether the user wants to configure explicit + check commands. When configured, the SDLC pipeline checker step runs + these commands instead of auto-discovering them. + + Args: + writable_repos: List of repos in "owner/repo" format. + + Returns: + A ``repo_settings`` dict suitable for writing to repositories.yaml. + Only repos with configured checks will have entries. + """ + if not writable_repos: + return {} + + print() + gate = input("Configure SDLC check commands? (yes/no) [no]: ").strip().lower() + if gate != "yes": + return {} + + info("Per-repository check commands:") + print(" The SDLC pipeline runs test/lint checks after implementing changes.") + print(" By default it auto-discovers commands (Makefile, package.json, etc.).") + print(" You can configure explicit commands per repo instead.") + print() + + repo_settings: dict = {} + + for repo in writable_repos: + response = input(f"Configure check commands for {repo}? (yes/no) [no]: ").strip().lower() + if response != "yes": + continue + + print(f" Enter check commands for {repo}.") + print(" Each check has a name (e.g. 'lint') and a shell command (e.g. 'make lint').") + print(" Press Enter on empty name when done.") + print() + + checks: list[dict[str, str]] = [] + while True: + name = input(" Check name (or Enter to finish): ").strip() + if not name: + break + command = input(f" Command for '{name}': ").strip() + if not command: + warn(f" No command provided for '{name}'. Skipping.") + continue + checks.append({"name": name, "command": command}) + success(f" Added check: {name} -> {command}") + + if checks: + repo_settings[repo] = {"checks": checks} + success(f"Configured {len(checks)} check(s) for {repo}") + else: + info(f" No checks configured for {repo} (will use auto-discovery)") + + return repo_settings + + def _create_repositories_config() -> bool: """Create repositories.yaml interactively.""" config_file = Config.USER_CONFIG_DIR / "repositories.yaml" @@ -370,18 +431,22 @@ def _create_repositories_config() -> bool: print() branch_prefix = input(f"Branch prefix [{bot_name}]: ").strip().lower() or bot_name + # Configure per-repo check commands + effective_writable = writable_repos if writable_repos else [f"{github_username}/egg"] + repo_settings = _configure_repo_checks(effective_writable) + # Build config config = { "github_username": github_username, "bot_username": bot_name, - "writable_repos": writable_repos if writable_repos else [f"{github_username}/egg"], + "writable_repos": effective_writable, "default_reviewer": github_username, "github_sync": { "sync_all_prs": True, "sync_interval_minutes": 5, }, "readable_repos": [], - "repo_settings": {}, + "repo_settings": repo_settings, "user_mode": {}, "local_repos": { "paths": local_repo_paths, diff --git a/shared/egg_config/validators.py b/shared/egg_config/validators.py index a330b3bbb9..68b0e236df 100644 --- a/shared/egg_config/validators.py +++ b/shared/egg_config/validators.py @@ -160,6 +160,29 @@ def validate_non_empty(value: str | None, field_name: str) -> tuple[bool, str | return True, None +def validate_checks(checks: list) -> list[dict[str, str]]: + """Validate and normalize a list of check command entries. + + Filters out malformed entries and coerces values to strings. + Used by config, orchestrator, and compose to validate check + definitions from YAML config or JSON env vars. + + Args: + checks: Raw list of check entries (e.g. from YAML or JSON). + + Returns: + List of {"name": "...", "command": "..."} dicts with only + valid entries retained. + """ + if not isinstance(checks, list): + return [] + return [ + {"name": str(c["name"]), "command": str(c["command"])} + for c in checks + if isinstance(c, dict) and "name" in c and "command" in c + ] + + def validate_port(port: int | str) -> tuple[bool, str | None]: """Validate a port number. diff --git a/tests/config/test_repo_config.py b/tests/config/test_repo_config.py index 0a3a0d5e59..22bce1b93f 100644 --- a/tests/config/test_repo_config.py +++ b/tests/config/test_repo_config.py @@ -371,3 +371,114 @@ def test_handles_unicode(self, temp_dir): config = yaml.safe_load(config_file.read_text()) assert "user_" in config["github_username"] + + +class TestGetRepoChecks: + """Tests for get_repo_checks function.""" + + def test_returns_configured_checks(self, temp_dir, monkeypatch): + """Test returning configured check commands for a repo.""" + from config.repo_config import get_repo_checks + + config_file = temp_dir / "repositories.yaml" + config_file.write_text( + "github_username: testuser\n" + "repo_settings:\n" + " testuser/my-app:\n" + " checks:\n" + " - name: lint\n" + " command: npm run lint\n" + " - name: test\n" + " command: npm test\n" + ) + monkeypatch.setenv("EGG_REPO_CONFIG", str(config_file)) + + checks = get_repo_checks("testuser/my-app") + assert len(checks) == 2 + assert checks[0] == {"name": "lint", "command": "npm run lint"} + assert checks[1] == {"name": "test", "command": "npm test"} + + def test_returns_empty_list_when_no_checks(self, temp_dir, monkeypatch): + """Test returning empty list when no checks configured for repo.""" + from config.repo_config import get_repo_checks + + config_file = temp_dir / "repositories.yaml" + config_file.write_text( + "github_username: testuser\n" + "repo_settings:\n" + " testuser/my-app:\n" + " restrict_to_configured_users: true\n" + ) + monkeypatch.setenv("EGG_REPO_CONFIG", str(config_file)) + + checks = get_repo_checks("testuser/my-app") + assert checks == [] + + def test_returns_empty_list_for_unknown_repo(self, temp_dir, monkeypatch): + """Test returning empty list when repo is not in config.""" + from config.repo_config import get_repo_checks + + config_file = temp_dir / "repositories.yaml" + config_file.write_text( + "github_username: testuser\n" + "repo_settings:\n" + " testuser/my-app:\n" + " checks:\n" + " - name: lint\n" + " command: npm run lint\n" + ) + monkeypatch.setenv("EGG_REPO_CONFIG", str(config_file)) + + checks = get_repo_checks("testuser/other-repo") + assert checks == [] + + def test_case_insensitive_repo_matching(self, temp_dir, monkeypatch): + """Test that repo name matching is case insensitive.""" + from config.repo_config import get_repo_checks + + config_file = temp_dir / "repositories.yaml" + config_file.write_text( + "github_username: testuser\n" + "repo_settings:\n" + " TestUser/My-App:\n" + " checks:\n" + " - name: test\n" + " command: make test\n" + ) + monkeypatch.setenv("EGG_REPO_CONFIG", str(config_file)) + + checks = get_repo_checks("testuser/my-app") + assert len(checks) == 1 + assert checks[0] == {"name": "test", "command": "make test"} + + def test_skips_invalid_check_entries(self, temp_dir, monkeypatch): + """Test that malformed check entries are filtered out.""" + from config.repo_config import get_repo_checks + + config_file = temp_dir / "repositories.yaml" + config_file.write_text( + "github_username: testuser\n" + "repo_settings:\n" + " testuser/my-app:\n" + " checks:\n" + " - name: valid\n" + " command: make test\n" + " - name: missing-command\n" + " - just-a-string\n" + ) + monkeypatch.setenv("EGG_REPO_CONFIG", str(config_file)) + + checks = get_repo_checks("testuser/my-app") + assert len(checks) == 1 + assert checks[0]["name"] == "valid" + + def test_returns_empty_for_no_repo_settings(self, temp_dir, monkeypatch): + """Test returning empty list when repo_settings section is absent.""" + from config.repo_config import get_repo_checks + + config_file = temp_dir / "repositories.yaml" + config_file.write_text("github_username: testuser\n") + monkeypatch.setenv("EGG_REPO_CONFIG", str(config_file)) + + checks = get_repo_checks("testuser/my-app") + assert checks == [] diff --git a/tests/sandbox/test_setup_flow.py b/tests/sandbox/test_setup_flow.py index 7446c722e7..9d96c8fe2f 100644 --- a/tests/sandbox/test_setup_flow.py +++ b/tests/sandbox/test_setup_flow.py @@ -8,8 +8,10 @@ sys.path.insert(0, str(sandbox_path)) from egg_lib.setup_flow import ( + _configure_repo_checks, _create_general_config, _create_launcher_secret, + _create_repositories_config, _get_template_path, _read_secrets_env, _write_secrets_env, @@ -352,3 +354,124 @@ def test_fails_on_build_failure(self, tmp_path): ): result = setup() assert result is False + + +class TestConfigureRepoChecks: + """Tests for _configure_repo_checks.""" + + def test_skips_when_gate_declined(self): + """Returns empty dict when user declines the top-level gate prompt.""" + with patch("builtins.input", return_value="no"): + result = _configure_repo_checks(["user/repo1", "user/repo2"]) + assert result == {} + + def test_skips_when_user_declines_per_repo(self): + """Returns empty dict when user passes gate but declines per repo.""" + # "yes" for the gate, "no" for each repo + inputs = iter(["yes", "no", "no"]) + with patch("builtins.input", side_effect=inputs): + result = _configure_repo_checks(["user/repo1", "user/repo2"]) + assert result == {} + + def test_configures_checks_for_single_repo(self): + """Stores checks when user configures a repo.""" + # "yes" for gate, "yes" for repo1, checks, "" to finish, "no" for repo2 + inputs = iter(["yes", "yes", "lint", "make lint", "test", "make test", "", "no"]) + with patch("builtins.input", side_effect=inputs): + result = _configure_repo_checks(["user/repo1", "user/repo2"]) + assert "user/repo1" in result + assert result["user/repo1"]["checks"] == [ + {"name": "lint", "command": "make lint"}, + {"name": "test", "command": "make test"}, + ] + assert "user/repo2" not in result + + def test_configures_checks_for_multiple_repos(self): + """Stores checks for multiple repos.""" + inputs = iter( + [ + "yes", # gate + "yes", # repo1 + "lint", + "npm run lint", + "", # repo1 done + "yes", # repo2 + "test", + "pytest", + "", # repo2 done + ] + ) + with patch("builtins.input", side_effect=inputs): + result = _configure_repo_checks(["user/repo1", "user/repo2"]) + assert len(result) == 2 + assert result["user/repo1"]["checks"] == [ + {"name": "lint", "command": "npm run lint"}, + ] + assert result["user/repo2"]["checks"] == [ + {"name": "test", "command": "pytest"}, + ] + + def test_skips_check_with_empty_command(self): + """Skips a check entry when no command is provided.""" + inputs = iter(["yes", "yes", "lint", "", "test", "make test", ""]) + with patch("builtins.input", side_effect=inputs): + result = _configure_repo_checks(["user/repo1"]) + checks = result["user/repo1"]["checks"] + assert len(checks) == 1 + assert checks[0]["name"] == "test" + + def test_no_entry_when_all_checks_skipped(self): + """No repo_settings entry when user starts but adds no valid checks.""" + inputs = iter(["yes", "yes", "lint", "", ""]) + with patch("builtins.input", side_effect=inputs): + result = _configure_repo_checks(["user/repo1"]) + assert result == {} + + def test_empty_repo_list(self): + """Returns empty dict for empty writable repos list.""" + result = _configure_repo_checks([]) + assert result == {} + + +class TestCreateRepositoriesConfigWithChecks: + """Integration test for _create_repositories_config with check commands.""" + + def test_generated_yaml_contains_repo_settings_with_checks(self, tmp_path): + """Verifies that the generated repositories.yaml includes repo_settings + with configured check commands.""" + import yaml + + inputs = iter( + [ + "testuser", # GitHub username + "/dev/null", # local repo path (will fail validation, that's fine) + "", # end local repos + "testuser/my-app", # writable repo + "", # end writable repos + "mybot", # bot name + "egg", # branch prefix + "yes", # gate: configure SDLC check commands? + "yes", # configure checks for testuser/my-app? + "lint", # check name + "make lint", # check command + "test", # check name + "make test", # check command + "", # done adding checks + ] + ) + with patch("egg_lib.setup_flow.Config") as mock_config: + mock_config.USER_CONFIG_DIR = tmp_path + with patch("builtins.input", side_effect=inputs): + result = _create_repositories_config() + + assert result is True + config_file = tmp_path / "repositories.yaml" + assert config_file.exists() + + config = yaml.safe_load(config_file.read_text()) + assert "repo_settings" in config + assert "testuser/my-app" in config["repo_settings"] + checks = config["repo_settings"]["testuser/my-app"]["checks"] + assert len(checks) == 2 + assert checks[0] == {"name": "lint", "command": "make lint"} + assert checks[1] == {"name": "test", "command": "make test"}