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
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ https://dimagi.github.io/dora/?url=https://raw.githubusercontent.com/<your-repo>
`dora.db` is the source of truth — `dora-report.json` is derived from it on every run. The DB is persisted between CI runs via [`actions/cache`](https://github.com/actions/cache):

- **Hot cache (typical)**: `dora pull` only fetches PRs/deployments updated since the previous run, plus refreshes labels and transient deployment statuses. Fast.
- **Cold cache (first run, or after 7+ days of inactivity)**: GitHub evicts the cache, the next run starts with an empty DB and re-pulls everything since `--since`. Slow but correct — typically a few minutes for a year of history.
- **Cold cache (first run, or after 7+ days of inactivity)**: GitHub evicts the cache, the next run starts with an empty DB and re-pulls everything since `--since`. Slow but correct — typically a few minutes for a year of history. Each new PR costs 3 API calls (commits + pull detail + timeline) for the size + ready-for-review fields; subsequent pulls only re-fetch labels and transient deployment statuses.

To bust the cache deliberately (e.g. if a future schema change requires it), bump the `v1` prefix in the workflow's cache `key`.

Expand Down Expand Up @@ -103,9 +103,12 @@ Either way, you'll also need:
| `deploy-freq` | Successful deployments per week | Counts both `success` and `inactive` GitHub statuses |
| `lead-time` | Hours from first commit to merge | Mean / median / p90 per week |
| `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` |
| `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.

## Label conventions

- **`caused-incident`** — applied to the PR that SHIPPED a production defect. This is what `change-failure-rate` counts.
Expand Down Expand Up @@ -143,6 +146,8 @@ pull_requests (repo, number) PK
title, author, base, labels (comma-joined)
opened_at, merged_at, first_commit_at
merge_sha
additions, deletions, changed_files -- powers `review-latency`
ready_for_review_at -- NULL when never drafted

deployments (repo, deployment_id) PK
sha, environment, created_at, status
Expand Down
68 changes: 61 additions & 7 deletions dashboard/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -212,22 +212,24 @@ 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 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));
// 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);
renderFreqChart(freqPrs, freqDeploys);
renderLeadChart(leadTime);
renderCFRChart(cfr);
renderCfrPrs(cfrPrs);
renderHotfixes(hotfixes);
renderReviewLatencyChart(reviewLatency);
}

function renderKPIs(summary, freqPrs, freqDeploys, leadTime, cfr) {
Expand Down Expand Up @@ -408,6 +410,58 @@ function renderCFRChart(cfr) {
}));
}

const REVIEW_LATENCY_BUCKETS = [
{ label: "XS", color: "#2d5a8e" },
{ label: "S", color: "#4a86c7" },
{ label: "M", color: "#b4450a" },
{ label: "L+", color: "#6a3d9a" },
];

function renderReviewLatencyChart(rows) {
const ctx = document.getElementById("reviewLatencyChart");
if (!rows.length) {
ctx.parentElement.innerHTML = '<div class="empty">No data yet</div>';
return;
}

// One sorted week axis spanning all buckets.
const weeks = [...new Set(rows.map(r => r.week))].sort();

// Per-bucket map: week → median_h.
const byBucket = {};
for (const r of rows) {
(byBucket[r.bucket] = byBucket[r.bucket] || {})[r.week] = r.median_h;
}

const datasets = REVIEW_LATENCY_BUCKETS.map(({ label, color }) => ({
label,
data: weeks.map(w => {
const v = byBucket[label]?.[w];
return v == null ? null : v;
}),
borderColor: color,
backgroundColor: color,
borderWidth: 2,
pointRadius: 2.5,
tension: 0.25,
spanGaps: false,
}));

const opts = baseOpts();
opts.scales.y.title = {
display: true,
text: "hours",
color: readVar("--chart-tick"),
font: { size: 11 },
};

charts.push(new Chart(ctx, {
type: "line",
data: { labels: weeks, datasets },
options: opts,
}));
}

function renderCfrPrs(rows) {
const el = document.getElementById("cfr-prs");
if (!rows.length) { el.innerHTML = ""; return; }
Expand Down
54 changes: 54 additions & 0 deletions dashboard/fixtures/sample.json
Original file line number Diff line number Diff line change
Expand Up @@ -821,6 +821,60 @@
}
]
},
{
"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+)",
"data": [
{"repo": "acme/example", "week": "2025-W43", "bucket": "XS", "n_prs": 4, "median_h": 3.2, "p90_h": 8.0},
{"repo": "acme/example", "week": "2025-W43", "bucket": "S", "n_prs": 3, "median_h": 6.5, "p90_h": 14.0},
{"repo": "acme/example", "week": "2025-W43", "bucket": "M", "n_prs": 2, "median_h": 18.0, "p90_h": 28.0},
{"repo": "acme/example", "week": "2025-W43", "bucket": "L+", "n_prs": 1, "median_h": 42.0, "p90_h": 42.0},
{"repo": "acme/example", "week": "2025-W44", "bucket": "XS", "n_prs": 5, "median_h": 2.8, "p90_h": 6.5},
{"repo": "acme/example", "week": "2025-W44", "bucket": "S", "n_prs": 4, "median_h": 7.1, "p90_h": 16.0},
{"repo": "acme/example", "week": "2025-W44", "bucket": "M", "n_prs": 3, "median_h": 22.0, "p90_h": 35.0},
{"repo": "acme/example", "week": "2025-W44", "bucket": "L+", "n_prs": 2, "median_h": 38.0, "p90_h": 50.0},
{"repo": "acme/example", "week": "2025-W45", "bucket": "XS", "n_prs": 6, "median_h": 4.1, "p90_h": 9.0},
{"repo": "acme/example", "week": "2025-W45", "bucket": "S", "n_prs": 4, "median_h": 8.0, "p90_h": 18.0},
{"repo": "acme/example", "week": "2025-W45", "bucket": "M", "n_prs": 2, "median_h": 16.0, "p90_h": 24.0},
{"repo": "acme/example", "week": "2025-W45", "bucket": "L+", "n_prs": 1, "median_h": 55.0, "p90_h": 55.0},
{"repo": "acme/example", "week": "2025-W46", "bucket": "XS", "n_prs": 3, "median_h": 3.5, "p90_h": 7.0},
{"repo": "acme/example", "week": "2025-W46", "bucket": "S", "n_prs": 5, "median_h": 6.2, "p90_h": 13.0},
{"repo": "acme/example", "week": "2025-W46", "bucket": "M", "n_prs": 3, "median_h": 20.0, "p90_h": 32.0},
{"repo": "acme/example", "week": "2025-W46", "bucket": "L+", "n_prs": 2, "median_h": 48.0, "p90_h": 60.0},
{"repo": "acme/example", "week": "2025-W47", "bucket": "XS", "n_prs": 4, "median_h": 2.5, "p90_h": 5.0},
{"repo": "acme/example", "week": "2025-W47", "bucket": "S", "n_prs": 3, "median_h": 5.8, "p90_h": 12.0},
{"repo": "acme/example", "week": "2025-W47", "bucket": "M", "n_prs": 4, "median_h": 19.0, "p90_h": 30.0},
{"repo": "acme/example", "week": "2025-W47", "bucket": "L+", "n_prs": 1, "median_h": 36.0, "p90_h": 36.0},
{"repo": "acme/example", "week": "2025-W48", "bucket": "XS", "n_prs": 5, "median_h": 3.0, "p90_h": 6.0},
{"repo": "acme/example", "week": "2025-W48", "bucket": "S", "n_prs": 4, "median_h": 7.5, "p90_h": 15.0},
{"repo": "acme/example", "week": "2025-W48", "bucket": "M", "n_prs": 3, "median_h": 17.0, "p90_h": 26.0},
{"repo": "acme/example", "week": "2025-W48", "bucket": "L+", "n_prs": 2, "median_h": 44.0, "p90_h": 58.0},
{"repo": "acme/example", "week": "2025-W49", "bucket": "XS", "n_prs": 4, "median_h": 3.8, "p90_h": 7.5},
{"repo": "acme/example", "week": "2025-W49", "bucket": "S", "n_prs": 3, "median_h": 6.8, "p90_h": 14.0},
{"repo": "acme/example", "week": "2025-W49", "bucket": "M", "n_prs": 2, "median_h": 21.0, "p90_h": 33.0},
{"repo": "acme/example", "week": "2025-W49", "bucket": "L+", "n_prs": 1, "median_h": 40.0, "p90_h": 40.0},
{"repo": "acme/example", "week": "2025-W50", "bucket": "XS", "n_prs": 5, "median_h": 2.9, "p90_h": 6.5},
{"repo": "acme/example", "week": "2025-W50", "bucket": "S", "n_prs": 4, "median_h": 7.2, "p90_h": 14.5},
{"repo": "acme/example", "week": "2025-W50", "bucket": "M", "n_prs": 3, "median_h": 18.5, "p90_h": 28.0},
{"repo": "acme/example", "week": "2025-W50", "bucket": "L+", "n_prs": 2, "median_h": 46.0, "p90_h": 62.0},
{"repo": "acme/example", "week": "2026-W01", "bucket": "XS", "n_prs": 3, "median_h": 4.5, "p90_h": 9.0},
{"repo": "acme/example", "week": "2026-W01", "bucket": "S", "n_prs": 5, "median_h": 8.5, "p90_h": 17.0},
{"repo": "acme/example", "week": "2026-W01", "bucket": "M", "n_prs": 3, "median_h": 24.0, "p90_h": 38.0},
{"repo": "acme/example", "week": "2026-W01", "bucket": "L+", "n_prs": 1, "median_h": 52.0, "p90_h": 52.0},
{"repo": "acme/example", "week": "2026-W02", "bucket": "XS", "n_prs": 4, "median_h": 3.3, "p90_h": 7.0},
{"repo": "acme/example", "week": "2026-W02", "bucket": "S", "n_prs": 4, "median_h": 6.5, "p90_h": 13.0},
{"repo": "acme/example", "week": "2026-W02", "bucket": "M", "n_prs": 3, "median_h": 19.5, "p90_h": 31.0},
{"repo": "acme/example", "week": "2026-W02", "bucket": "L+", "n_prs": 2, "median_h": 41.0, "p90_h": 54.0},
{"repo": "acme/example", "week": "2026-W03", "bucket": "XS", "n_prs": 5, "median_h": 2.7, "p90_h": 5.5},
{"repo": "acme/example", "week": "2026-W03", "bucket": "S", "n_prs": 4, "median_h": 7.0, "p90_h": 14.0},
{"repo": "acme/example", "week": "2026-W03", "bucket": "M", "n_prs": 4, "median_h": 18.0, "p90_h": 29.0},
{"repo": "acme/example", "week": "2026-W03", "bucket": "L+", "n_prs": 1, "median_h": 38.0, "p90_h": 38.0},
{"repo": "acme/example", "week": "2026-W04", "bucket": "XS", "n_prs": 4, "median_h": 3.6, "p90_h": 8.0},
{"repo": "acme/example", "week": "2026-W04", "bucket": "S", "n_prs": 3, "median_h": 6.0, "p90_h": 12.0},
{"repo": "acme/example", "week": "2026-W04", "bucket": "M", "n_prs": 2, "median_h": 17.5, "p90_h": 27.0},
{"repo": "acme/example", "week": "2026-W04", "bucket": "L+", "n_prs": 2, "median_h": 43.0, "p90_h": 56.0}
]
},
{
"metric": "summary",
"description": "Per-repo roll-up over the whole window",
Expand Down
18 changes: 18 additions & 0 deletions dashboard/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,24 @@ <h1>DORA metrics</h1>
<div id="hotfixes"></div>
</div>
</div>

<div class="panel">
<div class="panel-head">
<p class="panel-title">Review latency</p>
<p class="panel-sub" title="merged − ready_for_review_at (or opened_at, if never drafted). Buckets: XS=1, S=2-3, M=4-9, L+=10+ files.">Median hours waiting for review, by PR size</p>
</div>
<div class="legend">
<span><i style="background: #2d5a8e;"></i>XS (1 file)</span>
<span><i style="background: #4a86c7;"></i>S (2–3)</span>
<span><i style="background: #b4450a;"></i>M (4–9)</span>
<span><i style="background: #6a3d9a;"></i>L+ (10+)</span>
</div>
<div class="chart-wrap tall">
<canvas id="reviewLatencyChart" role="img" aria-label="Weekly review latency by PR size">
Review latency by PR size
</canvas>
</div>
</div>
</section>
</div>
</div>
Expand Down
Loading
Loading