From d5316f10cd83bb9549a009d80af55281de48b7cf Mon Sep 17 00:00:00 2001 From: charliespy Date: Wed, 11 Mar 2026 20:16:05 -0700 Subject: [PATCH 1/2] feat: add cr fetch/submit commands for code review challenges Add cr command group with fetch (clone + branch setup) and submit (file, inline, or stdin review text) subcommands. Make API_BASE configurable via AICODINGGYM_API_BASE env var. Co-Authored-By: Claude Opus 4.6 --- api.py | 13 ++++- cli.py | 156 +++++++++++++++++++++++++++++++++++++++++++++++++++++ git_ops.py | 44 +++++++++++++++ 3 files changed, 212 insertions(+), 1 deletion(-) 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..f9f9485 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,160 @@ 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 submit CR_PROBLEM_ID -f review.md + 2. aicodinggym cr submit CR_PROBLEM_ID -m "Found a bug in line 42" + 3. echo "review text" | aicodinggym cr submit CR_PROBLEM_ID + """ + 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 or not base_branch or not head_branch: + _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..98951a2 100644 --- a/git_ops.py +++ b/git_ops.py @@ -83,6 +83,50 @@ 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). + """ + 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}" + run_git_command(f"git branch -f {branch} FETCH_HEAD", str(problem_dir)) + return True, ( + f"Already exists. Updated both branches.\n" + f"Repository: {problem_dir}\n" + f"Branches: {base_branch}, {head_branch}" + ) + + # Clone base branch (shallow) + 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 + run_git_command(f"git branch {head_branch} FETCH_HEAD", str(problem_dir)) + + 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. From 4c767aef64948db3723c39c633845a7ed694d375 Mon Sep 17 00:00:00 2001 From: charliespy Date: Thu, 12 Mar 2026 22:28:05 -0700 Subject: [PATCH 2/2] fix: harden cr commands with input validation and error handling Add defense-in-depth shell metacharacter validation for git refs in clone_repo_cr, check return codes on all git branch operations, validate API responses reject whitespace-only strings, and fix cr group docstring to document the fetch+submit workflow. Co-Authored-By: Claude Opus 4.6 --- cli.py | 7 +++---- git_ops.py | 22 +++++++++++++++++++--- 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/cli.py b/cli.py index f9f9485..23172fe 100644 --- a/cli.py +++ b/cli.py @@ -860,9 +860,8 @@ def cr(): \b WORKFLOW: - 1. aicodinggym cr submit CR_PROBLEM_ID -f review.md - 2. aicodinggym cr submit CR_PROBLEM_ID -m "Found a bug in line 42" - 3. echo "review text" | aicodinggym cr submit CR_PROBLEM_ID + 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 @@ -902,7 +901,7 @@ def cr_fetch(problem_id: str, user_id: str | None, workspace_dir: str | None): head_branch = data.get("head_branch") repo_url = data.get("repo_url") - if not repo_url or not base_branch or not head_branch: + 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 diff --git a/git_ops.py b/git_ops.py index 98951a2..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. @@ -91,6 +98,11 @@ def clone_repo_cr(repo_url: str, base_branch: str, head_branch: str, 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(): @@ -99,14 +111,16 @@ def clone_repo_cr(repo_url: str, base_branch: str, head_branch: str, 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}" - run_git_command(f"git branch -f {branch} FETCH_HEAD", str(problem_dir)) + 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) + # 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: @@ -119,7 +133,9 @@ def clone_repo_cr(repo_url: str, base_branch: str, head_branch: str, return False, f"Failed to fetch head branch '{head_branch}':\n{result.stderr}" # Create local head branch tracking the fetched ref - run_git_command(f"git branch {head_branch} FETCH_HEAD", str(problem_dir)) + 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"