| 1 | #!/usr/bin/env python3
|
| 2 | """Import GitGoodBench dataset exports (CSV or JSONL) into Oak benchmark scenarios.
|
| 3 |
|
| 4 | Dataset: GitGoodBench -- Tobias Lindenbauer, Egor Bogomolov, and Yaroslav
|
| 5 | Zharov, "GitGoodBench: A Novel Benchmark For Evaluating Agentic Performance
|
| 6 | On Git" (REALM 2025, doi:10.18653/v1/2025.realm-1.19). Apache-2.0 licensed,
|
| 7 | distributed as HuggingFace datasets (JetBrains/git_good_bench,
|
| 8 | JetBrains/git_good_bench-lite, JetBrains/git_good_bench-train). The authors'
|
| 9 | released harness is NOT runnable (proprietary code was removed before
|
| 10 | publication), so imported scenarios execute in OUR agent lane instead.
|
| 11 | Clean-room rule: no prompts or tool code are copied from the upstream
|
| 12 | repository; only published dataset rows are consumed.
|
| 13 |
|
| 14 | Export locally with the HuggingFace `datasets` package (docstring guidance
|
| 15 | only -- this importer is stdlib-only, never touches the network, and consumes
|
| 16 | LOCAL files exclusively):
|
| 17 |
|
| 18 | datasets.load_dataset("JetBrains/git_good_bench-lite")["train"].to_csv("lite.csv")
|
| 19 |
|
| 20 | ``--csv`` accepts the resulting ``.csv`` or a ``.jsonl`` export (sniffed by
|
| 21 | file extension).
|
| 22 |
|
| 23 | Real schema columns: id, name (owner/repository), default_branch, license,
|
| 24 | stargazers, created_at, topics (semicolon-delimited), programming_language,
|
| 25 | scenario (embedded structured per-scenario data), sample_type, project_size,
|
| 26 | difficulty. Raw HuggingFace CSV rows currently encode ``scenario`` as a
|
| 27 | Python-literal dict string; JSONL exports may carry JSON strings or already
|
| 28 | structured values. ``sample_type`` takes exactly two values:
|
| 29 |
|
| 30 | merge a historical merge with one or more conflicts
|
| 31 | file_commit_chain two commits bounding a chain where one file changed in
|
| 32 | every intermediate commit
|
| 33 |
|
| 34 | Canonical mapping (used whenever a row carries ``sample_type``):
|
| 35 |
|
| 36 | sample_type "merge" -> scenario_type "merge_conflict_resolution"
|
| 37 | sample_type "file_commit_chain" -> EXPANDED into TWO emitted scenarios:
|
| 38 | <id>--rebase scenario_type "interactive_rebase"
|
| 39 | <id>--commits scenario_type "iterative_commit"
|
| 40 |
|
| 41 | Repo comes from "name", scenario id from "id" (row index fallback);
|
| 42 | difficulty / programming_language / default_branch are carried through onto
|
| 43 | every emitted scenario under "metadata". The "scenario" column is parsed as
|
| 44 | JSON first, then as a Python literal for the raw HuggingFace CSV export, and
|
| 45 | embedded verbatim as "source_scenario"; its exact inner keys are not pinned
|
| 46 | publicly, so the merge commit is extracted liberally (keys containing
|
| 47 | "merge_commit", then "hash", then "sha"). Unparseable scenario data turns
|
| 48 | that sample into a skip entry with reason ``scenario_json_unparseable``.
|
| 49 |
|
| 50 | Legacy fallback (rows WITHOUT a sample_type column -- old synthetic fixtures
|
| 51 | and unknown exports; the canonical path takes precedence):
|
| 52 |
|
| 53 | repo <- repo | repository | repo_name
|
| 54 | scenario_type <- scenario_type | type | task_type
|
| 55 | commit <- commit | sha | merge_commit_sha
|
| 56 | scenario_id <- id | scenario_id
|
| 57 |
|
| 58 | Mirrors are expected under ``--mirror-root`` as ``<owner>__<repo>/`` (bare or
|
| 59 | worktree clones; a ``<owner>__<repo>.git/`` bare directory is also accepted).
|
| 60 | A missing mirror skips that scenario with reason ``mirror_missing:<owner>/<repo>``
|
| 61 | (pointer datasets rot; skip-row honesty over hard errors, mirroring
|
| 62 | ``scripts/mount_probe.py`` and ``scripts/oakbench/rows.py``).
|
| 63 |
|
| 64 | Oracle modes are RECORDED here, not evaluated: ``strict-em`` / ``normalized``
|
| 65 | apply to merge_conflict_resolution scenarios. interactive_rebase and
|
| 66 | iterative_commit scenarios record oracle null with note
|
| 67 | ``oracle_defined_at_runtime:tree_equivalence`` -- their oracles are
|
| 68 | tree/history equivalence checks performed by the agent lane later.
|
| 69 |
|
| 70 | Exit codes: 0 if any scenario is ready; 3 if the dataset file is missing
|
| 71 | (skip JSON ``{"status": "skipped", "reason": "dataset_missing:<path>"}`` on
|
| 72 | stdout) or present but nothing is materializable; 2 on usage errors.
|
| 73 | """
|
| 74 |
|
| 75 | from __future__ import annotations
|
| 76 |
|
| 77 | import argparse
|
| 78 | import ast
|
| 79 | import csv
|
| 80 | import json
|
| 81 | import sys
|
| 82 | from pathlib import Path
|
| 83 | from typing import Any, Iterator, Optional
|
| 84 |
|
| 85 | SCHEMA_VERSION = 2
|
| 86 | SOURCE = "gitgoodbench"
|
| 87 | LICENSE = "Apache-2.0"
|
| 88 | CITATION = "lindenbauer-etal-2025-gitgoodbench (REALM 2025, doi:10.18653/v1/2025.realm-1.19)"
|
| 89 | SOURCE_DATASETS = [
|
| 90 | "JetBrains/git_good_bench",
|
| 91 | "JetBrains/git_good_bench-lite",
|
| 92 | "JetBrains/git_good_bench-train",
|
| 93 | ]
|
| 94 |
|
| 95 | EXIT_OK = 0
|
| 96 | EXIT_USAGE = 2
|
| 97 | EXIT_SKIP = 3
|
| 98 |
|
| 99 | ORACLE_STRICT_EM = "strict-em"
|
| 100 | ORACLE_NORMALIZED = "normalized"
|
| 101 | NORMALIZATION_NORMALIZED = "whitespace_and_blank_lines"
|
| 102 | ORACLE_NOTE_RUNTIME = "oracle_defined_at_runtime:tree_equivalence"
|
| 103 |
|
| 104 | # Canonical (real published schema) columns.
|
| 105 | SAMPLE_TYPE_COLUMNS = ("sample_type",)
|
| 106 | NAME_COLUMNS = ("name",)
|
| 107 | CANONICAL_ID_COLUMNS = ("id",)
|
| 108 |
|
| 109 | SAMPLE_TYPE_MERGE = "merge"
|
| 110 | SAMPLE_TYPE_CHAIN = "file_commit_chain"
|
| 111 |
|
| 112 | # Legacy liberal fallback columns (old synthetic fixtures, unknown exports).
|
| 113 | REPO_COLUMNS = ("repo", "repository", "repo_name")
|
| 114 | TYPE_COLUMNS = ("scenario_type", "type", "task_type")
|
| 115 | COMMIT_COLUMNS = ("commit", "sha", "merge_commit_sha")
|
| 116 | ID_COLUMNS = ("id", "scenario_id")
|
| 117 |
|
| 118 | TYPE_MERGE = "merge_conflict_resolution"
|
| 119 | TYPE_REBASE = "interactive_rebase"
|
| 120 | TYPE_ITERATIVE_COMMIT = "iterative_commit"
|
| 121 |
|
| 122 | ROW_ERROR_KEY = "__row_error__"
|
| 123 |
|
| 124 |
|
| 125 | def parse_args(argv: Optional[list[str]] = None) -> argparse.Namespace:
|
| 126 | parser = argparse.ArgumentParser(
|
| 127 | description="Import a GitGoodBench export (CSV or JSONL) into scenarios JSON."
|
| 128 | )
|
| 129 | parser.add_argument(
|
| 130 | "--csv",
|
| 131 | type=Path,
|
| 132 | required=True,
|
| 133 | help="Local path to a dataset export: .csv or .jsonl (sniffed by extension)",
|
| 134 | )
|
| 135 | parser.add_argument(
|
| 136 | "--mirror-root",
|
| 137 | type=Path,
|
| 138 | required=True,
|
| 139 | help="Directory containing mirrored repos as <owner>__<repo>/ (bare or worktree clones)",
|
| 140 | )
|
| 141 | parser.add_argument("--out", type=Path, required=True, help="Path to write the scenarios JSON")
|
| 142 | parser.add_argument(
|
| 143 | "--oracle",
|
| 144 | choices=[ORACLE_STRICT_EM, ORACLE_NORMALIZED],
|
| 145 | default=ORACLE_STRICT_EM,
|
| 146 | help="Oracle mode recorded per merge scenario (recorded only; evaluation lives in the agent lane)",
|
| 147 | )
|
| 148 | return parser.parse_args(argv)
|
| 149 |
|
| 150 |
|
| 151 | def pick_column(row: dict[str, Any], names: tuple[str, ...]) -> Optional[str]:
|
| 152 | lowered = {str(key).strip().lower(): value for key, value in row.items() if key is not None}
|
| 153 | for name in names:
|
| 154 | value = lowered.get(name)
|
| 155 | if value is not None and str(value).strip():
|
| 156 | return str(value).strip()
|
| 157 | return None
|
| 158 |
|
| 159 |
|
| 160 | def raw_field(row: dict[str, Any], name: str) -> Any:
|
| 161 | """Case-insensitive raw lookup (JSONL rows may carry non-string values)."""
|
| 162 | for key, value in row.items():
|
| 163 | if key is not None and str(key).strip().lower() == name:
|
| 164 | return value
|
| 165 | return None
|
| 166 |
|
| 167 |
|
| 168 | def normalize_scenario_type(raw: Optional[str]) -> Optional[str]:
|
| 169 | if not raw:
|
| 170 | return None
|
| 171 | text = raw.strip().lower()
|
| 172 | if "merge" in text:
|
| 173 | return TYPE_MERGE
|
| 174 | if "rebase" in text:
|
| 175 | return TYPE_REBASE
|
| 176 | if "commit" in text or "iterative" in text:
|
| 177 | return TYPE_ITERATIVE_COMMIT
|
| 178 | return None
|
| 179 |
|
| 180 |
|
| 181 | def mirror_path_for(mirror_root: Path, repo: str) -> Optional[Path]:
|
| 182 | """Return the existing mirror directory for owner/repo, or None."""
|
| 183 | slug = repo.strip().strip("/").replace("/", "__")
|
| 184 | for candidate in (mirror_root / slug, mirror_root / f"{slug}.git"):
|
| 185 | if candidate.is_dir():
|
| 186 | return candidate
|
| 187 | return None
|
| 188 |
|
| 189 |
|
| 190 | def oracle_record(mode: str) -> dict[str, Any]:
|
| 191 | return {
|
| 192 | "mode": mode,
|
| 193 | "normalization": NORMALIZATION_NORMALIZED if mode == ORACLE_NORMALIZED else None,
|
| 194 | }
|
| 195 |
|
| 196 |
|
| 197 | def _walk_scalar_items(obj: Any) -> Iterator[tuple[str, Any]]:
|
| 198 | """Depth-first (key, scalar value) pairs from nested dicts/lists."""
|
| 199 | if isinstance(obj, dict):
|
| 200 | for key, value in obj.items():
|
| 201 | if isinstance(value, (dict, list)):
|
| 202 | yield from _walk_scalar_items(value)
|
| 203 | else:
|
| 204 | yield str(key).strip().lower(), value
|
| 205 | elif isinstance(obj, list):
|
| 206 | for item in obj:
|
| 207 | yield from _walk_scalar_items(item)
|
| 208 |
|
| 209 |
|
| 210 | def extract_merge_commit(scenario_obj: Any) -> Optional[str]:
|
| 211 | """Liberal extraction: keys containing merge_commit, then hash, then sha."""
|
| 212 | items = list(_walk_scalar_items(scenario_obj))
|
| 213 | for needle in ("merge_commit", "hash", "sha"):
|
| 214 | for key, value in items:
|
| 215 | if needle in key and isinstance(value, str) and value.strip():
|
| 216 | return value.strip()
|
| 217 | return None
|
| 218 |
|
| 219 |
|
| 220 | def parse_scenario_field(row: dict[str, Any]) -> tuple[Any, bool]:
|
| 221 | """Parse the 'scenario' column. Returns (parsed, ok)."""
|
| 222 | raw = raw_field(row, "scenario")
|
| 223 | if isinstance(raw, (dict, list)):
|
| 224 | return raw, True # already structured (e.g. a generous JSONL export)
|
| 225 | if raw is None or not str(raw).strip():
|
| 226 | return None, False
|
| 227 | # Both parse paths accept only structured (dict/list) results: a scalar
|
| 228 | # scenario column carries no scenario data and must skip, not go ready.
|
| 229 | try:
|
| 230 | parsed = json.loads(str(raw))
|
| 231 | except (json.JSONDecodeError, ValueError):
|
| 232 | try:
|
| 233 | parsed = ast.literal_eval(str(raw))
|
| 234 | except (SyntaxError, ValueError):
|
| 235 | return None, False
|
| 236 | if isinstance(parsed, (dict, list)):
|
| 237 | return parsed, True
|
| 238 | return None, False
|
| 239 |
|
| 240 |
|
| 241 | def base_entry(
|
| 242 | scenario_id: str,
|
| 243 | scenario_type: Optional[str],
|
| 244 | sample_type: Optional[str],
|
| 245 | repo: Optional[str],
|
| 246 | metadata: Optional[dict[str, Any]],
|
| 247 | ) -> dict[str, Any]:
|
| 248 | return {
|
| 249 | "scenario_id": scenario_id,
|
| 250 | "scenario_type": scenario_type,
|
| 251 | "sample_type": sample_type,
|
| 252 | "repo": repo,
|
| 253 | "mirror_path": None,
|
| 254 | "commit": None,
|
| 255 | "source_scenario": None,
|
| 256 | "metadata": metadata,
|
| 257 | "oracle": None,
|
| 258 | "oracle_note": None,
|
| 259 | "status": "skipped",
|
| 260 | "skip_reason": None,
|
| 261 | }
|
| 262 |
|
| 263 |
|
| 264 | def build_canonical(
|
| 265 | row: dict[str, Any],
|
| 266 | index: int,
|
| 267 | mirror_root: Path,
|
| 268 | oracle_mode: str,
|
| 269 | sample_type: str,
|
| 270 | ) -> list[dict[str, Any]]:
|
| 271 | """Canonical path for rows carrying the real published schema."""
|
| 272 | base_id = pick_column(row, CANONICAL_ID_COLUMNS) or f"{SOURCE}-row-{index}"
|
| 273 | repo = pick_column(row, NAME_COLUMNS)
|
| 274 | metadata = {
|
| 275 | "difficulty": pick_column(row, ("difficulty",)),
|
| 276 | "programming_language": pick_column(row, ("programming_language",)),
|
| 277 | "default_branch": pick_column(row, ("default_branch",)),
|
| 278 | }
|
| 279 |
|
| 280 | normalized_sample_type = sample_type.strip().lower()
|
| 281 | if normalized_sample_type == SAMPLE_TYPE_MERGE:
|
| 282 | specs = [(base_id, TYPE_MERGE)]
|
| 283 | elif normalized_sample_type == SAMPLE_TYPE_CHAIN:
|
| 284 | specs = [
|
| 285 | (f"{base_id}--rebase", TYPE_REBASE),
|
| 286 | (f"{base_id}--commits", TYPE_ITERATIVE_COMMIT),
|
| 287 | ]
|
| 288 | else:
|
| 289 | entry = base_entry(base_id, None, sample_type, repo, metadata)
|
| 290 | entry["skip_reason"] = f"unrecognized_sample_type:{sample_type}"
|
| 291 | return [entry]
|
| 292 |
|
| 293 | source_scenario, parsed_ok = parse_scenario_field(row)
|
| 294 | if not parsed_ok:
|
| 295 | entry = base_entry(base_id, None, sample_type, repo, metadata)
|
| 296 | entry["skip_reason"] = "scenario_json_unparseable"
|
| 297 | return [entry]
|
| 298 |
|
| 299 | merge_commit = extract_merge_commit(source_scenario)
|
| 300 |
|
| 301 | entries: list[dict[str, Any]] = []
|
| 302 | for scenario_id, scenario_type in specs:
|
| 303 | entry = base_entry(scenario_id, scenario_type, sample_type, repo, metadata)
|
| 304 | entry["source_scenario"] = source_scenario
|
| 305 | if scenario_type == TYPE_MERGE:
|
| 306 | entry["commit"] = merge_commit
|
| 307 |
|
| 308 | if not repo:
|
| 309 | entry["skip_reason"] = "missing_repo"
|
| 310 | entries.append(entry)
|
| 311 | continue
|
| 312 | mirror = mirror_path_for(mirror_root, repo)
|
| 313 | if mirror is None:
|
| 314 | entry["skip_reason"] = f"mirror_missing:{repo}"
|
| 315 | entries.append(entry)
|
| 316 | continue
|
| 317 |
|
| 318 | entry["mirror_path"] = str(mirror)
|
| 319 | entry["status"] = "ready"
|
| 320 | if scenario_type == TYPE_MERGE:
|
| 321 | entry["oracle"] = oracle_record(oracle_mode)
|
| 322 | else:
|
| 323 | entry["oracle"] = None
|
| 324 | entry["oracle_note"] = ORACLE_NOTE_RUNTIME
|
| 325 | entries.append(entry)
|
| 326 | return entries
|
| 327 |
|
| 328 |
|
| 329 | def build_legacy(
|
| 330 | row: dict[str, Any],
|
| 331 | index: int,
|
| 332 | mirror_root: Path,
|
| 333 | oracle_mode: str,
|
| 334 | ) -> dict[str, Any]:
|
| 335 | """Legacy liberal fallback for rows without a sample_type column."""
|
| 336 | repo = pick_column(row, REPO_COLUMNS)
|
| 337 | raw_type = pick_column(row, TYPE_COLUMNS)
|
| 338 | commit = pick_column(row, COMMIT_COLUMNS)
|
| 339 | scenario_id = pick_column(row, ID_COLUMNS) or f"{SOURCE}-row-{index}"
|
| 340 | scenario_type = normalize_scenario_type(raw_type)
|
| 341 |
|
| 342 | entry = base_entry(scenario_id, scenario_type, None, repo, None)
|
| 343 | entry["commit"] = commit
|
| 344 |
|
| 345 | if scenario_type is None:
|
| 346 | entry["skip_reason"] = f"unrecognized_scenario_type:{raw_type or ''}"
|
| 347 | return entry
|
| 348 | if not repo:
|
| 349 | entry["skip_reason"] = "missing_repo"
|
| 350 | return entry
|
| 351 |
|
| 352 | mirror = mirror_path_for(mirror_root, repo)
|
| 353 | if mirror is None:
|
| 354 | entry["skip_reason"] = f"mirror_missing:{repo}"
|
| 355 | return entry
|
| 356 |
|
| 357 | entry["mirror_path"] = str(mirror)
|
| 358 | entry["status"] = "ready"
|
| 359 | if scenario_type == TYPE_MERGE:
|
| 360 | entry["oracle"] = oracle_record(oracle_mode)
|
| 361 | else:
|
| 362 | entry["oracle"] = None
|
| 363 | entry["oracle_note"] = ORACLE_NOTE_RUNTIME
|
| 364 | return entry
|
| 365 |
|
| 366 |
|
| 367 | def build_from_row(
|
| 368 | row: dict[str, Any],
|
| 369 | index: int,
|
| 370 | mirror_root: Path,
|
| 371 | oracle_mode: str,
|
| 372 | ) -> list[dict[str, Any]]:
|
| 373 | row_error = row.get(ROW_ERROR_KEY)
|
| 374 | if row_error:
|
| 375 | entry = base_entry(f"{SOURCE}-row-{index}", None, None, None, None)
|
| 376 | entry["skip_reason"] = str(row_error)
|
| 377 | return [entry]
|
| 378 | sample_type = pick_column(row, SAMPLE_TYPE_COLUMNS)
|
| 379 | if sample_type:
|
| 380 | return build_canonical(row, index, mirror_root, oracle_mode, sample_type)
|
| 381 | return [build_legacy(row, index, mirror_root, oracle_mode)]
|
| 382 |
|
| 383 |
|
| 384 | def read_rows(path: Path) -> Iterator[dict[str, Any]]:
|
| 385 | """Yield row dicts from a .csv or .jsonl export (sniffed by extension)."""
|
| 386 | if path.suffix.lower() == ".jsonl":
|
| 387 | with path.open(encoding="utf-8") as fh:
|
| 388 | for line in fh:
|
| 389 | text = line.strip()
|
| 390 | if not text:
|
| 391 | continue
|
| 392 | try:
|
| 393 | obj = json.loads(text)
|
| 394 | except json.JSONDecodeError:
|
| 395 | yield {ROW_ERROR_KEY: "row_json_unparseable"}
|
| 396 | continue
|
| 397 | if isinstance(obj, dict):
|
| 398 | yield obj
|
| 399 | else:
|
| 400 | yield {ROW_ERROR_KEY: "row_not_an_object"}
|
| 401 | else:
|
| 402 | with path.open(newline="", encoding="utf-8") as fh:
|
| 403 | yield from csv.DictReader(fh)
|
| 404 |
|
| 405 |
|
| 406 | def import_dataset(dataset_path: Path, mirror_root: Path, oracle_mode: str) -> dict[str, Any]:
|
| 407 | scenarios: list[dict[str, Any]] = []
|
| 408 | for index, row in enumerate(read_rows(dataset_path)):
|
| 409 | scenarios.extend(build_from_row(row, index, mirror_root, oracle_mode))
|
| 410 | ready = sum(1 for item in scenarios if item["status"] == "ready")
|
| 411 | skipped = sum(1 for item in scenarios if item["status"] == "skipped")
|
| 412 | return {
|
| 413 | "schema_version": SCHEMA_VERSION,
|
| 414 | "source": SOURCE,
|
| 415 | "license": LICENSE,
|
| 416 | "citation": CITATION,
|
| 417 | "source_datasets": SOURCE_DATASETS,
|
| 418 | "oracle_mode": oracle_mode,
|
| 419 | "scenarios": scenarios,
|
| 420 | "counts": {"ready": ready, "skipped": skipped},
|
| 421 | }
|
| 422 |
|
| 423 |
|
| 424 | def main(argv: Optional[list[str]] = None) -> int:
|
| 425 | args = parse_args(argv)
|
| 426 |
|
| 427 | if not args.csv.is_file():
|
| 428 | print(json.dumps({"status": "skipped", "reason": f"dataset_missing:{args.csv}"}))
|
| 429 | return EXIT_SKIP
|
| 430 |
|
| 431 | document = import_dataset(args.csv, args.mirror_root, args.oracle)
|
| 432 |
|
| 433 | args.out.parent.mkdir(parents=True, exist_ok=True)
|
| 434 | args.out.write_text(json.dumps(document, indent=2, sort_keys=True) + "\n")
|
| 435 | print(f"[result] {args.out}", file=sys.stderr)
|
| 436 |
|
| 437 | if document["counts"]["ready"] > 0:
|
| 438 | return EXIT_OK
|
| 439 | return EXIT_SKIP
|
| 440 |
|
| 441 |
|
| 442 | if __name__ == "__main__":
|
| 443 | raise SystemExit(main())
|