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 sqlite3
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
@ -59,7 +60,8 @@ def _compute_badges(handle, row, domain_breakdown, conn):
|
||||||
badges.append("VETERAN")
|
badges.append("VETERAN")
|
||||||
|
|
||||||
challenger = row.get("challenger_count", 0) or 0
|
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")
|
badges.append("BELIEF MOVER")
|
||||||
|
|
||||||
sourcer = row.get("sourcer_count", 0) or 0
|
sourcer = row.get("sourcer_count", 0) or 0
|
||||||
|
|
@ -127,6 +129,43 @@ def _get_review_stats(handle, conn):
|
||||||
return stats
|
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):
|
def _get_git_contributor(handle):
|
||||||
"""Fallback: check git log for contributors not in pipeline.db."""
|
"""Fallback: check git log for contributors not in pipeline.db."""
|
||||||
try:
|
try:
|
||||||
|
|
@ -185,9 +224,12 @@ def get_contributor_profile(handle):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
ci_score = _compute_ci(data)
|
ci_score = _compute_ci(data)
|
||||||
|
action_ci = _get_action_ci(handle, conn)
|
||||||
domain_breakdown = _get_domain_breakdown(handle, conn)
|
domain_breakdown = _get_domain_breakdown(handle, conn)
|
||||||
timeline = _get_contribution_timeline(handle, conn)
|
timeline = _get_contribution_timeline(handle, conn)
|
||||||
review_stats = _get_review_stats(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)
|
badges = _compute_badges(handle, data, domain_breakdown, conn)
|
||||||
|
|
||||||
# For git-only contributors, build domain breakdown from git
|
# For git-only contributors, build domain breakdown from git
|
||||||
|
|
@ -220,6 +262,7 @@ def get_contributor_profile(handle):
|
||||||
"handle": data.get("handle", handle),
|
"handle": data.get("handle", handle),
|
||||||
"display_name": data.get("display_name"),
|
"display_name": data.get("display_name"),
|
||||||
"ci_score": ci_score,
|
"ci_score": ci_score,
|
||||||
|
"action_ci": action_ci,
|
||||||
"hero_badge": hero_badge,
|
"hero_badge": hero_badge,
|
||||||
"badges": [{"name": b, **BADGE_DEFS.get(b, {})} for b in badges],
|
"badges": [{"name": b, **BADGE_DEFS.get(b, {})} for b in badges],
|
||||||
"joined": data.get("first_contribution"),
|
"joined": data.get("first_contribution"),
|
||||||
|
|
|
||||||
|
|
@ -180,8 +180,16 @@ def _init_domain_counts():
|
||||||
DOMAIN_CLAIM_COUNTS[domain_dir.name] = count
|
DOMAIN_CLAIM_COUNTS[domain_dir.name] = count
|
||||||
|
|
||||||
|
|
||||||
def _normalize_contributor(submitted_by: str | None, agent: str | None) -> str:
|
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."""
|
"""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 = submitted_by or agent or "unknown"
|
||||||
raw = raw.strip()
|
raw = raw.strip()
|
||||||
if raw.startswith("@"):
|
if raw.startswith("@"):
|
||||||
|
|
@ -334,7 +342,7 @@ def collect_and_score(hours: int = 24) -> dict:
|
||||||
score, breakdown = score_contribution(action_type, claim_file, domain)
|
score, breakdown = score_contribution(action_type, claim_file, domain)
|
||||||
|
|
||||||
contributor = _normalize_contributor(
|
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
|
contributor_deltas[contributor] = contributor_deltas.get(contributor, 0) + score
|
||||||
domain_activity[domain] = domain_activity.get(domain, 0) + 1
|
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"]))
|
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):
|
def save_digest_json(digest: dict):
|
||||||
"""Save latest digest as JSON for API consumption."""
|
"""Save latest digest as JSON for API consumption."""
|
||||||
DIGEST_JSON_PATH.parent.mkdir(parents=True, exist_ok=True)
|
DIGEST_JSON_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
@ -508,6 +548,7 @@ def main():
|
||||||
return
|
return
|
||||||
|
|
||||||
save_digest_json(digest)
|
save_digest_json(digest)
|
||||||
|
save_scores_to_db(digest)
|
||||||
update_contributors(digest)
|
update_contributors(digest)
|
||||||
|
|
||||||
if not no_telegram:
|
if not no_telegram:
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue