From 4101048cd0424924001829be44009b732a83bc9f Mon Sep 17 00:00:00 2001 From: m3taversal Date: Tue, 21 Apr 2026 11:29:01 +0100 Subject: [PATCH] feat: wire action-type CI into contributor profiles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- diagnostics/contributor_profile_api.py | 45 +++++++++++++++++++++++- scripts/scoring_digest.py | 47 ++++++++++++++++++++++++-- 2 files changed, 88 insertions(+), 4 deletions(-) diff --git a/diagnostics/contributor_profile_api.py b/diagnostics/contributor_profile_api.py index ac80e23..9cd05fa 100644 --- a/diagnostics/contributor_profile_api.py +++ b/diagnostics/contributor_profile_api.py @@ -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"), diff --git a/scripts/scoring_digest.py b/scripts/scoring_digest.py index fdfe361..3ceb4cd 100644 --- a/scripts/scoring_digest.py +++ b/scripts/scoring_digest.py @@ -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: