1- # forge — a personal git forge (tier 1: hosting + browsing; tier 2: issues; tier 2.5: nostr agents + xlogin; tier 3a: forks + pull requests)
1+ # forge — a personal git forge (tier 1: hosting + browsing; tier 2: issues; tier 2.5: nostr agents + xlogin; tier 3a: forks + pull requests; polish: labels, search, releases )
22
33The useful slice of Gogs/Gitea as a JSS plugin: push a repo over smart
44HTTP, get a GitHub-style (light theme) web UI for it — repo list, file
@@ -11,7 +11,12 @@ architecture below). Tier 2.5 makes **did:nostr agents first-class**
1111issue bodies for podless agents) and puts the vendored ** xlogin** widget
1212on the issues pages. Tier 3a adds ** forks, compare, and pull requests
1313with real merges** (` merge-tree --write-tree ` + ` commit-tree ` +
14- compare-and-swap ` update-ref ` — see "Forks & pull requests").
14+ compare-and-swap ` update-ref ` — see "Forks & pull requests"). The polish
15+ wave adds ** labels** on issues and PRs (GitHub's default set, colored
16+ chips, ` ?label= ` filters), ** read-time search** (repo-list filter +
17+ in-repo ` git grep ` , bounded, no index), ** releases** (every tag with
18+ streamed tar.gz/zip archives), and hides the internal compare refs from
19+ ` ls-remote ` (closing Finding 15).
1520Zero npm dependencies, zero build step, no
1621framework: every page is server-rendered HTML with inline CSS, all git
1722work is done by the system ` git ` binary, and the wire protocol is
@@ -70,11 +75,14 @@ git -c http.extraHeader="Authorization: Bearer <token>" push forge main
7075| ` .../commits/<ref>?page=N ` | log, 30/page: message, short sha, author, relative time, identicon |
7176| ` .../commit/<sha> ` | full commit with GitHub-style unified diff (collapsible per-file sections, +N/−M counts) |
7277| ` .../branches ` , ` .../tags ` | ref lists |
73- | ` .../issues?state=open\|closed&page=N ` | issue list: GitHub-style filter tabs, green open / purple closed icons, relative times, comment counts |
78+ | ` .../issues?state=open\|closed&label=<name>&page=N ` | issue list: GitHub-style filter tabs, label chips, green open / purple closed icons, relative times, comment counts |
79+ | ` .../search?q=<text> ` | read-time code search over the DEFAULT branch: ` git grep ` hits (path: line + escaped excerpt) plus matching file paths — bounded, no index |
80+ | ` .../releases ` | every tag, newest first: annotated-tag message, commit link, tar.gz + zip download buttons (linked from the Tags tab) |
81+ | ` .../archive/<ref>.tar.gz ` , ` .../archive/<ref>.zip ` | ` git archive ` streamed (attachment, ` --prefix=<repo>-<ref>/ ` ); unresolvable refs are 404 |
7482| ` .../issues/<n> ` | thread: issue body then comments in comment boxes (identicon, author → WebID link, relative time, ` owner ` badge), markdown bodies |
7583| ` .../issues/new ` | new-issue form (vanilla-JS client, see below) |
7684| ` .../compare/<base>...[<owner>:]<ref> ` | compare view: ahead/behind counts, the ahead commit list, the structured diff, an "Open pull request" button |
77- | ` .../pulls?state=open\|merged\|closed&page=N ` | PR list: three-state filter tabs — green open, purple merged, red closed |
85+ | ` .../pulls?state=open\|merged\|closed&label=<name>& page=N ` | PR list: three-state filter tabs — green open, purple merged, red closed — plus label chips |
7886| ` .../pulls/<n> ` | PR conversation: state banner (merged / closed / clean-with-merge-button / conflict list), thread, comment form |
7987| ` .../pulls/<n>/commits ` , ` .../pulls/<n>/files ` | GitHub-style sub-tabs: the ahead commits, the structured diff |
8088| ` .../pulls/new?base=...&head=... ` | new-PR form (from the compare page) |
@@ -147,6 +155,36 @@ Tier 3a (additive):
147155 guard); 501 naming the git version when ` merge-tree --write-tree ` is
148156 missing (needs git ≥ 2.38).
149157
158+ Polish wave (additive):
159+
160+ - Issue/pull list entries and details gain `labels:
161+ [ { name, color }] ` (resolved against the repo's label set; empty array
162+ when unlabeled — pre-polish consumers keep working).
163+ - ` GET api/repos/<o>/<n>/labels ` → ` { labels: [{ name, color }] } ` —
164+ GitHub's default set (` bug ` #d73a4a, ` enhancement ` #a2eeef,
165+ ` documentation ` #0075ca, ` question ` #d876e3, ` wontfix ` #ffffff) until
166+ the first label write materializes it into the repo's index.
167+ - ` POST .../labels ` ` {name, color} ` (OWNER only; color = 6 hex digits,
168+ name 1–50 printable chars) → 201 ` { name, color } ` ; 409 on a
169+ (case-insensitive) duplicate.
170+ - ` PATCH .../labels/<name> ` ` {name?, color?} ` (owner) → ` { name, color } `
171+ — a rename cascades onto every issue and pull carrying it.
172+ - ` DELETE .../labels/<name> ` (owner) → ` { name, deleted: true } ` — the
173+ label falls off every issue and pull (items store names, so a recolor
174+ needs no cascade at all).
175+ - ` PUT .../issues/<n>/labels ` / ` PUT .../pulls/<n>/labels `
176+ ` {labels: [names]} ` (repo owner or the item's author) → `{ number,
177+ labels }` — replaces the item's set; unknown names are 422 (max 20).
178+ - ` GET .../issues?label=<name> ` / ` GET .../pulls?label=<name> ` — filters,
179+ composing with ` state ` .
180+ - ` GET .../search?q=<text> ` →
181+ ` { q, ref, paths: [...], matches: [{ path, line, text }], truncated } `
182+ — read-time grep of the default branch (see "Search"); 422 without ` q ` .
183+ - ` GET .../releases ` →
184+ ` { releases: [{ tag, sha, at, annotated, message, tarball, zipball }] } `
185+ — every tag, newest first; ` message ` is the first line of an annotated
186+ tag's message, ` null ` for lightweight tags; ` sha ` is the peeled commit.
187+
150188Errors are ` { error } ` with 4xx. ` cloneUrl ` is absolute: the origin comes
151189from ` api.serverInfo ` (#601 ) at request time, with ` config.baseUrl ` as
152190the reverse-proxy override.
@@ -292,6 +330,51 @@ somewhere to keep the words (pod or hosted); close/reopen are for the
292330target repo owner or the PR author; ** merge is target repo owner
293331only** . Anonymous writes are 401, everything else 403 — house rules.
294332
333+ ## Labels (polish)
334+
335+ The per-repo label SET lives in the repo's issues index file
336+ (` idx.labels ` ); until the first label write, GitHub's default five apply
337+ (and are what ` GET .../labels ` serves), so every repo has useful labels
338+ with zero setup. Items — issues AND pull requests — store label ** names**
339+ only: a recolor is one index write with no cascade, while rename/delete
340+ cascade over both the issues and pulls indexes under their locks. Chips
341+ render GitHub-style (rounded-full, background = the label color,
342+ black-ish/white text picked by perceptual luminance, hairline border so
343+ ` wontfix ` 's white survives). Authorization is two-level, matching the
344+ close/reopen beat: the label set is the OWNER's (CRUD), a given item's
345+ labels are settable by the owner ** or that item's author** .
346+
347+ ## Search (polish)
348+
349+ Deliberately ** read-time only — nothing is indexed** :
350+
351+ - The repo list (` <prefix>/?q= ` ) is a plain GET form (no JS): a
352+ case-insensitive substring filter over ` owner/name ` and description of
353+ the (already capped at 200) repo cards.
354+ - In-repo (` .../search?q= ` , HTML + api): one ` git ls-tree -r --name-only `
355+ pass for path matches plus one ` git grep -I -n -i -F ` over the
356+ ** default branch** — ` -F ` means literal substring (no regex
357+ injection), ` -I ` skips binaries, ` -i ` case-insensitive. Bounds, all
358+ hard: 256-char query, 100 grep hits, 100 path matches, 5 hits per file
359+ (` --max-count ` , git ≥ 2.38 — the same floor merge-tree already probes;
360+ on older gits the per-file bound drops and the total cap still holds),
361+ 200-char excerpts, escaped like everything else. The documented cost:
362+ O(repo) work per query, which is the right trade at personal-forge
363+ scale; an index would be cache-invalidation machinery the plugin api
364+ cannot power yet (Finding 5's family).
365+
366+ ## Releases (polish)
367+
368+ ` .../releases ` lists ** every tag, newest first** (`for-each-ref
369+ --sort=-creatordate`) — annotated tags show the first line of their tag
370+ message, lightweight tags are marked as such — each with tar.gz and zip
371+ download buttons. ` .../archive/<ref>.tar.gz|.zip ` runs `git archive
372+ --format=… --prefix=<repo >-<ref >/ <ref >` and ** streams the child's
373+ stdout** to the response (attachment disposition, no buffering); the ref
374+ is validated (` okRef ` /sha) and resolved with ` rev-parse ` * before* any
375+ header goes out, so a bogus ref is a clean 404. Any resolvable ref works
376+ — tags, branches, shas — which is exactly GitHub's archive behavior.
377+
295378## Nostr agents (tier 2.5)
296379
297380### Identity model — hex canonical, npub display-only
@@ -436,17 +519,20 @@ detection is a NUL sniff over the first 8 KB.
436519- Client-side JavaScript is the clone-box copy button (degrades to a
437520 selectable input) and the issues client above (degrades to read-only
438521 pages). The page CSP grants ` connect-src 'self' ` — exactly enough for
439- the client's same-origin fetches, nothing outbound.
522+ the client's same-origin fetches, nothing outbound — and
523+ ` form-action 'self' ` for the no-JS search forms (Finding 16).
440524
441525## Deliberate cuts
442526
443527- ** No syntax highlighting** — that's where the old attempt's 1 MB
444528 bundle came from. A ` <pre> ` with line numbers covers tier-1 browsing;
445529 highlighting is a candidate for a later wave * if* it can be done
446530 server-side and dependency-free.
447- - ** No search, no webhooks** — later tiers (see Findings 3). Issues
448- arrived in tier 2; PRs arrived in tier 3a with the same pod-native
449- treatment (bodies in pods, spine in pluginDir).
531+ - ** No search index, no webhooks** — search arrived in the polish wave
532+ as bounded read-time grep (see "Search"); webhooks still want
533+ ` api.events ` (see Findings 3). Issues arrived in tier 2; PRs arrived
534+ in tier 3a with the same pod-native treatment (bodies in pods, spine
535+ in pluginDir).
450536- ** No browser-git** — every byte of git logic is the system binary;
451537 server-side rendering made the old client bundle unnecessary.
452538
@@ -691,3 +777,24 @@ proportional to compared branches. The alternative (fetch into a
691777temp ref + delete) re-downloads objects on every compare AND still
692778leaves the objects in the odb until gc. If it ever grates,
693779` uploadpack.hideRefs=refs/forge ` per repo is the one-line cure.
780+
781+ ** Closed in the polish wave** : the one-line cure is applied —
782+ ` uploadpack.hideRefs=refs/forge/ ` is set in every repo's git config at
783+ creation (materialize AND fork), and lazily on the first compare-fetch
784+ for repos that predate it (a cheap config-file read skips the git spawn
785+ once the line exists). Test-proven with a real ` git ls-remote ` : the
786+ internal refs exist in the repo, and do not ride the wire.
787+
788+ ### 16. CSP form-action 'none' blocks even a plain GET form
789+
790+ The search boxes are the no-JS ideal — ` <form method="get"> ` , a text
791+ input, the server filters — and they silently did nothing under the
792+ tier-1 CSP, because ` form-action 'none' ` governs ALL form submissions,
793+ navigation-only GET forms included (unlike ` connect-src ` , which only
794+ sees script-initiated fetches). The delta is ` form-action 'self' ` : form
795+ targets stay same-origin, which is exactly what the search forms need
796+ and all they can reach. Worth writing down because the failure mode is
797+ invisible — the page renders, the button clicks, nothing happens, and
798+ only the browser console says why. Same lesson as Finding 8's
799+ connect-src discovery: each new * kind* of page interactivity trips a
800+ different CSP directive.
0 commit comments