Every team has the PR that has been "almost ready" for nine days. Nobody is ignoring it on purpose. It's on page two of a list nobody opens, and the person who could review it thinks someone else already did.
The fix isn't another notification. It's one page that answers, at a glance: how many PRs are open, which ones are oldest, and whose queue is piling up. Put it on a link the team already has open, and the nine-day PR gets reviewed out of mild embarrassment.
Why not just use GitHub's PR list?
You can, and it's the source of truth. But it's sorted for finding a PR, not for noticing one. It doesn't tell you the oldest PR is nine days old unless you go and look, and "go and look" is the step that keeps not happening.
The other options are heavier than the problem:
- A BI tool wants the GitHub data in a warehouse first.
- A chat bot posts a daily list that scrolls away by lunch.
- An HTML page an agent built for you is lovely and stuck at the moment it was generated.
GitHub Actions can already read the PR list. All it needs is somewhere to put the result.
The dashboard
Three visuals on an Output Charts dashboard, each holding one JSON document:
- A KPI: the age of the oldest open PR.
- A table: the open PRs, oldest first.
- A horizontal bar: open PRs by author.
Here's what the workflow will send. The KPI:
{ "label": "Oldest open PR", "value": 9, "format": "number", "suffix": " days" }
The table:
{
"columns": [
{ "key": "pr", "label": "PR", "align": "left", "format": "text" },
{ "key": "title", "label": "Title", "align": "left", "format": "text" },
{ "key": "author", "label": "Author", "align": "left", "format": "text" },
{ "key": "days", "label": "Open (days)", "align": "right", "format": "number" }
],
"data": [
{ "pr": "#212", "title": "Retry failed webhook deliveries", "author": "sam", "days": 9 },
{ "pr": "#219", "title": "Move billing emails to the queue", "author": "ana", "days": 4 },
{ "pr": "#223", "title": "Fix timezone in weekly report", "author": "sam", "days": 1 }
]
}
A table takes up to 12 columns and 200 rows. Each cell is a string or a number of up to 200 characters, so long titles get trimmed.
The workflow
API push is part of Solo ($10 a month). Create a key under Account → API keys. Then, for each visual, copy its push URL from Push from code in the editor. Add four repository secrets: OC_KEY, OC_OLDEST_URL, OC_TABLE_URL and OC_AUTHORS_URL.
# .github/workflows/pr-dashboard.yml
name: PR dashboard
on:
schedule:
- cron: "15 * * * *" # every hour, UTC
workflow_dispatch:
permissions:
pull-requests: read
jobs:
push:
runs-on: ubuntu-latest
steps:
- name: List open PRs
env:
GH_TOKEN: ${{ github.token }}
run: |
gh pr list --repo "$GITHUB_REPOSITORY" --state open --limit 200 \
--json number,title,author,createdAt,isDraft \
--jq 'map(select(.isDraft | not))' > prs.json
- name: Push to Output Charts
env:
OC_KEY: ${{ secrets.OC_KEY }}
OC_OLDEST_URL: ${{ secrets.OC_OLDEST_URL }}
OC_TABLE_URL: ${{ secrets.OC_TABLE_URL }}
OC_AUTHORS_URL: ${{ secrets.OC_AUTHORS_URL }}
run: |
push() {
curl --fail-with-body -sS -X PUT "$1" \
-H "Authorization: Bearer $OC_KEY" \
-H "Content-Type: application/json" \
--data @-
}
jq '{label: "Oldest open PR", format: "number", suffix: " days",
value: (map((now - (.createdAt | fromdateiso8601)) / 86400 | floor) | max // 0)}' \
prs.json | push "$OC_OLDEST_URL"
jq '{columns: [
{key: "pr", label: "PR", align: "left", format: "text"},
{key: "title", label: "Title", align: "left", format: "text"},
{key: "author", label: "Author", align: "left", format: "text"},
{key: "days", label: "Open (days)", align: "right", format: "number"}],
data: (map({pr: "#\(.number)", title: .title[0:120], author: .author.login,
days: ((now - (.createdAt | fromdateiso8601)) / 86400 | floor)})
| sort_by(-.days))}' \
prs.json | push "$OC_TABLE_URL"
jq '{orientation: "horizontal", mode: "grouped", xKey: "author",
series: [{key: "prs", label: "Open PRs"}],
data: (group_by(.author.login)
| map({author: .[0].author.login, prs: length})
| sort_by(-.prs))}' \
prs.json | push "$OC_AUTHORS_URL"
gh and jq are already installed on GitHub's Ubuntu runners. Building the JSON with jq means a quote in a PR title can't break the request. Drafts are filtered out, because a draft is a promise, not a request for review.
If one push is rejected, --fail-with-body fails the step and prints the API's reason. That's usually a 422 naming the exact field, such as data[2].days.
Things to know
- Schedules run in UTC and can start a few minutes late. That's fine for this. For a public repository, GitHub turns schedules off after 60 days with no activity, so a quiet repo's dashboard can quietly stop.
- Each push replaces the whole document. Send the full list every time. Re-running the job is harmless.
- Open copies refresh by themselves every 15 seconds. Put the page on a spare screen and it keeps up.
- This only shows what
ghreturns. If you want review state or requested reviewers, add those fields to--jsonand to the table's columns. - It's view-only. Clicking a row doesn't open the PR. The table is for noticing; GitHub is still where you act.
Sharing it
Share the dashboard view-only by email with the team. For an open-source project, publish it as a link anyone can open without an account. Contributors like knowing their PR is visibly in the queue rather than lost in it.
Try it
Create a free dashboard and paste the table JSON above to see the layout. Upgrade to Solo when you want the workflow to keep it current. For the basics of pushing, see How to update a dashboard chart with cURL.