Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,6 @@ __pycache__/
# Build outputs
dist/
build/

# Local git worktrees (used by Claude Code)
.worktrees/
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,9 @@ Either way, you'll also need:
| `change-failure-rate` | % of merged PRs labelled `caused-incident` | Requires label discipline |
| `review-latency` | Median hours waiting for review (`merged − ready_for_review_at \| opened_at`), bucketed by `changed_files` (XS=1, S=2-3, M=4-9, L+=10+) | Chart shows median; JSON output also includes p90 |
| `hotfixes` | Recent `hotfix`-labelled PRs + their 3 preceding merges | Investigative — helps find causing PRs to backfill `caused-incident` |
| `large-prs` | Weekly count of merged PRs with `changed_files >= 10` | Same NULL coverage caveat as `review-latency` (legacy rows excluded) |
| `hotfix-count` | Weekly count of `hotfix`-labelled PRs | Aggregate of the same set `hotfixes` lists individually |
| `weekend-merges` | Individual PRs merged on Sat/Sun (UTC) | Per-PR drill-down with author + day-of-week |
| `summary` | Per-repo roll-up over the window | Used by the dashboard's summary tiles |

> **`review-latency` coverage ramps forward.** When you upgrade `dora` and run the next `dora pull`, only newly-merged PRs get their size + draft data fetched. Previously-cached PRs keep their `changed_files` and `ready_for_review_at` columns `NULL` and are excluded from the metric. Coverage fills in over time as new PRs merge. A `--rebuild` flag for forced backfill is parked as future work.
Expand Down
157 changes: 136 additions & 21 deletions dashboard/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,20 @@ function cfrTier(pct) {
return "low";
}

function largePrsTier(perWeek) {
if (perWeek == null) return "na";
if (perWeek < 2) return "elite"; // <2/wk: healthy churn
if (perWeek <= 4) return "medium"; // 2-4/wk: caution
return "low"; // >4/wk: too many big PRs
}

function hotfixTier(perWeek) {
if (perWeek == null) return "na";
if (perWeek < 1) return "elite"; // 0/wk
if (perWeek <= 2) return "medium"; // 1-2/wk
return "low"; // 3+/wk
}

const TIER_LABEL = { elite: "Elite", high: "High", medium: "Medium", low: "Low", na: "N/A" };

function kpiCard({ label, value, unit, subText, tier, info }) {
Expand Down Expand Up @@ -212,27 +226,31 @@ function render() {
function renderForRepo(metrics, repo) {
resetCharts();

const freqPrs = inRange(filterByRepo(metrics["deploy-freq-prs"] || [], repo));
const freqDeploys = inRange(filterByRepo(metrics["deploy-freq"] || [], repo));
const leadTime = inRange(filterByRepo(metrics["lead-time"] || [], repo));
const cfr = inRange(filterByRepo(metrics["change-failure-rate"] || [], repo));
const cfrPrs = inRange(filterByRepo(metrics["change-failure-prs"] || [], repo));
const hotfixes = inRangeHotfixes(filterByRepo(metrics["hotfixes"] || [], repo));
const reviewLatency = inRange(filterByRepo(metrics["review-latency"] || [], repo));
const freqPrs = inRange(filterByRepo(metrics["deploy-freq-prs"] || [], repo));
const freqDeploys = inRange(filterByRepo(metrics["deploy-freq"] || [], repo));
const leadTime = inRange(filterByRepo(metrics["lead-time"] || [], repo));
const cfr = inRange(filterByRepo(metrics["change-failure-rate"] || [], repo));
const cfrPrs = inRange(filterByRepo(metrics["change-failure-prs"] || [], repo));
const hotfixes = inRangeHotfixes(filterByRepo(metrics["hotfixes"] || [], repo));
const reviewLatency = inRange(filterByRepo(metrics["review-latency"] || [], repo));
const largePrs = inRange(filterByRepo(metrics["large-prs"] || [], repo));
const hotfixCount = inRange(filterByRepo(metrics["hotfix-count"] || [], repo));
const weekendMerges = inRangeWeekendMerges(filterByRepo(metrics["weekend-merges"] || [], repo));
// summary is not date-filterable; renderKPIs uses it only as a fallback,
// and that fallback is dropped when filtering is active (see renderKPIs).
const summary = filterByRepo(metrics["summary"] || [], repo);
const summary = filterByRepo(metrics["summary"] || [], repo);

renderKPIs(summary, freqPrs, freqDeploys, leadTime, cfr);
renderKPIs(summary, freqPrs, freqDeploys, leadTime, cfr, largePrs, hotfixCount);
renderFreqChart(freqPrs, freqDeploys);
renderLeadChart(leadTime);
renderCFRChart(cfr);
renderCfrPrs(cfrPrs);
renderHotfixes(hotfixes);
renderReviewLatencyChart(reviewLatency);
renderWeekendMerges(weekendMerges);
}

function renderKPIs(summary, freqPrs, freqDeploys, leadTime, cfr) {
function renderKPIs(summary, freqPrs, freqDeploys, leadTime, cfr, largePrs, hotfixCount) {
const filtering = currentFrom !== null && currentTo !== null;
const s = filtering ? null : summary[0];

Expand Down Expand Up @@ -267,6 +285,23 @@ function renderKPIs(summary, freqPrs, freqDeploys, leadTime, cfr) {
totals.d > 0 ? (100 * totals.f) / totals.d
: (s?.cfr != null ? parseFloat(String(s.cfr).replace("%", "")) : null);

// Large PRs / wk and Hotfixes / wk: average per week.
// Unfiltered → last 4 weeks; filtered → range avg.
// Empty + filtering → 0 (the metric only emits weeks with non-zero counts,
// so an empty filtered range legitimately means "none in this window").
// Empty + unfiltered → "—" (metric likely absent from the report entirely).
const lpRows = filtering ? (largePrs || []) : recentN(largePrs || [], 4);
const lpDenom = filtering ? Math.max(1, lpRows.length) : 4;
const largePerWk = (largePrs && largePrs.length)
? lpRows.reduce((a, r) => a + (r.large_prs || 0), 0) / lpDenom
: (filtering ? 0 : null);

const hcRows = filtering ? (hotfixCount || []) : recentN(hotfixCount || [], 4);
const hcDenom = filtering ? Math.max(1, hcRows.length) : 4;
const hotfixPerWk = (hotfixCount && hotfixCount.length)
? hcRows.reduce((a, r) => a + (r.hotfix_count || 0), 0) / hcDenom
: (filtering ? 0 : null);

const subText = filtering ? "in selected range" : "last 4 weeks";
const leadSubText = filtering ? "median, in selected range" : "median, last 4 wk";
const cfrSubText = filtering ? "in selected range" : "across window";
Expand Down Expand Up @@ -298,6 +333,22 @@ function renderKPIs(summary, freqPrs, freqDeploys, leadTime, cfr) {
tier: cfrTier(cfrPct),
info: "PRs labelled `caused-incident` ÷ all merged PRs across the window. Apply the label to the PR that SHIPPED the defect (not the PR that fixed it). See the drill-down list below the chart.",
}),
kpiCard({
label: "Large PRs / wk",
value: largePerWk != null ? largePerWk.toFixed(1) : "—",
unit: "",
subText,
tier: largePrsTier(largePerWk),
info: "Average per-week count of merged PRs with 10 or more changed files. Large PRs are slower to review and more failure-prone — a sustained rate above ~2/wk is a smell.",
}),
kpiCard({
label: "Hotfixes / wk",
value: hotfixPerWk != null ? hotfixPerWk.toFixed(1) : "—",
unit: "",
subText,
tier: hotfixTier(hotfixPerWk),
info: "Average per-week count of merged PRs labelled `hotfix`. The `hotfix` label marks the PR that FIXED a prior defect; consistently nonzero rates point to upstream quality issues.",
}),
kpiCard({
label: "Mean time to restore",
value: "—",
Expand Down Expand Up @@ -524,6 +575,50 @@ function renderHotfixes(rows) {
el.innerHTML = html;
}

function renderWeekendMerges(rows) {
const el = document.getElementById("weekend-merges");
if (!rows.length) {
el.innerHTML = '<div class="empty">No weekend merges in the current range</div>';
return;
}

// Per-author tally for the selected range.
const byAuthor = new Map();
for (const r of rows) {
const a = r.author || "(unknown)";
if (!byAuthor.has(a)) byAuthor.set(a, []);
byAuthor.get(a).push(r);
}
const authors = [...byAuthor.entries()].sort((a, b) => b[1].length - a[1].length);

const total = rows.length;
const noun = total === 1 ? "merge" : "merges";
const summary =
`<div class="wk-summary">${total} weekend ${noun} · ${authors.length} author${authors.length === 1 ? "" : "s"}</div>`;

const list = authors.map(([author, prs]) => {
const prList = prs
.sort((a, b) => (a.merged < b.merged ? 1 : -1))
.map(p => `
<div class="wk-pr">
<span class="wk-tag wk-${escapeHtml((p.dow || "").toLowerCase())}">${escapeHtml(p.dow || "")}</span>
<a href="https://github.com/${escapeHtml(p.repo)}/pull/${encodeURIComponent(p.pr)}"
target="_blank" rel="noopener noreferrer">#${escapeHtml(p.pr)}</a>
<span>${escapeHtml(p.title || "")}</span>
<span class="wk-date">${escapeHtml(p.merged || "")}</span>
</div>
`).join("");
return `
<details class="wk-author">
<summary><span class="wk-author-name">${escapeHtml(author)}</span><span class="wk-author-count">${prs.length}</span></summary>
${prList}
</details>
`;
}).join("");

el.innerHTML = summary + list;
}

// --------- date range helpers ---------

/** Sorted unique week values across all metrics that have a `week` field. */
Expand Down Expand Up @@ -565,25 +660,36 @@ function weekToMondayDate(weekStr) {
return target.toISOString().slice(0, 10);
}

/** Convert a Monday-of-week date string ("YYYY-MM-DD") to the date string for
* that week's Sunday (i.e. Monday + 6 days), keeping everything in UTC. */
function weekEndDate(mondayDate) {
const d = new Date(mondayDate + "T00:00:00Z");
d.setUTCDate(d.getUTCDate() + 6);
return d.toISOString().slice(0, 10);
}

/** Resolve `[currentFrom, currentTo]` ISO weeks into a closed `[fromDate, toEnd]`
* date span (Monday-of-from to Sunday-of-to). Returns null if no range is
* active or the week strings can't be parsed. */
function currentRangeDates() {
if (!currentFrom || !currentTo) return null;
const fromDate = weekToMondayDate(currentFrom);
const toDate = weekToMondayDate(currentTo);
if (!fromDate || !toDate) return null;
return { fromDate, toEnd: weekEndDate(toDate) };
}

/** Filter hotfix rows: keep each `hotfix` row in range AND its trailing
* `preceded-by` rows (groups stay intact even if the prev row's date
* is technically outside the window). */
function inRangeHotfixes(rows) {
if (!currentFrom || !currentTo) return rows;
const fromDate = weekToMondayDate(currentFrom);
const toDate = weekToMondayDate(currentTo);
if (!fromDate || !toDate) return rows;
// Add 6 days to toDate to include the whole "to" week.
const toEnd = (() => {
const d = new Date(toDate + "T00:00:00Z");
d.setUTCDate(d.getUTCDate() + 6);
return d.toISOString().slice(0, 10);
})();
const span = currentRangeDates();
if (!span) return rows;
const out = [];
let keepGroup = false;
for (const r of rows) {
if (r.relation === "hotfix") {
keepGroup = r.merged >= fromDate && r.merged <= toEnd;
keepGroup = r.merged >= span.fromDate && r.merged <= span.toEnd;
if (keepGroup) out.push(r);
} else if (keepGroup) {
out.push(r);
Expand All @@ -592,6 +698,15 @@ function inRangeHotfixes(rows) {
return out;
}

/** Filter weekend-merges rows by the date covered by [currentFrom, currentTo].
* Each row carries `merged` (YYYY-MM-DD); we keep rows whose `merged` falls
* inside the Monday-of-from to Sunday-of-to span. */
function inRangeWeekendMerges(rows) {
const span = currentRangeDates();
if (!span) return rows;
return rows.filter(r => r.merged && r.merged >= span.fromDate && r.merged <= span.toEnd);
}

/** Compute [from, to] for a preset clicked on the current data.
* Preset "all" → full data extent; numeric → last N weeks ending at latestWeek. */
function computePresetRange(presetId) {
Expand Down
39 changes: 39 additions & 0 deletions dashboard/fixtures/sample.json
Original file line number Diff line number Diff line change
Expand Up @@ -821,6 +821,45 @@
}
]
},
{
"metric": "large-prs",
"description": "Weekly count of merged PRs with changed_files >= 10 (churn risk)",
"data": [
{ "repo": "acme/example", "week": "2025-W43", "large_prs": 1 },
{ "repo": "acme/example", "week": "2025-W44", "large_prs": 2 },
{ "repo": "acme/example", "week": "2025-W45", "large_prs": 1 },
{ "repo": "acme/example", "week": "2025-W46", "large_prs": 2 },
{ "repo": "acme/example", "week": "2025-W47", "large_prs": 1 },
{ "repo": "acme/example", "week": "2025-W48", "large_prs": 2 },
{ "repo": "acme/example", "week": "2025-W49", "large_prs": 1 },
{ "repo": "acme/example", "week": "2025-W50", "large_prs": 2 },
{ "repo": "acme/example", "week": "2026-W01", "large_prs": 1 },
{ "repo": "acme/example", "week": "2026-W02", "large_prs": 2 },
{ "repo": "acme/example", "week": "2026-W03", "large_prs": 1 },
{ "repo": "acme/example", "week": "2026-W04", "large_prs": 3 }
]
},
{
"metric": "hotfix-count",
"description": "Weekly count of merged PRs labelled `hotfix`",
"data": [
{ "repo": "acme/example", "week": "2025-W44", "hotfix_count": 2 },
{ "repo": "acme/example", "week": "2025-W46", "hotfix_count": 1 },
{ "repo": "acme/example", "week": "2026-W02", "hotfix_count": 1 }
]
},
{
"metric": "weekend-merges",
"description": "Individual PRs merged on a Saturday or Sunday (UTC)",
"data": [
{ "repo": "acme/example", "week": "2025-W43", "pr": 2510, "author": "alice", "title": "Cache busting for static assets", "merged": "2025-10-25", "dow": "Sat" },
{ "repo": "acme/example", "week": "2025-W43", "pr": 2511, "author": "bob", "title": "Fix flaky integration test", "merged": "2025-10-26", "dow": "Sun" },
{ "repo": "acme/example", "week": "2025-W45", "pr": 2547, "author": "alice", "title": "Bump pinned deps to address advisory", "merged": "2025-11-08", "dow": "Sat" },
{ "repo": "acme/example", "week": "2025-W46", "pr": 2569, "author": "snopoke", "title": "Hotfix: revert deploy that broke login", "merged": "2025-11-15", "dow": "Sat" },
{ "repo": "acme/example", "week": "2026-W01", "pr": 2710, "author": "alice", "title": "Tidy migration ordering", "merged": "2026-01-04", "dow": "Sun" },
{ "repo": "acme/example", "week": "2026-W02", "pr": 2741, "author": "carol", "title": "Fix S3 upload retry storm", "merged": "2026-01-11", "dow": "Sun" }
]
},
{
"metric": "review-latency",
"description": "Weekly review latency in hours (merged − ready_for_review_at | opened_at), bucketed by changed_files (XS=1, S=2-3, M=4-9, L+=10+)",
Expand Down
8 changes: 8 additions & 0 deletions dashboard/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,14 @@ <h1>DORA metrics</h1>
</div>
</div>

<div class="panel">
<div class="panel-head">
<p class="panel-title">Weekend merges</p>
<p class="panel-sub">PRs merged on Saturday or Sunday in the selected range, by author</p>
</div>
<div id="weekend-merges"></div>
</div>

</section>
</div>
</div>
Expand Down
65 changes: 64 additions & 1 deletion dashboard/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,7 @@ header.top {
/* KPIs */
.kpis {
display: grid;
grid-template-columns: repeat(4, 1fr);
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 12px;
margin-bottom: 28px;
}
Expand Down Expand Up @@ -589,3 +589,66 @@ header.top {
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25);
cursor: pointer;
}

/* Weekend merges panel */
.wk-summary {
font-size: 13px;
color: var(--text-muted);
margin-bottom: 10px;
}
.wk-author {
border-top: 1px solid var(--border);
padding: 6px 0;
}
.wk-author:first-of-type { border-top: 0; }
.wk-author > summary {
display: flex;
justify-content: space-between;
align-items: baseline;
cursor: pointer;
font-size: 14px;
list-style: none;
}
.wk-author > summary::-webkit-details-marker { display: none; }
.wk-author-name { font-weight: 500; color: var(--text); }
.wk-author-count {
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
font-size: 12px;
color: var(--text-muted);
background: var(--surface-muted);
padding: 2px 8px;
border-radius: 999px;
}
.wk-pr {
display: grid;
grid-template-columns: 44px 60px 1fr auto;
gap: 10px 12px;
align-items: baseline;
padding: 4px 0 4px 12px;
font-size: 13px;
color: var(--text-muted);
}
.wk-pr a {
font-family: var(--font-mono);
color: var(--text);
text-decoration: none;
}
.wk-pr a:hover { text-decoration: underline; }
.wk-tag {
font-size: 10px;
font-weight: 600;
text-align: center;
padding: 2px 6px;
border-radius: 4px;
text-transform: uppercase;
letter-spacing: 0.04em;
background: var(--surface-muted);
color: var(--text-soft);
}
.wk-date {
font-family: var(--font-mono);
font-size: 12px;
color: var(--text-soft);
white-space: nowrap;
}
Loading
Loading