RepoClerk is a caching and coordination layer for the MorphoDepot Slicer extension. A public dashboard showing the overall health and activity of the MorphoDepot ecosystem is available at MorphoDepot.github.io/RepoClerk.
The MorphoDepot Dashboard is a static web page served via GitHub Pages, automatically regenerated after every journal update. It provides a public view of the MorphoDepot ecosystem without requiring a GitHub account or the Slicer extension:
- Screenshot gallery — rotating carousel of specimen images drawn from all repositories
- Summary bar — total repository, open issue, and open PR counts at a glance
- Activity chart — count of repositories with pushes in the last day, week, month, and year
- Taxonomy chart — distribution of specimens by taxonomic level (kingdom through genus), with a dropdown to switch levels
- Repository table — one row per repo showing last push date, open issues, open PRs, and screenshot count; clicking a row expands a detail panel with full accession metadata and screenshot thumbnails
The dashboard data is stored in docs/dashboard-data.json and loaded client-side, so the HTML shell is fully static. Charts are rendered using Apache ECharts.
MorphoDepot is a 3D Slicer extension for collaborative specimen segmentation. It coordinates work across many GitHub repositories (each tagged with the morphodepot topic and structured as forks). The extension has Search, Annotate, and Review tabs that previously queried the GitHub GraphQL API directly on every refresh — causing latency and rate-limit issues with multiple concurrent users.
RepoClerk solves this by acting as a pre-computed journal of the state of all MorphoDepot repositories. Each MorphoDepot repo gets its own JSON file in journals/. MorphoDepot clients maintain a local git clone of RepoClerk and read from it instead of querying GitHub directly.
RepoClerk/
README.md
design/
near-realtime-ingestion.md # webhook-based near-real-time ingestion (design note)
journals/
{owner}^{repo}.json # one file per MorphoDepot repository
docs/
index.html # dashboard page (served via GitHub Pages)
dashboard.js # ECharts setup and repo table logic
dashboard-data.json # aggregated data, regenerated on every journal update
scripts/
drain.py # drain-loop logic used by update-repo.yml
sync-all.py # discovery and queuing logic used by sync-all.yml
generate-dashboard.py # reads journals/, writes docs/dashboard-data.json
.github/
workflows/
update-repo.yml # drain loop: triggered by repository_dispatch (webhook receiver / client) or update-request issues; regenerates dashboard
sync-all.yml # cron: queues stale/missing repos, deletes removed ones, regenerates dashboard
The ^ separator in filenames is used (rather than /) because / is not valid in filenames. The owner and repo fields correspond to a GitHub repository at github.com/{owner}/{repo}.
Each file at journals/{owner}^{repo}.json has this structure:
{
"$schema": "...",
"schemaVersion": 1,
"nameWithOwner": "owner/repo",
"journalUpdatedAt": "2026-03-23T10:00:00Z",
"pushedAt": "2026-03-23T09:55:00Z",
"openIssues": [
{
"number": 5,
"title": "Segment specimen X",
"url": "https://github.com/owner/repo/issues/5",
"author": "github-login",
"assignees": ["login1", "login2"]
}
],
"openPRs": [
{
"number": 3,
"title": "Segmentation of specimen X",
"isDraft": false,
"url": "https://github.com/owner/repo/pull/3",
"author": "github-login",
"closingIssue": {
"number": 5,
"title": "Segment specimen X",
"repoOwner": "owner"
}
}
],
"accession": {
"comment": "Contents of MorphoDepotAccession.json from the repo's main branch, merged in here verbatim"
},
"screenshotCount": 3,
"screenshotCaptions": [
{ "comment": "Contents of screenshots/captions.json from the repo's main branch" }
],
"volumeSize": 1234567890
}journalUpdatedAt: when this journal file was last written by a RepoClerk actionpushedAt: the GitHubpushedAttimestamp of the repo at the time the journal was written — used by clients to detect stalenessopenIssues: all currently open issues; clients filter client-side by assignee using their owngh authidentityopenPRs.closingIssue.repoOwner: the owner login of the repo the closing issue belongs to — used by the Review tab to determine if the current user is the curator for that PRaccession: verbatim contents ofMorphoDepotAccession.jsonfrom main branch — specimen metadata used by the Search tabscreenshotCaptions: verbatim contents ofscreenshots/captions.jsonif present; omitted (or[]) if not presentvolumeSize: size in bytes of the source volume file (from HTTPContent-Length), ornullif thesource_volumefile is absent or the URL could not be resolved/fetched
Clone the RepoClerk repo into the user's local MorphoDepot directory using a shallow clone to avoid downloading history:
git clone --depth 1 https://github.com/{RepoClerkOrg}/RepoClerkInstead of calling the GitHub GraphQL API, the client:
- Checks the size of the local RepoClerk clone and re-clones shallowly if it exceeds a threshold (see below)
- Runs
git pullon the local RepoClerk clone (fast, no API rate limits) - Reads the relevant
journals/*.jsonfiles from disk - Filters client-side (e.g., issues assigned to
whoami(), PRs authored by current user)
A shallow clone (--depth 1) starts with only one commit, but git pull accumulates new commits over time as the repo is updated. To prevent unbounded growth, MorphoDepot should check the clone size before pulling and re-clone if it exceeds a threshold:
import shutil
import subprocess
from pathlib import Path
REPOCLERK_SIZE_LIMIT_MB = 500
def refreshRepoClerk(clonePath, repoUrl):
clone = Path(clonePath)
if clone.exists():
size_mb = sum(f.stat().st_size for f in clone.rglob('*') if f.is_file()) / 1e6
if size_mb > REPOCLERK_SIZE_LIMIT_MB:
shutil.rmtree(clone) # history has grown too large — start fresh
if not clone.exists():
subprocess.run(['git', 'clone', '--depth', '1', repoUrl, str(clone)], check=True)
else:
subprocess.run(['git', 'pull'], cwd=clone, check=True)In practice the clone is unlikely to exceed the threshold for years of normal use, but the check is cheap and makes the behaviour predictable at scale.
When MorphoDepot performs a state-changing operation (assign issue, push commits, request review, approve/merge PR, create release), notify RepoClerk:
gh api repos/{RepoClerkOrg}/RepoClerk/dispatches \
--method POST \
--field event_type=update-repo \
--field client_payload[owner]={owner} \
--field client_payload[repo]={repo}If the RepoClerk clone is not present or git pull fails, MorphoDepot falls back to the existing direct GitHub API calls so the extension remains functional without RepoClerk.
A journal is (re)written by drain.py (via the update-repo.yml workflow). That workflow is
triggered three ways, in order of importance:
- GitHub webhook — near-real-time, primary. An org-level webhook on the
MorphoDepotorg POSTs every repo/issue/PR/release/push event to a receiver in the intake app (POST /github/webhook, served atjoin.morphodepot.org). The receiver HMAC-verifies the delivery, keeps only repos carrying themorphodepottopic (so infra repos are never journaled), coalesces bursts per repo over a short window, and fires arepository_dispatch(update-repo) at RepoClerk. Measured latency: an action on a repo → fresh journal in ~20 s. This is what makes live workshops viable (an instructor creates a repo and attendees immediately work on it). Full design + rationale:design/near-realtime-ingestion.md. - Client
repository_dispatch— best-effort. The Slicer extension fires the same dispatch after state-changing operations (see Triggering a Journal Update After State Changes above). It predates the webhook and overlaps harmlessly (coalesced); it may be retired once webhooks are proven. sync-allcron — hourly backstop. Discovers missing/stale (pushedAtmismatch) and schema-upgrade journals, queues them, and deletes journals for repos no longer live. Catches any missed webhook delivery and handles initial population.
All three funnel into the same drain.py, and all writers share the repoclerk-writer concurrency
group so journal/dashboard updates serialize safely.
The receiver lives in the intake app; the only RepoClerk-side requirement is that update-repo.yml
accepts repository_dispatch (it does). To (re)provision the webhook:
- Secret:
GITHUB_WEBHOOK_SECRETin the intake app's service env (/etc/morphodepot-intake/env); the receiver returns 503 if it is unset and 401 on a bad/missing signature. - Org webhook (Org → Settings → Webhooks): Payload URL
https://join.morphodepot.org/github/webhook, content typeapplication/json, the shared secret, and the events Issues, Pull requests, Releases, Pushes, Repositories (or "Send me everything"). Creating it via the API needs theadmin:org_hookscope; the org UI does not.
- Per-repo files (not a single combined file) to avoid write conflicts when multiple repos are updated concurrently
^separator in filenames for filesystem compatibility- Accession data embedded in the journal so the Search tab requires only a
git pull, not a separate HTTP fetch per repo - Client-side filtering by assignee/author — the journal stores all open issues/PRs; the client filters using its own identity
pushedAtpreserved from GitHub so the cron job can detect staleness without fetching full issue/PR data for every repo on every run- Webhook-driven, cron as safety net — the primary update path is the org webhook → on-demand
repository_dispatch(push, not poll; ~20 s to a fresh journal); the hourly cron catches any missed delivery and handles initial population. Seedesign/near-realtime-ingestion.mdand How Journals Get Updated above viewerPermissionis NOT in the journal — this is viewer-specific and cannot be pre-computed;administratedRepoList()in MorphoDepot still needs a directghAPI call
docs/dashboard-data.json and docs/volume-checksums.json are generated artifacts. They are
written only by the Sync/ingestion workflows (sync-all.yml, update-repo.yml) running
scripts/generate-dashboard.py on main, and they stay committed there because GitHub Pages serves
docs/ straight from main.
Do not commit changes to these two files in a pull request. They are rewritten on main on
essentially every journal update, so any PR that also edits them merge-conflicts with the Sync bot. A
conflicting PR cannot build its refs/pull/<n>/merge ref, which silently stops GitHub from running
the Claude Code Review (and every other pull_request workflow) on it — the review simply never
appears. The Guard generated files workflow enforces this by failing any PR that touches them.
When you change dashboard logic (generate-dashboard.py, dashboard.js, index.html), run
python3 scripts/generate-dashboard.py locally only to preview, then drop the generated files before
pushing:
git checkout origin/main -- docs/dashboard-data.json docs/volume-checksums.jsonAfter the PR merges, the next Sync run (hourly cron, or ~20 s via the webhook) regenerates them on
main with your new logic. Run the unit tests with python3 scripts/test_duplicates.py (no deps, no
network).