ship: add contributor attribution tracing to PR lifecycle
- Migration v19: submitted_by column on prs + sources tables - extract.py: propagates proposed_by from source frontmatter → PR record - merge.py: sets submitted_by from Forgejo author for human PRs - dashboard_prs.py: redesigned with Contributor column, improved claim visibility in expanded rows, cost estimates, evaluator chain display - dashboard_routes.py: submitted_by + source_path in pr-lifecycle API - backfill_submitted_by.py: one-time backfill (1525/1777 PRs matched) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
adbe3bd911
commit
9925576c13
6 changed files with 378 additions and 113 deletions
138
ops/diagnostics/backfill_submitted_by.py
Normal file
138
ops/diagnostics/backfill_submitted_by.py
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""One-time backfill: populate submitted_by on prs table from source archive files.
|
||||||
|
|
||||||
|
Matches PRs to sources via branch name slug → source filename.
|
||||||
|
Reads proposed_by and intake_tier from source frontmatter.
|
||||||
|
|
||||||
|
Run: python3 backfill_submitted_by.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sqlite3
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
DB_PATH = os.environ.get("DB_PATH", "/opt/teleo-eval/pipeline/pipeline.db")
|
||||||
|
ARCHIVE_DIR = Path(os.environ.get("ARCHIVE_DIR", "/opt/teleo-eval/workspaces/main/inbox/archive"))
|
||||||
|
|
||||||
|
|
||||||
|
def parse_frontmatter(path: Path) -> dict:
|
||||||
|
"""Parse YAML-like frontmatter from a markdown file."""
|
||||||
|
text = path.read_text(encoding="utf-8", errors="replace")
|
||||||
|
if not text.startswith("---"):
|
||||||
|
return {}
|
||||||
|
end = text.find("---", 3)
|
||||||
|
if end == -1:
|
||||||
|
return {}
|
||||||
|
fm = {}
|
||||||
|
for line in text[3:end].strip().split("\n"):
|
||||||
|
line = line.strip()
|
||||||
|
if not line or ":" not in line:
|
||||||
|
continue
|
||||||
|
key, _, val = line.partition(":")
|
||||||
|
key = key.strip()
|
||||||
|
val = val.strip().strip('"').strip("'")
|
||||||
|
if val.lower() == "null" or val == "":
|
||||||
|
val = None
|
||||||
|
fm[key] = val
|
||||||
|
return fm
|
||||||
|
|
||||||
|
|
||||||
|
def slug_from_branch(branch: str) -> str:
|
||||||
|
"""Extract source slug from branch name like 'extract/2026-04-06-slug-hash'."""
|
||||||
|
if "/" in branch:
|
||||||
|
branch = branch.split("/", 1)[1]
|
||||||
|
# Strip trailing hex hash (e.g., -3e68, -a6af)
|
||||||
|
branch = re.sub(r"-[0-9a-f]{4}$", "", branch)
|
||||||
|
return branch
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
conn = sqlite3.connect(DB_PATH, timeout=30)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
|
||||||
|
# Build source index: filename stem → frontmatter
|
||||||
|
source_index = {}
|
||||||
|
if ARCHIVE_DIR.exists():
|
||||||
|
for f in ARCHIVE_DIR.glob("*.md"):
|
||||||
|
fm = parse_frontmatter(f)
|
||||||
|
source_index[f.stem] = fm
|
||||||
|
print(f"Indexed {len(source_index)} source files from {ARCHIVE_DIR}")
|
||||||
|
|
||||||
|
# Get all PRs without submitted_by
|
||||||
|
prs = conn.execute(
|
||||||
|
"SELECT number, branch FROM prs WHERE submitted_by IS NULL AND branch IS NOT NULL"
|
||||||
|
).fetchall()
|
||||||
|
print(f"Found {len(prs)} PRs without submitted_by")
|
||||||
|
|
||||||
|
updated = 0
|
||||||
|
for pr in prs:
|
||||||
|
branch = pr["branch"]
|
||||||
|
slug = slug_from_branch(branch)
|
||||||
|
|
||||||
|
# Try to match slug to a source file
|
||||||
|
fm = source_index.get(slug)
|
||||||
|
if not fm:
|
||||||
|
# Try partial matching: slug might be a substring of the source filename
|
||||||
|
for stem, sfm in source_index.items():
|
||||||
|
if slug in stem or stem in slug:
|
||||||
|
fm = sfm
|
||||||
|
break
|
||||||
|
|
||||||
|
if fm:
|
||||||
|
proposed_by = fm.get("proposed_by")
|
||||||
|
intake_tier = fm.get("intake_tier")
|
||||||
|
|
||||||
|
if proposed_by:
|
||||||
|
contributor = proposed_by.strip().strip('"').strip("'")
|
||||||
|
elif intake_tier == "research-task":
|
||||||
|
# Derive agent from branch prefix
|
||||||
|
prefix = branch.split("/", 1)[0] if "/" in branch else "unknown"
|
||||||
|
agent_map = {
|
||||||
|
"extract": "pipeline", "ingestion": "pipeline",
|
||||||
|
"rio": "rio", "theseus": "theseus", "vida": "vida",
|
||||||
|
"clay": "clay", "astra": "astra", "leo": "leo",
|
||||||
|
"reweave": "pipeline",
|
||||||
|
}
|
||||||
|
agent = agent_map.get(prefix, prefix)
|
||||||
|
contributor = f"{agent} (self-directed)"
|
||||||
|
elif intake_tier == "directed":
|
||||||
|
contributor = "directed (unknown)"
|
||||||
|
else:
|
||||||
|
contributor = None
|
||||||
|
|
||||||
|
if contributor:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE prs SET submitted_by = ?, source_path = ? WHERE number = ?",
|
||||||
|
(contributor, f"inbox/archive/{slug}.md", pr["number"]),
|
||||||
|
)
|
||||||
|
updated += 1
|
||||||
|
else:
|
||||||
|
# For extract/ branches, mark as pipeline self-directed
|
||||||
|
if branch.startswith("extract/") or branch.startswith("ingestion/"):
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE prs SET submitted_by = 'pipeline (self-directed)' WHERE number = ?",
|
||||||
|
(pr["number"],),
|
||||||
|
)
|
||||||
|
updated += 1
|
||||||
|
elif branch.startswith(("rio/", "theseus/", "vida/", "clay/", "astra/", "leo/")):
|
||||||
|
agent = branch.split("/", 1)[0]
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE prs SET submitted_by = ? WHERE number = ?",
|
||||||
|
(f"{agent} (self-directed)", pr["number"]),
|
||||||
|
)
|
||||||
|
updated += 1
|
||||||
|
elif branch.startswith("reweave/"):
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE prs SET submitted_by = 'pipeline (reweave)' WHERE number = ?",
|
||||||
|
(pr["number"],),
|
||||||
|
)
|
||||||
|
updated += 1
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
print(f"Updated {updated}/{len(prs)} PRs with submitted_by")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
"""PR Lifecycle dashboard — single-page view of every PR through the pipeline.
|
"""PR Lifecycle dashboard — single-page view of every PR through the pipeline.
|
||||||
|
|
||||||
Sortable table: PR#, summary, agent, domain, outcome, TTM, date.
|
Sortable table: PR#, summary, claims, domain, contributor, outcome, evals, evaluator, cost, date.
|
||||||
Click any row to expand the full trace (triage reasoning, review text, cascade).
|
Click any row to expand: claim titles, eval chain, timeline, reviews, issues.
|
||||||
Hero cards: total PRs, merge rate, median TTM, median eval rounds.
|
Hero cards: total PRs, merge rate, total claims, est. cost.
|
||||||
|
|
||||||
Data sources: prs table, audit_log (eval rounds), review_records.
|
Data sources: prs table, audit_log (eval rounds), review_records.
|
||||||
Owner: Ship
|
Owner: Ship
|
||||||
|
|
@ -14,19 +14,23 @@ from shared_ui import render_page
|
||||||
|
|
||||||
|
|
||||||
EXTRA_CSS = """
|
EXTRA_CSS = """
|
||||||
|
.content-wrapper { max-width: 1600px !important; }
|
||||||
.filters { display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 16px; }
|
.filters { display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 16px; }
|
||||||
.filters select, .filters input {
|
.filters select, .filters input {
|
||||||
background: #161b22; color: #c9d1d9; border: 1px solid #30363d;
|
background: #161b22; color: #c9d1d9; border: 1px solid #30363d;
|
||||||
border-radius: 6px; padding: 6px 10px; font-size: 12px; }
|
border-radius: 6px; padding: 6px 10px; font-size: 12px; }
|
||||||
.filters select:focus, .filters input:focus { border-color: #58a6ff; outline: none; }
|
.filters select:focus, .filters input:focus { border-color: #58a6ff; outline: none; }
|
||||||
.pr-table { width: 100%; border-collapse: collapse; font-size: 13px; table-layout: fixed; }
|
.pr-table { width: 100%; border-collapse: collapse; font-size: 13px; table-layout: fixed; }
|
||||||
.pr-table th:nth-child(1) { width: 60px; } /* PR# */
|
.pr-table th:nth-child(1) { width: 50px; } /* PR# */
|
||||||
.pr-table th:nth-child(2) { width: 38%; } /* Summary */
|
.pr-table th:nth-child(2) { width: 28%; } /* Summary */
|
||||||
.pr-table th:nth-child(3) { width: 10%; } /* Agent */
|
.pr-table th:nth-child(3) { width: 50px; } /* Claims */
|
||||||
.pr-table th:nth-child(4) { width: 14%; } /* Domain */
|
.pr-table th:nth-child(4) { width: 11%; } /* Domain */
|
||||||
.pr-table th:nth-child(5) { width: 10%; } /* Outcome */
|
.pr-table th:nth-child(5) { width: 10%; } /* Contributor */
|
||||||
.pr-table th:nth-child(6) { width: 7%; } /* TTM */
|
.pr-table th:nth-child(6) { width: 10%; } /* Outcome */
|
||||||
.pr-table th:nth-child(7) { width: 10%; } /* Date */
|
.pr-table th:nth-child(7) { width: 44px; } /* Evals */
|
||||||
|
.pr-table th:nth-child(8) { width: 12%; } /* Evaluator */
|
||||||
|
.pr-table th:nth-child(9) { width: 60px; } /* Cost */
|
||||||
|
.pr-table th:nth-child(10) { width: 80px; } /* Date */
|
||||||
.pr-table td { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 8px 6px; }
|
.pr-table td { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 8px 6px; }
|
||||||
.pr-table td:nth-child(2) { white-space: normal; overflow: visible; line-height: 1.4; }
|
.pr-table td:nth-child(2) { white-space: normal; overflow: visible; line-height: 1.4; }
|
||||||
.pr-table th { cursor: pointer; user-select: none; position: relative; padding: 8px 18px 8px 6px; }
|
.pr-table th { cursor: pointer; user-select: none; position: relative; padding: 8px 18px 8px 6px; }
|
||||||
|
|
@ -46,11 +50,23 @@ EXTRA_CSS = """
|
||||||
.pr-table td .summary-text { font-size: 12px; color: #c9d1d9; }
|
.pr-table td .summary-text { font-size: 12px; color: #c9d1d9; }
|
||||||
.pr-table td .review-snippet { font-size: 11px; color: #f85149; margin-top: 2px; opacity: 0.8; }
|
.pr-table td .review-snippet { font-size: 11px; color: #f85149; margin-top: 2px; opacity: 0.8; }
|
||||||
.pr-table td .model-tag { font-size: 10px; color: #6e7681; background: #161b22; border-radius: 3px; padding: 1px 4px; }
|
.pr-table td .model-tag { font-size: 10px; color: #6e7681; background: #161b22; border-radius: 3px; padding: 1px 4px; }
|
||||||
|
.pr-table td .contributor-tag { font-size: 11px; color: #d2a8ff; }
|
||||||
|
.pr-table td .contributor-self { font-size: 11px; color: #6e7681; font-style: italic; }
|
||||||
.pr-table td .expand-chevron { display: inline-block; width: 12px; color: #484f58; font-size: 10px; transition: transform 0.2s; }
|
.pr-table td .expand-chevron { display: inline-block; width: 12px; color: #484f58; font-size: 10px; transition: transform 0.2s; }
|
||||||
.pr-table tr.expanded .expand-chevron { transform: rotate(90deg); color: #58a6ff; }
|
.pr-table tr.expanded .expand-chevron { transform: rotate(90deg); color: #58a6ff; }
|
||||||
.trace-panel { background: #0d1117; border: 1px solid #30363d; border-radius: 8px;
|
.trace-panel { background: #0d1117; border: 1px solid #30363d; border-radius: 8px;
|
||||||
padding: 16px; margin: 4px 0 8px 0; font-size: 12px; display: none; }
|
padding: 16px; margin: 4px 0 8px 0; font-size: 12px; display: none; }
|
||||||
.trace-panel.open { display: block; }
|
.trace-panel.open { display: block; }
|
||||||
|
.trace-panel h4 { color: #58a6ff; font-size: 12px; margin: 12px 0 6px 0; }
|
||||||
|
.trace-panel h4:first-child { margin-top: 0; }
|
||||||
|
.claim-list { list-style: none; padding: 0; margin: 0; }
|
||||||
|
.claim-list li { padding: 4px 0 4px 16px; border-left: 2px solid #238636; color: #c9d1d9; font-size: 12px; line-height: 1.5; }
|
||||||
|
.claim-list li .claim-confidence { font-size: 10px; color: #8b949e; margin-left: 6px; }
|
||||||
|
.issues-box { background: #1c1210; border: 1px solid #f8514933; border-radius: 6px;
|
||||||
|
padding: 8px 12px; margin: 4px 0; font-size: 12px; color: #f85149; }
|
||||||
|
.eval-chain { background: #161b22; border-radius: 6px; padding: 8px 12px; margin: 4px 0; font-size: 12px; }
|
||||||
|
.eval-chain .chain-step { display: inline-block; margin-right: 6px; }
|
||||||
|
.eval-chain .chain-arrow { color: #484f58; margin: 0 4px; }
|
||||||
.trace-timeline { list-style: none; padding: 0; }
|
.trace-timeline { list-style: none; padding: 0; }
|
||||||
.trace-timeline li { padding: 4px 0; border-left: 2px solid #30363d; padding-left: 12px; margin-left: 8px; }
|
.trace-timeline li { padding: 4px 0; border-left: 2px solid #30363d; padding-left: 12px; margin-left: 8px; }
|
||||||
.trace-timeline li .ts { color: #484f58; font-size: 11px; }
|
.trace-timeline li .ts { color: #484f58; font-size: 11px; }
|
||||||
|
|
@ -66,9 +82,6 @@ EXTRA_CSS = """
|
||||||
.pagination button:hover { border-color: #58a6ff; }
|
.pagination button:hover { border-color: #58a6ff; }
|
||||||
.pagination button:disabled { opacity: 0.4; cursor: default; }
|
.pagination button:disabled { opacity: 0.4; cursor: default; }
|
||||||
.pagination .page-info { color: #8b949e; font-size: 12px; }
|
.pagination .page-info { color: #8b949e; font-size: 12px; }
|
||||||
.stat-row { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 4px; }
|
|
||||||
.stat-row .mini-stat { font-size: 11px; color: #8b949e; }
|
|
||||||
.stat-row .mini-stat span { color: #c9d1d9; font-weight: 600; }
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -80,15 +93,14 @@ def render_prs_page(now: datetime) -> str:
|
||||||
<div class="grid" id="hero-cards">
|
<div class="grid" id="hero-cards">
|
||||||
<div class="card"><div class="label">Total PRs</div><div class="value blue" id="kpi-total">--</div><div class="detail" id="kpi-total-detail"></div></div>
|
<div class="card"><div class="label">Total PRs</div><div class="value blue" id="kpi-total">--</div><div class="detail" id="kpi-total-detail"></div></div>
|
||||||
<div class="card"><div class="label">Merge Rate</div><div class="value green" id="kpi-merge-rate">--</div><div class="detail" id="kpi-merge-detail"></div></div>
|
<div class="card"><div class="label">Merge Rate</div><div class="value green" id="kpi-merge-rate">--</div><div class="detail" id="kpi-merge-detail"></div></div>
|
||||||
<div class="card"><div class="label">Median Time-to-Merge</div><div class="value" id="kpi-ttm">--</div><div class="detail" id="kpi-ttm-detail"></div></div>
|
|
||||||
<div class="card"><div class="label">Median Eval Rounds</div><div class="value" id="kpi-rounds">--</div><div class="detail" id="kpi-rounds-detail"></div></div>
|
|
||||||
<div class="card"><div class="label">Total Claims</div><div class="value blue" id="kpi-claims">--</div><div class="detail" id="kpi-claims-detail"></div></div>
|
<div class="card"><div class="label">Total Claims</div><div class="value blue" id="kpi-claims">--</div><div class="detail" id="kpi-claims-detail"></div></div>
|
||||||
|
<div class="card"><div class="label">Est. Cost</div><div class="value" id="kpi-cost">--</div><div class="detail" id="kpi-cost-detail"></div></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Filters -->
|
<!-- Filters -->
|
||||||
<div class="filters">
|
<div class="filters">
|
||||||
<select id="filter-domain"><option value="">All Domains</option></select>
|
<select id="filter-domain"><option value="">All Domains</option></select>
|
||||||
<select id="filter-agent"><option value="">All Agents</option></select>
|
<select id="filter-contributor"><option value="">All Contributors</option></select>
|
||||||
<select id="filter-outcome">
|
<select id="filter-outcome">
|
||||||
<option value="">All Outcomes</option>
|
<option value="">All Outcomes</option>
|
||||||
<option value="merged">Merged</option>
|
<option value="merged">Merged</option>
|
||||||
|
|
@ -116,10 +128,13 @@ def render_prs_page(now: datetime) -> str:
|
||||||
<tr>
|
<tr>
|
||||||
<th data-col="number">PR# <span class="sort-arrow">▲</span></th>
|
<th data-col="number">PR# <span class="sort-arrow">▲</span></th>
|
||||||
<th data-col="summary">Summary <span class="sort-arrow">▲</span></th>
|
<th data-col="summary">Summary <span class="sort-arrow">▲</span></th>
|
||||||
<th data-col="agent">Agent <span class="sort-arrow">▲</span></th>
|
<th data-col="claims_count">Claims <span class="sort-arrow">▲</span></th>
|
||||||
<th data-col="domain">Domain <span class="sort-arrow">▲</span></th>
|
<th data-col="domain">Domain <span class="sort-arrow">▲</span></th>
|
||||||
|
<th data-col="submitted_by">Contributor <span class="sort-arrow">▲</span></th>
|
||||||
<th data-col="status">Outcome <span class="sort-arrow">▲</span></th>
|
<th data-col="status">Outcome <span class="sort-arrow">▲</span></th>
|
||||||
<th data-col="ttm_minutes">TTM <span class="sort-arrow">▲</span></th>
|
<th data-col="eval_rounds">Evals <span class="sort-arrow">▲</span></th>
|
||||||
|
<th data-col="evaluator_label">Evaluator <span class="sort-arrow">▲</span></th>
|
||||||
|
<th data-col="est_cost">Cost <span class="sort-arrow">▲</span></th>
|
||||||
<th data-col="created_at">Date <span class="sort-arrow">▲</span></th>
|
<th data-col="created_at">Date <span class="sort-arrow">▲</span></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|
@ -135,46 +150,71 @@ def render_prs_page(now: datetime) -> str:
|
||||||
</div>
|
</div>
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Use single-quoted JS strings throughout to avoid Python/HTML escaping issues
|
|
||||||
scripts = """<script>
|
scripts = """<script>
|
||||||
const PAGE_SIZE = 50;
|
var PAGE_SIZE = 50;
|
||||||
const FORGEJO = 'https://git.livingip.xyz/teleo/teleo-codex/pulls/';
|
var FORGEJO = 'https://git.livingip.xyz/teleo/teleo-codex/pulls/';
|
||||||
let allData = [];
|
var allData = [];
|
||||||
let filtered = [];
|
var filtered = [];
|
||||||
let sortCol = 'number';
|
var sortCol = 'number';
|
||||||
let sortAsc = false;
|
var sortAsc = false;
|
||||||
let page = 0;
|
var page = 0;
|
||||||
let expandedPr = null;
|
var expandedPr = null;
|
||||||
|
|
||||||
|
// Tier-based cost estimates (per eval round)
|
||||||
|
var TIER_COSTS = {
|
||||||
|
'DEEP': 0.145, // Haiku triage + Gemini Flash domain + Opus Leo
|
||||||
|
'STANDARD': 0.043, // Haiku triage + Gemini Flash domain + Sonnet Leo
|
||||||
|
'LIGHT': 0.027 // Haiku triage + Gemini Flash domain only
|
||||||
|
};
|
||||||
|
|
||||||
|
function estimateCost(pr) {
|
||||||
|
var tier = pr.tier || 'STANDARD';
|
||||||
|
var rounds = pr.eval_rounds || 1;
|
||||||
|
var baseCost = TIER_COSTS[tier] || TIER_COSTS['STANDARD'];
|
||||||
|
return baseCost * rounds;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtCost(val) {
|
||||||
|
if (val == null || val === 0) return '--';
|
||||||
|
return '$' + val.toFixed(3);
|
||||||
|
}
|
||||||
|
|
||||||
function loadData() {
|
function loadData() {
|
||||||
var days = document.getElementById('filter-days').value;
|
var days = document.getElementById('filter-days').value;
|
||||||
var url = '/api/pr-lifecycle' + (days !== '0' ? '?days=' + days : '?days=9999');
|
var url = '/api/pr-lifecycle' + (days !== '0' ? '?days=' + days : '?days=9999');
|
||||||
fetch(url).then(function(r) { return r.json(); }).then(function(data) {
|
fetch(url).then(function(r) { return r.json(); }).then(function(data) {
|
||||||
allData = data.prs || [];
|
allData = data.prs || [];
|
||||||
|
// Compute derived fields
|
||||||
|
allData.forEach(function(p) {
|
||||||
|
p.est_cost = estimateCost(p);
|
||||||
|
// Evaluator label for sorting
|
||||||
|
p.evaluator_label = p.domain_agent || p.agent || '--';
|
||||||
|
});
|
||||||
populateFilters(allData);
|
populateFilters(allData);
|
||||||
updateKPIs(data);
|
updateKPIs(data);
|
||||||
applyFilters();
|
applyFilters();
|
||||||
}).catch(function() {
|
}).catch(function() {
|
||||||
document.getElementById('pr-tbody').innerHTML =
|
document.getElementById('pr-tbody').innerHTML =
|
||||||
'<tr><td colspan="7" style="text-align:center;color:#f85149;">Failed to load data</td></tr>';
|
'<tr><td colspan="10" style="text-align:center;color:#f85149;">Failed to load data</td></tr>';
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function populateFilters(prs) {
|
function populateFilters(prs) {
|
||||||
var domains = [], agents = [], seenD = {}, seenA = {};
|
var domains = [], contribs = [], seenD = {}, seenC = {};
|
||||||
prs.forEach(function(p) {
|
prs.forEach(function(p) {
|
||||||
if (p.domain && !seenD[p.domain]) { seenD[p.domain] = 1; domains.push(p.domain); }
|
if (p.domain && !seenD[p.domain]) { seenD[p.domain] = 1; domains.push(p.domain); }
|
||||||
if (p.agent && !seenA[p.agent]) { seenA[p.agent] = 1; agents.push(p.agent); }
|
var c = p.submitted_by || 'unknown';
|
||||||
|
if (!seenC[c]) { seenC[c] = 1; contribs.push(c); }
|
||||||
});
|
});
|
||||||
domains.sort(); agents.sort();
|
domains.sort(); contribs.sort();
|
||||||
var domSel = document.getElementById('filter-domain');
|
var domSel = document.getElementById('filter-domain');
|
||||||
var agSel = document.getElementById('filter-agent');
|
var conSel = document.getElementById('filter-contributor');
|
||||||
var curDom = domSel.value, curAg = agSel.value;
|
var curDom = domSel.value, curCon = conSel.value;
|
||||||
domSel.innerHTML = '<option value="">All Domains</option>' +
|
domSel.innerHTML = '<option value="">All Domains</option>' +
|
||||||
domains.map(function(d) { return '<option value="' + esc(d) + '">' + esc(d) + '</option>'; }).join('');
|
domains.map(function(d) { return '<option value="' + esc(d) + '">' + esc(d) + '</option>'; }).join('');
|
||||||
agSel.innerHTML = '<option value="">All Agents</option>' +
|
conSel.innerHTML = '<option value="">All Contributors</option>' +
|
||||||
agents.map(function(a) { return '<option value="' + esc(a) + '">' + esc(a) + '</option>'; }).join('');
|
contribs.map(function(c) { return '<option value="' + esc(c) + '">' + esc(c) + '</option>'; }).join('');
|
||||||
domSel.value = curDom; agSel.value = curAg;
|
domSel.value = curDom; conSel.value = curCon;
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateKPIs(data) {
|
function updateKPIs(data) {
|
||||||
|
|
@ -186,40 +226,29 @@ def render_prs_page(now: datetime) -> str:
|
||||||
document.getElementById('kpi-merge-rate').textContent = fmtPct(rate);
|
document.getElementById('kpi-merge-rate').textContent = fmtPct(rate);
|
||||||
document.getElementById('kpi-merge-detail').textContent = fmtNum(data.open) + ' open';
|
document.getElementById('kpi-merge-detail').textContent = fmtNum(data.open) + ' open';
|
||||||
|
|
||||||
document.getElementById('kpi-ttm').textContent =
|
var totalClaims = 0, mergedClaims = 0, totalCost = 0;
|
||||||
data.median_ttm != null ? fmtDuration(data.median_ttm) : '--';
|
|
||||||
document.getElementById('kpi-ttm-detail').textContent =
|
|
||||||
data.p90_ttm != null ? 'p90: ' + fmtDuration(data.p90_ttm) : '';
|
|
||||||
|
|
||||||
document.getElementById('kpi-rounds').textContent =
|
|
||||||
data.median_rounds != null ? data.median_rounds.toFixed(1) : '--';
|
|
||||||
document.getElementById('kpi-rounds-detail').textContent =
|
|
||||||
data.max_rounds != null ? 'max: ' + data.max_rounds : '';
|
|
||||||
|
|
||||||
var totalClaims = 0, mergedClaims = 0;
|
|
||||||
(data.prs || []).forEach(function(p) {
|
(data.prs || []).forEach(function(p) {
|
||||||
totalClaims += (p.claims_count || 1);
|
totalClaims += (p.claims_count || 1);
|
||||||
if (p.status === 'merged') mergedClaims += (p.claims_count || 1);
|
if (p.status === 'merged') mergedClaims += (p.claims_count || 1);
|
||||||
|
totalCost += estimateCost(p);
|
||||||
});
|
});
|
||||||
document.getElementById('kpi-claims').textContent = fmtNum(totalClaims);
|
document.getElementById('kpi-claims').textContent = fmtNum(totalClaims);
|
||||||
document.getElementById('kpi-claims-detail').textContent = fmtNum(mergedClaims) + ' merged';
|
document.getElementById('kpi-claims-detail').textContent = fmtNum(mergedClaims) + ' merged';
|
||||||
}
|
|
||||||
|
|
||||||
function fmtDuration(mins) {
|
document.getElementById('kpi-cost').textContent = '$' + totalCost.toFixed(2);
|
||||||
if (mins < 60) return mins.toFixed(0) + 'm';
|
var perClaim = totalClaims > 0 ? totalCost / totalClaims : 0;
|
||||||
if (mins < 1440) return (mins / 60).toFixed(1) + 'h';
|
document.getElementById('kpi-cost-detail').textContent = '$' + perClaim.toFixed(3) + '/claim';
|
||||||
return (mins / 1440).toFixed(1) + 'd';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyFilters() {
|
function applyFilters() {
|
||||||
var dom = document.getElementById('filter-domain').value;
|
var dom = document.getElementById('filter-domain').value;
|
||||||
var ag = document.getElementById('filter-agent').value;
|
var con = document.getElementById('filter-contributor').value;
|
||||||
var out = document.getElementById('filter-outcome').value;
|
var out = document.getElementById('filter-outcome').value;
|
||||||
var tier = document.getElementById('filter-tier').value;
|
var tier = document.getElementById('filter-tier').value;
|
||||||
|
|
||||||
filtered = allData.filter(function(p) {
|
filtered = allData.filter(function(p) {
|
||||||
if (dom && p.domain !== dom) return false;
|
if (dom && p.domain !== dom) return false;
|
||||||
if (ag && p.agent !== ag) return false;
|
if (con && (p.submitted_by || 'unknown') !== con) return false;
|
||||||
if (out && p.status !== out) return false;
|
if (out && p.status !== out) return false;
|
||||||
if (tier && p.tier !== tier) return false;
|
if (tier && p.tier !== tier) return false;
|
||||||
return true;
|
return true;
|
||||||
|
|
@ -256,7 +285,7 @@ def render_prs_page(now: datetime) -> str:
|
||||||
var totalPages = Math.ceil(filtered.length / PAGE_SIZE);
|
var totalPages = Math.ceil(filtered.length / PAGE_SIZE);
|
||||||
|
|
||||||
if (slice.length === 0) {
|
if (slice.length === 0) {
|
||||||
tbody.innerHTML = '<tr><td colspan="7" style="text-align:center;color:#8b949e;">No PRs match filters</td></tr>';
|
tbody.innerHTML = '<tr><td colspan="10" style="text-align:center;color:#8b949e;">No PRs match filters</td></tr>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -266,48 +295,51 @@ def render_prs_page(now: datetime) -> str:
|
||||||
p.status === 'closed' ? 'outcome-closed' : 'outcome-open';
|
p.status === 'closed' ? 'outcome-closed' : 'outcome-open';
|
||||||
var tierClass = (p.tier || '').toLowerCase() === 'deep' ? 'tier-deep' :
|
var tierClass = (p.tier || '').toLowerCase() === 'deep' ? 'tier-deep' :
|
||||||
(p.tier || '').toLowerCase() === 'standard' ? 'tier-standard' : 'tier-light';
|
(p.tier || '').toLowerCase() === 'standard' ? 'tier-standard' : 'tier-light';
|
||||||
var ttm = p.ttm_minutes != null ? fmtDuration(p.ttm_minutes) : '--';
|
|
||||||
var date = p.created_at ? p.created_at.substring(0, 10) : '--';
|
var date = p.created_at ? p.created_at.substring(0, 10) : '--';
|
||||||
var agent = p.agent || '--';
|
|
||||||
|
|
||||||
// Summary: first claim title from description
|
// Summary: first claim title
|
||||||
var summary = '--';
|
var summary = p.summary || '--';
|
||||||
if (p.summary) {
|
|
||||||
summary = p.summary;
|
|
||||||
} else if (p.description) {
|
|
||||||
var parts = p.description.split('|');
|
|
||||||
summary = truncate(parts[0].trim(), 80);
|
|
||||||
if (parts.length > 1) summary += ' (+' + (parts.length - 1) + ' more)';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Outcome label with eval rounds
|
// Outcome with tier badge
|
||||||
var outcomeLabel = esc(p.status || '--');
|
|
||||||
if (p.eval_rounds > 1) {
|
|
||||||
outcomeLabel += ' <span style="color:#6e7681;font-size:11px;">(' + p.eval_rounds + ' evals)</span>';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Review snippet for closed/changes PRs
|
|
||||||
var reviewSnippet = '';
|
|
||||||
if (p.status === 'closed' && p.review_snippet) {
|
|
||||||
reviewSnippet = '<div class="review-snippet">' + esc(truncate(p.review_snippet, 120)) + '</div>';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tier badge inline with outcome
|
|
||||||
var tierBadge = p.tier ? ' <span class="' + tierClass + '" style="font-size:10px;">' + esc(p.tier) + '</span>' : '';
|
var tierBadge = p.tier ? ' <span class="' + tierClass + '" style="font-size:10px;">' + esc(p.tier) + '</span>' : '';
|
||||||
|
|
||||||
|
// Review snippet for issues
|
||||||
|
var reviewSnippet = '';
|
||||||
|
if (p.review_snippet) {
|
||||||
|
reviewSnippet = '<div class="review-snippet">' + esc(truncate(p.review_snippet, 100)) + '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Contributor display
|
||||||
|
var contributor = p.submitted_by || '--';
|
||||||
|
var contribClass = 'contributor-tag';
|
||||||
|
if (contributor.indexOf('self-directed') >= 0 || contributor === 'unknown') {
|
||||||
|
contribClass = 'contributor-self';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Evaluator: domain agent + model tags
|
||||||
|
var evaluator = '';
|
||||||
|
if (p.domain_agent) {
|
||||||
|
evaluator = esc(p.domain_agent);
|
||||||
|
} else if (p.agent && p.agent !== 'pipeline') {
|
||||||
|
evaluator = esc(p.agent);
|
||||||
|
}
|
||||||
|
|
||||||
rows.push(
|
rows.push(
|
||||||
'<tr data-pr="' + p.number + '">' +
|
'<tr data-pr="' + p.number + '">' +
|
||||||
'<td><span class="expand-chevron">▶</span> ' +
|
'<td><span class="expand-chevron">▶</span> ' +
|
||||||
'<a class="pr-link" href="' + FORGEJO + p.number + '" target="_blank" rel="noopener" onclick="event.stopPropagation();">#' + p.number + '</a></td>' +
|
'<a class="pr-link" href="' + FORGEJO + p.number + '" target="_blank" rel="noopener" onclick="event.stopPropagation();">#' + p.number + '</a></td>' +
|
||||||
'<td style="white-space:normal;"><span class="summary-text">' + esc(summary) + '</span>' + reviewSnippet + '</td>' +
|
'<td style="white-space:normal;"><span class="summary-text">' + esc(summary) + '</span>' + reviewSnippet + '</td>' +
|
||||||
'<td>' + esc(agent) + '</td>' +
|
'<td style="text-align:center;">' + (p.claims_count || 1) + '</td>' +
|
||||||
'<td>' + esc(p.domain || '--') + '</td>' +
|
'<td>' + esc(p.domain || '--') + '</td>' +
|
||||||
'<td class="' + outClass + '">' + outcomeLabel + tierBadge + '</td>' +
|
'<td><span class="' + contribClass + '">' + esc(truncate(contributor, 20)) + '</span></td>' +
|
||||||
'<td>' + ttm + '</td>' +
|
'<td class="' + outClass + '">' + esc(p.status || '--') + tierBadge + '</td>' +
|
||||||
|
'<td style="text-align:center;">' + (p.eval_rounds || '--') + '</td>' +
|
||||||
|
'<td>' + evaluator + '</td>' +
|
||||||
|
'<td>' + fmtCost(p.est_cost) + '</td>' +
|
||||||
'<td>' + date + '</td>' +
|
'<td>' + date + '</td>' +
|
||||||
'</tr>' +
|
'</tr>' +
|
||||||
'<tr id="trace-' + p.number + '" style="display:none;"><td colspan="7" style="padding:0;">' +
|
'<tr id="trace-' + p.number + '" style="display:none;"><td colspan="10" style="padding:0;">' +
|
||||||
'<div class="trace-panel" id="panel-' + p.number + '">Loading trace...</div>' +
|
'<div class="trace-panel" id="panel-' + p.number + '">Loading...</div>' +
|
||||||
'</td></tr>'
|
'</td></tr>'
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
@ -341,7 +373,6 @@ def render_prs_page(now: datetime) -> str:
|
||||||
|
|
||||||
// Row click -> trace expand
|
// Row click -> trace expand
|
||||||
document.getElementById('pr-tbody').addEventListener('click', function(e) {
|
document.getElementById('pr-tbody').addEventListener('click', function(e) {
|
||||||
// Don't expand if clicking a link
|
|
||||||
if (e.target.closest('a')) return;
|
if (e.target.closest('a')) return;
|
||||||
var row = e.target.closest('tr[data-pr]');
|
var row = e.target.closest('tr[data-pr]');
|
||||||
if (!row) return;
|
if (!row) return;
|
||||||
|
|
@ -371,20 +402,34 @@ def render_prs_page(now: datetime) -> str:
|
||||||
});
|
});
|
||||||
|
|
||||||
function loadTrace(pr, panel) {
|
function loadTrace(pr, panel) {
|
||||||
|
// Find the PR data for claim titles
|
||||||
|
var prData = null;
|
||||||
|
for (var i = 0; i < allData.length; i++) {
|
||||||
|
if (allData[i].number == pr) { prData = allData[i]; break; }
|
||||||
|
}
|
||||||
|
|
||||||
fetch('/api/trace/' + pr).then(function(r) { return r.json(); }).then(function(data) {
|
fetch('/api/trace/' + pr).then(function(r) { return r.json(); }).then(function(data) {
|
||||||
var html = '';
|
var html = '';
|
||||||
|
|
||||||
// PR metadata
|
// ─── Claims contained in this PR ───
|
||||||
if (data.pr) {
|
if (prData && prData.description) {
|
||||||
html += '<div class="stat-row" style="gap:16px;">';
|
var titles = prData.description.split('|').map(function(t) { return t.trim(); }).filter(Boolean);
|
||||||
html += '<div class="mini-stat">Source: <span>' + esc(data.pr.source_path || '--') + '</span></div>';
|
if (titles.length > 0) {
|
||||||
if (data.pr.agent) html += '<div class="mini-stat">Agent: <span>' + esc(data.pr.agent) + '</span></div>';
|
html += '<h4>Claims (' + titles.length + ')</h4>';
|
||||||
if (data.pr.tier) html += '<div class="mini-stat">Tier: <span>' + esc(data.pr.tier) + '</span></div>';
|
html += '<ul class="claim-list">';
|
||||||
html += '<div class="mini-stat"><a class="pr-link" href="' + FORGEJO + pr + '" target="_blank">View on Forgejo</a></div>';
|
titles.forEach(function(t) {
|
||||||
html += '</div>';
|
html += '<li>' + esc(t) + '</li>';
|
||||||
|
});
|
||||||
|
html += '</ul>';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Eval chain models
|
// ─── Issues (if any) ───
|
||||||
|
if (prData && prData.review_snippet) {
|
||||||
|
html += '<div class="issues-box">' + esc(prData.review_snippet) + '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Eval chain with models ───
|
||||||
var models = {};
|
var models = {};
|
||||||
if (data.timeline) {
|
if (data.timeline) {
|
||||||
data.timeline.forEach(function(ev) {
|
data.timeline.forEach(function(ev) {
|
||||||
|
|
@ -395,20 +440,38 @@ def render_prs_page(now: datetime) -> str:
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (Object.keys(models).length > 0) {
|
|
||||||
html += '<div style="background:#161b22;border-radius:6px;padding:8px 12px;margin:4px 0 8px;font-size:12px;">';
|
html += '<div class="eval-chain"><strong style="color:#58a6ff;">Eval Chain:</strong> ';
|
||||||
html += '<strong style="color:#58a6ff;">Eval Chain:</strong> ';
|
var chain = [];
|
||||||
var parts = [];
|
if (models['triage.haiku_triage'] || models['triage.deterministic_triage']) {
|
||||||
if (models['triage.haiku_triage']) parts.push('Triage: ' + models['triage.haiku_triage']);
|
chain.push('<span class="chain-step">Triage <span class="model-tag">' +
|
||||||
if (models['domain_review']) parts.push('Domain: ' + models['domain_review']);
|
esc(models['triage.haiku_triage'] || 'deterministic') + '</span></span>');
|
||||||
if (models['leo_review']) parts.push('Leo: ' + models['leo_review']);
|
}
|
||||||
html += parts.length > 0 ? parts.join(' → ') : '<span style="color:#484f58;">No model data</span>';
|
if (models['domain_review']) {
|
||||||
|
chain.push('<span class="chain-step">Domain <span class="model-tag">' +
|
||||||
|
esc(models['domain_review']) + '</span></span>');
|
||||||
|
}
|
||||||
|
if (models['leo_review']) {
|
||||||
|
chain.push('<span class="chain-step">Leo <span class="model-tag">' +
|
||||||
|
esc(models['leo_review']) + '</span></span>');
|
||||||
|
}
|
||||||
|
html += chain.length > 0 ? chain.join('<span class="chain-arrow">→</span>') :
|
||||||
|
'<span style="color:#484f58;">No model data</span>';
|
||||||
|
html += '</div>';
|
||||||
|
|
||||||
|
// ─── Source + contributor metadata ───
|
||||||
|
if (data.pr) {
|
||||||
|
html += '<div style="margin:8px 0;font-size:12px;color:#8b949e;">';
|
||||||
|
if (data.pr.source_path) html += 'Source: <span style="color:#c9d1d9;">' + esc(data.pr.source_path) + '</span> · ';
|
||||||
|
if (prData && prData.submitted_by) html += 'Contributor: <span style="color:#d2a8ff;">' + esc(prData.submitted_by) + '</span> · ';
|
||||||
|
if (data.pr.tier) html += 'Tier: <span style="color:#c9d1d9;">' + esc(data.pr.tier) + '</span> · ';
|
||||||
|
html += '<a class="pr-link" href="' + FORGEJO + pr + '" target="_blank">View on Forgejo</a>';
|
||||||
html += '</div>';
|
html += '</div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Timeline
|
// ─── Timeline ───
|
||||||
if (data.timeline && data.timeline.length > 0) {
|
if (data.timeline && data.timeline.length > 0) {
|
||||||
html += '<h4 style="color:#58a6ff;font-size:12px;margin:8px 0 4px;">Timeline</h4>';
|
html += '<h4>Timeline</h4>';
|
||||||
html += '<ul class="trace-timeline">';
|
html += '<ul class="trace-timeline">';
|
||||||
data.timeline.forEach(function(ev) {
|
data.timeline.forEach(function(ev) {
|
||||||
var cls = ev.event === 'approved' ? 'ev-approved' :
|
var cls = ev.event === 'approved' ? 'ev-approved' :
|
||||||
|
|
@ -437,12 +500,12 @@ def render_prs_page(now: datetime) -> str:
|
||||||
});
|
});
|
||||||
html += '</ul>';
|
html += '</ul>';
|
||||||
} else {
|
} else {
|
||||||
html += '<div style="color:#484f58;font-size:12px;">No timeline events</div>';
|
html += '<div style="color:#484f58;font-size:12px;margin:8px 0;">No timeline events</div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reviews
|
// ─── Reviews ───
|
||||||
if (data.reviews && data.reviews.length > 0) {
|
if (data.reviews && data.reviews.length > 0) {
|
||||||
html += '<h4 style="color:#58a6ff;font-size:12px;margin:8px 0 4px;">Reviews</h4>';
|
html += '<h4>Reviews</h4>';
|
||||||
data.reviews.forEach(function(r) {
|
data.reviews.forEach(function(r) {
|
||||||
var cls = r.outcome === 'approved' ? 'badge-green' :
|
var cls = r.outcome === 'approved' ? 'badge-green' :
|
||||||
r.outcome === 'rejected' ? 'badge-red' : 'badge-yellow';
|
r.outcome === 'rejected' ? 'badge-red' : 'badge-yellow';
|
||||||
|
|
@ -468,7 +531,7 @@ def render_prs_page(now: datetime) -> str:
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter listeners
|
// Filter listeners
|
||||||
['filter-domain', 'filter-agent', 'filter-outcome', 'filter-tier'].forEach(function(id) {
|
['filter-domain', 'filter-contributor', 'filter-outcome', 'filter-tier'].forEach(function(id) {
|
||||||
document.getElementById(id).addEventListener('change', applyFilters);
|
document.getElementById(id).addEventListener('change', applyFilters);
|
||||||
});
|
});
|
||||||
document.getElementById('filter-days').addEventListener('change', loadData);
|
document.getElementById('filter-days').addEventListener('change', loadData);
|
||||||
|
|
|
||||||
|
|
@ -732,7 +732,8 @@ async def handle_pr_lifecycle(request):
|
||||||
pr_rows = conn.execute(
|
pr_rows = conn.execute(
|
||||||
f"""SELECT p.number, p.agent, p.domain, p.tier, p.status,
|
f"""SELECT p.number, p.agent, p.domain, p.tier, p.status,
|
||||||
p.created_at, p.merged_at, p.leo_verdict, p.description,
|
p.created_at, p.merged_at, p.leo_verdict, p.description,
|
||||||
p.domain_agent, p.domain_model, p.branch
|
p.domain_agent, p.domain_model, p.branch, p.submitted_by,
|
||||||
|
p.source_path
|
||||||
FROM prs p
|
FROM prs p
|
||||||
WHERE 1=1 {day_clause}
|
WHERE 1=1 {day_clause}
|
||||||
ORDER BY p.number DESC""",
|
ORDER BY p.number DESC""",
|
||||||
|
|
@ -879,6 +880,8 @@ async def handle_pr_lifecycle(request):
|
||||||
"summary": summary,
|
"summary": summary,
|
||||||
"description": desc if desc.strip() else None,
|
"description": desc if desc.strip() else None,
|
||||||
"review_snippet": snippet_map.get(pr_num),
|
"review_snippet": snippet_map.get(pr_num),
|
||||||
|
"submitted_by": r["submitted_by"],
|
||||||
|
"source_path": r["source_path"],
|
||||||
})
|
})
|
||||||
|
|
||||||
# Summary KPIs
|
# Summary KPIs
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ from . import config
|
||||||
|
|
||||||
logger = logging.getLogger("pipeline.db")
|
logger = logging.getLogger("pipeline.db")
|
||||||
|
|
||||||
SCHEMA_VERSION = 18
|
SCHEMA_VERSION = 19
|
||||||
|
|
||||||
SCHEMA_SQL = """
|
SCHEMA_SQL = """
|
||||||
CREATE TABLE IF NOT EXISTS schema_version (
|
CREATE TABLE IF NOT EXISTS schema_version (
|
||||||
|
|
@ -516,6 +516,20 @@ def migrate(conn: sqlite3.Connection):
|
||||||
conn.commit()
|
conn.commit()
|
||||||
logger.info("Migration v18: created review_records table")
|
logger.info("Migration v18: created review_records table")
|
||||||
|
|
||||||
|
if current < 19:
|
||||||
|
# Add submitted_by for contributor attribution tracing.
|
||||||
|
# Tracks who submitted the source: human handle, agent name, or "self-directed".
|
||||||
|
try:
|
||||||
|
conn.execute("ALTER TABLE prs ADD COLUMN submitted_by TEXT")
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
pass # Column already exists
|
||||||
|
try:
|
||||||
|
conn.execute("ALTER TABLE sources ADD COLUMN submitted_by TEXT")
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
pass
|
||||||
|
conn.commit()
|
||||||
|
logger.info("Migration v19: added submitted_by to prs and sources tables")
|
||||||
|
|
||||||
if current < SCHEMA_VERSION:
|
if current < SCHEMA_VERSION:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT OR REPLACE INTO schema_version (version) VALUES (?)",
|
"INSERT OR REPLACE INTO schema_version (version) VALUES (?)",
|
||||||
|
|
|
||||||
|
|
@ -517,6 +517,49 @@ async def _extract_one_source(
|
||||||
if pr_result and pr_result.get("number"):
|
if pr_result and pr_result.get("number"):
|
||||||
pr_num = pr_result["number"]
|
pr_num = pr_result["number"]
|
||||||
logger.info("PR #%d created for %s (%d claims, %d entities)", pr_num, source_file, len(claim_files), len(entity_files))
|
logger.info("PR #%d created for %s (%d claims, %d entities)", pr_num, source_file, len(claim_files), len(entity_files))
|
||||||
|
|
||||||
|
# Store contributor attribution: who submitted this source?
|
||||||
|
# Priority: proposed_by field → intake_tier inference → "unknown"
|
||||||
|
if proposed_by:
|
||||||
|
contributor = proposed_by.strip().strip('"').strip("'")
|
||||||
|
elif intake_tier == "research-task":
|
||||||
|
contributor = f"{agent_name} (self-directed)"
|
||||||
|
elif intake_tier == "directed":
|
||||||
|
contributor = "directed (unknown)"
|
||||||
|
else:
|
||||||
|
contributor = "unknown"
|
||||||
|
|
||||||
|
# Build pipe-separated claim titles for the description field
|
||||||
|
claim_titles = " | ".join(
|
||||||
|
c.get("title", c.get("filename", "").replace("-", " ").replace(".md", ""))
|
||||||
|
for c in claims_raw if c.get("title") or c.get("filename")
|
||||||
|
)
|
||||||
|
|
||||||
|
# Upsert: if discover_external_prs already created the row, update it;
|
||||||
|
# if not, create a partial row that discover will complete.
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
"""INSERT INTO prs (number, branch, status, submitted_by, source_path, description)
|
||||||
|
VALUES (?, ?, 'open', ?, ?, ?)
|
||||||
|
ON CONFLICT(number) DO UPDATE SET
|
||||||
|
submitted_by = excluded.submitted_by,
|
||||||
|
source_path = excluded.source_path,
|
||||||
|
description = COALESCE(excluded.description, prs.description)""",
|
||||||
|
(pr_num, branch, contributor, source_path, claim_titles),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
except Exception:
|
||||||
|
logger.debug("Failed to upsert submitted_by for PR #%d", pr_num, exc_info=True)
|
||||||
|
|
||||||
|
# Also store on source record
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE sources SET submitted_by = ? WHERE path = ?",
|
||||||
|
(contributor, source_path),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
except Exception:
|
||||||
|
logger.debug("Failed to update source submitted_by", exc_info=True)
|
||||||
else:
|
else:
|
||||||
logger.warning("PR creation may have failed for %s — response: %s", source_file, pr_result)
|
logger.warning("PR creation may have failed for %s — response: %s", source_file, pr_result)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -119,12 +119,16 @@ async def discover_external_prs(conn) -> int:
|
||||||
domain = None if not is_pipeline else detect_domain_from_branch(pr["head"]["ref"])
|
domain = None if not is_pipeline else detect_domain_from_branch(pr["head"]["ref"])
|
||||||
agent, commit_type = classify_branch(pr["head"]["ref"])
|
agent, commit_type = classify_branch(pr["head"]["ref"])
|
||||||
|
|
||||||
|
# For human PRs, submitted_by is the Forgejo author.
|
||||||
|
# For pipeline PRs, submitted_by is set later by extract.py (from source proposed_by).
|
||||||
|
submitted_by = author if origin == "human" else None
|
||||||
|
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""INSERT OR IGNORE INTO prs
|
"""INSERT OR IGNORE INTO prs
|
||||||
(number, branch, status, origin, priority, domain, agent, commit_type,
|
(number, branch, status, origin, priority, domain, agent, commit_type,
|
||||||
prompt_version, pipeline_version)
|
prompt_version, pipeline_version, submitted_by)
|
||||||
VALUES (?, ?, 'open', ?, ?, ?, ?, ?, ?, ?)""",
|
VALUES (?, ?, 'open', ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||||
(pr["number"], pr["head"]["ref"], origin, priority, domain, agent, commit_type, config.PROMPT_VERSION, config.PIPELINE_VERSION),
|
(pr["number"], pr["head"]["ref"], origin, priority, domain, agent, commit_type, config.PROMPT_VERSION, config.PIPELINE_VERSION, submitted_by),
|
||||||
)
|
)
|
||||||
db.audit(
|
db.audit(
|
||||||
conn,
|
conn,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue