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/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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ commitarena = commitarena.cli:main
@@ -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.