docs/plans/2026-09-27-web-ux.md
3045 lines · 110761 bytes
41 symbols in this file
Web UX sweep implementation planGlobal constraintsOrder and dependenciesMR 1: architecture-review small fixes (branch `web-audit-fixes`)Task 1.1: run `foreign_key_check` inside the migration transaction, before commitTask 1.2: `pinToggle` and `watchToggle` dispatch through their commandsTask 1.3: Cache-Control: no-store on the login-link consuming requestTask 1.4: doc drift — API.org, Parity.org, Threat-Model.orgTask 1.5: open MR 1MR 2: settings page quotes working commands (branch `web-settings-commands`)Task 2.1: fix the two known-wrong quoted commandsTask 2.2: auth summary and help — done in the CLI UX planTask 2.3: a test that runs every quoted command through the registryTask 2.4: open MR 2MR 3: MR range-diff page (branch `web-mr-range-diff`)Task 3.1: `/{owner}/{repo}/mrs/{n}/range-diff`Task 3.2: revisions list with a "compare to previous" link per rowTask 3.3: update ParityTask 3.4: open MR 3MR 4: empty states and contribution hints sweep (branch `web-empty-states`)Task 4.1: apply the tableTask 4.2: MR list contribution hint by access levelTask 4.3: search scope caption always visible; tab zero-count ruleTask 4.4: open MR 4MR 5: UX review small fixes (branch `web-ux-small-fixes`)Task 5.1: issue form gains milestone and assigneeTask 5.2: "Muted" reachable on the watch controlTask 5.3: rail and phone "More" menu render from one listTask 5.4: "Discussion" heading before the comment threadTask 5.5: build page's "Live" note says the page updates itselfTask 5.6: open MR 5MR 6: wiki non-page links go to `_raw` (branch `wiki-raw-links`)Task 6.1: `rewriteWikiLinks` sends a non-page file link to `_raw`Task 6.2: open MR 6MR 7: API token page (branch `web-api-tokens`)Task 7.1: `Settings → Tokens`: create, list, revokeTask 7.2: `registered.html` next steps as a numbered listTask 7.3: update ParityTask 7.4: open MR 7Self-reviewOpen questions
1# Web UX sweep implementation plan
2
3> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
5**Goal:** Close out the architecture-review web findings (#261), the two
6web UX-review issues (#263, #271), the empty-state sweep (#270), the
7missing API-token page (#264), the MR range-diff view (#269), and the
8wiki non-page-link 404 (#283).
9
10**Architecture:** No new subsystems. Every write goes through
11`s.runControl`/`s.runControlCode` into the existing control registry
12(`internal/httpd/control.go`), the same rule every other web write
13already follows — this plan fixes the three handlers that did not
14(`pinToggle`, `watchToggle`, and adds a mute state to the latter). Reads
15either dispatch into a command (range-diff, whose text output the CLI
16and iOS already render, so the web reuses it rather than re-implementing
17revision resolution) or read the store directly, matching the existing
18convention for GET handlers (`accountPage` already reads
19`ListSSHKeys`/`ListPGPKeys` directly). Template copy changes are text-only;
20one new page (`mrrangediff.html`) and one new settings section
21(`Settings → Tokens`) are added following the existing settings-page and
22repo-page patterns.
23
24**Tech stack:** Go, `html/template`, the existing SQLite store, cobra
25(`cmd/gitbay`).
26
27**Spec:** none — this plan is written directly from the issue texts
28(`.gitbay/wiki` doc drift, architecture-review findings) and the current
29source; there is no separate design doc.
30
31## Global constraints
32
33- Five MRs (one skipped: this plan issues #261/#263/#269/#270/#271/#283
34 are covered by five MRs; #264 is a sixth), each on its own branch off
35 `main`. Commits are signed (the repository refuses unsigned ones);
36 messages end with `Ref #N`, and the last commit closing an issue ends
37 with `Closes #N`. No attribution to any assistant, model or AI
38 anywhere: commits, MR bodies, comments.
39- `gitbay mr create --source <branch> --target main --title "..."`;
40 merge with `gitbay mr merge <n> --strategy ff` once CI is green, then
41 delete the branch locally and on the remote. If the merge reports the
42 branch is behind, rebase onto `main`, force-push, merge again.
43- Locally: `go build ./...`, `go vet ./...`, the unit tests of touched
44 packages, and at most the one e2e test being written
45 (`go test ./e2e -run TestName -count=1`). CI on bay1 runs the full
46 suite (`go test ./...`), including `TestMainWidthClass`,
47 `e2e/readonly_test.go`'s `readArgs` coverage, and the `cmd/gitbay`
48 coverage test over `pass()` registrations — none of this plan's tasks
49 add a new control command, so none of those three registries gain a
50 new required row, but a new page template (`mrrangediff.html`) **does**
51 need a row in `TestMainWidthClass`'s width map
52 (`internal/web/web_test.go`).
53- No migrations in this plan (no schema changes).
54- Web writes dispatch through control commands
55 (`internal/httpd/control.go`: `runControl`/`runControlCode`/
56 `runControlStdin`); a handler that calls the store directly for a
57 *write* is exactly the bug #261 reports for `pinToggle` and
58 `watchToggle`, and this plan does not introduce a new instance of it.
59 Reads may call the store directly (the existing convention throughout
60 `internal/httpd`) or dispatch when the logic they need (e.g. revision
61 resolution for range-diff) already lives in a command.
62- Secrets travel on stdin or, for the one case that already carries one
63 in a URL by design (the emailed login link), never in a place the
64 code cannot document and cache-guard; never logged.
65- Wiki pages live in `.gitbay/wiki/`: `Parity.org`, `API.org`,
66 `Threat-Model.org`. Update the page in the same MR that changes the
67 behaviour it describes.
68- Writing style: plain, direct, no hype; UI copy uses the register fixed
69 in MR 4 (Task 4.1) everywhere else it appears afterward.
70- CLAUDE.md's rules apply throughout: surgical changes only, no
71 unrelated refactors, no speculative flexibility.
72
73## Order and dependencies
74
751. **`web-audit-fixes`** (branch `web-audit-fixes`) — closes #261.
76 Independent. Landing this first matters because MR 6 (#271's mute
77 option) depends on the toggle-dispatch fix here.
782. **`web-settings-commands`** (branch `web-settings-commands`) — closes
79 #263. Independent of 1. Lands after `cli-ux-help` (CLI UX plan),
80 which carries the auth summary and help part of #263.
813. **`web-mr-range-diff`** (branch `web-mr-range-diff`) — closes #269.
82 Independent.
834. **`web-empty-states`** (branch `web-empty-states`) — closes #270.
84 Independent, but touches `mrs.html` and `globalsearch.html`; land
85 before MR 6 to avoid the same files diverging on two branches at
86 once (MR 6 does not touch either).
875. **`web-ux-small-fixes`** (branch `web-ux-small-fixes`) — closes #271.
88 Depends on MR 1 (`repo watch`/`repo mute` dispatch and the cycling
89 toggle it introduces; #271's Muted option builds directly on it).
906. **`wiki-raw-links`** (branch `wiki-raw-links`) — closes #283.
91 Independent; small, can land anywhere, placed last only because it
92 is unrelated to the rest.
937. **`web-api-tokens`** (branch `web-api-tokens`) — closes #264.
94 Depends on plan 1 (`credentials-and-sessions`, #257): that plan
95 changes `token create`'s own default scope to `read`. This plan's
96 Task 7.1 makes the *web form* always send an explicit `--scope`
97 value regardless of what the command defaults to, so this MR does
98 not have to wait for plan 1 to land — but merge it after plan 1 to
99 pick up the release-note and any command-message changes plan 1
100 makes to `token create`. If plan 1 has not landed yet, this MR still
101 works correctly (the form never relies on the flag's default); note
102 in the MR description that it does not depend on plan 1 having
103 merged, only on eventually being consistent with it.
104
105---
106
107# MR 1: architecture-review small fixes (branch `web-audit-fixes`)
108
109Closes #261.
110
111### Task 1.1: run `foreign_key_check` inside the migration transaction, before commit
112
113**Files:**
114- Modify: `internal/store/store.go:182-259` (`step`, the `fkOff` branch)
115- Test: `internal/store/store_test.go` (create the case if no existing
116 migration test exercises an `fkOff` step; check first)
117
118**Interfaces:**
119- No signature changes; `step`'s behaviour changes only.
120
121- [ ] **Step 1: Confirm there is no existing FK-violation test to build on**
122
123Run: `grep -n "foreign_key_check\|fkOff\|foreign_keys: off" internal/store/store_test.go`
124If nothing matches, the test below is new.
125
126- [ ] **Step 2: Write the failing test**
127
128Add to `internal/store/store_test.go`:
129
130```go
131// A migration marked "-- foreign_keys: off" must have its
132// foreign_key_check run before the transaction commits, not after —
133// otherwise a violation is reported once the bad schema and
134// user_version are already persisted (#261).
135func TestFKOffMigrationChecksBeforeCommit(t *testing.T) {
136 dir := t.TempDir()
137 st, err := Open(dir + "/test.db")
138 if err != nil {
139 t.Fatal(err)
140 }
141 defer st.Close()
142
143 // A minimal two-step schema: a parent table, then a child that
144 // references it, or el se this migration wouldn't exercise anything.
145 // Reach in through the exported entry point rather than duplicating
146 // migration internals: two ad hoc migrations appended to the real
147 // list would require touching the embedded migration files, so this
148 // test instead runs the real migration set up to its current head
149 // and then drives step() through a synthetic single-migration
150 // upgrade using the unexported hook the package already has for
151 // tests, if one exists.
152 if err := st.MigrateUp(); err != nil {
153 t.Fatal(err)
154 }
155 before, err := st.DB.Query("PRAGMA user_version")
156 if err != nil {
157 t.Fatal(err)
158 }
159 before.Close()
160
161 // Insert a row through a raw statement that a fkOff rebuild would
162 // have to preserve or complain about: a milestone with no matching
163 // repo_id (the deliberately impossible case a corrupt migration
164 // would produce).
165 if _, err := st.DB.Exec("PRAGMA foreign_keys = OFF"); err != nil {
166 t.Fatal(err)
167 }
168 if _, err := st.DB.Exec(
169 "INSERT INTO milestones (repo_id, title, state, created_at) VALUES (99999, 'orphan', 'open', datetime('now'))"); err != nil {
170 t.Fatal(err)
171 }
172 if _, err := st.DB.Exec("PRAGMA foreign_keys = ON"); err != nil {
173 t.Fatal(err)
174 }
175
176 // A no-op fkOff step (rewriting milestones to itself) must now
177 // refuse — before it commits, not after — because the orphan row
178 // fails foreign_key_check. Confirm today's ordering leaves the
179 // schema version bumped despite the row it can never satisfy: this
180 // is the bug. Run the check directly the way step() will, and
181 // compare against the version left behind.
182 versionBefore := currentUserVersion(t, st)
183 err = st.runFKOffStepForTest(
184 "UPDATE sqlite_master SET name = name WHERE 0", versionBefore+1)
185 if err == nil {
186 t.Fatal("expected foreign_key_check to refuse the orphaned row")
187 }
188 if got := currentUserVersion(t, st); got != versionBefore {
189 t.Fatalf("user_version changed to %d despite the refused check (should stay %d)", got, versionBefore)
190 }
191}
192
193func currentUserVersion(t *testing.T, st *Store) int {
194 t.Helper()
195 var v int
196 if err := st.DB.QueryRow("PRAGMA user_version").Scan(&v); err != nil {
197 t.Fatal(err)
198 }
199 return v
200}
201```
202
203This calls an unexported `runFKOffStepForTest` that does not exist yet —
204it is Step 3's job to expose the already-unexported `step` closure's
205`fkOff` path under a name the test package can call. `step` is currently
206a closure local to `MigrateTo`; Step 3 promotes it to a package-level
207method so this test (and the real migration loop) can call the same
208code.
209
210- [ ] **Step 2: Run it and see it fail to compile**
211
212Run: `go test ./internal/store -run TestFKOffMigrationChecksBeforeCommit -count=1`
213Expected: FAIL to compile (`runFKOffStepForTest` undefined).
214
215- [ ] **Step 3: Promote `step` to a method and fix the ordering**
216
217In `internal/store/store.go`, replace the `step` closure inside
218`MigrateTo` (the whole `step := func(sqlText string, newVersion int,
219fkOff bool) (retErr error) { ... }` block at lines 182-259) with a call
220to a new method, and move its body there:
221
222```go
223func (s *Store) migrateStep(sqlText string, newVersion int, fkOff bool) (retErr error) {
224 if !fkOff {
225 tx, err := s.DB.Begin()
226 if err != nil {
227 return err
228 }
229 defer tx.Rollback()
230 if _, err := tx.Exec(sqlText); err != nil {
231 return err
232 }
233 if _, err := tx.Exec(fmt.Sprintf("PRAGMA user_version = %d", newVersion)); err != nil {
234 return err
235 }
236 return tx.Commit()
237 }
238
239 // A script whose first line is "-- foreign_keys: off" rebuilds a
240 // table that other tables reference (labels, milestones): with
241 // foreign keys on, the rebuild-by-rename loses the children's
242 // rows. PRAGMA foreign_keys is a no-op inside a transaction, and
243 // the pool gives no guarantee that a pragma set on one connection
244 // is seen by the connection Begin() draws next, so the whole step
245 // — pragma off, transaction, foreign_key_check, commit, pragma on —
246 // runs on a single pinned connection. The check runs before commit:
247 // checking after would report a violation once the bad schema and
248 // user_version were already persisted.
249 ctx := context.Background()
250 conn, err := s.DB.Conn(ctx)
251 if err != nil {
252 return err
253 }
254 defer conn.Close()
255 if _, err := conn.ExecContext(ctx, "PRAGMA foreign_keys = OFF"); err != nil {
256 return err
257 }
258 // The connection goes back to the pool when this returns, so every
259 // path out of here has to put foreign keys back on first.
260 restoreFK := func() error {
261 _, err := conn.ExecContext(ctx, "PRAGMA foreign_keys = ON")
262 return err
263 }
264 defer func() {
265 if err := restoreFK(); err != nil && retErr == nil {
266 retErr = err
267 }
268 }()
269 tx, err := conn.BeginTx(ctx, nil)
270 if err != nil {
271 return err
272 }
273 defer tx.Rollback()
274 if _, err := tx.Exec(sqlText); err != nil {
275 return err
276 }
277 if _, err := tx.Exec(fmt.Sprintf("PRAGMA user_version = %d", newVersion)); err != nil {
278 return err
279 }
280 // foreign_key_check works with enforcement off: it inspects the
281 // data directly rather than consulting the pragma. Running it here,
282 // inside the transaction, means a violation rolls back the whole
283 // rebuild (deferred tx.Rollback fires) instead of leaving the bad
284 // schema and version committed.
285 rows, err := tx.QueryContext(ctx, "PRAGMA foreign_key_check")
286 if err != nil {
287 return err
288 }
289 if rows.Next() {
290 var table string
291 var rowid sql.NullInt64
292 var referredTable string
293 var fkid int
294 if err := rows.Scan(&table, &rowid, &referredTable, &fkid); err != nil {
295 rows.Close()
296 return err
297 }
298 rows.Close()
299 return fmt.Errorf("foreign_key_check failed after migration: %s", table)
300 }
301 if err := rows.Err(); err != nil {
302 rows.Close()
303 return err
304 }
305 rows.Close()
306 return tx.Commit()
307}
308
309// runFKOffStepForTest exposes migrateStep's fkOff path to the package's
310// own tests, which need to drive one step in isolation rather than the
311// whole migration list MigrateTo runs.
312func (s *Store) runFKOffStepForTest(sqlText string, newVersion int) error {
313 return s.migrateStep(sqlText, newVersion, true)
314}
315```
316
317Update `MigrateTo`'s two loops to call the method instead of the removed
318closure:
319
320```go
321 for cur < target {
322 m := ms[cur]
323 if err := s.migrateStep(m.up, m.version, m.upFKOff); err != nil {
324 return fmt.Errorf("migration %d up: %w", m.version, err)
325 }
326 cur = m.version
327 }
328 for cur > target {
329 m := ms[cur-1]
330 if err := s.migrateStep(m.down, m.version-1, m.downFKOff); err != nil {
331 return fmt.Errorf("migration %d down: %w", m.version, err)
332 }
333 cur = m.version - 1
334 }
335```
336
337`runFKOffStepForTest` is exported to the test file only in the sense
338that it is an ordinary method in a `_test.go`-adjacent non-test file, so
339it ships in the binary; that is acceptable here since it is a one-line
340wrapper with no side effect beyond calling the real path, and keeping it
341out of the production file would mean either duplicating `migrateStep`
342in a test-only file or using an unexported test hook pattern the package
343does not otherwise have. If review prefers it test-only, move it to
344`internal/store/storetest_export_test.go` (package `store`) instead —
345functionally identical either way.
346
347- [ ] **Step 4: Run the test**
348
349Run: `go test ./internal/store -run TestFKOffMigrationChecksBeforeCommit -count=1`
350Expected: PASS (the orphan row now fails the check before commit, and
351`user_version` is left unchanged because `tx.Rollback()` fires).
352
353- [ ] **Step 5: Run the full package**
354
355Run: `go test ./internal/store -count=1`
356Expected: PASS — this is a reordering, not a behaviour change, for every
357migration that does not already violate its own foreign keys.
358
359- [ ] **Step 6: Commit**
360
361```bash
362git add internal/store/store.go internal/store/store_test.go
363git commit -m "store: run foreign_key_check inside the migration transaction, before commit" -m "Ref #261"
364```
365
366### Task 1.2: `pinToggle` and `watchToggle` dispatch through their commands
367
368**Files:**
369- Modify: `internal/httpd/accounts.go:241-250` (`pinToggle`)
370- Modify: `internal/httpd/notifyweb.go:61-72` (`watchToggle`)
371- Test: `internal/httpd/accounts_test.go` or `internal/httpd/account_test.go`
372 (check which file already has repo-toggle tests; add beside them)
373
374**Interfaces:**
375- Consumes: `s.runControl` (`internal/httpd/control.go:25`, already used
376 by `bookmarkToggle`, the correct existing model for this fix).
377- Produces: no new exported names; `watchToggle`'s behaviour becomes a
378 three-way cycle (`""` → `watching` → `muted` → `""`), which Task 5.x
379 in MR 5 (`web-ux-small-fixes`) builds on to expose "Muted" as a
380 reachable state rather than adding a new endpoint.
381
382- [ ] **Step 1: Write the failing tests**
383
384Add to `internal/httpd/accounts_test.go` (create the file if repo pin/watch
385tests do not already live somewhere; check with
386`grep -rln "pinToggle\|watchToggle" internal/httpd/*_test.go` first and
387add beside whatever that finds):
388
389```go
390package httpd
391
392import (
393 "net/http/httptest"
394 "testing"
395
396 "gitbay.org/gitbay/internal/config"
397 "gitbay.org/gitbay/internal/store"
398)
399
400// Pinning writes through the repo pin command, not the store directly,
401// so it carries the same audit trail and write budget as every other
402// mutating command (#261).
403func TestPinToggleDispatchesRepoPin(t *testing.T) {
404 st, err := store.Open(":memory:")
405 if err != nil {
406 t.Fatal(err)
407 }
408 defer st.Close()
409 if err := st.MigrateUp(); err != nil {
410 t.Fatal(err)
411 }
412 uid, err := st.CreateUser("alice", false)
413 if err != nil {
414 t.Fatal(err)
415 }
416 u := store.User{ID: uid, Username: "alice"}
417 if _, err := st.CreateRepo("user", uid, "app", "public"); err != nil {
418 t.Fatal(err)
419 }
420
421 s := New(config.Default(), st)
422 req := httptest.NewRequest("POST", "/alice/app/pin", nil)
423 req.SetPathValue("owner", "alice")
424 req.SetPathValue("repo", "app")
425 rr := httptest.NewRecorder()
426 s.pinToggle(rr, req, u)
427
428 repo, err := st.RepoByPath("alice/app")
429 if err != nil {
430 t.Fatal(err)
431 }
432 if !st.IsPinned(uid, repo.ID) {
433 t.Fatal("pin did not take effect")
434 }
435
436 rr2 := httptest.NewRecorder()
437 s.pinToggle(rr2, req, u)
438 if st.IsPinned(uid, repo.ID) {
439 t.Fatal("second toggle should have unpinned")
440 }
441}
442
443// The watch button cycles default, watching, muted — the three states
444// repo watch/repo mute/repo unwatch already support — rather than the
445// two the store-writing version offered (#261, #271).
446func TestWatchToggleCyclesThroughMuted(t *testing.T) {
447 st, err := store.Open(":memory:")
448 if err != nil {
449 t.Fatal(err)
450 }
451 defer st.Close()
452 if err := st.MigrateUp(); err != nil {
453 t.Fatal(err)
454 }
455 uid, err := st.CreateUser("alice", false)
456 if err != nil {
457 t.Fatal(err)
458 }
459 u := store.User{ID: uid, Username: "alice"}
460 if _, err := st.CreateRepo("user", uid, "app", "public"); err != nil {
461 t.Fatal(err)
462 }
463 repo, err := st.RepoByPath("alice/app")
464 if err != nil {
465 t.Fatal(err)
466 }
467
468 s := New(config.Default(), st)
469 req := httptest.NewRequest("POST", "/alice/app/watch", nil)
470 req.SetPathValue("owner", "alice")
471 req.SetPathValue("repo", "app")
472
473 click := func() string {
474 rr := httptest.NewRecorder()
475 s.watchToggle(rr, req, u)
476 return st.RepoWatchState(repo.ID, uid)
477 }
478 if got := click(); got != "watching" {
479 t.Fatalf("first click: got %q, want watching", got)
480 }
481 if got := click(); got != "muted" {
482 t.Fatalf("second click: got %q, want muted", got)
483 }
484 if got := click(); got != "" {
485 t.Fatalf("third click: got %q, want default (unwatched)", got)
486 }
487}
488```
489
490(Check `CreateRepo`'s exact signature with
491`grep -n "func (s \*Store) CreateRepo" internal/store/*.go` before
492using it — adjust argument order/names to match if it differs from the
493guess above.)
494
495- [ ] **Step 2: Run and see them fail**
496
497Run: `go test ./internal/httpd -run 'TestPinToggleDispatchesRepoPin|TestWatchToggleCyclesThroughMuted' -count=1`
498Expected: FAIL — pin toggles once but not twice cleanly is unlikely to
499be the failure; more likely the watch test fails because today's
500`watchToggle` only ever sets `"watching"` or clears it, never `"muted"`.
501
502- [ ] **Step 3: Fix `pinToggle`**
503
504In `internal/httpd/accounts.go`, replace:
505
506```go
507// pinToggle pins or unpins the repo for the logged-in viewer.
508func (s *Server) pinToggle(w http.ResponseWriter, r *http.Request, u store.User) {
509 repo, ok := s.repoForUser(w, r, u, policy.CanRead)
510 if !ok {
511 return
512 }
513 if s.st.IsPinned(u.ID, repo.ID) {
514 s.st.UnpinRepo(u.ID, repo.ID)
515 } else {
516 s.st.PinRepo(u.ID, repo.ID)
517 }
518 http.Redirect(w, r, "/"+repo.Path(), http.StatusSeeOther)
519}
520```
521
522with:
523
524```go
525// pinToggle pins or unpins the repo for the logged-in viewer, through
526// repo pin/repo unpin — the same commands the CLI runs — rather than
527// writing the store directly (#261).
528func (s *Server) pinToggle(w http.ResponseWriter, r *http.Request, u store.User) {
529 repo, ok := s.repoForUser(w, r, u, policy.CanRead)
530 if !ok {
531 return
532 }
533 verb := "pin"
534 if s.st.IsPinned(u.ID, repo.ID) {
535 verb = "unpin"
536 }
537 if _, msg, ok := s.runControl(u, []string{"repo", verb, repo.Path()}); !ok {
538 s.setFlash(w, msg)
539 }
540 http.Redirect(w, r, "/"+repo.Path(), http.StatusSeeOther)
541}
542```
543
544- [ ] **Step 4: Fix `watchToggle`**
545
546In `internal/httpd/notifyweb.go`, replace:
547
548```go
549// watchToggle turns watching a repository on and off from its header,
550// the way the pin button does.
551func (s *Server) watchToggle(w http.ResponseWriter, r *http.Request, u store.User) {
552 repo, ok := s.repoForUser(w, r, u, policy.CanRead)
553 if !ok {
554 return
555 }
556 if s.st.RepoWatchState(repo.ID, u.ID) == "watching" {
557 s.st.ClearRepoWatch(repo.ID, u.ID)
558 } else {
559 s.st.SetRepoWatch(repo.ID, u.ID, "watching")
560 }
561 http.Redirect(w, r, "/"+repo.Path(), http.StatusSeeOther)
562}
563```
564
565with:
566
567```go
568// watchToggle cycles the viewer's watch state on a repository: default,
569// watching, muted, back to default — through repo watch/repo mute/repo
570// unwatch, the same commands the CLI runs (#261, #271).
571func (s *Server) watchToggle(w http.ResponseWriter, r *http.Request, u store.User) {
572 repo, ok := s.repoForUser(w, r, u, policy.CanRead)
573 if !ok {
574 return
575 }
576 next := map[string]string{"": "watch", "watching": "mute", "muted": "unwatch"}
577 verb := next[s.st.RepoWatchState(repo.ID, u.ID)]
578 if _, msg, ok := s.runControl(u, []string{"repo", verb, repo.Path()}); !ok {
579 s.setFlash(w, msg)
580 }
581 http.Redirect(w, r, "/"+repo.Path(), http.StatusSeeOther)
582}
583```
584
585- [ ] **Step 5: Run**
586
587Run: `go test ./internal/httpd -run 'TestPinToggleDispatchesRepoPin|TestWatchToggleCyclesThroughMuted' -count=1 && go test ./internal/httpd -count=1`
588Expected: PASS. If an existing test asserted the old two-state watch
589behaviour, update its expectation to the three-state cycle rather than
590reverting the fix.
591
592- [ ] **Step 6: Commit**
593
594```bash
595git add internal/httpd/accounts.go internal/httpd/notifyweb.go internal/httpd/accounts_test.go
596git commit -m "web: pin and watch toggles dispatch through repo pin/watch/mute/unwatch" -m "Ref #261"
597```
598
599### Task 1.3: Cache-Control: no-store on the login-link consuming request
600
601**Files:**
602- Modify: `internal/httpd/accounts.go:123-157` (`login`)
603- Test: `internal/httpd/logincookie_test.go` (add beside its existing
604 login tests)
605
606- [ ] **Step 1: Write the failing test**
607
608Add to `internal/httpd/logincookie_test.go`:
609
610```go
611// The login link's token rides in the query string — the one
612// documented exception to "never in a URL" — so the response that
613// consumes it must never be cached by an intermediary that might log
614// or replay the URL (#261).
615func TestLoginNoStoreHeader(t *testing.T) {
616 st, err := store.Open(":memory:")
617 if err != nil {
618 t.Fatal(err)
619 }
620 defer st.Close()
621 if err := st.MigrateUp(); err != nil {
622 t.Fatal(err)
623 }
624 s := New(config.Default(), st)
625 rr := httptest.NewRecorder()
626 req := httptest.NewRequest("GET", "/login?token=bogus", nil)
627 s.login(rr, req)
628 if got := rr.Header().Get("Cache-Control"); got != "no-store" {
629 t.Errorf("Cache-Control = %q, want no-store", got)
630 }
631}
632```
633
634Check the file's existing imports (`config`, `store`, `httptest`, `testing`)
635before adding — they are almost certainly already present given the
636file already tests `/login`.
637
638- [ ] **Step 2: Run and see it fail**
639
640Run: `go test ./internal/httpd -run TestLoginNoStoreHeader -count=1`
641Expected: FAIL (`Cache-Control` header absent).
642
643- [ ] **Step 3: Set the header**
644
645In `internal/httpd/accounts.go`, at the top of `login`:
646
647```go
648func (s *Server) login(w http.ResponseWriter, r *http.Request) {
649 // token, when present, is a single-use secret in the query string —
650 // the documented exception to "never in a URL" (Threat-Model). No
651 // cache may keep a copy of this response.
652 w.Header().Set("Cache-Control", "no-store")
653 token := r.URL.Query().Get("token")
654```
655
656- [ ] **Step 4: Run**
657
658Run: `go test ./internal/httpd -run TestLoginNoStoreHeader -count=1 && go test ./internal/httpd -count=1`
659Expected: PASS.
660
661- [ ] **Step 5: Commit**
662
663```bash
664git add internal/httpd/accounts.go internal/httpd/logincookie_test.go
665git commit -m "web: Cache-Control: no-store on the login-link request" -m "Ref #261"
666```
667
668### Task 1.4: doc drift — API.org, Parity.org, Threat-Model.org
669
670**Files:**
671- Modify: `.gitbay/wiki/API.org` (the token-refusal line, near "Git
672 transport commands and the token commands are refused by name.")
673- Modify: `.gitbay/wiki/Parity.org` (the "Batched review is not built."
674 line; the watch/pin dispatch paragraph at lines 249-251)
675- Modify: `.gitbay/wiki/Threat-Model.org` (the "never as command
676 arguments... or query strings" line)
677
678No test — these are prose fixes; CI has no wiki-content check beyond
679what already exists (link and page-name tests in
680`internal/httpd/wiki_test.go`, untouched by this task).
681
682- [ ] **Step 1: Fix API.org's incorrect claim about token commands**
683
684The 401 message (`internal/httpd/api.go:131`, unchanged by this task)
685already reads `missing bearer token; mint one over SSH: token create
686--name <n>` — it does not say token commands are refused on the API,
687because they are not: `token create`/`token list`/`token revoke` all
688dispatch normally through `POST /api/v1/cmd` like any other command
689(minting a token from a token is exactly what a full-scope token can do,
690per `#234`/the "one registry" rule). Replace the false claim:
691
692```
693Commands that emit raw text rather than an envelope (=help=, =mr diff=)
694come wrapped as ={"output": "..."}=. Git transport commands are refused
695by name; the token commands are not — a full-scope token can mint,
696list and revoke tokens the same way it can run anything else.
697```
698
699(Replaces the sentence "Git transport commands and the token commands
700are refused by name.")
701
702- [ ] **Step 2: Fix Parity.org's stale "batched review" claim**
703
704Find (near "view. Batched review is not built."):
705
706```
707=mr range-diff= compares two heads: the iOS client
708shows it from a revision to the one before, as text; the web has no
709view. Batched review is not built.
710```
711
712`mr comment --pending`/`--discard` and `PublishPendingComments`
713(`internal/control/mr.go:161-162,1044,1052`) are the batched-review
714mechanism, and the web's diff-comment form already composes pending
715comments before publishing (`internal/httpd/mractions.go`'s
716`mrDiffCommentSubmit`, unchanged by this task — confirm with
717`grep -n "diff-comment\|PendingComments" internal/httpd/*.go`). Replace:
718
719```
720=mr range-diff= compares two heads: the iOS client shows it from a
721revision to the one before, as text; the web renders the same view
722(krz/gitbay#269). Batched review — draft diff comments held with
723=mr comment --pending= and sent together with =--comment=/=--discard=
724or a verdict — is built and the web uses it: composing a review comments
725before publishing them is the same round trip as the CLI's =--pending=
726flag.
727```
728
729Leave the `mr range-diff` web-view claim as `krz/gitbay#269` for now;
730MR 3 of this plan (`web-mr-range-diff`) lands the actual view and
731updates this sentence again to drop the issue reference — do not
732pre-empt that here, since this task's branch may merge before or after
733MR 3 and the wiki text must describe what is actually deployed at each
734point. (If MR 3 has already merged when this task is done, skip the
735issue-reference wording and write the view as already existing instead;
736check `ls internal/web/templates/mrrangediff.html` first.)
737
738- [ ] **Step 3: Fix the pin/watch dispatch paragraph**
739
740Find (lines 249-251):
741
742```
743The web's watch and pin controls write the store directly instead of
744dispatching =repo watch= and =repo pin=. That is why the web cannot
745mute: its toggle knows watching and default only.
746```
747
748Replace:
749
750```
751The web's watch and pin controls dispatch =repo pin=/=repo unpin= and
752=repo watch=/=repo mute=/=repo unwatch=, the same commands the CLI runs
753(krz/gitbay#261). The single watch button cycles default, watching and
754muted.
755```
756
757And update the `mute` row in the capability table (around line 189)
758from:
759
760```
761| mute | yes | no | yes |
762```
763
764to:
765
766```
767| mute | yes | yes | yes |
768```
769
770- [ ] **Step 4: Fix Threat-Model.org's "never in a URL" claim**
771
772Find (in "What gitbay never does"):
773
774```
775- *Put secrets in argv, URLs, or logs.* Import and mirror credentials,
776 registration invites, and API tokens travel on stdin or in request
777 bodies, never as command arguments (visible in =/proc=) or query
778 strings. Tokens are stored only as SHA-256 hashes.
779```
780
781Replace with (documenting the one deliberate exception and its
782mitigations from Task 1.3):
783
784```
785- *Put secrets in argv, URLs, or logs, with one documented exception.*
786 Import and mirror credentials, registration invites, and API tokens
787 travel on stdin or in request bodies, never as command arguments
788 (visible in =/proc=) or query strings. The one exception is the
789 emailed login link, =/login?token=...=: single-use, 15-minute expiry,
790 and the response that consumes it carries =Cache-Control: no-store= so
791 no intermediary keeps a copy. An operator running gitbay behind a
792 reverse proxy should configure that proxy to strip the query string
793 from its own access logs. Tokens are stored only as SHA-256 hashes.
794```
795
796- [ ] **Step 5: Commit**
797
798```bash
799git add .gitbay/wiki/API.org .gitbay/wiki/Parity.org .gitbay/wiki/Threat-Model.org
800git commit -m "wiki: fix API token-refusal claim, batched-review status, watch/pin dispatch, login-link URL exception" -m "Ref #261"
801```
802
803### Task 1.5: open MR 1
804
805- [ ] **Step 1: Push and open the MR**
806
807```bash
808git push -u origin web-audit-fixes
809gitbay mr create --source web-audit-fixes --target main --title "Architecture review small fixes: FK check, web toggles, doc drift"
810```
811
812- [ ] **Step 2: Wait for CI, merge, delete the branch**
813
814```bash
815gitbay mr merge <n> --strategy ff
816git branch -d web-audit-fixes
817git push origin --delete web-audit-fixes
818```
819
820The last commit in this branch (Task 1.4's) should be amended in message
821only if not already — reference `Closes #261` there instead of `Ref
822#261` before pushing, since this MR closes the issue in full.
823
824---
825
826# MR 2: settings page quotes working commands (branch `web-settings-commands`)
827
828Closes #263.
829
830### Task 2.1: fix the two known-wrong quoted commands
831
832**Files:**
833- Modify: `internal/web/templates/account.html:186,199-200`
834
835- [ ] **Step 1: Fix the `whoami` line**
836
837Find (`account.html:199-200`):
838
839```html
840<p class="meta">All of it works from stock OpenSSH too:
841<code>ssh git@{{.Host}} auth whoami</code>.</p>
842```
843
844`auth` is a CLI-only grouping (`cmd/gitbay/main.go`'s `authCmd`); the
845server command is `whoami` (`internal/control/identity.go:18`). Replace:
846
847```html
848<p class="meta">All of it works from stock OpenSSH too:
849<code>ssh git@{{.Host}} whoami</code>.</p>
850```
851
852- [ ] **Step 2: Show both forms for the token line**
853
854Find (`account.html:194-197`):
855
856```html
857<pre class="message" tabindex="0">gitbay auth token create --name laptop # API tokens
858gitbay web sessions list # browser sessions
859gitbay admin ... # instance administration</pre>
860```
861
862The CLI form (`gitbay auth token create ...`) and the literal stock-SSH
863form (`ssh git@host token create ...`) differ because `token` is nested
864under the CLI-only `auth` group but is a top-level server command
865(`internal/control/token.go`). Replace the pre block and the sentence
866after it:
867
868```html
869<pre class="message" tabindex="0">gitbay auth token create --name laptop # API tokens
870gitbay web sessions list # browser sessions
871gitbay admin ... # instance administration</pre>
872<p class="meta">All of it works from stock OpenSSH too, with the CLI's
873grouping words dropped: <code>ssh git@{{.Host}} whoami</code>,
874<code>ssh git@{{.Host}} token create --name laptop</code>.</p>
875```
876
877(This merges the "works from stock OpenSSH" sentence that Step 1 edited
878with the new token example, so it appears once rather than twice —
879remove the now-duplicate sentence Step 1 produced and keep this single
880combined one instead. After this step, the section reads: the `<pre>`
881block, then one `<p class="meta">` with both stock-SSH examples.)
882
883- [ ] **Step 3: Fix `gitbay account export`**
884
885Find (`account.html:186`, in the Export section):
886
887```html
888<p class="meta">Your profile, repositories, issues and merge requests as one
889JSON bundle, the same one <code>gitbay account export</code> writes. Keys are
890never included; a replayed bundle's emails arrive unverified.</p>
891```
892
893`account` is not a real top-level CLI command — the real path is `auth
894export` (`cmd/gitbay/main.go:471`, nested under `authCmd`), as
895`privacy.html:20` already correctly says. Replace:
896
897```html
898<p class="meta">Your profile, repositories, issues and merge requests as one
899JSON bundle, the same one <code>gitbay auth export</code> writes. Keys are
900never included; a replayed bundle's emails arrive unverified.</p>
901```
902
903- [ ] **Step 4: Commit (folded into Task 2.3, which adds the test these
904 fixes make pass — do not commit yet; Task 2.2 and 2.3 come first so
905 the fixes and their proof land together)**
906
907Skip committing here; continue to Task 2.2.
908
909### Task 2.2: auth summary and help — done in the CLI UX plan
910
911The auth summary ("whoami, SSH and PGP keys, email, API tokens") and
912the registry-layout `gitbay auth --help` are Task 2.3 of
913`docs/plans/2026-09-27-cli-ux.md` (MR `cli-ux-help`, Ref #267), which
914adds `nounAliases` to `internal/control/help.go` so `help auth` renders
915in one pass. Land `cli-ux-help` before this MR; nothing to do here.
916Check after rebasing: `go run ./cmd/gitbay auth --help` lists email and
917token commands.
918
919### Task 2.3: a test that runs every quoted command through the registry
920
921**Files:**
922- Create: `cmd/gitbay/templatecmds_test.go`
923
924**Interfaces:**
925- Consumes: `newRoot()` (`cmd/gitbay/main.go:30`, unexported — this test
926 must live in package `main`), `control.Lookup`
927 (`internal/control/control.go:102`), `web.Pages`/`web.TemplateSource`
928 (`internal/web/web.go:271,277`).
929
930- [ ] **Step 1: Write the test**
931
932```go
933package main
934
935import (
936 "regexp"
937 "strings"
938 "testing"
939
940 "gitbay.org/gitbay/internal/control"
941 "gitbay.org/gitbay/internal/web"
942)
943
944// quotedRe finds the two shapes a command appears in on a page: inline
945// in <code>, or one per line in a <pre class="quickstart"> quickstart
946// block. Both need (?s) so a multi-line <pre> is captured as one match.
947var quotedRe = regexp.MustCompile(`(?s)<code>(.*?)</code>|<pre class="quickstart"[^>]*>(.*?)</pre>`)
948
949// commandArgv reads the literal words at the front of a quoted command
950// line — the part naming the command rather than its arguments — and
951// stops at the first flag, template action, or literal ellipsis, since
952// those mark the boundary between "what command" and "what argument".
953func commandArgv(rest string) []string {
954 var argv []string
955 for _, tok := range strings.Fields(rest) {
956 if strings.HasPrefix(tok, "-") || strings.Contains(tok, "{{") || strings.Contains(tok, "...") {
957 break
958 }
959 argv = append(argv, tok)
960 }
961 return argv
962}
963
964// TestTemplateQuotedCommandsResolve runs every command quoted in a web
965// template through the same registry the server uses, so a renamed
966// command fails CI instead of shipping a dead instruction (#263).
967//
968// A line starting "gitbay " is checked against the CLI's own command
969// tree with cobra's Find, since the CLI's grouping words (like "auth")
970// are not part of the server's argv. A line starting "ssh git@{{.Host}}
971// " is checked directly against control.Lookup, since that is exactly
972// the argv the server receives.
973func TestTemplateQuotedCommandsResolve(t *testing.T) {
974 root := newRoot()
975 for _, name := range web.Pages() {
976 src, err := web.TemplateSource(name)
977 if err != nil {
978 t.Fatalf("%s: %v", name, err)
979 }
980 for _, m := range quotedRe.FindAllStringSubmatch(src, -1) {
981 block := m[1] + m[2]
982 for _, line := range strings.Split(block, "\n") {
983 if i := strings.Index(line, "#"); i >= 0 {
984 line = line[:i]
985 }
986 line = strings.TrimSpace(line)
987 switch {
988 case strings.HasPrefix(line, "gitbay "):
989 argv := commandArgv(strings.TrimPrefix(line, "gitbay "))
990 if len(argv) == 0 {
991 continue
992 }
993 found, _, err := root.Find(argv)
994 if err != nil || found == root {
995 t.Errorf("%s: %q: gitbay %s does not resolve (%v)", name, line, strings.Join(argv, " "), err)
996 }
997 case strings.HasPrefix(line, "ssh git@{{.Host}} "):
998 argv := commandArgv(strings.TrimPrefix(line, "ssh git@{{.Host}} "))
999 if len(argv) == 0 {
1000 continue
1001 }
1002 if _, _, ok := control.Lookup(argv); !ok {
1003 t.Errorf("%s: %q: %s is not in the control registry", name, line, strings.Join(argv, " "))
1004 }
1005 }
1006 }
1007 }
1008 }
1009}
1010```
1011
1012- [ ] **Step 2: Run and see it fail on the two known bugs**
1013
1014Run: `go test ./cmd/gitbay -run TestTemplateQuotedCommandsResolve -count=1`
1015Expected: FAIL on `account.html`'s `ssh git@{{.Host}} auth whoami` (not
1016in the registry — `auth` is not a server path) and `gitbay account
1017export` (not a real CLI path — the top-level command is `auth`, not
1018`account`), unless Task 2.1's edits are already applied (do Task 2.1 and
1019Task 2.2 first if not already committed, then this test should already
1020pass on those — if it still fails, the fixes in Task 2.1 or 2.2 are
1021incomplete).
1022
1023- [ ] **Step 3: Confirm it passes with Tasks 2.1 and 2.2 applied**
1024
1025Run: `go test ./cmd/gitbay -run TestTemplateQuotedCommandsResolve -count=1`
1026Expected: PASS. If it fails on a *different* template than
1027`account.html`, that is a genuine additional bug this test caught —
1028fix the template's text the same way (correct the command to what
1029`control.Lookup`/cobra's tree actually accepts), do not weaken the test.
1030As of this plan being written, every other quoted command in the
1031templates (`admin.html`, `adminusers.html`, `issues.html`, `landing.html`,
1032`login.html`, `mrs.html`, `mrnew.html`, `privacy.html`, `registered.html`,
1033plus the ones this task edits) was checked by hand against
1034`cmd/gitbay/main.go` and `internal/control/*.go` and resolves correctly —
1035see the research notes in this plan's Order section — but the test is
1036the source of truth, not that hand check.
1037
1038- [ ] **Step 4: Run the full package**
1039
1040Run: `go build ./... && go vet ./... && go test ./cmd/gitbay -count=1`
1041Expected: PASS.
1042
1043- [ ] **Step 5: Commit everything for this MR**
1044
1045```bash
1046git add internal/web/templates/account.html cmd/gitbay/main.go cmd/gitbay/templatecmds_test.go
1047git commit -m "web, cli: fix two dead quoted commands; test every quoted command against the registry" -m "Closes #263"
1048```
1049
1050### Task 2.4: open MR 2
1051
1052```bash
1053git push -u origin web-settings-commands
1054gitbay mr create --source web-settings-commands --target main --title "Settings page: working stock-OpenSSH commands, registry-checked"
1055```
1056
1057Wait for CI, `gitbay mr merge <n> --strategy ff`, delete the branch both
1058places.
1059
1060---
1061
1062# MR 3: MR range-diff page (branch `web-mr-range-diff`)
1063
1064Closes #269.
1065
1066### Task 3.1: `/{owner}/{repo}/mrs/{n}/range-diff`
1067
1068**Files:**
1069- Create: `internal/httpd/mrrangediff.go`
1070- Create: `internal/web/templates/mrrangediff.html`
1071- Modify: `internal/httpd/routes.go` (add the route beside
1072 `/{owner}/{repo}/mrs/{n}` at line 102, in the always-registered block)
1073- Modify: `internal/web/web_test.go` (`TestMainWidthClass`'s `wide` map)
1074- Test: `internal/httpd/mrrangediff_test.go`
1075
1076**Interfaces:**
1077- Consumes: `mrArgs` (`internal/httpd/mractions.go:37`), `s.repoFor`,
1078 `s.runControlCode`, `s.webViewer`.
1079- Produces: `func (s *Server) mrRangeDiff(w http.ResponseWriter, r *http.Request)`
1080
1081- [ ] **Step 1: Write the failing test**
1082
1083```go
1084package httpd
1085
1086import (
1087 "net/http/httptest"
1088 "strings"
1089 "testing"
1090
1091 "gitbay.org/gitbay/internal/config"
1092 "gitbay.org/gitbay/internal/store"
1093)
1094
1095// The range-diff page dispatches mr range-diff and renders its text
1096// output, the same comparison the CLI and iOS already show (#269).
1097func TestMRRangeDiffPageRendersCommandOutput(t *testing.T) {
1098 st, err := store.Open(":memory:")
1099 if err != nil {
1100 t.Fatal(err)
1101 }
1102 defer st.Close()
1103 if err := st.MigrateUp(); err != nil {
1104 t.Fatal(err)
1105 }
1106 uid, err := st.CreateUser("alice", false)
1107 if err != nil {
1108 t.Fatal(err)
1109 }
1110 u := store.User{ID: uid, Username: "alice"}
1111 if _, err := st.CreateRepo("user", uid, "app", "public"); err != nil {
1112 t.Fatal(err)
1113 }
1114
1115 s := New(config.Default(), st)
1116 req := httptest.NewRequest("GET", "/alice/app/mrs/1/range-diff", nil)
1117 req.SetPathValue("owner", "alice")
1118 req.SetPathValue("repo", "app")
1119 req.SetPathValue("n", "1")
1120 rr := httptest.NewRecorder()
1121 s.mrRangeDiff(rr, req)
1122
1123 // No merge request 1 exists yet, so this must 404 rather than error.
1124 if rr.Code != 404 {
1125 t.Fatalf("status %d, body %s", rr.Code, rr.Body.String())
1126 }
1127 _ = strings.TrimSpace // placeholder import use removed once a real MR fixture is added below
1128}
1129```
1130
1131(`CreateRepo`'s signature: confirm with
1132`grep -n "func (s \*Store) CreateRepo" internal/store/*.go` and adjust
1133the call above to match — the guess here follows the shape used
1134elsewhere in this plan's other tests.)
1135
1136- [ ] **Step 2: Run and see it fail to compile**
1137
1138Run: `go test ./internal/httpd -run TestMRRangeDiffPageRendersCommandOutput -count=1`
1139Expected: FAIL to compile (`s.mrRangeDiff` undefined).
1140
1141- [ ] **Step 3: Write the handler**
1142
1143Create `internal/httpd/mrrangediff.go`:
1144
1145```go
1146package httpd
1147
1148import (
1149 "net/http"
1150 "strconv"
1151
1152 "gitbay.org/gitbay/internal/protocol"
1153 "gitbay.org/gitbay/internal/store"
1154)
1155
1156// mrRangeDiff renders what changed between two revisions of a merge
1157// request — the same comparison `mr range-diff` prints on the CLI and
1158// the iOS app already show — so a reviewer whose approval a force-push
1159// staled can see what moved without leaving the browser (#269).
1160func (s *Server) mrRangeDiff(w http.ResponseWriter, r *http.Request) {
1161 p, ok := s.repoFor(w, r, "")
1162 if !ok {
1163 return
1164 }
1165 p.Tab = "merge requests"
1166 n, err := strconv.ParseInt(r.PathValue("n"), 10, 64)
1167 if err != nil {
1168 s.notFound(w, r)
1169 return
1170 }
1171 m, err := s.st.MRByNumber(p.Repo.ID, n)
1172 if err != nil {
1173 s.notFound(w, r)
1174 return
1175 }
1176
1177 viewer := s.webViewer(r)
1178 argv := mrArgs(r, "range-diff")
1179 if from := r.URL.Query().Get("from"); from != "" {
1180 argv = append(argv, "--from", from)
1181 }
1182 if to := r.URL.Query().Get("to"); to != "" {
1183 argv = append(argv, "--to", to)
1184 }
1185 out, msg, code := s.runControlCode(viewer, argv)
1186 if code == protocol.ExitNotFound {
1187 s.notFound(w, r)
1188 return
1189 }
1190 errMsg := ""
1191 if code != protocol.ExitOK {
1192 errMsg = msg
1193 }
1194 s.render(w, "mrrangediff.html", struct {
1195 repoPage
1196 MR store.MR
1197 Diff string
1198 Error string
1199 }{p, m, out, errMsg})
1200}
1201```
1202
1203- [ ] **Step 4: Write the template**
1204
1205Create `internal/web/templates/mrrangediff.html`:
1206
1207```html
1208{{define "width"}}wide{{end}}
1209{{define "title"}}range-diff · !{{.MR.Number}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
1210{{define "content"}}
1211<h1>Range-diff <span class="issuenumber">!{{.MR.Number}}</span></h1>
1212<p class="meta"><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/mrs/{{.MR.Number}}">back to !{{.MR.Number}} {{.MR.Title}}</a></p>
1213{{if .Error}}<p class="error" role="alert">{{.Error}}</p>
1214{{else if .Diff}}<pre class="code buildlog" tabindex="0">{{.Diff}}</pre>
1215{{else}}<p class="empty-note">Nothing to compare: this merge request has one revision.</p>{{end}}
1216{{end}}
1217```
1218
1219- [ ] **Step 5: Register the route**
1220
1221In `internal/httpd/routes.go`, right after the existing
1222`/{owner}/{repo}/mrs/{n}` route (line 102, in the block registered
1223regardless of `web.mode`, so range-diff reads the same way the MR page
1224itself does — anonymously on a public repository):
1225
1226```go
1227 Route{Method: "GET", Pattern: "/{owner}/{repo}/mrs/{n}", Handler: s.mr},
1228 Route{Method: "GET", Pattern: "/{owner}/{repo}/mrs/{n}/range-diff", Handler: s.mrRangeDiff},
1229```
1230
1231- [ ] **Step 6: Add the width-map row**
1232
1233In `internal/web/web_test.go`, `TestMainWidthClass`, add
1234`"mrrangediff.html": true` to the `wide` map (alongside `"mrs.html"` and
1235`"build.html"`, which it resembles).
1236
1237- [ ] **Step 7: Run**
1238
1239Run: `go build ./... && go test ./internal/httpd -run TestMRRangeDiffPageRendersCommandOutput -count=1 && go test ./internal/web -run TestMainWidthClass -count=1`
1240Expected: PASS.
1241
1242- [ ] **Step 8: Commit**
1243
1244```bash
1245git add internal/httpd/mrrangediff.go internal/httpd/mrrangediff_test.go internal/httpd/routes.go internal/web/templates/mrrangediff.html internal/web/web_test.go
1246git commit -m "web: range-diff page for a merge request's revisions" -m "Ref #269"
1247```
1248
1249### Task 3.2: revisions list with a "compare to previous" link per row
1250
1251**Files:**
1252- Modify: `internal/web/templates/mr.html:136-138`
1253- Test: `internal/httpd/mrpage_test.go`
1254
1255- [ ] **Step 1: Write the failing test**
1256
1257Add to `internal/httpd/mrpage_test.go`:
1258
1259```go
1260// Each revision after the first carries a link comparing it to the one
1261// before, so a reviewer does not have to type mr range-diff by hand
1262// (#269).
1263func TestMRPageListsRevisionsWithCompareLinks(t *testing.T) {
1264 var sb strings.Builder
1265 revs := []store.MRHead{
1266 {SHA: "aaaa1111", CreatedAt: "2026-09-23T10:00:00Z"},
1267 {SHA: "bbbb2222", CreatedAt: "2026-09-24T10:00:00Z"},
1268 }
1269 if err := web.Render(&sb, "mr.html", mrPageData{
1270 repoPage: testRepoPage(), MR: testMR("open"), View: "conversation", Revisions: revs,
1271 }); err != nil {
1272 t.Fatalf("render: %v", err)
1273 }
1274 out := sb.String()
1275 for _, want := range []string{"aaaa1111", "bbbb2222", "compare to previous", "from=aaaa1111", "to=bbbb2222"} {
1276 if !strings.Contains(out, want) {
1277 t.Errorf("missing %q in:\n%s", want, out)
1278 }
1279 }
1280 if strings.Contains(out, "gitbay mr range-diff") {
1281 t.Error("still quotes the CLI command instead of linking the new page")
1282 }
1283}
1284```
1285
1286- [ ] **Step 2: Run and see it fail**
1287
1288Run: `go test ./internal/httpd -run TestMRPageListsRevisionsWithCompareLinks -count=1`
1289Expected: FAIL (no "compare to previous" text yet).
1290
1291- [ ] **Step 3: Replace the one-liner with a revisions list**
1292
1293Find (`mr.html:136-138`):
1294
1295```html
1296 {{if gt (len .Revisions) 1}}<p class="row none">{{len .Revisions}} revisions pushed. What changed between the last two:
1297 <code>gitbay mr range-diff {{.Repo.OwnerName}}/{{.Repo.Name}} {{.MR.Number}}</code></p>{{end}}
1298 </div>
1299```
1300
1301Replace with:
1302
1303```html
1304 </div>
1305 {{if .Revisions}}<div class="grp">
1306 <h2>Revisions</h2>
1307 {{range $i, $rv := .Revisions}}<p class="row none">{{add $i 1}}. <code>{{short $rv.SHA}}</code> {{when $rv.CreatedAt}}{{if $i}} · <a href="{{$base}}/range-diff?from={{(index $.Revisions (sub $i 1)).SHA}}&to={{$rv.SHA}}">compare to previous</a>{{end}}</p>
1308 {{end}}
1309 </div>{{end}}
1310```
1311
1312(The closing `</div>` that used to end the Reviews `grp` stays where it
1313is — this adds a new sibling `grp` right after it, using `add`/`sub`,
1314already registered template funcs in `internal/web/web.go`.)
1315
1316- [ ] **Step 4: Run**
1317
1318Run: `go test ./internal/httpd -run TestMRPageListsRevisionsWithCompareLinks -count=1 && go test ./internal/httpd -count=1`
1319Expected: PASS.
1320
1321- [ ] **Step 5: Commit**
1322
1323```bash
1324git add internal/web/templates/mr.html internal/httpd/mrpage_test.go
1325git commit -m "web: list each MR revision with a compare-to-previous link" -m "Ref #269"
1326```
1327
1328### Task 3.3: update Parity
1329
1330**Files:**
1331- Modify: `.gitbay/wiki/Parity.org`
1332
1333- [ ] **Step 1: Fix the range-diff line**
1334
1335Find:
1336
1337```
1338=mr range-diff= compares two heads: the iOS client
1339shows it from a revision to the one before, as text; the web has no
1340view. Batched review is not built.
1341```
1342
1343(If MR 1's Task 1.4 already changed this sentence to reference
1344`krz/gitbay#269`, edit that version instead — the end state either way
1345is:)
1346
1347```
1348=mr range-diff= compares two heads: the iOS client shows it from a
1349revision to the one before, as text; the web renders the same view, with
1350a "compare to previous" link on each revision after the first. Batched
1351review — draft diff comments held with =mr comment --pending= and sent
1352together with =--comment=/=--discard= or a verdict — is built and the
1353web uses it.
1354```
1355
1356- [ ] **Step 2: Commit**
1357
1358```bash
1359git add .gitbay/wiki/Parity.org
1360git commit -m "wiki: Parity reflects the web range-diff view" -m "Closes #269"
1361```
1362
1363### Task 3.4: open MR 3
1364
1365```bash
1366git push -u origin web-mr-range-diff
1367gitbay mr create --source web-mr-range-diff --target main --title "Web: MR range-diff page"
1368```
1369
1370Wait for CI, merge (`--strategy ff`), delete the branch both places.
1371
1372---
1373
1374# MR 4: empty states and contribution hints sweep (branch `web-empty-states`)
1375
1376Closes #270.
1377
1378This MR is one register applied across templates. The table below is
1379the full set of strings this task changes — every empty-state or
1380contribution-hint string identified in the issue and confirmed against
1381the current template source. Implement exactly this table; do not
1382invent additional wording beyond it.
1383
1384| File:line | Current | New |
1385|---|---|---|
1386| `mrs.html:29` (no query) | `no {{if ne .State "all"}}{{.State}} {{end}}merge requests — open one with <code>gitbay mr create {{.Repo.OwnerName}}/{{.Repo.Name}} --source ... --target {{.Repo.DefaultBranch}}</code>` | `no {{if ne .State "all"}}{{.State}} {{end}}merge requests` (the create instruction moves to the new "New merge request"/fork/sign-in line — Task 4.2 — and is never a CLI command on the web) |
1387| `mrs.html:28` (search, no match) | `no {{if ne .State "all"}}{{.State}} {{end}}merge requests matching "{{.Query}}"` | unchanged (already follows the register: lower-case, no CLI command, states the fact) |
1388| `mr.html:136` (reviews, none) | `none yet` | unchanged — "none yet" is correct register for a section that can still gain entries (open MR); Task 4.1's rule is "no 'yet' on a *finished* item", and reviews on an open MR are not finished |
1389| `mr.html:143` (reviewers, none) | `nobody yet` | `no reviewers` (drops "yet" for consistency with the rest of the sweep's noun-first register even though the MR could still be open — "nobody yet" reads as a placeholder guess about who *will* review, which is not information the page has; "no reviewers" states the fact plainly, matching `dashboard.html`'s "No open merge requests") |
1390| `mr.html:174` (labels, none, on a merged/closed MR) | `none yet` | `no labels` when `.MR.State` is `merged` or `closed` (a finished item gets no "yet"); keep `none yet` when open |
1391| `builds.html:53` | `no builds — push a commit with a <code>.gitbay/ci.yml</code>` | `no builds` when the viewer cannot push (`.CanWrite` false or absent); `no builds — push a commit with a .gitbay/ci.yml` (drop `<code>`, since a filename is not a command) stays for a viewer who can push. Never plain "no builds" for a writer, since that leaves them without the one instruction the page can give them |
1392| `dashboard.html:29` | `Nothing pinned yet. Press Pin on a repository.` | `nothing pinned — press Pin on a repository you visit` |
1393| `dashboard.html:42` (`Empty` value for MRs queue) | `No open merge requests` | unchanged (already matches the register: capital first word is this partial's own convention — see Step 1 below — lower-case the whole sweep *within* `<li class="empty">` items, leave `queue` partial's own `Empty` string as-is since it is Title Case by that partial's design, confirmed by reading the `queue` template define before changing it) |
1394| `notifications.html:21` (all read) | `nothing here yet` | `no unread notifications` when `.All` is false is already separate; the `.All` branch (nothing at all, read or unread, in the *whole* inbox) becomes `nothing to show` |
1395| `notifications.html:21` (unread, default) | `nothing unread — <a>show all</a>` | `no unread notifications — <a href="/notifications?all=1">show all</a>` (already close; #265, a different plan, covers the CLI side of this exact wording — keep the two in step: `no unread notifications` matches what plan 6's CLI task sets for `notifications list`) |
1396| `globalsearch.html:47` | shown only when `.Query` is empty | shown always, as a permanent caption under the search input (Task 4.3) |
1397
1398- [ ] **Step 1: Read the `queue` partial before touching `dashboard.html`**
1399
1400Run: `grep -n '{{define "queue"}}' -A 15 internal/web/templates/dashboard.html`
1401Confirm whether its `Empty` value is rendered as given (in which case
1402`"No open merge requests"` stays capitalised by the caller's choice) or
1403lower-cased by the partial itself. Write down which, then leave that
1404line's casing exactly as the partial expects — do not change
1405`dashboard.html:42-43`'s `"Empty"` values in this task; the table above
1406already reflects "unchanged" for it.
1407
1408### Task 4.1: apply the table
1409
1410**Files:**
1411- Modify: `internal/web/templates/mrs.html:28-29`
1412- Modify: `internal/web/templates/mr.html:143,174`
1413- Modify: `internal/web/templates/builds.html:53`
1414- Modify: `internal/web/templates/dashboard.html:29`
1415- Modify: `internal/web/templates/notifications.html:21`
1416- Test: `internal/httpd/mrpage_test.go`, `internal/httpd/buildpages_test.go`
1417 (or wherever a `builds.html` render test already lives — check with
1418 `grep -rln '"builds.html"' internal/httpd/*_test.go`), a new or
1419 existing dashboard render test, a new or existing notifications test.
1420
1421- [ ] **Step 1: Write the failing tests**
1422
1423Add to `internal/httpd/mrpage_test.go`:
1424
1425```go
1426// A finished merge request states an empty label list as a fact, not a
1427// promise something is still coming (#270).
1428func TestMRPageLabelsNoYetOnFinishedState(t *testing.T) {
1429 var sb strings.Builder
1430 if err := web.Render(&sb, "mr.html", mrPageData{
1431 repoPage: testRepoPage(), MR: testMR("merged"), View: "conversation",
1432 }); err != nil {
1433 t.Fatalf("render: %v", err)
1434 }
1435 if !strings.Contains(sb.String(), "no labels") {
1436 t.Error(`merged MR with no labels should read "no labels", not "none yet"`)
1437 }
1438}
1439
1440func TestMRPageReviewersEmptyStateDropsNobody(t *testing.T) {
1441 var sb strings.Builder
1442 if err := web.Render(&sb, "mr.html", mrPageData{
1443 repoPage: testRepoPage(), MR: testMR("open"), View: "conversation",
1444 }); err != nil {
1445 t.Fatalf("render: %v", err)
1446 }
1447 if strings.Contains(sb.String(), "nobody yet") {
1448 t.Error(`reviewers empty state should read "no reviewers"`)
1449 }
1450 if !strings.Contains(sb.String(), "no reviewers") {
1451 t.Error(`missing "no reviewers"`)
1452 }
1453}
1454```
1455
1456Add to whichever file already renders `builds.html` (or create
1457`internal/httpd/buildslist_test.go` if none does; check first with the
1458grep in the Files list above):
1459
1460```go
1461// A writer sees the instruction to add CI; a reader without push access
1462// sees only the fact, since the instruction is not theirs to act on
1463// (#270).
1464func TestBuildsEmptyStateOmitsInstructionForReaders(t *testing.T) {
1465 var sb strings.Builder
1466 if err := web.Render(&sb, "builds.html", buildsPageData{
1467 repoPage: testRepoPage(), CanWrite: false,
1468 }); err != nil {
1469 t.Fatalf("render: %v", err)
1470 }
1471 out := sb.String()
1472 if !strings.Contains(out, "no builds") {
1473 t.Error(`missing "no builds"`)
1474 }
1475 if strings.Contains(out, "ci.yml") {
1476 t.Error("a reader without push access should not see the push instruction")
1477 }
1478}
1479```
1480
1481(`buildsPageData` may not exist as a named type the way `mrPageData`
1482does for `mr.html` — check
1483`grep -n '"builds.html"' internal/httpd/web.go` for the anonymous
1484struct `builds` (the list handler, not `build`, the single-build one)
1485renders with, and mirror its fields the way `mrPageData` mirrors
1486`mr.html`'s, the same pattern `mrpage_test.go` already uses.)
1487
1488- [ ] **Step 2: Run and see them fail**
1489
1490Run: `go test ./internal/httpd -run 'TestMRPageLabelsNoYetOnFinishedState|TestMRPageReviewersEmptyStateDropsNobody|TestBuildsEmptyStateOmitsInstructionForReaders' -count=1`
1491Expected: FAIL.
1492
1493- [ ] **Step 3: `mr.html` reviewers and labels**
1494
1495Find (`mr.html:143`):
1496
1497```html
1498 {{else}}<p class="none">nobody yet</p>{{end}}
1499```
1500
1501(in the Reviewers `grp`). Replace:
1502
1503```html
1504 {{else}}<p class="none">no reviewers</p>{{end}}
1505```
1506
1507Find (`mr.html:174`, in the Labels `grp`):
1508
1509```html
1510 {{else}}<p class="none">none yet</p>{{end}}
1511```
1512
1513Replace:
1514
1515```html
1516 {{else}}<p class="none">{{if or (eq .MR.State "merged") (eq .MR.State "closed")}}no labels{{else}}none yet{{end}}</p>{{end}}
1517```
1518
1519- [ ] **Step 4: `builds.html`**
1520
1521Read the current line first: `grep -n "no builds" internal/web/templates/builds.html`.
1522Replace it (adjust the exact surrounding markup to match what that grep
1523shows; the text change is):
1524
1525```html
1526{{else}}<li class="empty">no builds{{if .CanWrite}} — push a commit with a <code>.gitbay/ci.yml</code>{{end}}</li>{{end}}
1527```
1528
1529Check whether `builds.html`'s page struct already carries `CanWrite`
1530(`grep -n "CanWrite" internal/httpd/builds.go internal/web/templates/builds.html`);
1531if it does not, add it the way `mr.html`'s does
1532(`s.canWriteRepo(r, p.Repo)` in the handler, a new `CanWrite bool` field
1533in the render struct).
1534
1535- [ ] **Step 5: `dashboard.html`**
1536
1537Find (`dashboard.html:29`):
1538
1539```html
1540 {{else}}<p class="none">Nothing pinned yet. Press Pin on a repository.</p>{{end}}
1541```
1542
1543Replace:
1544
1545```html
1546 {{else}}<p class="none">nothing pinned — press Pin on a repository you visit</p>{{end}}
1547```
1548
1549- [ ] **Step 6: `notifications.html`**
1550
1551Find (`notifications.html:21`):
1552
1553```html
1554{{else}}<li class="empty">{{if .All}}nothing here yet{{else}}nothing unread — <a href="/notifications?all=1">show all</a>{{end}}</li>{{end}}
1555```
1556
1557Replace:
1558
1559```html
1560{{else}}<li class="empty">{{if .All}}nothing to show{{else}}no unread notifications — <a href="/notifications?all=1">show all</a>{{end}}</li>{{end}}
1561```
1562
1563- [ ] **Step 7: `mrs.html`**
1564
1565Find (`mrs.html:28-29`):
1566
1567```html
1568{{else}}{{if .Query}}<li class="empty">no {{if ne .State "all"}}{{.State}} {{end}}merge requests matching “{{.Query}}”</li>
1569{{else}}<li class="empty">no {{if ne .State "all"}}{{.State}} {{end}}merge requests — open one with <code>gitbay mr create {{.Repo.OwnerName}}/{{.Repo.Name}} --source ... --target {{.Repo.DefaultBranch}}</code></li>{{end}}{{end}}
1570```
1571
1572Replace (the create instruction moves to Task 4.2's contribution-hint
1573line, so the empty state itself states only the fact):
1574
1575```html
1576{{else}}{{if .Query}}<li class="empty">no {{if ne .State "all"}}{{.State}} {{end}}merge requests matching “{{.Query}}”</li>
1577{{else}}<li class="empty">no {{if ne .State "all"}}{{.State}} {{end}}merge requests</li>{{end}}{{end}}
1578```
1579
1580- [ ] **Step 8: Run**
1581
1582Run: `go test ./internal/httpd -run 'TestMRPageLabelsNoYetOnFinishedState|TestMRPageReviewersEmptyStateDropsNobody|TestBuildsEmptyStateOmitsInstructionForReaders' -count=1 && go test ./internal/httpd -count=1`
1583Expected: PASS. Fix any pre-existing test that asserted the old strings
1584("nobody yet", "none yet" on a merged MR's labels, "Nothing pinned yet",
1585"nothing here yet", "nothing unread", the old `mrs.html` CLI-command
1586text) to expect the new ones — these are exactly the tests this sweep
1587is supposed to change.
1588
1589- [ ] **Step 9: Commit**
1590
1591```bash
1592git add internal/web/templates/mr.html internal/web/templates/builds.html internal/web/templates/dashboard.html internal/web/templates/notifications.html internal/web/templates/mrs.html internal/httpd/mrpage_test.go internal/httpd/*_test.go
1593git commit -m "web: one empty-state register — no CLI commands, no 'yet' on finished items" -m "Ref #270"
1594```
1595
1596### Task 4.2: MR list contribution hint by access level
1597
1598**Files:**
1599- Modify: `internal/httpd/web.go:2061-2126` (`mrs` handler, add `CanWrite`)
1600- Modify: `internal/web/templates/mrs.html:16`
1601- Test: `internal/httpd/mrslist_test.go` (create, or add beside an
1602 existing `mrs.html` render test if one exists — check first)
1603
1604- [ ] **Step 1: Write the failing test**
1605
1606```go
1607package httpd
1608
1609import (
1610 "net/http"
1611 "net/http/httptest"
1612 "strings"
1613 "testing"
1614 "time"
1615
1616 "gitbay.org/gitbay/internal/config"
1617 "gitbay.org/gitbay/internal/store"
1618)
1619
1620// A repository's MR list offers the right next step by access level: a
1621// writer gets "New merge request", a signed-in reader without push gets
1622// a fork link, and a signed-out visitor gets a sign-in prompt (#270).
1623func TestMRsListContributionHintByAccess(t *testing.T) {
1624 st, err := store.Open(":memory:")
1625 if err != nil {
1626 t.Fatal(err)
1627 }
1628 defer st.Close()
1629 if err := st.MigrateUp(); err != nil {
1630 t.Fatal(err)
1631 }
1632 owner, err := st.CreateUser("alice", false)
1633 if err != nil {
1634 t.Fatal(err)
1635 }
1636 reader, err := st.CreateUser("bob", false)
1637 if err != nil {
1638 t.Fatal(err)
1639 }
1640 if _, err := st.CreateRepo("user", owner, "app", "public"); err != nil {
1641 t.Fatal(err)
1642 }
1643
1644 cfg := config.Default()
1645 cfg.Web.Mode = "accounts"
1646 s := New(cfg, st)
1647
1648 // mrs reads the viewer through s.viewer(r), which resolves a
1649 // session cookie (internal/httpd/accounts.go:37-47) rather than
1650 // taking the viewer as a parameter the way a POST handler test
1651 // does. Give a real viewer a real session; leave the request
1652 // cookie-less for the anonymous case.
1653 sessionFor := func(uid int64) *http.Cookie {
1654 tok, hash, err := store.NewToken()
1655 if err != nil {
1656 t.Fatal(err)
1657 }
1658 if err := st.CreateWebSession(hash, uid, time.Hour); err != nil {
1659 t.Fatal(err)
1660 }
1661 return s.sessionCookieFor(tok)
1662 }
1663
1664 get := func(uid int64) string {
1665 req := httptest.NewRequest("GET", "/alice/app/mrs", nil)
1666 req.SetPathValue("owner", "alice")
1667 req.SetPathValue("repo", "app")
1668 if uid != 0 {
1669 req.AddCookie(sessionFor(uid))
1670 }
1671 rr := httptest.NewRecorder()
1672 s.mrs(rr, req)
1673 return rr.Body.String()
1674 }
1675 anonymous := get(0)
1676 if !strings.Contains(anonymous, "Sign in to propose a change") {
1677 t.Errorf("signed-out visitor: missing sign-in prompt:\n%s", anonymous)
1678 }
1679 if strings.Contains(anonymous, "New merge request") {
1680 t.Error("signed-out visitor should not see New merge request")
1681 }
1682
1683 readerOut := get(reader)
1684 if !strings.Contains(readerOut, "Fork this repository to propose a change") {
1685 t.Errorf("reader without push: missing fork hint:\n%s", readerOut)
1686 }
1687
1688 ownerOut := get(owner)
1689 if !strings.Contains(ownerOut, "New merge request") {
1690 t.Errorf("owner: missing New merge request link:\n%s", ownerOut)
1691 }
1692}
1693```
1694
1695- [ ] **Step 2: Run and see it fail**
1696
1697Run: `go test ./internal/httpd -run TestMRsListContributionHintByAccess -count=1`
1698Expected: FAIL (no such text yet — `mrs.html:16` still only checks
1699`.Viewer`).
1700
1701- [ ] **Step 3: Add `CanWrite` to the `mrs` page struct**
1702
1703In `internal/httpd/web.go`, in `mrs` (around line 2061), after
1704`p.Tab = "merge requests"`:
1705
1706```go
1707 canWrite := s.canWriteRepo(r, p.Repo)
1708```
1709
1710and add `CanWrite bool` to the anonymous struct passed to `s.render`,
1711with `canWrite` in the corresponding position of the literal.
1712
1713- [ ] **Step 4: Update `mrs.html`**
1714
1715Find (`mrs.html:16`):
1716
1717```html
1718{{if .Viewer}}<p class="meta"><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/mrs/new">New merge request</a></p>{{end}}
1719```
1720
1721Replace:
1722
1723```html
1724{{if .CanWrite}}<p class="meta"><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/mrs/new">New merge request</a></p>
1725{{else if .Viewer}}<p class="meta"><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/fork">Fork this repository to propose a change</a></p>
1726{{else}}<p class="meta"><a href="/login">Sign in to propose a change</a></p>{{end}}
1727```
1728
1729- [ ] **Step 5: Run**
1730
1731Run: `go test ./internal/httpd -run TestMRsListContributionHintByAccess -count=1 && go test ./internal/httpd -count=1`
1732Expected: PASS.
1733
1734- [ ] **Step 6: Commit**
1735
1736```bash
1737git add internal/httpd/web.go internal/web/templates/mrs.html internal/httpd/mrslist_test.go
1738git commit -m "web: MR list offers a fork link or a sign-in prompt to visitors who cannot open one directly" -m "Ref #270"
1739```
1740
1741### Task 4.3: search scope caption always visible; tab zero-count rule
1742
1743**Files:**
1744- Modify: `internal/web/templates/globalsearch.html:45-48`
1745- Modify: `internal/web/templates/layout.html:71-72` (comment only — see
1746 Step 2)
1747- Test: `internal/httpd/searchweb_test.go` (or wherever a
1748 `globalsearch.html` render test already lives)
1749
1750- [ ] **Step 1: Write the failing test**
1751
1752```go
1753// The scope sentence is a permanent caption, not a first-visit-only
1754// hint: a visitor who has already searched still needs to know what a
1755// search here does and does not cover (#270).
1756func TestGlobalSearchScopeCaptionAlwaysShown(t *testing.T) {
1757 var sb strings.Builder
1758 if err := web.Render(&sb, "globalsearch.html", struct {
1759 basePage
1760 Query, Kind string
1761 Results []searchHit
1762 QueryErr string
1763 }{Query: "gitbay"}); err != nil {
1764 t.Fatalf("render: %v", err)
1765 }
1766 if !strings.Contains(sb.String(), "File contents are searched per repository") {
1767 t.Error("scope caption missing once a query is present")
1768 }
1769}
1770```
1771
1772(Check the real render struct's field names and the `searchHit` type
1773name with `grep -n '"globalsearch.html"' internal/httpd/*.go` and match
1774them exactly — the struct above is a best guess at the shape from
1775reading the template, not a verified signature.)
1776
1777- [ ] **Step 2: Run and see it fail**
1778
1779Run: `go test ./internal/httpd -run TestGlobalSearchScopeCaptionAlwaysShown -count=1`
1780Expected: FAIL (the caption is currently inside the `{{else}}` branch
1781that only renders when `.Query` is empty).
1782
1783- [ ] **Step 3: Move the caption out of the conditional**
1784
1785Find (`globalsearch.html:45-48`):
1786
1787```html
1788{{/* The count line above already says nothing matched, so this one
1789 carries the way out instead of repeating it. */}}
1790{{else}}<p class="empty-note">Try fewer words{{if .Kind}}, <a href="?q={{.Query}}">search everything</a>,{{end}} or <a href="/explore">browse the repositories</a>.</p>{{end}}
1791{{else}}
1792<p class="empty-note">Repository names, descriptions and topics, and the title and body of every issue and merge request you can read. File contents are searched per repository, from a repository's Code tab.</p>
1793{{end}}
1794```
1795
1796Replace with (the scope sentence moves out to render unconditionally,
1797right after the search form, and the "no results" hint keeps its own
1798conditional unchanged):
1799
1800```html
1801{{/* The count line above already says nothing matched, so this one
1802 carries the way out instead of repeating it. */}}
1803{{else}}<p class="empty-note">Try fewer words{{if .Kind}}, <a href="?q={{.Query}}">search everything</a>,{{end}} or <a href="/explore">browse the repositories</a>.</p>{{end}}
1804{{end}}
1805<p class="meta">Repository names, descriptions and topics, and the title and body of every issue and merge request you can read. File contents are searched per repository, from a repository's Code tab.</p>
1806```
1807
1808(Dropping the outer `{{if .QueryErr}}...{{else if .Query}}...{{else}}...{{end}}`'s
1809final `{{else}}` branch this way requires re-reading the template's
1810actual brace nesting before editing — the three-way `{{if
1811.QueryErr}}{{else if .Query}}{{else}}{{end}}` collapses to a two-way
1812`{{if .QueryErr}}{{else}}...{{end}}` once the "no query yet" case no
1813longer needs its own branch for this sentence. Read
1814`globalsearch.html`'s full `{{if}}/{{else}}` structure before editing —
1815line numbers above are from this plan's research and may have shifted.)
1816
1817- [ ] **Step 4: Run**
1818
1819Run: `go test ./internal/httpd -run TestGlobalSearchScopeCaptionAlwaysShown -count=1 && go test ./internal/httpd -count=1`
1820Expected: PASS.
1821
1822- [ ] **Step 5: Document the tab zero-count rule (no code change: the
1823 current behaviour is already the rule)**
1824
1825Reading `layout.html:71-74`: `Issues` and `Merge requests` already hide
1826their count badge at zero (`{{with field $ "OpenIssues"}}{{if .}} <i>{{.}}</i>{{end}}{{end}}`,
1827same for `OpenMRs`); `Builds`, `Releases`, `Wiki` and `Settings` never
1828carry a count at all. The inconsistency the issue names ("Issues 9, then
1829Merge requests with no count") is two tabs following the same rule
1830producing different-looking output depending on the data, not a code
1831bug — but `dashboard.html`'s pin row (`<b{{if .Issues}} class="wants"{{end}}>{{.Issues}} ...`)
1832always prints the number, including `0`, which genuinely is a different
1833rule from the tabs'. Fix that inconsistency by hiding a zero the same
1834way the tabs do. Find (`dashboard.html:27`, inside the pin row):
1835
1836```html
1837<b{{if .Issues}} class="wants"{{end}}>{{.Issues}} <span class="vh">open issues</span></b> <b{{if .MRs}} class="wants"{{end}}>{{.MRs}} <span class="vh">open merge requests</span></b>
1838```
1839
1840Replace:
1841
1842```html
1843<b{{if .Issues}} class="wants"{{end}}>{{if .Issues}}{{.Issues}}{{else}}0{{end}} <span class="vh">open issues</span></b> <b{{if .MRs}} class="wants"{{end}}>{{if .MRs}}{{.MRs}}{{else}}0{{end}} <span class="vh">open merge requests</span></b>
1844```
1845
1846Wait — re-read this before implementing: this keeps `0` printed, which
1847does not change anything (`{{.Issues}}` and `{{if .Issues}}{{.Issues}}{{else}}0{{end}}`
1848render identically for an int, since Go's `%v`-style template output of
1849`0` is already `"0"`). The dashboard pin row is not actually
1850inconsistent with the tabs in a way a template edit can fix: it is a
1851`<b>` badge that always shows a resting value (like a count chip
1852elsewhere in the app, e.g. label/milestone counts), whereas the tabs
1853hide their `<i>` badge entirely at zero because an empty `<i>` there
1854would look like stray punctuation next to the tab word. These are
1855two different UI elements with two different, both-reasonable rules.
1856Do not change `dashboard.html` in this task. Instead add a one-line
1857comment at `layout.html:69` (just above the `<nav class="tabs">`)
1858recording the decision so a future pass does not "fix" this again:
1859
1860```html
1861 {{/* A tab's own count badge is omitted at zero (an empty <i> reads as
1862 stray punctuation next to the tab word); the dashboard pin row's
1863 count chip always shows its number, zero included, the same as
1864 every other count chip in the app. Two elements, two rules,
1865 decided once here (#270). */}}
1866 <nav class="tabs" aria-label="Repository">
1867```
1868
1869- [ ] **Step 6: Run the full package once more**
1870
1871Run: `go test ./internal/httpd ./internal/web -count=1`
1872Expected: PASS.
1873
1874- [ ] **Step 7: Commit**
1875
1876```bash
1877git add internal/web/templates/globalsearch.html internal/web/templates/layout.html internal/httpd/searchweb_test.go
1878git commit -m "web: search scope caption is permanent; document the tab zero-count rule" -m "Closes #270"
1879```
1880
1881### Task 4.4: open MR 4
1882
1883```bash
1884git push -u origin web-empty-states
1885gitbay mr create --source web-empty-states --target main --title "Web: empty-state and contribution-hint sweep"
1886```
1887
1888Wait for CI, merge, delete the branch both places.
1889
1890---
1891
1892# MR 5: UX review small fixes (branch `web-ux-small-fixes`)
1893
1894Closes #271. Depends on MR 1 (`repo watch`/`repo mute` dispatch and the
1895cycling toggle).
1896
1897### Task 5.1: issue form gains milestone and assignee
1898
1899**Files:**
1900- Modify: `internal/web/templates/issuenew.html`
1901- Modify: `internal/httpd/accounts.go:455-477` (`issueCreateSubmit`)
1902- Test: `internal/httpd/accounts_test.go` or wherever an existing
1903 `issueCreateSubmit` test lives (`grep -rln "issueCreateSubmit" internal/httpd/*_test.go`)
1904
1905**Ground truth from reading the code:** `issue create`
1906(`internal/control/issue.go:18-31`) takes only `--title`, `--body`/
1907`--file`, and `--format` — no `--milestone`/`--assignee` flags.
1908`issueCreateSubmit` (`internal/httpd/accounts.go:455-477`) already
1909handles this shape for labels: it creates the issue first (decoding the
1910created issue's number via `dispatchIntoStdin` into `control.Created`),
1911then, only if the labels field was non-empty, makes a second dispatch
1912(`issue label ... --add ...`) with that number. Milestone and assignee
1913follow the same two-step shape, using the existing commands `issue
1914milestone <owner/name> <n> <title>` and `issue assign <owner/name> <n>
1915[--add <user>]` (`internal/control/issue.go:101-109` for assign; the
1916milestone command's exact path is confirmed by
1917`internal/httpd/issueactions.go`'s `issueMilestoneSubmit`, which already
1918calls `issueArgs(r, "milestone", title)`).
1919
1920- [ ] **Step 1: Write the failing test**
1921
1922```go
1923// The new-issue form takes milestone and assignee, the same as the
1924// issue page's own edit controls already do (#271).
1925func TestIssueCreateFormHasMilestoneAndAssignee(t *testing.T) {
1926 var sb strings.Builder
1927 if err := web.Render(&sb, "issuenew.html", struct {
1928 basePage
1929 Repo store.Repo
1930 Milestones []string
1931 Draft *draft
1932 }{Repo: store.Repo{OwnerName: "alice", Name: "app"}}); err != nil {
1933 t.Fatalf("render: %v", err)
1934 }
1935 out := sb.String()
1936 if !strings.Contains(out, `name="milestone"`) {
1937 t.Error("no milestone field")
1938 }
1939 if !strings.Contains(out, `name="assignee"`) {
1940 t.Error("no assignee field")
1941 }
1942}
1943```
1944
1945(Match the render struct to whatever `issueCreateForm` actually passes —
1946read its handler first, per Step 1, and adjust field names here.)
1947
1948- [ ] **Step 2: Run and see it fail**
1949
1950Run: `go test ./internal/httpd -run TestIssueCreateFormHasMilestoneAndAssignee -count=1`
1951Expected: FAIL.
1952
1953- [ ] **Step 3: Add the fields to the form**
1954
1955Add to `issuenew.html`, alongside the existing labels input (matching
1956its markup style exactly — an `<input>` with the same classes/attributes
1957the labels field uses, adjusted for name and placeholder):
1958
1959```html
1960<p><input type="text" name="milestone" aria-label="Milestone" placeholder="milestone (optional)"></p>
1961<p><input type="text" name="assignee" aria-label="Assignee" placeholder="assignee, one username (optional)"></p>
1962```
1963
1964Place these after the existing labels `<input>` and before the submit
1965button, matching the vertical rhythm (`<p>` wrapping) the rest of the
1966form uses.
1967
1968- [ ] **Step 4: Wire them into the handler as follow-up dispatches**
1969
1970In `internal/httpd/accounts.go`, `issueCreateSubmit` (lines 455-477),
1971add two more follow-up dispatches after the existing labels one, using
1972the same `n` (the created issue's number, already decoded from
1973`created.Number`):
1974
1975```go
1976 if args := fieldArgs("--add", r.FormValue("labels")); len(args) > 0 {
1977 s.runControl(u, append([]string{"issue", "label", repoPath, fmt.Sprint(n)}, args...))
1978 }
1979 if milestone := strings.TrimSpace(r.FormValue("milestone")); milestone != "" {
1980 s.runControl(u, []string{"issue", "milestone", repoPath, fmt.Sprint(n), milestone})
1981 }
1982 if assignee := strings.TrimSpace(r.FormValue("assignee")); assignee != "" {
1983 s.runControl(u, []string{"issue", "assign", repoPath, fmt.Sprint(n), "--add", assignee})
1984 }
1985 http.Redirect(w, r, fmt.Sprintf("/%s/issues/%d", repoPath, n), http.StatusSeeOther)
1986```
1987
1988(The first block — the existing labels dispatch — is unchanged; the
1989milestone and assignee blocks are new, inserted between it and the
1990final redirect.)
1991
1992- [ ] **Step 5: Run**
1993
1994Run: `go test ./internal/httpd -run TestIssueCreateFormHasMilestoneAndAssignee -count=1 && go test ./internal/httpd -count=1`
1995Expected: PASS.
1996
1997- [ ] **Step 6: Commit**
1998
1999```bash
2000git add internal/web/templates/issuenew.html internal/httpd/accounts.go internal/httpd/*_test.go
2001git commit -m "web: new-issue form takes milestone and assignee" -m "Ref #271"
2002```
2003
2004### Task 5.2: "Muted" reachable on the watch control
2005
2006MR 1 (Task 1.2) already made `watchToggle` cycle default → watching →
2007muted → default, closing the functional half of this. This task is the
2008UI half: the header button's label and title must describe all three
2009states (today it only ever renders "Watch" or "Watching").
2010
2011**Files:**
2012- Modify: `internal/web/templates/layout.html:64`
2013- Test: a `layout.html` render test, or a repo-page test that already
2014 checks the watch button (`grep -rln 'aria-pressed.*Watch\|"Watching"' internal/httpd/*_test.go`)
2015
2016- [ ] **Step 1: Write the failing test**
2017
2018```go
2019// The watch button names all three states it cycles through, including
2020// muted, which MR 1 made reachable (#271).
2021func TestRepoHeaderWatchButtonNamesMutedState(t *testing.T) {
2022 var sb strings.Builder
2023 rp := testRepoPage()
2024 rp.Watch = "muted"
2025 if err := web.Render(&sb, "dashboard.html", struct {
2026 repoPage
2027 }{rp}); err != nil {
2028 t.Fatalf("render: %v", err)
2029 }
2030 if !strings.Contains(sb.String(), "Muted") {
2031 t.Error(`watch button does not render "Muted" for a muted repo`)
2032 }
2033}
2034```
2035
2036`dashboard.html` is not a repo page and will not carry the header at
2037all — use whatever page template this plan's other tasks already found
2038does render `field $ "Repo"` (any `repoPage`-embedding page works,
2039e.g. `mr.html`); adjust the render call to a page that actually shows
2040the header (check with `grep -n 'field \$ "Repo"' internal/web/templates/layout.html`
2041and pick any page in the `repoPage` family, such as `mrs.html`, matching
2042whatever fixture data that page's own tests already use).
2043
2044- [ ] **Step 2: Run and see it fail**
2045
2046Run: `go test ./internal/httpd -run TestRepoHeaderWatchButtonNamesMutedState -count=1`
2047Expected: FAIL.
2048
2049- [ ] **Step 3: Update the button**
2050
2051Find (`layout.html:64`):
2052
2053```html
2054 <form method="post" action="/{{.OwnerName}}/{{.Name}}/watch" class="inline"><button type="submit" class="btn" aria-pressed="{{if eq (str $ "Watch") "watching"}}true{{else}}false{{end}}" title="Watching sends every issue, request and build to your inbox">{{if eq (str $ "Watch") "watching"}}Watching{{else}}Watch{{end}}</button></form>
2055```
2056
2057Replace:
2058
2059```html
2060 <form method="post" action="/{{.OwnerName}}/{{.Name}}/watch" class="inline"><button type="submit" class="btn" aria-pressed="{{if ne (str $ "Watch") ""}}true{{else}}false{{end}}" title="{{if eq (str $ "Watch") "watching"}}Watching: every issue, request and build. Click to mute.{{else if eq (str $ "Watch") "muted"}}Muted: nothing from this repository. Click to stop muting.{{else}}Only what involves you. Click to watch everything.{{end}}">{{if eq (str $ "Watch") "watching"}}Watching{{else if eq (str $ "Watch") "muted"}}Muted{{else}}Watch{{end}}</button></form>
2061```
2062
2063- [ ] **Step 4: Run**
2064
2065Run: `go test ./internal/httpd -run TestRepoHeaderWatchButtonNamesMutedState -count=1 && go test ./internal/httpd -count=1`
2066Expected: PASS.
2067
2068- [ ] **Step 5: Commit**
2069
2070```bash
2071git add internal/web/templates/layout.html internal/httpd/*_test.go
2072git commit -m "web: watch button names all three states, muted included" -m "Ref #271"
2073```
2074
2075### Task 5.3: rail and phone "More" menu render from one list
2076
2077**Files:**
2078- Modify: `internal/web/web.go` (add `railItem` type, `railOptItems`
2079 func, register it in `funcs`)
2080- Modify: `internal/web/templates/layout.html:26,30-31,37-40`
2081- Test: `internal/web/web_test.go` (`TestRailIconsAreLabelled` already
2082 parses `layout.html`; add a focused new test rather than folding into
2083 that one)
2084
2085- [ ] **Step 1: Write the failing test**
2086
2087Add to `internal/web/web_test.go`:
2088
2089```go
2090// The main rail and the phone "More" menu render New repository,
2091// Settings, Admin and Log out from one list, so adding a destination in
2092// one place reaches both (#271).
2093func TestRailOptItemsDriveBothRailAndMoreMenu(t *testing.T) {
2094 items := railOptItems(struct {
2095 Tab string
2096 Admin bool
2097 }{Tab: "admin", Admin: true})
2098 if len(items) != 4 {
2099 t.Fatalf("got %d items, want 4 (New repository, Settings, Admin, Log out)", len(items))
2100 }
2101 if items[2].Name != "Admin" || !items[2].Show {
2102 t.Errorf("Admin item: %+v", items[2])
2103 }
2104 if !items[2].Current {
2105 t.Error("Admin item should be Current when Tab is admin")
2106 }
2107
2108 nonAdmin := railOptItems(struct {
2109 Tab string
2110 Admin bool
2111 }{Tab: "account"})
2112 if nonAdmin[2].Show {
2113 t.Error("Admin item should not Show for a non-admin viewer")
2114 }
2115 if !nonAdmin[1].Current {
2116 t.Error("Settings item should be Current when Tab is account")
2117 }
2118}
2119```
2120
2121- [ ] **Step 2: Run and see it fail to compile**
2122
2123Run: `go test ./internal/web -run TestRailOptItemsDriveBothRailAndMoreMenu -count=1`
2124Expected: FAIL (`railOptItems` undefined).
2125
2126- [ ] **Step 3: Add the type and function**
2127
2128In `internal/web/web.go`, near the other template-data helpers (before
2129the `funcs` map, so it can be referenced there):
2130
2131```go
2132// railItem is one destination the rail's icon strip and the phone
2133// "More" menu both render — from this one list, so a destination added
2134// here reaches both instead of the two being hand-kept in step (#271).
2135type railItem struct {
2136 Href string
2137 Icon string
2138 Name string
2139 Current bool
2140 Count int64 // unused by railOptItems; present so "raillink" can read it uniformly
2141 Show bool
2142}
2143
2144// railField and railBool read a named field off the page value the
2145// layout was given — the same reflection str/field already do for the
2146// repo header, duplicated narrowly here rather than exported, since
2147// railOptItems is their only other caller.
2148func railField(v any, name string) string {
2149 rv := reflect.ValueOf(v)
2150 for rv.Kind() == reflect.Ptr || rv.Kind() == reflect.Interface {
2151 rv = rv.Elem()
2152 }
2153 if rv.Kind() != reflect.Struct {
2154 return ""
2155 }
2156 f := rv.FieldByName(name)
2157 if !f.IsValid() || f.Kind() != reflect.String {
2158 return ""
2159 }
2160 return f.String()
2161}
2162
2163func railBool(v any, name string) bool {
2164 rv := reflect.ValueOf(v)
2165 for rv.Kind() == reflect.Ptr || rv.Kind() == reflect.Interface {
2166 rv = rv.Elem()
2167 }
2168 if rv.Kind() != reflect.Struct {
2169 return false
2170 }
2171 f := rv.FieldByName(name)
2172 return f.IsValid() && f.Kind() == reflect.Bool && f.Bool()
2173}
2174
2175// railOptItems is the rail's "New repository", "Settings", "Admin" and
2176// "Log out" destinations, in the order the rail shows them. v is the
2177// page value the layout renders (any page struct that embeds
2178// basePage), read by field name since the layout has no single common
2179// type for every page.
2180func railOptItems(v any) []railItem {
2181 tab := railField(v, "Tab")
2182 admin := railBool(v, "Admin")
2183 return []railItem{
2184 {Href: "/new", Icon: "plus", Name: "New repository", Show: true},
2185 {Href: "/settings", Icon: "gear", Name: "Settings", Current: tab == "account", Show: true},
2186 {Href: "/admin", Icon: "shield", Name: "Admin", Current: tab == "admin", Show: admin},
2187 {Href: "/logout", Icon: "signout", Name: "Log out", Show: true},
2188 }
2189}
2190```
2191
2192Add `"reflect"` to the file's imports if not already present (it almost
2193certainly is, since `str`/`field` already use it).
2194
2195Register it in the `funcs` map (anywhere in the literal, e.g. beside
2196`"initial"`):
2197
2198```go
2199 "railOptItems": railOptItems,
2200```
2201
2202- [ ] **Step 4: Run the new test**
2203
2204Run: `go test ./internal/web -run TestRailOptItemsDriveBothRailAndMoreMenu -count=1`
2205Expected: PASS.
2206
2207- [ ] **Step 5: Use it in `layout.html`**
2208
2209Find (lines 21-32):
2210
2211```html
2212 <ul class="raillist">
2213 {{if .Viewer}}<li>{{template "raillink" dict "Href" "/" "Icon" "home" "Name" "Dashboard" "Current" (eq (str . "Tab") "dashboard")}}</li>{{end}}
2214 <li>{{template "raillink" dict "Href" "/explore" "Icon" "compass" "Name" "Explore" "Current" (eq (str . "Tab") "explore")}}</li>
2215 <li>{{template "raillink" dict "Href" "/search" "Icon" "search" "Name" "Search" "Current" (eq (str . "Tab") "sitesearch")}}</li>
2216 {{if .Viewer}}<li>{{template "raillink" dict "Href" "/notifications" "Icon" "bell" "Name" "Notifications" "Current" (eq (str . "Tab") "notifications") "Count" .Rail.Unread}}</li>
2217 <li class="railopt">{{template "raillink" dict "Href" "/new" "Icon" "plus" "Name" "New repository"}}</li>{{end}}
2218 </ul>
2219 <span class="railgap"></span>
2220 <ul class="raillist">
2221 {{if .Viewer}}<li class="railopt">{{template "raillink" dict "Href" "/settings" "Icon" "gear" "Name" "Settings" "Current" (eq (str . "Tab") "account")}}</li>{{end}}
2222 {{if .Admin}}<li class="railopt">{{template "raillink" dict "Href" "/admin" "Icon" "shield" "Name" "Admin" "Current" (eq (str . "Tab") "admin")}}</li>{{end}}
2223 </ul>
2224```
2225
2226Replace:
2227
2228```html
2229 <ul class="raillist">
2230 {{if .Viewer}}<li>{{template "raillink" dict "Href" "/" "Icon" "home" "Name" "Dashboard" "Current" (eq (str . "Tab") "dashboard")}}</li>{{end}}
2231 <li>{{template "raillink" dict "Href" "/explore" "Icon" "compass" "Name" "Explore" "Current" (eq (str . "Tab") "explore")}}</li>
2232 <li>{{template "raillink" dict "Href" "/search" "Icon" "search" "Name" "Search" "Current" (eq (str . "Tab") "sitesearch")}}</li>
2233 {{if .Viewer}}<li>{{template "raillink" dict "Href" "/notifications" "Icon" "bell" "Name" "Notifications" "Current" (eq (str . "Tab") "notifications") "Count" .Rail.Unread}}</li>
2234 <li class="railopt">{{template "raillink" (index (railOptItems .) 0)}}</li>{{end}}
2235 </ul>
2236 <span class="railgap"></span>
2237 <ul class="raillist">
2238 {{if .Viewer}}<li class="railopt">{{template "raillink" (index (railOptItems .) 1)}}</li>
2239 {{if (index (railOptItems .) 2).Show}}<li class="railopt">{{template "raillink" (index (railOptItems .) 2)}}</li>{{end}}{{end}}
2240 </ul>
2241```
2242
2243Find (lines 34-42):
2244
2245```html
2246 {{if .Viewer}}<details class="railmore">
2247 <summary class="railicon" aria-label="More" title="More">{{template "icon" "ellipsis"}}<span class="vh">More</span></summary>
2248 <div class="raildrop">
2249 <a href="/new">{{template "icon" "plus"}} New repository</a>
2250 <a href="/settings">{{template "icon" "gear"}} Settings</a>
2251 {{if .Admin}}<a href="/admin">{{template "icon" "shield"}} Admin</a>{{end}}
2252 <a href="/logout">{{template "icon" "signout"}} Log out</a>
2253 </div>
2254 </details>
2255```
2256
2257Replace:
2258
2259```html
2260 {{if .Viewer}}<details class="railmore">
2261 <summary class="railicon" aria-label="More" title="More">{{template "icon" "ellipsis"}}<span class="vh">More</span></summary>
2262 <div class="raildrop">
2263 {{range railOptItems .}}{{if .Show}}<a href="{{.Href}}">{{template "icon" .Icon}} {{.Name}}</a>{{end}}{{end}}
2264 </div>
2265 </details>
2266```
2267
2268- [ ] **Step 6: Run**
2269
2270Run: `go test ./internal/web -count=1 && go test ./internal/httpd -count=1`
2271Expected: PASS. `TestRailIconsAreLabelled` must still pass unchanged —
2272`raillink`'s template still receives the same field names (`Href`,
2273`Icon`, `Name`, `Current`, `Count`), now off a `railItem` struct instead
2274of a `dict` map, which `html/template`'s field access treats
2275identically.
2276
2277- [ ] **Step 7: Commit**
2278
2279```bash
2280git add internal/web/web.go internal/web/web_test.go internal/web/templates/layout.html
2281git commit -m "web: rail and phone More menu render New repository/Settings/Admin/Log out from one list" -m "Ref #271"
2282```
2283
2284### Task 5.4: "Discussion" heading before the comment thread
2285
2286**Files:**
2287- Modify: `internal/web/templates/mr.html` (inside the `conversation` view)
2288- Modify: `internal/web/templates/issue.html`
2289- Test: existing render tests in `mrpage_test.go`; a new or existing
2290 `issue.html` render test
2291
2292- [ ] **Step 1: Write the failing tests**
2293
2294Add to `internal/httpd/mrpage_test.go`:
2295
2296```go
2297// A heading precedes the comment thread, so a screen-reader user
2298// skimming by heading does not fall from the aside's groups straight
2299// into the first comment with no landmark (#271).
2300func TestMRPageHasDiscussionHeading(t *testing.T) {
2301 var sb strings.Builder
2302 if err := web.Render(&sb, "mr.html", mrPageData{
2303 repoPage: testRepoPage(), MR: testMR("open"), View: "conversation",
2304 }); err != nil {
2305 t.Fatalf("render: %v", err)
2306 }
2307 if !strings.Contains(sb.String(), "<h2>Discussion</h2>") {
2308 t.Error("no Discussion heading")
2309 }
2310}
2311```
2312
2313Add the equivalent for `issue.html` in whatever file already tests it
2314(`grep -rln '"issue.html"' internal/httpd/*_test.go`).
2315
2316- [ ] **Step 2: Run and see them fail**
2317
2318Run: `go test ./internal/httpd -run TestMRPageHasDiscussionHeading -count=1`
2319Expected: FAIL.
2320
2321- [ ] **Step 3: `mr.html`**
2322
2323Find, inside the `{{if eq .View "conversation"}}` block, right after
2324`<div class="prose">`:
2325
2326```html
2327{{if eq .View "conversation"}}
2328<div class="prose">
2329{{if .CanEdit}}<details class="editbox"{{if .Draft.Is "edit"}} open{{end}}><summary>Edit</summary>
2330```
2331
2332Replace:
2333
2334```html
2335{{if eq .View "conversation"}}
2336<div class="prose">
2337<h2>Discussion</h2>
2338{{if .CanEdit}}<details class="editbox"{{if .Draft.Is "edit"}} open{{end}}><summary>Edit</summary>
2339```
2340
2341- [ ] **Step 4: `issue.html`**
2342
2343Find, right after `<div class="mainside">`:
2344
2345```html
2346<div class="mainside">
2347{{if .CanEdit}}<details class="editbox"{{if .Draft.Is "edit"}} open{{end}}><summary>edit</summary>
2348```
2349
2350Replace:
2351
2352```html
2353<div class="mainside">
2354<h2>Discussion</h2>
2355{{if .CanEdit}}<details class="editbox"{{if .Draft.Is "edit"}} open{{end}}><summary>edit</summary>
2356```
2357
2358- [ ] **Step 5: Run**
2359
2360Run: `go test ./internal/httpd -count=1`
2361Expected: PASS.
2362
2363- [ ] **Step 6: Commit**
2364
2365```bash
2366git add internal/web/templates/mr.html internal/web/templates/issue.html internal/httpd/*_test.go
2367git commit -m "web: Discussion heading before the comment thread on issue and MR pages" -m "Ref #271"
2368```
2369
2370### Task 5.5: build page's "Live" note says the page updates itself
2371
2372**Files:**
2373- Modify: `internal/web/templates/build.html:15`
2374- Test: `internal/httpd/builds_test.go` (or wherever a live-build render
2375 test already exists — check with `grep -rln '"Live"' internal/httpd/*_test.go`)
2376
2377- [ ] **Step 1: Write the failing test**
2378
2379```go
2380// The Live note says the page updates itself, in plain words, rather
2381// than the more technical "streams here" (#271).
2382func TestBuildPageLiveNoteSaysItUpdatesItself(t *testing.T) {
2383 var sb strings.Builder
2384 if err := web.Render(&sb, "build.html", buildView{
2385 repoPage: testRepoPage(), Live: true,
2386 }); err != nil {
2387 t.Fatalf("render: %v", err)
2388 }
2389 if !strings.Contains(sb.String(), "This page updates itself") {
2390 t.Error(`Live note does not say the page updates itself`)
2391 }
2392}
2393```
2394
2395- [ ] **Step 2: Run and see it fail**
2396
2397Run: `go test ./internal/httpd -run TestBuildPageLiveNoteSaysItUpdatesItself -count=1`
2398Expected: FAIL.
2399
2400- [ ] **Step 3: Update the note**
2401
2402Find (`build.html:15`):
2403
2404```html
2405{{if .Live}}<p class="meta">Live: the log streams here until the build ends. If it stops without a “build finished” line, reload to pick it up again. <a href="?follow=0">Show it without updates</a></p>
2406```
2407
2408Replace:
2409
2410```html
2411{{if .Live}}<p class="meta">This page updates itself until the build ends. If it stops without a “build finished” line, reload to pick it up again. <a href="?follow=0">Show it without updates</a></p>
2412```
2413
2414- [ ] **Step 4: Run**
2415
2416Run: `go test ./internal/httpd -run TestBuildPageLiveNoteSaysItUpdatesItself -count=1 && go test ./internal/httpd -count=1`
2417Expected: PASS.
2418
2419- [ ] **Step 5: Commit**
2420
2421```bash
2422git add internal/web/templates/build.html internal/httpd/builds_test.go
2423git commit -m "web: build page's Live note says the page updates itself" -m "Closes #271"
2424```
2425
2426### Task 5.6: open MR 5
2427
2428```bash
2429git push -u origin web-ux-small-fixes
2430gitbay mr create --source web-ux-small-fixes --target main --title "Web UX review small fixes: issue form, mute, rail list, Discussion heading"
2431```
2432
2433Wait for CI, merge, delete the branch both places.
2434
2435---
2436
2437# MR 6: wiki non-page links go to `_raw` (branch `wiki-raw-links`)
2438
2439Closes #283.
2440
2441### Task 6.1: `rewriteWikiLinks` sends a non-page file link to `_raw`
2442
2443**Files:**
2444- Modify: `internal/httpd/wiki.go:250-253`
2445- Test: `internal/httpd/wiki_test.go`
2446
2447**Interfaces:**
2448- No signature changes — `rewriteWikiLinks`'s parameters and the
2449 `isPage`/`isFile` predicates it already takes are unchanged; only its
2450 href branch's internal logic changes.
2451
2452- [ ] **Step 1: Write the failing test**
2453
2454Add to `internal/httpd/wiki_test.go`, in `TestRewriteWikiLinksInSubfolder`
2455(extend the existing test rather than adding a new one — it already sets
2456up exactly the `pages`/`files` fixtures this needs):
2457
2458```go
2459 in := template.HTML(`<a href="Identity.org">i</a><a href="Admin.org">a</a>` +
2460 `<a href="b.svg">diagram</a>` +
2461 `<img src="b.svg"><img src="diagrams/a.svg"><a href="https://x.test/">x</a>`)
2462 out := string(rewriteWikiLinks(in, p, "Architecture/Trust",
2463 func(s string) bool { return pages[s] }, func(s string) bool { return files[s] }))
2464 for _, want := range []string{
2465 `href="/krz/gitbay/wiki/Architecture/Identity"`,
2466 `href="/krz/gitbay/wiki/Admin"`,
2467 `href="/krz/gitbay/wiki/_raw/Architecture/b.svg"`,
2468 `src="/krz/gitbay/wiki/_raw/Architecture/b.svg"`,
2469 `src="/krz/gitbay/wiki/_raw/diagrams/a.svg"`,
2470 `href="https://x.test/"`,
2471 } {
2472```
2473
2474(This adds the `<a href="b.svg">` link to the input and the matching
2475`href="...wiki/_raw/Architecture/b.svg"` expectation to the existing
2476`for _, want := range` loop — the surrounding test body, `p`, `pages`
2477and `files` setup, and the final `if !strings.Contains` check stay
2478exactly as they are.)
2479
2480- [ ] **Step 2: Run and see it fail**
2481
2482Run: `go test ./internal/httpd -run TestRewriteWikiLinksInSubfolder -count=1`
2483Expected: FAIL — the new href expectation
2484(`href="/krz/gitbay/wiki/_raw/Architecture/b.svg"`) is missing; today's
2485code rewrites that link to `href="/krz/gitbay/wiki/Architecture/b.svg"`
2486instead (a page-style link to a file that is not a page, which 404s —
2487the bug #283 reports).
2488
2489- [ ] **Step 3: Fix `rewriteWikiLinks`**
2490
2491Find (`internal/httpd/wiki.go:250-253`):
2492
2493```go
2494 if target, ok := wikiResolve(page, trimPageExt(v), isPage); ok {
2495 n.Attr[i].Val = base + "/" + target
2496 }
2497```
2498
2499Replace:
2500
2501```go
2502 // A plain link is usually to another page, but a link to
2503 // an existing non-page file (an .svg, .txt, .pdf) must
2504 // go to _raw the same as an image src, or it 404s
2505 // against the page route (#283).
2506 if target, ok := wikiResolve(page, trimPageExt(v), isPage); ok {
2507 if raw, rok := wikiResolve(page, v, isFile); rok && isFile(raw) && !isPage(target) {
2508 n.Attr[i].Val = base + "/_raw/" + raw
2509 } else {
2510 n.Attr[i].Val = base + "/" + target
2511 }
2512 }
2513```
2514
2515- [ ] **Step 4: Run**
2516
2517Run: `go test ./internal/httpd -run TestRewriteWikiLinksInSubfolder -count=1`
2518Expected: PASS.
2519
2520- [ ] **Step 5: Run the package**
2521
2522Run: `go test ./internal/httpd -count=1`
2523Expected: PASS.
2524
2525- [ ] **Step 6: Commit**
2526
2527```bash
2528git add internal/httpd/wiki.go internal/httpd/wiki_test.go
2529git commit -m "wiki: a link to an existing non-page file resolves to _raw, not the page route" -m "Closes #283"
2530```
2531
2532### Task 6.2: open MR 6
2533
2534```bash
2535git push -u origin wiki-raw-links
2536gitbay mr create --source wiki-raw-links --target main --title "Wiki: links to non-page files resolve to _raw"
2537```
2538
2539Wait for CI, merge, delete the branch both places.
2540
2541---
2542
2543# MR 7: API token page (branch `web-api-tokens`)
2544
2545Closes #264. Depends on plan 1 (`credentials-and-sessions`, #257) per
2546the "Order and dependencies" section above — implementable and testable
2547independently, merge after plan 1 lands.
2548
2549### Task 7.1: `Settings → Tokens`: create, list, revoke
2550
2551**Files:**
2552- Modify: `internal/httpd/account.go` (`accountPage`, `accountSubmit`)
2553- Modify: `internal/httpd/flash.go` (a token-shown-once cookie, parallel
2554 to the existing flash cookie)
2555- Modify: `internal/web/templates/account.html`
2556- Test: `internal/httpd/account_test.go`
2557
2558**Interfaces:**
2559- Consumes: `store.ListAPITokens`/`RevokeAPIToken`
2560 (`internal/store/tokens.go:53,74`, read/delete directly, matching how
2561 `accountPage` already reads keys and PGP keys); `s.runControl`
2562 dispatching `token create` (a write, so it goes through the command,
2563 matching every other write on this page).
2564- Produces: `func (s *Server) setTokenFlash(w http.ResponseWriter, msg string)`,
2565 `func (s *Server) takeTokenFlash(w http.ResponseWriter, r *http.Request) string`
2566 (`internal/httpd/flash.go`).
2567
2568- [ ] **Step 1: Write the failing tests**
2569
2570Add to `internal/httpd/account_test.go`:
2571
2572```go
2573// The settings page lists a user's API tokens with scope and expiry,
2574// and creating one shows the token exactly once, never in the URL
2575// (#264).
2576func TestAccountPageListsTokens(t *testing.T) {
2577 st, err := store.Open(":memory:")
2578 if err != nil {
2579 t.Fatal(err)
2580 }
2581 defer st.Close()
2582 if err := st.MigrateUp(); err != nil {
2583 t.Fatal(err)
2584 }
2585 uid, err := st.CreateUser("alice", false)
2586 if err != nil {
2587 t.Fatal(err)
2588 }
2589 if err := st.CreateAPIToken(uid, "laptop", "somehash", "read", nil); err != nil {
2590 t.Fatal(err)
2591 }
2592
2593 s := New(config.Default(), st)
2594 rr := httptest.NewRecorder()
2595 req := httptest.NewRequest("GET", "/settings", nil)
2596 s.accountPage(rr, req, store.User{ID: uid, Username: "alice"})
2597
2598 body := rr.Body.String()
2599 if !strings.Contains(body, "laptop") || !strings.Contains(body, "read") {
2600 t.Fatalf("token row missing: %s", body)
2601 }
2602 if strings.Contains(body, "somehash") {
2603 t.Fatal("the page printed a token hash")
2604 }
2605}
2606
2607// Creating a token always sends an explicit --scope, defaulting the
2608// form to read regardless of what token create itself defaults to, so
2609// this page's behaviour does not depend on that command's default
2610// (#264, #257).
2611func TestAccountSubmitTokenCreateDefaultsToReadScope(t *testing.T) {
2612 st, err := store.Open(":memory:")
2613 if err != nil {
2614 t.Fatal(err)
2615 }
2616 defer st.Close()
2617 if err := st.MigrateUp(); err != nil {
2618 t.Fatal(err)
2619 }
2620 uid, err := st.CreateUser("alice", false)
2621 if err != nil {
2622 t.Fatal(err)
2623 }
2624 u := store.User{ID: uid, Username: "alice"}
2625 s := New(config.Default(), st)
2626
2627 rr := submitAccountForm(t, s, u, url.Values{"field": {"token-create"}, "name": {"laptop"}})
2628 if rr.Code != http.StatusSeeOther {
2629 t.Fatalf("status %d, body %s", rr.Code, rr.Body.String())
2630 }
2631 if !strings.Contains(strings.Join(rr.Result().Header.Values("Set-Cookie"), ";"), "gitbay_token=") {
2632 t.Fatal("no token-shown cookie set")
2633 }
2634 tokens, err := st.ListAPITokens(uid)
2635 if err != nil || len(tokens) != 1 {
2636 t.Fatalf("tokens: %v %v", tokens, err)
2637 }
2638 if tokens[0].Scope != "read" {
2639 t.Errorf("scope = %q, want read", tokens[0].Scope)
2640 }
2641}
2642
2643// Revoking a token requires the name typed back, the same guard every
2644// other removal on this page uses.
2645func TestAccountSubmitTokenRevokeRequiresConfirm(t *testing.T) {
2646 st, err := store.Open(":memory:")
2647 if err != nil {
2648 t.Fatal(err)
2649 }
2650 defer st.Close()
2651 if err := st.MigrateUp(); err != nil {
2652 t.Fatal(err)
2653 }
2654 uid, err := st.CreateUser("alice", false)
2655 if err != nil {
2656 t.Fatal(err)
2657 }
2658 if err := st.CreateAPIToken(uid, "laptop", "somehash", "read", nil); err != nil {
2659 t.Fatal(err)
2660 }
2661 u := store.User{ID: uid, Username: "alice"}
2662 s := New(config.Default(), st)
2663
2664 submitAccountForm(t, s, u, url.Values{"field": {"token-revoke"}, "name": {"laptop"}})
2665 if tokens, _ := st.ListAPITokens(uid); len(tokens) != 1 {
2666 t.Fatal("token revoked without confirmation")
2667 }
2668
2669 rr := submitAccountForm(t, s, u, url.Values{"field": {"token-revoke"}, "name": {"laptop"}, "confirm": {"laptop"}})
2670 if rr.Code != http.StatusSeeOther {
2671 t.Fatalf("status %d, body %s", rr.Code, rr.Body.String())
2672 }
2673 if tokens, _ := st.ListAPITokens(uid); len(tokens) != 0 {
2674 t.Fatal("token not revoked")
2675 }
2676}
2677```
2678
2679- [ ] **Step 2: Run and see them fail**
2680
2681Run: `go test ./internal/httpd -run 'TestAccountPageListsTokens|TestAccountSubmitTokenCreateDefaultsToReadScope|TestAccountSubmitTokenRevokeRequiresConfirm' -count=1`
2682Expected: FAIL to compile (no `token-create`/`token-revoke` cases, no
2683token rows on the page).
2684
2685- [ ] **Step 3: Add the token-shown-once cookie**
2686
2687In `internal/httpd/flash.go`, alongside `flashCookie`/`setFlash`/`takeFlash`:
2688
2689```go
2690// tokenFlashCookie carries a freshly minted API token to the settings
2691// page exactly once. A cookie, not the ?m= query parameter the other
2692// account forms use for their success text, because a token is a
2693// secret and must never ride a URL a browser might history, bookmark,
2694// or hand to a proxy's access log (#264).
2695const tokenFlashCookie = "gitbay_token"
2696
2697// setTokenFlash queues a freshly minted token's display text for the
2698// next render of the settings page.
2699func (s *Server) setTokenFlash(w http.ResponseWriter, msg string) {
2700 if msg == "" {
2701 return
2702 }
2703 http.SetCookie(w, &http.Cookie{
2704 Name: tokenFlashCookie, Value: url.QueryEscape(msg), Path: "/settings",
2705 HttpOnly: true, SameSite: http.SameSiteLaxMode,
2706 Secure: s.cfg.HTTP.TLS != "off",
2707 MaxAge: 60,
2708 })
2709}
2710
2711// takeTokenFlash returns the queued token text, if any, and clears it.
2712func (s *Server) takeTokenFlash(w http.ResponseWriter, r *http.Request) string {
2713 c, err := r.Cookie(tokenFlashCookie)
2714 if err != nil || c.Value == "" {
2715 return ""
2716 }
2717 http.SetCookie(w, s.clearCookie(tokenFlashCookie, http.SameSiteLaxMode))
2718 msg, err := url.QueryUnescape(c.Value)
2719 if err != nil {
2720 return ""
2721 }
2722 return msg
2723}
2724```
2725
2726`clearCookie` takes only `name` and `sameSite` and hard-codes `Path:
2727"/"` (`internal/httpd/flash.go:101-108`) — confirm this still clears a
2728cookie set with `Path: "/settings"` (it does: browsers key deletion on
2729name+domain+path, and `"/settings"` is under `"/"`... actually a
2730`Path=/` clearing cookie does **not** delete a `Path=/settings` cookie —
2731paths must match exactly for deletion semantics in most browsers).
2732Fix this by setting `Path: "/settings"` on both the set and the clear:
2733add a `path` parameter to a small local variant, or simplest, write
2734`takeTokenFlash`'s clear inline instead of reusing `clearCookie`:
2735
2736```go
2737func (s *Server) takeTokenFlash(w http.ResponseWriter, r *http.Request) string {
2738 c, err := r.Cookie(tokenFlashCookie)
2739 if err != nil || c.Value == "" {
2740 return ""
2741 }
2742 http.SetCookie(w, &http.Cookie{
2743 Name: tokenFlashCookie, Value: "", Path: "/settings",
2744 HttpOnly: true, SameSite: http.SameSiteLaxMode,
2745 Secure: s.cfg.HTTP.TLS != "off", MaxAge: -1,
2746 })
2747 msg, err := url.QueryUnescape(c.Value)
2748 if err != nil {
2749 return ""
2750 }
2751 return msg
2752}
2753```
2754
2755(Use this version; drop the `clearCookie` call from the draft above.)
2756
2757- [ ] **Step 4: Add token data to `accountPage`**
2758
2759In `internal/httpd/account.go`, add a view type near `accountDevice`:
2760
2761```go
2762// accountToken is one API token as the settings page shows it: never
2763// the token itself, only what identifies and describes it.
2764type accountToken struct {
2765 Name string
2766 Scope string
2767 Created string
2768 Expires string // "never" or a formatted timestamp
2769 LastUsed string // "never" or a formatted timestamp
2770}
2771```
2772
2773In `accountPage`, alongside the existing `devices` collection:
2774
2775```go
2776 var tokens []accountToken
2777 if list, err := s.st.ListAPITokens(u.ID); err == nil {
2778 for _, tk := range list {
2779 expires, lastUsed := "never", "never"
2780 if tk.ExpiresAt != nil {
2781 expires = tk.ExpiresAt.UTC().Format("2006-01-02 15:04 UTC")
2782 }
2783 if tk.LastUsedAt != nil {
2784 lastUsed = tk.LastUsedAt.UTC().Format("2006-01-02 15:04 UTC")
2785 }
2786 tokens = append(tokens, accountToken{tk.Name, tk.Scope, tk.CreatedAt, expires, lastUsed})
2787 }
2788 }
2789```
2790
2791Add `Tokens []accountToken` and `TokenShown string` to the struct passed
2792to `s.render(w, "account.html", ...)`, with `tokens` and
2793`s.takeTokenFlash(w, r)` in the corresponding literal positions.
2794
2795- [ ] **Step 5: Add `token-create` and `token-revoke` to `accountSubmit`**
2796
2797In `internal/httpd/account.go`, `accountSubmit`'s `switch`, add two
2798cases (alongside `email-primary` and before `theme`, or anywhere in the
2799switch — order does not matter):
2800
2801```go
2802 case "token-create":
2803 name := strings.TrimSpace(r.FormValue("name"))
2804 if name == "" {
2805 back("name the token", "")
2806 return
2807 }
2808 scope := r.FormValue("scope")
2809 if scope != "full" {
2810 scope = "read" // this page's own default, regardless of what token create defaults to (#257, #264)
2811 }
2812 argv := []string{"token", "create", "--name", name, "--scope", scope}
2813 if ttl := strings.TrimSpace(r.FormValue("ttl")); ttl != "" {
2814 argv = append(argv, "--ttl", ttl)
2815 }
2816 out, msg, ok := s.runControl(u, argv)
2817 if !ok {
2818 back(msg, "")
2819 return
2820 }
2821 s.setTokenFlash(w, out)
2822 http.Redirect(w, r, "/settings#tokens", http.StatusSeeOther)
2823 return
2824 case "token-revoke":
2825 name := r.FormValue("name")
2826 if ok, msg := confirmed(r, name); !ok {
2827 back(msg, "")
2828 return
2829 }
2830 if _, msg, ok := s.runControl(u, []string{"token", "revoke", name}); !ok {
2831 back(msg, "")
2832 return
2833 }
2834 back("", "token revoked")
2835```
2836
2837(This `switch` does not use `back`'s redirect-with-query pattern for
2838`token-create`'s success path, since the token's display text cannot go
2839through `?m=`; it returns directly after the redirect, matching the
2840early-return shape every other case already uses.)
2841
2842- [ ] **Step 6: Add the Tokens section to `account.html`**
2843
2844Add a new `<section id="tokens">` — placed before the existing `<section
2845id="cli">` (which the sidebar's existing anchor list under "On the
2846command line" leaves in place; add a `<li><a href="#tokens">API
2847tokens</a></li>` to that anchor list too, alongside the other section
2848links):
2849
2850```html
2851<section id="tokens"><h2>API tokens</h2>
2852<p class="meta">A token signs in the iOS app, or a script, without your
2853password. A phone app needs full scope to comment and merge; full scope
2854on an admin account can administer the instance, so give a token the
2855narrowest scope and shortest lifetime the job needs.</p>
2856{{if .TokenShown}}<pre class="message" tabindex="0">{{.TokenShown}}</pre>{{end}}
2857<table>
2858<tr><th>Name</th><th>Scope</th><th>Created</th><th>Expires</th><th>Last used</th><th></th></tr>
2859{{range .Tokens}}<tr>
2860 <td>{{.Name}}</td><td>{{.Scope}}</td><td>{{.Created}}</td><td>{{.Expires}}</td><td>{{.LastUsed}}</td>
2861 <td class="act"><form method="post" action="/settings"><input type="hidden" name="field" value="token-revoke"><input type="hidden" name="name" value="{{.Name}}">{{template "confirmfield" .Name}} <button type="submit" class="danger">Revoke</button></form></td>
2862</tr>{{else}}<tr><td colspan="6">no tokens</td></tr>{{end}}
2863</table>
2864<form method="post" action="/settings" class="actions">
2865<input type="hidden" name="field" value="token-create">
2866<input type="text" name="name" aria-label="Token name" placeholder="name, e.g. iphone" required>
2867<select name="scope" aria-label="Scope">
2868 <option value="read" selected>read</option>
2869 <option value="full">full</option>
2870</select>
2871<input type="text" name="ttl" aria-label="Expires after" placeholder="expires after, e.g. 30d (optional)">
2872<button type="submit" class="btn">Create token</button>
2873</form>
2874</section>
2875```
2876
2877- [ ] **Step 7: Run**
2878
2879Run: `go test ./internal/httpd -run 'TestAccountPageListsTokens|TestAccountSubmitTokenCreateDefaultsToReadScope|TestAccountSubmitTokenRevokeRequiresConfirm' -count=1 && go test ./internal/httpd -count=1`
2880Expected: PASS.
2881
2882- [ ] **Step 8: Commit**
2883
2884```bash
2885git add internal/httpd/flash.go internal/httpd/account.go internal/web/templates/account.html internal/httpd/account_test.go
2886git commit -m "web: Settings → Tokens — create, list, revoke API tokens" -m "Ref #264"
2887```
2888
2889### Task 7.2: `registered.html` next steps as a numbered list
2890
2891**Files:**
2892- Modify: `internal/web/templates/registered.html`
2893- Test: a render test in whatever file covers signup
2894 (`grep -rln '"registered.html"' internal/httpd/*_test.go`; create one
2895 if none exists)
2896
2897- [ ] **Step 1: Write the failing test**
2898
2899```go
2900func TestRegisteredPageNumberedStepsAndTokenMention(t *testing.T) {
2901 var sb strings.Builder
2902 if err := web.Render(&sb, "registered.html", struct {
2903 basePage
2904 Username, Message, Host string
2905 }{Username: "alice", Host: "gitbay.org"}); err != nil {
2906 t.Fatalf("render: %v", err)
2907 }
2908 out := sb.String()
2909 if !strings.Contains(out, "<ol>") {
2910 t.Error("next steps are not a numbered list")
2911 }
2912 if !strings.Contains(out, "Settings → Tokens") {
2913 t.Error("no mention of Settings → Tokens for the iOS app")
2914 }
2915}
2916```
2917
2918- [ ] **Step 2: Run and see it fail**
2919
2920Run: `go test ./internal/httpd -run TestRegisteredPageNumberedStepsAndTokenMention -count=1`
2921Expected: FAIL.
2922
2923- [ ] **Step 3: Rewrite the section**
2924
2925Find (`registered.html:7-10`):
2926
2927```html
2928<h2>On the web</h2>
2929<p>Check your mail for the code, <a href="/login">sign in</a> with an emailed
2930link, and paste the code under <a href="/settings">Settings</a>. Then +
2931creates your first repository.</p>
2932```
2933
2934Replace:
2935
2936```html
2937<h2>On the web</h2>
2938<ol>
2939<li>Copy the verification code from the mail you were just sent.</li>
2940<li><a href="/login">Sign in</a> with an emailed link.</li>
2941<li>Paste the code in <a href="/settings#emails">Settings → Email</a>.</li>
2942</ol>
2943<p>Then + creates your first repository. Using the iOS app? Create a
2944token in <a href="/settings#tokens">Settings → Tokens</a>.</p>
2945```
2946
2947(`#emails` matches the existing section id in `account.html`; confirm
2948with `grep -n 'id="email' internal/web/templates/account.html` — it may
2949be `id="emails"` plural or singular, match whichever is actually there.)
2950
2951- [ ] **Step 4: Run**
2952
2953Run: `go test ./internal/httpd -run TestRegisteredPageNumberedStepsAndTokenMention -count=1 && go test ./internal/httpd -count=1`
2954Expected: PASS.
2955
2956- [ ] **Step 5: Commit**
2957
2958```bash
2959git add internal/web/templates/registered.html internal/httpd/*_test.go
2960git commit -m "web: registered page's next steps as a numbered list, with a token mention for the iOS app" -m "Ref #264"
2961```
2962
2963### Task 7.3: update Parity
2964
2965**Files:**
2966- Modify: `.gitbay/wiki/Parity.org`
2967
2968- [ ] **Step 1: Fix the API token mint row**
2969
2970Find:
2971
2972```
2973| API token mint | yes | no | no |
2974```
2975
2976Replace:
2977
2978```
2979| API token mint | yes | yes | no |
2980```
2981
2982(The iOS side — linking to this page from sign-in — is filed separately
2983in `krz/gitbay-ios`, per the issue text, so its column stays `no` here.)
2984
2985- [ ] **Step 2: Commit**
2986
2987```bash
2988git add .gitbay/wiki/Parity.org
2989git commit -m "wiki: Parity reflects the web API-token page" -m "Closes #264"
2990```
2991
2992### Task 7.4: open MR 7
2993
2994```bash
2995git push -u origin web-api-tokens
2996gitbay mr create --source web-api-tokens --target main --title "Web: Settings → Tokens page"
2997```
2998
2999Wait for CI, merge, delete the branch both places.
3000
3001---
3002
3003## Self-review
3004
3005**Spec coverage** (against the seven issue texts):
3006- #261: FK check ordering (Task 1.1), pin/watch dispatch (Task 1.2),
3007 Cache-Control (Task 1.3), all four doc-drift bullets (Task 1.4). ✓.
3008- #263: whoami line, token line both forms, auth summary, registry test
3009 (Tasks 2.1-2.3). ✓.
3010- #264: create/list/revoke with confirmfield, scope defaults to read on
3011 this page regardless of the command default, registered.html numbered
3012 steps + token mention, Parity (Tasks 7.1-7.3). ✓.
3013- #269: range-diff page, compare-to-previous per row, Parity (Tasks
3014 3.1-3.3). ✓.
3015- #270: the empty-state table (Task 4.1), MR list contribution hint
3016 (Task 4.2), search caption + tab zero-count rule (Task 4.3). ✓.
3017- #271: issue form fields, Muted reachable, rail/More unification,
3018 Discussion heading, Live note (Tasks 5.1-5.5). ✓.
3019- #283: `_raw` fix and test (Task 6.1). ✓.
3020
3021**Placeholder scan:** no task stops short of real code. The three points
3022this plan's first draft could not pin down from the code — `group()`'s
3023help-rendering mechanism (Task 2.2), `issue create`'s flag set (Task
30245.1), and how an `internal/httpd` test authenticates a GET as a given
3025viewer (Task 4.2) — were each resolved by reading the relevant source
3026(`cmd/gitbay/main.go`'s `group()`/`serverHelp()`, `internal/control/help.go`'s
3027`runHelp()`, `internal/control/issue.go`'s `issue create`/`issue
3028milestone`/`issue assign` registrations, and `internal/httpd/logincookie_test.go`'s
3029`sessionCookieFor` plus `store.CreateWebSession`) before this plan was
3030finished; the tasks above carry the resolved code directly, not a
3031placeholder.
3032
3033**Type consistency:** `railItem` (Task 5.3) is used identically in
3034`web.go` and `layout.html`; `accountToken` (Task 7.1) fields match the
3035template's `.Name`/`.Scope`/`.Created`/`.Expires`/`.LastUsed` access;
3036`mrRangeDiff`'s render struct (Task 3.1) matches `mrrangediff.html`'s
3037`.MR`/`.Diff`/`.Error`.
3038
3039## Open questions
3040
3041None outstanding — the three research gaps found while first drafting
3042this plan (Task 2.2's help mechanism, Task 4.2's session-cookie test
3043fixture, Task 5.1's `issue create` flag set) were each resolved by
3044reading the relevant source before this plan was finished; see
3045"Placeholder scan" above for what was read.