feat: wire action-type CI into contributor profiles

- contribution_scores table stores per-PR CI with action type
- Profile endpoint returns action_ci alongside role-based ci_score
- Branch-name attribution: contrib/NAME/ PRs attributed to NAME
- Cameron now shows 0.32 CI + BELIEF MOVER badge from challenge
- Handle variant matching (cameron-s1 → cameron) for cross-system lookup
- Full historical backfill: 985 scores across 9 contributors

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
m3taversal 2026-04-21 11:29:01 +01:00
parent af027d3ced
commit 4101048cd0
2 changed files with 88 additions and 4 deletions

View file

@ -3,6 +3,7 @@
import sqlite3
import json
import os
import re
import subprocess
from datetime import datetime
@ -59,7 +60,8 @@ def _compute_badges(handle, row, domain_breakdown, conn):
badges.append("VETERAN")
challenger = row.get("challenger_count", 0) or 0
if challenger > 0:
challenge_ci = row.get("_challenge_count_from_scores", 0)
if challenger > 0 or challenge_ci > 0:
badges.append("BELIEF MOVER")
sourcer = row.get("sourcer_count", 0) or 0
@ -127,6 +129,43 @@ def _get_review_stats(handle, conn):
return stats
def _get_action_ci(handle, conn):
"""Get action-type CI from contribution_scores table.
Checks both exact handle and common variants (with/without suffix).
"""
h = handle.lower()
base = re.sub(r"[-_]\w+\d+$", "", h)
variants = list({h, base}) if base and base != h else [h]
try:
placeholders = ",".join("?" for _ in variants)
rows = conn.execute(f"""
SELECT event_type, SUM(ci_earned) as total, COUNT(*) as cnt
FROM contribution_scores
WHERE LOWER(contributor) IN ({placeholders})
GROUP BY event_type
""", variants).fetchall()
except Exception:
return None
if not rows:
return None
breakdown = {}
total = 0.0
for r in rows:
breakdown[r["event_type"]] = {
"count": r["cnt"],
"ci": round(r["total"], 4),
}
total += r["total"]
return {
"total": round(total, 4),
"breakdown": breakdown,
}
def _get_git_contributor(handle):
"""Fallback: check git log for contributors not in pipeline.db."""
try:
@ -185,9 +224,12 @@ def get_contributor_profile(handle):
return None
ci_score = _compute_ci(data)
action_ci = _get_action_ci(handle, conn)
domain_breakdown = _get_domain_breakdown(handle, conn)
timeline = _get_contribution_timeline(handle, conn)
review_stats = _get_review_stats(handle, conn)
if action_ci and "challenge" in action_ci.get("breakdown", {}):
data["_challenge_count_from_scores"] = action_ci["breakdown"]["challenge"]["count"]
badges = _compute_badges(handle, data, domain_breakdown, conn)
# For git-only contributors, build domain breakdown from git
@ -220,6 +262,7 @@ def get_contributor_profile(handle):
"handle": data.get("handle", handle),
"display_name": data.get("display_name"),
"ci_score": ci_score,
"action_ci": action_ci,
"hero_badge": hero_badge,
"badges": [{"name": b, **BADGE_DEFS.get(b, {})} for b in badges],
"joined": data.get("first_contribution"),

View file

@ -180,8 +180,16 @@ def _init_domain_counts():
DOMAIN_CLAIM_COUNTS[domain_dir.name] = count
def _normalize_contributor(submitted_by: str | None, agent: str | None) -> str:
"""Normalize contributor handle — strip @, map agent self-directed to agent name."""
def _normalize_contributor(submitted_by: str | None, agent: str | None, branch: str | None = None) -> str:
"""Normalize contributor handle — strip @, map agent self-directed to agent name.
For fork PRs (contrib/NAME/...), extract contributor from branch name.
"""
if branch and branch.startswith("contrib/"):
parts = branch.split("/")
if len(parts) >= 2 and parts[1]:
return parts[1].lower()
raw = submitted_by or agent or "unknown"
raw = raw.strip()
if raw.startswith("@"):
@ -334,7 +342,7 @@ def collect_and_score(hours: int = 24) -> dict:
score, breakdown = score_contribution(action_type, claim_file, domain)
contributor = _normalize_contributor(
pr.get("submitted_by"), pr.get("agent")
pr.get("submitted_by"), pr.get("agent"), pr.get("branch")
)
contributor_deltas[contributor] = contributor_deltas.get(contributor, 0) + score
domain_activity[domain] = domain_activity.get(domain, 0) + 1
@ -395,6 +403,38 @@ def update_contributors(digest: dict):
log.info("Updated %d contributor records", len(digest["contributor_deltas"]))
def save_scores_to_db(digest: dict):
"""Write individual contribution scores to contribution_scores table."""
conn = sqlite3.connect(str(DB_PATH))
try:
conn.execute("""CREATE TABLE IF NOT EXISTS contribution_scores (
id INTEGER PRIMARY KEY AUTOINCREMENT,
pr_number INTEGER UNIQUE,
contributor TEXT NOT NULL,
event_type TEXT CHECK(event_type IN ('create','enrich','challenge')),
ci_earned REAL,
claim_slug TEXT,
domain TEXT,
scored_at TEXT NOT NULL
)""")
for c in digest["contributions"]:
slug = (c.get("description") or "")[:200] or c.get("breakdown", {}).get("action", "")
conn.execute(
"""INSERT INTO contribution_scores (pr_number, contributor, event_type, ci_earned, claim_slug, domain, scored_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(pr_number) DO UPDATE SET
contributor = excluded.contributor,
ci_earned = excluded.ci_earned,
event_type = excluded.event_type,
scored_at = excluded.scored_at""",
(c["pr_number"], c["contributor"], c["action"], c["score"], slug, c["domain"], c["merged_at"]),
)
conn.commit()
log.info("Wrote %d contribution scores to DB", len(digest["contributions"]))
finally:
conn.close()
def save_digest_json(digest: dict):
"""Save latest digest as JSON for API consumption."""
DIGEST_JSON_PATH.parent.mkdir(parents=True, exist_ok=True)
@ -508,6 +548,7 @@ def main():
return
save_digest_json(digest)
save_scores_to_db(digest)
update_contributors(digest)
if not no_telegram: