#!/usr/bin/env python3
"""Build a bounded, deterministic view of Claude-attributed GitHub commits."""
import argparse
import datetime as dt
import html
import json
import pathlib
import re
import urllib.error
import urllib.parse
import urllib.request
ROOT = pathlib.Path(__file__).resolve().parent
LOOKBACK_DAYS = 30
MAX_RESULTS_PER_QUERY = 50
SEARCHES = [
"author:claude[bot]",
"committer:claude[bot]",
"author:claude",
"committer:claude",
'"Co-authored-by: Claude"',
]
COAUTHOR_RE = re.compile(r"(?im)^\s*co-authored-by:\s*(.*?)\s*<([^>]+)>\s*$")
class GithubApiError(RuntimeError):
def __init__(self, status, detail, headers):
super().__init__(detail)
self.status = status
self.headers = headers
def api(url):
req = urllib.request.Request(url, headers={
"User-Agent": "notplanned-claude-scout/0.1",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
})
try:
with urllib.request.urlopen(req, timeout=20) as response:
return json.load(response), dict(response.headers)
except urllib.error.HTTPError as error:
try:
body = json.load(error)
detail = body.get("message", "GitHub API request failed")
except Exception:
detail = "GitHub API request failed"
raise GithubApiError(error.code, detail, dict(error.headers)) from error
def esc(value):
return html.escape(str(value), quote=True)
def parse_date(value):
try:
return dt.datetime.fromisoformat(value.replace("Z", "+00:00"))
except Exception:
return dt.datetime.min.replace(tzinfo=dt.timezone.utc)
def age_days(value, now):
return max(0, (now - parse_date(value)).days)
def identity_text(identity):
if not identity:
return ""
return " ".join(str(identity.get(key) or "") for key in ("login", "name", "email")).lower()
def is_claude_identity(identity):
text = identity_text(identity)
return bool(re.search(r"(?:claude\s*\[bot\]|claude\s+code|\bclaude\b)", text))
def item_identity(item, role):
commit_identity = (item.get("commit") or {}).get(role) or {}
account = item.get(role) or {}
return {
"login": account.get("login") or "",
"name": commit_identity.get("name") or "",
"email": commit_identity.get("email") or "",
}
def coauthors(message):
return [
{"name": name.strip(), "email": email.strip()}
for name, email in COAUTHOR_RE.findall(message or "")
if is_claude_identity({"name": name, "email": email})
]
def category(message):
text = (message or "").lower()
buckets = [
("browser", ["browser", "playwright", "puppeteer", "chrome", "screenshot"]),
("integration", ["mcp", "api", "webhook", "integration", "plugin", "connector"]),
("workflow", ["workflow", "task", "agent", "command", "hook", "automation"]),
("context", ["context", "memory", "session", "history", "resume", "checkpoint"]),
("interface", ["ui", "ux", "sidebar", "dashboard", "panel", "menu"]),
("testing", ["test", "spec", "fixture", "assert", "coverage"]),
("infra", ["ci", "build", "deploy", "docker", "release", "dependency"]),
("docs", ["readme", "docs", "documentation", "changelog"]),
]
for name, words in buckets:
if any(word in text for word in words):
return name
return "implementation"
def attribution_kind(author_match, committer_match, coauthor_match):
kinds = []
if author_match:
kinds.append("author")
if committer_match:
kinds.append("committer")
if coauthor_match:
kinds.append("co-author")
return " + ".join(kinds)
def candidate(item, query):
commit = item.get("commit") or {}
message = commit.get("message") or ""
author = item_identity(item, "author")
committer = item_identity(item, "committer")
claude_coauthors = coauthors(message)
author_match = is_claude_identity(author)
committer_match = is_claude_identity(committer)
coauthor_match = bool(claude_coauthors)
if not (author_match or committer_match or coauthor_match):
return None
repository = item.get("repository") or {}
repo = repository.get("full_name") or ""
if not repo:
return None
timestamp = (committer.get("date") or author.get("date") or "")
# GitHub returns dates inside commit.author/commit.committer, not the flattened identities.
timestamp = (commit.get("committer") or {}).get("date") or (commit.get("author") or {}).get("date") or timestamp
official = repo.lower().startswith("anthropics/")
return {
"repo": repo,
"repo_url": repository.get("html_url") or f"https://github.com/{repo}",
"commit_sha": item.get("sha") or "",
"commit_url": item.get("html_url") or "",
"commit_message": message[:4000],
"subject": (message.splitlines() or ["(no commit message)"])[0][:240],
"author": author,
"committer": committer,
"timestamp": timestamp,
"attribution_kind": attribution_kind(author_match, committer_match, coauthor_match),
"coauthors": claude_coauthors,
"category": category(message),
"ecosystem": "official Anthropic" if official else "independent",
"official_anthropic": official,
"matched_queries": [query],
"_author_match": author_match,
"_committer_match": committer_match,
"_coauthor_match": coauthor_match,
}
def merge(existing, incoming):
for field in ("_author_match", "_committer_match", "_coauthor_match"):
existing[field] = existing[field] or incoming[field]
for query in incoming["matched_queries"]:
if query not in existing["matched_queries"]:
existing["matched_queries"].append(query)
if not existing["coauthors"] and incoming["coauthors"]:
existing["coauthors"] = incoming["coauthors"]
existing["attribution_kind"] = attribution_kind(
existing["_author_match"], existing["_committer_match"], existing["_coauthor_match"]
)
def rate_snapshot(headers):
if not headers:
return {}
return {
key: headers.get(value)
for key, value in (
("limit", "X-RateLimit-Limit"),
("remaining", "X-RateLimit-Remaining"),
("reset", "X-RateLimit-Reset"),
)
if headers.get(value) is not None
}
def build():
now = dt.datetime.now(dt.timezone.utc)
cutoff = (now - dt.timedelta(days=LOOKBACK_DAYS)).date().isoformat()
rows = {}
errors = []
query_log = []
last_rate = {}
filtered_future = 0
for term in SEARCHES:
today = now.date().isoformat()
query = f"{term} committer-date:>={cutoff} committer-date:<={today}"
url = "https://api.github.com/search/commits?" + urllib.parse.urlencode({
"q": query,
"sort": "committer-date",
"order": "desc",
"per_page": MAX_RESULTS_PER_QUERY,
})
try:
payload, headers = api(url)
last_rate = rate_snapshot(headers) or last_rate
items = payload.get("items", [])
query_log.append({"term": term, "query": query, "returned": len(items), "total_count": payload.get("total_count", 0)})
except GithubApiError as error:
last_rate = rate_snapshot(error.headers) or last_rate
query_log.append({"term": term, "query": query, "returned": 0, "error": str(error)})
errors.append({"term": term, "status": error.status, "error": str(error)})
continue
except Exception as error:
query_log.append({"term": term, "query": query, "returned": 0, "error": type(error).__name__})
errors.append({"term": term, "error": type(error).__name__})
continue
for item in items:
row = candidate(item, term)
if not row or not row["commit_sha"]:
continue
if parse_date(row["timestamp"]) > now:
filtered_future += 1
continue
key = (row["repo"].lower(), row["commit_sha"])
if key in rows:
merge(rows[key], row)
else:
rows[key] = row
candidates = list(rows.values())
candidates.sort(key=lambda row: (
1 if not row["official_anthropic"] else 0,
row["timestamp"],
row["repo"].lower(),
row["commit_sha"],
), reverse=True)
for row in candidates:
for field in ("_author_match", "_committer_match", "_coauthor_match"):
del row[field]
return {
"generated_at": now.isoformat(),
"source": "https://api.github.com/search/commits",
"rules": {
"lookback_days": LOOKBACK_DAYS,
"max_results_per_query": MAX_RESULTS_PER_QUERY,
"dedupe_key": "repo + commit_sha",
"searches": SEARCHES,
"official_definition": "repo full_name starts with anthropics/",
},
"api": {"rate_limit": last_rate, "queries": query_log},
"filters": {"future_timestamps_excluded": filtered_future},
"candidates": candidates,
"errors": errors,
}
def identity_label(identity):
return identity.get("login") or identity.get("name") or identity.get("email") or "unknown"
def card(row):
authors = f"author {identity_label(row['author'])} · committer {identity_label(row['committer'])}"
if row["coauthors"]:
authors += " · co-author " + ", ".join(x["name"] for x in row["coauthors"])
return f"""
{esc(row['subject'])}