> For the complete documentation index, see llms.txt.
Skip to main content

Check out Port for yourself ➜ 

Measure PR delivery metrics

Included by default

If you signed up for Port on or after May 1, 2026, this dashboard is already set up in your portal. You can follow this guide to customize it to fit your organization's needs.

Supported integrations

This guide supports GitHub (Ocean), GitLab, and Azure DevOps. Select the tab that matches your integration below.

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:

  • Port's GitHub Ocean integration is installed in your account.
  • The githubPullRequest, githubRepository, githubUser, and githubTeam blueprints already exist (these are created automatically when you install the GitHub Ocean integration).

Key metrics overview

This dashboard tracks PR delivery metrics across two levels - individual services and teams:

MetricWhat it measuresWhy it matters
Open PRsTotal number of currently open PRsShows current work in progress and potential bottleneck signals
PR throughputNumber of PRs merged per week/monthIndicates delivery flow and team output capacity
Throughput trendWeekly vs. monthly throughput directionReveals whether delivery velocity is improving or degrading
Stale PRsPRs open longer than 7 daysHighlights blocked work, unclear ownership, or review delays
Stale PR share (%)Percentage of open PRs that are staleQuantifies the severity of staleness across the portfolio
Cycle timeHours from PR creation to merge (weekly/monthly avg)Exposes friction in reviews, CI, and approval processes
Cycle time trendWeekly vs. monthly cycle time directionShows whether review and merge speed is improving over time
Reviewer coverageWhether open PRs have reviewers assignedSignals process compliance and review readiness
Assignee coverageWhether open PRs have assignees assignedIndicates ownership clarity for open work

Set up data model

The GitHub Ocean integration automatically creates the githubPullRequest, githubRepository, githubUser, and githubTeam blueprints with default properties. The pull request blueprint comes with status, createdAt, mergedAt, closedAt, prNumber, link, branch, and lead_time_hours out of the box, along with relations for repository, git_hub_creator, git_hub_assignees, git_hub_reviewers, and a dynamic service relation.

lead_time_hours vs cycle_time_hours

lead_time_hours is a schema property that exists on the blueprint but is not populated by the default GitHub Ocean mapping. The actively mapped lead time metric is cycle_time_hours, which all aggregations in this guide reference.

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.

  1. Go to the Builder page of your portal.

  2. Find the GitHub Pull Request blueprint and click on it.

  3. Click on the {...} button in the top right corner, and choose Edit JSON.

  4. Add the following properties to the properties section of the schema object (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"
    }
    reviewDecision population

    reviewDecision exists as a schema property on the blueprint but is not populated by the default GitHub Ocean mapping. You must add a custom mapping entry to populate it if needed.

  5. Add the following entry to the mirrorProperties section of the blueprint. This resolves the owning team of each reviewer so you can filter or group PRs by reviewer team:

    PR mirror property (click to expand)
    "reviewer_teams": {
    "title": "Reviewer Teams",
    "path": "reviewers.$team"
    }
  6. Add the following entries to the existing calculationProperties section:

    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"
    }
    }
    Existing relations

    The default githubPullRequest blueprint already includes relations for repository, git_hub_creator, git_hub_assignees, git_hub_reviewers, and a dynamic service relation. No changes to relations are needed.

  7. 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.

  1. Go to your Builder page.

  2. Find the Service blueprint and click on it.

  3. Click on the {...} button in the top right corner, and choose Edit JSON.

  4. Add the following entries to the aggregationProperties section of the blueprint:

    Service aggregation properties (click to expand)
    "github_open_prs": {
    "title": "Open PRs",
    "type": "number",
    "target": "githubPullRequest",
    "query": {
    "combinator": "and",
    "rules": [
    {
    "property": "status",
    "operator": "=",
    "value": "open"
    }
    ]
    },
    "calculationSpec": {
    "func": "count",
    "calculationBy": "entities"
    }
    },
    "github_stale_prs_7d": {
    "title": "Stale PRs (7d)",
    "type": "number",
    "target": "githubPullRequest",
    "query": {
    "combinator": "and",
    "rules": [
    {
    "property": "status",
    "operator": "=",
    "value": "open"
    },
    {
    "property": "createdAt",
    "operator": "notBetween",
    "value": {
    "preset": "lastWeek"
    }
    }
    ]
    },
    "calculationSpec": {
    "func": "count",
    "calculationBy": "entities"
    }
    },
    "github_merged_prs_last_week": {
    "title": "Merged PRs (Last Week)",
    "type": "number",
    "target": "githubPullRequest",
    "query": {
    "combinator": "and",
    "rules": [
    {
    "property": "mergedAt",
    "operator": "between",
    "value": {
    "preset": "lastWeek"
    }
    }
    ]
    },
    "calculationSpec": {
    "func": "count",
    "calculationBy": "entities"
    }
    },
    "github_merged_prs_last_month": {
    "title": "Merged PRs (Last Month)",
    "type": "number",
    "target": "githubPullRequest",
    "query": {
    "combinator": "and",
    "rules": [
    {
    "property": "mergedAt",
    "operator": "between",
    "value": {
    "preset": "lastMonth"
    }
    }
    ]
    },
    "calculationSpec": {
    "func": "count",
    "calculationBy": "entities"
    }
    },
    "github_pr_cycle_time": {
    "title": "Monthly PR Cycle Time",
    "type": "number",
    "target": "githubPullRequest",
    "query": {
    "combinator": "and",
    "rules": [
    {
    "property": "mergedAt",
    "operator": "between",
    "value": {
    "preset": "lastMonth"
    }
    }
    ]
    },
    "calculationSpec": {
    "func": "average",
    "averageOf": "total",
    "calculationBy": "property",
    "property": "cycle_time_hours",
    "measureTimeBy": "$createdAt"
    }
    },
    "github_pr_cycle_time_weekly": {
    "title": "Weekly PR Cycle Time",
    "type": "number",
    "target": "githubPullRequest",
    "query": {
    "combinator": "and",
    "rules": [
    {
    "property": "mergedAt",
    "operator": "between",
    "value": {
    "preset": "lastWeek"
    }
    }
    ]
    },
    "calculationSpec": {
    "func": "average",
    "averageOf": "total",
    "calculationBy": "property",
    "property": "cycle_time_hours",
    "measureTimeBy": "$createdAt"
    }
    }
  5. Add the following entries to the calculationProperties section of the blueprint:

    Service calculation properties (click to expand)
    "github_stale_pr_share_percent": {
    "title": "Stale PR Share (%)",
    "type": "number",
    "calculation": "if (.properties.github_open_prs != null and .properties.github_open_prs != 0) then (.properties.github_stale_prs_7d / .properties.github_open_prs) * 100 else 0 end"
    },
    "github_cycle_time_trend": {
    "title": "Cycle Time Trend",
    "type": "string",
    "colorized": true,
    "colors": {
    "Improving": "green",
    "Degrading": "red",
    "Stable": "blue"
    },
    "calculation": "((.properties.github_pr_cycle_time // 0) - (.properties.github_pr_cycle_time_weekly // 0)) as $diff | if $diff > 0 then \"Improving\" elif $diff < 0 then \"Degrading\" else \"Stable\" end"
    },
    "github_throughput_trend": {
    "title": "Throughput Trend",
    "type": "string",
    "colorized": true,
    "colors": {
    "Improving": "green",
    "Degrading": "red",
    "Stable": "blue"
    },
    "calculation": "((.properties.github_merged_prs_last_week // 0) * 30 - (.properties.github_merged_prs_last_month // 0) * 7) as $diff | if $diff > 0 then \"Improving\" elif $diff < 0 then \"Degrading\" else \"Stable\" end"
    }
  6. Click Save to update the blueprint.

Update the team blueprint

Add relations, mirror properties, and aggregation properties to the Team blueprint to aggregate delivery metrics across all services owned by each team. The aggregations use pathFilter to traverse the PR → service → _team relation chain and compute metrics directly from pull request data.

  1. Go to your Builder page.

  2. Find the Team blueprint and click on it.

  3. Click on the {...} button in the top right corner, and choose Edit JSON.

  4. Add the following entry to the relations section of the blueprint. This self-relation allows teams to be organized into a hierarchy (e.g., a "Frontend" team under an "Engineering" parent), which the dashboard uses to group team metrics by parent team:

    Team parent relation (click to expand)
    "parent_team": {
    "title": "Parent Team",
    "target": "_team",
    "required": false,
    "many": false
    }
  5. Add the following entry to the mirrorProperties section of the blueprint. This mirror resolves the parent team's name so the "Team Performance" table can group rows by parent team:

    Team mirror property (click to expand)
    "parent_team_name": {
    "title": "Parent Team",
    "path": "parent_team.$title"
    }
  6. Add the following entries to the aggregationProperties section of the blueprint:

    Team aggregation properties (click to expand)
    "services_count": {
    "title": "Services Count",
    "type": "number",
    "target": "service",
    "calculationSpec": {
    "func": "count",
    "calculationBy": "entities"
    },
    "pathFilter": [
    {
    "fromBlueprint": "service",
    "path": ["_team"]
    }
    ]
    },
    "github_open_prs": {
    "title": "Open PRs",
    "type": "number",
    "target": "githubPullRequest",
    "query": {
    "combinator": "and",
    "rules": [
    {
    "property": "status",
    "operator": "=",
    "value": "open"
    }
    ]
    },
    "calculationSpec": {
    "func": "count",
    "calculationBy": "entities"
    },
    "pathFilter": [
    {
    "fromBlueprint": "githubPullRequest",
    "path": ["service", "_team"]
    }
    ]
    },
    "github_stale_prs_7d": {
    "title": "Stale PRs (7d)",
    "type": "number",
    "target": "githubPullRequest",
    "query": {
    "combinator": "and",
    "rules": [
    {
    "property": "status",
    "operator": "=",
    "value": "open"
    },
    {
    "property": "createdAt",
    "operator": "notBetween",
    "value": {
    "preset": "lastWeek"
    }
    }
    ]
    },
    "calculationSpec": {
    "func": "count",
    "calculationBy": "entities"
    },
    "pathFilter": [
    {
    "fromBlueprint": "githubPullRequest",
    "path": ["service", "_team"]
    }
    ]
    },
    "github_merged_prs_last_week": {
    "title": "Merged PRs (Last Week)",
    "type": "number",
    "target": "githubPullRequest",
    "query": {
    "combinator": "and",
    "rules": [
    {
    "property": "mergedAt",
    "operator": "between",
    "value": {
    "preset": "lastWeek"
    }
    }
    ]
    },
    "calculationSpec": {
    "func": "count",
    "calculationBy": "entities"
    },
    "pathFilter": [
    {
    "fromBlueprint": "githubPullRequest",
    "path": ["service", "_team"]
    }
    ]
    },
    "github_merged_prs_last_month": {
    "title": "Merged PRs (Last Month)",
    "type": "number",
    "target": "githubPullRequest",
    "query": {
    "combinator": "and",
    "rules": [
    {
    "property": "mergedAt",
    "operator": "between",
    "value": {
    "preset": "lastMonth"
    }
    }
    ]
    },
    "calculationSpec": {
    "func": "count",
    "calculationBy": "entities"
    },
    "pathFilter": [
    {
    "fromBlueprint": "githubPullRequest",
    "path": ["service", "_team"]
    }
    ]
    },
    "github_pr_cycle_time_weekly": {
    "title": "Weekly PR Cycle Time",
    "type": "number",
    "target": "githubPullRequest",
    "query": {
    "combinator": "and",
    "rules": [
    {
    "property": "mergedAt",
    "operator": "between",
    "value": {
    "preset": "lastWeek"
    }
    }
    ]
    },
    "calculationSpec": {
    "func": "average",
    "averageOf": "total",
    "calculationBy": "property",
    "property": "cycle_time_hours",
    "measureTimeBy": "$createdAt"
    },
    "pathFilter": [
    {
    "fromBlueprint": "githubPullRequest",
    "path": ["service", "_team"]
    }
    ]
    },
    "github_pr_cycle_time": {
    "title": "Monthly PR Cycle Time",
    "type": "number",
    "target": "githubPullRequest",
    "query": {
    "combinator": "and",
    "rules": [
    {
    "property": "mergedAt",
    "operator": "between",
    "value": {
    "preset": "lastMonth"
    }
    }
    ]
    },
    "calculationSpec": {
    "func": "average",
    "averageOf": "total",
    "calculationBy": "property",
    "property": "cycle_time_hours",
    "measureTimeBy": "$createdAt"
    },
    "pathFilter": [
    {
    "fromBlueprint": "githubPullRequest",
    "path": ["service", "_team"]
    }
    ]
    }
  7. Add the following entries to the calculationProperties section of the blueprint:

    Team calculation properties (click to expand)
    "github_stale_pr_share_percent": {
    "title": "Stale PR Share (%)",
    "type": "number",
    "calculation": "if (.properties.github_open_prs != null and .properties.github_open_prs != 0) then (.properties.github_stale_prs_7d / .properties.github_open_prs) * 100 else 0 end"
    },
    "github_cycle_time_trend": {
    "title": "Cycle Time Trend",
    "type": "string",
    "colorized": true,
    "colors": {
    "Improving": "green",
    "Degrading": "red",
    "Stable": "blue"
    },
    "calculation": "((.properties.github_pr_cycle_time // 0) - (.properties.github_pr_cycle_time_weekly // 0)) as $diff | if $diff > 0 then \"Improving\" elif $diff < 0 then \"Degrading\" else \"Stable\" end"
    },
    "github_throughput_trend": {
    "title": "Throughput Trend",
    "type": "string",
    "colorized": true,
    "colors": {
    "Improving": "green",
    "Degrading": "red",
    "Stable": "blue"
    },
    "calculation": "((.properties.github_merged_prs_last_week // 0) * 30 - (.properties.github_merged_prs_last_month // 0) * 7) as $diff | if $diff > 0 then \"Improving\" elif $diff < 0 then \"Degrading\" else \"Stable\" end"
    },
    "github_merged_prs_per_service_last_month": {
    "title": "Merged PRs per Service (Monthly)",
    "type": "number",
    "calculation": "if (.properties.services_count != null and .properties.services_count != 0) then (.properties.github_merged_prs_last_month / .properties.services_count) else 0 end"
    },
    "github_open_prs_per_service": {
    "title": "Open PRs per Service",
    "type": "number",
    "calculation": "if (.properties.services_count != null and .properties.services_count != 0) then (.properties.github_open_prs / .properties.services_count) else 0 end"
    },
    "github_stale_prs_per_service_7d": {
    "title": "Stale PRs per Service (7d)",
    "type": "number",
    "calculation": "if (.properties.services_count != null and .properties.services_count != 0) then (.properties.github_stale_prs_7d / .properties.services_count) else 0 end"
    }
  8. Click Save to update the blueprint.

Relation chain

The aggregation properties on the Team blueprint use a pathFilter that traverses the relation chain PR → service → _team. For team-level metrics to populate, each service must have its Port team ownership ($team) set to the appropriate team.

Update integration mapping

Now we'll update the GitHub Ocean 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, and all user/repository relations. The cycle_time_hours property is the actively mapped lead time metric and is what all aggregations in this guide reference. The lead_time_hours schema property exists on the blueprint but is not populated by default. We only need to add mappings for the new fields.

  1. Go to your Data Source page.

  2. Select the GitHub integration.

  3. Find the first pull-request resource 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.)
    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
  4. In the first pull-request resource block, configure the selector to include closed PRs using states, maxResults, and since. Ocean uses .__repository for relations and .state for the status property:

    - kind: pull-request
    selector:
    query: "true"
    states: ["open", "closed"]
    maxResults: 100
    since: 90
    Ocean property mapping

    For GitHub Ocean, map status from .state in your entity mappings, and use .__repository for the repository relation. See the GitHub Ocean migration guide for details.

  5. Click Save & Resync to apply the mapping.

Closed PRs

By default, the GitHub Ocean integration only fetches open pull requests. Setting states: ["open", "closed"] ensures that merged and closed PRs are ingested, which is required for cycle time and throughput calculations.

Set up GitHub service relations

The service relation on each githubPullRequest entity is set in two ways:

  1. At ingest - the integration mapping sets it via a search query on github_repository_id to match the linked repository to a service.
  2. On change - the set_pull_request_relations automation fires whenever a service's github_repository relation changes and bulk-updates the service relation on all githubPullRequest entities whose repository relation matches the updated repository.

Create the following automation so that changes to a service's linked repository propagate to all related pull requests:

  1. Go to the automations page.
  2. Click + Automation.
  3. Click the {...} Edit JSON button.
  4. Paste the following JSON and click Save:
Set Pull Request relations automation (click to expand)
{
"identifier": "set_pull_request_relations",
"title": "Set Pull Request relations",
"trigger": {
"type": "automation",
"event": {"type": "ANY_ENTITY_CHANGE", "blueprintIdentifier": "service"},
"condition": {
"type": "JQ",
"expressions": [
".diff.before.relations.github_repository != .diff.after.relations.github_repository",
".diff.after.relations.github_repository != null"
],
"combinator": "and"
}
},
"invocationMethod": {
"type": "WEBHOOK",
"url": "https://api.getport.io/v1/migrations",
"agent": false,
"synchronized": true,
"method": "POST",
"headers": {"RUN_ID": "{{ .run.id }}", "Content-Type": "application/json"},
"body": {
"sourceBlueprint": "githubPullRequest",
"mapping": {
"blueprint": "githubPullRequest",
"filter": ".relations.repository == \"{{ .event.diff.after.relations.github_repository }}\"",
"entity": {
"identifier": ".identifier",
"relations": {"service": "\"{{ .event.context.entityIdentifier }}\""}
}
}
}
}
}

Set up GitHub user relations

Port-user relations (creator, reviewers, assignees) on githubPullRequest are kept in sync by the set_pull_request_user_relations automation. It fires whenever a _user entity's git_hub_user relation changes and bulk-updates the relevant Port-user relations on all matching pull request entities.

Create the following automation:

  1. Go to the automations page.
  2. Click + Automation.
  3. Click the {...} Edit JSON button.
  4. Paste the following JSON and click Save:
Set Pull Request user relations automation (click to expand)
{
"identifier": "set_pull_request_user_relations",
"title": "Set Pull Requests User Relations",
"trigger": {
"type": "automation",
"event": {"type": "ANY_ENTITY_CHANGE", "blueprintIdentifier": "_user"},
"condition": {
"type": "JQ",
"expressions": [
".diff.before.relations.git_hub_user != .diff.after.relations.git_hub_user",
".diff.after.relations.git_hub_user != null"
],
"combinator": "and"
}
},
"invocationMethod": {
"type": "WEBHOOK",
"url": "https://api.getport.io/v1/migrations",
"agent": false,
"synchronized": true,
"method": "POST",
"headers": {"Content-Type": "application/json", "RUN_ID": "{{ .run.id }}"},
"body": {
"sourceBlueprint": "githubPullRequest",
"mapping": {
"blueprint": "githubPullRequest",
"filter": "\"{{ .event.diff.after.relations.git_hub_user }}\" as $item | (.relations.git_hub_assignees | any(. == $item)) or ($item == .relations.git_hub_creator) or (.relations.git_hub_reviewers | any(. == $item))",
"entity": {
"identifier": ".identifier",
"relations": {
"creator": "if \"{{ .event.diff.after.relations.git_hub_user }}\" == .relations.git_hub_creator then \"{{ .event.context.entityIdentifier }}\" else .relations.creator end",
"reviewers": "if (\"{{ .event.diff.after.relations.git_hub_user }}\" as $item | .relations.git_hub_reviewers | any(. == $item)) then ((.relations.reviewers // []) + [\"{{ .event.context.entityIdentifier }}\"] | unique) else .relations.reviewers end",
"assignees": "if (\"{{ .event.diff.after.relations.git_hub_user }}\" as $item | .relations.git_hub_assignees | any(. == $item)) then ((.relations.assignees // []) + [\"{{ .event.context.entityIdentifier }}\"] | unique) else .relations.assignees end"
}
}
}
}
}
}

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:

  1. Navigate to your software catalog.
  2. Click on the + New button in the left sidebar.
  3. Select New folder.
  4. Name the folder Engineering Intelligence and click Create.
  5. Inside the Engineering Intelligence folder, click + New again.
  6. Select New dashboard.
  7. 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.

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

  1. In your Port portal, click on your profile picture in the top right corner.

  2. Select Credentials.

  3. Click Generate API token.

  4. Copy the generated token and store it as an environment variable:

    export PORT_ACCESS_TOKEN="YOUR_GENERATED_TOKEN"
EU region

If your portal is hosted in the EU region, replace api.port.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": 400,
"columns": [
{"id": "groupPerformance", "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 organization",
"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": "organization",
"chartType": "displaySingleProperty",
"entity": "default-org",
"property": "github_stale_pr_share_percent",
"unit": "none"
},
{
"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": "$team",
"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": ["team"]},
"propertiesSettings": {
"order": ["team", "$icon", "$title", "delivery_performance", "github_pr_cycle_time", "gitlab_mr_cycle_time", "ado_pr_cycle_time", "github_cycle_time_trend", "gitlab_cycle_time_trend", "ado_cycle_time_trend", "github_merged_prs_last_month", "gitlab_merged_mrs_last_month", "ado_merged_prs_last_month", "github_throughput_trend", "gitlab_throughput_trend", "ado_throughput_trend", "github_stale_pr_share_percent", "gitlab_stale_mr_share_percent", "ado_stale_pr_share_percent", "github_open_prs", "gitlab_open_mrs", "ado_open_prs", "github_stale_prs_7d", "gitlab_stale_mrs_7d", "ado_stale_prs_7d", "github_readme", "gitlab_readme", "ado_readme", "FROZEN_RIGHT_COLUMN"],
"shown": ["$title", "github_readme", "gitlab_readme", "ado_readme", "github_cycle_time_trend", "gitlab_cycle_time_trend", "ado_cycle_time_trend", "github_stale_pr_share_percent", "gitlab_stale_mr_share_percent", "ado_stale_pr_share_percent", "github_throughput_trend", "gitlab_throughput_trend", "ado_throughput_trend", "github_merged_prs_last_month", "gitlab_merged_mrs_last_month", "ado_merged_prs_last_month", "github_open_prs", "gitlab_open_mrs", "ado_open_prs", "github_pr_cycle_time", "gitlab_mr_cycle_time", "ado_pr_cycle_time", "github_stale_prs_7d", "gitlab_stale_mrs_7d", "ado_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": [{"operator": "=", "property": "type", "value": "team"}]},
"excludedFields": [],
"blueprintConfig": {
"_team": {
"groupSettings": {"groupBy": []},
"propertiesSettings": {
"order": ["$title", "github_merged_prs_last_month", "github_throughput_trend", "github_pr_cycle_time", "github_cycle_time_trend", "github_stale_pr_share_percent", "github_open_prs", "github_stale_prs_7d", "services_count"],
"shown": ["$title", "github_stale_pr_share_percent", "services_count", "github_open_prs", "github_stale_prs_7d", "github_merged_prs_last_month", "github_throughput_trend", "github_pr_cycle_time", "github_cycle_time_trend"]
},
"filterSettings": {"filterBy": {"combinator": "and", "rules": []}},
"sortSettings": {"sortBy": []}
}
}
},
{
"id": "groupPerformance",
"type": "table-entities-explorer",
"displayMode": "widget",
"title": "Group Performance",
"icon": "Table",
"description": "Delivery metrics aggregated per group",
"blueprint": "_team",
"dataset": {"combinator": "and", "rules": [{"operator": "=", "property": "type", "value": "group"}]},
"excludedFields": [],
"blueprintConfig": {
"_team": {
"groupSettings": {"groupBy": []},
"propertiesSettings": {
"order": ["$title", "github_merged_prs_last_month", "github_throughput_trend", "github_pr_cycle_time", "github_cycle_time_trend", "github_stale_pr_share_percent", "github_open_prs", "github_stale_prs_7d", "services_count"],
"shown": ["$title", "github_stale_pr_share_percent", "services_count", "github_open_prs", "github_stale_prs_7d", "github_merged_prs_last_month", "github_throughput_trend", "github_pr_cycle_time", "github_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": ["service"]},
"propertiesSettings": {
"order": ["repository", "service", "$team", "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": ["service"]},
"propertiesSettings": {
"order": ["repository", "service", "$team", "link", "$title", "git_hub_creator", "$updatedAt", "days_old"],
"shown": ["$updatedAt", "$title", "has_assignees", "has_reviewers", "link", "$team", "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": ["service"]},
"propertiesSettings": {
"order": ["repository", "service", "$team", "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": ["service"]},
"propertiesSettings": {
"order": ["repository", "service", "$team", "$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.port.io/v1/pages" \
-H "Authorization: Bearer $PORT_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d @dp_dashboard.json | python3 -m json.tool
Engineering Intelligence folder

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

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.