From d58045f414a817c4f9ae491b22911c957fa4bb8c Mon Sep 17 00:00:00 2001 From: kt Date: Sat, 3 Oct 2026 17:45:25 -0700 Subject: [PATCH 1/4] explore: --serve, so other machines can open the page The page is one self-contained file, so this is a file server for exactly that file, kept up until ctrl-c, printing the addresses the page can be opened on. The page is now rendered beside its path and swapped in whole, so a page being served is never read half-written and two writers never share a draft. Every request is logged: the viewer's login when a proxy in front sets it, the client address, path, status, size and time. --- CHANGELOG.md | 1 + README.md | 1 + cli/all-flags.go | 1 + cli/explore/explore.go | 22 ++++- cli/explore/serve.go | 168 ++++++++++++++++++++++++++++++++++++++ cli/explore/serve_test.go | 41 ++++++++++ 6 files changed, 231 insertions(+), 3 deletions(-) create mode 100644 cli/explore/serve.go create mode 100644 cli/explore/serve_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index f0ef6bb..716f75e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ - `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 `: 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 - 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_=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`) diff --git a/README.md b/README.md index 9bb5d3d..4ccceed 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,7 @@ 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: diff --git a/cli/all-flags.go b/cli/all-flags.go index 1e36651..f8d0e45 100644 --- a/cli/all-flags.go +++ b/cli/all-flags.go @@ -88,6 +88,7 @@ type FlagsCommands struct { type FlagsExplore struct { Out string `mapstructure:"explore-out"` // the html file to write Checks bool `mapstructure:"checks"` // run the close checks for the checks tab + Serve string `mapstructure:"serve"` // "" writes and exits; a port or host:port serves the page until interrupted } // FlagsReport configures prawn close report. diff --git a/cli/explore/explore.go b/cli/explore/explore.go index 81ab3cc..0c53c70 100644 --- a/cli/explore/explore.go +++ b/cli/explore/explore.go @@ -59,6 +59,7 @@ checkout (--src-dir / PRAWN_SRC_DIR) and is skipped with a note without it; --wi c.Flags().Bool("checks", true, "run the close checks for the checks tab (needs --src-dir)") c.Flags().Bool("with-ai", false, "AI-score every check candidate, as prawn close report --with-ai does (cached verdicts are reused)") c.Flags().Int("limit", 0, "cap candidates per check when scoring, for cheap test runs (0 = all)") + c.Flags().String("serve", "", "after writing, serve the page on this address until ctrl-c (a port like 8765, or host:port) so other machines on the network can open it") return c } @@ -183,6 +184,9 @@ func run(f *cli.FlagData) error { if abs, aerr := filepath.Abs(out); aerr == nil { cout.Printf("open: file://%s\n", abs) } + if f.Cmd.Explore.Serve != "" { + return serve(out, f.Cmd.Explore.Serve) + } return nil } @@ -277,14 +281,26 @@ func write(path string, data *explore.Data) error { if err != nil { return fmt.Errorf("parsing explore template: %w", err) } - out, err := os.Create(path) //nolint:gosec // G304: user-chosen output path is the point + // rendered beside the page under a name of its own and swapped in whole, + // so a page being served (--serve, or the container's cron refresh) is + // never read half-written, and two writers never share a draft + out, err := os.CreateTemp(filepath.Dir(path), filepath.Base(path)+".*.tmp") if err != nil { return fmt.Errorf("creating %s: %w", path, err) } - defer func() { _ = out.Close() }() - + tmp := out.Name() if err := tmpl.Execute(out, pageData{Repo: data.Repo, Since: data.Since, Count: len(data.PRs), DataJSON: template.JS(js), Script: template.JS(assets.ExploreJS())}); err != nil { //nolint:gosec // G203: see above + _ = out.Close() + _ = os.Remove(tmp) return fmt.Errorf("rendering explore page: %w", err) } + if err := out.Close(); err != nil { + _ = os.Remove(tmp) + return fmt.Errorf("writing %s: %w", tmp, err) + } + if err := os.Rename(tmp, path); err != nil { + _ = os.Remove(tmp) + return fmt.Errorf("replacing %s: %w", path, err) + } return nil } diff --git a/cli/explore/serve.go b/cli/explore/serve.go new file mode 100644 index 0000000..874b358 --- /dev/null +++ b/cli/explore/serve.go @@ -0,0 +1,168 @@ +package explore + +import ( + "context" + "errors" + "fmt" + "net" + "net/http" + "os" + "os/signal" + "strings" + "syscall" + "time" + + "github.com/katbyte/go-kt/cout" +) + +// serve keeps the written page available over http until interrupted, so +// other machines on the network can open it — the page is one file with +// its data embedded, so this is a file server for exactly that file. +func serve(path, addr string) error { + if !strings.Contains(addr, ":") { + addr = ":" + addr // a bare port + } + mux := http.NewServeMux() + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" && r.URL.Path != "/explore.html" { + http.NotFound(w, r) + return + } + w.Header().Set("Cache-Control", "no-cache") // a regenerated page must win over a cached one + http.ServeFile(w, r, path) + }) + srv := &http.Server{Addr: addr, Handler: logged(mux), ReadHeaderTimeout: 10 * time.Second} + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + ln, err := (&net.ListenConfig{}).Listen(ctx, "tcp", addr) + if err != nil { + return fmt.Errorf("listening on %s: %w", addr, err) + } + port := "" + if _, p, perr := net.SplitHostPort(ln.Addr().String()); perr == nil { + port = p // the real port, when 0 asked for a free one + } + cout.Printf("\nserving %s — ctrl-c stops\n", path) + for _, h := range reachableHosts(addr) { + cout.Printf(" http://%s:%s/\n", h, port) + } + + errc := make(chan error, 1) + go func() { errc <- srv.Serve(ln) }() + select { + case err := <-errc: + if errors.Is(err, http.ErrServerClosed) { + return nil + } + return fmt.Errorf("serving: %w", err) + case <-ctx.Done(): + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + cout.Printf("\nstopping\n") + return srv.Shutdown(shutdownCtx) + } +} + +// userHeaders are where a login-aware proxy in front (traefik's github auth +// plugins, oauth2-proxy, authelia) puts the viewer's login; the first one set +// names the viewer in the log. They mean nothing without such a proxy — any +// client can send them — so they are a label, never a decision. +var userHeaders = []string{"X-Forwarded-User", "X-Auth-Request-User", "X-Auth-User", "Remote-User"} + +// logged writes one line per request — who, from where, what, how it went — +// so the container's log (or the terminal) shows who is looking at what. +func logged(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + sw := &statusWriter{ResponseWriter: w, status: http.StatusOK} + next.ServeHTTP(sw, r) + cout.Printf("%s %s %s %s %s %s %s %s\n", + start.Format("2006-01-02 15:04:05"), viewer(r), from(r), r.Method, r.URL.Path, statusColour(sw.status), humanSize(sw.bytes), time.Since(start).Round(time.Millisecond)) + }) +} + +// viewer is the login the proxy in front vouched for, "-" without one. +func viewer(r *http.Request) string { + for _, h := range userHeaders { + if v := r.Header.Get(h); v != "" { + return v + } + } + return "-" +} + +// from is the client's address: the first hop in X-Forwarded-For when a +// proxy set it, else the connection's own. +func from(r *http.Request) string { + if xff := r.Header.Get("X-Forwarded-For"); xff != "" { + return strings.TrimSpace(strings.Split(xff, ",")[0]) + } + if h, _, err := net.SplitHostPort(r.RemoteAddr); err == nil { + return h + } + return r.RemoteAddr +} + +func statusColour(status int) string { + if status >= 400 { + return fmt.Sprintf("%d", status) + } + return fmt.Sprintf("%d", status) +} + +func humanSize(n int64) string { + switch { + case n >= 1<<20: + return fmt.Sprintf("%.1fMB", float64(n)/(1<<20)) + case n >= 1<<10: + return fmt.Sprintf("%dKB", n/(1<<10)) + } + return fmt.Sprintf("%dB", n) +} + +// statusWriter remembers the status and the bytes sent for the log line. +type statusWriter struct { + http.ResponseWriter + status int + bytes int64 +} + +func (w *statusWriter) WriteHeader(status int) { + w.status = status + w.ResponseWriter.WriteHeader(status) +} + +func (w *statusWriter) Write(b []byte) (int, error) { + n, err := w.ResponseWriter.Write(b) + w.bytes += int64(n) + return n, err +} + +// reachableHosts names the addresses the page can be opened on: localhost, +// the machine's hostname, and each non-loopback IPv4 address — or just the +// bound address when the listener is pinned to one. +func reachableHosts(addr string) []string { + host, _, _ := net.SplitHostPort(addr) + if host != "" && host != "0.0.0.0" && host != "::" { + return []string{host} + } + hosts := []string{"localhost"} + if hn, err := os.Hostname(); err == nil && hn != "" { + hn = strings.ToLower(hn) + if !strings.Contains(hn, ".") { + hn += ".local" // the name other machines resolve over mdns + } + hosts = append(hosts, hn) + } + addrs, err := net.InterfaceAddrs() + if err != nil { + return hosts + } + for _, a := range addrs { + if ipn, ok := a.(*net.IPNet); ok && !ipn.IP.IsLoopback() && ipn.IP.To4() != nil { + hosts = append(hosts, ipn.IP.String()) + } + } + return hosts +} diff --git a/cli/explore/serve_test.go b/cli/explore/serve_test.go new file mode 100644 index 0000000..74b23aa --- /dev/null +++ b/cli/explore/serve_test.go @@ -0,0 +1,41 @@ +package explore + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestViewerAndFrom(t *testing.T) { + t.Parallel() + r := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", http.NoBody) + r.RemoteAddr = "10.0.0.7:4242" + if got := viewer(r); got != "-" { + t.Errorf("viewer without a proxy header = %q, want -", got) + } + if got := from(r); got != "10.0.0.7" { + t.Errorf("from without a proxy = %q, want 10.0.0.7", got) + } + + r.Header.Set("X-Forwarded-User", "katbyte") + r.Header.Set("X-Forwarded-For", "203.0.113.9, 10.0.0.1") + if got := viewer(r); got != "katbyte" { + t.Errorf("viewer = %q, want katbyte", got) + } + if got := from(r); got != "203.0.113.9" { + t.Errorf("from behind a proxy = %q, want the first hop", got) + } +} + +func TestStatusWriter(t *testing.T) { + t.Parallel() + rec := httptest.NewRecorder() + sw := &statusWriter{ResponseWriter: rec, status: http.StatusOK} + sw.WriteHeader(http.StatusNotFound) + if _, err := sw.Write([]byte("nope")); err != nil { + t.Fatal(err) + } + if sw.status != http.StatusNotFound || sw.bytes != 4 { + t.Errorf("recorded %d/%d bytes, want 404/4", sw.status, sw.bytes) + } +} From 1f83736058cad3f64b3ec7a4048cf4274d2f17bd Mon Sep 17 00:00:00 2001 From: kt Date: Sat, 3 Oct 2026 17:46:39 -0700 Subject: [PATCH 2/4] docker: a container that serves the explore page and keeps it fresh ghp-sync's image, adapted: prawn explore --serve runs as the main process, and a cron job inside re-runs prawn explore on a schedule to sync the database and swap in a new page. Compose reads the same .prawn file the cli does, so the token never lands in a committed file. The release workflow publishes the image to ghcr.io, signed and attested, as ghp-sync's does. Unlike ghp-sync, the crontab is written outside /etc/cron.d, which dcron reads on its own: a crontab there and then installed with crontab ran every job twice at once. --- .dockerignore | 15 +++++++++ .github/workflows/release.yaml | 56 ++++++++++++++++++++++++++++++++++ .gitignore | 1 + CHANGELOG.md | 1 + README.md | 15 +++++++++ docker-compose.yml | 20 ++++++++++++ dockerfile | 38 +++++++++++++++++++++++ makefile | 6 +++- scripts/entry.sh | 18 +++++++++++ scripts/run.sh | 12 ++++++++ 10 files changed, 181 insertions(+), 1 deletion(-) create mode 100644 .dockerignore create mode 100644 docker-compose.yml create mode 100644 dockerfile create mode 100644 scripts/entry.sh create mode 100644 scripts/run.sh diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..2d3e64c --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index f4826b1..b32307e 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -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 diff --git a/.gitignore b/.gitignore index f15cbab..4bdf2de 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ *.db-shm *.db-wal /report/ +/data/ .prawn .DS_Store .idea/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 716f75e..5082cde 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ - `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 `: 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 +- 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_=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`) diff --git a/README.md b/README.md index 4ccceed..48e675d 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,21 @@ One self-contained page over every PR open at any point in the period, with a fi 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_... — 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) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..1907bde --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,20 @@ +services: + prawn: + # published on release; `docker compose pull` picks up a new build of this tag. `make docker` + # builds and tags the same name locally, for running from a checkout + image: ghcr.io/katbyte/prawn:latest + container_name: prawn + restart: unless-stopped + ports: + - "8765:8765" # the page is open to anyone who can reach this port: put a login in front of it + # the same env-format file the cli reads: GITHUB_TOKEN, PRAWN_REPO, PRAWN_SINCE, PRAWN_GROUP_, + # PRAWN_MAINTAINERS...; gitignored, so the token never lands in this file + env_file: .prawn + environment: + - "TZ=America/Vancouver" + - "EXPLORE_CRON=30 0,3,6,9,12,15,18,21 * * *" # half an hour after ghp-sync's stamps land + - "PORT=8765" + volumes: + - ./data:/data # prs.db and report/explore.html + # a provider checkout gives the release markers and the checks tab; set PRAWN_SRC_DIR=/src in .prawn + # - /path/to/terraform-provider-azurerm:/src:ro diff --git a/dockerfile b/dockerfile new file mode 100644 index 0000000..9ab4722 --- /dev/null +++ b/dockerfile @@ -0,0 +1,38 @@ +# syntax=docker/dockerfile:1 + +# no default: .go-version is the single source of truth and make/CI pass it in, so the builder can +# never lag go.mod's go directive (the official go images pin GOTOOLCHAIN=local, so an older +# builder fails the build rather than fetching a newer toolchain). build by hand with: +# docker build --build-arg GO_VERSION=$(cat .go-version) . +ARG GO_VERSION + +# the build stage runs on the builder's own architecture and cross-compiles for the target, so the +# multi-arch image builds in seconds rather than minutes under qemu emulation +FROM --platform=$BUILDPLATFORM golang:${GO_VERSION}-alpine AS build + +ARG TARGETOS +ARG TARGETARCH +ARG VERSION=dev +ARG GIT_COMMIT=docker + +WORKDIR /src +COPY . . +RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -trimpath -mod=vendor \ + -ldflags "-s -w -X github.com/katbyte/go-kt/version.Version=${VERSION} -X github.com/katbyte/go-kt/version.GitCommit=${GIT_COMMIT}" \ + -o /out/prawn . + +# dcron refreshes the page on a schedule, ca-certificates let prawn reach github, git is for the +# release markers and the checks tab when a provider checkout is mounted, tzdata makes TZ work. +# upgrade first so a release rebuild picks up alpine security fixes the base image tag has not +# been rebuilt with yet +FROM alpine:3.24 +RUN apk upgrade --no-cache && apk add --no-cache ca-certificates dcron git tzdata + +# the volume: prawn's defaults put prs.db and report/explore.html under the working directory +WORKDIR /data +COPY --from=build /out/prawn /usr/bin/prawn +COPY scripts/entry.sh scripts/run.sh /app/scripts/ +RUN chmod +x /app/scripts/entry.sh /app/scripts/run.sh + +EXPOSE 8765 +CMD ["/app/scripts/entry.sh"] diff --git a/makefile b/makefile index c8376de..c1a5aee 100644 --- a/makefile +++ b/makefile @@ -89,6 +89,10 @@ build: ## Compile prawn with version info from git @echo "==> building..." go build -o prawn -ldflags "${LDFLAGS}" +docker: ## Build the prawn docker image, tagged as the published name so `docker compose up` runs it + docker build --tag prawn --tag ghcr.io/katbyte/prawn:latest --build-arg GO_VERSION=$(shell cat .go-version) \ + --build-arg VERSION=${GIT_VERSION} --build-arg GIT_COMMIT=${GIT_COMMIT} . + install: ## Install prawn into GOPATH/bin with version info from git @echo "==> installing..." go install -ldflags "${LDFLAGS}" . @@ -166,4 +170,4 @@ test: build ## Run the tests under the race detector check-all: build test lint actionlint yamllint shellcheck typos depscheck ## Run build + test + all linters + depscheck -.PHONY: default all help fmt goimports build lint lint-fix actionlint yamllint shellcheck typos typos-fix zizmor depscheck check-all install tools test +.PHONY: default all help fmt goimports build docker lint lint-fix actionlint yamllint shellcheck typos typos-fix zizmor depscheck check-all install tools test diff --git a/scripts/entry.sh b/scripts/entry.sh new file mode 100644 index 0000000..d0daa4b --- /dev/null +++ b/scripts/entry.sh @@ -0,0 +1,18 @@ +#!/bin/sh +set -e + +# the image ships a prebuilt /usr/bin/prawn, so this only wires up the cron refresh and runs the server + +# cron starts its jobs with a bare environment: keep ours, quoted, for run.sh to source +export -p > /app/env.sh +chmod 600 /app/env.sh + +# the refresh job writes to the container log through pid 1, which is prawn once exec'd below. +# the file lives outside /etc/cron.d, which dcron also reads: a crontab there is installed twice +# and the job runs twice at once +echo "${EXPLORE_CRON:-30 */3 * * *} /app/scripts/run.sh >> /proc/1/fd/1 2>&1" > /app/crontab +crontab /app/crontab +/usr/sbin/crond -b + +# fetch what moved, write the page, serve it; a fresh volume first walks the whole repo, which takes a while +exec prawn explore --serve "${PORT:-8765}" diff --git a/scripts/run.sh b/scripts/run.sh new file mode 100644 index 0000000..bfa0268 --- /dev/null +++ b/scripts/run.sh @@ -0,0 +1,12 @@ +#!/bin/sh + +# the cron refresh: sync what moved since the last run and rewrite the page the server is handing out + +# shellcheck disable=SC1091 # written by entry.sh at container start +. /app/env.sh +cd /data || exit 1 + +echo +echo "refresh started: $(date)" +prawn explore +echo "refresh finished: $(date)" From ddfa5e7935183818c59bf28b9b04018ecdc857a2 Mon Sep 17 00:00:00 2001 From: kt Date: Sat, 3 Oct 2026 17:46:39 -0700 Subject: [PATCH 3/4] fetch: a timeline page the token can't fully read no longer fails the run A review request to a team needs org access to read the team; without it GitHub returns the rest of the page with that one node null and an error beside it. The first page already tolerated this; the follow-up pages now do too, as the team's name is only ever shown on the timeline and never drives a decision. --- CHANGELOG.md | 1 + lib/gh/graphql-timeline.go | 6 ++++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5082cde..f3575f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ - `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 `: 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 +- `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 diff --git a/lib/gh/graphql-timeline.go b/lib/gh/graphql-timeline.go index 0a86a0d..5ba512c 100644 --- a/lib/gh/graphql-timeline.go +++ b/lib/gh/graphql-timeline.go @@ -203,7 +203,9 @@ query($owner: String!, $name: String!, $number: Int!, $cursor: String) { }` // PRTimeline fetches one further page of a PR's timeline from cursor — the -// follow-up for PRs whose first page (fetched with the PR) had more. +// follow-up for PRs whose first page (fetched with the PR) had more. Tolerant +// like that first page: a team the token can't read (a review request to +// one needs org access) nulls the one node rather than failing the fetch. func (c *Client) PRTimeline(owner, name string, number int, cursor string) (*TimelinePage, RateLimit, error) { vars := repoVars(owner, name, cursor) vars["number"] = number @@ -216,7 +218,7 @@ func (c *Client) PRTimeline(owner, name string, number int, cursor string) (*Tim } `json:"pullRequest"` } `json:"repository"` } - if err := c.Do(prTimelineQuery, vars, &resp); err != nil { + if err := c.DoTolerant(prTimelineQuery, vars, &resp); err != nil { return nil, RateLimit{}, fmt.Errorf("fetching timeline page for #%d: %w", number, err) } return &resp.Repository.PullRequest.TimelineItems, resp.RateLimit, nil From c415788ac7329226217cd37ce53d8b3887c4485d Mon Sep 17 00:00:00 2001 From: kt Date: Sat, 3 Oct 2026 18:28:27 -0700 Subject: [PATCH 4/4] explore: tabs in the header, a draft toggle, group by, and failing ci The tab bar is a dropdown in the header where the page's name was, and two tabs are renamed: data is prs, areas is services. Old links and saved views still open. The view chips are a preset dropdown with shorter names, since only one is ever active, which puts the saved-view controls on the same line; those controls now show on every tab. A ci failing preset joins conflicted, and the PR detail's review line shows the head commit's ci. A draft toggle sits beside the states, on for drafts only. The prs tab's group by puts the table in sections (suggested category, kind of change, service, court, status, ci, author group, effort, author) to tackle alike PRs together, each keeping the sort inside it. --- CHANGELOG.md | 1 + README.md | 6 +-- assets/reports/explore.html.tmpl | 12 ++++- assets/reports/explore.js | 88 ++++++++++++++++++++++---------- 4 files changed, 75 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f3575f9..9720e46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ - `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 `: 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`) diff --git a/README.md b/README.md index 48e675d..339afa5 100644 --- a/README.md +++ b/README.md @@ -80,13 +80,13 @@ 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. diff --git a/assets/reports/explore.html.tmpl b/assets/reports/explore.html.tmpl index d2cfd11..4a0f9a0 100644 --- a/assets/reports/explore.html.tmpl +++ b/assets/reports/explore.html.tmpl @@ -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; } @@ -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); } @@ -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; } @@ -288,11 +296,11 @@ tr.detail td { background: var(--page); padding: 10px 14px 12px; }
-

🦐 prawn · explore · {{ .Repo }}

+

🦐 prawn · · {{ .Repo }}

{{ .Count }} PRs open at some point since {{ .Since }}