File size: 5,906 Bytes
910dadd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
"""Build data/prs.jsonl from oumi-ai/oumi git history.

Mines squash-merge commits (subject ending in "(#NNNN)"), derives a subsystem
area label from the files each PR changed, and (best-effort) fetches PR bodies
and merge dates via the GitHub GraphQL API using `gh` auth.

Usage:
    git clone --filter=blob:none --single-branch https://github.com/oumi-ai/oumi /tmp/oumi-mine
    uv run python scripts/build_pr_dataset.py --repo /tmp/oumi-mine [--no-bodies]
"""

import argparse
import json
import re
import subprocess
from collections import Counter
from pathlib import Path

OUT_PATH = Path(__file__).resolve().parent.parent / "data" / "prs.jsonl"

AREAS = [
    "training", "inference", "data", "evaluation", "configs",
    "launcher", "cli", "docs", "infra", "other",
]

# Ordered (prefix-or-regex, area) rules; first match wins. Paths not matching
# any rule fall through to "other". Tests and barrel files don't vote.
IGNORE = re.compile(r"^tests/|(^|/)__init__\.py$")
RULES: list[tuple[re.Pattern, str]] = [
    (re.compile(r"^docs/|^notebooks/|\.(md|rst|ipynb)$|^CITATION|^LICENSE"), "docs"),
    (re.compile(r"^\.github/|^scripts/|^pyproject\.toml$|^uv\.lock$|^Makefile$|^Dockerfile|^install\.sh$|^\.pre-commit|^\.(gitignore|dockerignore)|^\.style|^\.readthedocs"), "infra"),
    (re.compile(r"^configs/|^src/(oumi|lema)/core/configs/"), "configs"),
    (re.compile(r"^src/(oumi|lema)/(inference/|infer.*\.py$|deploy/)|^src/(oumi|lema)/core/inference/"), "inference"),
    (re.compile(r"^src/(oumi|lema)/core/(trainers|tuners|callbacks|collators)/|^src/(oumi|lema)/(train|tune)\.py$|^src/(oumi|lema)/performance/|^src/(oumi|lema)/core/distributed\.py$"), "training"),
    (re.compile(r"^src/(oumi|lema)/(evaluation/|evaluate.*\.py$|judges/|judge\.py$|analyze/)|^src/(oumi|lema)/core/(evaluation|analyze)/"), "evaluation"),
    (re.compile(r"^src/(oumi|lema)/(datasets/|synth\.py$)|^src/(oumi|lema)/core/(datasets|tokenizers|processors|feature_generators|synthesis|types)/|^data/"), "data"),
    (re.compile(r"^src/(oumi|lema)/launcher/|^src/(oumi|lema)/core/launcher/"), "launcher"),
    (re.compile(r"^src/(oumi|lema)/cli/"), "cli"),
]

SUBJECT_RE = re.compile(r"^(?P<title>.*)\s+\(#(?P<number>\d+)\)$")
DOMINANCE = 0.70
MAX_BODY_CHARS = 4000


def map_file(path: str) -> str | None:
    if IGNORE.search(path):
        return None
    for pattern, area in RULES:
        if pattern.search(path):
            return area
    return "other"


def derive_area(files: list[str]) -> tuple[str | None, dict[str, int]]:
    """Return (area, votes); area is None when no file dominates."""
    votes = Counter(a for f in files if (a := map_file(f)))
    if not votes:
        return None, {}
    area, top = votes.most_common(1)[0]
    if top / sum(votes.values()) < DOMINANCE:
        return None, dict(votes)
    return area, dict(votes)


def mine_commits(repo: str) -> list[dict]:
    # Single pass: NUL-prefixed header line per commit, then its changed files.
    log = subprocess.run(
        ["git", "-C", repo, "log", "--name-only", "--no-merges",
         "--format=%x00%s", "main"],
        capture_output=True, text=True, check=True,
    ).stdout
    records = []
    for block in log.split("\x00"):
        if not block.strip():
            continue
        subject, _, file_block = block.partition("\n")
        m = SUBJECT_RE.match(subject.strip())
        if not m:
            continue
        files = [ln.strip() for ln in file_block.splitlines() if ln.strip()]
        area, votes = derive_area(files)
        if area is None:
            continue
        records.append({
            "number": int(m["number"]),
            "title": m["title"].strip(),
            "files": files,
            "area": area,
            "area_votes": votes,
        })
    # Dedupe (reverts etc. can repeat a PR number); keep first (newest) occurrence.
    seen: set[int] = set()
    return [r for r in records if not (r["number"] in seen or seen.add(r["number"]))]


def fetch_bodies(records: list[dict], batch_size: int = 50) -> None:
    """Attach `body` and `merged_at` in-place via GitHub GraphQL; skip on failure."""
    for i in range(0, len(records), batch_size):
        batch = records[i:i + batch_size]
        fields = "".join(
            f'p{r["number"]}: pullRequest(number: {r["number"]}) {{ body mergedAt }}\n'
            for r in batch
        )
        query = f'query {{ repository(owner: "oumi-ai", name: "oumi") {{ {fields} }} }}'
        try:
            out = subprocess.run(
                ["gh", "api", "graphql", "-f", f"query={query}"],
                capture_output=True, text=True, check=True, timeout=120,
            ).stdout
            repo_data = json.loads(out)["data"]["repository"]
        except (subprocess.SubprocessError, KeyError, json.JSONDecodeError) as e:
            print(f"  body fetch failed for batch at {i}: {e}; continuing without")
            continue
        for r in batch:
            pr = repo_data.get(f'p{r["number"]}') or {}
            r["body"] = (pr.get("body") or "")[:MAX_BODY_CHARS]
            r["merged_at"] = pr.get("mergedAt")
        print(f"  bodies {i + len(batch)}/{len(records)}")


def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument("--repo", default="/tmp/oumi-mine")
    parser.add_argument("--no-bodies", action="store_true")
    args = parser.parse_args()

    records = mine_commits(args.repo)
    print(f"mined {len(records)} PRs with unambiguous areas")
    print("label distribution:", Counter(r["area"] for r in records).most_common())
    if not args.no_bodies:
        fetch_bodies(records)

    records.sort(key=lambda r: -r["number"])
    OUT_PATH.parent.mkdir(exist_ok=True)
    with OUT_PATH.open("w") as f:
        for r in records:
            f.write(json.dumps(r) + "\n")
    print(f"wrote {len(records)} records to {OUT_PATH}")


if __name__ == "__main__":
    main()