clone

a personal forge on plain git: static pages, stacked-diff reviews, signed local ci, repo sites and releases

Comment and approve from a review page, signed in

cat committed b8bdf20
The review page's discussion ends in a comment form and an Approve
button. Pages are static files, the same for everyone, so both start
hidden and do nothing without a script: discuss.ts asks /login/whoami
who is signed in and shows them to a user, without Approve for the
review's author or a review that isn't live. It posts the tip the page
shows, so a comment from a page built before a push belongs to the
revision its writer read, and an approval from it is refused, in the
forge's words.

A sent comment shows as pending, its text as typed (the build renders
and sanitizes the markdown, not the browser), until the rebuilt page
comes in: live.ts swaps it in when the build's event arrives, and
discuss.ts asks for it again now and then should none arrive, through a
forge:refresh event live.ts now takes. Drafts are kept in
sessionStorage, so a swap or a reload doesn't lose them.
commit
b8bdf201665854667618dc57b0b234b6447cc1f1
parent
933aac1f0984126a9e608a820cf906505b1281b7
author
cat <admin@dev.site>
ci
✓ passed in 109s, signed by cat
kind
feat risk score 1.3 of 3, Jev's estimate
review
#11 Comment and approve from the web
ci attestation
forge-attestation 1
type ci
commit b8bdf201665854667618dc57b0b234b6447cc1f1
tree 981b9eda6deb6104df22db639d266e2734bd5a97
script .forge/ci 38df37c0d5a6382f60569079a0dd821094e37e4a
result pass
exit 0
started 2026-10-19T00:54:16Z
duration 109s
runner (not shown here; refs/notes/ci has it)
system Darwin 25.5.0 arm64
env host
log sha256:ed4796973fdfb569304e1076deaabfa66421775093529fc685699d80ebd4ec03 345 lines
|remote: review #4: /dami/greet/reviews/4/
|pushed wip/i18n
|9ab744f running .forge/ci: greet.sh: say what it is
|  | sh: /bin/sh
|  | ok   greet
|9ab744f pass in 0s, signed
|pushed main
|remote: review #4: /dami/greet/reviews/4/
|pushed wip/i18n
|c2924e7 running .forge/ci: wave at the world
|  | sh: /bin/sh
|  | FAIL greet : got 'hello, world o/', want 'hello, world'
|c2924e7 fail in 0s, signed
|forge: 1 commit(s) did not pass
|remote: review #6: /dami/greet/reviews/6/
|pushed ana/wave
|fd6ee16 running .forge/ci: update the test for the wave
|  | sh: /bin/sh
|  | ok   greet
|fd6ee16 pass in 0s, signed
|remote: review #6: /dami/greet/reviews/6/
|pushed ana/wave
|warning: You appear to have cloned an empty repository.
|site home: deployed b82ce6e -> /sites/ana/dotfiles/home/
|created ana/notes
|remote: review #1: /ana/notes/reviews/1/
|pushed ana/todo
|landing #1 A todo list: ana/todo -> main
|eca094d running .forge/ci: web, server and Python versions of greet
|  | sh: /bin/sh
|  | ok   greet
|eca094d pass in 0s, signed
|pushed main
|271fe9a running .forge/ci: messages: notes for the later regions
|  | sh: /bin/sh
|  | ok   greet
|271fe9a pass in 0s, signed
|remote: review #7: /dami/greet/reviews/7/
|pushed dami/regions
|created dami/forge
|warning: You appear to have cloned an empty repository.
|site guide: deployed a949f6a -> /sites/dami/forge/guide/
|(node:56963) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time
|(Use `node --trace-warnings ...` to show where the warning was created)
|built ana/dotfiles in 1s -> /Users/dami/Library/Caches/forge/ci/35f7a65d0235/demo/root/.forge/www
|(node:57088) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time
|(Use `node --trace-warnings ...` to show where the warning was created)
|built ana/notes in 0s -> /Users/dami/Library/Caches/forge/ci/35f7a65d0235/demo/root/.forge/www
|(node:57225) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time
|(Use `node --trace-warnings ...` to show where the warning was created)
|built dami/forge in 1s -> /Users/dami/Library/Caches/forge/ci/35f7a65d0235/demo/root/.forge/www
|(node:57356) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time
|(Use `node --trace-warnings ...` to show where the warning was created)
|built dami/greet in 1s -> /Users/dami/Library/Caches/forge/ci/35f7a65d0235/demo/root/.forge/www
|(node:58278) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time
|(Use `node --trace-warnings ...` to show where the warning was created)
|built - in 1s -> /Users/dami/Library/Caches/forge/ci/35f7a65d0235/demo/root/.forge/www
|226 of 226 pages render the same without 11ty
|guide: 4 files, links ok
|ok

Signature: good, cat

5 files changed, +257 -4
Msite/_includes/discussion.njk24+++-
Asite/assets/discuss.ts196++++++++++++++++++++++++++++++
Msite/assets/live.ts6+-
Msite/repo/review.njk1+
Atest/scenarios/web-comment-page.sh34+++++
M site/_includes/discussion.njk +21 -3
@@ -1,6 +1,8 @@
11 {# A review's discussion: approvals, then comments oldest first. A line22 comment quotes its line as it was when written; comments from an older3- revision are outdated. Written with forge comment and forge approve. #}3+ revision are outdated. Written with forge comment and forge approve, or4+ from this page signed in: the form at the end stays hidden until5+ discuss.ts finds who is signed in, and posts the tip the page shows. #}46 {% set u = p.repo.urls %}57 {% css %}68 ul.approvals { list-style: none; margin: 0 0 12px; padding: 0; display: flex; flex-wrap: wrap; gap: 4px 18px; font-size: 0.92em; }
@@ -21,13 +23,25 @@ ol.comments { list-style: none; margin: 0; padding: 0; }
2123 .comment.outdated .quote pre { color: var(--muted); }2224 .comment .markdown { padding: 8px 12px; font-size: 0.95rem; line-height: 1.55; }2325 .comment .markdown > :last-child { margin-bottom: 0; }26+/* Sent from this page, until the rebuilt page shows it. */27+.comment.pending { border-style: dashed; }28+.comment.pending .text { padding: 8px 12px; white-space: pre-wrap; overflow-wrap: anywhere; color: var(--muted); }29+.reply { margin: 12px 0 0; }30+.reply textarea { display: block; width: 100%; min-height: 6em; resize: vertical; font: 14px/1.5 var(--sans); color: var(--fg); background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); padding: 8px 10px; }31+.reply textarea:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }32+.reply-bar { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 8px; margin-top: 8px; font-size: 0.92em; }33+.reply-bar .grow { flex: 1; }34+.reply-bar button.go { background: var(--accent); border-color: var(--accent); color: #fff; }35+.reply-bar button.go:hover { filter: brightness(1.1); }36+.reply-bar button:disabled { opacity: 0.6; cursor: default; filter: none; }37+.reply-status.bad { color: var(--fail); white-space: pre-line; }2438 {% endcss %}2539 <section class="discussion" id="discussion">2640 <h2>Discussion <span class="desc">{{ rv.comments.length | plural('comment') }}{% if rv.approvals.length %}, {{ rv.approvals.length | plural('approval') }}{% endif %}</span></h2>2741 {% if rv.approvals.length %}2842 <ul class="approvals">2943 {% for a in rv.approvals %}30-<li class="{{ 'current' if a.current else 'stale' }}"><span class="tick">&#10004;</span> <b>{{ a.user }}</b> approved{% if a.rev %} r{{ a.rev }}{% endif %} <span class="date">({{ a.ts | ago }})</span>{% if not a.current %}, changed since{% endif %}</li>44+<li class="{{ 'current' if a.current else 'stale' }}" data-user="{{ a.user }}"><span class="tick">&#10004;</span> <b>{{ a.user }}</b> approved{% if a.rev %} r{{ a.rev }}{% endif %} <span class="date">({{ a.ts | ago }})</span>{% if not a.current %}, changed since{% endif %}</li>3145 {% endfor %}3246 </ul>3347 {% endif %}
@@ -45,6 +59,10 @@ ol.comments { list-style: none; margin: 0; padding: 0; }
4559 {% endfor %}4660 </ol>4761 {% else %}48-<p class="desc">No comments yet: <code>forge comment {{ rv.id }}</code> adds one.</p>62+<p class="desc" id="no-comments">No comments yet: <code>forge comment {{ rv.id }}</code> adds one.</p>4963 {% endif %}64+<form class="reply" id="reply" data-post="{{ u.review(rv.id).href }}" data-at="{{ rv.tip }}" data-author="{{ rv.author or '' }}" hidden>65+<textarea name="body" maxlength="32768" aria-label="Comment on #{{ rv.id }}" placeholder="Comment on #{{ rv.id }}. Markdown is fine."></textarea>66+<div class="reply-bar"><span class="desc who"></span><span class="grow"></span><span class="reply-status" role="status"></span>{% if rv.live %}<button type="button" data-approve title="Approve this tree of #{{ rv.id }}, as forge approve {{ rv.id }} does">Approve</button>{% endif %}<button type="submit" class="go">Comment</button></div>67+</form>5068 </section>
A site/assets/discuss.ts +196 -0
@@ -0,0 +1,196 @@
1+// Commenting and approving from a review page, signed in. Pages are static2+// and the same for everyone, so the comment form and the Approve button3+// (discussion.njk) start hidden; this asks the private server who is4+// signed in (/login/whoami, never cached) and shows them to a user. Posts5+// go to /<owner>/<repo>/reviews/<id>/comment and .../approve with the6+// session's CSRF token and the tip the page shows, and lib/private-server.ts7+// runs forge comment's and forge approve's own code for them. Until the8+// rebuilt page comes in (live.ts swaps it in once the build is done), a9+// sent comment shows as pending, as typed: its markdown is rendered by the10+// build, not here. Drafts outlast that swap, and reloads (sessionStorage).11+(() => {12+ interface Me {13+ user: string;14+ csrf: string;15+ }16+ interface Sent {17+ kind: 'comment' | 'approve';18+ n?: number;19+ text?: string;20+ }21+ let me: Me | null = null;22+ // What was sent from this page and isn't on it yet.23+ let sent: Sent[] = [];24+25+ const draftKey = (k: string): string => `forge.draft ${location.pathname} ${k}`;26+ const draft = (k: string, text?: string): string => {27+ try {28+ if (text === undefined) return sessionStorage.getItem(draftKey(k)) ?? '';29+ if (text) sessionStorage.setItem(draftKey(k), text);30+ else sessionStorage.removeItem(draftKey(k));31+ } catch {}32+ return text ?? '';33+ };34+35+ class Refused extends Error {}36+ async function send(url: string, fields: Record<string, string>): Promise<{ message: string; comment?: number }> {37+ const r = await fetch(url, {38+ method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' },39+ body: JSON.stringify({ ...fields, csrf: me?.csrf ?? '' }),40+ });41+ const data = (await r.json().catch(() => ({}))) as { error?: string; message?: string; comment?: number };42+ if (!r.ok) throw new Refused(data.error ?? (r.status === 404 ? 'This review is not there any more.' : `The forge said HTTP ${r.status}.`));43+ return { message: data.message ?? '', comment: data.comment };44+ }45+46+ const say = (form: Element | null, text: string, bad = false): void => {47+ const s = form?.querySelector<HTMLElement>('.reply-status');48+ if (!s) return;49+ s.textContent = text;50+ s.classList.toggle('bad', bad);51+ };52+ const busy = (form: Element, on: boolean): void => {53+ for (const b of form.querySelectorAll<HTMLButtonElement>('button')) b.disabled = on;54+ };55+56+ // Is it on the page now? Then it is no longer pending.57+ const shown = (s: Sent): boolean =>58+ s.kind === 'comment' ? !!document.getElementById(`comment-${s.n}`) : !!me && [...document.querySelectorAll<HTMLElement>('ul.approvals li.current')].some((li) => li.dataset.user === me!.user);59+60+ // Pending comments at the end of the discussion, and the approval in the61+ // form's status line, drawn again after each swap of the content.62+ function drawSent(): void {63+ sent = sent.filter((s) => !shown(s));64+ for (const el of document.querySelectorAll('[data-sent]')) el.remove();65+ const form = document.getElementById('reply');66+ if (!form || !me) return;67+ for (const s of sent) {68+ if (s.kind === 'approve') {69+ say(form, 'Approved; it shows here once the page is rebuilt.');70+ continue;71+ }72+ let list = document.querySelector('ol.comments');73+ if (!list) {74+ list = document.createElement('ol');75+ list.className = 'comments';76+ list.setAttribute('data-sent', '');77+ form.before(list);78+ }79+ const li = document.createElement('li');80+ li.className = 'comment pending';81+ li.setAttribute('data-sent', '');82+ li.innerHTML = '<div class="comment-head"><b></b> <span class="date">sent, shown here once the page is rebuilt</span></div><div class="text"></div>';83+ li.querySelector('b')!.textContent = me.user;84+ li.querySelector('.text')!.textContent = s.text ?? '';85+ list.append(li);86+ }87+ if (document.querySelector('ol.comments [data-sent]')) document.getElementById('no-comments')?.setAttribute('hidden', '');88+ }89+90+ // live.ts brings the rebuilt page when the build's event arrives. Should91+ // none arrive (the stream is down, say), ask for it now and then.92+ let waiting: ReturnType<typeof setTimeout> | undefined;93+ function watch(wait = 4000): void {94+ clearTimeout(waiting);95+ if (!sent.length || wait > 120000) return;96+ waiting = setTimeout(() => {97+ document.dispatchEvent(new Event('forge:refresh'));98+ watch(wait * 2);99+ }, wait);100+ }101+102+ function setup(): void {103+ const form = document.getElementById('reply') as HTMLFormElement | null;104+ if (form && me) {105+ form.hidden = false;106+ form.querySelector('.who')!.textContent = `as ${me.user}`;107+ const text = form.querySelector('textarea')!;108+ if (!text.value) text.value = draft('review');109+ const approve = form.querySelector<HTMLButtonElement>('[data-approve]');110+ if (approve && me.user === form.dataset.author) approve.hidden = true;111+ else if (approve && shown({ kind: 'approve' })) {112+ approve.disabled = true;113+ approve.textContent = 'Approved';114+ approve.title = 'You approved this tree';115+ }116+ }117+ drawSent();118+ }119+120+ document.addEventListener('input', (e) => {121+ const t = e.target as HTMLTextAreaElement;122+ if (t.closest('form#reply')) draft('review', t.value);123+ });124+125+ // Ctrl or Cmd and Enter sends, as in most comment boxes.126+ document.addEventListener('keydown', (e) => {127+ const t = e.target as HTMLElement;128+ if (e.key === 'Enter' && (e.metaKey || e.ctrlKey) && t.matches('form#reply textarea')) {129+ e.preventDefault();130+ t.closest('form')!.requestSubmit();131+ }132+ });133+134+ document.addEventListener('submit', async (e) => {135+ const form = (e.target as Element).closest<HTMLFormElement>('form#reply');136+ if (!form || !me) return;137+ e.preventDefault();138+ const box = form.querySelector('textarea')!;139+ const text = box.value;140+ if (!text.trim()) return say(form, 'Write a comment first.', true);141+ busy(form, true);142+ say(form, 'Sending…');143+ try {144+ const r = await send(form.dataset.post + 'comment', { body: text, at: form.dataset.at ?? '' });145+ box.value = '';146+ draft('review', '');147+ sent.push({ kind: 'comment', n: r.comment, text });148+ // forge comment's own note, when the review moved on since this page was built.149+ say(form, r.message.split('\n').slice(1).join(' '));150+ drawSent();151+ watch();152+ } catch (err) {153+ say(form, err instanceof Refused ? err.message : "Couldn't reach the forge; your text is still here.", true);154+ } finally {155+ busy(form, false);156+ }157+ });158+159+ document.addEventListener('click', async (e) => {160+ const b = (e.target as Element).closest<HTMLButtonElement>('form#reply [data-approve]');161+ const form = b?.closest<HTMLFormElement>('form');162+ if (!b || !form || !me) return;163+ busy(form, true);164+ say(form, 'Approving…');165+ try {166+ const r = await send(form.dataset.post + 'approve', { at: form.dataset.at ?? '' });167+ // Approved before: nothing changes, so nothing to wait for.168+ if (/^you already approved/.test(r.message)) say(form, 'You approved this tree already.');169+ else {170+ sent.push({ kind: 'approve' });171+ drawSent();172+ watch();173+ }174+ b.textContent = 'Approved';175+ busy(form, false);176+ b.disabled = true;177+ } catch (err) {178+ say(form, err instanceof Refused ? err.message : "Couldn't reach the forge.", true);179+ busy(form, false);180+ }181+ });182+183+ // Signed out, or a server without the private server (npm run dev): the184+ // controls stay hidden.185+ if (!document.getElementById('reply')) return;186+ fetch('/login/whoami', { cache: 'no-store', credentials: 'same-origin' })187+ .then((r) => (r.ok ? r.json() : null))188+ .then((w: { user?: string | null; csrf?: string } | null) => {189+ if (!w?.user || !w.csrf) return;190+ me = { user: w.user, csrf: w.csrf };191+ setup();192+ // live.ts swaps in rebuilt content.193+ document.addEventListener('forge:content', setup);194+ })195+ .catch(() => {});196+})();
M site/assets/live.ts +5 -1
M site/repo/review.njk +1 -0
A test/scenarios/web-comment-page.sh +34 -0
@@ -0,0 +1,34 @@
1+# A review page carries a comment form and an Approve button for whoever2+# is signed in. The page is a static file, the same for everyone, so they3+# start hidden and discuss.js shows them once /login/whoami names a user.4+# The form posts the tip the page shows and knows the review's author,5+# whose Approve button it hides. A review that isn't live has no Approve6+# button. A private repo's review page has the same form.7+. "$(dirname "$0")/../lib.sh"8+user dami9+repo dami/greet10+repo --private dami/secret11+for r in greet secret; do12+ clone dami "dami/$r" "$T/w/$r"13+ at "$T/w/$r" commit "first"14+ ok push_as dami "$T/w/$r" origin main15+ at "$T/w/$r" git switch -q -c dami/feature16+ at "$T/w/$r" commit "feature"17+ ok push_as dami "$T/w/$r" origin dami/feature18+done19+at "$T/w/greet" git switch -q -c dami/gone20+at "$T/w/greet" commit "gone"21+ok push_as dami "$T/w/greet" origin dami/gone22+ok push_as dami "$T/w/greet" origin :dami/gone23+build24+tip=$(git --git-dir="$FORGE_ROOT/dami/greet.git" rev-parse dami/feature)25+26+p=$FORGE_WWW/dami/greet/reviews/1/index.html27+has "$p" "<form class=\"reply\" id=\"reply\" data-post=\"/dami/greet/reviews/1/\" data-at=\"$tip\" data-author=\"dami\" hidden>"28+has "$p" '<textarea name="body" maxlength="32768"'29+has "$p" '<button type="button" data-approve '30+has "$p" '<script src="/assets/discuss\.[A-Za-z0-9_-]{10}\.js" defer>'31+exists "$FORGE_WWW$(grep -o '/assets/discuss\.[A-Za-z0-9_-]*\.js' "$p")"32+has "$FORGE_WWW/dami/greet/reviews/2/index.html" 'id="reply" data-post="/dami/greet/reviews/2/"'33+lacks "$FORGE_WWW/dami/greet/reviews/2/index.html" 'data-approve'34+has "$FORGE_ROOT/.forge/private/www/dami/secret/reviews/1/index.html" 'id="reply" data-post="/dami/secret/reviews/1/"'