diff --git a/api.py b/api.py index 7ad8a54..3e91442 100644 --- a/api.py +++ b/api.py @@ -1,8 +1,10 @@ """HTTP API client for the AI Coding Gym backend at aicodinggym.com.""" +import os + import requests -API_BASE = "https://aicodinggym.com/api" +API_BASE = os.environ.get("AICODINGGYM_API_BASE", "https://aicodinggym.com/api") TIMEOUT = 30 @@ -84,6 +86,15 @@ def submit_notification(problem_id: str, user_id: str, commit_hash: str, }) +def cr_submit_review(user_id: str, problem_id: str, review: str) -> dict: + """Submit a code review.""" + return _post("code-review-submit", { + "user_id": user_id, + "problem_id": problem_id, + "review": review, + }) + + def mlebench_download_info(user_id: str, competition_id: str, dest_path: str) -> None: """Download dataset for an MLE-bench competition directly to dest_path.""" resp = _get(f"competitions/{competition_id}/download", stream=True) diff --git a/cli.py b/cli.py index c7f93da..23172fe 100644 --- a/cli.py +++ b/cli.py @@ -31,6 +31,7 @@ from .api import ( APIError, configure as api_configure, + cr_submit_review, fetch_problem as api_fetch_problem, mlebench_download_file, mlebench_download_info, @@ -47,6 +48,7 @@ add_commit_push, check_tool_installed, clone_repo, + clone_repo_cr, generate_ssh_key_pair, reset_to_setup_commit, ) @@ -845,6 +847,159 @@ def swe_test(problem_id: str, user_id: str | None, workspace_dir: str | None, sys.exit(proc.returncode) +# ── cr group ────────────────────────────────────────────────────────────────── + + +@main.group() +def cr(): + """Code Review challenges - submit reviews for code diffs. + + \b + PREREQUISITE: + Run 'aicodinggym configure --user-id YOUR_USER_ID' before using these commands. + + \b + WORKFLOW: + 1. aicodinggym cr fetch CR_PROBLEM_ID # Clone repo with base/head branches + 2. aicodinggym cr submit CR_PROBLEM_ID -f review.md # Submit your review + """ + pass + + +@cr.command("fetch") +@click.argument("problem_id") +@click.option("--user-id", default=None, help="Override configured user ID.") +@click.option("--workspace-dir", default=None, type=click.Path(), + help="Directory to clone into. Overrides configured workspace.") +def cr_fetch(problem_id: str, user_id: str | None, workspace_dir: str | None): + """Fetch a Code Review problem repo with base and head branches. + + Clones the Problemset-CodeReview repository and checks out both the + base and head branches so you can diff them locally. + + \b + ARGUMENTS: + PROBLEM_ID The code review problem identifier (e.g., 'cr/sentry-0001'). + + \b + EXAMPLE: + aicodinggym cr fetch cr/sentry-0001 + cd /cr/sentry-0001 + git diff sentry-0001/base..sentry-0001/head + """ + config = load_config() + uid = _resolve_user_id(config, user_id) + workspace = _resolve_workspace(config, workspace_dir) + + try: + click.echo(f"Fetching problem '{problem_id}' from server...") + data = api_fetch_problem(uid, problem_id) + except APIError as e: + _error(str(e)) + + base_branch = data.get("base_branch") + head_branch = data.get("head_branch") + repo_url = data.get("repo_url") + + if not (repo_url and repo_url.strip()) or not (base_branch and base_branch.strip()) or not (head_branch and head_branch.strip()): + _error("Server did not return required fields (repo_url, base_branch, head_branch).") + + # Save credentials for later submit + credentials = load_credentials() + credentials[problem_id] = { + "repo_url": repo_url, + "base_branch": base_branch, + "head_branch": head_branch, + "user_id": uid, + "workspace_dir": str(workspace), + "benchmark": "cr", + } + save_credentials(credentials) + + workspace.mkdir(parents=True, exist_ok=True) + + click.echo(f"Cloning into {workspace / problem_id}...") + success, msg = clone_repo_cr(repo_url, base_branch, head_branch, + problem_id, str(workspace)) + if not success: + _error(msg) + + click.echo( + f"\nSuccessfully fetched: {problem_id}\n" + f"\n" + f" {msg}\n" + f"\n" + f"To see the diff:\n" + f" cd {workspace / problem_id}\n" + f" git diff {base_branch}..{head_branch}\n" + ) + + +@cr.command("submit") +@click.argument("problem_id") +@click.option("--user-id", default=None, help="Override configured user ID.") +@click.option( + "-f", "--file", "review_file", type=click.Path(exists=True), + help="Path to a file containing your review.", +) +@click.option( + "-m", "--message", "review_text", + help="Inline review text.", +) +def cr_submit(problem_id: str, user_id: str | None, review_file: str | None, + review_text: str | None): + """Submit a code review for a Code Review challenge. + + Reads your review from a file (-f), inline text (-m), or piped stdin, + and submits it to the AI Coding Gym server. + + \b + ARGUMENTS: + PROBLEM_ID The code review problem identifier (e.g., 'cr/sentry-0001'). + + \b + EXAMPLE: + aicodinggym cr submit cr/sentry-0001 -f review.md + aicodinggym cr submit cr/sentry-0001 -m "Found a null pointer bug on line 42" + echo "My review" | aicodinggym cr submit cr/sentry-0001 + """ + config = load_config() + uid = _resolve_user_id(config, user_id) + + # Collect review text (priority: -f > -m > stdin) + review = None + if review_file: + review = Path(review_file).read_text() + elif review_text: + review = review_text + elif not sys.stdin.isatty(): + review = sys.stdin.read() + + if not review or not review.strip(): + _error( + "No review text provided.\n\n" + "Provide your review using one of:\n" + " -f Read review from a file\n" + " -m \"text\" Inline review text\n" + " echo ... | ... Pipe from stdin\n\n" + "Example:\n" + f" aicodinggym cr submit {problem_id} -f review.md" + ) + + try: + result = cr_submit_review(uid, problem_id, review.strip()) + except APIError as e: + _error(str(e)) + + click.echo( + f"\nSuccessfully submitted code review for {problem_id}\n" + f"\n" + f" Status: {result.get('status', 'COMPLETED')}\n" + f"\n" + f"View results at: https://aicodinggym.com/challenge/{problem_id}" + ) + + # ── mle group ──────────────────────────────────────────────────────────────── diff --git a/git_ops.py b/git_ops.py index a63d8fd..1d95ae8 100644 --- a/git_ops.py +++ b/git_ops.py @@ -1,6 +1,7 @@ """Git and SSH key operations for AI Coding Gym CLI.""" import os +import re import shutil import subprocess from pathlib import Path @@ -9,6 +10,12 @@ from .config import ensure_config_dir +def _validate_git_ref(name: str, label: str) -> None: + """Raise ValueError if name contains suspicious shell metacharacters.""" + if re.search(r'[;&|`$(){}]', name): + raise ValueError(f"Invalid {label}: {name!r}") + + def generate_ssh_key_pair(user_id: str) -> tuple[Path, str]: """Generate an SSH key pair for the user. @@ -83,6 +90,59 @@ def clone_repo(repo_url: str, branch: str, dest_name: str, return True, f"Cloned to: {problem_dir}\nBranch: {branch}" +def clone_repo_cr(repo_url: str, base_branch: str, head_branch: str, + dest_name: str, workspace: str, + key_path: Optional[Path] = None) -> tuple[bool, str]: + """Clone a code review repo with both base and head branches. + + Clones the base branch first (shallow), then fetches the head branch. + Returns (success, message). + """ + _validate_git_ref(base_branch, "base_branch") + _validate_git_ref(head_branch, "head_branch") + _validate_git_ref(repo_url, "repo_url") + _validate_git_ref(dest_name, "dest_name") + + problem_dir = Path(workspace) / dest_name + + if problem_dir.exists(): + # Already cloned — fetch latest for both branches + for branch in (base_branch, head_branch): + result = run_git_command(f"git fetch origin {branch}", str(problem_dir), key_path) + if result.returncode != 0: + return False, f"Git fetch failed for {branch}:\n{result.stderr}" + result = run_git_command(f"git branch -f {branch} FETCH_HEAD", str(problem_dir)) + if result.returncode != 0: + return False, f"Failed to update branch {branch}:\n{result.stderr}" + return True, ( + f"Already exists. Updated both branches.\n" + f"Repository: {problem_dir}\n" + f"Branches: {base_branch}, {head_branch}" + ) + + # Clone base branch (shallow); depth 50 needed for diffing between branches + cmd = f"git clone --single-branch --branch {base_branch} --depth 50 {repo_url} {dest_name}" + result = run_git_command(cmd, workspace, key_path) + if result.returncode != 0: + return False, f"Git clone failed:\n{result.stderr}" + + # Fetch head branch + fetch_cmd = f"git fetch origin {head_branch}" + result = run_git_command(fetch_cmd, str(problem_dir), key_path) + if result.returncode != 0: + return False, f"Failed to fetch head branch '{head_branch}':\n{result.stderr}" + + # Create local head branch tracking the fetched ref + result = run_git_command(f"git branch -f {head_branch} FETCH_HEAD", str(problem_dir)) + if result.returncode != 0: + return False, f"Failed to create branch {head_branch}:\n{result.stderr}" + + return True, ( + f"Cloned to: {problem_dir}\n" + f"Branches: {base_branch}, {head_branch}" + ) + + def add_commit_push(problem_dir: str, branch: str, key_path: Path, message: str, force: bool = False) -> tuple[bool, str, str]: """Stage, commit, and push changes.