How to Calculate DORA Metrics from GitHub Data (Step-by-Step)
Most teams have GitHub. Many teams do not yet have a dedicated engineering metrics platform. This guide walks through the exact API calls, data structures, and calculation logic you need to instrument all four DORA metrics from your GitHub organization data — including where GitHub alone is not sufficient and what you need to add.
What this guide covers
GitHub API endpoints for each DORA metric, the exact calculation logic including edge cases, Python code examples using the GitHub REST API, and where you need supplementary data sources (incident tools for MTTR).
Prerequisites and Setup
You will need a GitHub personal access token or GitHub App installation token with the following scopes: repo (for private repos), read:org(for organization-wide queries), and deployments. All examples below use the GitHub REST API v3 — the GraphQL API can be used for more efficient bulk queries once you understand the data model.
import requests
from datetime import datetime, timedelta
import statistics
GITHUB_TOKEN = "ghp_..."
OWNER = "your-org"
REPO = "your-repo"
HEADERS = {
"Authorization": f"token {GITHUB_TOKEN}",
"Accept": "application/vnd.github.v3+json"
}
BASE_URL = "https://api.github.com"Metric 1: Deployment Frequency
Deployment frequency requires knowing when production deployments happened. GitHub provides two mechanisms: the Deployments API (requires your CI/CD to push deployment events) and GitHub Releases (for teams using release tags as deployment markers).
Option A: GitHub Deployments API (preferred)
If your CI/CD pipeline creates GitHub Deployment objects, this is the most accurate source. Each deployment has an environment field — filter for production.
def get_production_deployments(owner, repo, days=30):
"""Fetch successful production deployments in the last N days."""
since = (datetime.utcnow() - timedelta(days=days)).isoformat() + "Z"
url = f"{BASE_URL}/repos/{owner}/{repo}/deployments"
params = {"environment": "production", "per_page": 100}
deployments = []
while url:
resp = requests.get(url, headers=HEADERS, params=params)
resp.raise_for_status()
data = resp.json()
for deploy in data:
created = deploy["created_at"]
if created >= since:
# Fetch the status to confirm it succeeded
status_url = deploy["statuses_url"]
status_resp = requests.get(status_url, headers=HEADERS)
statuses = status_resp.json()
if statuses and statuses[0]["state"] == "success":
deployments.append(deploy)
url = resp.links.get("next", {}).get("url")
return deployments
def calculate_deployment_frequency(deployments, days=30):
"""Returns deployments per day."""
return len(deployments) / days
deploys = get_production_deployments(OWNER, REPO, days=30)
freq = calculate_deployment_frequency(deploys, days=30)
print(f"Deployment frequency: {freq:.2f} per day")Option B: GitHub Releases as a proxy
If your team tags releases in GitHub but does not use the Deployments API, releases are a reasonable proxy — but they overcount if you create pre-release or draft releases, and undercount if you deploy hotfixes without creating a release.
def get_releases(owner, repo, days=30):
since = (datetime.utcnow() - timedelta(days=days)).isoformat() + "Z"
url = f"{BASE_URL}/repos/{owner}/{repo}/releases"
releases = []
while url:
resp = requests.get(url, headers=HEADERS, params={"per_page": 100})
data = resp.json()
for rel in data:
# Skip drafts and pre-releases for deployment frequency
if not rel["draft"] and not rel["prerelease"]:
if rel["published_at"] >= since:
releases.append(rel)
url = resp.links.get("next", {}).get("url")
return releasesMetric 2: Lead Time for Changes
Lead time is the elapsed time from PR merge to production deployment. This requires correlating PR merge timestamps with deployment timestamps via the commit SHA.
def get_merged_prs(owner, repo, days=30):
"""Fetch PRs merged in the last N days."""
since = (datetime.utcnow() - timedelta(days=days)).isoformat() + "Z"
url = f"{BASE_URL}/repos/{owner}/{repo}/pulls"
params = {"state": "closed", "sort": "updated", "per_page": 100}
merged_prs = []
while url:
resp = requests.get(url, headers=HEADERS, params=params)
data = resp.json()
for pr in data:
if pr.get("merged_at") and pr["merged_at"] >= since:
merged_prs.append({
"number": pr["number"],
"merged_at": pr["merged_at"],
"merge_commit_sha": pr["merge_commit_sha"],
})
url = resp.links.get("next", {}).get("url")
return merged_prs
def calculate_lead_times(merged_prs, deployments):
"""For each PR, find the first deployment that contains its merge commit."""
lead_times = []
# Build a lookup of deployment SHA -> deployment timestamp
deploy_by_sha = {}
for d in deployments:
deploy_by_sha[d["sha"]] = d["created_at"]
for pr in merged_prs:
sha = pr["merge_commit_sha"]
if sha in deploy_by_sha:
merged = datetime.fromisoformat(pr["merged_at"].rstrip("Z"))
deployed = datetime.fromisoformat(deploy_by_sha[sha].rstrip("Z"))
lead_time_hours = (deployed - merged).total_seconds() / 3600
if lead_time_hours >= 0:
lead_times.append(lead_time_hours)
if lead_times:
# Use median — lead time distributions are right-skewed
return statistics.median(lead_times)
return None
prs = get_merged_prs(OWNER, REPO, days=30)
median_lt = calculate_lead_times(prs, deploys)
print(f"Median lead time: {median_lt:.1f} hours")Important edge case: GitHub's Deployments API stores the commit SHA of the deployment, which may be a merge commit rather than the PR's merge commit SHA. If your pipeline rebases or squash-merges, you will need to walk the commit graph to find which deployment contains a given PR's commit. The simplest workaround: store the PR number or branch name in the deployment description field so you can match directly.
Metric 3: Change Failure Rate
CFR requires identifying which deployments were failures. The definition varies by team, but the most reliable approach combines two signals: deployment status and incident correlation.
Signal 1: Rollback/hotfix detection from PR patterns
import re
ROLLBACK_PATTERNS = [
r"^revert",
r"^rollback",
r"^hotfix",
r"^fix.*production",
r"^emergency",
]
def is_unplanned_deployment(pr_title, branch_name):
"""Returns True if the PR looks like a rollback or hotfix."""
text = (pr_title + " " + branch_name).lower()
return any(re.search(pat, text) for pat in ROLLBACK_PATTERNS)
def calculate_cfr(merged_prs):
"""Basic CFR from PR title pattern matching."""
if not merged_prs:
return 0
failed = sum(
1 for pr in merged_prs
if is_unplanned_deployment(
pr.get("title", ""),
pr.get("head", {}).get("ref", "")
)
)
return (failed / len(merged_prs)) * 100Signal 2: Deployment status failures
def get_failed_deployments(owner, repo, days=30):
"""Find deployments that ended in failure status."""
all_deploys = get_production_deployments.__wrapped__(owner, repo, days)
failed = []
for deploy in all_deploys:
status_resp = requests.get(deploy["statuses_url"], headers=HEADERS)
statuses = status_resp.json()
if statuses and statuses[0]["state"] in ("failure", "error"):
failed.append(deploy)
return failedMetric 4: MTTR — What GitHub Cannot Give You
MTTR requires incident data: when the incident was detected and when service was restored. GitHub does not have this natively. You need to pull data from your incident management tool.
PagerDuty
PD_TOKEN = "your-pagerduty-token"
PD_HEADERS = {"Authorization": f"Token token={PD_TOKEN}"}
def get_pagerduty_incidents(days=30):
since = (datetime.utcnow() - timedelta(days=days)).isoformat() + "Z"
url = "https://api.pagerduty.com/incidents"
params = {
"since": since,
"statuses[]": ["resolved"],
"limit": 100,
}
resp = requests.get(url, headers=PD_HEADERS, params=params)
return resp.json().get("incidents", [])
def calculate_mttr_hours(incidents):
"""Mean time from incident created to resolved."""
durations = []
for inc in incidents:
created = datetime.fromisoformat(inc["created_at"].rstrip("Z"))
resolved = datetime.fromisoformat(
inc["last_status_change_at"].rstrip("Z")
)
hours = (resolved - created).total_seconds() / 3600
durations.append(hours)
return statistics.mean(durations) if durations else None
incidents = get_pagerduty_incidents(days=30)
mttr = calculate_mttr_hours(incidents)
print(f"Mean MTTR: {mttr:.1f} hours")OpsGenie / incident.io
OpsGenie exposes the same pattern through its Alerts API: GET /v2/alerts?status=closed&createdAt>=.... Each alert has acreatedAt and a closedAt field. incident.io uses a similar REST API with /v1/incidents?status=resolved.
Putting It All Together: A Weekly DORA Report
def weekly_dora_report(owner, repo, days=7):
deploys = get_production_deployments(owner, repo, days)
prs = get_merged_prs(owner, repo, days)
incidents = get_pagerduty_incidents(days)
freq = calculate_deployment_frequency(deploys, days)
lead_time = calculate_lead_times(prs, deploys)
cfr = calculate_cfr(prs)
mttr = calculate_mttr_hours(incidents)
print(f"=== DORA Report ({days}d) ===")
print(f"Deployment Frequency: {freq:.2f}/day")
print(f"Lead Time (median): {lead_time:.1f}h" if lead_time else "Lead Time: insufficient data")
print(f"Change Failure Rate: {cfr:.1f}%")
print(f"MTTR (mean): {mttr:.1f}h" if mttr else "MTTR: no incidents")
weekly_dora_report(OWNER, REPO, days=30)Common Calculation Mistakes
Before you trust these numbers, check for the most common errors teams make when implementing DORA from scratch:
- Counting PRs instead of deployments. A PR merged to main is not a deployment. If you deploy once per day regardless of how many PRs merged, your deployment frequency is once per day — not once per PR.
- Using UTC timestamps without timezone adjustment. A Friday 5pm EST deploy is a Friday deploy risk. In UTC, it appears as a Saturday midnight event. Always convert to the team's local timezone before day-of-week analysis.
- Measuring repository frequency, not service frequency. A monorepo that deploys five services from a single merge produces five deployment events. Count them separately per service, not once per repo push.
- Using mean instead of median for lead time. A single large refactor PR with a 2-week lead time skews the mean dramatically. Median is the correct central tendency measure for skewed distributions.
Skip the implementation — connect GitHub in 5 minutes
The code above is the right foundation for a custom implementation. If you want DORA metrics today without building and maintaining the pipeline, Koalr handles all of this automatically — including the incident correlation, SHA matching, and timezone normalization that make the data trustworthy.
Get accurate DORA metrics without writing the pipeline yourself
Koalr connects to GitHub, PagerDuty, OpsGenie, and incident.io and calculates all four DORA metrics automatically — with correct SHA correlation, timezone handling, and service-level segmentation out of the box.