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
13 changes: 12 additions & 1 deletion api.py
Original file line number Diff line number Diff line change
@@ -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


Expand Down Expand Up @@ -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)
Expand Down
155 changes: 155 additions & 0 deletions cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -47,6 +48,7 @@
add_commit_push,
check_tool_installed,
clone_repo,
clone_repo_cr,
generate_ssh_key_pair,
reset_to_setup_commit,
)
Expand Down Expand Up @@ -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 <workspace>/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 <file> 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 ────────────────────────────────────────────────────────────────


Expand Down
60 changes: 60 additions & 0 deletions git_ops.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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.

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