Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# the build stage needs the source and vendor/ only; everything else just slows the context upload
.git/
.github/
.idea/
.claude/
.tools/
dist/
report/
data/
prawn
prawn-env.sh
.prawn
*.db
*.db-shm
*.db-wal
56 changes: 56 additions & 0 deletions .github/workflows/release.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,62 @@ jobs:
base64-subjects: ${{ needs.goreleaser.outputs.hashes }}
upload-assets: true

docker:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write # push the image to ghcr.io
id-token: write # cosign keyless signing via OIDC
attestations: write # actions/attest-build-provenance stores the attestation
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0
- uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# tag v1.2.3 publishes :1.2.3, :1.2 and :latest, plus :v1.2.3 so the image tag can be written
# the same way as the git tag
- uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
id: meta
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=${{ github.ref_name }}
type=raw,value=latest
- name: read the go version to build with
id: go
run: echo "version=$(cat .go-version)" >> "$GITHUB_OUTPUT"
# the dockerfile cross-compiles from the build platform, so arm64 needs no qemu emulation
- uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
id: push
with:
context: .
file: ./dockerfile
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
GO_VERSION=${{ steps.go.outputs.version }}
VERSION=${{ github.ref_name }}
GIT_COMMIT=${{ github.sha }}
- name: sign the image digest
env:
DIGEST: ${{ steps.push.outputs.digest }}
run: cosign sign --yes "ghcr.io/${GITHUB_REPOSITORY}@${DIGEST}"
- uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
with:
subject-name: ghcr.io/${{ github.repository }}
subject-digest: ${{ steps.push.outputs.digest }}
push-to-registry: true

homebrew:
needs: goreleaser
runs-on: ubuntu-latest
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
*.db-shm
*.db-wal
/report/
/data/
.prawn
.DS_Store
.idea/
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@
- `explore` data tab: the columns are configurable — drag a header to reorder, × on a header removes it, **columns** adds any field collected on a PR (the review roll, CI, dates, decision, milestone, waiting, resolve time, closes, checks... ~45 of them); the order lives in the url (`dc=`) so saved views, download, upload, paste, and the AI prompt cover the table as they do the trends
- `explore` collects what [ghp-sync](https://github.com/katbyte/ghp-sync) stamps on a project: **reviewed by**, **approved by**, **changes requested by** with request and review-comment counts (`kat(×2 ✎10)`), review comment totals, a **status** (merged, closed, or an open PR's state today), and **ci** (the head commit's checks: passing, failing, running); every `fetch` refreshes mergeability and CI across the open set in its reconcile walk, since neither moves a PR's updated time; query keys `reviewedby approvedby changesby status ci reviewcomments`
- `explore` trends: **quality by group** — member reviews, review comments, and changes-requested rounds per PR, and the share merged clean, by the author's group and the month the PR opened (resolved PRs only); **review load by group** — the same member work by the month it was left; presets for each, and a **partners: member work** preset — member reviews, review comments, and comments on the partner group's PRs by month, as lines and as stacked bars; `member reviews` and `member ✎` data columns and `memberreviews memberreviewcomments membercomments` query keys
- `explore --serve <port|host:port>`: after writing the page, serve it until ctrl-c and print the addresses other machines on the network can open it on; the page is written beside its path and swapped in whole, so a page being served is never read half-written; every request is logged with the viewer's login when a login-aware proxy in front sets it (`X-Forwarded-User` and friends), the client address, path, status, size and time
- `explore`: the tabs moved into the header as a dropdown where the page's name was, and two are renamed — **data** is **prs**, **areas** is **services** (old links and saved views still open); the view chips are a **preset** dropdown (one is ever active) with shorter names and a new **ci failing** entry, which puts the saved-view controls on the same line, and the PR detail's review line shows the head commit's ci; the saved-view controls show on every tab, since a view is the filter every tab shares plus the prs columns and the trend panels; a **draft** toggle beside the states, on for drafts only (`dr=yes`); the prs tab's **group by** (`gb=`) puts the table in sections — suggested category (docs only, tests and ci only, dependency bumps, small fixes…), kind of change, service, court, status today, ci, author group, effort, author — each keeping the sort inside it
- `fetch`: a later timeline page holding something the token can't read (a review request to a team, without org access) no longer fails the run; the node is left empty, as the first page already did
- a container (`dockerfile`, `docker-compose.yml`, `make docker`): serves the explore page and refreshes it on a cron schedule (`EXPLORE_CRON`), reading the same `.prawn` file the cli does; the release workflow publishes it to `ghcr.io/katbyte/prawn`, signed with cosign and attested
- the standard katbyte tooling, as tctest and terrafmt: CI workflows (build, tests, golangci-lint with azproviderlint, depscheck, govulncheck, CodeQL, actionlint, yamllint, shellcheck, typos), dependabot, goreleaser releases with cosign signing and SLSA provenance, a homebrew tap formula, dev tools pinned in `.tools/go.mod` and built by `make tools`, `SECURITY.md`, `CODE_OF_CONDUCT.md`; the stricter linters cleaned up (`slices.Sort`, unused receivers, `fmt.Fprint`)
- `fetch --since`: backfills every PR closed or merged since the date in month windows, resumable, and stores every PR's full timeline (`events`, `commits` tables) with `merged_by`, `milestone`, `base_ref`, `head_ref`; long timelines page to completion
- `PRAWN_GROUP_<name>=login,login`: named groups of authors for the explore page, from the environment or `.prawn`; `PRAWN_MAINTAINERS` names the group that counts as maintainers (default `members`)
22 changes: 19 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,19 +77,35 @@ export PRAWN_GROUP_MEMBERS=katbyte,jackofallops # author groups, any number of
export PRAWN_GROUP_PARTNERS=magodo,wodansson
prawn fetch
prawn explore # -> report/explore.html
prawn explore --serve 8765 # ...and serve it, for other machines on the network
```

One self-contained page over every PR open at any point in the period, with a filter bar over six tabs:
One self-contained page over every PR open at any point in the period, with a filter bar, and six tabs picked from the dropdown in the header:

- **data** — the matching PRs as a table; pick, reorder, and sort the columns, open a row for the PR's timeline
- **prs** — the matching PRs as a table; pick, reorder, and sort the columns, **group by** suggested category, kind, service, court, status, ci, author group, effort, or author to tackle alike PRs together; open a row for the PR's timeline
- **trends** — metrics over time: backlog, flow, review status, times, review load, and quality by author group
- **suggested** — easy wins by category (docs only, approved but unmerged, small and unanswered, …) or by service
- **people** — authors and reviewers
- **areas** — services, kinds of change, labels
- **services** — services, kinds of change, labels
- **checks** — the close candidates, restricted to the filter

Every control lives in the url, so a view is a link, and views can be saved, downloaded, and pasted.

## Docker

A container that keeps the explore page fresh and serves it: `prawn explore --serve` runs on start, and
a cron job inside refreshes the page on a schedule (`EXPLORE_CRON`, set in `docker-compose.yml`).

```bash
# .prawn holds GITHUB_TOKEN, PRAWN_REPO, PRAWN_SINCE, PRAWN_GROUP_<name>... — the same file the cli reads
make docker # or `docker compose pull` once a release has published the image
docker compose up -d # -> http://localhost:8765/ ; the db and the page live in ./data
```

The first start of an empty `./data` walks the whole repo before the page appears. The page is open to
anyone who can reach the port, so put a login in front of it (oauth2-proxy, Cloudflare Access) before
exposing it beyond the local network.

## Configuration

All options can be passed as command-line flags, environment variables, or via a `.prawn` file (env format)
Expand Down
12 changes: 10 additions & 2 deletions assets/reports/explore.html.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ header { display: flex; flex-wrap: wrap; align-items: baseline; gap: 6px 18px; p
header h1 { font-size: 18px; margin: 0; font-weight: 650; }
header h1 span { color: var(--muted); font-weight: 400; }
header h1 b { font-weight: 650; }
/* the tab, as the page's name: a dropdown dressed as the title */
header h1 select { font: inherit; font-weight: 650; background: var(--surface); color: var(--ink); border: 1px solid var(--axis); border-radius: 6px; padding: 2px 6px; cursor: pointer; }
header .sub { color: var(--ink-2); font-size: 13px; }
header .right { margin-left: auto; display: flex; gap: 8px; align-items: center; }
button.plain, label.plain { border: 1px solid var(--axis); background: var(--surface); color: var(--ink-2); border-radius: 6px; padding: 3px 9px; font: inherit; font-size: 12px; cursor: pointer; }
Expand Down Expand Up @@ -173,6 +175,8 @@ aside label.opt .lr button.on { background: var(--ink-2); color: var(--surface);
.seg.multi button { display: inline-flex; align-items: center; gap: 5px; }
.seg.multi button i { width: 8px; height: 8px; border-radius: 2px; display: inline-block; border: 1px dashed currentColor; }
.seg.multi button.on i { background: var(--mc); border-color: var(--mc); }
.seg.multi.apart { margin-left: 8px; } /* the draft toggle: beside the states, not one of them */
#filters label .row { display: flex; align-items: center; }
select { border: 1px solid var(--axis); background: var(--surface); color: var(--ink); border-radius: 6px; padding: 3px 6px; font: inherit; font-size: 12px; }
.pillbtn { font-size: 12px; padding: 2px 9px; border-radius: 6px; border: 1px solid var(--axis); background: var(--surface); color: var(--ink-2); cursor: pointer; user-select: none; }
.pillbtn.on { color: var(--surface); background: var(--ink-2); border-color: var(--ink-2); }
Expand Down Expand Up @@ -248,6 +252,10 @@ td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; white-sp
#colpick .pop { width: 360px; }
#colpick .opt .desc { color: var(--muted); font-size: 11px; margin-left: auto; text-align: right; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 200px; flex: none; }
#colpick .opt.reset { border-bottom: 1px solid var(--grid); border-radius: 0; margin-bottom: 4px; padding-bottom: 6px; }
/* group by: a section header per group, the count and the grouping's note beside the name */
tr.group td { font-weight: 650; color: var(--ink); background: var(--page); padding: 10px 10px 5px; border-top: none; }
tr.group td .n { color: var(--muted); font-weight: 400; margin-left: 7px; font-variant-numeric: tabular-nums; }
tr.group td .desc { color: var(--muted); font-weight: 400; font-size: 12px; margin-left: 10px; }
tr.pr { cursor: pointer; }
tr.pr:hover td { background: var(--wash); }
tr.pr td.title { max-width: 560px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
Expand Down Expand Up @@ -288,11 +296,11 @@ tr.detail td { background: var(--page); padding: 10px 14px 12px; }
</head>
<body>
<header>
<h1>🦐 prawn <span>·</span> <b>explore</b> <span>· {{ .Repo }}</span></h1>
<h1>🦐 prawn <span>·</span> <select id="tabSel" title="the tab"></select> <span>· {{ .Repo }}</span></h1>
<span class="sub" id="subtitle">{{ .Count }} PRs open at some point since {{ .Since }}</span>
<div class="right views">
<span id="viewhold" hidden><span id="viewctl" hidden>
<select id="viewSel" title="saved views (this browser): the filter, the data tab's columns and sort, and the trend panels"></select>
<select id="viewSel" title="saved views (this browser): the filter, the prs tab's columns and sort, and the trend panels"></select>
<button class="plain" id="viewSave" title="save the current state into the selected view">save</button>
<button class="plain" id="viewSaveAs">save as…</button>
<button class="plain" id="viewDel">delete</button>
Expand Down
Loading
Loading