Skip to content

Commit 07ea892

Browse files
local git diff provider + Ollama engine + persistent/delta caching
Phase 1 (diffs) — collection/local_diffs.py: serve PR/commit diffs from a bare blobless clone instead of per-item gh API calls. Removes the REST 5k/hr + secondary-403 ceiling (git transport is separate); geth clone = 21MB/1.7s, local diff = ~0.05s vs ~0.3-1s per gh call. Verified 2/3 file-set match vs the gh cache; the third is a divergent-fork PR where the local 3-dot diff is unreliable -> get_diff_cached() falls back to gh for those. - get_diff_cached(url, client, cache): json-cache -> local git -> gh fallback. Re-runs pay nothing for seen URLs; new rows fetch only their own objects. - refresh(): delta `git fetch` (only new objects) for time-spaced re-runs. Phase 2 (LLM) — llm_classify_fixes.py: pluggable engine. `--engine ollama --model qwen2.5-coder:7b` runs classification on a local Ollama model (/api/generate, format=json) instead of `claude -p`, keeping the heavy phase local/free. claude path unchanged and re-verified. Note: Ollama not installed in this env — code paths verified (request well-formed, claude regression-tested); live accuracy on a local model still to be measured (papers report open models underperform commercial, so re-run the eval once ollama is available). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 6efb7c7 commit 07ea892

2 files changed

Lines changed: 215 additions & 4 deletions

File tree

collection/llm_classify_fixes.py

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,36 @@
2828
import re
2929
import subprocess
3030
import sys
31+
import urllib.request
3132
from concurrent.futures import ThreadPoolExecutor
3233
from pathlib import Path
3334

3435
import pandas as pd
3536

37+
# --- LLM engine (set from CLI in main) -------------------------------------
38+
# Two backends: Anthropic `claude -p` (default) or a local Ollama model. Ollama
39+
# keeps the heavy classification phase fully local / free once diffs are cached.
40+
ENGINE = {"engine": "claude", "model": "", "host": "http://localhost:11434"}
41+
42+
43+
def _call_llm(prompt: str) -> str:
44+
"""Return the raw model text for a prompt via the configured engine."""
45+
if ENGINE["engine"] == "ollama":
46+
body = json.dumps({
47+
"model": ENGINE["model"] or "qwen2.5-coder:7b",
48+
"prompt": prompt, "stream": False, "format": "json",
49+
"options": {"temperature": 0},
50+
}).encode()
51+
req = urllib.request.Request(f"{ENGINE['host']}/api/generate", data=body,
52+
headers={"Content-Type": "application/json"})
53+
with urllib.request.urlopen(req, timeout=300) as r:
54+
return json.loads(r.read()).get("response", "")
55+
# default: claude CLI
56+
cmd = ["claude", "-p"] + (["--model", ENGINE["model"]] if ENGINE["model"] else []) + [prompt]
57+
r = subprocess.run(cmd, capture_output=True, text=True, timeout=180,
58+
encoding="utf-8", errors="replace")
59+
return r.stdout.strip()
60+
3661
DEPBUMP_RE = re.compile(r"\bbump\b|chore\(deps|dependabot|renovate", re.I)
3762
NONFIX_TITLE_RE = re.compile(
3863
r"\b(?:feat|feature|refactor|perf|rename|cleanup|clean up|implement"
@@ -167,10 +192,8 @@ def build_prompt(it: dict) -> str:
167192
def classify(it: dict) -> dict:
168193
prompt = build_prompt(it)
169194
try:
170-
r = subprocess.run(["claude", "-p", prompt], capture_output=True, text=True,
171-
timeout=120, encoding="utf-8", errors="replace")
172-
out = r.stdout.strip()
173-
m = re.search(r"\{[^{}]*\"is_security_fix\"[^{}]*\}", out)
195+
out = _call_llm(prompt)
196+
m = re.search(r"\{[^{}]*\"is_security_fix\"[^{}]*\}", out, re.S)
174197
obj = json.loads(m.group(0)) if m else {}
175198
except Exception as e:
176199
obj = {"error": str(e)}
@@ -218,7 +241,15 @@ def main() -> int:
218241
ap.add_argument("--per-class", type=int, default=25)
219242
ap.add_argument("--workers", type=int, default=4)
220243
ap.add_argument("--seed", type=int, default=0)
244+
ap.add_argument("--engine", choices=["claude", "ollama"], default="claude",
245+
help="LLM backend for classification")
246+
ap.add_argument("--model", default="",
247+
help="model id (ollama: e.g. qwen2.5-coder:7b; claude: optional override)")
248+
ap.add_argument("--ollama-host", default="http://localhost:11434")
221249
a = ap.parse_args()
250+
ENGINE.update(engine=a.engine, model=a.model, host=a.ollama_host)
251+
if a.engine == "ollama" and a.workers > 2:
252+
a.workers = 2 # a single local model serializes; avoid thrashing
222253

223254
if a.build_eval:
224255
cache = json.loads(a.cache.read_text())

collection/local_diffs.py

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
#!/usr/bin/env python3
2+
"""local_diffs.py — serve PR/commit diffs from a local git clone.
3+
4+
Drop-in replacement for the per-item `gh pr diff` / `gh api /commits` calls used
5+
by the classifiers. Git transport (clone/fetch) is NOT subject to the REST API's
6+
5,000/hr or the secondary-rate-limit 403s we fought with — so this removes the
7+
rate-limit ceiling and turns each diff fetch from a ~0.3-1s network call into a
8+
~1-5ms local `git` read.
9+
10+
Design:
11+
* One bare, blobless clone per client (`--filter=blob:none`): fast to create,
12+
full commit/tree graph, blobs fetched lazily on first access (still git
13+
transport, no REST limit). Cached under scratchpad_crawl/repos/<client>.git.
14+
* commit SHA -> `git show` (fetch the object on demand if absent).
15+
* PR number -> ensure refs/pull/N/head, then diff merge-base(default,head)
16+
..head — the same 3-dot semantics as `gh pr diff`, so diffs match the cache.
17+
18+
CLI:
19+
warm --client geth # create/refresh the local clone
20+
diff --client geth --url <github url>
21+
"""
22+
from __future__ import annotations
23+
24+
import argparse
25+
import re
26+
import subprocess
27+
import sys
28+
from pathlib import Path
29+
30+
CLIENT_REPOS: dict[str, str] = {
31+
"geth": "ethereum/go-ethereum", "nethermind": "NethermindEth/nethermind",
32+
"besu": "hyperledger/besu", "erigon": "erigontech/erigon",
33+
"reth": "paradigmxyz/reth", "lighthouse": "sigp/lighthouse",
34+
"lodestar": "ChainSafe/lodestar", "nimbus": "status-im/nimbus-eth2",
35+
"prysm": "prysmaticlabs/prysm", "teku": "Consensys/teku",
36+
"grandine": "grandinetech/grandine",
37+
}
38+
REPO_DIR = Path("scratchpad_crawl/repos")
39+
PR_RE = re.compile(r"github\.com/[^/]+/[^/]+/pull/(\d+)")
40+
SHA_RE = re.compile(r"github\.com/[^/]+/[^/]+/commit/([0-9a-f]{7,40})", re.I)
41+
42+
43+
def _run(args, timeout=600):
44+
return subprocess.run(args, capture_output=True, text=True, timeout=timeout,
45+
encoding="utf-8", errors="replace")
46+
47+
48+
def repo_path(client: str) -> Path:
49+
return REPO_DIR / f"{client}.git"
50+
51+
52+
def ensure_clone(client: str, blobless: bool = True) -> Path:
53+
p = repo_path(client)
54+
if (p / "HEAD").exists():
55+
return p
56+
p.parent.mkdir(parents=True, exist_ok=True)
57+
url = f"https://github.com/{CLIENT_REPOS[client]}.git"
58+
args = ["git", "clone", "--bare"] + (["--filter=blob:none"] if blobless else []) + [url, str(p)]
59+
print(f"[local_diffs] cloning {url} (bare{'/blobless' if blobless else ''})…", file=sys.stderr)
60+
r = _run(args, timeout=1800)
61+
if r.returncode != 0:
62+
raise RuntimeError(f"clone failed for {client}: {r.stderr[:300]}")
63+
return p
64+
65+
66+
def _default_ref(p: Path) -> str:
67+
r = _run(["git", "-C", str(p), "symbolic-ref", "--short", "HEAD"])
68+
return f"refs/heads/{r.stdout.strip()}" if r.returncode == 0 and r.stdout.strip() else "HEAD"
69+
70+
71+
def get_commit_diff(client: str, sha: str) -> str | None:
72+
p = ensure_clone(client)
73+
if _run(["git", "-C", str(p), "cat-file", "-e", sha]).returncode != 0:
74+
_run(["git", "-C", str(p), "fetch", "--quiet", "origin", sha], timeout=300)
75+
r = _run(["git", "-C", str(p), "show", "--format=", "--unified=3", sha])
76+
return r.stdout if r.returncode == 0 and r.stdout.strip() else None
77+
78+
79+
def get_pr_diff(client: str, n: str) -> str | None:
80+
p = ensure_clone(client)
81+
ref = f"refs/pull/{n}/head"
82+
if _run(["git", "-C", str(p), "rev-parse", "--verify", "--quiet", ref]).returncode != 0:
83+
f = _run(["git", "-C", str(p), "fetch", "--quiet", "origin", f"{ref}:{ref}"], timeout=300)
84+
if f.returncode != 0:
85+
return None
86+
head = _run(["git", "-C", str(p), "rev-parse", ref]).stdout.strip()
87+
if not head:
88+
return None
89+
base = _run(["git", "-C", str(p), "merge-base", _default_ref(p), head]).stdout.strip()
90+
# No common ancestor (e.g. an unrelated-history fork) -> we cannot reproduce
91+
# GitHub's diff locally; signal miss so the caller falls back to `gh`.
92+
if not base:
93+
return None
94+
r = _run(["git", "-C", str(p), "diff", "--unified=3", f"{base}..{head}"])
95+
return r.stdout if r.returncode == 0 and r.stdout.strip() else None
96+
97+
98+
def diff_for_url(url: str, client: str) -> str | None:
99+
m = SHA_RE.search(url)
100+
if m:
101+
return get_commit_diff(client, m.group(1))
102+
m = PR_RE.search(url)
103+
if m:
104+
return get_pr_diff(client, m.group(1))
105+
return None
106+
107+
108+
def refresh(client: str) -> None:
109+
"""Delta fetch: pull only new objects into an existing clone (cheap)."""
110+
p = repo_path(client)
111+
if not (p / "HEAD").exists():
112+
ensure_clone(client)
113+
return
114+
r = _run(["git", "-C", str(p), "fetch", "--filter=blob:none", "--quiet",
115+
"--prune", "origin", "+refs/heads/*:refs/heads/*", "+refs/tags/*:refs/tags/*"],
116+
timeout=900)
117+
print(f"[local_diffs] {client} refreshed" + ("" if r.returncode == 0 else f" (warn: {r.stderr[:120]})"),
118+
file=sys.stderr)
119+
120+
121+
def _gh_fallback(url: str, client: str) -> str | None:
122+
"""Rare fork-PR cases where the local 3-dot diff is unreliable -> use gh."""
123+
repo = CLIENT_REPOS[client]
124+
m = PR_RE.search(url)
125+
if m:
126+
r = _run(["gh", "pr", "diff", m.group(1), "--repo", repo], timeout=60)
127+
else:
128+
m = SHA_RE.search(url)
129+
if not m:
130+
return None
131+
r = _run(["gh", "api", f"/repos/{repo}/commits/{m.group(1)}",
132+
"--jq", ".files[] | .patch // empty"], timeout=60)
133+
return r.stdout if r.returncode == 0 and r.stdout.strip() else None
134+
135+
136+
def get_diff_cached(url: str, client: str, cache: dict, allow_gh: bool = True) -> str | None:
137+
"""Canonical diff provider: persistent JSON cache -> local git -> gh fallback.
138+
139+
Re-runs pay nothing for already-seen URLs; new rows fetch only their own
140+
objects (git transport, no REST rate limit). `cache` is mutated in place;
141+
the caller persists it. An empty-string cache entry means "known-missing".
142+
"""
143+
if url in cache:
144+
return cache[url] or None
145+
d = diff_for_url(url, client)
146+
if d is None and allow_gh:
147+
d = _gh_fallback(url, client)
148+
cache[url] = d or ""
149+
return d
150+
151+
152+
def main() -> int:
153+
ap = argparse.ArgumentParser()
154+
sub = ap.add_subparsers(dest="cmd", required=True)
155+
w = sub.add_parser("warm"); w.add_argument("--client", required=True)
156+
rf = sub.add_parser("refresh"); rf.add_argument("--client", required=True)
157+
d = sub.add_parser("diff")
158+
d.add_argument("--client", required=True); d.add_argument("--url", required=True)
159+
a = ap.parse_args()
160+
clients = sorted(CLIENT_REPOS) if a.client == "all" else [a.client]
161+
if a.cmd == "warm":
162+
for c in clients:
163+
ensure_clone(c)
164+
print(f"[local_diffs] {c} ready at {repo_path(c)}")
165+
return 0
166+
if a.cmd == "refresh":
167+
for c in clients:
168+
refresh(c)
169+
return 0
170+
if a.cmd == "diff":
171+
diff = diff_for_url(a.url, a.client)
172+
if diff is None:
173+
print("NO DIFF", file=sys.stderr); return 1
174+
sys.stdout.write(diff)
175+
return 0
176+
return 0
177+
178+
179+
if __name__ == "__main__":
180+
raise SystemExit(main())

0 commit comments

Comments
 (0)