Skip to content

Commit 007d728

Browse files
authored
Merge pull request #2 from dimagi/feature/three-metrics
Add three additive metrics: large-prs, hotfix-count, weekend-merges
2 parents 4edd38e + c07f99d commit 007d728

10 files changed

Lines changed: 802 additions & 22 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,6 @@ __pycache__/
1919
# Build outputs
2020
dist/
2121
build/
22+
23+
# Local git worktrees (used by Claude Code)
24+
.worktrees/

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,9 @@ Either way, you'll also need:
105105
| `change-failure-rate` | % of merged PRs labelled `caused-incident` | Requires label discipline |
106106
| `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 |
107107
| `hotfixes` | Recent `hotfix`-labelled PRs + their 3 preceding merges | Investigative — helps find causing PRs to backfill `caused-incident` |
108+
| `large-prs` | Weekly count of merged PRs with `changed_files >= 10` | Same NULL coverage caveat as `review-latency` (legacy rows excluded) |
109+
| `hotfix-count` | Weekly count of `hotfix`-labelled PRs | Aggregate of the same set `hotfixes` lists individually |
110+
| `weekend-merges` | Individual PRs merged on Sat/Sun (UTC) | Per-PR drill-down with author + day-of-week |
108111
| `summary` | Per-repo roll-up over the window | Used by the dashboard's summary tiles |
109112

110113
> **`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.

dashboard/app.js

Lines changed: 136 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,20 @@ function cfrTier(pct) {
142142
return "low";
143143
}
144144

145+
function largePrsTier(perWeek) {
146+
if (perWeek == null) return "na";
147+
if (perWeek < 2) return "elite"; // <2/wk: healthy churn
148+
if (perWeek <= 4) return "medium"; // 2-4/wk: caution
149+
return "low"; // >4/wk: too many big PRs
150+
}
151+
152+
function hotfixTier(perWeek) {
153+
if (perWeek == null) return "na";
154+
if (perWeek < 1) return "elite"; // 0/wk
155+
if (perWeek <= 2) return "medium"; // 1-2/wk
156+
return "low"; // 3+/wk
157+
}
158+
145159
const TIER_LABEL = { elite: "Elite", high: "High", medium: "Medium", low: "Low", na: "N/A" };
146160

147161
function kpiCard({ label, value, unit, subText, tier, info }) {
@@ -212,27 +226,31 @@ function render() {
212226
function renderForRepo(metrics, repo) {
213227
resetCharts();
214228

215-
const freqPrs = inRange(filterByRepo(metrics["deploy-freq-prs"] || [], repo));
216-
const freqDeploys = inRange(filterByRepo(metrics["deploy-freq"] || [], repo));
217-
const leadTime = inRange(filterByRepo(metrics["lead-time"] || [], repo));
218-
const cfr = inRange(filterByRepo(metrics["change-failure-rate"] || [], repo));
219-
const cfrPrs = inRange(filterByRepo(metrics["change-failure-prs"] || [], repo));
220-
const hotfixes = inRangeHotfixes(filterByRepo(metrics["hotfixes"] || [], repo));
221-
const reviewLatency = inRange(filterByRepo(metrics["review-latency"] || [], repo));
229+
const freqPrs = inRange(filterByRepo(metrics["deploy-freq-prs"] || [], repo));
230+
const freqDeploys = inRange(filterByRepo(metrics["deploy-freq"] || [], repo));
231+
const leadTime = inRange(filterByRepo(metrics["lead-time"] || [], repo));
232+
const cfr = inRange(filterByRepo(metrics["change-failure-rate"] || [], repo));
233+
const cfrPrs = inRange(filterByRepo(metrics["change-failure-prs"] || [], repo));
234+
const hotfixes = inRangeHotfixes(filterByRepo(metrics["hotfixes"] || [], repo));
235+
const reviewLatency = inRange(filterByRepo(metrics["review-latency"] || [], repo));
236+
const largePrs = inRange(filterByRepo(metrics["large-prs"] || [], repo));
237+
const hotfixCount = inRange(filterByRepo(metrics["hotfix-count"] || [], repo));
238+
const weekendMerges = inRangeWeekendMerges(filterByRepo(metrics["weekend-merges"] || [], repo));
222239
// summary is not date-filterable; renderKPIs uses it only as a fallback,
223240
// and that fallback is dropped when filtering is active (see renderKPIs).
224-
const summary = filterByRepo(metrics["summary"] || [], repo);
241+
const summary = filterByRepo(metrics["summary"] || [], repo);
225242

226-
renderKPIs(summary, freqPrs, freqDeploys, leadTime, cfr);
243+
renderKPIs(summary, freqPrs, freqDeploys, leadTime, cfr, largePrs, hotfixCount);
227244
renderFreqChart(freqPrs, freqDeploys);
228245
renderLeadChart(leadTime);
229246
renderCFRChart(cfr);
230247
renderCfrPrs(cfrPrs);
231248
renderHotfixes(hotfixes);
232249
renderReviewLatencyChart(reviewLatency);
250+
renderWeekendMerges(weekendMerges);
233251
}
234252

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

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

288+
// Large PRs / wk and Hotfixes / wk: average per week.
289+
// Unfiltered → last 4 weeks; filtered → range avg.
290+
// Empty + filtering → 0 (the metric only emits weeks with non-zero counts,
291+
// so an empty filtered range legitimately means "none in this window").
292+
// Empty + unfiltered → "—" (metric likely absent from the report entirely).
293+
const lpRows = filtering ? (largePrs || []) : recentN(largePrs || [], 4);
294+
const lpDenom = filtering ? Math.max(1, lpRows.length) : 4;
295+
const largePerWk = (largePrs && largePrs.length)
296+
? lpRows.reduce((a, r) => a + (r.large_prs || 0), 0) / lpDenom
297+
: (filtering ? 0 : null);
298+
299+
const hcRows = filtering ? (hotfixCount || []) : recentN(hotfixCount || [], 4);
300+
const hcDenom = filtering ? Math.max(1, hcRows.length) : 4;
301+
const hotfixPerWk = (hotfixCount && hotfixCount.length)
302+
? hcRows.reduce((a, r) => a + (r.hotfix_count || 0), 0) / hcDenom
303+
: (filtering ? 0 : null);
304+
270305
const subText = filtering ? "in selected range" : "last 4 weeks";
271306
const leadSubText = filtering ? "median, in selected range" : "median, last 4 wk";
272307
const cfrSubText = filtering ? "in selected range" : "across window";
@@ -298,6 +333,22 @@ function renderKPIs(summary, freqPrs, freqDeploys, leadTime, cfr) {
298333
tier: cfrTier(cfrPct),
299334
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.",
300335
}),
336+
kpiCard({
337+
label: "Large PRs / wk",
338+
value: largePerWk != null ? largePerWk.toFixed(1) : "—",
339+
unit: "",
340+
subText,
341+
tier: largePrsTier(largePerWk),
342+
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.",
343+
}),
344+
kpiCard({
345+
label: "Hotfixes / wk",
346+
value: hotfixPerWk != null ? hotfixPerWk.toFixed(1) : "—",
347+
unit: "",
348+
subText,
349+
tier: hotfixTier(hotfixPerWk),
350+
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.",
351+
}),
301352
kpiCard({
302353
label: "Mean time to restore",
303354
value: "—",
@@ -524,6 +575,50 @@ function renderHotfixes(rows) {
524575
el.innerHTML = html;
525576
}
526577

578+
function renderWeekendMerges(rows) {
579+
const el = document.getElementById("weekend-merges");
580+
if (!rows.length) {
581+
el.innerHTML = '<div class="empty">No weekend merges in the current range</div>';
582+
return;
583+
}
584+
585+
// Per-author tally for the selected range.
586+
const byAuthor = new Map();
587+
for (const r of rows) {
588+
const a = r.author || "(unknown)";
589+
if (!byAuthor.has(a)) byAuthor.set(a, []);
590+
byAuthor.get(a).push(r);
591+
}
592+
const authors = [...byAuthor.entries()].sort((a, b) => b[1].length - a[1].length);
593+
594+
const total = rows.length;
595+
const noun = total === 1 ? "merge" : "merges";
596+
const summary =
597+
`<div class="wk-summary">${total} weekend ${noun} · ${authors.length} author${authors.length === 1 ? "" : "s"}</div>`;
598+
599+
const list = authors.map(([author, prs]) => {
600+
const prList = prs
601+
.sort((a, b) => (a.merged < b.merged ? 1 : -1))
602+
.map(p => `
603+
<div class="wk-pr">
604+
<span class="wk-tag wk-${escapeHtml((p.dow || "").toLowerCase())}">${escapeHtml(p.dow || "")}</span>
605+
<a href="https://github.com/${escapeHtml(p.repo)}/pull/${encodeURIComponent(p.pr)}"
606+
target="_blank" rel="noopener noreferrer">#${escapeHtml(p.pr)}</a>
607+
<span>${escapeHtml(p.title || "")}</span>
608+
<span class="wk-date">${escapeHtml(p.merged || "")}</span>
609+
</div>
610+
`).join("");
611+
return `
612+
<details class="wk-author">
613+
<summary><span class="wk-author-name">${escapeHtml(author)}</span><span class="wk-author-count">${prs.length}</span></summary>
614+
${prList}
615+
</details>
616+
`;
617+
}).join("");
618+
619+
el.innerHTML = summary + list;
620+
}
621+
527622
// --------- date range helpers ---------
528623

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

663+
/** Convert a Monday-of-week date string ("YYYY-MM-DD") to the date string for
664+
* that week's Sunday (i.e. Monday + 6 days), keeping everything in UTC. */
665+
function weekEndDate(mondayDate) {
666+
const d = new Date(mondayDate + "T00:00:00Z");
667+
d.setUTCDate(d.getUTCDate() + 6);
668+
return d.toISOString().slice(0, 10);
669+
}
670+
671+
/** Resolve `[currentFrom, currentTo]` ISO weeks into a closed `[fromDate, toEnd]`
672+
* date span (Monday-of-from to Sunday-of-to). Returns null if no range is
673+
* active or the week strings can't be parsed. */
674+
function currentRangeDates() {
675+
if (!currentFrom || !currentTo) return null;
676+
const fromDate = weekToMondayDate(currentFrom);
677+
const toDate = weekToMondayDate(currentTo);
678+
if (!fromDate || !toDate) return null;
679+
return { fromDate, toEnd: weekEndDate(toDate) };
680+
}
681+
568682
/** Filter hotfix rows: keep each `hotfix` row in range AND its trailing
569683
* `preceded-by` rows (groups stay intact even if the prev row's date
570684
* is technically outside the window). */
571685
function inRangeHotfixes(rows) {
572-
if (!currentFrom || !currentTo) return rows;
573-
const fromDate = weekToMondayDate(currentFrom);
574-
const toDate = weekToMondayDate(currentTo);
575-
if (!fromDate || !toDate) return rows;
576-
// Add 6 days to toDate to include the whole "to" week.
577-
const toEnd = (() => {
578-
const d = new Date(toDate + "T00:00:00Z");
579-
d.setUTCDate(d.getUTCDate() + 6);
580-
return d.toISOString().slice(0, 10);
581-
})();
686+
const span = currentRangeDates();
687+
if (!span) return rows;
582688
const out = [];
583689
let keepGroup = false;
584690
for (const r of rows) {
585691
if (r.relation === "hotfix") {
586-
keepGroup = r.merged >= fromDate && r.merged <= toEnd;
692+
keepGroup = r.merged >= span.fromDate && r.merged <= span.toEnd;
587693
if (keepGroup) out.push(r);
588694
} else if (keepGroup) {
589695
out.push(r);
@@ -592,6 +698,15 @@ function inRangeHotfixes(rows) {
592698
return out;
593699
}
594700

701+
/** Filter weekend-merges rows by the date covered by [currentFrom, currentTo].
702+
* Each row carries `merged` (YYYY-MM-DD); we keep rows whose `merged` falls
703+
* inside the Monday-of-from to Sunday-of-to span. */
704+
function inRangeWeekendMerges(rows) {
705+
const span = currentRangeDates();
706+
if (!span) return rows;
707+
return rows.filter(r => r.merged && r.merged >= span.fromDate && r.merged <= span.toEnd);
708+
}
709+
595710
/** Compute [from, to] for a preset clicked on the current data.
596711
* Preset "all" → full data extent; numeric → last N weeks ending at latestWeek. */
597712
function computePresetRange(presetId) {

dashboard/fixtures/sample.json

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -821,6 +821,45 @@
821821
}
822822
]
823823
},
824+
{
825+
"metric": "large-prs",
826+
"description": "Weekly count of merged PRs with changed_files >= 10 (churn risk)",
827+
"data": [
828+
{ "repo": "acme/example", "week": "2025-W43", "large_prs": 1 },
829+
{ "repo": "acme/example", "week": "2025-W44", "large_prs": 2 },
830+
{ "repo": "acme/example", "week": "2025-W45", "large_prs": 1 },
831+
{ "repo": "acme/example", "week": "2025-W46", "large_prs": 2 },
832+
{ "repo": "acme/example", "week": "2025-W47", "large_prs": 1 },
833+
{ "repo": "acme/example", "week": "2025-W48", "large_prs": 2 },
834+
{ "repo": "acme/example", "week": "2025-W49", "large_prs": 1 },
835+
{ "repo": "acme/example", "week": "2025-W50", "large_prs": 2 },
836+
{ "repo": "acme/example", "week": "2026-W01", "large_prs": 1 },
837+
{ "repo": "acme/example", "week": "2026-W02", "large_prs": 2 },
838+
{ "repo": "acme/example", "week": "2026-W03", "large_prs": 1 },
839+
{ "repo": "acme/example", "week": "2026-W04", "large_prs": 3 }
840+
]
841+
},
842+
{
843+
"metric": "hotfix-count",
844+
"description": "Weekly count of merged PRs labelled `hotfix`",
845+
"data": [
846+
{ "repo": "acme/example", "week": "2025-W44", "hotfix_count": 2 },
847+
{ "repo": "acme/example", "week": "2025-W46", "hotfix_count": 1 },
848+
{ "repo": "acme/example", "week": "2026-W02", "hotfix_count": 1 }
849+
]
850+
},
851+
{
852+
"metric": "weekend-merges",
853+
"description": "Individual PRs merged on a Saturday or Sunday (UTC)",
854+
"data": [
855+
{ "repo": "acme/example", "week": "2025-W43", "pr": 2510, "author": "alice", "title": "Cache busting for static assets", "merged": "2025-10-25", "dow": "Sat" },
856+
{ "repo": "acme/example", "week": "2025-W43", "pr": 2511, "author": "bob", "title": "Fix flaky integration test", "merged": "2025-10-26", "dow": "Sun" },
857+
{ "repo": "acme/example", "week": "2025-W45", "pr": 2547, "author": "alice", "title": "Bump pinned deps to address advisory", "merged": "2025-11-08", "dow": "Sat" },
858+
{ "repo": "acme/example", "week": "2025-W46", "pr": 2569, "author": "snopoke", "title": "Hotfix: revert deploy that broke login", "merged": "2025-11-15", "dow": "Sat" },
859+
{ "repo": "acme/example", "week": "2026-W01", "pr": 2710, "author": "alice", "title": "Tidy migration ordering", "merged": "2026-01-04", "dow": "Sun" },
860+
{ "repo": "acme/example", "week": "2026-W02", "pr": 2741, "author": "carol", "title": "Fix S3 upload retry storm", "merged": "2026-01-11", "dow": "Sun" }
861+
]
862+
},
824863
{
825864
"metric": "review-latency",
826865
"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+)",

dashboard/index.html

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,14 @@ <h1>DORA metrics</h1>
150150
</div>
151151
</div>
152152

153+
<div class="panel">
154+
<div class="panel-head">
155+
<p class="panel-title">Weekend merges</p>
156+
<p class="panel-sub">PRs merged on Saturday or Sunday in the selected range, by author</p>
157+
</div>
158+
<div id="weekend-merges"></div>
159+
</div>
160+
153161
</section>
154162
</div>
155163
</div>

dashboard/style.css

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,7 @@ header.top {
189189
/* KPIs */
190190
.kpis {
191191
display: grid;
192-
grid-template-columns: repeat(4, 1fr);
192+
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
193193
gap: 12px;
194194
margin-bottom: 28px;
195195
}
@@ -589,3 +589,66 @@ header.top {
589589
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25);
590590
cursor: pointer;
591591
}
592+
593+
/* Weekend merges panel */
594+
.wk-summary {
595+
font-size: 13px;
596+
color: var(--text-muted);
597+
margin-bottom: 10px;
598+
}
599+
.wk-author {
600+
border-top: 1px solid var(--border);
601+
padding: 6px 0;
602+
}
603+
.wk-author:first-of-type { border-top: 0; }
604+
.wk-author > summary {
605+
display: flex;
606+
justify-content: space-between;
607+
align-items: baseline;
608+
cursor: pointer;
609+
font-size: 14px;
610+
list-style: none;
611+
}
612+
.wk-author > summary::-webkit-details-marker { display: none; }
613+
.wk-author-name { font-weight: 500; color: var(--text); }
614+
.wk-author-count {
615+
font-family: var(--font-mono);
616+
font-variant-numeric: tabular-nums;
617+
font-size: 12px;
618+
color: var(--text-muted);
619+
background: var(--surface-muted);
620+
padding: 2px 8px;
621+
border-radius: 999px;
622+
}
623+
.wk-pr {
624+
display: grid;
625+
grid-template-columns: 44px 60px 1fr auto;
626+
gap: 10px 12px;
627+
align-items: baseline;
628+
padding: 4px 0 4px 12px;
629+
font-size: 13px;
630+
color: var(--text-muted);
631+
}
632+
.wk-pr a {
633+
font-family: var(--font-mono);
634+
color: var(--text);
635+
text-decoration: none;
636+
}
637+
.wk-pr a:hover { text-decoration: underline; }
638+
.wk-tag {
639+
font-size: 10px;
640+
font-weight: 600;
641+
text-align: center;
642+
padding: 2px 6px;
643+
border-radius: 4px;
644+
text-transform: uppercase;
645+
letter-spacing: 0.04em;
646+
background: var(--surface-muted);
647+
color: var(--text-soft);
648+
}
649+
.wk-date {
650+
font-family: var(--font-mono);
651+
font-size: 12px;
652+
color: var(--text-soft);
653+
white-space: nowrap;
654+
}

0 commit comments

Comments
 (0)