Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions config/repo_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
15 changes: 15 additions & 0 deletions config/repositories.yaml.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions integration_tests/local_pipeline/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand Down
200 changes: 149 additions & 51 deletions orchestrator/routes/pipelines.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")


Expand Down Expand Up @@ -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.

Expand All @@ -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:
Expand Down Expand Up @@ -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(
Expand All @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
Loading