README
¶
forge-dashboard
A dashboard of open pull requests, issues, and CI status across every repository you have write access to on GitHub and a Forgejo instance — one page instead of two forge UIs. It has passwordless login, per-user tokens, sharing and an admin role.
| Page | Light | Dark |
|---|---|---|
| Branches. The CI status of every default branch, latest commit first. | ![]() |
![]() |
| Pull requests. Open pull requests across both forges, with CI status, filters, and sorting. | ![]() |
![]() |
| Pull request details. A pull request's description, comments, and thumbs reactions. | ![]() |
![]() |
| Phone sheet. The same details on a 360px screen. | ![]() |
![]() |
| Pipeline checks. A pull request's checks with state, time and failure excerpt, from its CI pill. | ![]() |
![]() |
| Issues. Open issues across both forges. | ![]() |
![]() |
| Releases. Release pull requests waiting to ship. | ![]() |
![]() |
| Release details. The release notes a release pull request publishes. | ![]() |
![]() |
| Bots. Dependabot and Renovate pull requests. | ![]() |
![]() |
| Bot details. A bot pull request, its generated text collapsed. | ![]() |
![]() |
| Insights. Charts of CI state and the busiest repos. | ![]() |
![]() |
| Webhooks. Which tracked repos have a confirmed webhook, and a way to add one. | ![]() |
![]() |
| Settings. Five groups, one at a time: display, sharing, connections, behaviour, integrations and security. | ![]() |
![]() |
| Admin. Users and invites, for the first registered user. | ![]() |
![]() |
| Login. Passkey sign-in. | ![]() |
![]() |
Fixture data, not a real account's actual repositories. The release
workflow regenerates every screenshot on each release (see
scripts/capture-screenshots.ts), so they're never more than one release
stale.
Requirements
- Go 1.27 or newer, to build.
- bun, to build from source —
bun run build:webhas to run beforego buildpicks up its output (see Architecture). Not needed to run a pre-built release binary or the Docker image, both of which already have that step baked in. - A passkey-capable browser to sign in at all — any current Chrome, Safari, Firefox or Edge; a phone counts too. Nothing else to install.
- Somewhere writable for the database (
DB_PATH, default/data/forge-dashboard.db) that survives a restart — registered passkeys, sessions, and every user's encrypted forge credentials live there. In Docker that means a mounted volume at/data; see Docker below. ENCRYPTION_KEY, a base64-encoded 32-byte key the process refuses to start without — see Configuration below for how to generate one. It's what encrypts every user's forge tokens at rest; there's no sensible default for a secret like this one.- Each signed-in user configures their own forge credentials from the Settings page after registering — see Credentials for exactly which permissions to grant. Nothing forge-related needs setting before the process starts.
Documentation
- Pull request actions: what a row offers, and why a blocked action is blocked.
- Issues: the issues page, the settings that hide issues, and how filters are remembered.
- Main branches: the default-branch CI page and
GET /api/main-branches. - Live updates: how the board refreshes, what waits behind "N updates available", and the first load after a restart.
- API: the HTTP endpoints, the OpenAPI contract and the MCP endpoint.
- Accounts: passkey login, API tokens, the admin area, sharing a dashboard and the GitHub and Forgejo credentials.
- Webhooks: registering the forge webhook.
- Architecture: the design choices behind the app, from passkey login to the MCP endpoint and the single binary.
Error pages
The dashboard has its own error pages, built with the rest of the web app as
404.html ("Page not found") and 500.html ("Something went wrong on our
side"). The Go server sends the first for an address nothing matches and the
second for a crash, each with its real status code. Asking for either file by
name gets the 404, never a 200. Each page says what happened in plain words and
gives a way back: a primary link to the pull requests and a short list of the
other pages. The 500 page also has a Try again link to the same address. They
show nothing about the server beyond the release version the footer shows
everywhere (no stack trace, path or host), and follow the light or dark theme.
They run no script, so the app can't fetch that version: the Go server fills it
into the #footer-version slot of the footer when it starts, and a test fails
if the web build drops the slot. The same no-script rule means the app's router
can't swap one for the page whose address it was sent at. Inside the app,
+error.svelte renders the same page for an error the router meets, and
handleError keeps the message generic. src/error.html is the static fallback
for an error the app can't render itself.
Pull request details
Clicking a pull request row, anywhere on it, opens a read-only pop-up. The row's title is the button that does it, stretched over the row; the CI pill, the labels and the action buttons stay clickable on top of it. The pop-up has the pull request's description, its comments and the thumbs up and thumbs down reactions on each, and an Open on GitHub (or Forgejo) link. On the Releases page the pop-up shows the release notes in place of the description. A Dependabot or Renovate pull request opens the same pop-up, with its long generated description folded away until you open it. There are no actions in it yet: Merge, Update branch and the rest stay on the row.
The pop-up loads when it opens, from GET /api/pull-requests/details, and
never as part of the dashboard snapshot, so a forge's budget isn't spent on
rows nobody opens. It shows the first 50 comments and says so when there are
more. The header draws the row's three tiers as they were when it opened: the CI
slot first (Running, Failed or Passed, the row's glyphs and words) with a
Conflicting or Blocked pill beside it when the row has one, then the title,
then the attributes as quiet outlined chips (auto-merge, draft, bot, out of
date, labels, and the version on the Releases page). Each comment carries the
author's avatar (the forge's own HTTPS image URL, or an initial if it isn't one
or can't load), a relative time and an
"edited" mark. A deleted author reads "ghost". Hovering or focusing a reaction
lists who gave it, up to ten names and then "and N more"; Escape hides that
list first and a second press closes the pop-up. Reactions are display only.
When the details can't load, it says so in plain words with Try again.
The text is Markdown and the server doesn't sanitise it, so the page renders
it with markdown-it with raw HTML
off: tags show as text, images aren't loaded, only absolute http, https
and mailto links are links (anything else stays plain text), and they open
in a new tab with rel="noopener noreferrer". Task list items (- [x] and
- [ ]) show as read-only checkboxes; ticking one changes nothing on the
forge. The library loads the first time a pop-up opens.
Releases
Release pull requests have their own page, /releases.html, reached from the
Releases item in the navigation. It is the pull request board over the
pull requests the server marks kind: release (release-please's, by its
autorelease: label), with the same filters, rows and actions as the main
page. The main pull request page lists none of them, and doesn't count them. Each
row shows the version being released next to the title. Merge is offered when
the release can merge, or greyed out with the reason when it can't. A release
that is behind its base branch gets Update branch and a line saying
release-please won't update it until a releasable change lands. With no release
pull requests open the page says nothing is waiting to ship.
The quick filter pills above the list include Failing CI, Running CI and Passing CI, each offered only when it would show something. Next to them an Auto-merge control has Any, On and Off. On shows only pull requests that have auto-merge armed, Off only those without. It combines with a pill, so Failing CI with Auto-merge On finds armed pull requests whose checks have since failed.
Bots
Dependabot and Renovate pull requests have their own page, /bots.html,
reached from the Bots item in the navigation. Like Releases, it is the pull
request board over the pull requests the server marks kind: dependency, with
the same filters, rows and actions as the main page. The main pull request page
lists none of them, so it has no quick filter for bots. A filter saved before
that change is ignored. With no bot pull requests open the page says there is
nothing to review.
Every item in the navigation shows how many items its page lists, and how many
of them are failing. It is one pill: a neutral total, then the outcomes,
failing (red) and passing (green), then a thin divider and the state, running
(amber, a spinner, or a still clock under reduced motion). Each count has its
own glyph so colour is never the only signal. Branches counts default
branches. Pull requests, Releases and Bots count their pull requests (without
the ones that belong to another of the three). All four show those three
counts, in that order. The divider appears only when a running count follows an
outcome, and not on a phone. A pull request with no checks is in the total
only, so its numbers needn't add up. Branches adds a fifth, neutral count with
a dashed circle for the branches with no pipeline, last, so its numbers do add
up. A zero is never drawn, and the link's name says the same in words, in the
same order: "Pull requests, 15 total, 2 failing, 10 passing, 3 running", and
"Branches, 7 total, 2 failing, 3 passing, 1 running, 1 without a pipeline". On
a phone the total sits on its own line above the counts, and with three-digit
numbers each count takes a line, so the busy tabs grow to about 110px. Issues
counts open issues, without Renovate's Dependency Dashboard or what your
settings hide, and has no failed count. The server works the numbers out from
the same lists the pages show (navCounts on /api/dashboard), so a page
agrees with its icon, and a hidden pull request is in neither. The dashboard
pages set them from each snapshot. The other signed-in pages (Branches,
Insights, Settings and the rest) load them and ask again every 30 seconds
while the tab is visible, so a refresh or a webhook changes them without a
reload.
A zero is never drawn: a page with nothing in it has no total, and nothing failing means no red pill. A page with nothing to show leaves the navigation: the Pull requests, Releases, Bots and Issues links are not in the header or the phone's tab bar while their total is 0. Branches always shows. A count that isn't known yet is not 0, so the links stay until the first snapshot says otherwise, and the page you are on keeps its own link until you leave it. The page still opens when typed into the address bar, and says it is empty.
The pages start with their list, with no row of tiles repeating those numbers. One line near the list's heading says how many rows show: "12 open" with no filter, "Showing 1 of 12" when a filter narrows it. The Failing CI pill filters to failing pull requests, and All clears it. How many repositories are tracked is a quiet line under the Insights heading. A page with nothing open says so, even when its nav item is hidden.
On a phone the numbers sit above each tab's icon. An item's accessible name says both in words: "Branches, 12 total, 3 failing, 8 passing, 1 running" on the header icon, "12 Branches, 3 failing, 8 passing, 1 running" on the tab, which shows its numbers before its word.
The menu runs Branches, Pull requests, Releases, Bots, Issues, then the rest.
Filters on a phone
Below 760px wide the filter bar folds down so the first screen shows pull requests. Closed, it is one search row (the title filter and a Filters button) and one row of quick filters that scrolls sideways. The button's count, as in "Filters (2)", is how many filters are on: forge, repo, author, label, created, updated, CI status, and Show drafts. Each active filter also shows as a chip you can tap to remove without opening anything.
Filters opens a bottom sheet with the rest of the controls: forge, Show drafts, sort, group, repo, author, label, created and updated. Filters apply as you change them, and the sheet's Show N results button, which closes it, says what you would see, so a refresh can't change something you are editing. Clear all resets everything. Escape, the close button, or a tap outside closes the sheet and returns focus to the Filters button. On wider screens nothing changes except that the title filter now sits first in the bar.
Hiding one pull request
A pull request you can't or won't act on yet can be hidden. More actions on its row has Hide this pull request, and Hide for 7 days under it. It leaves the list, the quick-filter counts and Insights at once, with no confirm step, since it is reversible. Nothing is sent to the forge.
A status message says Hid PR #123 ‘title’. with an Undo button. It stays
until the next message, and the same restore is in the list below, so it isn't
lost to a timeout. Once anything is hidden, a Hidden (N) button by the list
header opens that list: repo, number, title, "Hidden until you restore it" or
"Back in 6 days", and a Restore button on each. With none hidden, neither
shows.
A hide for days ends by itself. A hide is forgotten once the pull request is no longer open, while its forge answers. A forge that is rate limited or unreachable draws no controls on its rows, so Hide is absent there too until it answers again.
Webhooks
Optional. Without one, the dashboard still refreshes on its own schedule and the browser tab polls it on the server's schedule (30 seconds today); a webhook just means a new pull request, a closed issue, a new comment, or a CI status change on a tracked repo shows up within seconds instead of at the next poll. Set up from the Settings page, one forge at a time — full copy-pasteable steps, exactly which events each part of the dashboard needs (CI status specifically needs its own, easy-to-miss event), and how delivery verification works: docs/webhooks.md.
A delivery refreshes just the repository it names, not the account's whole tracked-repo set — a much smaller GraphQL query on GitHub than a full refresh runs. A payload with no repository (the initial "ping" delivery a new webhook sends, say) falls back to a full refresh instead.
Each refresh also asks GitHub whether every repo's webhook is pointed at the dashboard. A GitHub App without the repository webhooks permission gets "Resource not accessible by integration" for all of them, so that answer is remembered for an hour and the checks are skipped, with one warning in the log. A token that can't administer one repo is skipped for that repo alone. Grant the permission and the next check after the hour sees it.
A webhook the dashboard created earlier is brought up to date without a click. After the first refresh that finds a repo's webhook, the dashboard lists that repo's hooks once and, only if the hook lacks an event the dashboard now subscribes to, edits it in place. It never creates a hook there. Cost: one extra hooks listing per covered repo per process start (plus one edit for a hook that is out of date), at most four at a time, each after a short random pause. A forge that is unreachable or out of REST budget is skipped and tried again on a later refresh, and never fails one. A repo whose hooks the token can't administer is tried once and left until the next restart.
Configuration
Everything the process itself needs is environment variables — no config file, and nothing forge-related, since that's per-user now (see Credentials):
| Variable | Required | Default | Meaning |
|---|---|---|---|
ADDR |
no | :8080 |
Listen address. |
DB_PATH |
no | /data/forge-dashboard.db |
Where passkeys, sessions, and every user's encrypted forge credentials live. |
RP_ID |
no | localhost |
The WebAuthn relying party ID — set to your real domain in any real deployment. |
RP_ORIGIN |
no | http://localhost:8080 |
The WebAuthn relying party origin — set to the real https:// origin users reach this at. |
ENCRYPTION_KEY |
yes | — | Base64-encoded 32-byte key for encrypting forge tokens at rest. Generate with openssl rand -base64 32. |
GITHUB_APP_ID |
no | — | The numeric ID of a GitHub App this deployment lets users connect instead of pasting a personal access token — see GitHub App support below. Unset means the option doesn't exist at all; no other GitHub behaviour changes. |
GITHUB_APP_PRIVATE_KEY_BASE64 |
yes, if GITHUB_APP_ID is set |
— | Base64-encoded PEM private key for that App, downloaded from its GitHub settings page. Validated at startup — a malformed key fails loudly there, the same way a bad ENCRYPTION_KEY does. |
REFRESH_INTERVAL |
no | 20m |
How often the backend re-polls a signed-in user's forges, as a Go duration (2m30s, 10m). A safety net now that every tracked repo gets a webhook — lower it if you're not relying on those. |
SHUTDOWN_DRAIN |
no | 5s |
How long /readyz answers 503 after SIGTERM before the server stops accepting connections, so a router polling it takes the container out of rotation first. 0s skips the wait. Keep the container's stop grace period longer than this plus 5 seconds. |
CI_POLL_INTERVAL |
no | 1m |
How often the backend re-checks just the open pull requests whose CI is still pending, or that have GitHub's own auto-merge armed (GitHub merges those by itself, and without a webhook the board would otherwise learn of it at the next full refresh; one is polled for as long as it stays armed, a query a minute per repo, and not at all while the forge's budget is spent), as a Go duration. A fallback for real Forgejo instances that silently drop the webhook event CI status rides on (see docs/webhooks.md) — set to 0s to turn it off if you don't need it. |
LOG_LEVEL |
no | info |
debug, info, warn, or error. debug logs every outbound request to GitHub/Forgejo (method, URL) — turn it on to diagnose a request-volume spike from the process's own logs instead of reasoning about the code from the outside. |
Repository discovery is automatic per user. With a token, the dashboard
lists every repository it has push access to — on GitHub, one GraphQL
query returns that whole list along with every repo's open pull requests,
open issues, and CI status in a single request (drawing from GraphQL's
own separate rate-limit pool, not the REST budget everything else on the
account shares); on Forgejo, GET /user/repos. A GitHub App installation
discovers its repos differently: GraphQL's viewer field means "the
authenticated user," which an installation access token has none of, so
this path lists the installation's own granted repos via REST
GET /installation/repositories instead, then still fetches each one's
open pull requests/issues/CI status via GraphQL (repository(owner:, name:), which has no such restriction). With only a username, it lists
that account's public repositories (GET /users/<username>/repos on
both) with no credential in play at all — GitHub's GraphQL API allows no
anonymous access, so this mode stays REST-based there too. Either way
there's no per-repo allowlist to maintain — archived and forked
repositories are excluded automatically, on both forges, in every mode.
On Forgejo, a mirrored repository is excluded too — a pull mirror has no
pull requests or issues of its own to poll, and its canonical home is
whichever forge it's mirrored from.
Each pull request also carries its review state (review in /api/dashboard:
decision, approvals, requested reviewers). The Ready to Merge and Needs
review quick filters read the server's own answers, readyToMerge and
needsReview on each pull request, so the page holds no definition of either.
On GitHub that rides on fields of the same GraphQL query, so it costs no extra
requests. On Forgejo the requested reviewers come with the pull request list,
but approvals take one GET /repos/{owner}/{repo}/pulls/{index}/reviews call
per open, non-draft pull request. Those results are cached until the pull
request's updated_at changes, so after the first refresh a quiet dashboard
spends none of them. A pull request that closed or merged drops out of that
cache on the next refresh of its repo. A pull request whose review state
couldn't be read has no review and never matches the filter. Needs review
means a review is outstanding: the forge requires one, or a reviewer was asked
and hasn't answered. A pull request nobody was asked to review doesn't count.
The Review requested from me quick filter keeps the pull requests that ask
you in particular: reviewRequestedFromMe on each pull request, which the
server matches, ignoring case, against the username saved for that forge in
Settings. It needs that username: with none saved for a forge, none of that
forge's pull requests match. It adds no requests, since the requested
reviewers' names come with the data already fetched.
Each pull request also counts its review comments, reviewComments in
/api/dashboard, and the review threads still open, unresolvedThreads.
Ordinary conversation comments aren't counted. GitHub reads both from the
review threads in the pull request query, so it costs no request. It reads the
first 30 threads, so a pull request with more reports fewer than it has. The
unauthenticated fallback has no thread data and reports 0 comments and a null
unresolvedThreads.
Forgejo's count is the sum of each review's comments from the reviews call that
already feeds review, so a draft reports 0. Forgejo gives no resolved state
without a request per review, so its unresolvedThreads is null: unknown, not
zero.
Each row shows that as a pill beside the other status pills: the number
of review comments, and 7 · 2 open in amber, with a dot, when threads are
still unresolved. A pull request with no review comments shows nothing, and on
Forgejo, where unresolvedThreads is null, the pill shows the count with no
open state. The pill is a link to the pull request's files-and-review view on
the forge, opened in a new tab. Its accessible name is the full sentence, such
as "7 review comments, 2 unresolved threads".
awaitingWorkflowApproval is true when GitHub is holding a workflow run on the
head commit until someone approves it (a check run counted as ACTION_REQUIRED
in the checks summary). It adds no request, and it is false when that summary
belongs to an earlier commit. It can be true while ci is pending or failing
for another reason. A third-party check that concludes action_required counts
too. Forgejo and the unauthenticated fallback always report false.
The Conflicts quick filter keeps the pull requests with a merge conflict, so
they can be fixed in one pass. It reads the mergeStatus of each pull request,
conflicting, the same field behind the row's own Conflicting pill, so the page
holds no rule of its own. A draft that conflicts is listed too.
Draft pull requests are left out of /api/dashboard by default, since nothing
can be merged or updated on one. The response says how many it left out in
hiddenDrafts. Pass includeDrafts=true to get them back, on the dashboard,
its stream and the refresh endpoint alike. The auto-update-branch feature
never touches a draft either.
Stacked pull requests are marked in /api/dashboard. A pull request is
stacked on another when its base branch is that one's head branch, in the same
repository and neither comes from a fork. Each pull request carries stack
(its position and the stack's size, or null), stackedOn (the parent) and
stackChildren. Merge on a stacked child is blocked with the code stacked,
since merging it would land it in the parent's branch, and auto-merge isn't
offered either (GitHub refuses it there, because the parent's branch has no
protection rule). The snapshot holds open pull requests only, so a parent that
already merged without the child having its base changed isn't flagged.
When a refresh against a forge fails (a rate limit, a timeout, an error from
its API), the dashboard keeps showing that forge's last good pull requests,
issues and repos instead of none, and the forge's health carries staleSince,
the time that data was fetched, next to reachable: false. The next good
refresh replaces it. A forge that has never fetched successfully has nothing
to show.
GitHub App support
GitHub's primary rate limit (5,000 requests/hour, both REST and GraphQL)
is scoped to the account, not the token — a personal access token's
polling shares that exact budget with anything else acting as that same
account: your own use of gh, other tools, CI. A GitHub App
installation token draws from that installation's own independent
budget instead, so this is worth setting up once your account's own
usage is bumping into the shared limit.
To offer this to your users:
- Register a GitHub App — any name, no webhook needed. Grant it Contents, Issues, and Pull requests (read and write), which is what "Add a webhook" and repo discovery need; Metadata (read) comes along automatically.
- Generate a private key on the App's own settings page, then
base64-encode the downloaded
.pem:base64 -w0 your-key.pem. - Set
GITHUB_APP_ID(the numeric ID the App's settings page shows) andGITHUB_APP_PRIVATE_KEY_BASE64(the value from step 2) on the deployment and restart it — a malformed key fails at startup, not on a user's first save.
From there it's per-user, from Settings: each person installs the App
on their own GitHub account (its own settings page → Install App)
and pastes the installation ID GitHub shows them into the new field
next to their token. An installation always wins over a saved token
when both are set for polling and every other action — the token stays
saved rather than being cleared, and is used for one thing only:
sending @dependabot comments, which Dependabot refuses from a GitHub
App (see the Dependabot buttons in
Pull request actions). Leave GITHUB_APP_ID
unset and the field simply doesn't appear; nothing else about GitHub credentials
changes.
Running it
go build -o forge-dashboard ./cmd/forge-dashboard
mkdir -p data
DB_PATH=./data/forge-dashboard.db \
ENCRYPTION_KEY="$(openssl rand -base64 32)" \
./forge-dashboard
Then open http://localhost:8080, register a passkey — RP_ID/
RP_ORIGIN default to localhost/http://localhost:8080, which matches
this local run with nothing extra to set — and add your GitHub/Forgejo
credentials from the Settings page.
Docker
The /data directory the image ships is where the database goes — mount
a volume there, or every passkey and every saved credential is gone the
moment the container is recreated:
docker build -t forge-dashboard .
docker run --rm -p 8080:8080 \
--cap-drop=ALL --security-opt=no-new-privileges --read-only \
--memory=64m --cpus=0.5 --pids-limit=64 \
-v forge-dashboard-data:/data \
-e RP_ID=localhost -e RP_ORIGIN=http://localhost:8080 \
-e ENCRYPTION_KEY="$(openssl rand -base64 32)" \
forge-dashboard
The image carries a HEALTHCHECK that runs /forge-dashboard healthcheck,
which asks GET /readyz. It means "can serve": the database answers and its
tables exist. Once someone has signed in and a first dashboard refresh has
finished, it also waits on that, and a forge that failed the refresh still
counts. A freshly started container has no refresh to wait for, since the
app only fetches from the forges after a sign-in. The database ping gets 2
seconds and its result is reused for about 3, so a stuck database answers
503 instead of hanging the probe. On SIGTERM it answers 503 at once, then
the server waits SHUTDOWN_DRAIN before it stops. A 200 also reports the
goroutines and OS threads the process holds. The container's process limit
counts threads, so watch threads to see growth before the healthcheck can no
longer start. A 50-repo refresh from six tabs peaks at about 20 threads, so
--pids-limit=64 leaves room. Read it with:
docker inspect --format '{{.State.Health.Status}}' <container>
starting is normal for up to 30 seconds after boot, then healthy. A
healthy container stays healthy when a forge goes unreachable. That shows
on the dashboard, not here. GET /healthz stays the cheap liveness answer
(the process is up). In Compose, depends_on with
condition: service_healthy waits on the same state.
Generate ENCRYPTION_KEY once and keep it — losing it makes every saved
credential unrecoverable, and every user has to re-enter theirs from the
Settings page.
The published image is ghcr.io/alrayyes/forge-dashboard, built and
pushed on every release by .goreleaser.yml's dockers: block — tagged
by version and :latest, linux/amd64 and linux/arm64.
Deployment
Deployed on a homelab, reachable only over Tailscale. Passkey login is
now the actual access control — the tailnet is defence in depth on top
of it, not the only thing standing between a visitor and the dashboard
the way v1 originally had it. The image is pulled into that homelab's
existing compose setup as its own service directory, routed through the
Traefik instance already running there; there's no per-service Tailscale
sidecar, since every service on that host reaches the tailnet the same
way. That deployment config — including the /data volume this now
needs, and RP_ID/RP_ORIGIN set to the real deployed domain rather
than the localhost defaults — lives in that private deployment repo,
not here.
The changelog page
The version in the footer links to /changelog.html, which works without
signing in. It lists the releases in CHANGELOG.md, newest first, 20 at a time
with a button for the older ones, and makes no request to GitHub. The web build
turns CHANGELOG.md into /changelog.json (scripts/changelog-json.ts, a
build output that isn't committed) and the page reads that one same-origin file.
It therefore lists exactly the releases the running version was built from, so
the footer version and the newest entry agree. The footer reads the running
binary, so it lags the published release until the deployment updates. The page
used to live at /releases.html. That address now serves the Releases page
below, with no redirect, so an old bookmark or a browser that cached the earlier
301 needs /changelog.html typed once.
Reports
Every push to main that passes CI publishes the generated reports at
https://apis.ryankes.eu/forge-dashboard/reports/:
- Test results, the
JUnit XML of the Go tests (
unit.xml) and the Playwright journeys (e2e.xml). - Coverage, an
HTML view plus
coverage.xml(Cobertura) and the native Gocoverage.out. - Lighthouse,
one report (and its JSON) for the login page and for each signed-in page,
under fixed names such as
login.report.htmlandhome.report.jsonthat don't change between deploys. Only each page's representative run is kept.lighthouse/report.htmlandreport.jsonstay as the first page's run.
Contributing
See CONTRIBUTING.md for the toolchain, the hooks, and how a change gets reviewed and released. ARCHITECTURE.md is the one-page map of how the pieces fit, and the decision records record why the big choices went the way they did.
Licence
AGPL-3.0. Chosen over the plain GPL-3.0 this project started under because AGPL's network-use clause also covers running a modified copy as a hosted service, which GPL alone doesn't.
Fonts
The app serves its own fonts, so a page never contacts another host to render
text. IBM Plex Sans (400, 500, 600, 700) and IBM Plex Mono (400, 500, 600) come
from the pinned @fontsource/ibm-plex-sans and @fontsource/ibm-plex-mono
packages, bundled by the web build in the Latin and Latin Extended subsets, with
font-display: swap. IBM Plex is released under
OFL-1.1, the SIL open font licence, which allows
bundling and redistribution with this app.
Directories
¶
| Path | Synopsis |
|---|---|
|
cmd
|
|
|
forge-dashboard
command
Command forge-dashboard is the composition root: it wires the SQLite database, the passkey auth service, and the per-user dashboard.Manager, then serves the API and static frontend.
|
Command forge-dashboard is the composition root: it wires the SQLite database, the passkey auth service, and the per-user dashboard.Manager, then serves the API and static frontend. |
|
internal
|
|
|
api
Package api is the composition root's one dependency: an http.Handler satisfying api/openapi.yaml, serving the aggregated dashboard snapshot and the static frontend.
|
Package api is the composition root's one dependency: an http.Handler satisfying api/openapi.yaml, serving the aggregated dashboard snapshot and the static frontend. |
|
auth
Package auth is passkey (WebAuthn) registration and login: no password ever stored or transmitted, a session cookie once a ceremony succeeds, and the store both ceremonies and sessions persist to (SQLite).
|
Package auth is passkey (WebAuthn) registration and login: no password ever stored or transmitted, a session cookie once a ceremony succeeds, and the store both ceremonies and sessions persist to (SQLite). |
|
dashboard
Package dashboard holds the domain model both forge clients produce and the aggregator that merges them into one snapshot.
|
Package dashboard holds the domain model both forge clients produce and the aggregator that merges them into one snapshot. |
|
forgejo
Package forgejo is a client for the pieces of the Forgejo API (Gitea-compatible) forge-dashboard needs — the same shape as internal/github, against a different API under a self-hosted host.
|
Package forgejo is a client for the pieces of the Forgejo API (Gitea-compatible) forge-dashboard needs — the same shape as internal/github, against a different API under a self-hosted host. |
|
github
Package github is a client for the pieces of the GitHub API forge-dashboard needs: which repositories the credential can write to, their open pull requests and issues, and combined CI status per pull request.
|
Package github is a client for the pieces of the GitHub API forge-dashboard needs: which repositories the credential can write to, their open pull requests and issues, and combined CI status per pull request. |
|
requestlog
Package requestlog persists a queryable record of every outbound request forge-dashboard makes to GitHub or Forgejo (#482), so a rate-limit or connectivity incident can be debugged from what actually happened on the wire instead of from stdout logs alone.
|
Package requestlog persists a queryable record of every outbound request forge-dashboard makes to GitHub or Forgejo (#482), so a rate-limit or connectivity incident can be debugged from what actually happened on the wire instead of from stdout logs alone. |
|
settings
Package settings is each user's own GitHub/Forgejo credentials — saved through the Settings page, encrypted at rest, and what dashboard.Manager builds that user's forge sources from.
|
Package settings is each user's own GitHub/Forgejo credentials — saved through the Settings page, encrypted at rest, and what dashboard.Manager builds that user's forge sources from. |
|
sharing
Package sharing tracks which users have granted which other users read-only access to their aggregated dashboard.
|
Package sharing tracks which users have granted which other users read-only access to their aggregated dashboard. |





























