Skip to content

Commit 3414c66

Browse files
forge: labels, search, releases, ref hygiene (tier 3b)
GitHub's five default labels with owner CRUD and cascading rename/ delete across both indexes; ?label= filters composing with state on issues and pulls. Read-time bounded search: git grep over the default branch plus path matches, no index built. Releases from tags with streamed git-archive tar.gz/zip downloads (kill-on-client-close, no buffering). refs/forge/* now hidden from ls-remote via uploadpack.hideRefs, set at creation and lazily healed — closes Finding 15. New Finding 16: CSP form-action 'none' silently blocks plain GET navigation forms — same lesson family as connect-src. 86/86 tests.
1 parent 6b6ee36 commit 3414c66

3 files changed

Lines changed: 894 additions & 43 deletions

File tree

forge/README.md

Lines changed: 115 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
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

33
The useful slice of Gogs/Gitea as a JSS plugin: push a repo over smart
44
HTTP, 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**
1111
issue bodies for podless agents) and puts the vendored **xlogin** widget
1212
on the issues pages. Tier 3a adds **forks, compare, and pull requests
1313
with 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).
1520
Zero npm dependencies, zero build step, no
1621
framework: every page is server-rendered HTML with inline CSS, all git
1722
work 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+
150188
Errors are `{ error }` with 4xx. `cloneUrl` is absolute: the origin comes
151189
from `api.serverInfo` (#601) at request time, with `config.baseUrl` as
152190
the reverse-proxy override.
@@ -292,6 +330,51 @@ somewhere to keep the words (pod or hosted); close/reopen are for the
292330
target repo owner or the PR author; **merge is target repo owner
293331
only**. 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
691777
temp ref + delete) re-downloads objects on every compare AND still
692778
leaves 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

Comments
 (0)