commitarena 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- commitarena/__init__.py +40 -0
- commitarena/__main__.py +3 -0
- commitarena/agent.py +205 -0
- commitarena/cli.py +313 -0
- commitarena/demo/LICENSE-sqlparse +25 -0
- commitarena/demo/results.jsonl +33 -0
- commitarena/demo/run.json +36 -0
- commitarena/demo/tasks.json +5603 -0
- commitarena/errors.py +40 -0
- commitarena/llm.py +206 -0
- commitarena/mine.py +248 -0
- commitarena/report.py +132 -0
- commitarena/report_template.html +394 -0
- commitarena/runner.py +298 -0
- commitarena/sandbox.py +180 -0
- commitarena/tasks.py +229 -0
- commitarena/testrun.py +77 -0
- commitarena-0.1.1.dist-info/METADATA +242 -0
- commitarena-0.1.1.dist-info/RECORD +22 -0
- commitarena-0.1.1.dist-info/WHEEL +4 -0
- commitarena-0.1.1.dist-info/entry_points.txt +2 -0
- commitarena-0.1.1.dist-info/licenses/LICENSE +21 -0
commitarena/tasks.py
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""Task model, task files and workspace construction from a git repository."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import io
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import subprocess
|
|
10
|
+
import tarfile
|
|
11
|
+
from dataclasses import asdict, dataclass, field
|
|
12
|
+
from pathlib import Path, PurePosixPath
|
|
13
|
+
|
|
14
|
+
from .errors import TaskError
|
|
15
|
+
from .sandbox import EnvSpec
|
|
16
|
+
|
|
17
|
+
TASKS_FORMAT = 1
|
|
18
|
+
|
|
19
|
+
_TEST_DIRS = {"tests", "test", "testing"}
|
|
20
|
+
_DOC_RE = re.compile(
|
|
21
|
+
r"(^|/)(docs?/|examples?/|benchmarks?/|CHANGE(S|LOG)|HISTORY|AUTHORS|NEWS)|\.(md|rst|txt)$", re.I
|
|
22
|
+
)
|
|
23
|
+
IGNORED_PARTS = {".git", "__pycache__", ".pytest_cache", ".mypy_cache", ".ruff_cache"}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def is_test_path(path: str) -> bool:
|
|
27
|
+
"""Files the agent may not change: anything under a tests dir, test_*.py, *_test.py, conftest.py."""
|
|
28
|
+
p = PurePosixPath(path)
|
|
29
|
+
if any(part in _TEST_DIRS for part in p.parts[:-1]):
|
|
30
|
+
return True
|
|
31
|
+
n = p.name
|
|
32
|
+
return n == "conftest.py" or (n.endswith(".py") and (n.startswith("test_") or n.endswith("_test.py")))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def is_doc_path(path: str) -> bool:
|
|
36
|
+
return bool(_DOC_RE.search(path))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def is_ignored(rel: str) -> bool:
|
|
40
|
+
parts = PurePosixPath(rel).parts
|
|
41
|
+
return any(p in IGNORED_PARTS or p.endswith(".egg-info") for p in parts) or rel.endswith(".pyc")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class Task:
|
|
46
|
+
id: str
|
|
47
|
+
repo: str # clone URL or local path
|
|
48
|
+
commit: str
|
|
49
|
+
parent: str
|
|
50
|
+
date: str # committer date, ISO 8601
|
|
51
|
+
prompt: str
|
|
52
|
+
fail_to_pass: list[str]
|
|
53
|
+
pass_to_pass: list[str]
|
|
54
|
+
test_files: dict[str, str | None] # test-side files at the fix commit (None = deleted)
|
|
55
|
+
gold_patch: str # human fix (non-test changes), unified diff
|
|
56
|
+
image: str
|
|
57
|
+
env: dict = field(default_factory=dict) # EnvSpec fields
|
|
58
|
+
source_files: list[str] = field(default_factory=list)
|
|
59
|
+
title_hint: str = "" # short neutral label (never the commit message)
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def env_spec(self) -> EnvSpec:
|
|
63
|
+
return EnvSpec(**self.env) if self.env else EnvSpec()
|
|
64
|
+
|
|
65
|
+
def to_dict(self) -> dict:
|
|
66
|
+
return asdict(self)
|
|
67
|
+
|
|
68
|
+
@classmethod
|
|
69
|
+
def from_dict(cls, d: dict) -> Task:
|
|
70
|
+
known = {f for f in cls.__dataclass_fields__}
|
|
71
|
+
return cls(**{k: v for k, v in d.items() if k in known})
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def save_tasks(path: Path, tasks: list[Task], meta: dict | None = None) -> None:
|
|
75
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
76
|
+
data = {"format": TASKS_FORMAT, "meta": meta or {}, "tasks": [t.to_dict() for t in tasks]}
|
|
77
|
+
path.write_text(json.dumps(data, indent=1, ensure_ascii=False))
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def load_tasks(path: str | os.PathLike, ids: list[str] | None = None) -> list[Task]:
|
|
81
|
+
"""Load mined tasks from a tasks.json written by ``commitarena mine``."""
|
|
82
|
+
p = Path(path)
|
|
83
|
+
if p.is_dir():
|
|
84
|
+
p = p / "tasks.json"
|
|
85
|
+
if not p.exists():
|
|
86
|
+
raise TaskError(f"Task file not found: {p}. Create one with: commitarena mine <repo>")
|
|
87
|
+
data = json.loads(p.read_text())
|
|
88
|
+
if data.get("format") != TASKS_FORMAT:
|
|
89
|
+
raise TaskError(f"{p} has unsupported format {data.get('format')!r} (expected {TASKS_FORMAT}).")
|
|
90
|
+
tasks = [Task.from_dict(t) for t in data["tasks"]]
|
|
91
|
+
if ids:
|
|
92
|
+
wanted = set(ids)
|
|
93
|
+
tasks = [t for t in tasks if t.id in wanted or any(t.id.endswith(i) for i in wanted)]
|
|
94
|
+
if not tasks:
|
|
95
|
+
raise TaskError(f"None of the task ids {sorted(wanted)} are in {p}.")
|
|
96
|
+
return tasks
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def load_tasks_meta(path: str | os.PathLike) -> dict:
|
|
100
|
+
p = Path(path)
|
|
101
|
+
if p.is_dir():
|
|
102
|
+
p = p / "tasks.json"
|
|
103
|
+
return json.loads(p.read_text()).get("meta", {})
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# ---------------------------------------------------------------- git helpers
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def git(repo: Path, *args: str, text: bool = True) -> str | bytes:
|
|
110
|
+
cp = subprocess.run(["git", "-C", str(repo), *args], capture_output=True)
|
|
111
|
+
if cp.returncode != 0:
|
|
112
|
+
raise TaskError(f"git {' '.join(args)} failed: {cp.stderr.decode(errors='replace').strip()}")
|
|
113
|
+
return cp.stdout.decode(errors="replace") if text else cp.stdout
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def extract_commit(repo: Path, rev: str, dest: Path) -> None:
|
|
117
|
+
data = git(repo, "archive", "--format=tar", rev, text=False)
|
|
118
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
119
|
+
with tarfile.open(fileobj=io.BytesIO(data)) as tar:
|
|
120
|
+
tar.extractall(dest, filter="data")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def cache_dir() -> Path:
|
|
124
|
+
base = os.environ.get("COMMITARENA_CACHE") or os.path.join(
|
|
125
|
+
os.environ.get("XDG_CACHE_HOME", os.path.expanduser("~/.cache")), "commitarena"
|
|
126
|
+
)
|
|
127
|
+
return Path(base)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def repo_slug(repo: str) -> str:
|
|
131
|
+
s = repo.rstrip("/").removesuffix(".git")
|
|
132
|
+
s = s.split(":")[-1] if s.startswith("git@") else s
|
|
133
|
+
parts = [p for p in re.split(r"[/\\]", s) if p][-2:]
|
|
134
|
+
return re.sub(r"[^a-z0-9]+", "-", "-".join(parts).lower()).strip("-") or "repo"
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def resolve_repo(repo: str) -> Path:
|
|
138
|
+
"""Local path as-is; URLs are cloned (once) into the cache."""
|
|
139
|
+
local = Path(repo).expanduser()
|
|
140
|
+
if local.exists():
|
|
141
|
+
if not (local / ".git").exists() and not (local / "HEAD").exists():
|
|
142
|
+
raise TaskError(f"{local} is not a git repository.")
|
|
143
|
+
return local.resolve()
|
|
144
|
+
dest = cache_dir() / "repos" / repo_slug(repo)
|
|
145
|
+
if not (dest / ".git").exists():
|
|
146
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
147
|
+
cp = subprocess.run(["git", "clone", "-q", repo, str(dest)], capture_output=True)
|
|
148
|
+
if cp.returncode != 0:
|
|
149
|
+
raise TaskError(f"git clone {repo} failed: {cp.stderr.decode(errors='replace').strip()}")
|
|
150
|
+
return dest
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def write_files(root: Path, files: dict[str, str | None]) -> None:
|
|
154
|
+
for rel, content in files.items():
|
|
155
|
+
target = root / rel
|
|
156
|
+
if content is None:
|
|
157
|
+
if target.exists():
|
|
158
|
+
target.unlink()
|
|
159
|
+
continue
|
|
160
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
161
|
+
target.write_text(content)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def init_workspace_git(root: Path) -> None:
|
|
165
|
+
"""Fresh one-commit history so the agent can use git diff without seeing the real history."""
|
|
166
|
+
env = {**os.environ, "GIT_CONFIG_GLOBAL": os.devnull, "GIT_CONFIG_SYSTEM": os.devnull}
|
|
167
|
+
for args in (
|
|
168
|
+
["init", "-q", "-b", "main"],
|
|
169
|
+
["add", "-A"],
|
|
170
|
+
["-c", "user.name=commitarena", "-c", "user.email=commitarena@localhost",
|
|
171
|
+
"commit", "-q", "--no-gpg-sign", "-m", "task start"],
|
|
172
|
+
): # fmt: skip
|
|
173
|
+
subprocess.run(["git", "-C", str(root), *args], capture_output=True, check=True, env=env)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def build_workspace(task: Task, dest: Path, with_git: bool = True) -> Path:
|
|
177
|
+
"""Repo at the parent commit + the fix commit's test files. This is what the agent sees."""
|
|
178
|
+
repo = resolve_repo(task.repo)
|
|
179
|
+
extract_commit(repo, task.parent, dest)
|
|
180
|
+
write_files(dest, task.test_files)
|
|
181
|
+
if with_git:
|
|
182
|
+
init_workspace_git(dest)
|
|
183
|
+
return dest
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def tree_files(root: Path) -> dict[str, Path]:
|
|
187
|
+
out = {}
|
|
188
|
+
for p in root.rglob("*"):
|
|
189
|
+
if p.is_file() and not p.is_symlink():
|
|
190
|
+
rel = p.relative_to(root).as_posix()
|
|
191
|
+
if not is_ignored(rel):
|
|
192
|
+
out[rel] = p
|
|
193
|
+
return out
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def changed_files(before: Path, after: Path) -> dict[str, bytes | None]:
|
|
197
|
+
"""Files that differ between two trees: rel path -> new bytes (None = deleted)."""
|
|
198
|
+
a, b = tree_files(before), tree_files(after)
|
|
199
|
+
out: dict[str, bytes | None] = {}
|
|
200
|
+
for rel in sorted(set(a) | set(b)):
|
|
201
|
+
if rel not in b:
|
|
202
|
+
out[rel] = None
|
|
203
|
+
elif rel not in a or a[rel].read_bytes() != b[rel].read_bytes():
|
|
204
|
+
out[rel] = b[rel].read_bytes()
|
|
205
|
+
return out
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def unified_diff(before: Path, after: Path, only: list[str] | None = None) -> str:
|
|
209
|
+
"""git diff --no-index between two trees, restricted to `only` paths if given."""
|
|
210
|
+
import difflib
|
|
211
|
+
|
|
212
|
+
a, b = tree_files(before), tree_files(after)
|
|
213
|
+
paths = sorted(set(a) | set(b)) if only is None else sorted(only)
|
|
214
|
+
chunks: list[str] = []
|
|
215
|
+
for rel in paths:
|
|
216
|
+
try:
|
|
217
|
+
old = a[rel].read_text().splitlines(keepends=True) if rel in a else []
|
|
218
|
+
new = b[rel].read_text().splitlines(keepends=True) if rel in b else []
|
|
219
|
+
except UnicodeDecodeError:
|
|
220
|
+
chunks.append(f"Binary file {rel} differs\n")
|
|
221
|
+
continue
|
|
222
|
+
if old == new:
|
|
223
|
+
continue
|
|
224
|
+
diff = difflib.unified_diff(
|
|
225
|
+
old, new, f"a/{rel}" if rel in a else "/dev/null", f"b/{rel}" if rel in b else "/dev/null", n=3
|
|
226
|
+
)
|
|
227
|
+
text = "".join(line if line.endswith("\n") else line + "\n" for line in diff)
|
|
228
|
+
chunks.append(f"diff --git a/{rel} b/{rel}\n{text}")
|
|
229
|
+
return "".join(chunks)
|
commitarena/testrun.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Run pytest inside a sandbox and parse JUnit XML into per-test outcomes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import shlex
|
|
6
|
+
import xml.etree.ElementTree as ET
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
|
|
9
|
+
from .sandbox import Sandbox
|
|
10
|
+
|
|
11
|
+
JUNIT_PATH = "/tmp/commitarena-junit.xml"
|
|
12
|
+
|
|
13
|
+
PASSED, FAILED, SKIPPED = "passed", "failed", "skipped"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class TestOutcomes:
|
|
18
|
+
status: dict[str, str] = field(default_factory=dict) # test id -> passed/failed/skipped
|
|
19
|
+
messages: dict[str, str] = field(default_factory=dict) # failure text (truncated)
|
|
20
|
+
output: str = ""
|
|
21
|
+
ok: bool = True # False if pytest crashed or produced no XML
|
|
22
|
+
|
|
23
|
+
def passed(self) -> set[str]:
|
|
24
|
+
return {t for t, s in self.status.items() if s == PASSED}
|
|
25
|
+
|
|
26
|
+
def failed(self) -> set[str]:
|
|
27
|
+
return {t for t, s in self.status.items() if s == FAILED}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def pytest_command(paths: list[str] | None = None, extra: str = "") -> str:
|
|
31
|
+
target = " ".join(shlex.quote(p) for p in paths) if paths else ""
|
|
32
|
+
return (
|
|
33
|
+
f"rm -f {JUNIT_PATH}; python -m pytest -p no:cacheprovider -o addopts='' -q "
|
|
34
|
+
f"--continue-on-collection-errors --junitxml={JUNIT_PATH} {extra} {target}"
|
|
35
|
+
).strip()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def parse_junit(xml_text: str, max_msg: int = 1500) -> dict[str, tuple[str, str]]:
|
|
39
|
+
"""Return {test_id: (status, message)}. Test id is ``classname::name``."""
|
|
40
|
+
out: dict[str, tuple[str, str]] = {}
|
|
41
|
+
root = ET.fromstring(xml_text)
|
|
42
|
+
for case in root.iter("testcase"):
|
|
43
|
+
cls = case.get("classname", "")
|
|
44
|
+
name = case.get("name", "")
|
|
45
|
+
tid = f"{cls}::{name}" if cls else name
|
|
46
|
+
status, msg = PASSED, ""
|
|
47
|
+
for child in case:
|
|
48
|
+
if child.tag in ("failure", "error"):
|
|
49
|
+
status = FAILED
|
|
50
|
+
msg = ((child.get("message") or "") + "\n" + (child.text or "")).strip()[:max_msg]
|
|
51
|
+
break
|
|
52
|
+
if child.tag == "skipped":
|
|
53
|
+
status = SKIPPED
|
|
54
|
+
# an error in teardown after a pass also counts as failed (handled by break above)
|
|
55
|
+
prev = out.get(tid)
|
|
56
|
+
if prev is None or status == FAILED:
|
|
57
|
+
out[tid] = (status, msg)
|
|
58
|
+
return out
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def run_tests(sb: Sandbox, timeout: int = 600, paths: list[str] | None = None) -> TestOutcomes:
|
|
62
|
+
res = sb.exec(pytest_command(paths), timeout=timeout)
|
|
63
|
+
xml = sb.exec(f"cat {JUNIT_PATH}", timeout=60)
|
|
64
|
+
to = TestOutcomes(output=res.output[-6000:])
|
|
65
|
+
if xml.exit_code != 0 or "<testsuite" not in xml.output:
|
|
66
|
+
to.ok = False
|
|
67
|
+
return to
|
|
68
|
+
try:
|
|
69
|
+
parsed = parse_junit(xml.output[xml.output.index("<") :])
|
|
70
|
+
except ET.ParseError:
|
|
71
|
+
to.ok = False
|
|
72
|
+
return to
|
|
73
|
+
for tid, (status, msg) in parsed.items():
|
|
74
|
+
to.status[tid] = status
|
|
75
|
+
if msg:
|
|
76
|
+
to.messages[tid] = msg
|
|
77
|
+
return to
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: commitarena
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Your git history is the benchmark. Replay real bug fixes against any model, in a sandbox with no network.
|
|
5
|
+
Project-URL: Homepage, https://diegoperezg7.github.io/commitarena/
|
|
6
|
+
Project-URL: Source, https://github.com/diegoperezg7/commitarena
|
|
7
|
+
Project-URL: Issues, https://github.com/diegoperezg7/commitarena/issues
|
|
8
|
+
Author: Diego Pérez
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: agents,benchmark,docker,evaluation,git,llm,swe-bench
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Software Development :: Testing
|
|
20
|
+
Requires-Python: >=3.11
|
|
21
|
+
Requires-Dist: openai==3.42.1
|
|
22
|
+
Requires-Dist: rich==15.0.0
|
|
23
|
+
Requires-Dist: typer==0.27.3
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
<p align="center"><img src="docs/assets/banner.svg" alt="commitarena" width="640"></p>
|
|
27
|
+
|
|
28
|
+
<p align="center">
|
|
29
|
+
<a href="https://github.com/diegoperezg7/commitarena/actions/workflows/ci.yml"><img alt="CI" src="https://github.com/diegoperezg7/commitarena/actions/workflows/ci.yml/badge.svg"></a>
|
|
30
|
+
<a href="LICENSE"><img alt="License: MIT" src="https://img.shields.io/badge/license-MIT-3b5bd4"></a>
|
|
31
|
+
<a href="https://github.com/diegoperezg7/commitarena/releases"><img alt="Release" src="https://img.shields.io/github/v/release/diegoperezg7/commitarena?color=3b5bd4"></a>
|
|
32
|
+
<a href="https://github.com/diegoperezg7/commitarena/stargazers"><img alt="Stars" src="https://img.shields.io/github/stars/diegoperezg7/commitarena?color=3b5bd4"></a>
|
|
33
|
+
</p>
|
|
34
|
+
|
|
35
|
+
<p align="center"><b>Your git history is the benchmark. Replay real bug fixes against any model, in a sandbox with no network.</b></p>
|
|
36
|
+
|
|
37
|
+
<p align="center"><a href="README.es.md">Español</a> · <a href="https://diegoperezg7.github.io/commitarena/">Example report</a></p>
|
|
38
|
+
|
|
39
|
+
<p align="center"><img src="docs/assets/demo.gif" alt="commitarena demo in a terminal" width="760"></p>
|
|
40
|
+
|
|
41
|
+
commitarena mines commits from your repository where a bug fix came with a test, checks in Docker that the
|
|
42
|
+
test fails without the fix and passes with it, and then asks each model to make that test pass. Every
|
|
43
|
+
model gets the same small agent loop with one `bash` tool, and every command it runs executes in a
|
|
44
|
+
container started with `--network none`. The report splits the pass rate into fixes committed before and
|
|
45
|
+
after each model's training cutoff, so you can see whether a model solves the bug or remembers the patch.
|
|
46
|
+
|
|
47
|
+
## Why
|
|
48
|
+
|
|
49
|
+
There are good tools close to this. What I wanted and did not find in one place:
|
|
50
|
+
|
|
51
|
+
| | What it does | Difference |
|
|
52
|
+
|---|---|---|
|
|
53
|
+
| [sjarmak/codeprobe](https://github.com/sjarmak/codeprobe) (10 stars) | Mines merged PRs, runs agent CLIs, ranks with cost and an HTML report. | Its README says each agent runs "inside a `--network=bridge` container (the agent needs the model API)". In commitarena the model is called from the host and the code the model writes never has a network. |
|
|
54
|
+
| [PozziTiv4ik/Repo-Trials](https://github.com/PozziTiv4ik/Repo-Trials) (0 stars) | FAIL_TO_PASS tasks from your git history. | commitarena adds the same harness for every model, local ones included, and the before/after-cutoff split. |
|
|
55
|
+
| [harbor-framework/harbor](https://github.com/harbor-framework/harbor) (6,755 stars) | A general framework for agent evaluations. | commitarena is one command for one question: which model fixes the bugs of this repo. |
|
|
56
|
+
|
|
57
|
+
Star counts from the GitHub API on 2026-11-03.
|
|
58
|
+
|
|
59
|
+
Three things commitarena does on purpose:
|
|
60
|
+
|
|
61
|
+
1. **No network for model-written code.** The agent loop runs on your machine and only sends chat
|
|
62
|
+
requests. Each `bash` call goes through `docker exec` into a container with `--network none`,
|
|
63
|
+
`--cap-drop ALL`, `no-new-privileges`, 2 CPU, 2 GB, a PID limit and a per-command timeout.
|
|
64
|
+
2. **One harness for every model.** Same system prompt, same single tool, same step and time limits,
|
|
65
|
+
whether it is a hosted model or a 4B model on Ollama. No vendor agent, no extra tools.
|
|
66
|
+
3. **Solve or remember.** Public repos are in training data. The report shows pass rate on fixes dated
|
|
67
|
+
before each model's cutoff next to fixes dated after it. Cutoffs are configurable; by default the
|
|
68
|
+
model's release date is used, which is a safe upper bound.
|
|
69
|
+
|
|
70
|
+
## Quickstart
|
|
71
|
+
|
|
72
|
+
The demo needs no API keys and no Docker. It prints a recorded run and writes the HTML report.
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
uvx --from git+https://github.com/diegoperezg7/commitarena@v0.1.1 commitarena demo
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Or from a clone:
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
git clone https://github.com/diegoperezg7/commitarena && cd commitarena && uv run commitarena demo
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
`uvx commitarena` is coming to PyPI.
|
|
85
|
+
|
|
86
|
+
## Example output
|
|
87
|
+
|
|
88
|
+
<!-- demo:start -->
|
|
89
|
+
```text
|
|
90
|
+
$ commitarena demo
|
|
91
|
+
Recorded result (not a live run): 2026-11-03, commitarena 0.1.0, models mistral/codestral-latest,
|
|
92
|
+
mistral/ministral-8b-latest, mistral/mistral-large-4, on Darwin arm64 (Apple M4, 16 GB RAM).
|
|
93
|
+
Repo: https://github.com/andialbrecht/sqlparse (BSD-3-Clause)
|
|
94
|
+
Leaderboard
|
|
95
|
+
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━┳━━━━━━┳━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━━┳━━━━━━━┳━━━━━━━━━━━┓
|
|
96
|
+
┃ model ┃ pass ┃ rate ┃ before cutoff ┃ after cutoff ┃ cost $ ┃ tokens ┃ avg s ┃ avg steps ┃
|
|
97
|
+
┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━╇━━━━━━╇━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━━━╇━━━━━━━╇━━━━━━━━━━━┩
|
|
98
|
+
│ mistral/mistral-large-4 │ 11/11 │ 100% │ 8/8 │ 3/3 │ 0.5401 │ 672,848 │ 118 │ 11.1 │
|
|
99
|
+
│ mistral/ministral-8b-latest │ 1/11 │ 9% │ 0/5 │ 1/6 │ 0.2445 │ 1,630,017 │ 68 │ 28.3 │
|
|
100
|
+
│ mistral/codestral-latest │ 0/11 │ 0% │ 0/4 │ 0/7 │ 0.6507 │ 2,038,536 │ 75 │ 27.8 │
|
|
101
|
+
└─────────────────────────────┴───────┴──────┴───────────────┴──────────────┴────────┴───────────┴───────┴───────────┘
|
|
102
|
+
```
|
|
103
|
+
<!-- demo:end -->
|
|
104
|
+
|
|
105
|
+
<p align="center"><a href="https://diegoperezg7.github.io/commitarena/"><img src="docs/assets/report-light.png" alt="report" width="760"></a></p>
|
|
106
|
+
|
|
107
|
+
How to read it: with the same prompt, tool, limits and sandbox, Mistral Large 4 fixed all 11 bugs,
|
|
108
|
+
including the 3 committed after its release (October 2026), so it was solving, not recalling. Ministral 8B
|
|
109
|
+
fixed one, and that one is dated after its own cutoff. Codestral fixed none. Three tasks after a cutoff is
|
|
110
|
+
a small sample: treat the split as a signal, not a verdict.
|
|
111
|
+
|
|
112
|
+
**Why Codestral scores 0/11.** It is the model, not the scoring: it repeated an earlier command verbatim
|
|
113
|
+
in 170 of its 304 tool calls, kept doing so after 163 "you already ran this exact command" notices from
|
|
114
|
+
the harness, ran out of its 30 steps in 9 of 11 attempts, left the code untouched in 6, and never edited a
|
|
115
|
+
test file. This is the start of the `PRIMARY KEY` task, whose human fix is one regex line in
|
|
116
|
+
`sqlparse/keywords.py` (full trajectories are in [docs/demo-run/trajectories](docs/demo-run/trajectories)):
|
|
117
|
+
|
|
118
|
+
```text
|
|
119
|
+
12 grep -n 'PRIMARY' sqlparse/keywords.py
|
|
120
|
+
13 sed -n '440,450p' sqlparse/keywords.py
|
|
121
|
+
14 sed -n '440,450p' sqlparse/keywords.py <- repeat notice
|
|
122
|
+
15 grep -n 'KEY' sqlparse/keywords.py
|
|
123
|
+
...
|
|
124
|
+
23 grep -n 'PRIMARY' sqlparse/keywords.py <- repeat notice
|
|
125
|
+
24 sed -n '440,450p' sqlparse/keywords.py <- repeat notice
|
|
126
|
+
25 grep -n 'KEY' sqlparse/keywords.py <- repeat notice
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## Use it on your repo
|
|
130
|
+
|
|
131
|
+
You need Docker running, plus Ollama or an API key.
|
|
132
|
+
|
|
133
|
+
```bash
|
|
134
|
+
# 1. Mine tasks: commits that change source and tests, validated in Docker
|
|
135
|
+
commitarena mine https://github.com/you/yourrepo --max-tasks 10
|
|
136
|
+
|
|
137
|
+
# 2. Run models (a local one and a hosted one)
|
|
138
|
+
export MISTRAL_API_KEY=...
|
|
139
|
+
commitarena run --tasks tasks/you-yourrepo/tasks.json \
|
|
140
|
+
--models ollama/qwen3:4b-16k,mistral/codestral-latest --max-cost 1
|
|
141
|
+
|
|
142
|
+
# 3. The report is written to runs/<timestamp>/report.html; re-render it with other cutoffs
|
|
143
|
+
commitarena report runs/<timestamp> --cutoff mistral/codestral-latest=2025-07-31 --open
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
## How it works
|
|
147
|
+
|
|
148
|
+
```mermaid
|
|
149
|
+
flowchart LR
|
|
150
|
+
subgraph host[Your machine]
|
|
151
|
+
G[git history] --> M[mine: commits touching src + tests]
|
|
152
|
+
M --> V{Docker, no network:<br/>new tests fail before,<br/>pass after?}
|
|
153
|
+
V -->|yes| T[tasks.json<br/>failing tests + test diff<br/>no commit message]
|
|
154
|
+
T --> L[agent loop<br/>one bash tool]
|
|
155
|
+
L <-->|chat API| P[(model provider<br/>or Ollama)]
|
|
156
|
+
end
|
|
157
|
+
L -->|docker exec| S[sandbox<br/>--network none<br/>2 CPU, 2 GB]
|
|
158
|
+
S --> R[copy out the workspace]
|
|
159
|
+
R --> X[keep non-test changes only,<br/>restore original tests]
|
|
160
|
+
X --> Y[fresh no-network container:<br/>full test suite]
|
|
161
|
+
Y --> Z[report.html<br/>leaderboard, matrix,<br/>cutoff split, diffs]
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
- **Mining.** Non-merge commits that change at least one test file and at least one `.py` source file
|
|
165
|
+
(small fixes only by default). For each one the test suite runs twice in a no-network container: the
|
|
166
|
+
parent commit plus the new test files, and the fix commit. A task is kept when some tests go from
|
|
167
|
+
failing to passing (FAIL_TO_PASS) and nothing that passed before breaks (PASS_TO_PASS). The test image
|
|
168
|
+
is built once per repo with your install command, which is the only step with network.
|
|
169
|
+
- **The prompt** lists the failing tests, the test diff and the current failure output. The commit
|
|
170
|
+
message, the issue and the human fix are never shown. The workspace gets a fresh one-commit git history
|
|
171
|
+
so the real history cannot leak the fix.
|
|
172
|
+
- **Scoring.** After the attempt the workspace is copied out. Only changes to non-test files are applied
|
|
173
|
+
onto a pristine copy, so edits to tests or `conftest.py` are discarded, and the full suite runs in a
|
|
174
|
+
fresh container. Pass means every FAIL_TO_PASS test passes and no PASS_TO_PASS test fails.
|
|
175
|
+
- **Metrics.** Pass, cost (provider-reported for OpenRouter, otherwise tokens times list price; local
|
|
176
|
+
models cost 0), input and output tokens, wall time and steps (model calls).
|
|
177
|
+
|
|
178
|
+
## Configuration
|
|
179
|
+
|
|
180
|
+
| Flag | Default | |
|
|
181
|
+
|---|---|---|
|
|
182
|
+
| `run --models` | | Comma-separated `provider/model`. Providers: `openrouter`, `openai`, `mistral`, `ollama`. |
|
|
183
|
+
| `run --max-cost` | `1.0` | USD. Checked before every model call; the run stops with partial results. |
|
|
184
|
+
| `run --max-steps` / `--max-time` | `30` / `1200` | Model calls and seconds per attempt. |
|
|
185
|
+
| `run --cutoff MODEL=DATE` | release date | Training cutoff used for the before/after split (repeatable). |
|
|
186
|
+
| `run --price MODEL=IN,OUT` | built-in | USD per million tokens for models without a known price. |
|
|
187
|
+
| `run --context-file F` | | Inject an AGENTS.md / CLAUDE.md: `--context-mode both\|repo\|prompt`. |
|
|
188
|
+
| `run --strip-context-files` | off | Remove the repo's own AGENTS.md / CLAUDE.md before the attempt. |
|
|
189
|
+
| `run --cpus` / `--memory` / `--command-timeout` | `2` / `2g` / `120` | Sandbox limits. |
|
|
190
|
+
| `mine --since` / `--until` / `--scan` / `--max-tasks` | | Which commits to consider. |
|
|
191
|
+
| `mine --python` / `--setup` | `3.12` / `pip install -e .` | How the test image is built. |
|
|
192
|
+
|
|
193
|
+
Environment variables: `OPENROUTER_API_KEY`, `OPENAI_API_KEY`, `MISTRAL_API_KEY`, `OLLAMA_HOST`,
|
|
194
|
+
`COMMITARENA_CACHE` (clones, default `~/.cache/commitarena`). See [.env.example](.env.example). There is
|
|
195
|
+
no telemetry.
|
|
196
|
+
|
|
197
|
+
**Ollama context window.** Ollama's OpenAI-compatible endpoint ignores `num_ctx`, and its default
|
|
198
|
+
window can be 4096 tokens, which silently drops the task text and gives worse results. commitarena warns
|
|
199
|
+
about it. Use a variant with `num_ctx 16384` (the one used in development is `qwen3:4b-16k`); the agent
|
|
200
|
+
loop elides older tool outputs to stay inside 16K.
|
|
201
|
+
|
|
202
|
+
```bash
|
|
203
|
+
printf 'FROM qwen3:4b\nPARAMETER num_ctx 16384\n' > Modelfile && ollama create qwen3:4b-16k -f Modelfile
|
|
204
|
+
commitarena run --tasks tasks.json --models ollama/qwen3:4b-16k
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
Local models go through exactly the same code path (qwen3:4b-16k was run during development), but it is not
|
|
208
|
+
in the recorded demo. On this machine it spent every 4096-token reply reasoning and never called the
|
|
209
|
+
tool, and its thinking could not be turned off through the OpenAI endpoint (`think: false`,
|
|
210
|
+
`reasoning_effort: "none"` and `/no_think` were all tried). Raising the reply limit for one model would
|
|
211
|
+
break the same-harness rule, so it was left out rather than shown with a misleading 0.
|
|
212
|
+
|
|
213
|
+
## Python API
|
|
214
|
+
|
|
215
|
+
```python
|
|
216
|
+
from commitarena import load_tasks, run_task, Budget, ContextFile
|
|
217
|
+
|
|
218
|
+
task = load_tasks("tasks.json")[0]
|
|
219
|
+
res = run_task(task, "ollama/qwen3:4b-16k", budget=Budget(max_cost=1.0),
|
|
220
|
+
context=ContextFile(open("AGENTS.md").read(), in_repo=True, in_prompt=True))
|
|
221
|
+
print(res.passed, res.cost_usd, res.tokens_in, res.steps, res.wall_time_s)
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
## Demo data
|
|
225
|
+
|
|
226
|
+
The recorded demo uses 11 tasks mined from [sqlparse](https://github.com/andialbrecht/sqlparse)
|
|
227
|
+
(BSD-3-Clause, Copyright (c) 2016, Andi Albrecht). The bundled files contain the failing tests, the test
|
|
228
|
+
diffs and the upstream fixes as diffs, each linked to its commit. The upstream license is bundled verbatim in
|
|
229
|
+
[src/commitarena/demo/LICENSE-sqlparse](src/commitarena/demo/LICENSE-sqlparse).
|
|
230
|
+
|
|
231
|
+
## Roadmap
|
|
232
|
+
|
|
233
|
+
- Other languages and test runners
|
|
234
|
+
- Claude Code and Codex as contestants
|
|
235
|
+
- GitHub issue text as an optional prompt
|
|
236
|
+
- pass@k and repeated attempts
|
|
237
|
+
- Parallel runs
|
|
238
|
+
- Export to Harbor
|
|
239
|
+
|
|
240
|
+
## License
|
|
241
|
+
|
|
242
|
+
MIT. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
commitarena/__init__.py,sha256=ugkyzT5UoXywcg8nmA7kEyXvu_tZjxquz8nhCnt9b70,994
|
|
2
|
+
commitarena/__main__.py,sha256=bYt9eEaoRQWdejEHFD8REx9jxVEdZptECFsV7F49Ink,30
|
|
3
|
+
commitarena/agent.py,sha256=Jea4mQ1H5TGl0tUbKYsr6fmNMhHAbaA-L6NXzYI9-Ms,7993
|
|
4
|
+
commitarena/cli.py,sha256=IOkyFxoE3USefvsKLdgBtaLKyIzB8cqbWEC-XVKjM8c,13235
|
|
5
|
+
commitarena/errors.py,sha256=vp5lOo83HKl60GFcY514ntNljROpSfRCNTkdyEsNDEk,1239
|
|
6
|
+
commitarena/llm.py,sha256=0SMln61wmsvy5J96qpOaHeUB87ZIqrnQLi6IhHynxXc,8071
|
|
7
|
+
commitarena/mine.py,sha256=G-wdQEpo85W1I5ZOlYx1iRr3nOdGenFWXp6Fzszk9lY,9257
|
|
8
|
+
commitarena/report.py,sha256=Z_pQ2wcRvjDFY343KzHpLAMHPug_iQqXUvssC8PYVWc,5459
|
|
9
|
+
commitarena/report_template.html,sha256=3k7RsciKwoKy87KmA14LB3Spmqc-mTQl8sdICt0dv6c,27837
|
|
10
|
+
commitarena/runner.py,sha256=9IBKt3ylkn0ImbpzTU3eEMJ74OXDOobFpiFdDl4_k7A,11323
|
|
11
|
+
commitarena/sandbox.py,sha256=X90jX7KGEAUzy4tBC4V6Qs2h23zE-WP-BJM3BAO-mCg,6778
|
|
12
|
+
commitarena/tasks.py,sha256=rQYHi6zDYsPIPywhU68HhiqtXzwWKhdH4AS5b8_Gzio,8438
|
|
13
|
+
commitarena/testrun.py,sha256=YK7FiAQU6anB_N1IPSMdNGZu-Ah4w9ZvNeCd12PWSg0,2776
|
|
14
|
+
commitarena/demo/LICENSE-sqlparse,sha256=wZOCNbgNOekxOOrontw69n4Y7LxA0mZSn6V7Lc5CYxA,1537
|
|
15
|
+
commitarena/demo/results.jsonl,sha256=dLsfxwd0d6S9r8EDhjxe_1o4ieWGdqCBDiYIvtiJ-Dw,96821
|
|
16
|
+
commitarena/demo/run.json,sha256=51N2M9SSVkqgpcUFWmyBGTk5hAKeVr4f4ItiiGU8Lcc,1645
|
|
17
|
+
commitarena/demo/tasks.json,sha256=mQ0HRjUNogAHZda8umcFXANWp3MkM7O-z4w-nukM5AE,609679
|
|
18
|
+
commitarena-0.1.1.dist-info/METADATA,sha256=0QnCWqHap67CQD0olCFoP4DRNadLNIdE8qb1Bw9C1v4,14085
|
|
19
|
+
commitarena-0.1.1.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
20
|
+
commitarena-0.1.1.dist-info/entry_points.txt,sha256=uZVCdVojRaQjNHgSzdDQ7_6IHTidFxlDHytV1u63Fqo,53
|
|
21
|
+
commitarena-0.1.1.dist-info/licenses/LICENSE,sha256=A7Jh3ub2oGhc2l6P9raz5AeuQKzRIinCPYAXENE2Lig,1069
|
|
22
|
+
commitarena-0.1.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Diego Pérez
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|