Skip to main content

Check out Port for yourself 

Fetch Historical GitHub Data for DORA Metrics

Organizations using Port often want to track engineering metrics like DORA. These metrics provide insight into how teams build, test, and deploy software. But to calculate meaningful metrics, you first need data.

This guide walks you through how to fetch historical GitHub data using GitHub Actions. This data will become the foundation for building and visualizing your engineering metrics—like deployment frequency, lead time for changes, and more.

Use cases

  • These workflows are designed for one-time or on-demand use to help you backfill historical data. This is especially useful when onboarding to Port or when certain types of data (e.g., old PRs, releases, workflow runs) were never synced in real time.

Prerequisites

Tracking deployments

In this section, we focus on deployment tracking. A deployment refers to shipping code to a live environment such as Production, Staging, or QA. We'll provide ready-to-use GitHub Actions that extract historical deployment-related data and send it to Port.

Data model setup

You will need to manually create blueprints for Github Repository and User as you will need them when defining the self service actions.

  1. Go to your Builder page.

  2. Click on + Blueprint button to create a new blueprint.

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

  4. Add the following JSON schemas separately into the editor while clicking Save to create the blueprint one after the other:

    GitHub Repository Blueprint (Click to expand)
    {
    "identifier": "githubRepository",
    "title": "Repository",
    "icon": "Github",
    "ownership": {
    "type": "Direct"
    },
    "schema": {
    "properties": {
    "readme": {
    "title": "README",
    "type": "string",
    "format": "markdown"
    },
    "url": {
    "icon": "DefaultProperty",
    "title": "Repository URL",
    "type": "string",
    "format": "url"
    },
    "defaultBranch": {
    "title": "Default branch",
    "type": "string"
    },
    "last_contributor": {
    "title": "Last contributor",
    "icon": "TwoUsers",
    "type": "string",
    "format": "user"
    },
    "last_push": {
    "icon": "GitPullRequest",
    "title": "Last push",
    "description": "Last commit to the main branch",
    "type": "string",
    "format": "date-time"
    },
    "require_code_owner_review": {
    "title": "Require code owner review",
    "type": "boolean",
    "icon": "DefaultProperty",
    "description": "Requires review from code owners before a pull request can be merged"
    },
    "require_approval_count": {
    "title": "Require approvals",
    "type": "number",
    "icon": "DefaultProperty",
    "description": "The number of approvals required before merging a pull request"
    }
    },
    "required": []
    },
    "mirrorProperties": {},
    "calculationProperties": {},
    "aggregationProperties": {},
    "relations": {}
    }
    GitHub User Blueprint (Click to expand)
    {
    "identifier": "githubUser",
    "title": "Github User",
    "icon": "Github",
    "schema": {
    "properties": {
    "email": {
    "title": "Email",
    "type": "string"
    }
    },
    "required": []
    },
    "mirrorProperties": {},
    "calculationProperties": {},
    "aggregationProperties": {},
    "relations": {}
    }

Tracking strategies

Choose your preferred historical strategy below. Each one targets a different kind of GitHub event that could indicate a deployment:

One of the ways to track deployments is by monitoring when pull requests (PRs) are merged into a branch, typically the main or master branch.

We will create a GitHub Action that extracts this historical data and pushes it to Port.

Step 1: Add GitHub Workflow

Create the file .github/workflows/create_deployment_for_pull_request.yaml in the .github/workflows folder of your repository.

Dedicated Workflows Repository

We recommend creating a dedicated repository for the workflows that are used by Port actions.

Fetch Historical Pull Request GitHub Workflow (Click to expand)
name: Fetch Historical PR Deployment Data

on:
workflow_dispatch:
inputs:
config:
description: 'JSON input configuration for fetching PRs'
required: true
type: string
port_payload:
required: true
description: Port's payload, including details for who triggered the action and general context (blueprint, run id, etc...)

jobs:
fetch-prs:
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v4

- name: Inform execution of request to fetch historical pull request data
uses: port-labs/port-github-action@v1
with:
clientId: ${{ secrets.PORT_CLIENT_ID }}
clientSecret: ${{ secrets.PORT_CLIENT_SECRET }}
baseUrl: https://api.getport.io
operation: PATCH_RUN
runId: ${{fromJson(inputs.port_payload).runId}}
logMessage: "About to fetch pull request data from GitHub..."

- name: Fetch Port Access Token
id: fetch_port_token
run: |
PORT_ACCESS_TOKEN=$(curl -s -L 'https://api.getport.io/v1/auth/access_token' \
-H 'Content-Type: application/json' \
-H 'Accept: application/json' \
-d '{
"clientId": "${{ secrets.PORT_CLIENT_ID }}",
"clientSecret": "${{ secrets.PORT_CLIENT_SECRET }}"
}' | jq -r '.accessToken')

echo "PORT_ACCESS_TOKEN=$PORT_ACCESS_TOKEN" >> "$GITHUB_ENV"

- name: Parse Input Configuration
id: parse_config
run: |
echo '${{ inputs.config }}' > config.json
CONFIG_JSON=$(jq -c . config.json)
echo "CONFIG_JSON=$CONFIG_JSON" >> "$GITHUB_ENV"

- name: Extract Filters from JSON
id: extract_filters
run: |
CONFIG_JSON=$(echo '${{ env.CONFIG_JSON }}' | jq -c .)

# Extract individual fields
DEPLOY_BRANCH=$(echo "$CONFIG_JSON" | jq -r '.deploy_branch // "main"')
PR_RULES_FIELDS=$(echo "$CONFIG_JSON" | jq -c '.pr_rules_fields // []')
PR_STATUS_OP=$(echo "$CONFIG_JSON" | jq -r '.pr_rules_status_op // empty')
PR_STATUS_VALUE=$(echo "$CONFIG_JSON" | jq -r '.pr_rules_status_options // empty')
PR_AUTHOR_OP=$(echo "$CONFIG_JSON" | jq -r '.pr_rules_author_op // empty')
PR_AUTHORS=$(echo "$CONFIG_JSON" | jq -r 'if .pr_rules_author then .pr_rules_author | map(.identifier) | join(" ") else "" end')
PR_LABEL_OP=$(echo "$CONFIG_JSON" | jq -r '.pr_rules_label_op // empty')
PR_LABEL_VALUE=$(echo "$CONFIG_JSON" | jq -r '.pr_rules_label_str // empty')
PR_TITLE_OP=$(echo "$CONFIG_JSON" | jq -r '.pr_rules_title_op // empty')
PR_TITLE_VALUE=$(echo "$CONFIG_JSON" | jq -r '.pr_rules_title_str // empty')
PR_REPO_OP=$(echo "$CONFIG_JSON" | jq -r '.pr_rules_repo_op // empty')
PR_REPO_VALUE=$(echo "$CONFIG_JSON" | jq -r 'if .pr_rules_repository then .pr_rules_repository | map(.identifier) | join(" ") else "" end')

echo "DEPLOY_BRANCH=$DEPLOY_BRANCH" >> "$GITHUB_ENV"
echo "PR_RULES_FIELDS=$PR_RULES_FIELDS" >> "$GITHUB_ENV"
echo "PR_STATUS_OP=$PR_STATUS_OP" >> "$GITHUB_ENV"
echo "PR_STATUS_VALUE=$PR_STATUS_VALUE" >> "$GITHUB_ENV"
echo "PR_AUTHOR_OP=$PR_AUTHOR_OP" >> "$GITHUB_ENV"
echo "PR_AUTHORS=$PR_AUTHORS" >> "$GITHUB_ENV"
echo "PR_LABEL_OP=$PR_LABEL_OP" >> "$GITHUB_ENV"
echo "PR_LABEL_VALUE=$PR_LABEL_VALUE" >> "$GITHUB_ENV"
echo "PR_TITLE_OP=$PR_TITLE_OP" >> "$GITHUB_ENV"
echo "PR_TITLE_VALUE=$PR_TITLE_VALUE" >> "$GITHUB_ENV"
echo "PR_REPO_OP=$PR_REPO_OP" >> "$GITHUB_ENV"
echo "PR_REPO_VALUE=$PR_REPO_VALUE" >> "$GITHUB_ENV"

- name: Fetch All Repositories (if no repo filter applied)
if: ${{ !contains(env.PR_RULES_FIELDS, 'Repository') }}
id: fetch_repos
run: |
GH_TOKEN=${{ secrets.GH_TOKEN }}
REPOS=()
PAGE=1
ORG=${{ github.repository_owner }}

while :; do
RESPONSE=$(curl -s -H "Authorization: token $GH_TOKEN" \
"https://api.github.com/users/$ORG/repos?per_page=100&page=$PAGE")

NEW_REPOS=$(echo "$RESPONSE" | jq -r '.[].full_name')
if [[ -z "$NEW_REPOS" ]]; then break; fi

REPOS+=($NEW_REPOS)
((PAGE++))
done

echo "REPO_LIST=${REPOS[*]}" >> "$GITHUB_ENV"

- name: Set Single Repo if Repository Filter Exists
if: ${{ contains(env.PR_RULES_FIELDS, 'Repository') }}
run: |
echo "REPO_LIST=${{ env.PR_REPO_VALUE }}" >> "$GITHUB_ENV"

- name: Fetch PR Data
id: upsert_pr_entity
run: |
GH_TOKEN=${{ secrets.GH_TOKEN }}
REPOS=(${{ env.REPO_LIST }})
CUTOFF_DATE=$(date -d '3 months ago' --utc +%Y-%m-%dT%H:%M:%SZ)
FILTERED_PRS=""
BLUEPRINT_ID="githubPullRequest"

for REPO in "${REPOS[@]}"; do
echo "Processing repo: $REPO"
PAGE=1

while true; do

PR_STATE_FILTER="all" # Default to 'all' if no status filter is provided

if [[ "$PR_STATUS_OP" == "is" ]]; then
PR_STATE_FILTER="$PR_STATUS_VALUE"
elif [[ "$PR_STATUS_OP" == "is not" ]]; then
PR_STATE_FILTER=$([[ "$PR_STATUS_VALUE" == "closed" ]] && echo "open" || echo "closed")
fi

RESPONSE=$(curl -s -H "Authorization: token $GH_TOKEN" \
"https://api.github.com/repos/$REPO/pulls?state=$PR_STATE_FILTER&per_page=100&page=$PAGE")

# Convert JSON response into an array (to avoid broken pipe issues)
PR_LIST=()
while IFS= read -r PR; do
PR_LIST+=("$PR")
done < <(echo "$RESPONSE" | jq -c '.[]')

# Stop if no more PRs are found
if [[ "${#PR_LIST[@]}" -eq 0 ]]; then
echo "No more PRs found for $REPO. Moving to the next repo..."
break
fi

for PR in "${PR_LIST[@]}"; do
PR_CREATED_AT=$(echo "$PR" | jq -r '.created_at')

if [[ "$PR_CREATED_AT" < "$CUTOFF_DATE" ]]; then
#echo "PR is older than 3 months. Stopping further fetch for $REPO."
break 2 # Exit both loops
fi

PR_MATCHES_FILTERS=true

PR_LABELS=($(echo "$PR" | jq -r '.labels[].name'))
LABEL_MATCH=false

for LABEL in "${PR_LABELS[@]}"; do
case "$PR_LABEL_OP" in
"equals") [[ "$LABEL" == "$PR_LABEL_VALUE" ]] && LABEL_MATCH=true ;;
"contains") [[ "$LABEL" == *"$PR_LABEL_VALUE"* ]] && LABEL_MATCH=true ;;
"starts with") [[ "$LABEL" == "$PR_LABEL_VALUE"* ]] && LABEL_MATCH=true ;;
"does not contain") [[ "$LABEL" == *"$PR_LABEL_VALUE"* ]] && LABEL_MATCH=false || LABEL_MATCH=true ;;
"does not start with") [[ "$LABEL" == "$PR_LABEL_VALUE"* ]] && LABEL_MATCH=false || LABEL_MATCH=true ;;
esac
[[ "$LABEL_MATCH" == true ]] && break # Exit early if a match is found
done

# Apply final decision
if [[ "$PR_LABEL_OP" =~ ^(equals|contains|starts with)$ && "$LABEL_MATCH" == false ]]; then
PR_MATCHES_FILTERS=false
elif [[ "$PR_LABEL_OP" =~ ^(does not contain|does not start with)$ && "$LABEL_MATCH" == true ]]; then
PR_MATCHES_FILTERS=false
fi


PR_AUTHOR=$(echo "$PR" | jq -r '.user.login') # Extract actual PR author
AUTHOR_MATCH=false

for AUTHOR in $PR_AUTHORS; do
echo "$AUTHOR"
case "$PR_AUTHOR_OP" in
"is") [[ "$PR_AUTHOR" == "$AUTHOR" ]] && AUTHOR_MATCH=true && break ;;
"is not") [[ "$PR_AUTHOR" == "$AUTHOR" ]] && AUTHOR_MATCH=false ;;
esac
done

# Apply final decision
if [[ "$PR_AUTHOR_OP" == "is" && "$AUTHOR_MATCH" == false ]]; then
PR_MATCHES_FILTERS=false
elif [[ "$PR_AUTHOR_OP" == "is not" && "$AUTHOR_MATCH" == true ]]; then
PR_MATCHES_FILTERS=false
fi

PR_TITLE=$(echo "$PR" | jq -r '.title')

case "$PR_TITLE_OP" in
"equals") [[ "$PR_TITLE" != "$PR_TITLE_VALUE" ]] && PR_MATCHES_FILTERS=false ;;
"contains") [[ "$PR_TITLE" != *"$PR_TITLE_VALUE"* ]] && PR_MATCHES_FILTERS=false ;;
"does not contain") [[ "$PR_TITLE" == *"$PR_TITLE_VALUE"* ]] && PR_MATCHES_FILTERS=false ;;
"starts with") [[ "$PR_TITLE" != "$PR_TITLE_VALUE"* ]] && PR_MATCHES_FILTERS=false ;;
"does not start with") [[ "$PR_TITLE" == "$PR_TITLE_VALUE"* ]] && PR_MATCHES_FILTERS=false ;;
esac


if $PR_MATCHES_FILTERS; then
PR_IDENTIFIER=$(echo "$PR" | jq -r '.id')
PR_TITLE=$(echo "$PR" | jq -r '.title')
PR_NUMBER=$(echo "$PR" | jq -r '.number')
PR_LINK=$(echo "$PR" | jq -r '.html_url')
PR_BRANCH=$(echo "$PR" | jq -r '.head.ref')
PR_CREATED_AT=$(echo "$PR" | jq -r '.created_at')
PR_UPDATED_AT=$(echo "$PR" | jq -r '.updated_at')
PR_CLOSED_AT=$(echo "$PR" | jq -r '.closed_at')
PR_MERGED_AT=$(echo "$PR" | jq -r '.merged_at')
PR_STATUS=$(echo "$PR" | jq -r '.state')
REPO_IDENTIFIER=$(echo "$PR" | jq -r '.head.repo.full_name')
PR_CREATOR=$(echo "$PR" | jq -r '.user.login')

curl --location --request POST "https://api.getport.io/v1/blueprints/${BLUEPRINT_ID}/entities?upsert=true&merge=true&run_id=${{fromJson(inputs.port_payload).runId}}" \
--header "Authorization: Bearer ${{ env.PORT_ACCESS_TOKEN }}" \
--header "Content-Type: application/json" \
--data-raw "{
\"identifier\": \"${PR_IDENTIFIER}\",
\"title\": \"${PR_TITLE}\",
\"properties\": {
\"status\": \"${PR_STATUS}\",
\"closedAt\": \"${PR_CLOSED_AT}\",
\"updatedAt\": \"${PR_CLOSED_AT}\",
\"mergedAt\": \"${PR_MERGED_AT}\",
\"createdAt\": \"${PR_CREATED_AT}\",
\"link\": \"${PR_LINK}\",
\"prNumber\": ${PR_NUMBER},
\"branch\": \"${PR_BRANCH}\"
},
\"relations\": {
\"repository\": \"${REPO_IDENTIFIER}\",
\"git_hub_creator\": \"${PR_CREATOR}\"
}
}"
fi
done

((PAGE++))
done
done

- name: Inform entity upsert failure
if: steps.upsert_pr_entity.outcome == 'failure'
uses: port-labs/port-github-action@v1
with:
clientId: ${{ secrets.PORT_CLIENT_ID }}
clientSecret: ${{ secrets.PORT_CLIENT_SECRET }}
baseUrl: https://api.getport.io
operation: PATCH_RUN
runId: ${{fromJson(inputs.port_payload).runId}}
logMessage: "Failed to report the created entities back to Port ..."

- name: Inform completion of pull request upsert
uses: port-labs/port-github-action@v1
with:
clientId: ${{ secrets.PORT_CLIENT_ID }}
clientSecret: ${{ secrets.PORT_CLIENT_SECRET }}
baseUrl: https://api.getport.io
operation: PATCH_RUN
runId: ${{fromJson(inputs.port_payload).runId}}
logMessage: "Fetching of historical PR was successful ✅"

Step 2: Set Up Self-Service Action

Create a self-service action in Port to run this workflow.

  1. Head to the self-service page.

  2. Click on the + New Action button.

  3. Click on the {...} Edit JSON button.

  4. Copy and paste the following JSON configuration into the editor.

    Create a deployment for every GitHub Pull Request (Click to expand)
    Modification Required

    Make sure to replace <GITHUB_ORG> and <GITHUB_REPO> with your GitHub organization and repository names respectively.

    {
    "identifier": "create_a_deployment_for_every_pull_request",
    "title": "Create a deployment for every GitHub Pull Request",
    "icon": "GitPullRequest",
    "description": "Track deployments triggered via merged pull requests",
    "trigger": {
    "type": "self-service",
    "operation": "CREATE",
    "userInputs": {
    "properties": {
    "your_deployment_integration": {
    "icon": "DefaultProperty",
    "title": "Which integration is used to define your deployment",
    "type": "string",
    "enum": [
    "GitHub"
    ],
    "enumColors": {
    "GitHub": "lightGray"
    },
    "default": "GitHub"
    },
    "your_deployment_type": {
    "icon": "DefaultProperty",
    "title": "How do you define deployment?",
    "type": "string",
    "enum": [
    "PR/MR merge"
    ],
    "enumColors": {
    "PR/MR merge": "lightGray"
    },
    "default": "PR/MR merge"
    },
    "pr_rules_fields": {
    "title": "PR rules - fields",
    "icon": "DefaultProperty",
    "type": "array",
    "default": [
    "Status"
    ],
    "items": {
    "enum": [
    "Status",
    "Repository",
    "Label",
    "Author",
    "Title"
    ],
    "enumColors": {
    "Status": "lightGray",
    "Repository": "lightGray",
    "Label": "lightGray",
    "Author": "lightGray",
    "Title": "lightGray"
    },
    "type": "string"
    }
    },
    "pr_rules_status_options": {
    "icon": "DefaultProperty",
    "title": "PR rules - Status - options",
    "type": "string",
    "default": "closed",
    "enum": [
    "open",
    "closed"
    ],
    "enumColors": {
    "open": "lightGray",
    "closed": "lightGray"
    }
    },
    "pr_rules_status_op": {
    "icon": "DefaultProperty",
    "title": "PR rules - Status - operator",
    "type": "string",
    "default": "is",
    "enum": [
    "is",
    "is not"
    ],
    "enumColors": {
    "is": "lightGray",
    "is not": "lightGray"
    }
    },
    "pr_rules_repo_str": {
    "type": "string",
    "title": "PR rules - Repository - string to look for",
    "visible": {
    "jqQuery": "if (.form.pr_rules_repo_op == \"is\" or .form.pr_rules_repo_op == \"is not\" or .form.pr_rules_repo_op == null) then false else true end"
    }
    },
    "pr_rules_repo_op": {
    "title": "PR rules - Repository operator",
    "icon": "DefaultProperty",
    "type": "string",
    "enum": [
    "starts with",
    "does not start with",
    "contains",
    "does not contain",
    "is",
    "is not"
    ],
    "enumColors": {
    "starts with": "lightGray",
    "does not start with": "lightGray",
    "contains": "lightGray",
    "does not contain": "lightGray",
    "is": "lightGray",
    "is not": "lightGray"
    },
    "visible": {
    "jqQuery": ".form.pr_rules_fields | index(\"Repository\") != null"
    }
    },
    "pr_rules_label_str": {
    "type": "string",
    "title": "PR rules - Label - string to look for",
    "visible": {
    "jqQuery": ".form.pr_rules_fields | index(\"Label\") != null"
    }
    },
    "pr_rules_label_op": {
    "title": "PR rules - Label - operator",
    "icon": "DefaultProperty",
    "type": "string",
    "enum": [
    "starts with",
    "does not start with",
    "contains",
    "does not contain",
    "equals"
    ],
    "enumColors": {
    "starts with": "lightGray",
    "does not start with": "lightGray",
    "contains": "lightGray",
    "does not contain": "lightGray",
    "equals": "lightGray"
    },
    "visible": {
    "jqQuery": ".form.pr_rules_fields | index(\"Label\") != null"
    }
    },
    "pr_rules_author_op": {
    "title": "PR rules - Author - operator",
    "icon": "DefaultProperty",
    "type": "string",
    "enum": [
    "is",
    "is not"
    ],
    "enumColors": {
    "is": "lightGray",
    "is not": "lightGray"
    },
    "visible": {
    "jqQuery": ".form.pr_rules_fields | index(\"Author\") != null"
    }
    },
    "deploy_branch": {
    "type": "string",
    "title": "Deploy branch",
    "description": "We will create deployments for every PR merged to this branch",
    "icon": "Branch",
    "default": "main"
    },
    "pr_rules_title_str": {
    "type": "string",
    "title": "PR rules - Title - string to look for",
    "visible": {
    "jqQuery": ".form.pr_rules_fields | index(\"Title\") != null"
    }
    },
    "pr_rules_title_op": {
    "title": "PR rules - Title - operator",
    "icon": "DefaultProperty",
    "type": "string",
    "enum": [
    "starts with",
    "does not start with",
    "contains",
    "does not contain",
    "equals"
    ],
    "enumColors": {
    "starts with": "lightGray",
    "does not start with": "lightGray",
    "contains": "lightGray",
    "does not contain": "lightGray",
    "equals": "lightGray"
    },
    "visible": {
    "jqQuery": ".form.pr_rules_fields | index(\"Title\") != null"
    }
    },
    "pr_rules_repository": {
    "type": "array",
    "title": "PR rules - Repository",
    "items": {
    "type": "string",
    "format": "entity",
    "blueprint": "githubRepository"
    },
    "visible": {
    "jqQuery": ".form.pr_rules_repo_op | IN(\"is\", \"is not\")"
    }
    },
    "pr_rules_author": {
    "title": "PR rules - Author",
    "icon": "DefaultProperty",
    "type": "array",
    "items": {
    "type": "string",
    "format": "entity",
    "blueprint": "githubUser"
    },
    "visible": {
    "jqQuery": ".form.pr_rules_fields | index(\"Author\") != null"
    }
    }
    },
    "required": [
    "your_deployment_integration",
    "your_deployment_type",
    "deploy_branch"
    ],
    "steps": [
    {
    "title": "Deployment setup",
    "order": [
    "your_deployment_integration",
    "your_deployment_type",
    "deploy_branch",
    "pr_rules_fields",
    "pr_rules_status_op",
    "pr_rules_status_options",
    "pr_rules_author_op",
    "pr_rules_author",
    "pr_rules_label_op",
    "pr_rules_label_str",
    "pr_rules_repo_op",
    "pr_rules_repository",
    "pr_rules_repo_str",
    "pr_rules_title_op",
    "pr_rules_title_str"
    ]
    }
    ]
    }
    },
    "invocationMethod": {
    "type": "GITHUB",
    "org": "<GITHUB_ORG>",
    "repo": "<GITHUB_REPO>",
    "workflow": "create_deployment_for_pull_request.yaml",
    "workflowInputs": {
    "config": {
    "{{ spreadValue() }}": "{{ .inputs }}"
    },
    "port_payload": {
    "runId": "{{ .run.id }}"
    }
    },
    "reportWorkflowStatus": true
    },
    "requiredApproval": false
    }
  5. Click Save.

Now you should see the Create a deployment for every GitHub Pull Request action in the self-service page. 🎉

Hardcoded values

The workflow uses CUTOFF_DATE = 3 months ago to define how far back in time to fetch data.