Skip to content

Commit 3efb424

Browse files
committed
Add dependabot-triage skill for Kaggle dependency PRs
Stdlib-only helper that checks whether a Dependabot bump is already shipped by Kaggle/docker-python (and, for competitions/hearth, within uv's exclude-newer cutoff and not a major bump), then prints a JSON verdict (auto-approve / needs-attention / skip-js). Co-authored-by: kaggle-agent <kaggle-agent@users.noreply.github.com>
1 parent e5b4d14 commit 3efb424

3 files changed

Lines changed: 234 additions & 0 deletions

File tree

‎dependabot-triage/SKILL.md‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
---
2+
name: dependabot-triage
3+
description: Triage Dependabot PRs on Kaggle repos (e.g. Kaggle/competitions, Kaggle/kaggle-environments) by checking whether the proposed package version is already shipped in Kaggle/docker-python, allowed by hearth's uv exclude-newer cutoff, and not a semver major bump. Use whenever a maintainer asks to triage, sweep, or auto-approve Dependabot PRs.
4+
---
5+
6+
# Dependabot Triage
7+
8+
A Dependabot bump is almost always safe when the proposed version is already
9+
shipped in Kaggle's `docker-python` image. This skill checks that (plus hearth's
10+
`exclude-newer` cutoff and major-version bumps) and prints a verdict.
11+
12+
## Run it
13+
14+
Triages every open Dependabot PR on the repo (Docker runs once for the batch):
15+
16+
```bash
17+
python3 dependabot-triage/check_dependabot_pr.py --repo Kaggle/competitions
18+
```
19+
20+
Requirements (the script itself is stdlib only — no pip install):
21+
22+
- `gh` CLI authenticated with read access to the target repo.
23+
- `docker`, plus GCR auth to pull `gcr.io/kaggle-images/python:latest`
24+
(`gcloud auth configure-docker gcr.io`). The script runs `pip list` inside that
25+
image to get the full installed-package set, so the first run pulls the image.
26+
27+
## Output
28+
29+
A JSON list with one dict per open PR: `{ "verdict", "package", "version", "reason" }`.
30+
31+
- `auto-approve` — docker-python ships this version or newer, no major bump, and
32+
(for `competitions/hearth`) within the `exclude-newer` cutoff. Safe to approve:
33+
`gh pr review <pr> -R <repo> --approve --body "<reason>"`. Do not merge — leave
34+
that to branch protection.
35+
- `needs-attention` — read `reason`. Common blockers: package not installed in
36+
docker-python, proposed version newer than docker-python ships, a major bump,
37+
or blocked by hearth's `exclude-newer`. Review manually before approving.
38+
- `skip-js` — a JS/npm package with no docker-python baseline; review manually.
39+
40+
Review the list and only approve the `auto-approve` rows after confirming.
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
#!/usr/bin/env python3
2+
"""Triage a Dependabot PR: is the proposed version already shipped by
3+
Kaggle/docker-python (and, for competitions/hearth, within uv's exclude-newer
4+
cutoff and not a major bump)? Prints a JSON verdict. Stdlib only; needs `gh`."""
5+
6+
import argparse
7+
import json
8+
import re
9+
import subprocess
10+
import sys
11+
import urllib.request
12+
from datetime import datetime, timezone
13+
14+
DP_IMAGE = "gcr.io/kaggle-images/python:latest"
15+
HEARTH_PYPROJECT = "competitions/hearth/pyproject.toml"
16+
17+
TITLE_RE = re.compile(
18+
r"Bump\s+(?P<pkg>\S+)\s+from\s+(?P<old>\S+)\s+to\s+(?P<new>\S+)"
19+
r"(?:\s+in\s+(?P<dir>\S+))?", re.IGNORECASE)
20+
JS_PKGS = {"vitest", "vite", "ws", "eslint", "prettier", "typescript", "react",
21+
"react-dom", "next", "axios", "webpack", "rollup", "jest", "esbuild"}
22+
23+
24+
def normalize(name):
25+
return re.sub(r"[-_.]+", "-", name).lower()
26+
27+
28+
def parse_version(v):
29+
m = re.match(r"v?(\d+(?:\.\d+)*)", v.strip()) if v else None
30+
return tuple(int(x) for x in m.group(1).split(".")) if m else ()
31+
32+
33+
def version_le(a, b):
34+
"""True if version a <= version b, comparing numeric release segments."""
35+
pa, pb = parse_version(a), parse_version(b)
36+
n = max(len(pa), len(pb))
37+
return pa + (0,) * (n - len(pa)) <= pb + (0,) * (n - len(pb))
38+
39+
40+
def is_major_bump(old, new):
41+
po, pn = parse_version(old), parse_version(new)
42+
return bool(po and pn and po[0] != pn[0])
43+
44+
45+
def within_cutoff(upload_iso, cutoff_iso):
46+
"""True if no cutoff applies or the upload happened on/before the cutoff."""
47+
def at(s):
48+
d = datetime.fromisoformat(s.strip().replace("Z", "+00:00"))
49+
return d if d.tzinfo else d.replace(tzinfo=timezone.utc)
50+
if not cutoff_iso:
51+
return True
52+
if not upload_iso:
53+
return False
54+
return at(upload_iso) <= at(cutoff_iso)
55+
56+
57+
def _gh(*args):
58+
return subprocess.run(["gh", *args], capture_output=True, text=True, check=True).stdout
59+
60+
61+
def _fetch(repo, path):
62+
"""Raw file contents from the repo's default branch."""
63+
return _gh("api", f"repos/{repo}/contents/{path}",
64+
"-H", "Accept: application/vnd.github.raw")
65+
66+
67+
def docker_python_versions():
68+
"""Get all installed packages from the live docker-python image."""
69+
try:
70+
out = subprocess.run(
71+
["docker", "run", "--rm", DP_IMAGE, "pip", "list", "--format=freeze"],
72+
capture_output=True, text=True, check=True, timeout=120).stdout
73+
except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired):
74+
return {}
75+
pkgs = {}
76+
for line in out.strip().splitlines():
77+
if "==" in line:
78+
name, ver = line.split("==", 1)
79+
pkgs[normalize(name.strip())] = ver.strip()
80+
return pkgs
81+
82+
83+
def exclude_newer_date(repo, directory):
84+
"""The hearth exclude-newer cutoff, only when the PR targets competitions/hearth."""
85+
if repo != "Kaggle/competitions" or "hearth" not in (directory or ""):
86+
return None
87+
try:
88+
text = _fetch(repo, HEARTH_PYPROJECT)
89+
except subprocess.CalledProcessError:
90+
return None
91+
m = re.search(r"""exclude-newer\s*=\s*["']([^"']+)["']""", text)
92+
return m.group(1) if m else None
93+
94+
95+
def pypi_upload_time(package, version):
96+
url = f"https://pypi.org/pypi/{package}/{version}/json"
97+
try:
98+
with urllib.request.urlopen(url, timeout=20) as resp:
99+
data = json.load(resp)
100+
except Exception:
101+
return None
102+
src = (data.get("urls") or [data.get("info", {})])[0]
103+
return src.get("upload_time_iso_8601") or src.get("upload_time")
104+
105+
106+
def triage(repo, pr, versions=None):
107+
title = json.loads(_gh("pr", "view", str(pr), "-R", repo, "--json", "title"))["title"]
108+
m = TITLE_RE.search(title or "")
109+
if not m:
110+
return {"verdict": "needs-attention", "package": None, "version": None,
111+
"reason": f"could not parse Dependabot title: {title!r}"}
112+
pkg, old, new = m.group("pkg"), m.group("old"), m.group("new")
113+
out = {"verdict": "needs-attention", "package": pkg, "version": new, "reason": ""}
114+
if pkg.startswith("@") or pkg.lower() in JS_PKGS:
115+
out.update(verdict="skip-js", reason="JS package — no docker-python baseline, review manually")
116+
return out
117+
118+
reasons = []
119+
if versions is None:
120+
versions = docker_python_versions()
121+
key = normalize(pkg)
122+
shipped = versions.get(key)
123+
if key not in versions:
124+
reasons.append("package not in docker-python")
125+
elif shipped and not version_le(new, shipped):
126+
reasons.append(f"docker-python pins {shipped}, older than proposed {new}")
127+
if is_major_bump(old, new):
128+
reasons.append(f"major version bump ({old} -> {new})")
129+
cutoff = exclude_newer_date(repo, m.group("dir"))
130+
if cutoff and not within_cutoff(pypi_upload_time(key, new), cutoff):
131+
reasons.append(f"upload is after hearth exclude-newer cutoff {cutoff}")
132+
133+
if reasons:
134+
out["reason"] = "; ".join(reasons)
135+
else:
136+
out["verdict"] = "auto-approve"
137+
out["reason"] = (f"docker-python pins {shipped} >= proposed {new}" if shipped
138+
else "already shipped in docker-python (tracks latest)")
139+
return out
140+
141+
142+
def main(argv=None):
143+
p = argparse.ArgumentParser(description=__doc__)
144+
p.add_argument("--repo", required=True, help="e.g. Kaggle/competitions")
145+
args = p.parse_args(argv)
146+
try:
147+
prs = json.loads(_gh("pr", "list", "-R", args.repo,
148+
"--author", "app/dependabot", "--state", "open",
149+
"--json", "number,title"))
150+
versions = docker_python_versions()
151+
result = [triage(args.repo, pr["number"], versions) for pr in prs]
152+
except subprocess.CalledProcessError as e:
153+
sys.stderr.write(f"gh error: {e.stderr or e}\n")
154+
return 2
155+
print(json.dumps(result, indent=2, sort_keys=True))
156+
return 0
157+
158+
159+
if __name__ == "__main__":
160+
sys.exit(main())
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import os
2+
import sys
3+
4+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
5+
6+
from check_dependabot_pr import ( # noqa: E402
7+
normalize, version_le, is_major_bump, within_cutoff,
8+
)
9+
10+
11+
def test_normalize_collapses_separators():
12+
assert normalize("Flask_Cors") == "flask-cors"
13+
assert normalize("zope.interface") == "zope-interface"
14+
assert normalize("a__b..c") == "a-b-c"
15+
16+
17+
def test_version_le_uses_numeric_ordering():
18+
assert version_le("1.2.3", "1.10.0") # not lexicographic
19+
assert version_le("2.0", "2.0") # equal
20+
assert version_le("1.9", "1.9.1") # zero-padding
21+
assert not version_le("2.0.0", "1.9.9")
22+
23+
24+
def test_is_major_bump():
25+
assert is_major_bump("1.4.2", "2.0.0")
26+
assert not is_major_bump("1.4.2", "1.9.0")
27+
assert not is_major_bump("garbage", "2.0") # unparseable -> not a bump
28+
29+
30+
def test_within_cutoff():
31+
assert within_cutoff("2024-01-01T00:00:00Z", "2024-06-01T00:00:00Z")
32+
assert not within_cutoff("2024-12-01T00:00:00Z", "2024-06-01T00:00:00Z")
33+
assert within_cutoff("anything", None) # no cutoff configured
34+
assert not within_cutoff(None, "2024-06-01T00:00:00Z") # unknown upload time

0 commit comments

Comments
 (0)