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
4automatically on daemon start; upgrade notes appear per release when 4automatically on daemon start; upgrade notes appear per release when
5anything beyond "replace the binary and restart" is needed. 5anything beyond "replace the binary and restart" is needed.
6 6
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
7* v1.20.1 — 2026-09-11 32* v1.20.1 — 2026-09-11
8 33
9One config check. 34One 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) {
129 t.Fatalf("edit submit: %d", status) 129 t.Fatalf("edit submit: %d", status)
130 } 130 }
131 131
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
132 // The edit is a real commit: authored with the verified email, and it 142 // The edit is a real commit: authored with the verified email, and it
133 // displays as unsigned — the honest outcome for a server-side commit. 143 // displays as unsigned — the honest outcome for a server-side commit.
134 logOut, _, code := inst.ssh(t, aliceKey, "", "repo", "log", "alice/site", "--limit", "1", "--json") 144 logOut, _, code := inst.ssh(t, aliceKey, "", "repo", "log", "alice/site", "--limit", "1", "--json")
@@ -189,6 +199,13 @@ func TestWebAccounts(t *testing.T) {
189 t.Fatalf("require-signed web edit not refused:\n%s", body) 199 t.Fatalf("require-signed web edit not refused:\n%s", body)
190 } 200 }
191 201
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
192 // Issue participation through the web. 209 // Issue participation through the web.
193 if _, _, code := inst.ssh(t, aliceKey, "", "issue", "create", "alice/site", "--title", "'from ssh'"); code != 0 { 210 if _, _, code := inst.ssh(t, aliceKey, "", "issue", "create", "alice/site", "--title", "'from ssh'"); code != 0 {
194 t.Fatal("issue create failed") 211 t.Fatal("issue create failed")
e2e/accountweb_test.go +14 −2
@@ -72,10 +72,22 @@ func TestAccountSettingsWeb(t *testing.T) {
72 t.Error("git-scoped key ran a control command") 72 t.Error("git-scoped key ran a control command")
73 } 73 }
74 74
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.
76 fp := gitScopedFingerprint(t, out) 77 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{
78 "field": {"key-remove"}, "fingerprint": {fp}, 80 "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},
79 }); status != 303 && status != 200 { 91 }); status != 303 && status != 200 {
80 t.Fatalf("key remove: %d", status) 92 t.Fatalf("key remove: %d", status)
81 } 93 }
e2e/commitrefs_test.go +7 −6
@@ -41,7 +41,7 @@ func TestCommitMessageIssueActions(t *testing.T) {
41 mustGit(t, dir, env, "push", "-q", "origin", "main") 41 mustGit(t, dir, env, "push", "-q", "origin", "main")
42 42
43 out, _, _ := inst.ssh(t, aliceKey, "", "issue", "show", "alice/app", "1", "--json") 43 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 ") {
45 t.Fatalf("issue 1 not closed by commit: %s", out) 45 t.Fatalf("issue 1 not closed by commit: %s", out)
46 } 46 }
47 // The entry is a system message with a linked sha, not a user comment. 47 // The entry is a system message with a linked sha, not a user comment.
@@ -86,15 +86,15 @@ func TestCommitMessageIssueActions(t *testing.T) {
86 t.Fatalf("merge: %s", errOut) 86 t.Fatalf("merge: %s", errOut)
87 } 87 }
88 out, _, _ = inst.ssh(t, aliceKey, "", "issue", "show", "alice/app", "3", "--json") 88 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 ") {
90 t.Fatalf("merge did not close issue 3: %s", out) 90 t.Fatalf("merge did not close issue 3: %s", out)
91 } 91 }
92 // This one was authored by an address nobody has verified, so it names 92 // This one was authored by an address nobody has verified, so it names
93 // git's author without inventing a profile link for them. 93 // 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]") {
95 t.Fatalf("unresolved author should stay plain text: %s", out) 95 t.Fatalf("unresolved author should stay plain text: %s", out)
96 } 96 }
97 if strings.Count(out, "closed by commit") != 1 { 97 if strings.Count(out, "closed by ") != 1 {
98 t.Fatalf("duplicate close comments: %s", out) 98 t.Fatalf("duplicate close comments: %s", out)
99 } 99 }
100 100
@@ -126,8 +126,9 @@ func TestCommitMessageIssueActions(t *testing.T) {
126 t.Fatalf("commit-file: %s", errOut) 126 t.Fatalf("commit-file: %s", errOut)
127 } 127 }
128 out, _, _ = inst.ssh(t, aliceKey, "", "issue", "show", "alice/app", "2", "--json") 128 out, _, _ = inst.ssh(t, aliceKey, "", "issue", "show", "alice/app", "2", "--json")
129 if !strings.Contains(out, `"state":"closed"`) || !strings.Contains(out, "closed by commit") || 129 if !strings.Contains(out, `"state":"closed"`) ||
130 !strings.Contains(out, "by [alice](/alice): Closes #2 from the editor") { 130 !strings.Contains(out, "closed by [alice](/alice) in commit") ||
131 !strings.Contains(out, ": Closes #2 from the editor") {
131 t.Fatalf("commit-file did not close issue 2: %s", out) 132 t.Fatalf("commit-file did not close issue 2: %s", out)
132 } 133 }
133 if strings.Count(out, "referenced in commit") != 1 { 134 if strings.Count(out, "referenced in commit") != 1 {
e2e/labelweb_test.go +11 −2
@@ -51,9 +51,18 @@ func TestLabelsWeb(t *testing.T) {
51 t.Errorf("bad colour accepted:\n%s", body) 51 t.Errorf("bad colour accepted:\n%s", body)
52 } 52 }
53 53
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 }
55 if status, _ := browserPost(t, alice, base+"/labels", url.Values{ 64 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 {
57 t.Fatal("label remove failed") 66 t.Fatal("label remove failed")
58 } 67 }
59 if out, _, _ := inst.ssh(t, aliceKey, "", "issue", "show", "alice/app", "1", "--json"); strings.Contains(out, `"bug"`) { 68 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) {
155 if _, err := os.Stat(filepath.Join(dir, "feature.txt")); err != nil { 155 if _, err := os.Stat(filepath.Join(dir, "feature.txt")); err != nil {
156 t.Fatal("merged content missing from main") 156 t.Fatal("merged content missing from main")
157 } 157 }
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 }
158 165
159 // Readers get no controls, and a forged POST is refused by the command. 166 // Readers get no controls, and a forged POST is refused by the command.
160 _, anon := browserGet(t, newBrowser(t), mrURL) 167 _, anon := browserGet(t, newBrowser(t), mrURL)
e2e/orgweb_test.go +19
@@ -85,6 +85,25 @@ func TestOrgManagementWeb(t *testing.T) {
85 browserPost(t, alice, inst.base()+"/acme", url.Values{ 85 browserPost(t, alice, inst.base()+"/acme", url.Values{
86 "field": {"team-revoke"}, "team": {"builders"}, "repo": {"acme/widget"}, 86 "field": {"team-revoke"}, "team": {"builders"}, "repo": {"acme/widget"},
87 }) 87 })
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
88 browserPost(t, alice, inst.base()+"/acme", url.Values{ 107 browserPost(t, alice, inst.base()+"/acme", url.Values{
89 "field": {"member-remove"}, "user": {"bob"}, 108 "field": {"member-remove"}, "user": {"bob"},
90 }) 109 })
e2e/releaseweb_test.go +10 −1
@@ -98,8 +98,17 @@ func TestReleaseAndBuildWeb(t *testing.T) {
98 if _, p := browserGet(t, alice, base+"/releases"); !strings.Contains(p, "Delete release") { 98 if _, p := browserGet(t, alice, base+"/releases"); !strings.Contains(p, "Delete release") {
99 t.Fatalf("owner is not offered the delete control:\n%s", p) 99 t.Fatalf("owner is not offered the delete control:\n%s", p)
100 } 100 }
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 }
101 if status, _ := browserPost(t, alice, base+"/releases", url.Values{ 110 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 {
103 t.Fatal("release delete failed") 112 t.Fatal("release delete failed")
104 } 113 }
105 out, _, _ = inst.ssh(t, aliceKey, "", "release", "list", "alice/app", "--json") 114 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) {
72 if resp.StatusCode != 200 || !strings.HasPrefix(resp.Header.Get("Content-Type"), "text/plain") || resp.Header.Get("X-Content-Type-Options") != "nosniff" { 72 if resp.StatusCode != 200 || !strings.HasPrefix(resp.Header.Get("Content-Type"), "text/plain") || resp.Header.Get("X-Content-Type-Options") != "nosniff" {
73 t.Fatalf("raw headers: %d %v", resp.StatusCode, resp.Header) 73 t.Fatalf("raw headers: %d %v", resp.StatusCode, resp.Header)
74 } 74 }
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 {
76 t.Fatalf("raw for a missing file: %d", status) 76 t.Fatalf("raw for a missing file: %d", status)
77 } 77 }
78 78
@@ -138,14 +138,25 @@ func TestSnippetsWeb(t *testing.T) {
138 if status, _ := browserPost(t, alice, page+"/file", url.Values{"name": {"b.txt"}, "content": {"b\n"}}); status != 200 { 138 if status, _ := browserPost(t, alice, page+"/file", url.Values{"name": {"b.txt"}, "content": {"b\n"}}); status != 200 {
139 t.Fatal("file add failed") 139 t.Fatal("file add failed")
140 } 140 }
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 {
142 t.Fatal("file remove failed") 151 t.Fatal("file remove failed")
143 } 152 }
144 if _, _, code := inst.ssh(t, aliceKey, "", "snippet", "file", "get", created, "b.txt"); code != 3 { 153 if _, _, code := inst.ssh(t, aliceKey, "", "snippet", "file", "get", created, "b.txt"); code != 3 {
145 t.Fatalf("b.txt after web remove: exit %d", code) 154 t.Fatalf("b.txt after web remove: exit %d", code)
146 } 155 }
147 // A refusal comes back on the page as a message, not a bare error. 156 // 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"}})
149 if !strings.Contains(body, `class="error"`) || !strings.Contains(body, "at least one file") { 160 if !strings.Contains(body, `class="error"`) || !strings.Contains(body, "at least one file") {
150 t.Fatalf("last-file refusal on the page:\n%s", body) 161 t.Fatalf("last-file refusal on the page:\n%s", body)
151 } 162 }
@@ -167,10 +178,25 @@ func TestSnippetsWeb(t *testing.T) {
167 if status, _ := browserGet(t, bob, inst.base()+"/alice/-/snippets/"+unlisted); status != 200 { 178 if status, _ := browserGet(t, bob, inst.base()+"/alice/-/snippets/"+unlisted); status != 200 {
168 t.Fatalf("stranger on an unlisted page: %d", status) 179 t.Fatalf("stranger on an unlisted page: %d", status)
169 } 180 }
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 }
170 if status, _ := browserPost(t, bob, inst.base()+"/alice/-/snippets/"+public+"/edit", url.Values{"description": {"x"}, "visibility": {"public"}}); status != 403 { 187 if status, _ := browserPost(t, bob, inst.base()+"/alice/-/snippets/"+public+"/edit", url.Values{"description": {"x"}, "visibility": {"public"}}); status != 403 {
171 t.Fatalf("bob editing alice's snippet: %d", status) 188 t.Fatalf("bob editing alice's snippet: %d", status)
172 } 189 }
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 {
174 t.Fatal("delete failed") 200 t.Fatal("delete failed")
175 } 201 }
176 if _, _, code := inst.ssh(t, aliceKey, "", "snippet", "show", created); code != 3 { 202 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) {
85 if status != 200 || !strings.Contains(body, "src/") || !strings.Contains(body, "README.md") { 85 if status != 200 || !strings.Contains(body, "src/") || !strings.Contains(body, "README.md") {
86 t.Fatalf("repo home: %d\n%s", status, body) 86 t.Fatalf("repo home: %d\n%s", status, body)
87 } 87 }
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 }
88 if !strings.Contains(body, "<h2 id=\"hello-site\">hello site</h2>") || !strings.Contains(body, "<em>markdown</em>") { 92 if !strings.Contains(body, "<h2 id=\"hello-site\">hello site</h2>") || !strings.Contains(body, "<em>markdown</em>") {
89 t.Fatalf("README not rendered:\n%s", body) 93 t.Fatalf("README not rendered:\n%s", body)
90 } 94 }
e2e/websessions_test.go +23
@@ -84,4 +84,27 @@ func TestWebSessionsListRevoke(t *testing.T) {
84 if _, _, code := inst.ssh(t, aliceKey, "", "web", "sessions", "revoke", "abcdefabcdef"); code != 3 { 84 if _, _, code := inst.ssh(t, aliceKey, "", "web", "sessions", "revoke", "abcdefabcdef"); code != 3 {
85 t.Fatal("unknown id accepted") 85 t.Fatal("unknown id accepted")
86 } 86 }
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 }
87} 110}
internal/control/commitrefs.go +1 −1
@@ -240,7 +240,7 @@ func actOnIssue(st *store.Store, source, target store.Repo, actorID int64, sha s
240 slog.Error("commit refs: closing issue", "issue", number, "err", err) 240 slog.Error("commit refs: closing issue", "issue", number, "err", err)
241 return 241 return
242 } 242 }
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))
244 st.RecordEvent(target.ID, actorID, "issue.closed", fmt.Sprintf(`{"number":%d,"sha":%q}`, number, sha)) 244 st.RecordEvent(target.ID, actorID, "issue.closed", fmt.Sprintf(`{"number":%d,"sha":%q}`, number, sha))
245 return 245 return
246 } 246 }
internal/control/mr.go +3 −3
@@ -1054,7 +1054,7 @@ func runMRMerge(c *Ctx, args []string) int {
1054 case "ff": 1054 case "ff":
1055 if !ffPossible { 1055 if !ffPossible {
1056 return c.fail(protocol.ExitUsage, 1056 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)
1058 } 1058 }
1059 newSHA = headSHA 1059 newSHA = headSHA
1060 1060
@@ -1129,7 +1129,7 @@ func runMRMerge(c *Ctx, args []string) int {
1129 } 1129 }
1130 if len(parents) > 1 { 1130 if len(parents) > 1 {
1131 return c.fail(protocol.ExitUsage, 1131 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)
1133 } 1133 }
1134 base := onto // root commit: replay against the new tip itself 1134 base := onto // root commit: replay against the new tip itself
1135 if len(parents) == 1 { 1135 if len(parents) == 1 {
@@ -1175,7 +1175,7 @@ func runMRMerge(c *Ctx, args []string) int {
1175 nums = append(nums, fmt.Sprintf("!%d", k.Number)) 1175 nums = append(nums, fmt.Sprintf("!%d", k.Number))
1176 } 1176 }
1177 return c.fail(protocol.ExitUsage, 1177 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",
1179 fmt.Sprintf("!%d", mr.Number), strings.Join(nums, ", "), strategy, mr.SourceRef) 1179 fmt.Sprintf("!%d", mr.Number), strings.Join(nums, ", "), strategy, mr.SourceRef)
1180 } 1180 }
1181 1181
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 {
20 Algo string 20 Algo string
21 Scope string 21 Scope string
22 Label string 22 Label string
23 Confirm string // the 8 characters after SHA256: — a label can be empty
23} 24}
24 25
25type accountPGP struct { 26type accountPGP struct {
@@ -27,6 +28,7 @@ type accountPGP struct {
27 UIDs []string 28 UIDs []string
28 Expired bool 29 Expired bool
29 Revoked bool 30 Revoked bool
31 Confirm string // the fingerprint's first 8 characters
30} 32}
31 33
32// accountForm renders the account's own settings: keys, addresses, and the 34// 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
35 var keys []accountKey 37 var keys []accountKey
36 if list, err := s.st.ListSSHKeys(u.ID); err == nil { 38 if list, err := s.st.ListSSHKeys(u.ID); err == nil {
37 for _, k := range list { 39 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})
39 } 42 }
40 } 43 }
41 var pgp []accountPGP 44 var pgp []accountPGP
@@ -43,9 +46,10 @@ func (s *Server) accountForm(w http.ResponseWriter, r *http.Request, u store.Use
43 for _, k := range list { 46 for _, k := range list {
44 var uids []string 47 var uids []string
45 json.Unmarshal([]byte(k.UIDsJSON), &uids) 48 json.Unmarshal([]byte(k.UIDsJSON), &uids)
49 confirm := prefix8(k.Fingerprint)
46 pgp = append(pgp, accountPGP{ 50 pgp = append(pgp, accountPGP{
47 Fingerprint: k.Fingerprint, UIDs: uids, 51 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,
49 }) 53 })
50 } 54 }
51 } 55 }
@@ -153,6 +157,11 @@ func (s *Server) accountSubmit(w http.ResponseWriter, r *http.Request, u store.U
153 } 157 }
154 back("", "key registered") 158 back("", "key registered")
155 case "key-remove": 159 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 }
156 if _, msg, ok := s.runControl(u, []string{"keys", "remove", r.FormValue("fingerprint")}); !ok { 165 if _, msg, ok := s.runControl(u, []string{"keys", "remove", r.FormValue("fingerprint")}); !ok {
157 back(msg, "") 166 back(msg, "")
158 return 167 return
@@ -170,7 +179,13 @@ func (s *Server) accountSubmit(w http.ResponseWriter, r *http.Request, u store.U
170 } 179 }
171 back("", "PGP key registered") 180 back("", "PGP key registered")
172 case "pgp-remove": 181 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 {
174 back(msg, "") 189 back(msg, "")
175 return 190 return
176 } 191 }
@@ -188,7 +203,12 @@ func (s *Server) accountSubmit(w http.ResponseWriter, r *http.Request, u store.U
188 } 203 }
189 back("", "address verified") 204 back("", "address verified")
190 case "email-remove": 205 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 {
192 back(msg, "") 212 back(msg, "")
193 return 213 return
194 } 214 }
internal/httpd/accounts.go +37 −10
@@ -49,6 +49,9 @@ func (s *Server) requireUser(h func(http.ResponseWriter, *http.Request, store.Us
49 return func(w http.ResponseWriter, r *http.Request) { 49 return func(w http.ResponseWriter, r *http.Request) {
50 u := s.viewer(r) 50 u := s.viewer(r)
51 if u.ID == 0 { 51 if u.ID == 0 {
52 if r.Method == http.MethodGet {
53 s.setNext(w, r.URL.RequestURI())
54 }
52 http.Redirect(w, r, "/login", http.StatusSeeOther) 55 http.Redirect(w, r, "/login", http.StatusSeeOther)
53 return 56 return
54 } 57 }
@@ -76,15 +79,16 @@ func (s *Server) checkOrigin(h http.HandlerFunc) http.HandlerFunc {
76// the page can tell a brand-new visitor how to get an account. EmailLogin 79// the page can tell a brand-new visitor how to get an account. EmailLogin
77// says whether this instance can mail a link; Sent switches the page to the 80// says whether this instance can mail a link; Sent switches the page to the
78// confirmation that follows a request. 81// 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) {
80 s.render(w, "login.html", struct { 83 s.render(w, "login.html", struct {
81 basePage 84 basePage
82 Mode string // closed | invite | open 85 Mode string // closed | invite | open
83 Error string 86 Error string
84 EmailLogin bool 87 EmailLogin bool
85 Sent bool 88 Sent bool
89 Next string
86 }{basePage{Site: s.siteName(), Host: s.cfg.SiteHost()}, 90 }{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})
88} 92}
89 93
90// emailLoginEnabled reports whether a link can be mailed at all. There is no 94// 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) {
111 if err := control.RequestLoginLink(s.cfg, s.st, r.FormValue("identifier")); err != nil { 115 if err := control.RequestLoginLink(s.cfg, s.st, r.FormValue("identifier")); err != nil {
112 log.Printf("login link: %v", err) 116 log.Printf("login link: %v", err)
113 } 117 }
114 s.renderLogin(w, "", true) 118 s.renderLogin(w, "", true, "")
115} 119}
116 120
117func (s *Server) login(w http.ResponseWriter, r *http.Request) { 121func (s *Server) login(w http.ResponseWriter, r *http.Request) {
118 token := r.URL.Query().Get("token") 122 token := r.URL.Query().Get("token")
119 if token == "" { 123 if token == "" {
120 s.renderLogin(w, "", false) 124 s.renderLogin(w, "", false, s.peekNext(r))
121 return 125 return
122 } 126 }
123 userID, err := s.st.ConsumeLoginToken(store.HashToken(token)) 127 userID, err := s.st.ConsumeLoginToken(store.HashToken(token))
124 if err != nil { 128 if err != nil {
125 s.renderLogin(w, badLoginToken, false) 129 s.renderLogin(w, badLoginToken, false, "")
126 return 130 return
127 } 131 }
128 // A token minted before the account was suspended is still consumable, 132 // 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) {
130 // read. Checking here covers every mint path. The message is the one a 134 // read. Checking here covers every mint path. The message is the one a
131 // bad token gets: a distinct one would confirm the account exists. 135 // bad token gets: a distinct one would confirm the account exists.
132 if u, err := s.st.UserByID(userID); err != nil || u.Disabled { 136 if u, err := s.st.UserByID(userID); err != nil || u.Disabled {
133 s.renderLogin(w, badLoginToken, false) 137 s.renderLogin(w, badLoginToken, false, "")
134 return 138 return
135 } 139 }
136 sessTok, sessHash, err := store.NewToken() 140 sessTok, sessHash, err := store.NewToken()
@@ -143,7 +147,11 @@ func (s *Server) login(w http.ResponseWriter, r *http.Request) {
143 return 147 return
144 } 148 }
145 http.SetCookie(w, s.sessionCookieFor(sessTok)) 149 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)
147} 155}
148 156
149// sessionCookieFor is the cookie a new session ships in. Secure follows TLS 157// sessionCookieFor is the cookie a new session ships in. Secure follows TLS
@@ -471,6 +479,9 @@ type editPage struct {
471 Path string 479 Path string
472 Content string 480 Content string
473 Error string 481 Error string
482 Blocked string
483 // Creating marks a path the branch does not have yet.
484 Creating bool
474} 485}
475 486
476func (s *Server) editForm(w http.ResponseWriter, r *http.Request, u store.User) { 487func (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)
480 } 491 }
481 ref := r.PathValue("ref") 492 ref := r.PathValue("ref")
482 filePath := strings.Trim(r.PathValue("path"), "/") 493 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
483 dir := control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name) 503 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 }
484 content, err := gitutil.ReadBlob(dir, "refs/heads/"+ref, filePath, maxRenderBytes) 510 content, err := gitutil.ReadBlob(dir, "refs/heads/"+ref, filePath, maxRenderBytes)
485 if err != nil { 511 creating := err != nil
486 content = nil // new file 512 if creating {
513 content = nil
487 } 514 }
488 if gitutil.IsBinary(content) { 515 if gitutil.IsBinary(content) {
489 http.Error(w, "binary files cannot be edited in the browser", http.StatusBadRequest) 516 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)
491 } 518 }
492 s.render(w, "edit.html", editPage{ 519 s.render(w, "edit.html", editPage{
493 basePage: s.baseFor(u), Repo: repo, 520 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,
495 }) 522 })
496} 523}
497 524
internal/httpd/compare.go +1 −1
@@ -69,7 +69,7 @@ func (s *Server) compare(w http.ResponseWriter, r *http.Request) {
69 cr.Subject = parsed.Subject 69 cr.Subject = parsed.Subject
70 cr.AuthorName = names.name(parsed.AuthorEmail, parsed.AuthorName) 70 cr.AuthorName = names.name(parsed.AuthorEmail, parsed.AuthorName)
71 cr.AuthorUser, _ = names.account(parsed.AuthorEmail) 71 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)
73 } 73 }
74 commits = append(commits, cr) 74 commits = append(commits, cr)
75 } 75 }
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) {
27 } 27 }
28 page := sb.String() 28 page := sb.String()
29 for _, want := range []string{ 29 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",
31 `href="/krz/gitbay/issues/140"`, 31 `href="/krz/gitbay/issues/140"`,
32 } { 32 } {
33 if !strings.Contains(page, want) { 33 if !strings.Contains(page, want) {
internal/httpd/flash.go +46
@@ -3,6 +3,7 @@ package httpd
3import ( 3import (
4 "net/http" 4 "net/http"
5 "net/url" 5 "net/url"
6 "strings"
6) 7)
7 8
8// A form action that fails redirects back to the page it came from with 9// 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 {
43 return msg 44 return msg
44} 45}
45 46
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
46// clearCookie is the expiring twin of a Set-Cookie, carrying the same 92// clearCookie is the expiring twin of a Set-Cookie, carrying the same
47// attributes the setting call used. 93// attributes the setting call used.
48// 94//
internal/httpd/labels.go +14 −2
@@ -28,13 +28,21 @@ func (s *Server) labels(w http.ResponseWriter, r *http.Request) {
28 http.Error(w, "internal error", http.StatusInternalServerError) 28 http.Error(w, "internal error", http.StatusInternalServerError)
29 return 29 return
30 } 30 }
31 anyColor := false
32 for _, l := range labels {
33 if l.Color != "" {
34 anyColor = true
35 break
36 }
37 }
31 s.render(w, "labels.html", struct { 38 s.render(w, "labels.html", struct {
32 repoPage 39 repoPage
33 Labels []store.Label 40 Labels []store.Label
34 LabelColors map[string]template.CSS 41 LabelColors map[string]template.CSS
35 CanWrite bool 42 CanWrite bool
43 AnyColor bool
36 Notice string 44 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)})
38} 46}
39 47
40// labelSubmit creates a label, sets its colour, or removes it, through 48// 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
44 name := strings.TrimSpace(r.FormValue("name")) 52 name := strings.TrimSpace(r.FormValue("name"))
45 back := func(w http.ResponseWriter, r *http.Request, msg string) { s.backTo(w, r, "labels", msg) } 53 back := func(w http.ResponseWriter, r *http.Request, msg string) { s.backTo(w, r, "labels", msg) }
46 if name == "" { 54 if name == "" {
47 s.backTo(w, r, "labels", "name the label") 55 back(w, r, "name the label")
48 return 56 return
49 } 57 }
50 argv := []string{"label", "set", repo, name, "--color", strings.TrimSpace(r.FormValue("color"))} 58 argv := []string{"label", "set", repo, name, "--color", strings.TrimSpace(r.FormValue("color"))}
51 if r.FormValue("action") == "remove" { 59 if r.FormValue("action") == "remove" {
60 if ok, msg := confirmed(r, name); !ok {
61 s.backTo(w, r, "labels", msg)
62 return
63 }
52 argv = []string{"label", "remove", repo, name} 64 argv = []string{"label", "remove", repo, name}
53 } 65 }
54 _, msg, code := s.runControlCode(u, argv) 66 _, msg, code := s.runControlCode(u, argv)
internal/httpd/mrpage_test.go +1
@@ -33,6 +33,7 @@ type mrPageData struct {
33 Notice string 33 Notice string
34 DetachedThreads []diffThread 34 DetachedThreads []diffThread
35 Gates *control.GatesOut 35 Gates *control.GatesOut
36 SourceGone bool
36} 37}
37 38
38func renderMR(t *testing.T, m store.MR, reviews []store.MRReview, checks []store.Check) string { 39func 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)
80 case "team-create": 80 case "team-create":
81 argv = []string{"org", "team", "create", owner, team} 81 argv = []string{"org", "team", "create", owner, team}
82 case "team-delete": 82 case "team-delete":
83 if ok, msg := confirmed(r, team); !ok {
84 back(msg)
85 return
86 }
83 argv = []string{"org", "team", "delete", owner, team} 87 argv = []string{"org", "team", "delete", owner, team}
84 case "team-add": 88 case "team-add":
85 argv = append([]string{"org", "team", "add", owner, team}, strings.Fields(user)...) 89 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
28 return 28 return
29 } 29 }
30 back := func(w http.ResponseWriter, r *http.Request, msg string) { s.backTo(w, r, "releases", msg) } 30 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 31 // The CLI's --yes guards against a mistyped tag; here the tag comes
32 // the page and the button sits behind a disclosure, so the click is the 32 // from the page, so the browser's own confirm field stands in.
33 // deliberate act.
34 if r.FormValue("action") == "delete" { 33 if r.FormValue("action") == "delete" {
34 if ok, msg := confirmed(r, tag); !ok {
35 back(w, r, msg)
36 return
37 }
35 _, msg, code := s.runControlCode(u, []string{"release", "delete", repo, tag, "--yes"}) 38 _, msg, code := s.runControlCode(u, []string{"release", "delete", repo, tag, "--yes"})
36 s.done(w, r, code, msg, back) 39 s.done(w, r, code, msg, back)
37 return 40 return
internal/httpd/snippets.go +38 −13
@@ -178,14 +178,12 @@ func (s *Server) snippetNewSubmit(w http.ResponseWriter, r *http.Request, u stor
178 http.Redirect(w, r, "/"+u.Username+"/-/snippets/"+out.ID, http.StatusSeeOther) 178 http.Redirect(w, r, "/"+u.Username+"/-/snippets/"+out.ID, http.StatusSeeOther)
179} 179}
180 180
181// snippetAction runs a write on the snippet in the URL and returns to its 181// snippetAction runs a write on an already-resolved snippet and returns to
182// page with the message, or to dest (the list, for a delete) on success. 182// its page with the message, or to dest (the list, for a delete) on
183// A snippet the viewer may not read is the 404 page, as on every read. 183// success. Callers resolve the snippet with snippetScope first, so a
184func (s *Server) snippetAction(w http.ResponseWriter, r *http.Request, u store.User, argv []string, stdin string, dest string) { 184// snippet the viewer may not read is the 404 page before any confirmation
185 sn, _, ok := s.snippetScope(w, r) 185// or write is considered.
186 if !ok { 186func (s *Server) snippetAction(w http.ResponseWriter, r *http.Request, u store.User, sn store.Snippet, argv []string, stdin string, dest string) {
187 return
188 }
189 page := "/" + sn.OwnerName + "/-/snippets/" + sn.PublicID 187 page := "/" + sn.OwnerName + "/-/snippets/" + sn.PublicID
190 if dest == "" { 188 if dest == "" {
191 dest = page 189 dest = page
@@ -207,23 +205,50 @@ func (s *Server) snippetAction(w http.ResponseWriter, r *http.Request, u store.U
207} 205}
208 206
209func (s *Server) snippetEditSubmit(w http.ResponseWriter, r *http.Request, u store.User) { 207func (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"),
211 "--description", strings.TrimSpace(r.FormValue("description")), 213 "--description", strings.TrimSpace(r.FormValue("description")),
212 "--visibility", r.FormValue("visibility")}, "", "") 214 "--visibility", r.FormValue("visibility")}, "", "")
213} 215}
214 216
215func (s *Server) snippetDeleteSubmit(w http.ResponseWriter, r *http.Request, u store.User) { 217func (s *Server) snippetDeleteSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
216 s.snippetAction(w, r, u, []string{"snippet", "delete", r.PathValue("id")}, "", 218 sn, _, ok := s.snippetScope(w, r)
217 "/"+r.PathValue("owner")+"/-/snippets") 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")
218} 229}
219 230
220// An empty textarea reaches the command as empty stdin, which it refuses; 231// An empty textarea reaches the command as empty stdin, which it refuses;
221// the message lands on the page like any other. 232// the message lands on the page like any other.
222func (s *Server) snippetFileSubmit(w http.ResponseWriter, r *http.Request, u store.User) { 233func (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"))},
224 r.FormValue("content"), "") 239 r.FormValue("content"), "")
225} 240}
226 241
227func (s *Server) snippetFileRemoveSubmit(w http.ResponseWriter, r *http.Request, u store.User) { 242func (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}, "", "")
229} 254}
internal/httpd/web.go +64 −31
@@ -243,17 +243,20 @@ type repoPage struct {
243 Repo store.Repo 243 Repo store.Repo
244 Ref string 244 Ref string
245 CloneURL string 245 CloneURL string
246 Dir string 246 // SSHCloneURL is the same repository over the SSH transport, which is
247 Tab string // active tab in the repo header 247 // the one a push needs.
248 Topics []string 248 SSHCloneURL string
249 Pinned bool // by the viewer 249 Dir string
250 Marked bool // bookmarked by the viewer 250 Tab string // active tab in the repo header
251 Watch string // the viewer's watch state: watching, muted, or "" 251 Topics []string
252 HasWiki bool 252 Pinned bool // by the viewer
253 Host string 253 Marked bool // bookmarked by the viewer
254 Mirrors []mirrorLine // repo admins only 254 Watch string // the viewer's watch state: watching, muted, or ""
255 CanAdmin bool // gates the settings tab 255 HasWiki bool
256 Feed string // Atom feed for this page, if it has one 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
257 // OpenIssues and OpenMRs are the counts on the header tabs. 260 // OpenIssues and OpenMRs are the counts on the header tabs.
258 OpenIssues int 261 OpenIssues int
259 OpenMRs int 262 OpenMRs int
@@ -331,22 +334,23 @@ func (s *Server) repoFor(w http.ResponseWriter, r *http.Request, ref string) (re
331 } 334 }
332 openIssues, openMRs := s.st.OpenCounts(repo.ID) 335 openIssues, openMRs := s.st.OpenCounts(repo.ID)
333 return repoPage{ 336 return repoPage{
334 basePage: s.baseFor(viewer), 337 basePage: s.baseFor(viewer),
335 CanAdmin: canAdmin, 338 CanAdmin: canAdmin,
336 Mirrors: mirrors, 339 Mirrors: mirrors,
337 Pinned: pinned, 340 Pinned: pinned,
338 Marked: marked, 341 Marked: marked,
339 Watch: watch, 342 Watch: watch,
340 HasWiki: s.hasWiki(repo), 343 HasWiki: s.hasWiki(repo),
341 Host: s.cfg.SiteHost(), 344 Host: s.cfg.SiteHost(),
342 Desc: gitutil.ReadDescription(control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name)), 345 Desc: gitutil.ReadDescription(control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name)),
343 Repo: repo, 346 Repo: repo,
344 Ref: ref, 347 Ref: ref,
345 CloneURL: s.cfg.Server.SiteURL + "/" + repo.Path() + ".git", 348 CloneURL: s.cfg.Server.SiteURL + "/" + repo.Path() + ".git",
346 Dir: control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name), 349 SSHCloneURL: s.sshCloneURL(repo),
347 Topics: topics, 350 Dir: control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name),
348 OpenIssues: openIssues, 351 Topics: topics,
349 OpenMRs: openMRs, 352 OpenIssues: openIssues,
353 OpenMRs: openMRs,
350 }, true 354 }, true
351} 355}
352 356
@@ -639,6 +643,7 @@ func (s *Server) releases(w http.ResponseWriter, r *http.Request) {
639 } 643 }
640 var freeTags []string 644 var freeTags []string
641 if tags, err := gitutil.Refs(p.Dir, "tags"); err == nil { 645 if tags, err := gitutil.Refs(p.Dir, "tags"); err == nil {
646 gitutil.SortVersions(tags)
642 for _, tg := range tags { 647 for _, tg := range tags {
643 if !released[tg.Name] { 648 if !released[tg.Name] {
644 freeTags = append(freeTags, tg.Name) 649 freeTags = append(freeTags, tg.Name)
@@ -860,7 +865,7 @@ func (s *Server) blame(w http.ResponseWriter, r *http.Request) {
860 } 865 }
861 date := h.Date 866 date := h.Date
862 if t, err := time.Parse(time.RFC3339, h.Date); err == nil { 867 if t, err := time.Parse(time.RFC3339, h.Date); err == nil {
863 date = t.Format("2006-01-02") 868 date = t.Format(time.RFC3339)
864 } 869 }
865 hv := hunkView{ 870 hv := hunkView{
866 BlameHunk: gitutil.BlameHunk{SHA: h.SHA, AuthorName: h.AuthorName, 871 BlameHunk: gitutil.BlameHunk{SHA: h.SHA, AuthorName: h.AuthorName,
@@ -1513,7 +1518,7 @@ func (s *Server) log(w http.ResponseWriter, r *http.Request) {
1513 rw.AuthorName = names.name(parsed.AuthorEmail, parsed.AuthorName) 1518 rw.AuthorName = names.name(parsed.AuthorEmail, parsed.AuthorName)
1514 rw.AuthorUser, _ = names.account(parsed.AuthorEmail) 1519 rw.AuthorUser, _ = names.account(parsed.AuthorEmail)
1515 rw.AuthorEmail = parsed.AuthorEmail 1520 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)
1517 } 1522 }
1518 rows = append(rows, rw) 1523 rows = append(rows, rw)
1519 } 1524 }
@@ -1887,7 +1892,7 @@ func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
1887 cr.Subject = parsed.Subject 1892 cr.Subject = parsed.Subject
1888 cr.AuthorName = mrNames.name(parsed.AuthorEmail, parsed.AuthorName) 1893 cr.AuthorName = mrNames.name(parsed.AuthorEmail, parsed.AuthorName)
1889 cr.AuthorUser, _ = mrNames.account(parsed.AuthorEmail) 1894 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)
1891 } 1896 }
1892 commits = append(commits, cr) 1897 commits = append(commits, cr)
1893 } 1898 }
@@ -1950,9 +1955,26 @@ func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
1950 StackedOn *store.MR 1955 StackedOn *store.MR
1951 Stacked []store.MR 1956 Stacked []store.MR
1952 Gates *control.GatesOut 1957 Gates *control.GatesOut
1958 SourceGone bool
1953 }{p, m, view, md(m.Body, m.BodyFormat), checks, combined, renderComments(comments, md), 1959 }{p, m, view, md(m.Body, m.BodyFormat), checks, combined, renderComments(comments, md),
1954 reviewRows, files, diffTruncated, stat, commits, commitsTotal, branches, s.canEditItem(r, p.Repo, m.Author), 1960 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
1956} 1978}
1957 1979
1958func (s *Server) refs(w http.ResponseWriter, r *http.Request) { 1980func (s *Server) refs(w http.ResponseWriter, r *http.Request) {
@@ -1963,6 +1985,7 @@ func (s *Server) refs(w http.ResponseWriter, r *http.Request) {
1963 p.Tab = "refs" 1985 p.Tab = "refs"
1964 branches, _ := gitutil.Refs(p.Dir, "heads") 1986 branches, _ := gitutil.Refs(p.Dir, "heads")
1965 tags, _ := gitutil.Refs(p.Dir, "tags") 1987 tags, _ := gitutil.Refs(p.Dir, "tags")
1988 gitutil.SortVersions(tags)
1966 s.render(w, "refs.html", struct { 1989 s.render(w, "refs.html", struct {
1967 repoPage 1990 repoPage
1968 Branches, Tags []gitutil.Ref 1991 Branches, Tags []gitutil.Ref
@@ -2005,3 +2028,13 @@ type reviewRow struct {
2005 store.MRReview 2028 store.MRReview
2006 Counts bool 2029 Counts bool
2007} 2030}
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 {
367button.btn:hover { border-color: var(--accent); color: var(--accent); } 367button.btn:hover { border-color: var(--accent); color: var(--accent); }
368button.btn[aria-pressed="true"] { border-color: var(--accent); color: var(--accent); } 368button.btn[aria-pressed="true"] { border-color: var(--accent); color: var(--accent); }
369form.inline { display: inline; } 369form.inline { display: inline; }
370input[name="confirm"] { width: auto; margin-right: var(--sp-2); }
370 371
371nav.tabs { 372nav.tabs {
372 display: flex; 373 display: flex;
internal/web/templates/account.html +4 −4
@@ -31,7 +31,7 @@ a CI checkout wants.</p>
31 <td class="mono">{{.Fingerprint}}</td> 31 <td class="mono">{{.Fingerprint}}</td>
32 <td>{{.Algo}}</td> 32 <td>{{.Algo}}</td>
33 <td>{{.Scope}}</td> 33 <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>
35</tr> 35</tr>
36{{end}}</table></div> 36{{end}}</table></div>
37{{else}}<p class="none">No SSH keys — which cannot be right, since you signed in.</p>{{end}} 37{{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>
61 {{if .Verified}}<span class="badge badge-verified">verified{{with .VerifiedBy}} · {{.}}{{end}}</span> 61 {{if .Verified}}<span class="badge badge-verified">verified{{with .VerifiedBy}} · {{.}}{{end}}</span>
62 {{else}}<span class="badge badge-unsigned">unverified</span>{{end}} 62 {{else}}<span class="badge badge-unsigned">unverified</span>{{end}}
63 {{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}} 63 {{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>
65{{end}}</ul>{{end}} 65{{end}}</ul>{{end}}
66<details class="editbox"> 66<details class="editbox">
67 <summary>Add an address</summary> 67 <summary>Add an address</summary>
@@ -87,7 +87,7 @@ account, and where notifications go.</p>
87{{range .PGP}}<tr> 87{{range .PGP}}<tr>
88 <td class="mono">{{.Fingerprint}}</td> 88 <td class="mono">{{.Fingerprint}}</td>
89 <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> 89 <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>
91</tr> 91</tr>
92{{end}}</table></div> 92{{end}}</table></div>
93{{else}}<p class="none">No OpenPGP keys.</p>{{end}} 93{{else}}<p class="none">No OpenPGP keys.</p>{{end}}
@@ -126,7 +126,7 @@ never included; a replayed bundle's emails arrive unverified.</p>
126<h2>On SSH only</h2> 126<h2>On SSH only</h2>
127<p class="meta">Anything whose input is a credential stays on the command line, 127<p class="meta">Anything whose input is a credential stays on the command line,
128where it can be piped instead of pasted:</p> 128where 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
130gitbay admin ... # instance administration</pre> 130gitbay admin ... # instance administration</pre>
131<p class="meta">All of the above works from stock OpenSSH too: 131<p class="meta">All of the above works from stock OpenSSH too:
132<code>ssh git@{{.Host}} auth whoami</code>.</p> 132<code>ssh git@{{.Host}} auth whoami</code>.</p>
internal/web/templates/blame.html +1 −1
@@ -13,7 +13,7 @@
13{{range .Hunks}}<div class="blamehunk"> 13{{range .Hunks}}<div class="blamehunk">
14 <div class="blameinfo"> 14 <div class="blameinfo">
15 <p class="subject"><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{.SHA}}">{{.Summary}}</a></p> 15 <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>
17 </div> 17 </div>
18 <pre class="blamecode">{{range .Numbered}}<span class="lineno">{{.N}}</span>{{.Text}} 18 <pre class="blamecode">{{range .Numbered}}<span class="lineno">{{.N}}</span>{{.Text}}
19{{end}}</pre> 19{{end}}</pre>
internal/web/templates/commit.html +1 −1
@@ -5,7 +5,7 @@
5 <p class="meta"><code class="fullsha">{{.SHA}}</code></p> 5 <p class="meta"><code class="fullsha">{{.SHA}}</code></p>
6 {{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}} 6 {{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}}
7 <p>{{template "sigbadge" .Sig}}{{range .Checks}} <span class="badge check-{{.State}}">{{.Context}}: {{.State}}</span>{{end}}</p> 7 <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}}
9 {{if .CommitterEmail}}<br>committer: &lt;{{.CommitterEmail}}&gt;{{end}}</p> 9 {{if .CommitterEmail}}<br>committer: &lt;{{.CommitterEmail}}&gt;{{end}}</p>
10</div> 10</div>
11<pre class="message">{{.Message}}</pre> 11<pre class="message">{{.Message}}</pre>
internal/web/templates/compare.html +1 −1
@@ -6,7 +6,7 @@
6{{range .Commits}}<li> 6{{range .Commits}}<li>
7 <div class="commitmain"> 7 <div class="commitmain">
8 <p class="subject"><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{.SHA}}">{{.Subject}}</a></p> 8 <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>
10 </div> 10 </div>
11 <span class="commitside">{{template "sigbadge" .Sig}} <code><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{.SHA}}">{{.ShortSHA}}</a></code></span> 11 <span class="commitside">{{template "sigbadge" .Sig}} <code><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{.SHA}}">{{.ShortSHA}}</a></code></span>
12</li>{{end}} 12</li>{{end}}
internal/web/templates/edit.html +3
@@ -2,6 +2,8 @@
2{{define "content"}} 2{{define "content"}}
3<h1>edit {{.Repo.OwnerName}}/{{.Repo.Name}} : {{.Path}} @ {{.Ref}}</h1> 3<h1>edit {{.Repo.OwnerName}}/{{.Repo.Name}} : {{.Path}} @ {{.Ref}}</h1>
4{{if .Error}}<p class="error" role="alert">{{.Error}}</p>{{end}} 4{{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}}
5<form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/edit/{{.Ref}}/{{.Path}}" class="editform"> 7<form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/edit/{{.Ref}}/{{.Path}}" class="editform">
6<p><textarea name="content" aria-label="File contents" rows="24" spellcheck="false">{{.Content}}</textarea></p> 8<p><textarea name="content" aria-label="File contents" rows="24" spellcheck="false">{{.Content}}</textarea></p>
7<p><input name="message" aria-label="Commit message" placeholder="commit message"> 9<p><input name="message" aria-label="Commit message" placeholder="commit message">
@@ -9,3 +11,4 @@
9<p class="crumbs">this commit will be unsigned and authored as {{.Viewer}}</p> 11<p class="crumbs">this commit will be unsigned and authored as {{.Viewer}}</p>
10</form> 12</form>
11{{end}} 13{{end}}
14{{end}}
internal/web/templates/globalsearch.html +1
@@ -8,6 +8,7 @@
8 <a {{if eq .Kind "issue"}}class="active" aria-current="page" {{end}}href="?q={{.Query}}&amp;kind=issue">issues</a> 8 <a {{if eq .Kind "issue"}}class="active" aria-current="page" {{end}}href="?q={{.Query}}&amp;kind=issue">issues</a>
9 <a {{if eq .Kind "mr"}}class="active" aria-current="page" {{end}}href="?q={{.Query}}&amp;kind=mr">merge requests</a> 9 <a {{if eq .Kind "mr"}}class="active" aria-current="page" {{end}}href="?q={{.Query}}&amp;kind=mr">merge requests</a>
10 </nav> 10 </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}}
11</div> 12</div>
12<form method="get" action="/search" class="searchform"> 13<form method="get" action="/search" class="searchform">
13 <input type="search" name="q" aria-label="Search" value="{{.Query}}" placeholder="repository names and topics, issue and merge request text" autofocus> 14 <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 @@
50 <div class="grp"> 50 <div class="grp">
51 <h2>Labels</h2> 51 <h2>Labels</h2>
52 {{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> 52 {{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}}
54 {{if .CanWrite}} 54 {{if .CanWrite}}
55 <form method="post" action="{{$base}}/label" class="actions"> 55 <form method="post" action="{{$base}}/label" class="actions">
56 <input type="text" name="add" aria-label="Add labels" placeholder="add, space-separated"> 56 <input type="text" name="add" aria-label="Add labels" placeholder="add, space-separated">
@@ -62,7 +62,7 @@
62 <div class="grp"> 62 <div class="grp">
63 <h2>Assignees</h2> 63 <h2>Assignees</h2>
64 {{if .Issue.Assignees}}<p class="row">{{range .Issue.Assignees}}<a href="/{{.}}">{{.}}</a> {{end}}</p> 64 {{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}}
66 {{if .CanWrite}} 66 {{if .CanWrite}}
67 <form method="post" action="{{$base}}/assign" class="actions"> 67 <form method="post" action="{{$base}}/assign" class="actions">
68 <input type="text" name="add" aria-label="Add assignees" placeholder="add, space-separated"> 68 <input type="text" name="add" aria-label="Add assignees" placeholder="add, space-separated">
@@ -74,7 +74,7 @@
74 <div class="grp"> 74 <div class="grp">
75 <h2>Milestone</h2> 75 <h2>Milestone</h2>
76 {{if .Issue.Milestone}}<p class="row"><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/milestones">{{.Issue.Milestone}}</a></p> 76 {{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}}
78 {{if .CanWrite}} 78 {{if .CanWrite}}
79 <form method="post" action="{{$base}}/milestone" class="actions"> 79 <form method="post" action="{{$base}}/milestone" class="actions">
80 <label class="none" for="milestone">Set milestone</label> 80 <label class="none" for="milestone">Set milestone</label>
internal/web/templates/issues.html +2 −2
@@ -20,9 +20,9 @@
20 <div class="issuemain"> 20 <div class="issuemain">
21 <p class="title"><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/issues/{{.Number}}">{{.Title}}</a> 21 <p class="title"><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/issues/{{.Number}}">{{.Title}}</a>
22 {{range .Labels}}<a class="chip label" style="{{index $.LabelColors .}}" href="?label={{.}}">{{.}}</a> {{end}}</p> 22 {{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>
24 </div> 24 </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}}
26</li> 26</li>
27{{else}}{{if .Query}}<li class="empty">no {{if ne .State "all"}}{{.State}} {{end}}issues matching “{{.Query}}”</li> 27{{else}}{{if .Query}}<li class="empty">no {{if ne .State "all"}}{{.State}} {{end}}issues matching “{{.Query}}”</li>
28{{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}} 28{{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 @@
3<h1>Labels</h1> 3<h1>Labels</h1>
4{{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}} 4{{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}}
5{{if .Labels}}<div class="tablewrap"><table class="keys"> 5{{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>
7{{range .Labels}}<tr> 7{{range .Labels}}<tr>
8 <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> 8 <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">
10 <input type="hidden" name="name" value="{{.Name}}"> 10 <input type="hidden" name="name" value="{{.Name}}">
11 <input type="text" name="color" value="{{.Color}}" aria-label="Colour for {{.Name}}" placeholder="rrggbb" size="8"> 11 <input type="text" name="color" value="{{.Color}}" aria-label="Colour for {{.Name}}" placeholder="rrggbb" size="8">
12 <button type="submit" class="btn">Save</button> 12 <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}}
14 <td>{{.Issues}}</td> 14 <td>{{.Issues}}</td>
15 <td class="act">{{if and $.CanWrite (not .Org)}}<form method="post" action="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/labels" class="inline"> 15 <td class="act">{{if and $.CanWrite (not .Org)}}<form method="post" action="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/labels" class="inline">
16 <input type="hidden" name="action" value="remove"> 16 <input type="hidden" name="action" value="remove">
17 <input type="hidden" name="name" value="{{.Name}}"> 17 <input type="hidden" name="name" value="{{.Name}}">
18 {{template "confirmfield" .Name}}
18 <button type="submit" class="linklike">Remove</button> 19 <button type="submit" class="linklike">Remove</button>
19 </form>{{else if .Org}}<a href="/{{$.Repo.OwnerName}}/-/labels">org</a>{{end}}</td> 20 </form>{{else if .Org}}<a href="/{{$.Repo.OwnerName}}/-/labels">org</a>{{end}}</td>
20</tr> 21</tr>
internal/web/templates/landing.html +1 −1
@@ -13,6 +13,6 @@ git clone ssh://git@{{.Host}}/owner/repo.git</pre>
13 <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> 13 <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>
14</div> 14</div>
15<p><a class="explorelink" href="/explore">explore public repositories →</a></p> 15<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}}
17</div> 17</div>
18{{end}} 18{{end}}
internal/web/templates/layout.html +4
@@ -137,6 +137,10 @@
137 </select> 137 </select>
138</p>{{end}} 138</p>{{end}}
139 139
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
140{{/* difffiles renders a parsed diff: one foldable section per file, with 144{{/* difffiles renders a parsed diff: one foldable section per file, with
141 line-number gutters and review threads inline. Base is the MR's 145 line-number gutters and review threads inline. Base is the MR's
142 endpoint and Viewer the signed-in account; the commit page passes 146 endpoint and Viewer the signed-in account; the commit page passes
internal/web/templates/log.html +1 −1
@@ -12,7 +12,7 @@
12{{range .Commits}}{{$c := .}}<li> 12{{range .Commits}}{{$c := .}}<li>
13 <div class="commitmain"> 13 <div class="commitmain">
14 <p class="subject"><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{.SHA}}">{{.Subject}}</a></p> 14 <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>
16 </div> 16 </div>
17 <div class="commitside"> 17 <div class="commitside">
18 {{with .Check}}<a class="badge badge-{{.}}" href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{$c.SHA}}" title="checks: {{.}}">{{.}}</a>{{end}} 18 {{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 @@
1{{define "title"}}login · {{.Site}}{{end}} 1{{define "title"}}login · {{.Site}}{{end}}
2{{define "content"}} 2{{define "content"}}
3<h1>Log in</h1> 3<h1>Log in</h1>
4{{if .Next}}<p class="meta">Log in to continue to <code>{{.Next}}</code>.</p>{{end}}
4{{if .Error}}<p class="error" role="alert">{{.Error}}</p>{{end}} 5{{if .Error}}<p class="error" role="alert">{{.Error}}</p>{{end}}
5{{if .Sent}} 6{{if .Sent}}
6<p>If that account exists, a login link is on its way. It works once and 7<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 @@
62{{range .Commits}}<li> 62{{range .Commits}}<li>
63 <div class="commitmain"> 63 <div class="commitmain">
64 <p class="subject"><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{.SHA}}">{{.Subject}}</a></p> 64 <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>
66 </div> 66 </div>
67 <div class="commitside"> 67 <div class="commitside">
68 {{template "sigbadge" .Sig}} 68 {{template "sigbadge" .Sig}}
@@ -129,7 +129,7 @@
129 <div class="grp"> 129 <div class="grp">
130 <h2>Reviewers</h2> 130 <h2>Reviewers</h2>
131 {{if .MR.ReviewRequests}}<p class="row">{{range .MR.ReviewRequests}}<a href="/{{.}}">{{.}}</a> {{end}}</p> 131 {{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}}
133 {{if and .CanWrite (or (eq .MR.State "open") (eq .MR.State "source_gone"))}} 133 {{if and .CanWrite (or (eq .MR.State "open") (eq .MR.State "source_gone"))}}
134 <form method="post" action="{{$base}}/review-request" class="actions"> 134 <form method="post" action="{{$base}}/review-request" class="actions">
135 <input type="text" name="add" aria-label="Add reviewers" placeholder="add, space-separated"> 135 <input type="text" name="add" aria-label="Add reviewers" placeholder="add, space-separated">
@@ -141,7 +141,7 @@
141 <div class="grp"> 141 <div class="grp">
142 <h2>Reviews</h2> 142 <h2>Reviews</h2>
143 {{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> 143 {{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}}
145 {{if gt (len .Revisions) 1}}<p class="row none">{{len .Revisions}} revisions pushed. What changed between the last two: 145 {{if gt (len .Revisions) 1}}<p class="row none">{{len .Revisions}} revisions pushed. What changed between the last two:
146 <code>gitbay mr range-diff {{.Repo.OwnerName}}/{{.Repo.Name}} {{.MR.Number}}</code></p>{{end}} 146 <code>gitbay mr range-diff {{.Repo.OwnerName}}/{{.Repo.Name}} {{.MR.Number}}</code></p>{{end}}
147 </div> 147 </div>
@@ -162,7 +162,7 @@
162 <div class="grp"> 162 <div class="grp">
163 <h2>Source</h2> 163 <h2>Source</h2>
164 <p class="row"><code>{{if .MR.SourcePath}}{{.MR.SourcePath}}:{{end}}{{.MR.SourceRef}}</code></p> 164 <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>
166 </div> 166 </div>
167 {{if .MR.Milestone}}<div class="grp"> 167 {{if .MR.Milestone}}<div class="grp">
168 <h2>Milestone</h2> 168 <h2>Milestone</h2>
internal/web/templates/mrs.html +1 −1
@@ -21,7 +21,7 @@
21 <p class="title"><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/mrs/{{.Number}}">{{.Title}}</a></p> 21 <p class="title"><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/mrs/{{.Number}}">{{.Title}}</a></p>
22 <p class="meta">!{{.Number}} by <a href="/{{.Author}}">{{.Author}}</a> · {{if .SourcePath}}{{.SourcePath}}:{{end}}{{.SourceRef}} → {{.TargetRef}}</p> 22 <p class="meta">!{{.Number}} by <a href="/{{.Author}}">{{.Author}}</a> · {{if .SourcePath}}{{.SourcePath}}:{{end}}{{.SourceRef}} → {{.TargetRef}}</p>
23 </div> 23 </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}}
25</li> 25</li>
26{{else}}{{if .Query}}<li class="empty">no {{if ne .State "all"}}{{.State}} {{end}}merge requests matching “{{.Query}}”</li> 26{{else}}{{if .Query}}<li class="empty">no {{if ne .State "all"}}{{.State}} {{end}}merge requests matching “{{.Query}}”</li>
27{{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}} 27{{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>
98 <form method="post" action="/{{$org}}" class="setform"> 98 <form method="post" action="/{{$org}}" class="setform">
99 <input type="hidden" name="field" value="team-delete"> 99 <input type="hidden" name="field" value="team-delete">
100 <input type="hidden" name="team" value="{{.Name}}"> 100 <input type="hidden" name="team" value="{{.Name}}">
101 {{template "confirmfield" .Name}}
101 <button type="submit" class="linklike">Delete this team</button> 102 <button type="submit" class="linklike">Delete this team</button>
102 </form> 103 </form>
103 </div> 104 </div>
internal/web/templates/releases.html +1 −1
@@ -36,7 +36,7 @@
36 <input type="hidden" name="action" value="delete"> 36 <input type="hidden" name="action" value="delete">
37 <input type="hidden" name="tag" value="{{$rel.Tag}}"> 37 <input type="hidden" name="tag" value="{{$rel.Tag}}">
38 <p class="meta">Deleting is permanent and takes the assets with it. The tag stays.</p> 38 <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>
40 </form>{{end}}</details>{{end}} 40 </form>{{end}}</details>{{end}}
41 {{if $rel.Assets}}<table class="assets"> 41 {{if $rel.Assets}}<table class="assets">
42 <thead><tr><th scope="col">File</th><th scope="col">Bytes</th><th scope="col">SHA-256</th></tr></thead> 42 <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 @@
9 <input type="hidden" name="field" value="description"> 9 <input type="hidden" name="field" value="description">
10 <label for="description">Description</label> 10 <label for="description">Description</label>
11 <input type="text" id="description" name="description" value="{{.Desc}}" placeholder="one line, shown in listings"> 11 <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>
13</form> 13</form>
14<form method="post" action="{{$base}}" class="setform"> 14<form method="post" action="{{$base}}" class="setform">
15 <input type="hidden" name="field" value="website"> 15 <input type="hidden" name="field" value="website">
16 <label for="website">Website</label> 16 <label for="website">Website</label>
17 <input type="text" id="website" name="website" value="{{.Repo.Settings.Website}}" placeholder="https://example.org"> 17 <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>
19</form> 19</form>
20{{if .Branches}}<form method="post" action="{{$base}}" class="setform"> 20{{if .Branches}}<form method="post" action="{{$base}}" class="setform">
21 <input type="hidden" name="field" value="default-branch"> 21 <input type="hidden" name="field" value="default-branch">
@@ -23,7 +23,7 @@
23 <select id="default-branch" name="default-branch"> 23 <select id="default-branch" name="default-branch">
24 {{$cur := .Repo.DefaultBranch}}{{range .Branches}}<option value="{{.Name}}"{{if eq .Name $cur}} selected{{end}}>{{.Name}}</option>{{end}} 24 {{$cur := .Repo.DefaultBranch}}{{range .Branches}}<option value="{{.Name}}"{{if eq .Name $cur}} selected{{end}}>{{.Name}}</option>{{end}}
25 </select> 25 </select>
26 <button type="submit">Save</button> 26 <button type="submit">Save default branch</button>
27</form> 27</form>
28{{end}}<form method="post" action="{{$base}}" class="setform"> 28{{end}}<form method="post" action="{{$base}}" class="setform">
29 <input type="hidden" name="field" value="topics"> 29 <input type="hidden" name="field" value="topics">
@@ -43,13 +43,13 @@
43 <option value="public"{{if eq .Repo.Visibility "public"}} selected{{end}}>Public</option> 43 <option value="public"{{if eq .Repo.Visibility "public"}} selected{{end}}>Public</option>
44 <option value="private"{{if eq .Repo.Visibility "private"}} selected{{end}}>Private</option> 44 <option value="private"{{if eq .Repo.Visibility "private"}} selected{{end}}>Private</option>
45 </select> 45 </select>
46 <button type="submit">Save</button> 46 <button type="submit">Save visibility</button>
47</form> 47</form>
48<form method="post" action="{{$base}}" class="setform"> 48<form method="post" action="{{$base}}" class="setform">
49 <input type="hidden" name="field" value="git-daemon"> 49 <input type="hidden" name="field" value="git-daemon">
50 <label for="git-daemon">Serve over git://</label> 50 <label for="git-daemon">Serve over git://</label>
51 <input type="checkbox" id="git-daemon" name="git-daemon" value="on"{{if .Repo.Settings.GitDaemon}} checked{{end}}> 51 <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>
53</form> 53</form>
54 54
55<h2>Merge gates</h2> 55<h2>Merge gates</h2>
@@ -58,31 +58,31 @@
58 <input type="hidden" name="field" value="require-checks"> 58 <input type="hidden" name="field" value="require-checks">
59 <label for="require-checks">Require green checks</label> 59 <label for="require-checks">Require green checks</label>
60 <input type="checkbox" id="require-checks" name="require-checks" value="on"{{if .Repo.Settings.RequireChecks}} checked{{end}}> 60 <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>
62</form> 62</form>
63<form method="post" action="{{$base}}" class="setform"> 63<form method="post" action="{{$base}}" class="setform">
64 <input type="hidden" name="field" value="require-approvals"> 64 <input type="hidden" name="field" value="require-approvals">
65 <label for="approvals">Required approvals</label> 65 <label for="approvals">Required approvals</label>
66 <input type="number" id="approvals" name="approvals" min="0" max="10" value="{{.Repo.Settings.RequireApprovals}}"> 66 <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>
68</form> 68</form>
69<form method="post" action="{{$base}}" class="setform"> 69<form method="post" action="{{$base}}" class="setform">
70 <input type="hidden" name="field" value="require-resolved"> 70 <input type="hidden" name="field" value="require-resolved">
71 <label for="require-resolved">Require resolved threads</label> 71 <label for="require-resolved">Require resolved threads</label>
72 <input type="checkbox" id="require-resolved" name="require-resolved" value="on"{{if .Repo.Settings.RequireResolved}} checked{{end}}> 72 <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>
74</form> 74</form>
75<form method="post" action="{{$base}}" class="setform"> 75<form method="post" action="{{$base}}" class="setform">
76 <input type="hidden" name="field" value="require-codeowners"> 76 <input type="hidden" name="field" value="require-codeowners">
77 <label for="require-codeowners">Require CODEOWNERS approval</label> 77 <label for="require-codeowners">Require CODEOWNERS approval</label>
78 <input type="checkbox" id="require-codeowners" name="require-codeowners" value="on"{{if .Repo.Settings.RequireCodeowners}} checked{{end}}> 78 <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>
80</form> 80</form>
81<form method="post" action="{{$base}}" class="setform"> 81<form method="post" action="{{$base}}" class="setform">
82 <input type="hidden" name="field" value="require-signed"> 82 <input type="hidden" name="field" value="require-signed">
83 <label for="require-signed">Require signed commits</label> 83 <label for="require-signed">Require signed commits</label>
84 <input type="checkbox" id="require-signed" name="require-signed" value="on"{{if .Repo.Settings.RequireSignedCommits}} checked{{end}}> 84 <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>
86</form> 86</form>
87 87
88<h2>Protected branches</h2> 88<h2>Protected branches</h2>
@@ -109,7 +109,7 @@
109 <input type="hidden" name="field" value="require-mr"> 109 <input type="hidden" name="field" value="require-mr">
110 <label for="require-mr">Merge requests only</label> 110 <label for="require-mr">Merge requests only</label>
111 <input type="checkbox" id="require-mr" name="require-mr" value="on"{{if .Repo.Settings.RequireMR}} checked{{end}}> 111 <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>
113</form> 113</form>
114<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> 114<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>
115 115
@@ -137,14 +137,14 @@
137 <input type="hidden" name="field" value="deps"> 137 <input type="hidden" name="field" value="deps">
138 <label for="deps">Check for updates</label> 138 <label for="deps">Check for updates</label>
139 <input type="checkbox" id="deps" name="deps" value="on"{{if .DepsEnabled}} checked{{end}}> 139 <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>
141</form> 141</form>
142<p class="meta">Compares the manifests on <code>{{.Repo.DefaultBranch}}</code> against 142<p class="meta">Compares the manifests on <code>{{.Repo.DefaultBranch}}</code> against
143proxy.golang.org, npm, crates.io, and PyPI once a day, and tracks what is behind 143proxy.golang.org, npm, crates.io, and PyPI once a day, and tracks what is behind
144in an issue. Checking a private repository tells those registries what it 144in an issue. Checking a private repository tells those registries what it
145depends on.</p> 145depends on.</p>
146{{if .DepsEnabled}} 146{{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>
148{{if .Deps.LastError}}<p class="error" role="alert">{{.Deps.LastError}}</p>{{end}} 148{{if .Deps.LastError}}<p class="error" role="alert">{{.Deps.LastError}}</p>{{end}}
149{{if .Deps.Behind}}<div class="tablewrap"><table class="keys"> 149{{if .Deps.Behind}}<div class="tablewrap"><table class="keys">
150<tr class="cols"><th scope="col">dependency</th><th scope="col">pinned</th><th scope="col">latest</th></tr> 150<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>
160<h2>Runners</h2> 160<h2>Runners</h2>
161{{if .Runners}} 161{{if .Runners}}
162<ul class="protlist"> 162<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>
164 <form method="post" action="{{$base}}" class="inline"> 164 <form method="post" action="{{$base}}" class="inline">
165 <input type="hidden" name="field" value="runner-remove"> 165 <input type="hidden" name="field" value="runner-remove">
166 <input type="hidden" name="fingerprint" value="{{.Fingerprint}}"> 166 <input type="hidden" name="fingerprint" value="{{.Fingerprint}}">
@@ -182,7 +182,7 @@ depends on.</p>
182 <input type="hidden" name="field" value="archive"> 182 <input type="hidden" name="field" value="archive">
183 <label for="archive">Archived (read-only)</label> 183 <label for="archive">Archived (read-only)</label>
184 <input type="checkbox" id="archive" name="archive" value="on"{{if .Repo.Settings.Archived}} checked{{end}}> 184 <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>
186</form> 186</form>
187<p class="meta">Deleting or transferring a repository is a CLI operation: 187<p class="meta">Deleting or transferring a repository is a CLI operation:
188<code>gitbay repo delete {{.Repo.OwnerName}}/{{.Repo.Name}} --yes</code></p> 188<code>gitbay repo delete {{.Repo.OwnerName}}/{{.Repo.Name}} --yes</code></p>
internal/web/templates/snippet.html +3 −3
@@ -2,7 +2,7 @@
2{{define "content"}} 2{{define "content"}}
3<h1><a href="/{{.Owner}}">{{.Owner}}</a> / <a href="/{{.Owner}}/-/snippets">snippets</a> / {{.Snippet.PublicID}}</h1> 3<h1><a href="/{{.Owner}}">{{.Owner}}</a> / <a href="/{{.Owner}}/-/snippets">snippets</a> / {{.Snippet.PublicID}}</h1>
4{{if .Snippet.Description}}<p class="desc lede">{{.Snippet.Description}}</p>{{end}} 4{{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>
6{{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}} 6{{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}}
7{{range .Files}} 7{{range .Files}}
8<section class="snippetfile" id="file-{{.Name}}"> 8<section class="snippetfile" id="file-{{.Name}}">
@@ -21,7 +21,7 @@
21</form> 21</form>
22<form method="post" action="/{{$.Owner}}/-/snippets/{{$.Snippet.PublicID}}/file/remove"> 22<form method="post" action="/{{$.Owner}}/-/snippets/{{$.Snippet.PublicID}}/file/remove">
23<input type="hidden" name="name" value="{{.Name}}"> 23<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>
25</form> 25</form>
26</details>{{end}} 26</details>{{end}}
27</section> 27</section>
@@ -44,7 +44,7 @@
44<p><button type="submit">Save</button></p> 44<p><button type="submit">Save</button></p>
45</form> 45</form>
46<form method="post" action="/{{.Owner}}/-/snippets/{{.Snippet.PublicID}}/delete"> 46<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>
48</form> 48</form>
49</details> 49</details>
50{{end}} 50{{end}}
internal/web/templates/snippets.html +1 −1
@@ -9,7 +9,7 @@
9 <td><a href="/{{$.Owner}}/-/snippets/{{.PublicID}}">{{if .Description}}{{.Description}}{{else}}{{.PublicID}}{{end}}</a></td> 9 <td><a href="/{{$.Owner}}/-/snippets/{{.PublicID}}">{{if .Description}}{{.Description}}{{else}}{{.PublicID}}{{end}}</a></td>
10 <td><span class="mono">{{.Names}}</span></td> 10 <td><span class="mono">{{.Names}}</span></td>
11 {{if $.All}}<td><span class="chip chip-neutral">{{.Visibility}}</span></td>{{end}} 11 {{if $.All}}<td><span class="chip chip-neutral">{{.Visibility}}</span></td>{{end}}
12 <td>{{.UpdatedAt}}</td> 12 <td>{{when .UpdatedAt}}</td>
13</tr> 13</tr>
14{{end}}</table></div> 14{{end}}</table></div>
15{{else}}<p class="none">No snippets yet.</p>{{end}} 15{{else}}<p class="none">No snippets yet.</p>{{end}}
internal/web/templates/tree.html +3 −3
@@ -10,7 +10,7 @@
10 <a class="act" href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/log/{{.Ref}}">History</a> 10 <a class="act" href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/log/{{.Ref}}">History</a>
11 <a class="act" href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/archive/{{.Ref}}.tar.gz">Download</a> 11 <a class="act" href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/archive/{{.Ref}}.tar.gz">Download</a>
12</div> 12</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}}
14{{if .Facts.Commits}}{{$r := printf "/%s/%s" .Repo.OwnerName .Repo.Name}}<div class="facts"> 14{{if .Facts.Commits}}{{$r := printf "/%s/%s" .Repo.OwnerName .Repo.Name}}<div class="facts">
15 <p class="counts"> 15 <p class="counts">
16 <a href="{{$r}}/log/{{.Ref}}"><strong>{{.Facts.Commits}}</strong> commit{{if ne .Facts.Commits 1}}s{{end}}</a> 16 <a href="{{$r}}/log/{{.Ref}}"><strong>{{.Facts.Commits}}</strong> commit{{if ne .Facts.Commits 1}}s{{end}}</a>
@@ -30,7 +30,7 @@
30 <a class="subject" href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{.SHA}}">{{.Subject}}</a> 30 <a class="subject" href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{.SHA}}">{{.Subject}}</a>
31 <span class="spacer"></span> 31 <span class="spacer"></span>
32 <a class="sha" href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{.SHA}}"><code>{{short .SHA}}</code></a> 32 <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>
34</div>{{end}}{{end}} 34</div>{{end}}{{end}}
35<div class="tablewrap"> 35<div class="tablewrap">
36<table class="tree"> 36<table class="tree">
@@ -39,7 +39,7 @@
39 {{if eq .Type "tree"}}<td class="name dir"><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/tree/{{$.Ref}}/{{$.Prefix}}{{.Name}}">{{.Name}}/</a></td> 39 {{if eq .Type "tree"}}<td class="name dir"><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/tree/{{$.Ref}}/{{$.Prefix}}{{.Name}}">{{.Name}}/</a></td>
40 {{else}}<td class="name"><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/blob/{{$.Ref}}/{{$.Prefix}}{{.Name}}">{{.Name}}</a></td>{{end}} 40 {{else}}<td class="name"><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/blob/{{$.Ref}}/{{$.Prefix}}{{.Name}}">{{.Name}}</a></td>{{end}}
41 <td class="lastcommit">{{with $c.Subject}}<a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{$c.SHA}}" title="{{.}}">{{.}}</a>{{end}}</td> 41 <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>
43</tr> 43</tr>
44{{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}} 44{{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}}
45</table> 45</table>
internal/web/web.go +6 −1
@@ -224,7 +224,12 @@ var funcs = template.FuncMap{
224 if err != nil { 224 if err != nil {
225 return s 225 return s
226 } 226 }
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")
228 }, 233 },
229} 234}
230 235
internal/web/web_test.go +12
@@ -71,3 +71,15 @@ func TestHeaderRowsAreLeftAligned(t *testing.T) {
71 } 71 }
72 } 72 }
73} 73}
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}