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:
parent
af027d3ced
commit
4101048cd0
2 changed files with 88 additions and 4 deletions
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Reference in a new issue