Output Charts

Blog

A pull request queue dashboard with GitHub Actions

Show your team which PRs are waiting and for how long. A small GitHub Actions workflow pushes the open-PR list to a dashboard every hour.

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:

  1. A KPI: the age of the oldest open PR.
  2. A table: the open PRs, oldest first.
  3. 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 gh returns. If you want review state or requested reviewers, add those fields to --json and 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.

More from the blog