Web UI/UX sweep (#182) !386

merged merged by cmc on 2026-09-12 05:37 UTC · krz/gitbay:ux-sweep into main

53 files changed, +1343 −130

Layout: unified · split

CHANGELOG.org +25
@@ -4,6 +4,31 @@ Versioning follows semver from v0.1.0. Database migrations run
44automatically on daemon start; upgrade notes appear per release when
55anything beyond "replace the binary and restart" is needed.
66
7* v1.21.0 — unreleased
8
9The web UI/UX sweep (#182).
10
11- Destructive controls ask for the object's name typed beside the
12 button: release delete, snippet delete and file remove, team delete,
13 label remove, and SSH key, email and PGP key removal. Reversible
14 controls keep a plain button.
15- Login returns to the page that asked for it, and says so.
16- One timestamp format everywhere, =2006-01-02 15:04 UTC=.
17- Tags on the refs page and in the release form are in version order,
18 newest first.
19- The file editor explains up front when signed commits or
20 merge-requests-only protection would refuse the commit, answers 404
21 for a branch that does not exist, and says when a path is new.
22- Merge refusals name the strategy rather than the flag; an issue
23 closed by a commit reads "closed by <who> in commit <sha>".
24- Repository settings: every Save names its field. Labels: the colour
25 column appears only when it means something. Sidebars use one shape
26 for empty. List rows show the state only under "all" and say "in
27 <milestone>". Global search counts its results. A merged MR shows
28 its merged head and a deleted source branch. The repository home
29 shows the SSH clone URL beside HTTPS. The settings page names
30 =auth token create=.
31
732* v1.20.1 — 2026-09-11
833
934One config check.
docs/plans/2026-09-11-web-ux-sweep.md added +688
@@ -0,0 +1,688 @@
1# Web UI/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:** Fix the seventeen findings posted on #182 under the rules in the spec, one commit per pattern, on one branch. Closes #182.
6
7**Architecture:** Every change is in the web layer (`internal/httpd`, `internal/web/templates`, `internal/web/static/style.css`) except three message rewrites at their source in `internal/control` and one sort helper in `internal/gitutil`. No new commands, no schema change. Web forms keep dispatching control commands; the typed confirmation is a check in the handler before dispatch.
8
9**Tech Stack:** Go, Go `html/template`, the control registry, the e2e harness in `e2e/` (real sshd and HTTP against a temp instance; `startInstanceWith(t, "[web]\nmode = \"accounts\"\n")`, `inst.login(t, key)`, `browserGet`, `browserPost`, `inst.get`).
10
11**Spec:** `docs/specs/2026-09-11-web-ux-sweep-design.md`
12
13## Global Constraints
14
15- No JavaScript in templates: the instance CSP is `script-src 'none'`.
16- Every `<input>`/`<textarea>`/`<select>` a person uses carries an `aria-label` or a `<label for>` (`internal/httpd/inputlabels_test.go`); one `<h1>` per page.
17- Every `Mutating: true` route stays wrapped in `checkOrigin`; no new routes in this plan.
18- Web handlers never reimplement a rule; a refused command's message reaches the page through the flash (`s.setFlash` / `s.done` / `backTo`).
19- Never mention an assistant or model anywhere: commit messages, comments, docs.
20- Commit messages: imperative subject, a body only where a why is needed, `Ref #182` as the last line; the docs task's commit ends `Closes #182`.
21- Run locally: `go build ./... && go vet ./...`, `go test ./internal/httpd ./internal/web ./internal/control ./internal/gitutil`, and only the e2e tests the task names. CI on bay1 runs the whole suite.
22- Before changing any user-visible string, grep `e2e/` and `internal/httpd/*_test.go` for it and update the assertions in the same commit; a task's step says which strings.
23- Plain-sentence comments; match the surrounding code.
24- Work on branch `ux-sweep` in the worktree `/Users/cmc/git/krz/gitbay-ux`, which already holds the spec.
25
26Facts every task can rely on (from reading the tree at 3e09d58):
27
28- `s.setFlash(w, msg)` / `s.takeFlash(w, r)` / `s.clearCookie(name, sameSite)` are in `internal/httpd/flash.go`; pages render the flash as `{{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}}`.
29- `s.done(w, r, code, msg, redirectFn)` in `internal/httpd/control.go:57`; `s.backTo(w, r, page, msg)` in `internal/httpd/releaseactions.go:15` redirects to `/{owner}/{repo}/{page}` with the flash.
30- Template helpers live in `internal/web/web.go` (`funcs`, line 62): `when(s string) string` parses RFC3339Nano and formats `2006-01-02 15:04`; `ago(t time.Time) string` is relative.
31- `repoPage` is built in `repoFor` (`internal/httpd/web.go:289`, fields set around line 341: `Host: s.cfg.SiteHost()`, `CloneURL: s.cfg.Server.SiteURL + "/" + repo.Path() + ".git"`).
32
33---
34
35### Task 1: Typed confirmation on destructive controls
36
37**Files:**
38- Create: `internal/httpd/confirm.go`
39- Modify: `internal/web/templates/account.html:34,64,90`; `internal/httpd/account.go:150-182`
40- Modify: `internal/web/templates/releases.html:35-38`; `internal/httpd/releaseactions.go:34-38`
41- Modify: `internal/web/templates/snippet.html:22-25,46-48`; `internal/httpd/snippets.go:215,227`
42- Modify: `internal/web/templates/owner.html:98-102`; `internal/httpd/orgweb.go:82`
43- Modify: `internal/web/templates/labels.html:15-19`; `internal/httpd/labels.go:50-53`
44- Modify: `internal/web/static/style.css` (one rule)
45- Test: `e2e/accountweb_test.go:78`, `e2e/releaseweb_test.go:102`, `e2e/snippetweb_test.go:141,148,173`, `e2e/labelweb_test.go:56`, `e2e/orgweb_test.go` (new team-delete steps)
46
47**Interfaces:**
48- Produces `func confirmed(r *http.Request, want string) (ok bool, msg string)` in `internal/httpd/confirm.go`: `ok` when `strings.TrimSpace(r.FormValue("confirm")) == want`; otherwise `msg` is `type ` + want + ` to confirm`.
49- Produces the template partial `confirmfield` in `internal/web/templates/layout.html`: `{{define "confirmfield"}}<input type="text" name="confirm" aria-label="Type {{.}} to confirm" placeholder="type {{.}} to confirm" size="{{len .}}" autocomplete="off">{{end}}`, called as `{{template "confirmfield" "v1.0"}}`.
50
51What each control asks for (the `want` value), from the spec:
52
53| control | template | want |
54|---|---|---|
55| SSH key remove | account.html:34 | the 8 characters after `SHA256:` in the fingerprint |
56| email remove | account.html:64 | the address |
57| PGP key remove | account.html:90 | the first 8 characters of the fingerprint |
58| release delete | releases.html:35 | the tag |
59| snippet delete | snippet.html:46 | the snippet's public id |
60| snippet file remove | snippet.html:22 | the file name |
61| team delete | owner.html:98 | the team name |
62| label remove | labels.html:15 | the label |
63
64Note the spec says the SSH key's label; a key's label can be empty, so the fingerprint prefix is used instead and the spec's Rules section is amended in this task (one line).
65
66- [ ] **Step 1: Write the failing e2e assertions**
67
68In `e2e/labelweb_test.go` around line 56, replace the label-remove post with two posts:
69
70```go
71 // Removing a label needs its name typed; a bare post is refused and
72 // the label stays.
73 _, body := browserPost(t, alice, base+"/labels", url.Values{
74 "action": {"remove"}, "name": {"bug"}})
75 if !strings.Contains(body, "type bug to confirm") {
76 t.Fatalf("unconfirmed remove was not refused:\n%s", body)
77 }
78 if out, _, _ := inst.ssh(t, aliceKey, "", "label", "list", "alice/app", "--json"); !strings.Contains(out, `"name":"bug"`) {
79 t.Fatalf("label removed without confirmation: %s", out)
80 }
81 if status, _ := browserPost(t, alice, base+"/labels", url.Values{
82 "action": {"remove"}, "name": {"bug"}, "confirm": {"bug"}}); status != 200 {
83 t.Fatal("label remove failed")
84 }
85```
86
87In `e2e/releaseweb_test.go` around line 102, the same shape: a post without `confirm` gets a body containing `type v1.0 to confirm` and `release list --json` still lists `v1.0`; then the post with `"confirm": {"v1.0"}` succeeds and the existing "still listed after delete" assertion stays.
88
89In `e2e/accountweb_test.go` around line 78, the key-remove post: first without `confirm`, assert the response body contains `to confirm` and `keys list --json` still lists the fingerprint; then with `"confirm": {prefix}` where `prefix := strings.TrimPrefix(fp, "SHA256:")[:8]`; keep the existing assertion that the key is gone.
90
91In `e2e/snippetweb_test.go`: line 141 (file remove `b.txt`) adds `"confirm": {"b.txt"}`; line 148 (the last-file refusal) adds `"confirm": {"notes.md"}` so the refusal under test is still the command's; line 173 (delete) becomes `url.Values{"confirm": {created}}`; and before line 173 add a post with no `confirm` asserting the body contains `type `+created+` to confirm` and `snippet show` still exits 0.
92
93In `e2e/orgweb_test.go` after the team-revoke step (around line 86), add: a post with `"field": {"team-delete"}, "team": {"builders"}` and no `confirm` whose body contains `type builders to confirm`, then `org team show acme builders --json` still exits 0; then the same post with `"confirm": {"builders"}`, after which `org team show` exits 3.
94
95- [ ] **Step 2: Run them to see them fail**
96
97Run: `go test ./e2e -run 'TestLabelsWeb$|TestReleaseAndBuildWeb$|TestAccountSettingsWeb$|TestSnippetsWeb$|TestOrgManagementWeb$'`
98Expected: each new "unconfirmed … was not refused" assertion fails, because the handlers act without a confirm field.
99
100- [ ] **Step 3: The helper and the partial**
101
102`internal/httpd/confirm.go`:
103
104```go
105package httpd
106
107import (
108 "net/http"
109 "strings"
110)
111
112// confirmed reports whether the form typed want into its confirm field.
113// It guards controls that destroy data nothing else holds; the person
114// is already authorised, so this is a check against a slip, not a
115// permission.
116func confirmed(r *http.Request, want string) (bool, string) {
117 if strings.TrimSpace(r.FormValue("confirm")) == want {
118 return true, ""
119 }
120 return false, "type " + want + " to confirm"
121}
122```
123
124Add the `confirmfield` partial to `internal/web/templates/layout.html` next to `formatpicker` (line 132), exactly as in Interfaces.
125
126- [ ] **Step 4: Handlers**
127
128Each handler checks before dispatch and reports through the page's existing failure path:
129
130- `internal/httpd/account.go`: in `case "key-remove"` compute `want := strings.TrimPrefix(r.FormValue("fingerprint"), "SHA256:")`, `if len(want) > 8 { want = want[:8] }`; `if ok, msg := confirmed(r, want); !ok { back(msg, ""); return }` before `s.runControl`. `case "pgp-remove"`: `want` is the fingerprint's first 8 characters. `case "email-remove"`: `want` is `r.FormValue("address")`. Read `back`'s signature at account.go:150 and call it as the other failures do.
131- `internal/httpd/releaseactions.go:34`: in the delete branch, `if ok, msg := confirmed(r, tag); !ok { back(w, r, msg); return }`.
132- `internal/httpd/snippets.go`: in `snippetDeleteSubmit` check against `r.PathValue("id")`; in `snippetFileRemoveSubmit` against the trimmed `name`. On refusal `s.setFlash(w, msg)` and redirect to the snippet page, as `snippetAction`'s back closure does.
133- `internal/httpd/orgweb.go:82`: in `case "team-delete"` check against `team`; on refusal `back(msg); return`.
134- `internal/httpd/labels.go:51`: inside `if r.FormValue("action") == "remove"`, check against `name`; on refusal `s.backTo(w, r, "labels", msg); return`.
135
136- [ ] **Step 5: Templates**
137
138Put `{{template "confirmfield" X}}` immediately before the button in each form, with X the same value the handler wants:
139
140- account.html:34 key remove: `{{template "confirmfield" (slice (trimSHA .Fingerprint) 0 8)}}` needs no new helper if you compute the prefix in Go instead: add `Confirm string` beside `Fingerprint` in the key row struct the account page builds (find it in `internal/httpd/account.go`'s GET handler) and use `{{template "confirmfield" .Confirm}}`. Same for the PGP row (`Confirm` = first 8 of the fingerprint). Email: `{{template "confirmfield" .Address}}`.
141- releases.html:35: `{{template "confirmfield" $rel.Tag}}`.
142- snippet.html:22: `{{template "confirmfield" .Name}}`; :46: `{{template "confirmfield" .Snippet.PublicID}}`.
143- owner.html:98: `{{template "confirmfield" .Name}}` (the team's name in that range).
144- labels.html:15: `{{template "confirmfield" .Name}}`.
145
146Style: in `internal/web/static/style.css` add `input[name="confirm"] { width: auto; margin-right: var(--sp-2); }` near the other form rules (grep `.inline` to find them; use the spacing token the neighbours use).
147
148- [ ] **Step 6: Spec line**
149
150In `docs/specs/2026-09-11-web-ux-sweep-design.md`, Rules, change "SSH key remove (the key's label)" to "SSH key remove (the 8 characters after `SHA256:` in the fingerprint; a label can be empty)".
151
152- [ ] **Step 7: Run the tests**
153
154Run: `go build ./... && go vet ./... && go test ./internal/httpd && go test ./e2e -run 'TestLabelsWeb$|TestReleaseAndBuildWeb$|TestAccountSettingsWeb$|TestSnippetsWeb$|TestOrgManagementWeb$'`
155Expected: PASS. `internal/httpd`'s input-label test sees the new input's `aria-label`.
156
157- [ ] **Step 8: Commit**
158
159```bash
160git add internal/httpd/confirm.go internal/httpd/account.go internal/httpd/releaseactions.go internal/httpd/snippets.go internal/httpd/orgweb.go internal/httpd/labels.go internal/web/templates/layout.html internal/web/templates/account.html internal/web/templates/releases.html internal/web/templates/snippet.html internal/web/templates/owner.html internal/web/templates/labels.html internal/web/static/style.css docs/specs/2026-09-11-web-ux-sweep-design.md e2e/accountweb_test.go e2e/releaseweb_test.go e2e/snippetweb_test.go e2e/labelweb_test.go e2e/orgweb_test.go
161git commit -m "web: type the name to confirm a destructive control
162
163Release delete, snippet delete and file remove, team delete, label
164remove, and SSH key, email and PGP key removal ask for the object's
165name in a text field; the handler refuses a mismatch with a flash.
166Reversible controls keep a plain button.
167
168Ref #182"
169```
170
171---
172
173### Task 2: Login returns to the page that asked for it
174
175**Files:**
176- Modify: `internal/httpd/flash.go` (two helpers)
177- Modify: `internal/httpd/accounts.go:48-57` (`requireUser`), `:117-145` (`login`), and `renderLogin`
178- Modify: `internal/web/templates/login.html:3-4`
179- Test: `e2e/websessions_test.go` (extend `TestWebSessionsListRevoke`)
180
181**Interfaces:**
182- Produces `setNext(w, path string)` and `takeNext(w, r) string` in `flash.go`, cookie name `gitbay_next`, `MaxAge: 600`, same flags as the flash cookie. `takeNext` returns `""` unless the value starts with `/` and not `//`.
183- `renderLogin` gains a `next string` argument rendered as `Next`.
184
185- [ ] **Step 1: Write the failing e2e test**
186
187Append to the session test in `e2e/websessions_test.go`, using its existing instance and key (read the file first; it starts an accounts-mode instance and mints a login link):
188
189```go
190 // An anonymous visit to a page that needs a session lands on the
191 // login page, which says where the visitor was going; the login
192 // link then returns them there.
193 anon := newBrowser(t)
194 status, body := browserGet(t, anon, inst.base()+"/settings")
195 if status != 200 || !strings.Contains(body, "continue to <code>/settings</code>") {
196 t.Fatalf("login page without the destination: %d\n%s", status, body)
197 }
198 out, _, _ := inst.ssh(t, aliceKey, "", "web", "login", "--json")
199 var env struct {
200 Data struct {
201 URL string `json:"url"`
202 } `json:"data"`
203 }
204 json.Unmarshal([]byte(out), &env)
205 link := inst.base() + env.Data.URL[strings.Index(env.Data.URL, "/login"):]
206 if status, body := browserGet(t, anon, link); status != 200 || !strings.Contains(body, "Account settings") {
207 t.Fatalf("login did not return to /settings: %d\n%s", status, body)
208 }
209 // The destination is used once.
210 if _, body := browserGet(t, anon, inst.base()+"/login"); strings.Contains(body, "continue to") {
211 t.Fatal("next survived its use")
212 }
213```
214
215Adjust `aliceKey` to the key variable the test already has, and add `encoding/json` to the imports if missing.
216
217- [ ] **Step 2: Run it to see it fail**
218
219Run: `go test ./e2e -run 'TestWebSessionsListRevoke$'`
220Expected: FAIL at "login page without the destination".
221
222- [ ] **Step 3: Cookie helpers**
223
224In `internal/httpd/flash.go`, after `takeFlash`:
225
226```go
227const nextCookie = "gitbay_next"
228
229// setNext remembers the local path an anonymous visitor asked for, so
230// the login that follows can return there. Only a GET path is stored:
231// a POST must not be replayed.
232func (s *Server) setNext(w http.ResponseWriter, path string) {
233 if !strings.HasPrefix(path, "/") || strings.HasPrefix(path, "//") || len(path) > 300 {
234 return
235 }
236 http.SetCookie(w, &http.Cookie{
237 Name: nextCookie, Value: url.QueryEscape(path), Path: "/",
238 HttpOnly: true, SameSite: http.SameSiteLaxMode,
239 Secure: s.cfg.HTTP.TLS != "off", MaxAge: 600,
240 })
241}
242
243// takeNext returns the remembered path once and clears it. Anything
244// that is not a local path comes back empty.
245func (s *Server) takeNext(w http.ResponseWriter, r *http.Request) string {
246 c, err := r.Cookie(nextCookie)
247 if err != nil || c.Value == "" {
248 return ""
249 }
250 http.SetCookie(w, s.clearCookie(nextCookie, http.SameSiteLaxMode))
251 p, err := url.QueryUnescape(c.Value)
252 if err != nil || !strings.HasPrefix(p, "/") || strings.HasPrefix(p, "//") {
253 return ""
254 }
255 return p
256}
257
258// peekNext reads the remembered path without clearing it, for the
259// login page to say where the visitor is going.
260func (s *Server) peekNext(r *http.Request) string {
261 c, err := r.Cookie(nextCookie)
262 if err != nil {
263 return ""
264 }
265 p, err := url.QueryUnescape(c.Value)
266 if err != nil || !strings.HasPrefix(p, "/") || strings.HasPrefix(p, "//") {
267 return ""
268 }
269 return p
270}
271```
272
273Add `"strings"` to the file's imports if absent.
274
275- [ ] **Step 4: requireUser and login**
276
277`requireUser` (accounts.go:48): before the redirect, `if r.Method == http.MethodGet { s.setNext(w, r.URL.RequestURI()) }`.
278
279`login` (accounts.go:117): the no-token branch passes `s.peekNext(r)` into `renderLogin`; the success branch replaces `http.Redirect(w, r, "/", ...)` with:
280
281```go
282 dest := s.takeNext(w, r)
283 if dest == "" {
284 dest = "/"
285 }
286 http.Redirect(w, r, dest, http.StatusSeeOther)
287```
288
289`renderLogin` gains `next string` and puts it in the page struct as `Next`; update its other callers (`loginSubmit` passes `""`).
290
291`login.html` after the `<h1>`: `{{if .Next}}<p class="meta">Log in to continue to <code>{{.Next}}</code>.</p>{{end}}`.
292
293- [ ] **Step 5: Run the tests**
294
295Run: `go build ./... && go vet ./... && go test ./internal/httpd && go test ./e2e -run 'TestWebSessionsListRevoke$|TestEmailLogin'` (the six `TestEmailLogin*` tests consume links too).
296Expected: PASS.
297
298- [ ] **Step 6: Commit**
299
300```bash
301git add internal/httpd/flash.go internal/httpd/accounts.go internal/web/templates/login.html e2e/websessions_test.go
302git commit -m "web: login returns to the page that needed it
303
304requireUser remembers a GET path in a short-lived cookie; the login
305page names it and both login paths redirect there once.
306
307Ref #182"
308```
309
310---
311
312### Task 3: One date format
313
314**Files:**
315- Modify: `internal/web/web.go:220-227` (`when`)
316- Modify: `internal/web/templates/commit.html:8`, `log.html:15`, `compare.html:9`, `mr.html:65`, `blame.html:16`, `snippets.html:12`, `snippet.html:5`, `settings.html:147,163`
317- Modify: `internal/httpd/web.go:1516,1564,1890`, `internal/httpd/compare.go:72`, `internal/httpd/web.go:860-862` (blame)
318- Test: `internal/web/web_test.go` (create if absent) and the e2e assertions the grep in Step 1 finds
319
320**Interfaces:**
321- `when` renders `2006-01-02 15:04 UTC`; input stays RFC3339/RFC3339Nano.
322
323- [ ] **Step 1: Find every assertion on the old formats**
324
325Run: `grep -rn '[0-9]\{4\}-[0-9]\{2\}-[0-9]\{2\} [0-9]\{2\}:[0-9]\{2\}' e2e/ internal/httpd/*_test.go internal/web/*_test.go | grep -v 'Z"'` and `grep -rn '"when"\|when(' internal/web/*_test.go`. List the hits in the report; each is updated in Step 5.
326
327- [ ] **Step 2: Write the failing unit test**
328
329`internal/web/web_test.go` (append, or create with `package web`):
330
331```go
332func TestWhenNamesTheZone(t *testing.T) {
333 got := funcs["when"].(func(string) string)("2026-09-12T02:18:07.123Z")
334 if got != "2026-09-12 02:18 UTC" {
335 t.Fatalf("when: %q", got)
336 }
337 if got := funcs["when"].(func(string) string)("not a time"); got != "not a time" {
338 t.Fatalf("passthrough: %q", got)
339 }
340}
341```
342
343Run: `go test ./internal/web -run TestWhenNamesTheZone` → FAIL (`2026-09-12 02:18`).
344
345- [ ] **Step 3: The helper**
346
347In `internal/web/web.go:226` change the format to `"2006-01-02 15:04 UTC"`.
348
349- [ ] **Step 4: The pages**
350
351- `commit.html:8`: `{{when .Date}}` (the value is already RFC3339 from `web.go:1564`).
352- `log.html:15`, `compare.html:9`, `mr.html:65`, `blame.html:16`: `{{when .Date}}`, and change the four producers to emit RFC3339 instead of `2006-01-02`: `web.go:1516`, `compare.go:72`, `web.go:1890`, `web.go:860-862` (each is a `time.Unix(...).UTC().Format("2006-01-02")` or a re-parse; make it `.Format(time.RFC3339)`). Read each site; if one already carries a `time.Time`, format it once.
353- `snippets.html:12` → `{{when .UpdatedAt}}`; `snippet.html:5` → `updated {{when .Snippet.UpdatedAt}}`.
354- `settings.html:147` → `{{when .Deps.LastCheck}}`; `:163` → `last poll {{when .LastSeen}}`.
355- Tree and blob listings keep `ago`; where `ago` is used in `tree.html`, add `title="{{when .When}}"` on the element if the row carries the raw time (read the row struct; if it only has a `time.Time`, add a `whenT` helper: `"whenT": func(t time.Time) string { return t.UTC().Format("2006-01-02 15:04 UTC") }` and use it in the title). Skip the title if the tree row has no time value at all; say so in the report.
356
357- [ ] **Step 5: Update the assertions from Step 1, run the tests**
358
359Run: `go build ./... && go vet ./... && go test ./internal/web ./internal/httpd && go test ./e2e -run '<the tests whose assertions changed>|TestSnippetsWeb$'`
360Expected: PASS.
361
362- [ ] **Step 6: Commit**
363
364```bash
365git add internal/web/web.go internal/web/web_test.go internal/web/templates internal/httpd e2e
366git commit -m "web: one timestamp format, with the zone named
367
368when renders 2006-01-02 15:04 UTC on every page; commit, log,
369compare, blame, snippet and settings pages use it instead of
370date-only, ISO, or raw stored strings.
371
372Ref #182"
373```
374
375---
376
377### Task 4: Version-aware tag order
378
379**Files:**
380- Create: `internal/gitutil/versions.go`, `internal/gitutil/versions_test.go`
381- Modify: `internal/httpd/web.go:1958-1969` (`refs`) and `:639-646` (`FreeTags` for the release form)
382
383**Interfaces:**
384- Produces `func SortVersions(refs []Ref)` in `internal/gitutil`: in place, newest version first; refs that do not parse as a version follow, by name ascending.
385
386- [ ] **Step 1: Write the failing unit test**
387
388`internal/gitutil/versions_test.go`:
389
390```go
391package gitutil
392
393import "testing"
394
395func TestSortVersionsNewestFirst(t *testing.T) {
396 refs := []Ref{{Name: "v1.2.0"}, {Name: "v1.10.0"}, {Name: "nightly"}, {Name: "v1.2.1"}, {Name: "v0.9"}, {Name: "beta"}, {Name: "2.0.0"}}
397 SortVersions(refs)
398 var got []string
399 for _, r := range refs {
400 got = append(got, r.Name)
401 }
402 want := []string{"2.0.0", "v1.10.0", "v1.2.1", "v1.2.0", "v0.9", "beta", "nightly"}
403 for i := range want {
404 if i >= len(got) || got[i] != want[i] {
405 t.Fatalf("order %v, want %v", got, want)
406 }
407 }
408}
409```
410
411Run: `go test ./internal/gitutil -run TestSortVersionsNewestFirst` → compile error, `SortVersions` undefined.
412
413- [ ] **Step 2: The helper**
414
415`internal/gitutil/versions.go`:
416
417```go
418package gitutil
419
420import (
421 "sort"
422 "strconv"
423 "strings"
424)
425
426// version parses "v1.2.3" or "1.2" into numeric parts. Anything else is
427// not a version.
428func version(name string) ([]int, bool) {
429 s := strings.TrimPrefix(name, "v")
430 if s == "" {
431 return nil, false
432 }
433 var parts []int
434 for _, p := range strings.Split(s, ".") {
435 n, err := strconv.Atoi(p)
436 if err != nil || n < 0 {
437 return nil, false
438 }
439 parts = append(parts, n)
440 }
441 return parts, true
442}
443
444func versionLess(a, b []int) bool {
445 for i := 0; i < len(a) && i < len(b); i++ {
446 if a[i] != b[i] {
447 return a[i] < b[i]
448 }
449 }
450 return len(a) < len(b)
451}
452
453// SortVersions orders refs newest version first. Names that are not
454// versions follow, by name.
455func SortVersions(refs []Ref) {
456 sort.SliceStable(refs, func(i, j int) bool {
457 vi, oki := version(refs[i].Name)
458 vj, okj := version(refs[j].Name)
459 switch {
460 case oki && okj:
461 return versionLess(vj, vi)
462 case oki != okj:
463 return oki
464 }
465 return refs[i].Name < refs[j].Name
466 })
467}
468```
469
470- [ ] **Step 3: Use it**
471
472In `refs` (`internal/httpd/web.go:1958`): after `tags, _ := gitutil.Refs(p.Dir, "tags")` add `gitutil.SortVersions(tags)`. In the release form's tag list (`web.go:639-646`), sort the tags the same way before filtering so the select offers the newest first.
473
474- [ ] **Step 4: Run the tests**
475
476Run: `go test ./internal/gitutil -run TestSortVersionsNewestFirst && go build ./... && go vet ./... && go test ./e2e -run 'TestWebUI$|TestReleaseAndBuildWeb$'` (if an assertion depends on the old order, update it).
477Expected: PASS.
478
479- [ ] **Step 5: Commit**
480
481```bash
482git add internal/gitutil/versions.go internal/gitutil/versions_test.go internal/httpd/web.go
483git commit -m "web: refs and the release form order tags by version
484
485Ref #182"
486```
487
488---
489
490### Task 5: The editor says no before the textarea
491
492**Files:**
493- Modify: `internal/httpd/accounts.go:476-494` (`editForm`)
494- Modify: `internal/web/templates/edit.html`
495- Test: `e2e/accounts_test.go` (`TestWebAccounts` is the test that drives the file editor at `/edit/`; extend it)
496
497**Interfaces:**
498- The edit page struct gains `Blocked string`; when set, the template renders it and no form.
499
500- [ ] **Step 1: Write the failing e2e test**
501
502Append to `TestWebAccounts` in `e2e/accounts_test.go`, after its existing successful edit and using its instance, key and logged-in client:
503
504```go
505 // With signed commits required the editor cannot succeed, so the page
506 // says so instead of offering a textarea.
507 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "settings", "require-signed", "alice/app", "on"); code != 0 {
508 t.Fatalf("require-signed: %s", errOut)
509 }
510 status, body := browserGet(t, alice, inst.base()+"/alice/app/edit/main/README.md")
511 if status != 200 || !strings.Contains(body, "requires signed commits") || strings.Contains(body, "<textarea") {
512 t.Fatalf("edit page under require-signed: %d\n%s", status, body)
513 }
514```
515
516Use the repository path, file and variable names the test already has; the setting's command name is in `internal/control/repo.go` (grep `require-signed`).
517
518- [ ] **Step 2: Run it to see it fail**
519
520Run: `go test ./e2e -run 'TestWebAccounts$'` → FAIL: the page still has a textarea.
521
522- [ ] **Step 3: The handler**
523
524In `editForm`, after `repo` is resolved and before reading the blob, compute:
525
526```go
527 blocked := ""
528 switch {
529 case repo.Settings.RequireSignedCommits:
530 blocked = repo.Path() + " requires signed commits and the web editor cannot sign; edit locally and push a signed commit."
531 case repo.Settings.RequireMR && slices.Contains(repo.Settings.ProtectedBranches, ref):
532 blocked = "branch " + ref + " accepts changes through merge requests only; edit on another branch and open one."
533 }
534```
535
536Pass `Blocked: blocked` in the page struct. Still read the blob; a missing path on a real branch is a new-file form. Add `"slices"` to the imports.
537
538- [ ] **Step 4: The template**
539
540`edit.html`: wrap the `<form>` in `{{if .Blocked}}<p class="empty-note">{{.Blocked}}</p>{{else}} … {{end}}`, keeping the `<h1>` and the error line outside.
541
542- [ ] **Step 5: Run the tests**
543
544Run: `go build ./... && go vet ./... && go test ./internal/httpd && go test ./e2e -run 'TestWebAccounts$'`
545Expected: PASS.
546
547- [ ] **Step 6: Commit**
548
549```bash
550git add internal/httpd/accounts.go internal/web/templates/edit.html e2e/accounts_test.go
551git commit -m "web: the editor explains a refusal before the textarea
552
553Ref #182"
554```
555
556---
557
558### Task 6: Messages a page shows
559
560**Files:**
561- Modify: `internal/control/mr.go:1057,1132,1178`
562- Modify: `internal/control/commitrefs.go:243`
563- Modify: `internal/web/static/style.css` (`.syscomment`)
564- Test: the e2e assertions the greps find (`grep -rn 'strategy merge\|--strategy\|closed by commit' e2e/ internal/`)
565
566Two commits: the merge messages, then the close-event line.
567
568- [ ] **Step 1: Grep the assertions**
569
570Run the grep above; list the hits.
571
572- [ ] **Step 2: Rewrite the three merge refusals in `internal/control/mr.go`**
573
574- line 1057: `"fast-forward not possible: %s has diverged from the MR head; merge with the merge strategy, or rebase and push again"`
575- line 1132: `"the MR contains merge commit %.10s; a rebase merge needs linear history — choose the merge or squash strategy"`
576- line 1178: keep the sentence about the stack and replace `--strategy ff or merge` with `the fast-forward or merge strategy`.
577
578Update the assertions found in Step 1 (they are substring checks; match the new wording). Run `go test ./internal/control && go test ./e2e -run '<the tests that assert them>'` and commit:
579
580```bash
581git commit -am "control: merge refusals name the strategy, not the flag
582
583The same text reaches the web merge form, which has no flags.
584
585Ref #182"
586```
587
588- [ ] **Step 3: The close-event line**
589
590`internal/control/commitrefs.go:243`: `fmt.Sprintf("closed by %s in commit %s: %s", author, link, subject)`. Update any assertion on `closed by commit` (Step 1). In `style.css`, find `.syscomment` and add `.syscomment p { display: inline; margin: 0; }` so the rendered body and the `when` span sit on one line. Run `go test ./internal/control && go test ./e2e -run 'TestCommitMessageIssueActions$'` and commit:
591
592```bash
593git commit -am "control, web: the close event reads closed by <who> in commit <sha>
594
595Ref #182"
596```
597
598---
599
600### Task 7: Small template fixes, one commit each
601
602**Files:**
603- `internal/web/templates/account.html:129`; `landing.html:16`
604- `internal/web/templates/settings.html` (the Save buttons listed below)
605- `internal/web/templates/labels.html:6-13`; `internal/httpd/labels.go:31-37`
606- `internal/web/templates/issue.html:53,65,77`; `mr.html:132,144`
607- `internal/web/templates/issues.html:19-26`; `mrs.html` (the matching row)
608- `internal/web/templates/globalsearch.html:4-19`
609- Tests: `grep -rn` for each changed string in `e2e/` and `internal/httpd/*_test.go`, updated per commit
610
611Do these in order, each its own commit with `Ref #182`:
612
613- [ ] **Step 1: Copy.** `account.html:129`: `gitbay auth token mint --name laptop` → `gitbay auth token create --name laptop`. `landing.html:16`: "have an account? mint a browser session from your terminal:" → "have an account? log in from your terminal:". Commit `web: the settings page names the real token command`.
614
615- [ ] **Step 2: Save buttons name their field.** In `settings.html` change each plain `Save` to: line 12 `Save description`, 18 `Save website`, 26 `Save default branch`, 46 `Save visibility`, 52 `Save git://`, 61 `Save checks`, 67 `Save approvals`, 73 `Save threads`, 79 `Save CODEOWNERS`, 85 `Save signing`, 112 `Save merge-only`, 140 `Save dependency checks`, 185 `Save archive`. Grep `>Save<` in `e2e/settingsweb_test.go` and the httpd tests first; a test that finds the button by its text needs the new text. Commit `web: every Save on repository settings names its field`.
616
617- [ ] **Step 3: Labels colour column only when it means something.** In `labels.go` add `AnyColor bool` to the page struct, true when any label's `Color != ""`. In `labels.html` render the `colour` header and cell only `{{if or $.CanWrite $.AnyColor}}`. Commit `web: the labels page hides an empty colour column`.
618
619- [ ] **Step 4: Empty states.** `issue.html:53` → `none yet`, `:65` → `nobody yet`, `:77` → `none yet`; `mr.html:132` → `nobody yet`, `:144` → `none yet`. Grep `None yet\|Nobody yet\|Nobody asked yet\|No reviews yet` in tests first. Commit `web: one shape for empty sidebars`.
620
621- [ ] **Step 5: Issue and MR rows.** In `issues.html:25` wrap the state chip in `{{if eq $.State "all"}} … {{end}}`; in the meta line change `· <a …>{{.Milestone}}</a>` to `· in <a …>{{.Milestone}}</a>`. Apply the same two changes to the row in `mrs.html`. Grep `chip-open` in tests. Commit `web: list rows show the state only under all, and name the milestone`.
622
623- [ ] **Step 6: Search count.** In `globalsearch.html` after the `<nav>`: `{{if .Query}}<p class="meta">{{len .Results}} {{if eq (len .Results) 1}}result{{else}}results{{end}} for <q>{{.Query}}</q>{{if .Kind}} in {{.Kind}}{{end}}</p>{{end}}`. Keep the existing `no matches` empty note. Commit `web: global search counts its results and echoes the query`.
624
625- [ ] **Step 7: Run the checks once at the end**
626
627`go build ./... && go vet ./... && go test ./internal/httpd && go test ./e2e -run 'TestRepoSettingsWeb$|TestLabelsWeb$|TestIssueWebTriage$|TestMRWebReviewLoop$|TestGlobalSearchAndNotificationsWeb$'`.
628
629---
630
631### Task 8: MR source line and both clone URLs
632
633**Files:**
634- Modify: `internal/httpd/web.go:1815-1920` (`mr` handler) and `internal/web/templates/mr.html:162-166`
635- Modify: `internal/httpd/web.go:341-345` (`repoPage` in `repoFor`) and `internal/web/templates/tree.html:13`
636- Test: `e2e/mrweb_test.go`, `e2e/web_test.go` (extend)
637
638Two commits.
639
640- [ ] **Step 1: MR source line.** In the `mr` handler compute `SourceGone bool`: true when `m.State == "source_gone"`, or when `m.SourcePath == ""` and `gitutil.ResolveRef(p.Dir, "refs/heads/"+m.SourceRef)` errors. Pass it in the page struct. In `mr.html:165` render: `into <code>{{.MR.TargetRef}}</code> · {{if eq .MR.State "merged"}}merged at{{else}}head{{end}} <code>{{short .MR.HeadSHA}}</code>{{if .SourceGone}} · <span class="chip chip-neutral">branch deleted</span>{{end}}`. Test: in the MR web test after a merge, delete the source branch over git (`git push origin --delete <branch>` with the test's env) and assert the MR page contains `branch deleted` and `merged at`. Commit `web: a merged MR shows its merged head and a deleted source branch`.
641
642- [ ] **Step 2: Both clone URLs.** In `repoFor` add `SSHCloneURL` to `repoPage`: `"ssh://git@" + s.cfg.SiteHost() + port + "/" + repo.Path() + ".git"` where `port` is `""` when `s.cfg.SSH.Port == 22` and `":" + strconv.Itoa(port)` otherwise. In `tree.html:13`: `Clone: <code>git clone {{.SSHCloneURL}}</code> · <code>git clone {{.CloneURL}}</code>`. Test: in `e2e/web_test.go`'s repo-home test assert the body contains `ssh://git@127.0.0.1:` followed by the instance's ssh port (the harness knows it; read `startInstanceWith` for the field). Commit `web: the repository home shows the SSH clone URL beside HTTPS`.
643
644- [ ] **Step 3: Run** `go build ./... && go vet ./... && go test ./internal/httpd && go test ./e2e -run 'TestMRWebReviewLoop$|TestWebUI$'`.
645
646---
647
648### Task 9: Changelog and the issue
649
650**Files:**
651- Modify: `CHANGELOG.org`
652
653- [ ] **Step 1:** Above `* v1.20.1 — 2026-09-11` add:
654
655```org
656* v1.21.0 — unreleased
657
658The web UI/UX sweep (#182).
659
660- Destructive controls ask for the object's name typed beside the
661 button: release delete, snippet delete and file remove, team delete,
662 label remove, and SSH key, email and PGP key removal. Reversible
663 controls keep a plain button.
664- Login returns to the page that asked for it, and says so.
665- One timestamp format everywhere, =2006-01-02 15:04 UTC=.
666- Tags on the refs page and in the release form are in version order,
667 newest first.
668- The file editor explains up front when signed commits or
669 merge-requests-only protection would refuse the commit.
670- Merge refusals name the strategy rather than the flag; an issue
671 closed by a commit reads "closed by <who> in commit <sha>".
672- Repository settings: every Save names its field. Labels: the colour
673 column appears only when it means something. Sidebars use one shape
674 for empty. List rows show the state only under "all" and say "in
675 <milestone>". Global search counts its results. A merged MR shows
676 its merged head and a deleted source branch. The repository home
677 shows the SSH clone URL beside HTTPS. The settings page names
678 =auth token create=.
679```
680
681- [ ] **Step 2: Commit**
682
683```bash
684git add CHANGELOG.org
685git commit -m "CHANGELOG: web UI/UX sweep
686
687Closes #182"
688```
docs/specs/2026-09-11-web-ux-sweep-design.md added +79
@@ -0,0 +1,79 @@
1# Web UI/UX sweep
2
3Closes #182. The findings posted on that issue after walking every route in
4`internal/httpd/routes.go` on gitbay.org at v1.20.1, anonymous and logged
5in, and the rules chosen to fix them.
6
7## Rules
8
9- **Confirmation.** A control that destroys data nothing else holds asks
10 the person to type the object's name into a text field beside the
11 button; the handler refuses with a flash line when the text differs.
12 No JavaScript: the instance CSP is `script-src 'none'`. Covered: release
13 delete (the tag), snippet delete (the id), snippet file remove (the file
14 name), team delete (the team name), label remove (the label), SSH key
15 remove (the 8 characters after `SHA256:` in the fingerprint; a label
16 can be empty), email remove (the address), PGP key remove (the first
17 8 characters of the fingerprint). Reversible state keeps a
18 plain button: close/reopen, merge, protect/unprotect, attach/detach,
19 resolve, cancel, make primary, org member remove.
20- **Refusal wording.** Control-command messages a web form can trigger
21 must not name a flag or a CLI command. The message is rewritten at the
22 source, in `internal/control`, since the CLI reads the same text; no
23 rewrite layer in the web.
24- **Login return-to.** `requireUser` stores the requested local path in a
25 short-lived cookie; the login page says where the person is going; the
26 emailed-link and `web login` paths both redirect there once and clear it.
27 Only a path starting with a single `/` is honoured.
28- **Dates.** One absolute format on every page: `2006-01-02 15:04 UTC`
29 through the existing `when` helper. Tree and blob listings keep their
30 relative time with the absolute one in a `title` attribute.
31- **Tags.** Version-aware order on the refs page, newest first; a tag that
32 does not parse as a version sorts after the ones that do, by name.
33- **Editor.** When the repository requires signed commits, or the ref
34 refuses direct pushes, the edit page explains that and shows no form.
35 A branch that does not exist is a 404; a path that does not exist on
36 a real branch is a new-file form that says so, since `commit-file`
37 creates it.
38- **Repository settings.** Every Save names its field.
39- **Empty states.** Sidebars use "none yet" for things and "nobody yet"
40 for people; lists keep their sentence and, where a command creates the
41 thing, name it.
42
43## Findings and their fixes
44
451. Destructive controls without confirmation: the rule above, applied to
46 `account.html`, `settings.html` (no change: unprotect and detach are
47 reversible), `owner.html` (team delete only), `labels.html`,
48 `releases.html`, `snippet.html`.
492. Refusals verbatim: audit and rewrite.
503. `account.html` says `gitbay auth token mint`; the command is
51 `auth token create`.
524. Login return-to.
535. Four date formats.
546. Refs page sorts tags as strings.
557. Editor offered where it cannot succeed.
568. Twelve unlabelled Save buttons on repository settings.
579. Labels page: colour column shown when no label has a colour; the
58 column and the per-row colour form appear only when a label has a
59 colour or the viewer can write.
6010. Empty-state wording.
6111. Issue close-event line reads "closed by commit X by Y"; becomes
62 "closed by Y in commit X" with the time inline.
6312. Issue rows repeat the state chip under a single-state filter; the
64 milestone reads like a label. The chip appears only under "all"; the
65 milestone renders as "in <milestone>".
6613. Merged MR page: "at" becomes "merged at"; a source branch that no
67 longer exists is marked "branch deleted".
6814. Global search shows a count and the query. No context line.
6915. Repository home shows both clone URLs, HTTPS and SSH.
7016. Landing page: "mint a browser session" becomes "log in from your
71 terminal". The account page's markup picker already sits beside its
72 label; that finding was wrong and nothing changes.
7317. Left alone: the anonymous "1 bookmark" stat, since the count is public
74 by design (the bookmarks page says so).
75
76## Out of scope
77
78A context line on global search results (a search backend change), the
79POST-only routes, `/settings/export`, archives, badges and Atom feeds.
e2e/accounts_test.go +17
@@ -129,6 +129,16 @@ func TestWebAccounts(t *testing.T) {
129129 t.Fatalf("edit submit: %d", status)
130130 }
131131
132 // A branch that does not exist is a 404; a path that does not exist
133 // on a real branch is a new-file form that says so.
134 if status, _ := browserGet(t, browser, inst.base()+"/alice/site/edit/nope/notes.txt"); status != 404 {
135 t.Fatalf("edit form on a missing branch: %d", status)
136 }
137 status, body = browserGet(t, browser, inst.base()+"/alice/site/edit/main/new.txt")
138 if status != 200 || !strings.Contains(body, "does not exist on main; committing creates it") || !strings.Contains(body, "<textarea") {
139 t.Fatalf("edit form for a new file: %d\n%s", status, body)
140 }
141
132142 // The edit is a real commit: authored with the verified email, and it
133143 // displays as unsigned — the honest outcome for a server-side commit.
134144 logOut, _, code := inst.ssh(t, aliceKey, "", "repo", "log", "alice/site", "--limit", "1", "--json")
@@ -189,6 +199,13 @@ func TestWebAccounts(t *testing.T) {
189199 t.Fatalf("require-signed web edit not refused:\n%s", body)
190200 }
191201
202 // With signed commits required the editor cannot succeed, so the GET
203 // form says so instead of offering a textarea.
204 status, body = browserGet(t, browser, inst.base()+"/alice/site/edit/main/notes.txt")
205 if status != 200 || !strings.Contains(body, "requires signed commits") || strings.Contains(body, "<textarea") {
206 t.Fatalf("edit page under require-signed: %d\n%s", status, body)
207 }
208
192209 // Issue participation through the web.
193210 if _, _, code := inst.ssh(t, aliceKey, "", "issue", "create", "alice/site", "--title", "'from ssh'"); code != 0 {
194211 t.Fatal("issue create failed")
e2e/accountweb_test.go +14 −2
@@ -72,10 +72,22 @@ func TestAccountSettingsWeb(t *testing.T) {
7272 t.Error("git-scoped key ran a control command")
7373 }
7474
75 // Removing it through the form removes it for SSH too.
75 // Removing it through the form needs the fingerprint's prefix typed
76 // to confirm; a bare post leaves the key in place.
7677 fp := gitScopedFingerprint(t, out)
77 if status, _ := browserPost(t, browser, inst.base()+"/settings", url.Values{
78 prefix := strings.TrimPrefix(fp, "SHA256:")[:8]
79 _, body = browserPost(t, browser, inst.base()+"/settings", url.Values{
7880 "field": {"key-remove"}, "fingerprint": {fp},
81 })
82 if !strings.Contains(body, "to confirm") {
83 t.Fatalf("unconfirmed key remove was not refused:\n%s", body)
84 }
85 out, _, _ = inst.ssh(t, aliceKey, "", "keys", "list", "--json")
86 if !strings.Contains(out, fp) {
87 t.Fatalf("key removed without confirmation: %s", out)
88 }
89 if status, _ := browserPost(t, browser, inst.base()+"/settings", url.Values{
90 "field": {"key-remove"}, "fingerprint": {fp}, "confirm": {prefix},
7991 }); status != 303 && status != 200 {
8092 t.Fatalf("key remove: %d", status)
8193 }
e2e/commitrefs_test.go +7 −6
@@ -41,7 +41,7 @@ func TestCommitMessageIssueActions(t *testing.T) {
4141 mustGit(t, dir, env, "push", "-q", "origin", "main")
4242
4343 out, _, _ := inst.ssh(t, aliceKey, "", "issue", "show", "alice/app", "1", "--json")
44 if !strings.Contains(out, `"state":"closed"`) || !strings.Contains(out, "closed by commit") {
44 if !strings.Contains(out, `"state":"closed"`) || !strings.Contains(out, "closed by ") {
4545 t.Fatalf("issue 1 not closed by commit: %s", out)
4646 }
4747 // The entry is a system message with a linked sha, not a user comment.
@@ -86,15 +86,15 @@ func TestCommitMessageIssueActions(t *testing.T) {
8686 t.Fatalf("merge: %s", errOut)
8787 }
8888 out, _, _ = inst.ssh(t, aliceKey, "", "issue", "show", "alice/app", "3", "--json")
89 if !strings.Contains(out, `"state":"closed"`) || !strings.Contains(out, "closed by commit") {
89 if !strings.Contains(out, `"state":"closed"`) || !strings.Contains(out, "closed by ") {
9090 t.Fatalf("merge did not close issue 3: %s", out)
9191 }
9292 // This one was authored by an address nobody has verified, so it names
9393 // git's author without inventing a profile link for them.
94 if !strings.Contains(out, "by t:") || strings.Contains(out, "by [t]") {
94 if !strings.Contains(out, "closed by t in commit") || strings.Contains(out, "closed by [t]") {
9595 t.Fatalf("unresolved author should stay plain text: %s", out)
9696 }
97 if strings.Count(out, "closed by commit") != 1 {
97 if strings.Count(out, "closed by ") != 1 {
9898 t.Fatalf("duplicate close comments: %s", out)
9999 }
100100
@@ -126,8 +126,9 @@ func TestCommitMessageIssueActions(t *testing.T) {
126126 t.Fatalf("commit-file: %s", errOut)
127127 }
128128 out, _, _ = inst.ssh(t, aliceKey, "", "issue", "show", "alice/app", "2", "--json")
129 if !strings.Contains(out, `"state":"closed"`) || !strings.Contains(out, "closed by commit") ||
130 !strings.Contains(out, "by [alice](/alice): Closes #2 from the editor") {
129 if !strings.Contains(out, `"state":"closed"`) ||
130 !strings.Contains(out, "closed by [alice](/alice) in commit") ||
131 !strings.Contains(out, ": Closes #2 from the editor") {
131132 t.Fatalf("commit-file did not close issue 2: %s", out)
132133 }
133134 if strings.Count(out, "referenced in commit") != 1 {
e2e/labelweb_test.go +11 −2
@@ -51,9 +51,18 @@ func TestLabelsWeb(t *testing.T) {
5151 t.Errorf("bad colour accepted:\n%s", body)
5252 }
5353
54 // Removing takes the label off the issue too.
54 // Removing a label needs its name typed; a bare post is refused and
55 // the label stays.
56 _, body = browserPost(t, alice, base+"/labels", url.Values{
57 "action": {"remove"}, "name": {"bug"}})
58 if !strings.Contains(body, "type bug to confirm") {
59 t.Fatalf("unconfirmed remove was not refused:\n%s", body)
60 }
61 if out, _, _ := inst.ssh(t, aliceKey, "", "label", "list", "alice/app", "--json"); !strings.Contains(out, `"name":"bug"`) {
62 t.Fatalf("label removed without confirmation: %s", out)
63 }
5564 if status, _ := browserPost(t, alice, base+"/labels", url.Values{
56 "action": {"remove"}, "name": {"bug"}}); status != 200 {
65 "action": {"remove"}, "name": {"bug"}, "confirm": {"bug"}}); status != 200 {
5766 t.Fatal("label remove failed")
5867 }
5968 if out, _, _ := inst.ssh(t, aliceKey, "", "issue", "show", "alice/app", "1", "--json"); strings.Contains(out, `"bug"`) {
e2e/mrweb_test.go +7
@@ -155,6 +155,13 @@ func TestMRWebReviewLoop(t *testing.T) {
155155 if _, err := os.Stat(filepath.Join(dir, "feature.txt")); err != nil {
156156 t.Fatal("merged content missing from main")
157157 }
158 // A merged MR shows its merged head, and marks a source branch that
159 // no longer exists.
160 mustGit(t, bobDir, bobEnv, "push", "-q", "origin", "--delete", "feature")
161 _, body = browserGet(t, alice, mrURL)
162 if !strings.Contains(body, "merged at") || !strings.Contains(body, "branch deleted") {
163 t.Fatalf("merged MR sidebar after the branch was deleted:\n%s", body)
164 }
158165
159166 // Readers get no controls, and a forged POST is refused by the command.
160167 _, anon := browserGet(t, newBrowser(t), mrURL)
e2e/orgweb_test.go +19
@@ -85,6 +85,25 @@ func TestOrgManagementWeb(t *testing.T) {
8585 browserPost(t, alice, inst.base()+"/acme", url.Values{
8686 "field": {"team-revoke"}, "team": {"builders"}, "repo": {"acme/widget"},
8787 })
88
89 // Deleting the team needs its name typed; a bare post is refused and
90 // the team stays.
91 _, body = browserPost(t, alice, inst.base()+"/acme", url.Values{
92 "field": {"team-delete"}, "team": {"builders"},
93 })
94 if !strings.Contains(body, "type builders to confirm") {
95 t.Fatalf("unconfirmed team delete was not refused:\n%s", body)
96 }
97 if _, _, code := inst.ssh(t, aliceKey, "", "org", "team", "show", "acme", "builders", "--json"); code != 0 {
98 t.Fatal("team deleted without confirmation")
99 }
100 browserPost(t, alice, inst.base()+"/acme", url.Values{
101 "field": {"team-delete"}, "team": {"builders"}, "confirm": {"builders"},
102 })
103 if _, _, code := inst.ssh(t, aliceKey, "", "org", "team", "show", "acme", "builders", "--json"); code != 3 {
104 t.Fatalf("team not deleted: exit %d", code)
105 }
106
88107 browserPost(t, alice, inst.base()+"/acme", url.Values{
89108 "field": {"member-remove"}, "user": {"bob"},
90109 })
e2e/releaseweb_test.go +10 −1
@@ -98,8 +98,17 @@ func TestReleaseAndBuildWeb(t *testing.T) {
9898 if _, p := browserGet(t, alice, base+"/releases"); !strings.Contains(p, "Delete release") {
9999 t.Fatalf("owner is not offered the delete control:\n%s", p)
100100 }
101 _, body := browserPost(t, alice, base+"/releases", url.Values{
102 "action": {"delete"}, "tag": {"v1.0"}})
103 if !strings.Contains(body, "type v1.0 to confirm") {
104 t.Fatalf("unconfirmed delete was not refused:\n%s", body)
105 }
106 out, _, _ = inst.ssh(t, aliceKey, "", "release", "list", "alice/app", "--json")
107 if !strings.Contains(out, "v1.0") {
108 t.Fatalf("release removed without confirmation: %s", out)
109 }
101110 if status, _ := browserPost(t, alice, base+"/releases", url.Values{
102 "action": {"delete"}, "tag": {"v1.0"}}); status != 200 {
111 "action": {"delete"}, "tag": {"v1.0"}, "confirm": {"v1.0"}}); status != 200 {
103112 t.Fatal("release delete failed")
104113 }
105114 out, _, _ = inst.ssh(t, aliceKey, "", "release", "list", "alice/app", "--json")
e2e/snippetweb_test.go +30 −4
@@ -72,7 +72,7 @@ func TestSnippetsWeb(t *testing.T) {
7272 if resp.StatusCode != 200 || !strings.HasPrefix(resp.Header.Get("Content-Type"), "text/plain") || resp.Header.Get("X-Content-Type-Options") != "nosniff" {
7373 t.Fatalf("raw headers: %d %v", resp.StatusCode, resp.Header)
7474 }
75 if status, _ := inst.get(t, "/alice/-/snippets/" + public + "/raw/other.go"); status != 404 {
75 if status, _ := inst.get(t, "/alice/-/snippets/"+public+"/raw/other.go"); status != 404 {
7676 t.Fatalf("raw for a missing file: %d", status)
7777 }
7878
@@ -138,14 +138,25 @@ func TestSnippetsWeb(t *testing.T) {
138138 if status, _ := browserPost(t, alice, page+"/file", url.Values{"name": {"b.txt"}, "content": {"b\n"}}); status != 200 {
139139 t.Fatal("file add failed")
140140 }
141 if status, _ := browserPost(t, alice, page+"/file/remove", url.Values{"name": {"b.txt"}}); status != 200 {
141 // Removing a file needs its name typed; a bare post is refused and
142 // the file stays.
143 _, body = browserPost(t, alice, page+"/file/remove", url.Values{"name": {"b.txt"}})
144 if !strings.Contains(body, "type b.txt to confirm") {
145 t.Fatalf("unconfirmed file remove was not refused:\n%s", body)
146 }
147 if _, _, code := inst.ssh(t, aliceKey, "", "snippet", "file", "get", created, "b.txt"); code != 0 {
148 t.Fatalf("b.txt removed without confirmation: exit %d", code)
149 }
150 if status, _ := browserPost(t, alice, page+"/file/remove", url.Values{"name": {"b.txt"}, "confirm": {"b.txt"}}); status != 200 {
142151 t.Fatal("file remove failed")
143152 }
144153 if _, _, code := inst.ssh(t, aliceKey, "", "snippet", "file", "get", created, "b.txt"); code != 3 {
145154 t.Fatalf("b.txt after web remove: exit %d", code)
146155 }
147156 // A refusal comes back on the page as a message, not a bare error.
148 _, body = browserPost(t, alice, page+"/file/remove", url.Values{"name": {"notes.md"}})
157 // The confirmation matches, so the refusal under test is still the
158 // command's last-file rule.
159 _, body = browserPost(t, alice, page+"/file/remove", url.Values{"name": {"notes.md"}, "confirm": {"notes.md"}})
149160 if !strings.Contains(body, `class="error"`) || !strings.Contains(body, "at least one file") {
150161 t.Fatalf("last-file refusal on the page:\n%s", body)
151162 }
@@ -167,10 +178,25 @@ func TestSnippetsWeb(t *testing.T) {
167178 if status, _ := browserGet(t, bob, inst.base()+"/alice/-/snippets/"+unlisted); status != 200 {
168179 t.Fatalf("stranger on an unlisted page: %d", status)
169180 }
181 // An unconfirmed delete on a private snippet is still 404 for a
182 // stranger: the snippet is resolved, and refused, before the
183 // confirmation is even checked.
184 if status, _ := browserPost(t, bob, inst.base()+"/alice/-/snippets/"+private+"/delete", nil); status != 404 {
185 t.Fatalf("stranger's unconfirmed delete on a private snippet: %d", status)
186 }
170187 if status, _ := browserPost(t, bob, inst.base()+"/alice/-/snippets/"+public+"/edit", url.Values{"description": {"x"}, "visibility": {"public"}}); status != 403 {
171188 t.Fatalf("bob editing alice's snippet: %d", status)
172189 }
173 if status, _ := browserPost(t, alice, page+"/delete", nil); status != 200 {
190 // Deleting needs the public id typed to confirm; a bare post leaves
191 // the snippet in place.
192 _, body = browserPost(t, alice, page+"/delete", nil)
193 if !strings.Contains(body, "type "+created+" to confirm") {
194 t.Fatalf("unconfirmed delete was not refused:\n%s", body)
195 }
196 if _, _, code := inst.ssh(t, aliceKey, "", "snippet", "show", created); code != 0 {
197 t.Fatalf("snippet deleted without confirmation: exit %d", code)
198 }
199 if status, _ := browserPost(t, alice, page+"/delete", url.Values{"confirm": {created}}); status != 200 {
174200 t.Fatal("delete failed")
175201 }
176202 if _, _, code := inst.ssh(t, aliceKey, "", "snippet", "show", created); code != 3 {
e2e/web_test.go +4
@@ -85,6 +85,10 @@ func TestWebUI(t *testing.T) {
8585 if status != 200 || !strings.Contains(body, "src/") || !strings.Contains(body, "README.md") {
8686 t.Fatalf("repo home: %d\n%s", status, body)
8787 }
88 // Both clone URLs: SSH for anyone with a key, HTTPS for reading.
89 if !strings.Contains(body, "git clone ssh://git@gitbay.test:") || !strings.Contains(body, "/alice/site.git</code> · <code>git clone https://gitbay.test/alice/site.git</code>") {
90 t.Fatalf("clone URLs missing:\n%s", body)
91 }
8892 if !strings.Contains(body, "<h2 id=\"hello-site\">hello site</h2>") || !strings.Contains(body, "<em>markdown</em>") {
8993 t.Fatalf("README not rendered:\n%s", body)
9094 }
e2e/websessions_test.go +23
@@ -84,4 +84,27 @@ func TestWebSessionsListRevoke(t *testing.T) {
8484 if _, _, code := inst.ssh(t, aliceKey, "", "web", "sessions", "revoke", "abcdefabcdef"); code != 3 {
8585 t.Fatal("unknown id accepted")
8686 }
87 // An anonymous visit to a page that needs a session lands on the
88 // login page, which says where the visitor was going; the login
89 // link then returns them there.
90 anon := newBrowser(t)
91 status, body := browserGet(t, anon, inst.base()+"/settings")
92 if status != 200 || !strings.Contains(body, "continue to <code>/settings</code>") {
93 t.Fatalf("login page without the destination: %d\n%s", status, body)
94 }
95 out, _, _ := inst.ssh(t, aliceKey, "", "web", "login", "--json")
96 var env struct {
97 Data struct {
98 URL string `json:"url"`
99 } `json:"data"`
100 }
101 json.Unmarshal([]byte(out), &env)
102 link := inst.base() + env.Data.URL[strings.Index(env.Data.URL, "/login"):]
103 if status, body := browserGet(t, anon, link); status != 200 || !strings.Contains(body, "Account settings") {
104 t.Fatalf("login did not return to /settings: %d\n%s", status, body)
105 }
106 // The destination is used once.
107 if _, body := browserGet(t, anon, inst.base()+"/login"); strings.Contains(body, "continue to") {
108 t.Fatal("next survived its use")
109 }
87110}
internal/control/commitrefs.go +1 −1
@@ -240,7 +240,7 @@ func actOnIssue(st *store.Store, source, target store.Repo, actorID int64, sha s
240240 slog.Error("commit refs: closing issue", "issue", number, "err", err)
241241 return
242242 }
243 st.AddIssueSystemComment(issue.ID, actorID, fmt.Sprintf("closed by commit %s by %s: %s", link, author, subject))
243 st.AddIssueSystemComment(issue.ID, actorID, fmt.Sprintf("closed by %s in commit %s: %s", author, link, subject))
244244 st.RecordEvent(target.ID, actorID, "issue.closed", fmt.Sprintf(`{"number":%d,"sha":%q}`, number, sha))
245245 return
246246 }
internal/control/mr.go +3 −3
@@ -1054,7 +1054,7 @@ func runMRMerge(c *Ctx, args []string) int {
10541054 case "ff":
10551055 if !ffPossible {
10561056 return c.fail(protocol.ExitUsage,
1057 "fast-forward not possible: %s has diverged from the MR head; use --strategy merge or rebase and re-push", mr.TargetRef)
1057 "fast-forward not possible: %s has diverged from the MR head; merge with the merge strategy, or rebase and push again", mr.TargetRef)
10581058 }
10591059 newSHA = headSHA
10601060
@@ -1129,7 +1129,7 @@ func runMRMerge(c *Ctx, args []string) int {
11291129 }
11301130 if len(parents) > 1 {
11311131 return c.fail(protocol.ExitUsage,
1132 "the MR contains merge commit %.10s; a rebase merge needs linear history — use --strategy merge or squash", sha)
1132 "the MR contains merge commit %.10s; a rebase merge needs linear history — choose the merge or squash strategy", sha)
11331133 }
11341134 base := onto // root commit: replay against the new tip itself
11351135 if len(parents) == 1 {
@@ -1175,7 +1175,7 @@ func runMRMerge(c *Ctx, args []string) int {
11751175 nums = append(nums, fmt.Sprintf("!%d", k.Number))
11761176 }
11771177 return c.fail(protocol.ExitUsage,
1178 "%s is stacked on by %s; a %s merge rewrites the commits they build on. Merge with --strategy ff or merge, or merge the stack into %s first",
1178 "%s is stacked on by %s; a %s merge rewrites the commits they build on. Merge with the fast-forward or merge strategy, or merge the stack into %s first",
11791179 fmt.Sprintf("!%d", mr.Number), strings.Join(nums, ", "), strategy, mr.SourceRef)
11801180 }
11811181
internal/gitutil/versions.go added +50
@@ -0,0 +1,50 @@
1package gitutil
2
3import (
4 "sort"
5 "strconv"
6 "strings"
7)
8
9// version parses "v1.2.3" or "1.2" into numeric parts. Anything else is
10// not a version.
11func version(name string) ([]int, bool) {
12 s := strings.TrimPrefix(name, "v")
13 if s == "" {
14 return nil, false
15 }
16 var parts []int
17 for _, p := range strings.Split(s, ".") {
18 n, err := strconv.Atoi(p)
19 if err != nil || n < 0 {
20 return nil, false
21 }
22 parts = append(parts, n)
23 }
24 return parts, true
25}
26
27func versionLess(a, b []int) bool {
28 for i := 0; i < len(a) && i < len(b); i++ {
29 if a[i] != b[i] {
30 return a[i] < b[i]
31 }
32 }
33 return len(a) < len(b)
34}
35
36// SortVersions orders refs newest version first. Names that are not
37// versions follow, by name.
38func SortVersions(refs []Ref) {
39 sort.SliceStable(refs, func(i, j int) bool {
40 vi, oki := version(refs[i].Name)
41 vj, okj := version(refs[j].Name)
42 switch {
43 case oki && okj:
44 return versionLess(vj, vi)
45 case oki != okj:
46 return oki
47 }
48 return refs[i].Name < refs[j].Name
49 })
50}
internal/gitutil/versions_test.go added +18
@@ -0,0 +1,18 @@
1package gitutil
2
3import "testing"
4
5func TestSortVersionsNewestFirst(t *testing.T) {
6 refs := []Ref{{Name: "v1.2.0"}, {Name: "v1.10.0"}, {Name: "nightly"}, {Name: "v1.2.1"}, {Name: "v0.9"}, {Name: "beta"}, {Name: "2.0.0"}}
7 SortVersions(refs)
8 var got []string
9 for _, r := range refs {
10 got = append(got, r.Name)
11 }
12 want := []string{"2.0.0", "v1.10.0", "v1.2.1", "v1.2.0", "v0.9", "beta", "nightly"}
13 for i := range want {
14 if i >= len(got) || got[i] != want[i] {
15 t.Fatalf("order %v, want %v", got, want)
16 }
17 }
18}
internal/httpd/account.go +24 −4
@@ -20,6 +20,7 @@ type accountKey struct {
2020 Algo string
2121 Scope string
2222 Label string
23 Confirm string // the 8 characters after SHA256: — a label can be empty
2324}
2425
2526type accountPGP struct {
@@ -27,6 +28,7 @@ type accountPGP struct {
2728 UIDs []string
2829 Expired bool
2930 Revoked bool
31 Confirm string // the fingerprint's first 8 characters
3032}
3133
3234// accountForm renders the account's own settings: keys, addresses, and the
@@ -35,7 +37,8 @@ func (s *Server) accountForm(w http.ResponseWriter, r *http.Request, u store.Use
3537 var keys []accountKey
3638 if list, err := s.st.ListSSHKeys(u.ID); err == nil {
3739 for _, k := range list {
38 keys = append(keys, accountKey{Fingerprint: k.Fingerprint, Algo: k.Algo, Scope: k.Scope, Label: k.Label})
40 confirm := prefix8(strings.TrimPrefix(k.Fingerprint, "SHA256:"))
41 keys = append(keys, accountKey{Fingerprint: k.Fingerprint, Algo: k.Algo, Scope: k.Scope, Label: k.Label, Confirm: confirm})
3942 }
4043 }
4144 var pgp []accountPGP
@@ -43,9 +46,10 @@ func (s *Server) accountForm(w http.ResponseWriter, r *http.Request, u store.Use
4346 for _, k := range list {
4447 var uids []string
4548 json.Unmarshal([]byte(k.UIDsJSON), &uids)
49 confirm := prefix8(k.Fingerprint)
4650 pgp = append(pgp, accountPGP{
4751 Fingerprint: k.Fingerprint, UIDs: uids,
48 Expired: k.ExpiresAt != nil, Revoked: k.RevokedAt != nil,
52 Expired: k.ExpiresAt != nil, Revoked: k.RevokedAt != nil, Confirm: confirm,
4953 })
5054 }
5155 }
@@ -153,6 +157,11 @@ func (s *Server) accountSubmit(w http.ResponseWriter, r *http.Request, u store.U
153157 }
154158 back("", "key registered")
155159 case "key-remove":
160 want := prefix8(strings.TrimPrefix(r.FormValue("fingerprint"), "SHA256:"))
161 if ok, msg := confirmed(r, want); !ok {
162 back(msg, "")
163 return
164 }
156165 if _, msg, ok := s.runControl(u, []string{"keys", "remove", r.FormValue("fingerprint")}); !ok {
157166 back(msg, "")
158167 return
@@ -170,7 +179,13 @@ func (s *Server) accountSubmit(w http.ResponseWriter, r *http.Request, u store.U
170179 }
171180 back("", "PGP key registered")
172181 case "pgp-remove":
173 if _, msg, ok := s.runControl(u, []string{"pgp", "remove", r.FormValue("fingerprint")}); !ok {
182 fp := r.FormValue("fingerprint")
183 want := prefix8(fp)
184 if ok, msg := confirmed(r, want); !ok {
185 back(msg, "")
186 return
187 }
188 if _, msg, ok := s.runControl(u, []string{"pgp", "remove", fp}); !ok {
174189 back(msg, "")
175190 return
176191 }
@@ -188,7 +203,12 @@ func (s *Server) accountSubmit(w http.ResponseWriter, r *http.Request, u store.U
188203 }
189204 back("", "address verified")
190205 case "email-remove":
191 if _, msg, ok := s.runControl(u, []string{"email", "remove", r.FormValue("address")}); !ok {
206 address := r.FormValue("address")
207 if ok, msg := confirmed(r, address); !ok {
208 back(msg, "")
209 return
210 }
211 if _, msg, ok := s.runControl(u, []string{"email", "remove", address}); !ok {
192212 back(msg, "")
193213 return
194214 }
internal/httpd/accounts.go +37 −10
@@ -49,6 +49,9 @@ func (s *Server) requireUser(h func(http.ResponseWriter, *http.Request, store.Us
4949 return func(w http.ResponseWriter, r *http.Request) {
5050 u := s.viewer(r)
5151 if u.ID == 0 {
52 if r.Method == http.MethodGet {
53 s.setNext(w, r.URL.RequestURI())
54 }
5255 http.Redirect(w, r, "/login", http.StatusSeeOther)
5356 return
5457 }
@@ -76,15 +79,16 @@ func (s *Server) checkOrigin(h http.HandlerFunc) http.HandlerFunc {
7679// the page can tell a brand-new visitor how to get an account. EmailLogin
7780// says whether this instance can mail a link; Sent switches the page to the
7881// confirmation that follows a request.
79func (s *Server) renderLogin(w http.ResponseWriter, errMsg string, sent bool) {
82func (s *Server) renderLogin(w http.ResponseWriter, errMsg string, sent bool, next string) {
8083 s.render(w, "login.html", struct {
8184 basePage
8285 Mode string // closed | invite | open
8386 Error string
8487 EmailLogin bool
8588 Sent bool
89 Next string
8690 }{basePage{Site: s.siteName(), Host: s.cfg.SiteHost()},
87 s.cfg.Registration.Mode, errMsg, s.emailLoginEnabled(), sent})
91 s.cfg.Registration.Mode, errMsg, s.emailLoginEnabled(), sent, next})
8892}
8993
9094// emailLoginEnabled reports whether a link can be mailed at all. There is no
@@ -111,18 +115,18 @@ func (s *Server) loginSubmit(w http.ResponseWriter, r *http.Request) {
111115 if err := control.RequestLoginLink(s.cfg, s.st, r.FormValue("identifier")); err != nil {
112116 log.Printf("login link: %v", err)
113117 }
114 s.renderLogin(w, "", true)
118 s.renderLogin(w, "", true, "")
115119}
116120
117121func (s *Server) login(w http.ResponseWriter, r *http.Request) {
118122 token := r.URL.Query().Get("token")
119123 if token == "" {
120 s.renderLogin(w, "", false)
124 s.renderLogin(w, "", false, s.peekNext(r))
121125 return
122126 }
123127 userID, err := s.st.ConsumeLoginToken(store.HashToken(token))
124128 if err != nil {
125 s.renderLogin(w, badLoginToken, false)
129 s.renderLogin(w, badLoginToken, false, "")
126130 return
127131 }
128132 // A token minted before the account was suspended is still consumable,
@@ -130,7 +134,7 @@ func (s *Server) login(w http.ResponseWriter, r *http.Request) {
130134 // read. Checking here covers every mint path. The message is the one a
131135 // bad token gets: a distinct one would confirm the account exists.
132136 if u, err := s.st.UserByID(userID); err != nil || u.Disabled {
133 s.renderLogin(w, badLoginToken, false)
137 s.renderLogin(w, badLoginToken, false, "")
134138 return
135139 }
136140 sessTok, sessHash, err := store.NewToken()
@@ -143,7 +147,11 @@ func (s *Server) login(w http.ResponseWriter, r *http.Request) {
143147 return
144148 }
145149 http.SetCookie(w, s.sessionCookieFor(sessTok))
146 http.Redirect(w, r, "/", http.StatusSeeOther)
150 dest := s.takeNext(w, r)
151 if dest == "" {
152 dest = "/"
153 }
154 http.Redirect(w, r, dest, http.StatusSeeOther)
147155}
148156
149157// sessionCookieFor is the cookie a new session ships in. Secure follows TLS
@@ -471,6 +479,9 @@ type editPage struct {
471479 Path string
472480 Content string
473481 Error string
482 Blocked string
483 // Creating marks a path the branch does not have yet.
484 Creating bool
474485}
475486
476487func (s *Server) editForm(w http.ResponseWriter, r *http.Request, u store.User) {
@@ -480,10 +491,26 @@ func (s *Server) editForm(w http.ResponseWriter, r *http.Request, u store.User)
480491 }
481492 ref := r.PathValue("ref")
482493 filePath := strings.Trim(r.PathValue("path"), "/")
494
495 blocked := ""
496 switch {
497 case repo.Settings.RequireSignedCommits:
498 blocked = repo.Path() + " requires signed commits and the web editor cannot sign; edit locally and push a signed commit."
499 case repo.Settings.RequireMR && slices.Contains(repo.Settings.ProtectedBranches, ref):
500 blocked = "branch " + ref + " accepts changes through merge requests only; edit on another branch and open one."
501 }
502
483503 dir := control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name)
504 // A branch that does not exist has nothing to edit. A path that does
505 // not exist on a real branch is a new file: commit-file creates it.
506 if _, err := gitutil.ResolveRef(dir, "refs/heads/"+ref); err != nil {
507 s.notFound(w, r)
508 return
509 }
484510 content, err := gitutil.ReadBlob(dir, "refs/heads/"+ref, filePath, maxRenderBytes)
485 if err != nil {
486 content = nil // new file
511 creating := err != nil
512 if creating {
513 content = nil
487514 }
488515 if gitutil.IsBinary(content) {
489516 http.Error(w, "binary files cannot be edited in the browser", http.StatusBadRequest)
@@ -491,7 +518,7 @@ func (s *Server) editForm(w http.ResponseWriter, r *http.Request, u store.User)
491518 }
492519 s.render(w, "edit.html", editPage{
493520 basePage: s.baseFor(u), Repo: repo,
494 Ref: ref, Path: filePath, Content: string(content),
521 Ref: ref, Path: filePath, Content: string(content), Blocked: blocked, Creating: creating,
495522 })
496523}
497524
internal/httpd/compare.go +1 −1
@@ -69,7 +69,7 @@ func (s *Server) compare(w http.ResponseWriter, r *http.Request) {
6969 cr.Subject = parsed.Subject
7070 cr.AuthorName = names.name(parsed.AuthorEmail, parsed.AuthorName)
7171 cr.AuthorUser, _ = names.account(parsed.AuthorEmail)
72 cr.Date = time.Unix(parsed.AuthorUnix, 0).UTC().Format("2006-01-02")
72 cr.Date = time.Unix(parsed.AuthorUnix, 0).UTC().Format(time.RFC3339)
7373 }
7474 commits = append(commits, cr)
7575 }
internal/httpd/confirm.go added +26
@@ -0,0 +1,26 @@
1package httpd
2
3import (
4 "net/http"
5 "strings"
6)
7
8// confirmed reports whether the form typed want into its confirm field.
9// It guards controls that destroy data nothing else holds; the person
10// is already authorised, so this is a check against a slip, not a
11// permission.
12func confirmed(r *http.Request, want string) (bool, string) {
13 if strings.TrimSpace(r.FormValue("confirm")) == want {
14 return true, ""
15 }
16 return false, "type " + want + " to confirm"
17}
18
19// prefix8 returns s truncated to its first 8 characters, or s unchanged
20// if it is shorter.
21func prefix8(s string) string {
22 if len(s) > 8 {
23 return s[:8]
24 }
25 return s
26}
internal/httpd/depspage_test.go +1 −1
@@ -27,7 +27,7 @@ func TestSettingsPageRendersDepsStatus(t *testing.T) {
2727 }
2828 page := sb.String()
2929 for _, want := range []string{
30 "2026-09-03T08:20:25Z", "golang.org/x/crypto", "v0.31.0", "v0.42.0",
30 "2026-09-03 08:20 UTC", "golang.org/x/crypto", "v0.31.0", "v0.42.0",
3131 `href="/krz/gitbay/issues/140"`,
3232 } {
3333 if !strings.Contains(page, want) {
internal/httpd/flash.go +46
@@ -3,6 +3,7 @@ package httpd
33import (
44 "net/http"
55 "net/url"
6 "strings"
67)
78
89// A form action that fails redirects back to the page it came from with
@@ -43,6 +44,51 @@ func (s *Server) takeFlash(w http.ResponseWriter, r *http.Request) string {
4344 return msg
4445}
4546
47const nextCookie = "gitbay_next"
48
49// setNext remembers the local path an anonymous visitor asked for, so
50// the login that follows can return there. Only a GET path is stored:
51// a POST must not be replayed.
52func (s *Server) setNext(w http.ResponseWriter, path string) {
53 if !strings.HasPrefix(path, "/") || strings.HasPrefix(path, "//") || len(path) > 300 {
54 return
55 }
56 http.SetCookie(w, &http.Cookie{
57 Name: nextCookie, Value: url.QueryEscape(path), Path: "/",
58 HttpOnly: true, SameSite: http.SameSiteLaxMode,
59 Secure: s.cfg.HTTP.TLS != "off", MaxAge: 600,
60 })
61}
62
63// takeNext returns the remembered path once and clears it. Anything
64// that is not a local path comes back empty.
65func (s *Server) takeNext(w http.ResponseWriter, r *http.Request) string {
66 c, err := r.Cookie(nextCookie)
67 if err != nil || c.Value == "" {
68 return ""
69 }
70 http.SetCookie(w, s.clearCookie(nextCookie, http.SameSiteLaxMode))
71 p, err := url.QueryUnescape(c.Value)
72 if err != nil || !strings.HasPrefix(p, "/") || strings.HasPrefix(p, "//") {
73 return ""
74 }
75 return p
76}
77
78// peekNext reads the remembered path without clearing it, for the
79// login page to say where the visitor is going.
80func (s *Server) peekNext(r *http.Request) string {
81 c, err := r.Cookie(nextCookie)
82 if err != nil {
83 return ""
84 }
85 p, err := url.QueryUnescape(c.Value)
86 if err != nil || !strings.HasPrefix(p, "/") || strings.HasPrefix(p, "//") {
87 return ""
88 }
89 return p
90}
91
4692// clearCookie is the expiring twin of a Set-Cookie, carrying the same
4793// attributes the setting call used.
4894//
internal/httpd/labels.go +14 −2
@@ -28,13 +28,21 @@ func (s *Server) labels(w http.ResponseWriter, r *http.Request) {
2828 http.Error(w, "internal error", http.StatusInternalServerError)
2929 return
3030 }
31 anyColor := false
32 for _, l := range labels {
33 if l.Color != "" {
34 anyColor = true
35 break
36 }
37 }
3138 s.render(w, "labels.html", struct {
3239 repoPage
3340 Labels []store.Label
3441 LabelColors map[string]template.CSS
3542 CanWrite bool
43 AnyColor bool
3644 Notice string
37 }{p, labels, s.labelColors(p.Repo), s.canWriteRepo(r, p.Repo), s.takeFlash(w, r)})
45 }{p, labels, s.labelColors(p.Repo), s.canWriteRepo(r, p.Repo), anyColor, s.takeFlash(w, r)})
3846}
3947
4048// labelSubmit creates a label, sets its colour, or removes it, through
@@ -44,11 +52,15 @@ func (s *Server) labelSubmit(w http.ResponseWriter, r *http.Request, u store.Use
4452 name := strings.TrimSpace(r.FormValue("name"))
4553 back := func(w http.ResponseWriter, r *http.Request, msg string) { s.backTo(w, r, "labels", msg) }
4654 if name == "" {
47 s.backTo(w, r, "labels", "name the label")
55 back(w, r, "name the label")
4856 return
4957 }
5058 argv := []string{"label", "set", repo, name, "--color", strings.TrimSpace(r.FormValue("color"))}
5159 if r.FormValue("action") == "remove" {
60 if ok, msg := confirmed(r, name); !ok {
61 s.backTo(w, r, "labels", msg)
62 return
63 }
5264 argv = []string{"label", "remove", repo, name}
5365 }
5466 _, msg, code := s.runControlCode(u, argv)
internal/httpd/mrpage_test.go +1
@@ -33,6 +33,7 @@ type mrPageData struct {
3333 Notice string
3434 DetachedThreads []diffThread
3535 Gates *control.GatesOut
36 SourceGone bool
3637}
3738
3839func renderMR(t *testing.T, m store.MR, reviews []store.MRReview, checks []store.Check) string {
internal/httpd/orgweb.go +4
@@ -80,6 +80,10 @@ func (s *Server) orgSubmit(w http.ResponseWriter, r *http.Request, u store.User)
8080 case "team-create":
8181 argv = []string{"org", "team", "create", owner, team}
8282 case "team-delete":
83 if ok, msg := confirmed(r, team); !ok {
84 back(msg)
85 return
86 }
8387 argv = []string{"org", "team", "delete", owner, team}
8488 case "team-add":
8589 argv = append([]string{"org", "team", "add", owner, team}, strings.Fields(user)...)
internal/httpd/releaseactions.go +6 −3
@@ -28,10 +28,13 @@ func (s *Server) releaseSubmit(w http.ResponseWriter, r *http.Request, u store.U
2828 return
2929 }
3030 back := func(w http.ResponseWriter, r *http.Request, msg string) { s.backTo(w, r, "releases", msg) }
31 // The CLI's --yes guards against a mistyped tag; here the tag comes from
32 // the page and the button sits behind a disclosure, so the click is the
33 // deliberate act.
31 // The CLI's --yes guards against a mistyped tag; here the tag comes
32 // from the page, so the browser's own confirm field stands in.
3433 if r.FormValue("action") == "delete" {
34 if ok, msg := confirmed(r, tag); !ok {
35 back(w, r, msg)
36 return
37 }
3538 _, msg, code := s.runControlCode(u, []string{"release", "delete", repo, tag, "--yes"})
3639 s.done(w, r, code, msg, back)
3740 return
internal/httpd/snippets.go +38 −13
@@ -178,14 +178,12 @@ func (s *Server) snippetNewSubmit(w http.ResponseWriter, r *http.Request, u stor
178178 http.Redirect(w, r, "/"+u.Username+"/-/snippets/"+out.ID, http.StatusSeeOther)
179179}
180180
181// snippetAction runs a write on the snippet in the URL and returns to its
182// page with the message, or to dest (the list, for a delete) on success.
183// A snippet the viewer may not read is the 404 page, as on every read.
184func (s *Server) snippetAction(w http.ResponseWriter, r *http.Request, u store.User, argv []string, stdin string, dest string) {
185 sn, _, ok := s.snippetScope(w, r)
186 if !ok {
187 return
188 }
181// snippetAction runs a write on an already-resolved snippet and returns to
182// its page with the message, or to dest (the list, for a delete) on
183// success. Callers resolve the snippet with snippetScope first, so a
184// snippet the viewer may not read is the 404 page before any confirmation
185// or write is considered.
186func (s *Server) snippetAction(w http.ResponseWriter, r *http.Request, u store.User, sn store.Snippet, argv []string, stdin string, dest string) {
189187 page := "/" + sn.OwnerName + "/-/snippets/" + sn.PublicID
190188 if dest == "" {
191189 dest = page
@@ -207,23 +205,50 @@ func (s *Server) snippetAction(w http.ResponseWriter, r *http.Request, u store.U
207205}
208206
209207func (s *Server) snippetEditSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
210 s.snippetAction(w, r, u, []string{"snippet", "edit", r.PathValue("id"),
208 sn, _, ok := s.snippetScope(w, r)
209 if !ok {
210 return
211 }
212 s.snippetAction(w, r, u, sn, []string{"snippet", "edit", r.PathValue("id"),
211213 "--description", strings.TrimSpace(r.FormValue("description")),
212214 "--visibility", r.FormValue("visibility")}, "", "")
213215}
214216
215217func (s *Server) snippetDeleteSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
216 s.snippetAction(w, r, u, []string{"snippet", "delete", r.PathValue("id")}, "",
217 "/"+r.PathValue("owner")+"/-/snippets")
218 sn, _, ok := s.snippetScope(w, r)
219 if !ok {
220 return
221 }
222 if ok, msg := confirmed(r, sn.PublicID); !ok {
223 s.setFlash(w, msg)
224 http.Redirect(w, r, "/"+sn.OwnerName+"/-/snippets/"+sn.PublicID, http.StatusSeeOther)
225 return
226 }
227 s.snippetAction(w, r, u, sn, []string{"snippet", "delete", sn.PublicID}, "",
228 "/"+sn.OwnerName+"/-/snippets")
218229}
219230
220231// An empty textarea reaches the command as empty stdin, which it refuses;
221232// the message lands on the page like any other.
222233func (s *Server) snippetFileSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
223 s.snippetAction(w, r, u, []string{"snippet", "file", "set", r.PathValue("id"), strings.TrimSpace(r.FormValue("name"))},
234 sn, _, ok := s.snippetScope(w, r)
235 if !ok {
236 return
237 }
238 s.snippetAction(w, r, u, sn, []string{"snippet", "file", "set", r.PathValue("id"), strings.TrimSpace(r.FormValue("name"))},
224239 r.FormValue("content"), "")
225240}
226241
227242func (s *Server) snippetFileRemoveSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
228 s.snippetAction(w, r, u, []string{"snippet", "file", "remove", r.PathValue("id"), strings.TrimSpace(r.FormValue("name"))}, "", "")
243 sn, _, ok := s.snippetScope(w, r)
244 if !ok {
245 return
246 }
247 name := strings.TrimSpace(r.FormValue("name"))
248 if ok, msg := confirmed(r, name); !ok {
249 s.setFlash(w, msg)
250 http.Redirect(w, r, "/"+sn.OwnerName+"/-/snippets/"+sn.PublicID, http.StatusSeeOther)
251 return
252 }
253 s.snippetAction(w, r, u, sn, []string{"snippet", "file", "remove", r.PathValue("id"), name}, "", "")
229254}
internal/httpd/web.go +64 −31
@@ -243,17 +243,20 @@ type repoPage struct {
243243 Repo store.Repo
244244 Ref string
245245 CloneURL string
246 Dir string
247 Tab string // active tab in the repo header
248 Topics []string
249 Pinned bool // by the viewer
250 Marked bool // bookmarked by the viewer
251 Watch string // the viewer's watch state: watching, muted, or ""
252 HasWiki bool
253 Host string
254 Mirrors []mirrorLine // repo admins only
255 CanAdmin bool // gates the settings tab
256 Feed string // Atom feed for this page, if it has one
246 // SSHCloneURL is the same repository over the SSH transport, which is
247 // the one a push needs.
248 SSHCloneURL string
249 Dir string
250 Tab string // active tab in the repo header
251 Topics []string
252 Pinned bool // by the viewer
253 Marked bool // bookmarked by the viewer
254 Watch string // the viewer's watch state: watching, muted, or ""
255 HasWiki bool
256 Host string
257 Mirrors []mirrorLine // repo admins only
258 CanAdmin bool // gates the settings tab
259 Feed string // Atom feed for this page, if it has one
257260 // OpenIssues and OpenMRs are the counts on the header tabs.
258261 OpenIssues int
259262 OpenMRs int
@@ -331,22 +334,23 @@ func (s *Server) repoFor(w http.ResponseWriter, r *http.Request, ref string) (re
331334 }
332335 openIssues, openMRs := s.st.OpenCounts(repo.ID)
333336 return repoPage{
334 basePage: s.baseFor(viewer),
335 CanAdmin: canAdmin,
336 Mirrors: mirrors,
337 Pinned: pinned,
338 Marked: marked,
339 Watch: watch,
340 HasWiki: s.hasWiki(repo),
341 Host: s.cfg.SiteHost(),
342 Desc: gitutil.ReadDescription(control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name)),
343 Repo: repo,
344 Ref: ref,
345 CloneURL: s.cfg.Server.SiteURL + "/" + repo.Path() + ".git",
346 Dir: control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name),
347 Topics: topics,
348 OpenIssues: openIssues,
349 OpenMRs: openMRs,
337 basePage: s.baseFor(viewer),
338 CanAdmin: canAdmin,
339 Mirrors: mirrors,
340 Pinned: pinned,
341 Marked: marked,
342 Watch: watch,
343 HasWiki: s.hasWiki(repo),
344 Host: s.cfg.SiteHost(),
345 Desc: gitutil.ReadDescription(control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name)),
346 Repo: repo,
347 Ref: ref,
348 CloneURL: s.cfg.Server.SiteURL + "/" + repo.Path() + ".git",
349 SSHCloneURL: s.sshCloneURL(repo),
350 Dir: control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name),
351 Topics: topics,
352 OpenIssues: openIssues,
353 OpenMRs: openMRs,
350354 }, true
351355}
352356
@@ -639,6 +643,7 @@ func (s *Server) releases(w http.ResponseWriter, r *http.Request) {
639643 }
640644 var freeTags []string
641645 if tags, err := gitutil.Refs(p.Dir, "tags"); err == nil {
646 gitutil.SortVersions(tags)
642647 for _, tg := range tags {
643648 if !released[tg.Name] {
644649 freeTags = append(freeTags, tg.Name)
@@ -860,7 +865,7 @@ func (s *Server) blame(w http.ResponseWriter, r *http.Request) {
860865 }
861866 date := h.Date
862867 if t, err := time.Parse(time.RFC3339, h.Date); err == nil {
863 date = t.Format("2006-01-02")
868 date = t.Format(time.RFC3339)
864869 }
865870 hv := hunkView{
866871 BlameHunk: gitutil.BlameHunk{SHA: h.SHA, AuthorName: h.AuthorName,
@@ -1513,7 +1518,7 @@ func (s *Server) log(w http.ResponseWriter, r *http.Request) {
15131518 rw.AuthorName = names.name(parsed.AuthorEmail, parsed.AuthorName)
15141519 rw.AuthorUser, _ = names.account(parsed.AuthorEmail)
15151520 rw.AuthorEmail = parsed.AuthorEmail
1516 rw.Date = time.Unix(parsed.AuthorUnix, 0).UTC().Format("2006-01-02")
1521 rw.Date = time.Unix(parsed.AuthorUnix, 0).UTC().Format(time.RFC3339)
15171522 }
15181523 rows = append(rows, rw)
15191524 }
@@ -1887,7 +1892,7 @@ func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
18871892 cr.Subject = parsed.Subject
18881893 cr.AuthorName = mrNames.name(parsed.AuthorEmail, parsed.AuthorName)
18891894 cr.AuthorUser, _ = mrNames.account(parsed.AuthorEmail)
1890 cr.Date = time.Unix(parsed.AuthorUnix, 0).UTC().Format("2006-01-02")
1895 cr.Date = time.Unix(parsed.AuthorUnix, 0).UTC().Format(time.RFC3339)
18911896 }
18921897 commits = append(commits, cr)
18931898 }
@@ -1950,9 +1955,26 @@ func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
19501955 StackedOn *store.MR
19511956 Stacked []store.MR
19521957 Gates *control.GatesOut
1958 SourceGone bool
19531959 }{p, m, view, md(m.Body, m.BodyFormat), checks, combined, renderComments(comments, md),
19541960 reviewRows, files, diffTruncated, stat, commits, commitsTotal, branches, s.canEditItem(r, p.Repo, m.Author),
1955 canWrite, unresolved, revisions, s.takeFlash(w, r), detachedThreads, stackedOn, stacked, gates})
1961 canWrite, unresolved, revisions, s.takeFlash(w, r), detachedThreads, stackedOn, stacked, gates,
1962 sourceGone(p, m)})
1963}
1964
1965// sourceGone reports whether an MR's source branch no longer exists: the
1966// push hook marks a deleted branch on an open MR, and a merged or closed
1967// one is checked here. A fork's branch lives in another repository and
1968// is left to the recorded state.
1969func sourceGone(p repoPage, m store.MR) bool {
1970 if m.State == "source_gone" {
1971 return true
1972 }
1973 if m.SourceRepoID != p.Repo.ID {
1974 return false
1975 }
1976 _, err := gitutil.ResolveRef(p.Dir, "refs/heads/"+m.SourceRef)
1977 return err != nil
19561978}
19571979
19581980func (s *Server) refs(w http.ResponseWriter, r *http.Request) {
@@ -1963,6 +1985,7 @@ func (s *Server) refs(w http.ResponseWriter, r *http.Request) {
19631985 p.Tab = "refs"
19641986 branches, _ := gitutil.Refs(p.Dir, "heads")
19651987 tags, _ := gitutil.Refs(p.Dir, "tags")
1988 gitutil.SortVersions(tags)
19661989 s.render(w, "refs.html", struct {
19671990 repoPage
19681991 Branches, Tags []gitutil.Ref
@@ -2005,3 +2028,13 @@ type reviewRow struct {
20052028 store.MRReview
20062029 Counts bool
20072030}
2031
2032// sshCloneURL is the SSH clone URL for a repository, with the port only
2033// when it is not the default.
2034func (s *Server) sshCloneURL(repo store.Repo) string {
2035 host := s.cfg.SiteHost()
2036 if s.cfg.SSH.Port != 22 {
2037 host += ":" + strconv.Itoa(s.cfg.SSH.Port)
2038 }
2039 return "ssh://git@" + host + "/" + repo.Path() + ".git"
2040}
internal/web/static/style.css +1
@@ -367,6 +367,7 @@ button.btn {
367367button.btn:hover { border-color: var(--accent); color: var(--accent); }
368368button.btn[aria-pressed="true"] { border-color: var(--accent); color: var(--accent); }
369369form.inline { display: inline; }
370input[name="confirm"] { width: auto; margin-right: var(--sp-2); }
370371
371372nav.tabs {
372373 display: flex;
internal/web/templates/account.html +4 −4
@@ -31,7 +31,7 @@ a CI checkout wants.</p>
3131 <td class="mono">{{.Fingerprint}}</td>
3232 <td>{{.Algo}}</td>
3333 <td>{{.Scope}}</td>
34 <td class="act"><form method="post" action="/settings"><input type="hidden" name="field" value="key-remove"><input type="hidden" name="fingerprint" value="{{.Fingerprint}}"><button type="submit" class="linklike">Remove</button></form></td>
34 <td class="act"><form method="post" action="/settings"><input type="hidden" name="field" value="key-remove"><input type="hidden" name="fingerprint" value="{{.Fingerprint}}">{{template "confirmfield" .Confirm}} <button type="submit" class="linklike">Remove</button></form></td>
3535</tr>
3636{{end}}</table></div>
3737{{else}}<p class="none">No SSH keys — which cannot be right, since you signed in.</p>{{end}}
@@ -61,7 +61,7 @@ account, and where notifications go.</p>
6161 {{if .Verified}}<span class="badge badge-verified">verified{{with .VerifiedBy}} · {{.}}{{end}}</span>
6262 {{else}}<span class="badge badge-unsigned">unverified</span>{{end}}
6363 {{if not .Primary}}{{if .Verified}}<form method="post" action="/settings" class="inline"><input type="hidden" name="field" value="email-primary"><input type="hidden" name="address" value="{{.Address}}"><button type="submit" class="linklike">Make primary</button></form>{{end}}
64 <form method="post" action="/settings" class="inline"><input type="hidden" name="field" value="email-remove"><input type="hidden" name="address" value="{{.Address}}"><button type="submit" class="linklike">Remove</button></form>{{end}}</li>
64 <form method="post" action="/settings" class="inline"><input type="hidden" name="field" value="email-remove"><input type="hidden" name="address" value="{{.Address}}">{{template "confirmfield" .Address}} <button type="submit" class="linklike">Remove</button></form>{{end}}</li>
6565{{end}}</ul>{{end}}
6666<details class="editbox">
6767 <summary>Add an address</summary>
@@ -87,7 +87,7 @@ account, and where notifications go.</p>
8787{{range .PGP}}<tr>
8888 <td class="mono">{{.Fingerprint}}</td>
8989 <td>{{range .UIDs}}{{.}}<br>{{end}}{{if .Revoked}}<span class="badge badge-bad_signature">revoked</span>{{else if .Expired}}<span class="badge badge-signed_key_expired">expired</span>{{end}}</td>
90 <td class="act"><form method="post" action="/settings"><input type="hidden" name="field" value="pgp-remove"><input type="hidden" name="fingerprint" value="{{.Fingerprint}}"><button type="submit" class="linklike">Remove</button></form></td>
90 <td class="act"><form method="post" action="/settings"><input type="hidden" name="field" value="pgp-remove"><input type="hidden" name="fingerprint" value="{{.Fingerprint}}">{{template "confirmfield" .Confirm}} <button type="submit" class="linklike">Remove</button></form></td>
9191</tr>
9292{{end}}</table></div>
9393{{else}}<p class="none">No OpenPGP keys.</p>{{end}}
@@ -126,7 +126,7 @@ never included; a replayed bundle's emails arrive unverified.</p>
126126<h2>On SSH only</h2>
127127<p class="meta">Anything whose input is a credential stays on the command line,
128128where it can be piped instead of pasted:</p>
129<pre class="message">gitbay auth token mint --name laptop # API tokens
129<pre class="message">gitbay auth token create --name laptop # API tokens
130130gitbay admin ... # instance administration</pre>
131131<p class="meta">All of the above works from stock OpenSSH too:
132132<code>ssh git@{{.Host}} auth whoami</code>.</p>
internal/web/templates/blame.html +1 −1
@@ -13,7 +13,7 @@
1313{{range .Hunks}}<div class="blamehunk">
1414 <div class="blameinfo">
1515 <p class="subject"><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{.SHA}}">{{.Summary}}</a></p>
16 <p class="meta"><code><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{.SHA}}">{{.ShortSHA}}</a></code> <span title="{{.AuthorEmail}}">{{.AuthorName}}</span> · {{.Date}} {{template "sigbadge" .Sig}}</p>
16 <p class="meta"><code><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{.SHA}}">{{.ShortSHA}}</a></code> <span title="{{.AuthorEmail}}">{{.AuthorName}}</span> · {{when .Date}} {{template "sigbadge" .Sig}}</p>
1717 </div>
1818 <pre class="blamecode">{{range .Numbered}}<span class="lineno">{{.N}}</span>{{.Text}}
1919{{end}}</pre>
internal/web/templates/commit.html +1 −1
@@ -5,7 +5,7 @@
55 <p class="meta"><code class="fullsha">{{.SHA}}</code></p>
66 {{if .Parents}}<p class="meta">parent{{if gt (len .Parents) 1}}s{{end}}:{{range .Parents}} <code><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{.}}">{{short .}}</a></code>{{end}}</p>{{end}}
77 <p>{{template "sigbadge" .Sig}}{{range .Checks}} <span class="badge check-{{.State}}">{{.Context}}: {{.State}}</span>{{end}}</p>
8 <p class="meta">{{template "authorname" dict "Name" .AuthorName "User" .AuthorUser "Email" .AuthorEmail}} &lt;{{.AuthorEmail}}&gt; · {{.Date}}
8 <p class="meta">{{template "authorname" dict "Name" .AuthorName "User" .AuthorUser "Email" .AuthorEmail}} &lt;{{.AuthorEmail}}&gt; · {{when .Date}}
99 {{if .CommitterEmail}}<br>committer: &lt;{{.CommitterEmail}}&gt;{{end}}</p>
1010</div>
1111<pre class="message">{{.Message}}</pre>
internal/web/templates/compare.html +1 −1
@@ -6,7 +6,7 @@
66{{range .Commits}}<li>
77 <div class="commitmain">
88 <p class="subject"><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{.SHA}}">{{.Subject}}</a></p>
9 <p class="meta">{{template "authorname" dict "Name" .AuthorName "User" .AuthorUser "Email" ""}} · {{.Date}}</p>
9 <p class="meta">{{template "authorname" dict "Name" .AuthorName "User" .AuthorUser "Email" ""}} · {{when .Date}}</p>
1010 </div>
1111 <span class="commitside">{{template "sigbadge" .Sig}} <code><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{.SHA}}">{{.ShortSHA}}</a></code></span>
1212</li>{{end}}
internal/web/templates/edit.html +3
@@ -2,6 +2,8 @@
22{{define "content"}}
33<h1>edit {{.Repo.OwnerName}}/{{.Repo.Name}} : {{.Path}} @ {{.Ref}}</h1>
44{{if .Error}}<p class="error" role="alert">{{.Error}}</p>{{end}}
5{{if .Blocked}}<p class="empty-note">{{.Blocked}}</p>{{else}}
6{{if .Creating}}<p class="meta"><code>{{.Path}}</code> does not exist on {{.Ref}}; committing creates it.</p>{{end}}
57<form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/edit/{{.Ref}}/{{.Path}}" class="editform">
68<p><textarea name="content" aria-label="File contents" rows="24" spellcheck="false">{{.Content}}</textarea></p>
79<p><input name="message" aria-label="Commit message" placeholder="commit message">
@@ -9,3 +11,4 @@
911<p class="crumbs">this commit will be unsigned and authored as {{.Viewer}}</p>
1012</form>
1113{{end}}
14{{end}}
internal/web/templates/globalsearch.html +1
@@ -8,6 +8,7 @@
88 <a {{if eq .Kind "issue"}}class="active" aria-current="page" {{end}}href="?q={{.Query}}&amp;kind=issue">issues</a>
99 <a {{if eq .Kind "mr"}}class="active" aria-current="page" {{end}}href="?q={{.Query}}&amp;kind=mr">merge requests</a>
1010 </nav>
11 {{if and .Query (not .QueryErr)}}<p class="meta">{{len .Results}} {{if eq (len .Results) 1}}result{{else}}results{{end}} for <q>{{.Query}}</q>{{if .Kind}} in {{.Kind}}{{end}}</p>{{end}}
1112</div>
1213<form method="get" action="/search" class="searchform">
1314 <input type="search" name="q" aria-label="Search" value="{{.Query}}" placeholder="repository names and topics, issue and merge request text" autofocus>
internal/web/templates/issue.html +3 −3
@@ -50,7 +50,7 @@
5050 <div class="grp">
5151 <h2>Labels</h2>
5252 {{if .Issue.Labels}}<p class="row">{{range .Issue.Labels}}<a class="chip label" style="{{index $.LabelColors .}}" href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/issues?label={{.}}">{{.}}</a> {{end}}</p>
53 {{else}}<p class="none">None yet</p>{{end}}
53 {{else}}<p class="none">none yet</p>{{end}}
5454 {{if .CanWrite}}
5555 <form method="post" action="{{$base}}/label" class="actions">
5656 <input type="text" name="add" aria-label="Add labels" placeholder="add, space-separated">
@@ -62,7 +62,7 @@
6262 <div class="grp">
6363 <h2>Assignees</h2>
6464 {{if .Issue.Assignees}}<p class="row">{{range .Issue.Assignees}}<a href="/{{.}}">{{.}}</a> {{end}}</p>
65 {{else}}<p class="none">Nobody yet</p>{{end}}
65 {{else}}<p class="none">nobody yet</p>{{end}}
6666 {{if .CanWrite}}
6767 <form method="post" action="{{$base}}/assign" class="actions">
6868 <input type="text" name="add" aria-label="Add assignees" placeholder="add, space-separated">
@@ -74,7 +74,7 @@
7474 <div class="grp">
7575 <h2>Milestone</h2>
7676 {{if .Issue.Milestone}}<p class="row"><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/milestones">{{.Issue.Milestone}}</a></p>
77 {{else}}<p class="none">None</p>{{end}}
77 {{else}}<p class="none">none yet</p>{{end}}
7878 {{if .CanWrite}}
7979 <form method="post" action="{{$base}}/milestone" class="actions">
8080 <label class="none" for="milestone">Set milestone</label>
internal/web/templates/issues.html +2 −2
@@ -20,9 +20,9 @@
2020 <div class="issuemain">
2121 <p class="title"><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/issues/{{.Number}}">{{.Title}}</a>
2222 {{range .Labels}}<a class="chip label" style="{{index $.LabelColors .}}" href="?label={{.}}">{{.}}</a> {{end}}</p>
23 <p class="meta">#{{.Number}} opened by <a href="/{{.Author}}">{{.Author}}</a>{{if .Milestone}} · <a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/milestones">{{.Milestone}}</a>{{end}}</p>
23 <p class="meta">#{{.Number}} opened by <a href="/{{.Author}}">{{.Author}}</a>{{if .Milestone}} · in <a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/milestones">{{.Milestone}}</a>{{end}}</p>
2424 </div>
25 <span class="chip {{if eq .State "open"}}chip-open{{else}}chip-done{{end}}">{{.State}}</span>
25 {{if eq $.State "all"}}<span class="chip {{if eq .State "open"}}chip-open{{else}}chip-done{{end}}">{{.State}}</span>{{end}}
2626</li>
2727{{else}}{{if .Query}}<li class="empty">no {{if ne .State "all"}}{{.State}} {{end}}issues matching “{{.Query}}”</li>
2828{{else}}<li class="empty">no {{if ne .State "all"}}{{.State}} {{end}}issues — open one with <code>gitbay issue create {{.Repo.OwnerName}}/{{.Repo.Name}} --title "..."</code></li>{{end}}{{end}}
internal/web/templates/labels.html +4 −3
@@ -3,18 +3,19 @@
33<h1>Labels</h1>
44{{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}}
55{{if .Labels}}<div class="tablewrap"><table class="keys">
6<tr class="cols"><th scope="col">label</th><th scope="col">colour</th><th scope="col">issues</th><th scope="col"></th></tr>
6<tr class="cols"><th scope="col">label</th>{{if or $.CanWrite $.AnyColor}}<th scope="col">colour</th>{{end}}<th scope="col">issues</th><th scope="col"></th></tr>
77{{range .Labels}}<tr>
88 <td><a class="chip label" style="{{index $.LabelColors .Name}}" href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/issues?label={{.Name}}">{{.Name}}</a>{{if .Org}} <span class="chip chip-neutral">org</span>{{end}}</td>
9 <td>{{if and $.CanWrite (not .Org)}}<form method="post" action="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/labels" class="inline">
9 {{if or $.CanWrite $.AnyColor}}<td>{{if and $.CanWrite (not .Org)}}<form method="post" action="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/labels" class="inline">
1010 <input type="hidden" name="name" value="{{.Name}}">
1111 <input type="text" name="color" value="{{.Color}}" aria-label="Colour for {{.Name}}" placeholder="rrggbb" size="8">
1212 <button type="submit" class="btn">Save</button>
13 </form>{{else}}<span class="mono">{{if .Color}}{{.Color}}{{else}}—{{end}}</span>{{end}}</td>
13 </form>{{else}}<span class="mono">{{if .Color}}{{.Color}}{{else}}—{{end}}</span>{{end}}</td>{{end}}
1414 <td>{{.Issues}}</td>
1515 <td class="act">{{if and $.CanWrite (not .Org)}}<form method="post" action="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/labels" class="inline">
1616 <input type="hidden" name="action" value="remove">
1717 <input type="hidden" name="name" value="{{.Name}}">
18 {{template "confirmfield" .Name}}
1819 <button type="submit" class="linklike">Remove</button>
1920 </form>{{else if .Org}}<a href="/{{$.Repo.OwnerName}}/-/labels">org</a>{{end}}</td>
2021</tr>
internal/web/templates/landing.html +1 −1
@@ -13,6 +13,6 @@ git clone ssh://git@{{.Host}}/owner/repo.git</pre>
1313 <section><h2>Review</h2><p>The web is a rendering of the same state: read a diff, comment on a line, approve, merge. Everything it can do, the terminal can do first.</p></section>
1414</div>
1515<p><a class="explorelink" href="/explore">explore public repositories →</a></p>
16{{if .Accounts}}<p class="meta">have an account? mint a browser session from your terminal: <code>gitbay web login</code>{{if .Signup}} · new here? <a href="/register">create an account</a>{{end}}</p>{{end}}
16{{if .Accounts}}<p class="meta">have an account? log in from your terminal: <code>gitbay web login</code>{{if .Signup}} · new here? <a href="/register">create an account</a>{{end}}</p>{{end}}
1717</div>
1818{{end}}
internal/web/templates/layout.html +4
@@ -137,6 +137,10 @@
137137 </select>
138138</p>{{end}}
139139
140{{/* confirmfield is the typed-name check beside a destructive control.
141 The argument is the text the person must type. */}}
142{{define "confirmfield"}}<input type="text" name="confirm" aria-label="Type {{.}} to confirm" placeholder="type {{.}} to confirm" size="{{len .}}" autocomplete="off">{{end}}
143
140144{{/* difffiles renders a parsed diff: one foldable section per file, with
141145 line-number gutters and review threads inline. Base is the MR's
142146 endpoint and Viewer the signed-in account; the commit page passes
internal/web/templates/log.html +1 −1
@@ -12,7 +12,7 @@
1212{{range .Commits}}{{$c := .}}<li>
1313 <div class="commitmain">
1414 <p class="subject"><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{.SHA}}">{{.Subject}}</a></p>
15 <p class="meta">{{template "authorname" dict "Name" .AuthorName "User" .AuthorUser "Email" .AuthorEmail}} · {{.Date}}</p>
15 <p class="meta">{{template "authorname" dict "Name" .AuthorName "User" .AuthorUser "Email" .AuthorEmail}} · {{when .Date}}</p>
1616 </div>
1717 <div class="commitside">
1818 {{with .Check}}<a class="badge badge-{{.}}" href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{$c.SHA}}" title="checks: {{.}}">{{.}}</a>{{end}}
internal/web/templates/login.html +1
@@ -1,6 +1,7 @@
11{{define "title"}}login · {{.Site}}{{end}}
22{{define "content"}}
33<h1>Log in</h1>
4{{if .Next}}<p class="meta">Log in to continue to <code>{{.Next}}</code>.</p>{{end}}
45{{if .Error}}<p class="error" role="alert">{{.Error}}</p>{{end}}
56{{if .Sent}}
67<p>If that account exists, a login link is on its way. It works once and
internal/web/templates/mr.html +4 −4
@@ -62,7 +62,7 @@
6262{{range .Commits}}<li>
6363 <div class="commitmain">
6464 <p class="subject"><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{.SHA}}">{{.Subject}}</a></p>
65 <p class="meta">{{template "authorname" dict "Name" .AuthorName "User" .AuthorUser "Email" ""}} · {{.Date}}</p>
65 <p class="meta">{{template "authorname" dict "Name" .AuthorName "User" .AuthorUser "Email" ""}} · {{when .Date}}</p>
6666 </div>
6767 <div class="commitside">
6868 {{template "sigbadge" .Sig}}
@@ -129,7 +129,7 @@
129129 <div class="grp">
130130 <h2>Reviewers</h2>
131131 {{if .MR.ReviewRequests}}<p class="row">{{range .MR.ReviewRequests}}<a href="/{{.}}">{{.}}</a> {{end}}</p>
132 {{else}}<p class="none">Nobody asked yet</p>{{end}}
132 {{else}}<p class="none">nobody yet</p>{{end}}
133133 {{if and .CanWrite (or (eq .MR.State "open") (eq .MR.State "source_gone"))}}
134134 <form method="post" action="{{$base}}/review-request" class="actions">
135135 <input type="text" name="add" aria-label="Add reviewers" placeholder="add, space-separated">
@@ -141,7 +141,7 @@
141141 <div class="grp">
142142 <h2>Reviews</h2>
143143 {{range .Reviews}}<p class="row"><span class="dot {{if eq .Verdict "approve"}}ok{{else}}pend{{end}}"></span><a href="/{{.Reviewer}}">{{.Reviewer}}</a> {{.Verdict}}{{if .Stale}} <span class="chip chip-stale">stale</span>{{end}}{{if not .Counts}} <span class="chip chip-neutral" title="This reviewer has no write access, so the merge gates do not count it">advisory</span>{{end}}<span class="sub">{{when .CreatedAt}}</span></p>
144 {{else}}<p class="none">No reviews yet</p>{{end}}
144 {{else}}<p class="none">none yet</p>{{end}}
145145 {{if gt (len .Revisions) 1}}<p class="row none">{{len .Revisions}} revisions pushed. What changed between the last two:
146146 <code>gitbay mr range-diff {{.Repo.OwnerName}}/{{.Repo.Name}} {{.MR.Number}}</code></p>{{end}}
147147 </div>
@@ -162,7 +162,7 @@
162162 <div class="grp">
163163 <h2>Source</h2>
164164 <p class="row"><code>{{if .MR.SourcePath}}{{.MR.SourcePath}}:{{end}}{{.MR.SourceRef}}</code></p>
165 <p class="row none">into <code>{{.MR.TargetRef}}</code> at <code>{{short .MR.HeadSHA}}</code></p>
165 <p class="row none">into <code>{{.MR.TargetRef}}</code> · {{if eq .MR.State "merged"}}merged at{{else}}head{{end}} <code>{{short .MR.HeadSHA}}</code>{{if .SourceGone}} · <span class="chip chip-neutral">branch deleted</span>{{end}}</p>
166166 </div>
167167 {{if .MR.Milestone}}<div class="grp">
168168 <h2>Milestone</h2>
internal/web/templates/mrs.html +1 −1
@@ -21,7 +21,7 @@
2121 <p class="title"><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/mrs/{{.Number}}">{{.Title}}</a></p>
2222 <p class="meta">!{{.Number}} by <a href="/{{.Author}}">{{.Author}}</a> · {{if .SourcePath}}{{.SourcePath}}:{{end}}{{.SourceRef}} → {{.TargetRef}}</p>
2323 </div>
24 {{if .Draft}}<span class="chip chip-neutral">draft</span> {{end}}<span class="chip chip-{{.State}}">{{.State}}</span>
24 {{if .Draft}}<span class="chip chip-neutral">draft</span> {{end}}{{if eq $.State "all"}}<span class="chip chip-{{.State}}">{{.State}}</span>{{end}}
2525</li>
2626{{else}}{{if .Query}}<li class="empty">no {{if ne .State "all"}}{{.State}} {{end}}merge requests matching “{{.Query}}”</li>
2727{{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}}
internal/web/templates/owner.html +1
@@ -98,6 +98,7 @@ of a team get its role on every repository it is granted.</p>
9898 <form method="post" action="/{{$org}}" class="setform">
9999 <input type="hidden" name="field" value="team-delete">
100100 <input type="hidden" name="team" value="{{.Name}}">
101 {{template "confirmfield" .Name}}
101102 <button type="submit" class="linklike">Delete this team</button>
102103 </form>
103104 </div>
internal/web/templates/releases.html +1 −1
@@ -36,7 +36,7 @@
3636 <input type="hidden" name="action" value="delete">
3737 <input type="hidden" name="tag" value="{{$rel.Tag}}">
3838 <p class="meta">Deleting is permanent and takes the assets with it. The tag stays.</p>
39 <p><button type="submit" class="linklike">Delete release</button></p>
39 <p>{{template "confirmfield" $rel.Tag}} <button type="submit" class="linklike">Delete release</button></p>
4040 </form>{{end}}</details>{{end}}
4141 {{if $rel.Assets}}<table class="assets">
4242 <thead><tr><th scope="col">File</th><th scope="col">Bytes</th><th scope="col">SHA-256</th></tr></thead>
internal/web/templates/settings.html +15 −15
@@ -9,13 +9,13 @@
99 <input type="hidden" name="field" value="description">
1010 <label for="description">Description</label>
1111 <input type="text" id="description" name="description" value="{{.Desc}}" placeholder="one line, shown in listings">
12 <button type="submit">Save</button>
12 <button type="submit">Save description</button>
1313</form>
1414<form method="post" action="{{$base}}" class="setform">
1515 <input type="hidden" name="field" value="website">
1616 <label for="website">Website</label>
1717 <input type="text" id="website" name="website" value="{{.Repo.Settings.Website}}" placeholder="https://example.org">
18 <button type="submit">Save</button>
18 <button type="submit">Save website</button>
1919</form>
2020{{if .Branches}}<form method="post" action="{{$base}}" class="setform">
2121 <input type="hidden" name="field" value="default-branch">
@@ -23,7 +23,7 @@
2323 <select id="default-branch" name="default-branch">
2424 {{$cur := .Repo.DefaultBranch}}{{range .Branches}}<option value="{{.Name}}"{{if eq .Name $cur}} selected{{end}}>{{.Name}}</option>{{end}}
2525 </select>
26 <button type="submit">Save</button>
26 <button type="submit">Save default branch</button>
2727</form>
2828{{end}}<form method="post" action="{{$base}}" class="setform">
2929 <input type="hidden" name="field" value="topics">
@@ -43,13 +43,13 @@
4343 <option value="public"{{if eq .Repo.Visibility "public"}} selected{{end}}>Public</option>
4444 <option value="private"{{if eq .Repo.Visibility "private"}} selected{{end}}>Private</option>
4545 </select>
46 <button type="submit">Save</button>
46 <button type="submit">Save visibility</button>
4747</form>
4848<form method="post" action="{{$base}}" class="setform">
4949 <input type="hidden" name="field" value="git-daemon">
5050 <label for="git-daemon">Serve over git://</label>
5151 <input type="checkbox" id="git-daemon" name="git-daemon" value="on"{{if .Repo.Settings.GitDaemon}} checked{{end}}>
52 <button type="submit">Save</button>
52 <button type="submit">Save git://</button>
5353</form>
5454
5555<h2>Merge gates</h2>
@@ -58,31 +58,31 @@
5858 <input type="hidden" name="field" value="require-checks">
5959 <label for="require-checks">Require green checks</label>
6060 <input type="checkbox" id="require-checks" name="require-checks" value="on"{{if .Repo.Settings.RequireChecks}} checked{{end}}>
61 <button type="submit">Save</button>
61 <button type="submit">Save checks</button>
6262</form>
6363<form method="post" action="{{$base}}" class="setform">
6464 <input type="hidden" name="field" value="require-approvals">
6565 <label for="approvals">Required approvals</label>
6666 <input type="number" id="approvals" name="approvals" min="0" max="10" value="{{.Repo.Settings.RequireApprovals}}">
67 <button type="submit">Save</button>
67 <button type="submit">Save approvals</button>
6868</form>
6969<form method="post" action="{{$base}}" class="setform">
7070 <input type="hidden" name="field" value="require-resolved">
7171 <label for="require-resolved">Require resolved threads</label>
7272 <input type="checkbox" id="require-resolved" name="require-resolved" value="on"{{if .Repo.Settings.RequireResolved}} checked{{end}}>
73 <button type="submit">Save</button>
73 <button type="submit">Save threads</button>
7474</form>
7575<form method="post" action="{{$base}}" class="setform">
7676 <input type="hidden" name="field" value="require-codeowners">
7777 <label for="require-codeowners">Require CODEOWNERS approval</label>
7878 <input type="checkbox" id="require-codeowners" name="require-codeowners" value="on"{{if .Repo.Settings.RequireCodeowners}} checked{{end}}>
79 <button type="submit">Save</button>
79 <button type="submit">Save CODEOWNERS</button>
8080</form>
8181<form method="post" action="{{$base}}" class="setform">
8282 <input type="hidden" name="field" value="require-signed">
8383 <label for="require-signed">Require signed commits</label>
8484 <input type="checkbox" id="require-signed" name="require-signed" value="on"{{if .Repo.Settings.RequireSignedCommits}} checked{{end}}>
85 <button type="submit">Save</button>
85 <button type="submit">Save signing</button>
8686</form>
8787
8888<h2>Protected branches</h2>
@@ -109,7 +109,7 @@
109109 <input type="hidden" name="field" value="require-mr">
110110 <label for="require-mr">Merge requests only</label>
111111 <input type="checkbox" id="require-mr" name="require-mr" value="on"{{if .Repo.Settings.RequireMR}} checked{{end}}>
112 <button type="submit">Save</button>
112 <button type="submit">Save merge-only</button>
113113</form>
114114<p class="meta">With merge requests only, a protected branch refuses every direct push once it exists; the merge gates above are then what a change has to pass.</p>
115115
@@ -137,14 +137,14 @@
137137 <input type="hidden" name="field" value="deps">
138138 <label for="deps">Check for updates</label>
139139 <input type="checkbox" id="deps" name="deps" value="on"{{if .DepsEnabled}} checked{{end}}>
140 <button type="submit">Save</button>
140 <button type="submit">Save dependency checks</button>
141141</form>
142142<p class="meta">Compares the manifests on <code>{{.Repo.DefaultBranch}}</code> against
143143proxy.golang.org, npm, crates.io, and PyPI once a day, and tracks what is behind
144144in an issue. Checking a private repository tells those registries what it
145145depends on.</p>
146146{{if .DepsEnabled}}
147<p class="meta">Last checked {{if .Deps.LastCheck}}{{.Deps.LastCheck}}{{else}}never{{end}}{{if .Deps.IssueNumber}} · tracked in <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/issues/{{.Deps.IssueNumber}}">#{{.Deps.IssueNumber}}</a>{{end}}</p>
147<p class="meta">Last checked {{if .Deps.LastCheck}}{{when .Deps.LastCheck}}{{else}}never{{end}}{{if .Deps.IssueNumber}} · tracked in <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/issues/{{.Deps.IssueNumber}}">#{{.Deps.IssueNumber}}</a>{{end}}</p>
148148{{if .Deps.LastError}}<p class="error" role="alert">{{.Deps.LastError}}</p>{{end}}
149149{{if .Deps.Behind}}<div class="tablewrap"><table class="keys">
150150<tr class="cols"><th scope="col">dependency</th><th scope="col">pinned</th><th scope="col">latest</th></tr>
@@ -160,7 +160,7 @@ depends on.</p>
160160<h2>Runners</h2>
161161{{if .Runners}}
162162<ul class="protlist">
163{{range .Runners}}<li><code>{{.Fingerprint}}</code> <span class="meta">{{.Username}}{{if .LastSeen}}, last poll {{.LastSeen}}{{else}}, never polled{{end}}{{if .BuildNumber}}, running {{.BuildRepo}} #{{.BuildNumber}} {{.BuildJob}}{{end}}</span>
163{{range .Runners}}<li><code>{{.Fingerprint}}</code> <span class="meta">{{.Username}}{{if .LastSeen}}, last poll {{when .LastSeen}}{{else}}, never polled{{end}}{{if .BuildNumber}}, running {{.BuildRepo}} #{{.BuildNumber}} {{.BuildJob}}{{end}}</span>
164164 <form method="post" action="{{$base}}" class="inline">
165165 <input type="hidden" name="field" value="runner-remove">
166166 <input type="hidden" name="fingerprint" value="{{.Fingerprint}}">
@@ -182,7 +182,7 @@ depends on.</p>
182182 <input type="hidden" name="field" value="archive">
183183 <label for="archive">Archived (read-only)</label>
184184 <input type="checkbox" id="archive" name="archive" value="on"{{if .Repo.Settings.Archived}} checked{{end}}>
185 <button type="submit">Save</button>
185 <button type="submit">Save archive</button>
186186</form>
187187<p class="meta">Deleting or transferring a repository is a CLI operation:
188188<code>gitbay repo delete {{.Repo.OwnerName}}/{{.Repo.Name}} --yes</code></p>
internal/web/templates/snippet.html +3 −3
@@ -2,7 +2,7 @@
22{{define "content"}}
33<h1><a href="/{{.Owner}}">{{.Owner}}</a> / <a href="/{{.Owner}}/-/snippets">snippets</a> / {{.Snippet.PublicID}}</h1>
44{{if .Snippet.Description}}<p class="desc lede">{{.Snippet.Description}}</p>{{end}}
5<p class="meta"><span class="chip chip-neutral">{{.Snippet.Visibility}}</span> · updated {{.Snippet.UpdatedAt}} · <code>gitbay snippet show {{.Snippet.PublicID}}</code></p>
5<p class="meta"><span class="chip chip-neutral">{{.Snippet.Visibility}}</span> · updated {{when .Snippet.UpdatedAt}} · <code>gitbay snippet show {{.Snippet.PublicID}}</code></p>
66{{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}}
77{{range .Files}}
88<section class="snippetfile" id="file-{{.Name}}">
@@ -21,7 +21,7 @@
2121</form>
2222<form method="post" action="/{{$.Owner}}/-/snippets/{{$.Snippet.PublicID}}/file/remove">
2323<input type="hidden" name="name" value="{{.Name}}">
24<p><button type="submit">Remove {{.Name}}</button></p>
24<p>{{template "confirmfield" .Name}} <button type="submit">Remove {{.Name}}</button></p>
2525</form>
2626</details>{{end}}
2727</section>
@@ -44,7 +44,7 @@
4444<p><button type="submit">Save</button></p>
4545</form>
4646<form method="post" action="/{{.Owner}}/-/snippets/{{.Snippet.PublicID}}/delete">
47<p><button type="submit">Delete snippet</button></p>
47<p>{{template "confirmfield" .Snippet.PublicID}} <button type="submit">Delete snippet</button></p>
4848</form>
4949</details>
5050{{end}}
internal/web/templates/snippets.html +1 −1
@@ -9,7 +9,7 @@
99 <td><a href="/{{$.Owner}}/-/snippets/{{.PublicID}}">{{if .Description}}{{.Description}}{{else}}{{.PublicID}}{{end}}</a></td>
1010 <td><span class="mono">{{.Names}}</span></td>
1111 {{if $.All}}<td><span class="chip chip-neutral">{{.Visibility}}</span></td>{{end}}
12 <td>{{.UpdatedAt}}</td>
12 <td>{{when .UpdatedAt}}</td>
1313</tr>
1414{{end}}</table></div>
1515{{else}}<p class="none">No snippets yet.</p>{{end}}
internal/web/templates/tree.html +3 −3
@@ -10,7 +10,7 @@
1010 <a class="act" href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/log/{{.Ref}}">History</a>
1111 <a class="act" href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/archive/{{.Ref}}.tar.gz">Download</a>
1212</div>
13{{if .Entries}}<p class="clone">Clone: <code>git clone {{.CloneURL}}</code></p>{{end}}
13{{if .Entries}}<p class="clone">Clone: <code>git clone {{.SSHCloneURL}}</code> · <code>git clone {{.CloneURL}}</code></p>{{end}}
1414{{if .Facts.Commits}}{{$r := printf "/%s/%s" .Repo.OwnerName .Repo.Name}}<div class="facts">
1515 <p class="counts">
1616 <a href="{{$r}}/log/{{.Ref}}"><strong>{{.Facts.Commits}}</strong> commit{{if ne .Facts.Commits 1}}s{{end}}</a>
@@ -30,7 +30,7 @@
3030 <a class="subject" href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{.SHA}}">{{.Subject}}</a>
3131 <span class="spacer"></span>
3232 <a class="sha" href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{.SHA}}"><code>{{short .SHA}}</code></a>
33 <span class="age">{{ago .When}}</span>
33 <span class="age"{{if not .When.IsZero}} title="{{whenT .When}}"{{end}}>{{ago .When}}</span>
3434</div>{{end}}{{end}}
3535<div class="tablewrap">
3636<table class="tree">
@@ -39,7 +39,7 @@
3939 {{if eq .Type "tree"}}<td class="name dir"><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/tree/{{$.Ref}}/{{$.Prefix}}{{.Name}}">{{.Name}}/</a></td>
4040 {{else}}<td class="name"><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/blob/{{$.Ref}}/{{$.Prefix}}{{.Name}}">{{.Name}}</a></td>{{end}}
4141 <td class="lastcommit">{{with $c.Subject}}<a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{$c.SHA}}" title="{{.}}">{{.}}</a>{{end}}</td>
42 <td class="age">{{ago $c.When}}</td>
42 <td class="age"{{if not $c.When.IsZero}} title="{{whenT $c.When}}"{{end}}>{{ago $c.When}}</td>
4343</tr>
4444{{else}}<tr><td class="name empty" colspan="3">this repository is empty — push something:<br><code>git remote add origin {{.CloneURL}}</code></td></tr>{{end}}
4545</table>
internal/web/web.go +6 −1
@@ -224,7 +224,12 @@ var funcs = template.FuncMap{
224224 if err != nil {
225225 return s
226226 }
227 return t.UTC().Format("2006-01-02 15:04")
227 return t.UTC().Format("2006-01-02 15:04 UTC")
228 },
229 // whenT is when for a value that is already a time.Time rather than
230 // a stored string.
231 "whenT": func(t time.Time) string {
232 return t.UTC().Format("2006-01-02 15:04 UTC")
228233 },
229234}
230235
internal/web/web_test.go +12
@@ -71,3 +71,15 @@ func TestHeaderRowsAreLeftAligned(t *testing.T) {
7171 }
7272 }
7373}
74
75// when names the zone rather than leaving an absolute time ambiguous, and
76// passes an unparseable value through unchanged (#182).
77func TestWhenNamesTheZone(t *testing.T) {
78 got := funcs["when"].(func(string) string)("2026-09-12T02:18:07.123Z")
79 if got != "2026-09-12 02:18 UTC" {
80 t.Fatalf("when: %q", got)
81 }
82 if got := funcs["when"].(func(string) string)("not a time"); got != "not a time" {
83 t.Fatalf("passthrough: %q", got)
84 }
85}