Web UI/UX sweep (#182) !386
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 | |||
| 4 | automatically on daemon start; upgrade notes appear per release when | 4 | automatically on daemon start; upgrade notes appear per release when |
| 5 | anything beyond "replace the binary and restart" is needed. | 5 | anything beyond "replace the binary and restart" is needed. |
| 6 | 6 | ||
| 7 | * v1.21.0 — unreleased | ||
| 8 | |||
| 9 | The 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 | ||
| 9 | One config check. | 34 | One 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 | |||
| 26 | Facts 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 | |||
| 51 | What 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 | |||
| 64 | Note 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 | |||
| 68 | In `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 | |||
| 87 | In `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 | |||
| 89 | In `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 | |||
| 91 | In `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 | |||
| 93 | In `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 | |||
| 97 | Run: `go test ./e2e -run 'TestLabelsWeb$|TestReleaseAndBuildWeb$|TestAccountSettingsWeb$|TestSnippetsWeb$|TestOrgManagementWeb$'` | ||
| 98 | Expected: 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 | ||
| 105 | package httpd | ||
| 106 | |||
| 107 | import ( | ||
| 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. | ||
| 116 | func 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 | |||
| 124 | Add the `confirmfield` partial to `internal/web/templates/layout.html` next to `formatpicker` (line 132), exactly as in Interfaces. | ||
| 125 | |||
| 126 | - [ ] **Step 4: Handlers** | ||
| 127 | |||
| 128 | Each 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 | |||
| 138 | Put `{{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 | |||
| 146 | Style: 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 | |||
| 150 | In `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 | |||
| 154 | Run: `go build ./... && go vet ./... && go test ./internal/httpd && go test ./e2e -run 'TestLabelsWeb$|TestReleaseAndBuildWeb$|TestAccountSettingsWeb$|TestSnippetsWeb$|TestOrgManagementWeb$'` | ||
| 155 | Expected: PASS. `internal/httpd`'s input-label test sees the new input's `aria-label`. | ||
| 156 | |||
| 157 | - [ ] **Step 8: Commit** | ||
| 158 | |||
| 159 | ```bash | ||
| 160 | git 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 | ||
| 161 | git commit -m "web: type the name to confirm a destructive control | ||
| 162 | |||
| 163 | Release delete, snippet delete and file remove, team delete, label | ||
| 164 | remove, and SSH key, email and PGP key removal ask for the object's | ||
| 165 | name in a text field; the handler refuses a mismatch with a flash. | ||
| 166 | Reversible controls keep a plain button. | ||
| 167 | |||
| 168 | Ref #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 | |||
| 187 | Append 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 | |||
| 215 | Adjust `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 | |||
| 219 | Run: `go test ./e2e -run 'TestWebSessionsListRevoke$'` | ||
| 220 | Expected: FAIL at "login page without the destination". | ||
| 221 | |||
| 222 | - [ ] **Step 3: Cookie helpers** | ||
| 223 | |||
| 224 | In `internal/httpd/flash.go`, after `takeFlash`: | ||
| 225 | |||
| 226 | ```go | ||
| 227 | const 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. | ||
| 232 | func (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. | ||
| 245 | func (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. | ||
| 260 | func (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 | |||
| 273 | Add `"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 | |||
| 295 | Run: `go build ./... && go vet ./... && go test ./internal/httpd && go test ./e2e -run 'TestWebSessionsListRevoke$|TestEmailLogin'` (the six `TestEmailLogin*` tests consume links too). | ||
| 296 | Expected: PASS. | ||
| 297 | |||
| 298 | - [ ] **Step 6: Commit** | ||
| 299 | |||
| 300 | ```bash | ||
| 301 | git add internal/httpd/flash.go internal/httpd/accounts.go internal/web/templates/login.html e2e/websessions_test.go | ||
| 302 | git commit -m "web: login returns to the page that needed it | ||
| 303 | |||
| 304 | requireUser remembers a GET path in a short-lived cookie; the login | ||
| 305 | page names it and both login paths redirect there once. | ||
| 306 | |||
| 307 | Ref #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 | |||
| 325 | Run: `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 | ||
| 332 | func 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 | |||
| 343 | Run: `go test ./internal/web -run TestWhenNamesTheZone` → FAIL (`2026-09-12 02:18`). | ||
| 344 | |||
| 345 | - [ ] **Step 3: The helper** | ||
| 346 | |||
| 347 | In `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 | |||
| 359 | Run: `go build ./... && go vet ./... && go test ./internal/web ./internal/httpd && go test ./e2e -run '<the tests whose assertions changed>|TestSnippetsWeb$'` | ||
| 360 | Expected: PASS. | ||
| 361 | |||
| 362 | - [ ] **Step 6: Commit** | ||
| 363 | |||
| 364 | ```bash | ||
| 365 | git add internal/web/web.go internal/web/web_test.go internal/web/templates internal/httpd e2e | ||
| 366 | git commit -m "web: one timestamp format, with the zone named | ||
| 367 | |||
| 368 | when renders 2006-01-02 15:04 UTC on every page; commit, log, | ||
| 369 | compare, blame, snippet and settings pages use it instead of | ||
| 370 | date-only, ISO, or raw stored strings. | ||
| 371 | |||
| 372 | Ref #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 | ||
| 391 | package gitutil | ||
| 392 | |||
| 393 | import "testing" | ||
| 394 | |||
| 395 | func 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 | |||
| 411 | Run: `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 | ||
| 418 | package gitutil | ||
| 419 | |||
| 420 | import ( | ||
| 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. | ||
| 428 | func 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 | |||
| 444 | func 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. | ||
| 455 | func 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 | |||
| 472 | In `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 | |||
| 476 | Run: `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). | ||
| 477 | Expected: PASS. | ||
| 478 | |||
| 479 | - [ ] **Step 5: Commit** | ||
| 480 | |||
| 481 | ```bash | ||
| 482 | git add internal/gitutil/versions.go internal/gitutil/versions_test.go internal/httpd/web.go | ||
| 483 | git commit -m "web: refs and the release form order tags by version | ||
| 484 | |||
| 485 | Ref #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 | |||
| 502 | Append 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 | |||
| 516 | Use 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 | |||
| 520 | Run: `go test ./e2e -run 'TestWebAccounts$'` → FAIL: the page still has a textarea. | ||
| 521 | |||
| 522 | - [ ] **Step 3: The handler** | ||
| 523 | |||
| 524 | In `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 | |||
| 536 | Pass `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 | |||
| 544 | Run: `go build ./... && go vet ./... && go test ./internal/httpd && go test ./e2e -run 'TestWebAccounts$'` | ||
| 545 | Expected: PASS. | ||
| 546 | |||
| 547 | - [ ] **Step 6: Commit** | ||
| 548 | |||
| 549 | ```bash | ||
| 550 | git add internal/httpd/accounts.go internal/web/templates/edit.html e2e/accounts_test.go | ||
| 551 | git commit -m "web: the editor explains a refusal before the textarea | ||
| 552 | |||
| 553 | Ref #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 | |||
| 566 | Two commits: the merge messages, then the close-event line. | ||
| 567 | |||
| 568 | - [ ] **Step 1: Grep the assertions** | ||
| 569 | |||
| 570 | Run 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 | |||
| 578 | Update 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 | ||
| 581 | git commit -am "control: merge refusals name the strategy, not the flag | ||
| 582 | |||
| 583 | The same text reaches the web merge form, which has no flags. | ||
| 584 | |||
| 585 | Ref #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 | ||
| 593 | git commit -am "control, web: the close event reads closed by <who> in commit <sha> | ||
| 594 | |||
| 595 | Ref #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 | |||
| 611 | Do 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 | |||
| 638 | Two 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 | |||
| 658 | The 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 | ||
| 684 | git add CHANGELOG.org | ||
| 685 | git commit -m "CHANGELOG: web UI/UX sweep | ||
| 686 | |||
| 687 | Closes #182" | ||
| 688 | ``` | ||
docs/specs/2026-09-11-web-ux-sweep-design.md added +79
| @@ -0,0 +1,79 @@ | |||
| 1 | # Web UI/UX sweep | ||
| 2 | |||
| 3 | Closes #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 | ||
| 5 | in, 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 | |||
| 45 | 1. 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`. | ||
| 49 | 2. Refusals verbatim: audit and rewrite. | ||
| 50 | 3. `account.html` says `gitbay auth token mint`; the command is | ||
| 51 | `auth token create`. | ||
| 52 | 4. Login return-to. | ||
| 53 | 5. Four date formats. | ||
| 54 | 6. Refs page sorts tags as strings. | ||
| 55 | 7. Editor offered where it cannot succeed. | ||
| 56 | 8. Twelve unlabelled Save buttons on repository settings. | ||
| 57 | 9. 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. | ||
| 60 | 10. Empty-state wording. | ||
| 61 | 11. Issue close-event line reads "closed by commit X by Y"; becomes | ||
| 62 | "closed by Y in commit X" with the time inline. | ||
| 63 | 12. 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>". | ||
| 66 | 13. Merged MR page: "at" becomes "merged at"; a source branch that no | ||
| 67 | longer exists is marked "branch deleted". | ||
| 68 | 14. Global search shows a count and the query. No context line. | ||
| 69 | 15. Repository home shows both clone URLs, HTTPS and SSH. | ||
| 70 | 16. 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. | ||
| 73 | 17. 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 | |||
| 78 | A context line on global search results (a search backend change), the | ||
| 79 | POST-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 @@ | |||
| 1 | package gitutil | ||
| 2 | |||
| 3 | import ( | ||
| 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. | ||
| 11 | func 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 | |||
| 27 | func 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. | ||
| 38 | func 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 @@ | |||
| 1 | package gitutil | ||
| 2 | |||
| 3 | import "testing" | ||
| 4 | |||
| 5 | func 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 | ||
| 25 | type accountPGP struct { | 26 | type 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. |
| 79 | func (s *Server) renderLogin(w http.ResponseWriter, errMsg string, sent bool) { | 82 | func (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 | ||
| 117 | func (s *Server) login(w http.ResponseWriter, r *http.Request) { | 121 | func (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 | ||
| 476 | func (s *Server) editForm(w http.ResponseWriter, r *http.Request, u store.User) { | 487 | func (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 @@ | |||
| 1 | package httpd | ||
| 2 | |||
| 3 | import ( | ||
| 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. | ||
| 12 | func 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. | ||
| 21 | func 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 | |||
| 3 | import ( | 3 | import ( |
| 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 | ||
| 47 | const 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. | ||
| 52 | func (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. | ||
| 65 | func (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. | ||
| 80 | func (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 | ||
| 38 | func renderMR(t *testing.T, m store.MR, reviews []store.MRReview, checks []store.Check) string { | 39 | func 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 |
| 184 | func (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 { | 186 | func (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 | ||
| 209 | func (s *Server) snippetEditSubmit(w http.ResponseWriter, r *http.Request, u store.User) { | 207 | func (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 | ||
| 215 | func (s *Server) snippetDeleteSubmit(w http.ResponseWriter, r *http.Request, u store.User) { | 217 | func (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. |
| 222 | func (s *Server) snippetFileSubmit(w http.ResponseWriter, r *http.Request, u store.User) { | 233 | func (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 | ||
| 227 | func (s *Server) snippetFileRemoveSubmit(w http.ResponseWriter, r *http.Request, u store.User) { | 242 | func (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. | ||
| 1969 | func 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 | ||
| 1958 | func (s *Server) refs(w http.ResponseWriter, r *http.Request) { | 1980 | func (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. | ||
| 2034 | func (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 { | |||
| 367 | button.btn:hover { border-color: var(--accent); color: var(--accent); } | 367 | button.btn:hover { border-color: var(--accent); color: var(--accent); } |
| 368 | button.btn[aria-pressed="true"] { border-color: var(--accent); color: var(--accent); } | 368 | button.btn[aria-pressed="true"] { border-color: var(--accent); color: var(--accent); } |
| 369 | form.inline { display: inline; } | 369 | form.inline { display: inline; } |
| 370 | input[name="confirm"] { width: auto; margin-right: var(--sp-2); } | ||
| 370 | 371 | ||
| 371 | nav.tabs { | 372 | nav.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, |
| 128 | where it can be piped instead of pasted:</p> | 128 | where 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 |
| 130 | gitbay admin ... # instance administration</pre> | 130 | gitbay 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}} <{{.AuthorEmail}}> · {{.Date}} | 8 | <p class="meta">{{template "authorname" dict "Name" .AuthorName "User" .AuthorUser "Email" .AuthorEmail}} <{{.AuthorEmail}}> · {{when .Date}} |
| 9 | {{if .CommitterEmail}}<br>committer: <{{.CommitterEmail}}>{{end}}</p> | 9 | {{if .CommitterEmail}}<br>committer: <{{.CommitterEmail}}>{{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}}&kind=issue">issues</a> | 8 | <a {{if eq .Kind "issue"}}class="active" aria-current="page" {{end}}href="?q={{.Query}}&kind=issue">issues</a> |
| 9 | <a {{if eq .Kind "mr"}}class="active" aria-current="page" {{end}}href="?q={{.Query}}&kind=mr">merge requests</a> | 9 | <a {{if eq .Kind "mr"}}class="active" aria-current="page" {{end}}href="?q={{.Query}}&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 |
| 143 | proxy.golang.org, npm, crates.io, and PyPI once a day, and tracks what is behind | 143 | proxy.golang.org, npm, crates.io, and PyPI once a day, and tracks what is behind |
| 144 | in an issue. Checking a private repository tells those registries what it | 144 | in an issue. Checking a private repository tells those registries what it |
| 145 | depends on.</p> | 145 | depends 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). | ||
| 77 | func 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 | } | ||