Measure PR delivery metrics
Understanding how pull requests flow through your engineering organization is essential for identifying delivery bottlenecks, measuring team efficiency, and driving continuous improvement. Without PR-level visibility, engineering leaders lack the data needed to spot stale reviews, unbalanced workloads, or degrading cycle times before they impact delivery.
This guide walks you through building a comprehensive PR delivery metrics dashboard in Port that answers critical questions at both the service level and team level:
- Throughput: How many PRs are being merged weekly and monthly?
- Cycle time: How long does it take from PR creation to merge?
- Staleness: Which PRs have been open for more than 7 days, and what share do they represent?
- Quality indicators: Do open PRs have reviewers and assignees assigned?
By the end of this guide, you will have a dashboard that provides full visibility into PR delivery health across services and teams, helping you identify improvement areas and track progress over time.
Common use cases
- Track PR cycle time trends to identify slowdowns in code review and CI processes.
- Monitor PR throughput to understand team delivery capacity and detect regressions.
- Surface stale PRs that have been open longer than 7 days to unblock delivery.
- Identify PRs without assigned reviewers or assignees to improve process compliance.
- Compare delivery metrics across teams to understand organizational performance.
Prerequisites
This guide assumes the following:
- You have a Port account and have completed the onboarding process.
- Port's GitHub integration is installed in your account.
- The
githubPullRequest,githubRepository,githubUser, andgithubTeamblueprints already exist (these are created automatically when you install the GitHub integration).
Key metrics overview
This dashboard tracks PR delivery metrics across two levels — individual services and teams:
| Metric | What it measures | Why it matters |
|---|---|---|
| Open PRs | Total number of currently open PRs | Shows current work in progress and potential bottleneck signals |
| PR throughput | Number of PRs merged per week/month | Indicates delivery flow and team output capacity |
| Throughput trend | Weekly vs. monthly throughput direction | Reveals whether delivery velocity is improving or degrading |
| Stale PRs | PRs open longer than 7 days | Highlights blocked work, unclear ownership, or review delays |
| Stale PR share (%) | Percentage of open PRs that are stale | Quantifies the severity of staleness across the portfolio |
| Cycle time | Hours from PR creation to merge (weekly/monthly avg) | Exposes friction in reviews, CI, and approval processes |
| Cycle time trend | Weekly vs. monthly cycle time direction | Shows whether review and merge speed is improving over time |
| Reviewer coverage | Whether open PRs have reviewers assigned | Signals process compliance and review readiness |
| Assignee coverage | Whether open PRs have assignees assigned | Indicates ownership clarity for open work |
Set up data model
The GitHub integration automatically creates the githubPullRequest, githubRepository, githubUser, and githubTeam blueprints with default properties. The pull request blueprint comes with status, createdAt, mergedAt, closedAt, updatedAt, prNumber, link, branch, and leadTimeHours out of the box, along with relations for repository, git_hub_creator, git_hub_assignees, git_hub_reviewers, and a dynamic service relation.
We need to extend these default blueprints with additional properties for cycle time, staleness tracking, and quality indicators, then add aggregation and calculation properties to the service and Team blueprints to surface metrics at those levels.
Update the GitHub pull request blueprint
Add properties for cycle time measurement, quality indicators, and staleness tracking to the existing githubPullRequest blueprint.
-
Go to the Builder page of your portal.
-
Find the GitHub Pull Request blueprint and click on it.
-
Click on the
{...}button in the top right corner, and choose Edit JSON. -
Add the following properties to the
propertiessection of theschemaobject (alongside the existing default properties):Additional PR properties (click to expand)
"cycle_time_hours": {
"title": "PR Cycle Time (Hours)",
"type": "number",
"description": "Time from PR creation to merge in hours"
},
"has_assignees": {
"title": "Has Assignees",
"type": "boolean",
"description": "Whether the PR has at least one assignee"
},
"has_reviewers": {
"title": "Has Reviewers",
"type": "boolean",
"description": "Whether the PR has at least one reviewer assigned"
},
"reviewDecision": {
"title": "Review Decision",
"type": "string"
} -
Add the following entry to the existing
mirrorPropertiessection:PR mirror property (click to expand)
"team_name": {
"title": "Team",
"path": "service.$team"
} -
Add the following entries to the existing
calculationPropertiessection:PR calculation properties (click to expand)
"days_old": {
"title": "Days Old",
"type": "number",
"calculation": "(now / 86400) - (.properties.createdAt | capture(\"(?<date>\\\\d{4}-\\\\d{2}-\\\\d{2})\") | .date | strptime(\"%Y-%m-%d\") | mktime / 86400) | floor"
},
"is_stale": {
"title": "Is Stale (7d+)",
"type": "boolean",
"calculation": "if .properties.status == \"open\" then ((now - (.properties.createdAt | sub(\"\\\\.[0-9]+Z$\"; \"Z\") | fromdateiso8601)) / 86400) > 7 else false end"
},
"pr_age_label": {
"title": "PR Age Label",
"type": "string",
"calculation": "((now - (.properties.createdAt | sub(\"\\\\.[0-9]+Z$\"; \"Z\") | fromdateiso8601)) / 86400 | round) as $age | if $age <= 3 then \"0-3 days\" elif $age <= 7 then \"3-7 days\" elif $age <= 30 then \"7-30 days\" else \">30 days\" end",
"colorized": true,
"colors": {
"0-3 days": "green",
"3-7 days": "orange",
"7-30 days": "red",
">30 days": "red"
}
},
"blocked_by_workflow": {
"title": "Blocked by Workflow",
"type": "boolean",
"calculation": "if .properties.status == \"open\" and .aggregationProperties.failed_workflow_runs > 0 then true else false end"
} -
Add the following entry to the existing
aggregationPropertiessection:PR aggregation property (click to expand)
"failed_workflow_runs": {
"title": "Failed Workflow Runs",
"type": "number",
"target": "githubWorkflowRun",
"query": {
"combinator": "and",
"rules": [
{
"property": "conclusion",
"operator": "=",
"value": "failure"
}
]
},
"calculationSpec": {
"func": "count",
"calculationBy": "entities"
}
}Existing relationsThe default
githubPullRequestblueprint already includes relations forrepository,git_hub_creator,git_hub_assignees,git_hub_reviewers, and a dynamicservicerelation. No changes to relations are needed. -
Click Save to update the blueprint.
Update the service blueprint
Add aggregation and calculation properties to the service blueprint so that each service displays its own PR delivery metrics.
-
Go to your Builder page.
-
Find the Service blueprint and click on it.
-
Click on the
{...}button in the top right corner, and choose Edit JSON. -
Add the following entries to the
aggregationPropertiessection of the blueprint:Service aggregation properties (click to expand)
"open_prs": {
"title": "Open PRs",
"type": "number",
"target": "githubPullRequest",
"query": {
"combinator": "and",
"rules": [
{
"property": "status",
"operator": "=",
"value": "open"
}
]
},
"calculationSpec": {
"func": "count",
"calculationBy": "entities"
}
},
"stale_prs_7d": {
"title": "Stale PRs (7d)",
"type": "number",
"target": "githubPullRequest",
"query": {
"combinator": "and",
"rules": [
{
"property": "status",
"operator": "=",
"value": "open"
},
{
"property": "is_stale",
"operator": "=",
"value": true
}
]
},
"calculationSpec": {
"func": "count",
"calculationBy": "entities"
}
},
"merged_prs_last_week": {
"title": "Merged PRs (Last Week)",
"type": "number",
"target": "githubPullRequest",
"query": {
"combinator": "and",
"rules": [
{
"property": "status",
"operator": "=",
"value": "merged"
},
{
"property": "mergedAt",
"operator": "between",
"value": {
"preset": "last7Days"
}
}
]
},
"calculationSpec": {
"func": "count",
"calculationBy": "entities"
}
},
"merged_prs_last_month": {
"title": "Merged PRs (Last Month)",
"type": "number",
"target": "githubPullRequest",
"query": {
"combinator": "and",
"rules": [
{
"property": "status",
"operator": "=",
"value": "merged"
},
{
"property": "mergedAt",
"operator": "between",
"value": {
"preset": "lastMonth"
}
}
]
},
"calculationSpec": {
"func": "count",
"calculationBy": "entities"
}
},
"pr_cycle_time": {
"title": "Monthly PR Cycle Time",
"type": "number",
"target": "githubPullRequest",
"query": {
"combinator": "and",
"rules": [
{
"property": "status",
"operator": "=",
"value": "merged"
},
{
"property": "mergedAt",
"operator": "between",
"value": {
"preset": "lastMonth"
}
}
]
},
"calculationSpec": {
"func": "average",
"calculationBy": "property",
"property": "cycle_time_hours"
}
},
"pr_cycle_time_weekly": {
"title": "Weekly PR Cycle Time",
"type": "number",
"target": "githubPullRequest",
"query": {
"combinator": "and",
"rules": [
{
"property": "status",
"operator": "=",
"value": "merged"
},
{
"property": "mergedAt",
"operator": "between",
"value": {
"preset": "last7Days"
}
}
]
},
"calculationSpec": {
"func": "average",
"calculationBy": "property",
"property": "cycle_time_hours"
}
} -
Add the following entries to the
calculationPropertiessection of the blueprint:Service calculation properties (click to expand)
"stale_pr_share_percent": {
"title": "Stale PR Share (%)",
"type": "number",
"calculation": "if .aggregationProperties.open_prs > 0 then ((.aggregationProperties.stale_prs_7d / .aggregationProperties.open_prs) * 100 | floor) else 0 end"
},
"cycle_time_trend": {
"title": "Cycle Time Trend",
"type": "string",
"colorized": true,
"colors": {
"Improving": "green",
"Degrading": "red",
"Stable": "blue"
},
"calculation": "if .aggregationProperties.pr_cycle_time == null or .aggregationProperties.pr_cycle_time_weekly == null then \"Stable\" elif .aggregationProperties.pr_cycle_time_weekly < (.aggregationProperties.pr_cycle_time * 0.9) then \"Improving\" elif .aggregationProperties.pr_cycle_time_weekly > (.aggregationProperties.pr_cycle_time * 1.1) then \"Degrading\" else \"Stable\" end"
},
"throughput_trend": {
"title": "Throughput Trend",
"type": "string",
"colorized": true,
"colors": {
"Improving": "green",
"Degrading": "red",
"Stable": "blue"
},
"calculation": "if .aggregationProperties.merged_prs_last_month == null or .aggregationProperties.merged_prs_last_week == null then \"Stable\" elif (.aggregationProperties.merged_prs_last_week * 4) > (.aggregationProperties.merged_prs_last_month * 1.1) then \"Improving\" elif (.aggregationProperties.merged_prs_last_week * 4) < (.aggregationProperties.merged_prs_last_month * 0.9) then \"Degrading\" else \"Stable\" end"
} -
Click Save to update the blueprint.
Update the team blueprint
Add aggregation and calculation properties to the Team blueprint to aggregate delivery metrics across all services owned by each team.
-
Go to your Builder page.
-
Find the Team blueprint and click on it.
-
Click on the
{...}button in the top right corner, and choose Edit JSON. -
Add the following entries to the
aggregationPropertiessection of the blueprint:Team aggregation properties (click to expand)
"services_count": {
"title": "Services Count",
"type": "number",
"target": "service",
"calculationSpec": {
"func": "count",
"calculationBy": "entities"
}
},
"open_prs": {
"title": "Open PRs",
"type": "number",
"target": "service",
"calculationSpec": {
"func": "sum",
"calculationBy": "property",
"property": "open_prs"
}
},
"stale_prs_7d": {
"title": "Stale PRs (7d)",
"type": "number",
"target": "service",
"calculationSpec": {
"func": "sum",
"calculationBy": "property",
"property": "stale_prs_7d"
}
},
"merged_prs_last_week": {
"title": "Merged PRs (Last Week)",
"type": "number",
"target": "service",
"calculationSpec": {
"func": "sum",
"calculationBy": "property",
"property": "merged_prs_last_week"
}
},
"merged_prs_last_month": {
"title": "Merged PRs (Last Month)",
"type": "number",
"target": "service",
"calculationSpec": {
"func": "sum",
"calculationBy": "property",
"property": "merged_prs_last_month"
}
},
"pr_cycle_time_weekly": {
"title": "Weekly PR Cycle Time",
"type": "number",
"target": "service",
"calculationSpec": {
"func": "average",
"calculationBy": "property",
"property": "pr_cycle_time_weekly"
}
},
"pr_cycle_time": {
"title": "Monthly PR Cycle Time",
"type": "number",
"target": "service",
"calculationSpec": {
"func": "average",
"calculationBy": "property",
"property": "pr_cycle_time"
}
} -
Add the following entries to the
calculationPropertiessection of the blueprint:Team calculation properties (click to expand)
"stale_pr_share_percent": {
"title": "Stale PR Share (%)",
"type": "number",
"calculation": "if .aggregationProperties.open_prs > 0 then ((.aggregationProperties.stale_prs_7d / .aggregationProperties.open_prs) * 100 | floor) else 0 end"
},
"cycle_time_trend": {
"title": "Cycle Time Trend",
"type": "string",
"colorized": true,
"colors": {
"Improving": "green",
"Degrading": "red",
"Stable": "blue"
},
"calculation": "if .aggregationProperties.pr_cycle_time == null or .aggregationProperties.pr_cycle_time_weekly == null then \"Stable\" elif .aggregationProperties.pr_cycle_time_weekly < (.aggregationProperties.pr_cycle_time * 0.9) then \"Improving\" elif .aggregationProperties.pr_cycle_time_weekly > (.aggregationProperties.pr_cycle_time * 1.1) then \"Degrading\" else \"Stable\" end"
},
"throughput_trend": {
"title": "Throughput Trend",
"type": "string",
"colorized": true,
"colors": {
"Improving": "green",
"Degrading": "red",
"Stable": "blue"
},
"calculation": "if .aggregationProperties.merged_prs_last_month == null or .aggregationProperties.merged_prs_last_week == null then \"Stable\" elif (.aggregationProperties.merged_prs_last_week * 4) > (.aggregationProperties.merged_prs_last_month * 1.1) then \"Improving\" elif (.aggregationProperties.merged_prs_last_week * 4) < (.aggregationProperties.merged_prs_last_month * 0.9) then \"Degrading\" else \"Stable\" end"
},
"merged_prs_per_service_last_month": {
"title": "Merged PRs per Service (Monthly)",
"type": "number",
"calculation": "if .aggregationProperties.services_count > 0 then (.aggregationProperties.merged_prs_last_month / .aggregationProperties.services_count | floor) else 0 end"
},
"open_prs_per_service": {
"title": "Open PRs per Service",
"type": "number",
"calculation": "if .aggregationProperties.services_count > 0 then (.aggregationProperties.open_prs / .aggregationProperties.services_count | floor) else 0 end"
},
"stale_prs_per_service_7d": {
"title": "Stale PRs per Service (7d)",
"type": "number",
"calculation": "if .aggregationProperties.services_count > 0 then (.aggregationProperties.stale_prs_7d / .aggregationProperties.services_count | floor) else 0 end"
} -
Click Save to update the blueprint.
Update integration mapping
Now we'll update the GitHub integration mapping to populate the new properties we added to the pull request blueprint. The default mapping already handles status, createdAt, mergedAt, closedAt, prNumber, link, branch, leadTimeHours, and all user/repository relations. We only need to add mappings for the new fields.
-
Go to your Data Source page.
-
Select the GitHub integration.
-
Find the first
pull-requestresource block in the mapping and add these additional property mappings alongside the existing ones:Additional pull request property mappings (click to expand)
# Add these properties to the first pull-request resource mapping
# alongside the existing properties (status, closedAt, mergedAt, etc.)
reviewDecision: .review_decision
cycle_time_hours: >-
(.created_at as $createdAt | .merged_at as $mergedAt |
($createdAt | sub("\\..*Z$"; "Z") |
strptime("%Y-%m-%dT%H:%M:%SZ") | mktime) as
$createdTimestamp | ($mergedAt | if . == null then null
else sub("\\..*Z$"; "Z") |
strptime("%Y-%m-%dT%H:%M:%SZ") | mktime end) as
$mergedTimestamp | if $mergedTimestamp == null then null
else (((($mergedTimestamp - $createdTimestamp) / 3600) *
100 | floor) / 100) end)
has_assignees: .assignees | length > 0
has_reviewers: .requested_reviewers | length > 0 -
Also in the first
pull-requestresource block, setclosedPullRequests: truein theselectorto ensure merged and closed PRs are ingested:- kind: pull-request
selector:
query: 'true'
closedPullRequests: true -
Click Save & Resync to apply the mapping.
By default, the GitHub integration only fetches open pull requests. Setting closedPullRequests: true ensures that merged and closed PRs are also ingested, which is required for cycle time and throughput calculations.
Visualize metrics
We will create a dedicated dashboard to monitor PR delivery metrics using Port's customizable widgets. The dashboard is organized into sections covering high-level metrics, service performance, team performance, stale PR analysis, and PR quality indicators.
Create the dashboard
First, let's create an Engineering Intelligence folder to organize your dashboards, then add the Delivery Performance dashboard inside it:
- Navigate to your software catalog.
- Click on the
+ Newbutton in the left sidebar. - Select New folder.
- Name the folder Engineering Intelligence and click Create.
- Inside the Engineering Intelligence folder, click
+ Newagain. - Select New dashboard.
- Name the dashboard Delivery Performance and click Create.
Add widgets
You can populate the dashboard using either an API script or by manually creating each widget through the UI.
- API script
- Manual setup
The fastest way to set up the dashboard is by using Port's API to create all widgets at once.
Get your Port API token
-
In your Port portal, click on your profile picture in the bottom left corner.
-
Select Credentials.
-
Click Generate API token.
-
Copy the generated token and store it as an environment variable:
export PORT_ACCESS_TOKEN="YOUR_GENERATED_TOKEN"
If your portal is hosted in the EU region, replace api.getport.io with api.port-eu.io in the dashboard creation command below.
Create the dashboard with widgets
Save the following JSON to a file named dp_dashboard.json:Dashboard JSON payload (click to expand)
{
"identifier": "delivery_performance",
"title": "Delivery Performance",
"icon": "Apps",
"type": "dashboard",
"parent": "engineering_intelligence",
"widgets": [
{
"id": "dpDashboardWidget",
"type": "dashboard-widget",
"layout": [
{
"height": 400,
"columns": [
{"id": "avgCycleTime", "size": 4},
{"id": "prCycleTimeTrend", "size": 4},
{"id": "prThroughputTrend", "size": 4}
]
},
{
"height": 400,
"columns": [
{"id": "totalOpenPrs", "size": 4},
{"id": "stalePrShare", "size": 4},
{"id": "teamThroughputBar", "size": 4}
]
},
{
"height": 400,
"columns": [
{"id": "servicePerformance", "size": 12}
]
},
{
"height": 400,
"columns": [
{"id": "teamPerformance", "size": 12}
]
},
{
"height": 484,
"columns": [
{"id": "stalePrTable", "size": 8},
{"id": "prAgeDistribution", "size": 4}
]
},
{
"height": 489,
"columns": [
{"id": "noReviewerTable", "size": 8},
{"id": "reviewerCoverage", "size": 4}
]
},
{
"height": 486,
"columns": [
{"id": "noAssigneeTable", "size": 8},
{"id": "assigneeCoverage", "size": 4}
]
},
{
"height": 400,
"columns": [
{"id": "openPrTable", "size": 12}
]
}
],
"widgets": [
{
"id": "avgCycleTime",
"type": "entities-number-chart",
"title": "Avg PR Cycle Time (Hours)",
"icon": "Metric",
"description": "Average time from PR creation to merge across all services (last month)",
"blueprint": "githubPullRequest",
"chartType": "aggregateByProperty",
"calculationBy": "property",
"func": "average",
"property": "cycle_time_hours",
"averageOf": "total",
"unit": "none",
"dataset": {
"combinator": "and",
"rules": [
{"operator": "between", "property": "mergedAt", "value": {"preset": "lastMonth"}}
]
}
},
{
"id": "totalOpenPrs",
"type": "entities-number-chart",
"title": "Total open PRs",
"icon": "Metric",
"description": "Total number of open PRs across all services in the organisation",
"blueprint": "githubPullRequest",
"chartType": "countEntities",
"calculationBy": "entities",
"func": "count",
"unit": "none",
"dataset": {
"combinator": "and",
"rules": [
{"property": "status", "operator": "=", "value": "open"}
]
}
},
{
"id": "stalePrShare",
"type": "entities-number-chart",
"title": "Stale PR Share (%)",
"icon": "Metric",
"description": "Percentage of open PRs that have been open for more than 7 days",
"blueprint": "_team",
"chartType": "aggregateByProperty",
"calculationBy": "property",
"func": "average",
"property": "stale_pr_share_percent",
"averageOf": "total",
"displayFormatting": "round",
"unit": "none",
"dataset": {"combinator": "and", "rules": []}
},
{
"id": "prCycleTimeTrend",
"type": "line-chart",
"title": "PR Cycle Time Trend (Monthly Trend)",
"icon": "LineChart",
"description": "Displays average time from PR creation to merge over time.",
"blueprint": "githubPullRequest",
"chartType": "aggregatePropertiesValues",
"func": "average",
"properties": ["properties.cycle_time_hours"],
"measureTimeBy": "mergedAt",
"timeInterval": "isoWeek",
"timeRange": {"preset": "lastMonth"},
"xAxisTitle": "Date",
"yAxisTitle": "Cycle Time",
"dataset": {"combinator": "and", "rules": []}
},
{
"id": "prThroughputTrend",
"type": "line-chart",
"title": "PR Throughput (Monthly Trend)",
"icon": "LineChart",
"description": "Shows merged PR volume over time.",
"blueprint": "githubPullRequest",
"chartType": "countEntities",
"func": "count",
"measureTimeBy": "mergedAt",
"timeInterval": "month",
"timeRange": {"preset": "last6Months"},
"xAxisTitle": "Date",
"yAxisTitle": "PRs Merged",
"dataset": {"combinator": "and", "rules": []}
},
{
"id": "teamThroughputBar",
"type": "bar-chart",
"title": "Teams with Highest PR Throughput",
"icon": "Bar",
"description": "Number of PRs merged per team in the last 30 days",
"blueprint": "githubPullRequest",
"property": "mirror-property#team_name",
"dataset": {
"combinator": "and",
"rules": [
{"property": "status", "operator": "=", "value": "merged"},
{"property": "mergedAt", "operator": "between", "value": {"preset": "lastMonth"}}
]
}
},
{
"id": "prAgeDistribution",
"type": "entities-pie-chart",
"title": "PR Age Distribution",
"icon": "Pie",
"description": "PRs opened for: 0-3 days | 3-7 days | 7-30 days | >30 days",
"blueprint": "githubPullRequest",
"property": "calculation-property#pr_age_label",
"dataset": {
"combinator": "and",
"rules": [
{"property": "status", "operator": "=", "value": "open"}
]
}
},
{
"id": "reviewerCoverage",
"type": "entities-pie-chart",
"title": "Open PR Reviewer Coverage",
"icon": "Pie",
"description": "Shows Open PRs with and without assigned reviewers.",
"blueprint": "githubPullRequest",
"property": "property#has_reviewers",
"dataset": {
"combinator": "and",
"rules": [
{"property": "status", "operator": "=", "value": "open"}
]
}
},
{
"id": "assigneeCoverage",
"type": "entities-pie-chart",
"title": "Open PR Assignment Coverage",
"icon": "Pie",
"description": "Shows open PRs with and without assigned owners.",
"blueprint": "githubPullRequest",
"property": "property#has_assignees",
"dataset": {
"combinator": "and",
"rules": [
{"property": "status", "operator": "=", "value": "open"}
]
}
},
{
"id": "servicePerformance",
"type": "table-entities-explorer",
"displayMode": "widget",
"title": "Service Performance Overview",
"icon": "Table",
"description": "Delivery metrics across services",
"blueprint": "service",
"dataset": {"combinator": "and", "rules": []},
"excludedFields": [],
"blueprintConfig": {
"service": {
"groupSettings": {"groupBy": ["parent_team_name", "team"]},
"propertiesSettings": {
"order": ["team", "parent_team_name", "$icon", "$title", "delivery_performance", "pr_cycle_time", "cycle_time_trend", "merged_prs_last_month", "throughput_trend", "stale_pr_share_percent", "open_prs", "stale_prs_7d", "FROZEN_RIGHT_COLUMN"],
"shown": ["$title", "readme", "parent_team_name", "cycle_time_trend", "stale_pr_share_percent", "throughput_trend", "merged_prs_last_month", "open_prs", "pr_cycle_time", "stale_prs_7d", "delivery_performance", "team"]
},
"filterSettings": {"filterBy": {"combinator": "and", "rules": []}},
"sortSettings": {"sortBy": []}
}
}
},
{
"id": "teamPerformance",
"type": "table-entities-explorer",
"displayMode": "widget",
"title": "Team Performance",
"icon": "Table",
"description": "Delivery metrics aggregated per team",
"blueprint": "_team",
"dataset": {"combinator": "and", "rules": []},
"excludedFields": [],
"blueprintConfig": {
"_team": {
"groupSettings": {"groupBy": ["parent_team_name"]},
"propertiesSettings": {
"order": ["$title", "merged_prs_last_month", "throughput_trend", "pr_cycle_time", "cycle_time_trend", "stale_pr_share_percent", "open_prs", "stale_prs_7d", "services_count"],
"shown": ["$title", "stale_pr_share_percent", "services_count", "open_prs", "stale_prs_7d", "merged_prs_last_month", "throughput_trend", "pr_cycle_time", "cycle_time_trend"]
},
"filterSettings": {"filterBy": {"combinator": "and", "rules": []}},
"sortSettings": {"sortBy": []}
}
}
},
{
"id": "stalePrTable",
"type": "table-entities-explorer",
"displayMode": "widget",
"title": "Stale Pull Requests",
"icon": "Table",
"description": "Pull requests that have been open for more than 7 days",
"blueprint": "githubPullRequest",
"dataset": {"combinator": "and", "rules": []},
"excludedFields": [],
"blueprintConfig": {
"githubPullRequest": {
"groupSettings": {"groupBy": ["team_name", "service", "repository"]},
"propertiesSettings": {
"order": ["repository", "service", "team_name", "link", "$title", "$updatedAt", "git_hub_creator"],
"shown": ["$updatedAt", "$title", "link", "git_hub_creator"]
},
"filterSettings": {
"filterBy": {
"combinator": "and",
"rules": [
{"property": "is_stale", "operator": "=", "value": true}
]
}
},
"sortSettings": {"sortBy": []}
}
}
},
{
"id": "openPrTable",
"type": "table-entities-explorer",
"displayMode": "widget",
"title": "All Open Pull Requests",
"icon": "Table",
"description": "All currently open pull requests across repositories",
"blueprint": "githubPullRequest",
"dataset": {
"combinator": "and",
"rules": [
{"property": "status", "operator": "=", "value": "open"}
]
},
"excludedFields": [],
"blueprintConfig": {
"githubPullRequest": {
"groupSettings": {"groupBy": ["team_name", "service", "repository"]},
"propertiesSettings": {
"order": ["repository", "service", "team_name", "link", "$title", "git_hub_creator", "$updatedAt", "days_old"],
"shown": ["$updatedAt", "$title", "has_assignees", "has_reviewers", "link", "team_name", "days_old", "is_stale", "service", "git_hub_creator"]
},
"filterSettings": {"filterBy": {"combinator": "and", "rules": []}},
"sortSettings": {"sortBy": []}
}
}
},
{
"id": "noReviewerTable",
"type": "table-entities-explorer",
"displayMode": "widget",
"title": "PRs Without Assigned Reviewers",
"icon": "Table",
"description": "Lists pull requests that currently have no reviewers assigned.",
"blueprint": "githubPullRequest",
"dataset": {
"combinator": "and",
"rules": [
{"property": "status", "operator": "=", "value": "open"},
{"property": "has_reviewers", "operator": "=", "value": false}
]
},
"excludedFields": [],
"blueprintConfig": {
"githubPullRequest": {
"groupSettings": {"groupBy": ["team_name", "service", "repository"]},
"propertiesSettings": {
"order": ["repository", "service", "team_name", "link", "$title", "git_hub_creator", "$updatedAt", "days_old"],
"shown": ["$updatedAt", "$title", "link", "days_old", "git_hub_creator"]
},
"filterSettings": {"filterBy": {"combinator": "and", "rules": []}},
"sortSettings": {"sortBy": [{"property": "$identifier", "order": "asc"}]}
}
}
},
{
"id": "noAssigneeTable",
"type": "table-entities-explorer",
"displayMode": "widget",
"title": "PRs Without Assignees",
"icon": "Table",
"description": "Lists pull requests that currently have no assignees assigned.",
"blueprint": "githubPullRequest",
"dataset": {
"combinator": "and",
"rules": [
{"property": "status", "operator": "=", "value": "open"},
{"property": "has_assignees", "operator": "=", "value": false}
]
},
"excludedFields": [],
"blueprintConfig": {
"githubPullRequest": {
"groupSettings": {"groupBy": ["team_name", "service", "repository"]},
"propertiesSettings": {
"order": ["repository", "service", "team_name", "$icon", "link", "$title", "git_hub_creator", "$updatedAt", "days_old", "FROZEN_RIGHT_COLUMN"],
"shown": ["$updatedAt", "$title", "link", "days_old", "git_hub_creator"]
},
"filterSettings": {"filterBy": {"combinator": "and", "rules": []}},
"sortSettings": {"sortBy": [{"property": "$identifier", "order": "asc"}]}
}
}
}
]
}
]
}
Then run the following command to create the dashboard with all widgets:
curl -s -X POST "https://api.getport.io/v1/pages" \
-H "Authorization: Bearer $PORT_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d @dp_dashboard.json | python3 -m json.tool
The script assumes an engineering_intelligence folder already exists in your catalog. If you haven't created it yet, follow step 1-4 in the create the dashboard section first else you will run into an error when you run the script
High-level PR metrics
Start with summary widgets that give an at-a-glance view of PR delivery health. Click Title: Description: Select Select Select Select Add this JSON to the Additional filters editor: Select Click Save. Click Title: Description: Select Select Add this JSON to the Dataset filter editor: Click Save. Click Title: Description: Select GitHub Pull Request as the Blueprint. Select Add this JSON to the Additional filters editor: Click Save.Avg PR cycle time (hours) (click to expand)
+ Widget and select Number Chart.Avg PR Cycle Time (Hours).Average time from PR creation to merge across all services.Aggregate Property (All Entities) Chart type and choose GitHub Pull Request as the Blueprint.cycle_time_hours as the Property.average for the Function.total for Average of.{
"combinator": "and",
"rules": [
{
"property": "mergedAt",
"operator": "between",
"value": {
"preset": "lastMonth"
}
}
]
}custom as the Unit and input hours as the Custom unit.PR cycle time trend (monthly) (click to expand)
+ Widget and select Line Chart.PR Cycle Time Trend (Monthly Trend).Displays average time from PR creation to merge over time.Aggregate Property (All Entities) Chart type and choose GitHub Pull Request as the Blueprint.Cycle Time as the Y axis Title.average for the Function.PR cycle time (hours) as the Property.Date as the X axis Title.mergedAt for Measure time by.Week and Time Range to In the past 30 days.PR throughput (monthly trend) (click to expand)
+ Widget and select Line Chart.PR Throughput (Monthly Trend).Shows merged PR volume over time.Count Entities (All Entities) Chart type and choose GitHub Pull Request as the Blueprint.PRs Merged as the Y axis Title.count for the Function.Date as the X axis Title.mergedAt for Measure time by.month and Time Range to In the past 180 days.Total open PRs (click to expand)
+ Widget and select Number Chart.Total open PRs.Total number of open PRs across all services in the organisation.Count entities Chart type and choose GitHub Pull Request as the Blueprint.count for the Function.{
"combinator": "and",
"rules": [
{
"property": "status",
"operator": "=",
"value": "open"
}
]
}Stale PR share (%) (click to expand)
+ Widget and select Number Chart.Stale PR Share (%).Percentage of open PRs that have been open for more than 7 days.Aggregate Property (All Entities) Chart type and choose Team as the Blueprint.Stale PR Share (%) as the Property.average for the Function.total for Average of.custom as the Unit and input % as the unit and align the unit to the right.Teams with highest PR throughput (click to expand)
+ Widget and select Bar Chart.Teams with Highest PR Throughput.Number of PRs merged per team in the last 30 days.Team as the Breakdown by property.{
"combinator": "and",
"rules": [
{
"value": "merged",
"property": "status",
"operator": "="
},
{
"property": "mergedAt",
"operator": "between",
"value": {
"preset": "lastMonth"
}
}
]
}
Service performance
Track delivery metrics at the individual service level using a table that aggregates key PR metrics per service.Service performance overview (click to expand)
+ Widget and select Table.Service Performance Overview.Delivery metrics across services.... button in the top right corner of the table and select Customize table.
pr_cycle_time): Average cycle time across PRs in the last month.cycle_time_trend): Sparkline showing cycle time direction.merged_prs_last_month): Number of PRs merged in the last 30 days.throughput_trend): Sparkline showing throughput direction.stale_pr_share_percent): Percentage of open PRs that are stale.open_prs): Number of currently open PRs.parent_team_name and team as the Group by column.
Team performance
Aggregate delivery metrics at the team level to compare performance across teams.Team performance (click to expand)
+ Widget and select Table.Team Performance.Delivery metrics aggregated per team.... button in the top right corner of the table and select Customize table.
merged_prs_last_month): Number of PRs merged in the last 30 days.throughput_trend): Whether throughput is improving, degrading, or stable.pr_cycle_time): Average PR cycle time for the last month.cycle_time_trend): Whether cycle time is improving, degrading, or stable.stale_pr_share_percent): Percentage of open PRs that are stale.open_prs): Number of currently open PRs.stale_prs_7d): Number of PRs open longer than 7 days.services_count): Number of services owned by the team.parent_team_name as the Group by column.
Stale PR analysis
Surface PRs that have been open too long and understand the age distribution across your organization. Click Title: Description: Choose the GitHub Pull Request blueprint. Under Breakdown by property, select the Add this JSON to the Additional filters editor: Click Save.Stale pull requests table (click to expand)
+ Widget and select Table.Stale Pull Requests.Pull requests that have been open for more than 7 days.... button in the top right corner of the table and select Customize table.
team_name mirror property).team_name, service and repository as the Group by columns.is_stale property and set the operator to = and the value to true.PR age distribution (click to expand)
+ Widget and select Pie chart.PR Age Distribution.PRs opened for: 0-3 days | 3-7 days | 7-30 days | >30 days.PR Age property.{
"combinator": "and",
"rules": [
{
"property": "status",
"operator": "=",
"value": "open"
}
]
}
PR quality indicators
Track whether open PRs have proper reviewer and assignee coverage, which are key indicators of process health. Click Title: Description: Choose the GitHub Pull Request blueprint. Add this JSON to the Initial filters editor: Click Save. Click on the In the top right corner of the table, click on Manage Properties and add the following columns: Select Click on the save icon in the top right corner of the widget to save the customized table. Click Title: Description: Choose the GitHub Pull Request blueprint. Under Breakdown by property, select the Add this JSON to the Additional filters editor: Click Save. Click Title: Description: Choose the GitHub Pull Request blueprint. Add this JSON to the Initial filters editor: Click Save. Click on the In the top right corner of the table, click on Manage Properties and add the following columns: Select Click on the save icon in the top right corner of the widget to save the customized table. Click Title: Description: Choose the GitHub Pull Request blueprint. Under Breakdown by property, select the Add this JSON to the Additional filters editor: Click Save. Click Title: Description: Choose the GitHub Pull Request blueprint. Add this JSON to the Initial filters editor: Click Save. Click on the In the top right corner of the table, click on Manage Properties and add the following columns: Select Click on the save icon in the top right corner of the widget to save the customized table.PRs without assigned reviewers (click to expand)
+ Widget and select Table.PRs Without Assigned Reviewers.Lists pull requests that currently have no reviewers assigned.{
"combinator": "and",
"rules": [
{
"property": "status",
"operator": "=",
"value": "open"
},
{
"property": "has_reviewers",
"operator": "=",
"value": false
}
]
}... button in the top right corner of the table and select Customize table.
team_name, service and repository as the Group by columns.Open PR reviewer coverage (click to expand)
+ Widget and select Pie chart.Open PR Reviewer Coverage.Shows Open PRs with and without assigned reviewers.has_reviewers property.{
"combinator": "and",
"rules": [
{
"property": "status",
"operator": "=",
"value": "open"
}
]
}PRs without assignees (click to expand)
+ Widget and select Table.PRs Without Assignees.Lists pull requests that currently have no assignees assigned.{
"combinator": "and",
"rules": [
{
"property": "status",
"operator": "=",
"value": "open"
},
{
"property": "has_assignees",
"operator": "=",
"value": false
}
]
}... button in the top right corner of the table and select Customize table.
team_name, service and repository as the Group by columns.Open PR assignment coverage (click to expand)
+ Widget and select Pie chart.Open PR Assignment Coverage.Shows open PRs with and without assigned owners.has_assignees property.{
"combinator": "and",
"rules": [
{
"property": "status",
"operator": "=",
"value": "open"
}
]
}All open pull requests (click to expand)
+ Widget and select Table.All Open Pull Requests.All currently open pull requests across repositories.{
"combinator": "and",
"rules": [
{
"property": "status",
"operator": "=",
"value": "open"
}
]
}... button in the top right corner of the table and select Customize table.
days_old): How many days the PR has been open.has_assignees): Whether the PR has assignees.has_reviewers): Whether the PR has reviewers.is_stale): Whether the PR has been open for more than 7 days.team_name, service and repository as the Group by columns.
Next steps
Once your Delivery Performance dashboard is in place, consider these additional improvements:
- Set up scorecards to automatically evaluate services and teams against delivery performance targets.
- Create automations to send Slack notifications when stale PR share exceeds a threshold or when PRs have been open without reviewers for more than 48 hours.
- Add an AI agent to provide natural language insights into your delivery data. See the delivery performance guide for configuration details.