web: content widths and the four reference pages !396

merged merged by cmc on 2026-09-18 01:45 UTC · krz/gitbay:design-pages into main

41 files changed, +543 −251

Layout: unified · split

e2e/bookmarks_test.go +1 −1
@@ -61,7 +61,7 @@ func TestBookmarks(t *testing.T) {
61 } 61 }
62 62
63 // The count is public and shows on the repository page. 63 // The count is public and shows on the repository page.
64 if _, body := inst.get(t, "/alice/app"); !strings.Contains(body, "1</strong> bookmark") { 64 if _, body := inst.get(t, "/alice/app"); !strings.Contains(body, "1</b> bookmark") {
65 t.Errorf("count not on the repo page:\n%s", body) 65 t.Errorf("count not on the repo page:\n%s", body)
66 } 66 }
67 67
e2e/dashboard_test.go +2 −2
@@ -61,7 +61,7 @@ func TestDashboard(t *testing.T) {
61 61
62 // Anonymous homepage: landing with explore link, repos on /explore. 62 // Anonymous homepage: landing with explore link, repos on /explore.
63 status, body := inst.get(t, "/") 63 status, body := inst.get(t, "/")
64 if status != 200 || !strings.Contains(body, `href="/explore"`) || !strings.Contains(body, "CLI-first") { 64 if status != 200 || !strings.Contains(body, `href="/explore"`) || !strings.Contains(body, "A git forge you drive from the terminal") {
65 t.Fatalf("landing: %d", status) 65 t.Fatalf("landing: %d", status)
66 } 66 }
67 if strings.Contains(body, "alice/app") { 67 if strings.Contains(body, "alice/app") {
@@ -93,7 +93,7 @@ func TestDashboard(t *testing.T) {
93 t.Fatalf("dashboard: %d", status) 93 t.Fatalf("dashboard: %d", status)
94 } 94 }
95 for _, want := range []string{ 95 for _, want := range []string{
96 ">Pinned</h2>", ">app<", // pinned group in the aside 96 `aria-labelledby="rail-pinned"`, `</span>app</a>`, // rail lists the pinned repo
97 "Waiting on your review", "Assigned to you", "Recent activity", 97 "Waiting on your review", "Assigned to you", "Recent activity",
98 "from bob", "alice/app!1", "todo one", "alice/app#1", 98 "from bob", "alice/app!1", "todo one", "alice/app#1",
99 `href="/alice/app/mrs/1"`, `href="/alice/app/issues/1"`, 99 `href="/alice/app/mrs/1"`, `href="/alice/app/issues/1"`,
e2e/design_test.go +68 −7
@@ -139,15 +139,42 @@ func TestReadmeRelativeLinks(t *testing.T) {
139 t.Errorf("explore row missing %q", want) 139 t.Errorf("explore row missing %q", want)
140 } 140 }
141 } 141 }
142 // The repo header renders the same on every tab of the repo. It sits 142 // The description and topics belong to the code tab; task tabs render
143 // above the tab bar, so anything that appears on one tab and not 143 // only the identity row and the tab bar.
144 // another moves the navigation between clicks of that navigation. 144 for _, p := range []string{"/alice/site/issues", "/alice/site/mrs", "/alice/site/releases"} {
145 for _, page := range []string{"", "/issues", "/mrs", "/releases"} { 145 if _, body := inst.get(t, p); strings.Contains(body, `class="chip topic"`) {
146 _, body := inst.get(t, "/alice/site"+page) 146 t.Errorf("%s: header still carries topics on a task tab", p)
147 if !strings.Contains(body, `href="/explore?q=web"`) {
148 t.Errorf("repo header topics missing on /alice/site%s", page)
149 } 147 }
150 } 148 }
149 if _, body := inst.get(t, "/alice/site"); !strings.Contains(body, `class="chip topic"`) {
150 t.Error("repo home lost its topics")
151 }
152}
153
154// TestLandingRoutes checks the landing page's copy and the two routes.
155func TestLandingRoutes(t *testing.T) {
156 smtp := startFakeSMTP(t)
157 inst := startInstanceWith(t, fmt.Sprintf(
158 "[web]\nmode = \"accounts\"\n[registration]\nmode = \"open\"\n[mail]\nsmtp_host = %q\nfrom = \"noreply@gitbay.test\"\n",
159 smtp.addr))
160 _, body := inst.get(t, "/")
161 for _, want := range []string{
162 "A git forge you drive from the terminal.",
163 `class="button primary" href="/explore">Explore repositories</a>`,
164 `class="button btn" href="/register">Create an account</a>`,
165 "web login</code>",
166 } {
167 if !strings.Contains(body, want) {
168 t.Errorf("landing lacks %q", want)
169 }
170 }
171 if strings.Contains(body, "is the whole onboarding") {
172 t.Error("landing still calls repo create the whole onboarding")
173 }
174 _, reg := inst.get(t, "/register")
175 if !strings.Contains(reg, "Paste the contents of your public key file") || !strings.Contains(reg, "/krz/gitbay/wiki/SSH-keys") {
176 t.Error("register page lacks the key hint or the wiki link")
177 }
151} 178}
152 179
153func TestWebInteractions(t *testing.T) { 180func TestWebInteractions(t *testing.T) {
@@ -308,3 +335,37 @@ func TestAuthorNamesResolve(t *testing.T) {
308 t.Fatalf("tipbar name is not a link:\n%s", tree) 335 t.Fatalf("tipbar name is not a link:\n%s", tree)
309 } 336 }
310} 337}
338
339// TestTreeSearchCodeAndClone: the overview links "Search code", not
340// "Find file", and shows two labelled clone blocks after the file table.
341func TestTreeSearchCodeAndClone(t *testing.T) {
342 inst := startInstance(t)
343 key := inst.newKey(t, "alice")
344 inst.admin(t, "admin", "user", "create", "alice", "--key", key+".pub")
345 inst.ssh(t, key, "", "repo", "create", "alice/app")
346 work := t.TempDir()
347 env := inst.gitEnv(key)
348 mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w")
349 dir := filepath.Join(work, "w")
350 os.WriteFile(filepath.Join(dir, "README.md"), []byte("# app\n"), 0o644)
351 os.MkdirAll(filepath.Join(dir, "docs"), 0o755)
352 os.WriteFile(filepath.Join(dir, "docs", "README.md"), []byte("# docs\n"), 0o644)
353 mustGit(t, dir, env, "checkout", "-q", "-b", "main")
354 mustGit(t, dir, env, "add", ".")
355 mustGit(t, dir, env, "commit", "-q", "-m", "init")
356 mustGit(t, dir, env, "push", "-q", "origin", "main")
357 _, body := inst.get(t, "/alice/app")
358 if strings.Contains(body, ">Find file<") || !strings.Contains(body, ">Search code<") {
359 t.Error("overview still says Find file")
360 }
361 if i, j := strings.Index(body, `<table class="tree">`), strings.Index(body, `<div class="clone">`); i < 0 || j < i {
362 t.Error("clone block does not follow the file table")
363 }
364 if !strings.Contains(body, `<label>SSH</label>`) || !strings.Contains(body, `<label>HTTPS</label>`) {
365 t.Error("clone blocks are not labelled")
366 }
367 // Clone and about belong to the repository root, not a subdirectory.
368 if _, sub := inst.get(t, "/alice/app/tree/main/docs"); strings.Contains(sub, `<div class="facts">`) {
369 t.Error("subdirectory listing still shows the clone/about facts block")
370 }
371}
e2e/facts_test.go +3 −3
@@ -46,9 +46,9 @@ func TestRepoFacts(t *testing.T) {
46 t.Fatalf("repo home: %d", status) 46 t.Fatalf("repo home: %d", status)
47 } 47 }
48 for _, want := range []string{ 48 for _, want := range []string{
49 "<strong>2</strong> commit", // both commits counted 49 "<b>2</b> commit", // both commits counted
50 "<strong>1</strong> branch", 50 "<b>1</b> branch",
51 "<strong>1</strong> tag", 51 "<b>1</b> tag",
52 "0BSD", // license detected and surfaced 52 "0BSD", // license detected and surfaced
53 "Go", // language census 53 "Go", // language census
54 "Shell", // and it is not single-language 54 "Shell", // and it is not single-language
e2e/mirror_test.go +2 −2
@@ -119,10 +119,10 @@ func TestMirrors(t *testing.T) {
119 if status, _ := browserGet(t, browser, inst.base()+loginPath); status != 200 { 119 if status, _ := browserGet(t, browser, inst.base()+loginPath); status != 200 {
120 t.Fatalf("login: %d", status) 120 t.Fatalf("login: %d", status)
121 } 121 }
122 if _, body := browserGet(t, browser, inst.base()+"/alice/app"); !strings.Contains(body, "Mirrors to <a href=\""+remoteURL) { 122 if _, body := browserGet(t, browser, inst.base()+"/alice/app"); !strings.Contains(body, "mirrors to <a href=\""+remoteURL) {
123 t.Fatalf("admin repo page missing mirror line:\n%s", body) 123 t.Fatalf("admin repo page missing mirror line:\n%s", body)
124 } 124 }
125 if _, body := browserGet(t, newBrowser(t), inst.base()+"/alice/app"); strings.Contains(body, "Mirrors to") { 125 if _, body := browserGet(t, newBrowser(t), inst.base()+"/alice/app"); strings.Contains(body, "mirrors to") {
126 t.Fatalf("anonymous repo page shows mirror line:\n%s", body) 126 t.Fatalf("anonymous repo page shows mirror line:\n%s", body)
127 } 127 }
128 128
e2e/mrweb_test.go +38 −3
@@ -145,10 +145,10 @@ func TestMRWebReviewLoop(t *testing.T) {
145 t.Fatalf("review carries no timestamp: %+v", merged.Reviews[0]) 145 t.Fatalf("review carries no timestamp: %+v", merged.Reviews[0])
146 } 146 }
147 _, body = browserGet(t, alice, mrURL) 147 _, body = browserGet(t, alice, mrURL)
148 if strings.Contains(body, "wants to merge") { 148 if strings.Contains(body, "opened by") {
149 t.Fatalf("merged MR still wants to merge:\n%s", body) 149 t.Fatalf("merged MR still says it was opened:\n%s", body)
150 } 150 }
151 if !strings.Contains(body, ">alice</a> merged") { 151 if !strings.Contains(body, "merged by <a href=\"/alice\">alice</a>") {
152 t.Fatalf("merged MR does not name the merger:\n%s", body) 152 t.Fatalf("merged MR does not name the merger:\n%s", body)
153 } 153 }
154 mustGit(t, dir, env, "pull", "-q", "origin", "main") 154 mustGit(t, dir, env, "pull", "-q", "origin", "main")
@@ -353,3 +353,38 @@ func (i *instance) mrThreads(t *testing.T, key, repo, n string) []mrThread {
353 } 353 }
354 return env.Data 354 return env.Data
355} 355}
356
357// TestMRDiffEmptyExplained: a merge request whose head was fast-forwarded
358// into the target outside the request shows why its diff is empty.
359func TestMRDiffEmptyExplained(t *testing.T) {
360 inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n")
361 key := inst.newKey(t, "alice")
362 inst.admin(t, "admin", "user", "create", "alice", "--key", key+".pub", "--email", "alice@example.test", "--verified")
363 if _, errOut, code := inst.ssh(t, key, "", "repo", "create", "alice/app"); code != 0 {
364 t.Fatalf("repo create: %s", errOut)
365 }
366 env := inst.gitEnv(key)
367 work := t.TempDir()
368 mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w")
369 dir := filepath.Join(work, "w")
370 os.WriteFile(filepath.Join(dir, "README"), []byte("base\n"), 0o644)
371 mustGit(t, dir, env, "checkout", "-q", "-b", "main")
372 mustGit(t, dir, env, "add", ".")
373 mustGit(t, dir, env, "commit", "-q", "-m", "base")
374 mustGit(t, dir, env, "push", "-q", "origin", "main")
375
376 mustGit(t, dir, env, "checkout", "-q", "-b", "feature")
377 os.WriteFile(filepath.Join(dir, "f.txt"), []byte("one\n"), 0o644)
378 mustGit(t, dir, env, "add", ".")
379 mustGit(t, dir, env, "commit", "-q", "-m", "one")
380 mustGit(t, dir, env, "push", "-q", "origin", "feature")
381 if _, errOut, code := inst.ssh(t, key, "", "mr", "create", "alice/app", "--source", "feature", "--target", "main", "--title", "one"); code != 0 {
382 t.Fatal(errOut)
383 }
384 mustGit(t, dir, env, "push", "-q", "origin", "feature:main")
385 _, body := inst.get(t, "/alice/app/mrs/1?view=diff")
386 if !strings.Contains(body, "No changes between the source and target.") ||
387 !strings.Contains(body, "already merged or fast-forwarded into <code>main</code>") {
388 t.Fatalf("empty diff unexplained:\n%s", body)
389 }
390}
e2e/settingsweb_test.go +15 −6
@@ -39,15 +39,27 @@ func TestRepoSettingsWeb(t *testing.T) {
39 return body 39 return body
40 } 40 }
41 41
42 if body := post(url.Values{"field": {"description"}, "description": {"a thing"}}); !strings.Contains(body, `class="notice" role="status">Saved the description.`) {
43 t.Fatalf("no success flash after saving the description:\n%s", body)
44 }
45 if body := post(url.Values{"field": {"topics"}, "topics": {"cli, forge"}}); !strings.Contains(body, `value="cli, forge"`) {
46 t.Fatalf("topics field is not prefilled after save:\n%s", body)
47 }
48 if body := post(url.Values{"field": {"topics"}, "topics": {"forge"}}); strings.Contains(body, `>cli<`) || !strings.Contains(body, `value="forge"`) {
49 t.Fatalf("removing a topic through the field failed:\n%s", body)
50 }
51 if body := post(url.Values{"field": {"website"}, "website": {"javascript:alert(1)"}}); !strings.Contains(body, `class="error"`) || !strings.Contains(body, `value="javascript:alert(1)"`) {
52 t.Fatalf("error does not keep the submitted website:\n%s", body)
53 }
54
42 post(url.Values{"field": {"description"}, "description": {"a fine tool"}}) 55 post(url.Values{"field": {"description"}, "description": {"a fine tool"}})
43 post(url.Values{"field": {"website"}, "website": {"https://tool.example"}}) 56 post(url.Values{"field": {"website"}, "website": {"https://tool.example"}})
44 post(url.Values{"field": {"topics"}, "add": {"cli forge"}})
45 post(url.Values{"field": {"require-checks"}, "require-checks": {"on"}}) 57 post(url.Values{"field": {"require-checks"}, "require-checks": {"on"}})
46 post(url.Values{"field": {"require-approvals"}, "approvals": {"2"}}) 58 post(url.Values{"field": {"require-approvals"}, "approvals": {"2"}})
47 post(url.Values{"field": {"protect"}, "branch": {"main"}}) 59 post(url.Values{"field": {"protect"}, "branch": {"main"}})
48 60
49 out, _, _ := inst.ssh(t, aliceKey, "", "repo", "show", "alice/app", "--json") 61 out, _, _ := inst.ssh(t, aliceKey, "", "repo", "show", "alice/app", "--json")
50 for _, want := range []string{"a fine tool", "https://tool.example", `"cli"`, `"forge"`} { 62 for _, want := range []string{"a fine tool", "https://tool.example", `"forge"`} {
51 if !strings.Contains(out, want) { 63 if !strings.Contains(out, want) {
52 t.Fatalf("repo show missing %q:\n%s", want, out) 64 t.Fatalf("repo show missing %q:\n%s", want, out)
53 } 65 }
@@ -83,12 +95,9 @@ func TestRepoSettingsWeb(t *testing.T) {
83 t.Fatalf("still archived:\n%s", out) 95 t.Fatalf("still archived:\n%s", out)
84 } 96 }
85 97
86 // Unprotecting works, and a refusal surfaces the command's message. 98 // Unprotecting works.
87 post(url.Values{"field": {"unprotect"}, "branch": {"main"}}) 99 post(url.Values{"field": {"unprotect"}, "branch": {"main"}})
88 if out, _, _ := inst.ssh(t, aliceKey, "", "repo", "settings", "show", "alice/app", "--json"); strings.Contains(out, `"protected_branches"`) { 100 if out, _, _ := inst.ssh(t, aliceKey, "", "repo", "settings", "show", "alice/app", "--json"); strings.Contains(out, `"protected_branches"`) {
89 t.Fatalf("branch still protected:\n%s", out) 101 t.Fatalf("branch still protected:\n%s", out)
90 } 102 }
91 if body := post(url.Values{"field": {"website"}, "website": {"javascript:alert(1)"}}); !strings.Contains(body, `class="error"`) {
92 t.Fatalf("bad website accepted:\n%s", body)
93 }
94} 103}
e2e/web_test.go +1 −1
@@ -86,7 +86,7 @@ func TestWebUI(t *testing.T) {
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. 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>") { 89 if !strings.Contains(body, "git clone ssh://git@gitbay.test:") || !strings.Contains(body, "/alice/site.git</code></pre>") || !strings.Contains(body, "<code>git clone https://gitbay.test/alice/site.git</code>") {
90 t.Fatalf("clone URLs missing:\n%s", body) 90 t.Fatalf("clone URLs missing:\n%s", body)
91 } 91 }
92 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>") {
internal/httpd/mrpage_test.go +23 −9
@@ -34,6 +34,8 @@ type mrPageData struct {
34 DetachedThreads []diffThread 34 DetachedThreads []diffThread
35 Gates *control.GatesOut 35 Gates *control.GatesOut
36 SourceGone bool 36 SourceGone bool
37 HeadMerged bool
38 Base string
37} 39}
38 40
39func renderMR(t *testing.T, m store.MR, reviews []store.MRReview, checks []store.Check) string { 41func renderMR(t *testing.T, m store.MR, reviews []store.MRReview, checks []store.Check) string {
@@ -60,12 +62,13 @@ func testMR(state string) store.MR {
60 HeadSHA: "ff6271a9d4570cd46f169091637a9d2e40ad5c2b"} 62 HeadSHA: "ff6271a9d4570cd46f169091637a9d2e40ad5c2b"}
61} 63}
62 64
63// The header states what happened to the MR. "wants to merge" is only true 65// The header states what happened to the MR: who opened, merged, or closed
64// while it is still open. 66// it, and when. A merge or close with no stamp (imports, pre-0029 merges)
67// names the state without claiming a byline it cannot back up.
65func TestMRHeaderByState(t *testing.T) { 68func TestMRHeaderByState(t *testing.T) {
66 open := renderMR(t, testMR("open"), nil, nil) 69 open := renderMR(t, testMR("open"), nil, nil)
67 if !strings.Contains(open, "wants to merge") { 70 if !strings.Contains(open, "opened by") {
68 t.Errorf("open MR does not say wants to merge:\n%s", open) 71 t.Errorf("open MR does not say who opened it:\n%s", open)
69 } 72 }
70 73
71 merged := testMR("merged") 74 merged := testMR("merged")
@@ -76,23 +79,34 @@ func TestMRHeaderByState(t *testing.T) {
76 t.Errorf("merged header missing %q:\n%s", want, out) 79 t.Errorf("merged header missing %q:\n%s", want, out)
77 } 80 }
78 } 81 }
79 if strings.Contains(out, "wants to merge") { 82 if strings.Contains(out, "opened by") {
80 t.Errorf("merged MR still wants to merge:\n%s", out) 83 t.Errorf("merged MR still says it was opened:\n%s", out)
81 } 84 }
82 85
83 closed := testMR("closed") 86 closed := testMR("closed")
84 closed.ClosedAt, closed.ClosedBy = "2026-08-27T14:03:11.000Z", "cmc" 87 closed.ClosedAt, closed.ClosedBy = "2026-08-27T14:03:11.000Z", "cmc"
85 out = renderMR(t, closed, nil, nil) 88 out = renderMR(t, closed, nil, nil)
86 if !strings.Contains(out, "without merging") || strings.Contains(out, "wants to merge") { 89 if !strings.Contains(out, "without merging") || strings.Contains(out, "opened by") {
87 t.Errorf("closed header:\n%s", out) 90 t.Errorf("closed header:\n%s", out)
88 } 91 }
89 92
90 // Imports and pre-0029 merges carry no stamp; the wording drops the 93 // Imports and pre-0029 merges carry no stamp; the wording drops the
91 // claim rather than inventing a time. 94 // byline claim rather than naming nobody or inventing a time.
92 out = renderMR(t, testMR("merged"), nil, nil) 95 out = renderMR(t, testMR("merged"), nil, nil)
93 if strings.Contains(out, "wants to merge") || strings.Contains(out, " on 20") { 96 if strings.Contains(out, "opened by") || strings.Contains(out, " on 20") || strings.Contains(out, "merged by") {
94 t.Errorf("unstamped merged header:\n%s", out) 97 t.Errorf("unstamped merged header:\n%s", out)
95 } 98 }
99 if !strings.Contains(out, "merged") {
100 t.Errorf("unstamped merged header drops the state entirely:\n%s", out)
101 }
102
103 out = renderMR(t, testMR("closed"), nil, nil)
104 if strings.Contains(out, "opened by") || strings.Contains(out, " on 20") || strings.Contains(out, "closed without merging by") {
105 t.Errorf("unstamped closed header:\n%s", out)
106 }
107 if !strings.Contains(out, "without merging") {
108 t.Errorf("unstamped closed header drops the state entirely:\n%s", out)
109 }
96} 110}
97 111
98// Approvals and checks carry their times in the aside, so reading the MR 112// Approvals and checks carry their times in the aside, so reading the MR
internal/httpd/settings.go +109 −9
@@ -3,6 +3,8 @@ package httpd
3import ( 3import (
4 "fmt" 4 "fmt"
5 "net/http" 5 "net/http"
6 "net/url"
7 "slices"
6 "strings" 8 "strings"
7 9
8 "gitbay.org/gitbay/internal/control" 10 "gitbay.org/gitbay/internal/control"
@@ -23,9 +25,18 @@ type settingsPage struct {
23 Deps control.DepsOut 25 Deps control.DepsOut
24 Runners []store.RepoRunner 26 Runners []store.RepoRunner
25 Notice string 27 Notice string
28 Saved bool
29 Submitted map[string]string
26} 30}
27 31
28func (s *Server) settingsForm(w http.ResponseWriter, r *http.Request, u store.User) { 32func (s *Server) settingsForm(w http.ResponseWriter, r *http.Request, u store.User) {
33 s.settingsFormWith(w, r, u, s.takeFlash(w, r), nil)
34}
35
36// settingsFormWith renders the page with the given notice. submitted is
37// nil on a plain GET; on a failed POST it carries the values the visitor
38// typed, so a rejected value is not silently dropped.
39func (s *Server) settingsFormWith(w http.ResponseWriter, r *http.Request, u store.User, notice string, submitted url.Values) {
29 repo, ok := s.repoForUser(w, r, u, policyCanAdmin) 40 repo, ok := s.repoForUser(w, r, u, policyCanAdmin)
30 if !ok { 41 if !ok {
31 return 42 return
@@ -44,11 +55,21 @@ func (s *Server) settingsForm(w http.ResponseWriter, r *http.Request, u store.Us
44 s.runControlInto(u, []string{"repo", "deps", "status", repo.Path()}, &deps) 55 s.runControlInto(u, []string{"repo", "deps", "status", repo.Path()}, &deps)
45 var runners []store.RepoRunner 56 var runners []store.RepoRunner
46 s.runControlInto(u, []string{"repo", "runner", "list", repo.Path()}, &runners) 57 s.runControlInto(u, []string{"repo", "runner", "list", repo.Path()}, &runners)
58 var subm map[string]string
59 if submitted != nil {
60 subm = map[string]string{
61 "description": submitted.Get("description"),
62 "website": submitted.Get("website"),
63 "topics": submitted.Get("topics"),
64 }
65 }
47 s.render(w, "settings.html", settingsPage{ 66 s.render(w, "settings.html", settingsPage{
48 repoPage: p, Topics: topics, Branches: branches, 67 repoPage: p, Topics: topics, Branches: branches,
49 DepsEnabled: deps.Enabled, Deps: deps, 68 DepsEnabled: deps.Enabled, Deps: deps,
50 Runners: runners, 69 Runners: runners,
51 Notice: s.takeFlash(w, r), 70 Notice: notice,
71 Saved: strings.HasPrefix(notice, "Saved "),
72 Submitted: subm,
52 }) 73 })
53} 74}
54 75
@@ -63,9 +84,10 @@ func (s *Server) settingsRedirect(w http.ResponseWriter, r *http.Request, msg st
63func (s *Server) settingsSubmit(w http.ResponseWriter, r *http.Request, u store.User) { 84func (s *Server) settingsSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
64 repo := r.PathValue("owner") + "/" + r.PathValue("repo") 85 repo := r.PathValue("owner") + "/" + r.PathValue("repo")
65 v := func(k string) string { return strings.TrimSpace(r.FormValue(k)) } 86 v := func(k string) string { return strings.TrimSpace(r.FormValue(k)) }
87 field := r.FormValue("field")
66 88
67 var argv []string 89 var argv []string
68 switch r.FormValue("field") { 90 switch field {
69 case "description": 91 case "description":
70 argv = []string{"repo", "settings", "description", repo, v("description")} 92 argv = []string{"repo", "settings", "description", repo, v("description")}
71 case "website": 93 case "website":
@@ -109,12 +131,45 @@ func (s *Server) settingsSubmit(w http.ResponseWriter, r *http.Request, u store.
109 } 131 }
110 argv = []string{"repo", verb, repo} 132 argv = []string{"repo", verb, repo}
111 case "topics": 133 case "topics":
112 if add := strings.Fields(v("add")); len(add) > 0 { 134 row, err := s.st.RepoByPath(repo)
135 if err != nil {
136 http.NotFound(w, r)
137 return
138 }
139 want := map[string]bool{}
140 var order []string
141 for _, t := range strings.Split(v("topics"), ",") {
142 if t = strings.ToLower(strings.TrimSpace(t)); t != "" && !want[t] {
143 want[t] = true
144 order = append(order, t)
145 }
146 }
147 have, err := s.st.ListTopics(row.ID)
148 if err != nil {
149 s.settingsRedirect(w, r, err.Error())
150 return
151 }
152 var add, remove []string
153 for _, t := range order {
154 if !slices.Contains(have, t) {
155 add = append(add, t)
156 }
157 }
158 for _, t := range have {
159 if !want[t] {
160 remove = append(remove, t)
161 }
162 }
163 if len(remove) > 0 {
164 if _, msg, ok := s.runControl(u, append([]string{"repo", "topics", "remove", repo}, remove...)); !ok {
165 s.settingsFormWith(w, r, u, msg, r.Form)
166 return
167 }
168 }
169 if len(add) > 0 {
113 argv = append([]string{"repo", "topics", "add", repo}, add...) 170 argv = append([]string{"repo", "topics", "add", repo}, add...)
114 } else if rm := strings.Fields(v("remove")); len(rm) > 0 {
115 argv = append([]string{"repo", "topics", "remove", repo}, rm...)
116 } else { 171 } else {
117 s.settingsRedirect(w, r, "name at least one topic") 172 s.settingsRedirect(w, r, "Saved the topics.")
118 return 173 return
119 } 174 }
120 case "runner-add": 175 case "runner-add":
@@ -138,9 +193,54 @@ func (s *Server) settingsSubmit(w http.ResponseWriter, r *http.Request, u store.
138 193
139 _, msg, ok := s.runControl(u, argv) 194 _, msg, ok := s.runControl(u, argv)
140 if ok { 195 if ok {
141 msg = "" 196 s.settingsRedirect(w, r, "Saved the "+fieldLabel(field)+".")
197 return
198 }
199 s.settingsFormWith(w, r, u, msg, r.Form)
200}
201
202// fieldLabel names a settings field for the saved flash and, on
203// rejection, the error notice — lower case, matching the label beside
204// its control.
205func fieldLabel(field string) string {
206 switch field {
207 case "description":
208 return "description"
209 case "website":
210 return "website"
211 case "visibility":
212 return "visibility"
213 case "default-branch":
214 return "default branch"
215 case "git-daemon":
216 return "git:// serving"
217 case "require-checks":
218 return "required checks"
219 case "require-approvals":
220 return "approvals"
221 case "require-resolved":
222 return "review threads"
223 case "require-codeowners":
224 return "CODEOWNERS"
225 case "require-mr":
226 return "require-MR"
227 case "require-signed":
228 return "signed commits"
229 case "protect", "unprotect":
230 return "protected branch"
231 case "protect-tag", "unprotect-tag":
232 return "protected tag"
233 case "deps":
234 return "dependency scanning"
235 case "archive":
236 return "archive"
237 case "topics":
238 return "topics"
239 case "runner-add", "runner-remove":
240 return "runner"
241 default:
242 return field
142 } 243 }
143 s.settingsRedirect(w, r, msg)
144} 244}
145 245
146// onOff normalises a checkbox to the on|off the commands take. 246// onOff normalises a checkbox to the on|off the commands take.
internal/httpd/web.go +24 −5
@@ -161,13 +161,20 @@ func (s *Server) index(w http.ResponseWriter, r *http.Request) {
161 s.cfg.Server.SiteURL, "https://"), "http://"), "/") 161 s.cfg.Server.SiteURL, "https://"), "http://"), "/")
162 s.render(w, "landing.html", struct { 162 s.render(w, "landing.html", struct {
163 basePage 163 basePage
164 Host string 164 Host string
165 Accounts bool 165 Accounts bool
166 Signup bool 166 Signup bool
167 Picture bool
168 EmailLogin bool
167 }{basePage{Site: s.siteName(), Host: s.cfg.SiteHost()}, host, s.cfg.Web.Mode == "accounts", 169 }{basePage{Site: s.siteName(), Host: s.cfg.SiteHost()}, host, s.cfg.Web.Mode == "accounts",
168 s.cfg.Web.Mode == "accounts" && s.cfg.Registration.Mode != "closed"}) 170 s.cfg.Web.Mode == "accounts" && s.cfg.Registration.Mode != "closed",
171 landingPicture, s.emailLoginEnabled()})
169} 172}
170 173
174// landingPicture says whether the landing page's screenshot images exist to
175// show. Task 15 replaces this with a check of the embedded images.
176var landingPicture = false
177
171func (s *Server) dashboard(w http.ResponseWriter, r *http.Request, viewer store.User) { 178func (s *Server) dashboard(w http.ResponseWriter, r *http.Request, viewer store.User) {
172 pinned, _ := s.st.PinnedRepos(viewer.ID) 179 pinned, _ := s.st.PinnedRepos(viewer.ID)
173 var visible []store.Repo 180 var visible []store.Repo
@@ -1861,6 +1868,16 @@ func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
1861 files, diffTruncated = parseDiff(patch), truncated 1868 files, diffTruncated = parseDiff(patch), truncated
1862 } 1869 }
1863 } 1870 }
1871 // The head is already reachable from the target, so the diff is empty
1872 // by construction rather than because nothing changed.
1873 headMerged := false
1874 if len(files) == 0 && m.HeadSHA != "" {
1875 if targetSHA, err := gitutil.ResolveRef(p.Dir, "refs/heads/"+m.TargetRef); err == nil {
1876 if ok, err := gitutil.IsAncestor(p.Dir, m.HeadSHA, targetSHA); err == nil {
1877 headMerged = ok
1878 }
1879 }
1880 }
1864 md := s.ugcFor(r, p.Repo) 1881 md := s.ugcFor(r, p.Repo)
1865 canWrite := s.canWriteRepo(r, p.Repo) 1882 canWrite := s.canWriteRepo(r, p.Repo)
1866 var detachedThreads []diffThread 1883 var detachedThreads []diffThread
@@ -1956,10 +1973,12 @@ func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
1956 Stacked []store.MR 1973 Stacked []store.MR
1957 Gates *control.GatesOut 1974 Gates *control.GatesOut
1958 SourceGone bool 1975 SourceGone bool
1976 HeadMerged bool
1977 Base string
1959 }{p, m, view, md(m.Body, m.BodyFormat), checks, combined, renderComments(comments, md), 1978 }{p, m, view, md(m.Body, m.BodyFormat), checks, combined, renderComments(comments, md),
1960 reviewRows, files, diffTruncated, stat, commits, commitsTotal, branches, s.canEditItem(r, p.Repo, m.Author), 1979 reviewRows, files, diffTruncated, stat, commits, commitsTotal, branches, s.canEditItem(r, p.Repo, m.Author),
1961 canWrite, unresolved, revisions, s.takeFlash(w, r), detachedThreads, stackedOn, stacked, gates, 1980 canWrite, unresolved, revisions, s.takeFlash(w, r), detachedThreads, stackedOn, stacked, gates,
1962 sourceGone(p, m)}) 1981 sourceGone(p, m), headMerged, base})
1963} 1982}
1964 1983
1965// sourceGone reports whether an MR's source branch no longer exists: the 1984// sourceGone reports whether an MR's source branch no longer exists: the
internal/web/static/style.css +18 −11
@@ -417,7 +417,6 @@ footer a:hover { color: var(--link); }
417 font-size: var(--fs-2); 417 font-size: var(--fs-2);
418 display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; 418 display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap;
419} 419}
420.repohead .topics { margin: var(--sp-2) 0 0; }
421.repometa { margin: var(--sp-1) 0 0; color: var(--muted); font-size: var(--fs-1); } 420.repometa { margin: var(--sp-1) 0 0; color: var(--muted); font-size: var(--fs-1); }
422.toggles { margin: var(--sp-2) 0 0; color: var(--muted); font-size: var(--fs-1); } 421.toggles { margin: var(--sp-2) 0 0; color: var(--muted); font-size: var(--fs-1); }
423 422
@@ -788,12 +787,17 @@ td.name a:hover { color: var(--link); }
788table.tree td.name { width: 25%; white-space: nowrap; } 787table.tree td.name { width: 25%; white-space: nowrap; }
789table.tree td.name.dir a { color: var(--link); } 788table.tree td.name.dir a { color: var(--link); }
790table.tree th.lastcommit, table.tree td.lastcommit { width: 55%; } 789table.tree th.lastcommit, table.tree td.lastcommit { width: 55%; }
790table.tree td.lastcommit {
791 max-width: 0;
792 overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
793}
791table.tree td.lastcommit a { 794table.tree td.lastcommit a {
792 color: var(--muted); 795 color: var(--muted);
793 display: block; 796 display: block;
794 overflow: hidden; text-overflow: ellipsis; white-space: nowrap; 797 overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
795} 798}
796table.tree td.lastcommit a:hover { color: var(--link); } 799table.tree td.lastcommit a:hover { color: var(--link); }
800table.tree th.age, table.tree td.age { width: 20%; }
797td.age, th.age { text-align: right; white-space: nowrap; color: var(--muted); font-variant-numeric: tabular-nums; } 801td.age, th.age { text-align: right; white-space: nowrap; color: var(--muted); font-variant-numeric: tabular-nums; }
798td.size, td.mode { color: var(--muted); white-space: nowrap; } 802td.size, td.mode { color: var(--muted); white-space: nowrap; }
799table.tree th.size, table.tree td.size { text-align: right; } 803table.tree th.size, table.tree td.size { text-align: right; }
@@ -810,15 +814,18 @@ table.assets td.name { width: 100%; font-family: var(--mono); font-size: var(--f
810table.assets { margin-top: var(--sp-2); } 814table.assets { margin-top: var(--sp-2); }
811 815
812/* the tip commit is the first row of the file table */ 816/* the tip commit is the first row of the file table */
813.tipbar { 817tr.tipbar td {
818 background: var(--surface);
819 padding: var(--sp-3) var(--sp-4);
820 border-bottom: 1px solid var(--line);
821 max-width: 0;
822}
823.tipbar .tip {
814 display: flex; 824 display: flex;
815 align-items: center; 825 align-items: center;
816 gap: var(--sp-3); 826 gap: var(--sp-2);
817 padding: var(--sp-2) var(--sp-4); 827 flex-wrap: wrap;
818 background: var(--surface); 828 min-width: 0;
819 border: 1px solid var(--line);
820 border-bottom: 0;
821 border-radius: var(--r-card) var(--r-card) 0 0;
822 font-size: var(--fs-2); 829 font-size: var(--fs-2);
823} 830}
824.tipbar .who { font-weight: 600; } 831.tipbar .who { font-weight: 600; }
@@ -826,8 +833,6 @@ table.assets { margin-top: var(--sp-2); }
826.tipbar .subject:hover { color: var(--link); } 833.tipbar .subject:hover { color: var(--link); }
827.tipbar .spacer { flex: 1; } 834.tipbar .spacer { flex: 1; }
828.tipbar .age { color: var(--muted); white-space: nowrap; } 835.tipbar .age { color: var(--muted); white-space: nowrap; }
829.tipbar + table.tree,
830.tipbar + .tablewrap table.tree { border-radius: 0 0 var(--r-card) var(--r-card); }
831 836
832.readme { margin-top: var(--sp-5); } 837.readme { margin-top: var(--sp-5); }
833.readme .cardbody { max-width: 78ch; } 838.readme .cardbody { max-width: 78ch; }
@@ -1263,6 +1268,7 @@ a.memberchip .role { color: var(--muted); }
1263.pathbar { display: flex; align-items: center; gap: var(--sp-2) var(--sp-3); margin: 0 0 var(--sp-3); flex-wrap: wrap; font-size: var(--fs-2); } 1268.pathbar { display: flex; align-items: center; gap: var(--sp-2) var(--sp-3); margin: 0 0 var(--sp-3); flex-wrap: wrap; font-size: var(--fs-2); }
1264.pathbar .spacer { flex: 1; } 1269.pathbar .spacer { flex: 1; }
1265.pathbar .actions { display: flex; gap: var(--sp-2); } 1270.pathbar .actions { display: flex; gap: var(--sp-2); }
1271.pathbar .acts { display: flex; gap: var(--sp-2); }
1266.crumbs { color: var(--muted); } 1272.crumbs { color: var(--muted); }
1267.crumbs a { color: var(--link); } 1273.crumbs a { color: var(--link); }
1268.crumbs strong { color: var(--fg); } 1274.crumbs strong { color: var(--fg); }
@@ -1324,6 +1330,7 @@ details.refmenu .refdrop a.allrefs {
1324 color: var(--muted); 1330 color: var(--muted);
1325} 1331}
1326.clone { margin: 0 0 var(--sp-3); } 1332.clone { margin: 0 0 var(--sp-3); }
1333.facts .about { min-width: 0; }
1327.clone label { font-size: var(--fs-1); color: var(--muted); font-weight: 500; margin-bottom: 2px; } 1334.clone label { font-size: var(--fs-1); color: var(--muted); font-weight: 500; margin-bottom: 2px; }
1328.clone pre { margin-bottom: var(--sp-3); user-select: all; } 1335.clone pre { margin-bottom: var(--sp-3); user-select: all; }
1329.factgrid { display: grid; grid-template-columns: repeat(4, auto); gap: var(--sp-2) var(--sp-4); font-size: var(--fs-2); margin-bottom: var(--sp-3); } 1336.factgrid { display: grid; grid-template-columns: repeat(4, auto); gap: var(--sp-2) var(--sp-4); font-size: var(--fs-2); margin-bottom: var(--sp-3); }
@@ -1369,6 +1376,7 @@ details.refmenu .refdrop a.allrefs {
1369 readable on the templates that do not set one yet */ 1376 readable on the templates that do not set one yet */
1370.landing { max-width: 48rem; padding: var(--sp-6) 0; } 1377.landing { max-width: 48rem; padding: var(--sp-6) 0; }
1371.landing h1 { font-size: var(--fs-6); display: flex; align-items: center; gap: var(--sp-3); margin-bottom: var(--sp-3); } 1378.landing h1 { font-size: var(--fs-6); display: flex; align-items: center; gap: var(--sp-3); margin-bottom: var(--sp-3); }
1379.landing h1 .mark { width: 32px; height: 32px; }
1372.lede { font-size: var(--fs-4); color: var(--muted); line-height: 1.5; max-width: 40rem; margin: 0 0 var(--sp-5); } 1380.lede { font-size: var(--fs-4); color: var(--muted); line-height: 1.5; max-width: 40rem; margin: 0 0 var(--sp-5); }
1373pre.quickstart { margin: 0 0 var(--sp-4); } 1381pre.quickstart { margin: 0 0 var(--sp-4); }
1374.shot { border: 1px solid var(--line); border-radius: var(--r-card); overflow: hidden; margin: var(--sp-5) 0; } 1382.shot { border: 1px solid var(--line); border-radius: var(--r-card); overflow: hidden; margin: var(--sp-5) 0; }
@@ -1377,7 +1385,6 @@ pre.quickstart { margin: 0 0 var(--sp-4); }
1377.facets h2 { font-size: var(--fs-3); margin: 0 0 var(--sp-1); } 1385.facets h2 { font-size: var(--fs-3); margin: 0 0 var(--sp-1); }
1378.facets p { color: var(--muted); font-size: var(--fs-2); margin: 0; } 1386.facets p { color: var(--muted); font-size: var(--fs-2); margin: 0; }
1379.routes { display: flex; gap: var(--sp-3); align-items: center; margin: var(--sp-5) 0 var(--sp-3); } 1387.routes { display: flex; gap: var(--sp-3); align-items: center; margin: var(--sp-5) 0 var(--sp-3); }
1380.explorelink { font-weight: 500; }
1381 1388
1382/* ---- profile ---- */ 1389/* ---- profile ---- */
1383.profilehead { border-bottom: 1px solid var(--faint); padding-bottom: var(--sp-4); margin-bottom: var(--sp-4); } 1390.profilehead { border-bottom: 1px solid var(--faint); padding-bottom: var(--sp-4); margin-bottom: var(--sp-4); }
internal/web/templates/404.html +1
@@ -1,3 +1,4 @@
1{{define "width"}}bounded{{end}}
1{{define "title"}}not found · {{.Site}}{{end}} 2{{define "title"}}not found · {{.Site}}{{end}}
2{{define "content"}} 3{{define "content"}}
3<div class="notfound"> 4<div class="notfound">
internal/web/templates/account.html +1
@@ -1,3 +1,4 @@
1{{define "width"}}bounded{{end}}
1{{define "title"}}account settings{{end}} 2{{define "title"}}account settings{{end}}
2{{define "content"}} 3{{define "content"}}
3<h1>Account settings</h1> 4<h1>Account settings</h1>
internal/web/templates/admin.html +1
@@ -1,3 +1,4 @@
1{{define "width"}}bounded{{end}}
1{{define "title"}}admin · {{.Site}}{{end}} 2{{define "title"}}admin · {{.Site}}{{end}}
2{{define "content"}} 3{{define "content"}}
3<h1>Admin</h1> 4<h1>Admin</h1>
internal/web/templates/blame.html +1
@@ -1,3 +1,4 @@
1{{define "width"}}wide{{end}}
1{{define "title"}}blame: {{.Path}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} 2{{define "title"}}blame: {{.Path}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
2{{define "content"}} 3{{define "content"}}
3<h1 class="vh">Blame: {{.Path}}</h1> 4<h1 class="vh">Blame: {{.Path}}</h1>
internal/web/templates/blob.html +1
@@ -1,3 +1,4 @@
1{{define "width"}}wide{{end}}
1{{define "title"}}{{.Path}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} 2{{define "title"}}{{.Path}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
2{{define "content"}} 3{{define "content"}}
3<h1 class="vh">{{.Path}}</h1> 4<h1 class="vh">{{.Path}}</h1>
internal/web/templates/build.html +1
@@ -1,3 +1,4 @@
1{{define "width"}}wide{{end}}
1{{define "title"}}build {{.Build.Number}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} 2{{define "title"}}build {{.Build.Number}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
2{{define "content"}} 3{{define "content"}}
3{{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}} 4{{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}}
internal/web/templates/builds.html +1
@@ -1,3 +1,4 @@
1{{define "width"}}wide{{end}}
1{{define "title"}}builds · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} 2{{define "title"}}builds · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
2{{define "content"}} 3{{define "content"}}
3<h1>Builds</h1> 4<h1>Builds</h1>
internal/web/templates/commit.html +1
@@ -1,3 +1,4 @@
1{{define "width"}}wide{{end}}
1{{define "title"}}{{.ShortSHA}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} 2{{define "title"}}{{.ShortSHA}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
2{{define "content"}} 3{{define "content"}}
3<div class="commithead"> 4<div class="commithead">
internal/web/templates/compare.html +1
@@ -1,3 +1,4 @@
1{{define "width"}}wide{{end}}
1{{define "title"}}compare {{.Base}}...{{.Head}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} 2{{define "title"}}compare {{.Base}}...{{.Head}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
2{{define "content"}} 3{{define "content"}}
3<h1>Compare <code>{{.Base}}</code> … <code>{{.Head}}</code></h1> 4<h1>Compare <code>{{.Base}}</code> … <code>{{.Head}}</code></h1>
internal/web/templates/dashboard.html −5
@@ -31,11 +31,6 @@
31</div> 31</div>
32 32
33<aside class="aside"> 33<aside class="aside">
34 <div class="grp">
35 <h2>Pinned</h2>
36 {{range .Pinned}}<p class="row"><a href="/{{.OwnerName}}/{{.Name}}"><span class="owner">{{.OwnerName}}/</span>{{.Name}}</a>{{if eq .Visibility "private"}} <span class="chip">Private</span>{{end}}</p>
37 {{else}}<p class="none">Pin a repository to keep it here</p>{{end}}
38 </div>
39 <div class="grp"> 34 <div class="grp">
40 <h2>Recent activity</h2> 35 <h2>Recent activity</h2>
41 {{range .Feed}}<p class="row feedline"><a href="/{{.Actor}}">{{.Actor}}</a> {{.Verb}} <a href="{{.URL}}">{{.Ref}}</a><br><span class="none">{{.Repo}} · {{when .When}}</span></p> 36 {{range .Feed}}<p class="row feedline"><a href="/{{.Actor}}">{{.Actor}}</a> {{.Verb}} <a href="{{.URL}}">{{.Ref}}</a><br><span class="none">{{.Repo}} · {{when .When}}</span></p>
internal/web/templates/edit.html +1
@@ -1,3 +1,4 @@
1{{define "width"}}bounded{{end}}
1{{define "title"}}edit {{.Path}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} 2{{define "title"}}edit {{.Path}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
2{{define "content"}} 3{{define "content"}}
3<h1>edit {{.Repo.OwnerName}}/{{.Repo.Name}} : {{.Path}} @ {{.Ref}}</h1> 4<h1>edit {{.Repo.OwnerName}}/{{.Repo.Name}} : {{.Path}} @ {{.Ref}}</h1>
internal/web/templates/globalsearch.html +1
@@ -1,3 +1,4 @@
1{{define "width"}}wide{{end}}
1{{define "title"}}search · {{.Site}}{{end}} 2{{define "title"}}search · {{.Site}}{{end}}
2{{define "content"}} 3{{define "content"}}
3<div class="listhead"> 4<div class="listhead">
internal/web/templates/issuenew.html +1
@@ -1,3 +1,4 @@
1{{define "width"}}bounded{{end}}
1{{define "title"}}new issue · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} 2{{define "title"}}new issue · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
2{{define "content"}} 3{{define "content"}}
3<h1>New issue</h1> 4<h1>New issue</h1>
internal/web/templates/landing.html +13 −10
@@ -1,18 +1,21 @@
1{{define "title"}}{{.Site}}{{end}} 1{{define "title"}}{{.Site}}{{end}}
2{{define "width"}}bounded{{end}}
2{{define "content"}} 3{{define "content"}}
3<div class="landing"> 4<div class="landing">
4<h1>{{.Site}}</h1> 5<h1>{{template "mark"}}{{.Site}}</h1>
5<p class="lede">A CLI-first git forge. SSH is the API: repositories, issues, and 6<p class="lede">A git forge you drive from the terminal. Repositories, issues, merge requests and CI over SSH, with a fast, readable web view of the same state.</p>
6merge requests are managed from your terminal with nothing but OpenSSH — 7<pre class="quickstart">ssh git@{{.Host}} help # every command, no client to install
7the web is a fast, readable rendering of that state.</p>
8<pre class="quickstart">ssh git@{{.Host}} help # every command, no client needed
9git clone ssh://git@{{.Host}}/owner/repo.git</pre> 8git clone ssh://git@{{.Host}}/owner/repo.git</pre>
9{{if .Picture}}<div class="shot"><picture>
10 <source srcset="/static/img/mr-dark.png" media="(prefers-color-scheme: dark)">
11 <img src="/static/img/mr-light.png" width="1280" height="900" alt="A merge request page: the conversation on the left, checks and reviewers on the right.">
12</picture></div>{{end}}
10<div class="facets"> 13<div class="facets">
11 <section><h2>Read</h2><p>Browse and clone any public repository, over HTTPS or <code>git://</code>, with no account. Every commit shows whether its signature was verified.</p></section> 14 <section><h2>Read</h2><p>Browse and clone any public repository over HTTPS or <code>git://</code>, no account. Every commit shows whether its signature verified.</p></section>
12 <section><h2>Write</h2><p>Push over SSH with the key you already have. <code>ssh git@{{.Host}} repo create you/project</code> is the whole onboarding; issues, merge requests and reviews are commands too.</p></section> 15 <section><h2>Write</h2><p>Push over SSH with the key you already have. Create a repository, file an issue, open and merge a request, all as commands.</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> 16 <section><h2>Review</h2><p>Read a diff, comment on a line, approve, merge, in the browser or the terminal.</p></section>
14</div> 17</div>
15<p><a class="explorelink" href="/explore">explore public repositories →</a></p> 18<div class="routes"><a class="button primary" href="/explore">Explore repositories</a>{{if .Signup}}<a class="button btn" href="/register">Create an account</a>{{end}}</div>
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}} 19{{if .Accounts}}<p class="meta">Have an account? {{if .EmailLogin}}<a href="/login">Sign in with an emailed link</a>, or run{{else}}Run{{end}} <code>ssh git@{{.Host}} web login</code>.</p>{{end}}
17</div> 20</div>
18{{end}} 21{{end}}
internal/web/templates/layout.html +9 −8
@@ -71,13 +71,13 @@
71 <form method="post" action="/{{.OwnerName}}/{{.Name}}/fork" class="inline"><button type="submit" class="btn">Fork</button></form>{{end}} 71 <form method="post" action="/{{.OwnerName}}/{{.Name}}/fork" class="inline"><button type="submit" class="btn">Fork</button></form>{{end}}
72 </div> 72 </div>
73 {{$top := topTab (str $ "Tab")}} 73 {{$top := topTab (str $ "Tab")}}
74 {{/* The header is the same on every tab: it sits above the tab bar, 74 {{/* Description, topics and metadata belong to the code tab. On task
75 and anything shown on one tab and not another would move the 75 tabs only the identity row and the tab bar render, and the header
76 navigation between clicks of that navigation. */}} 76 is identical on every page within a tab. */}}
77 {{with field $ "Desc"}}<p class="repodesc">{{.}}</p>{{end}} 77 {{if eq $top "code"}}
78 {{with field $ "Topics"}}<p class="topics">{{range .}}<a class="chip topic" href="/explore?q={{.}}">{{.}}</a> {{end}}</p>{{end}} 78 <p class="repodesc">{{with field $ "Desc"}}{{.}}{{end}} {{with field $ "Topics"}}{{range .}}<a class="chip topic" href="/explore?q={{.}}">{{.}}</a> {{end}}{{end}}</p>
79 {{with $.Repo.Settings.Website}}<p class="repometa"><a href="{{.}}" rel="nofollow">{{.}}</a></p>{{end}} 79 {{if or $.Repo.Settings.Website (field $ "Mirrors")}}<p class="repometa">{{with $.Repo.Settings.Website}}<a href="{{.}}" rel="nofollow">{{.}}</a>{{end}}{{range $i, $m := field $ "Mirrors"}}{{if or $i $.Repo.Settings.Website}} · {{end}}{{if eq $m.Direction "push"}}mirrors to{{else}}mirrors from{{end}} <a href="{{$m.URL}}" rel="nofollow">{{$m.Target}}</a>{{if $m.Error}}, <span class="bad">sync error: {{$m.Error}}</span>{{else if $m.Synced}}, synced {{$m.Synced}}{{end}}{{end}}</p>{{end}}
80 {{range field $ "Mirrors"}}<p class="repometa">{{if eq .Direction "push"}}Mirrors to{{else}}Mirrors from{{end}} <a href="{{.URL}}" rel="nofollow">{{.Target}}</a>{{if .Error}} · <span class="bad">sync error: {{.Error}}</span>{{else if .Synced}} · synced {{.Synced}}{{end}}</p> 80 {{if $.Viewer}}<p class="toggles">Pinned shows in your rail. Watching sends every issue, request and build to your inbox. Bookmarked lists it under Bookmarks.</p>{{end}}
81 {{end}} 81 {{end}}
82 <nav class="tabs" aria-label="Repository"> 82 <nav class="tabs" aria-label="Repository">
83 <a {{if eq $top "code"}}aria-current="page" {{end}}href="/{{.OwnerName}}/{{.Name}}">Code</a> 83 <a {{if eq $top "code"}}aria-current="page" {{end}}href="/{{.OwnerName}}/{{.Name}}">Code</a>
@@ -91,7 +91,7 @@
91</header> 91</header>
92{{end}} 92{{end}}
93 93
94<main id="content" class="content"> 94<main id="content" class="content {{template "width" .}}">
95{{template "content" .}} 95{{template "content" .}}
96</main> 96</main>
97 97
@@ -104,6 +104,7 @@
104</html>{{end}} 104</html>{{end}}
105 105
106{{define "mark"}}<svg class="mark" width="19" height="19" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 2.25 18.75 12H5.25z" fill="#ff6b3d"/><rect x="3" y="13.5" width="18" height="3" fill="currentColor"/><rect x="7.5" y="18" width="9" height="3" fill="currentColor"/></svg>{{end}} 106{{define "mark"}}<svg class="mark" width="19" height="19" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 2.25 18.75 12H5.25z" fill="#ff6b3d"/><rect x="3" y="13.5" width="18" height="3" fill="currentColor"/><rect x="7.5" y="18" width="9" height="3" fill="currentColor"/></svg>{{end}}
107{{define "width"}}reading{{end}}
107 108
108{{define "reporow"}}<li> 109{{define "reporow"}}<li>
109 <p class="reponame"><a href="/{{.OwnerName}}/{{.Name}}">{{.OwnerName}}<span class="sep">/</span><strong>{{.Name}}</strong></a>{{if eq .Visibility "private"}} <span class="chip">Private</span>{{end}}{{if .Archived}} <span class="chip">Archived</span>{{end}}</p> 110 <p class="reponame"><a href="/{{.OwnerName}}/{{.Name}}">{{.OwnerName}}<span class="sep">/</span><strong>{{.Name}}</strong></a>{{if eq .Visibility "private"}} <span class="chip">Private</span>{{end}}{{if .Archived}} <span class="chip">Archived</span>{{end}}</p>
internal/web/templates/log.html +1
@@ -1,3 +1,4 @@
1{{define "width"}}wide{{end}}
1{{define "title"}}log · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} 2{{define "title"}}log · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
2{{define "content"}} 3{{define "content"}}
3<h1 class="vh">Commits</h1> 4<h1 class="vh">Commits</h1>
internal/web/templates/login.html +3 −2
@@ -1,3 +1,4 @@
1{{define "width"}}bounded{{end}}
1{{define "title"}}login · {{.Site}}{{end}} 2{{define "title"}}login · {{.Site}}{{end}}
2{{define "content"}} 3{{define "content"}}
3<h1>Sign in</h1> 4<h1>Sign in</h1>
@@ -9,8 +10,8 @@ expires in fifteen minutes.</p>
9{{else}} 10{{else}}
10{{if .EmailLogin}} 11{{if .EmailLogin}}
11<form method="post" action="/login"> 12<form method="post" action="/login">
12 <label for="identifier">Username or email address</label> 13 <div class="field"><label for="identifier">Username or email address</label>
13 <input type="text" id="identifier" name="identifier" autocomplete="username" required> 14 <input type="text" id="identifier" name="identifier" autocomplete="username" required></div>
14 <button type="submit">Email me a link</button> 15 <button type="submit">Email me a link</button>
15</form> 16</form>
16<p>Or, from a machine with your registered key:</p> 17<p>Or, from a machine with your registered key:</p>
internal/web/templates/mr.html +42 −49
@@ -1,20 +1,13 @@
1{{define "mrrange"}}<code>{{if .SourcePath}}{{.SourcePath}}:{{end}}{{.SourceRef}}</code> into <code>{{.TargetRef}}</code>{{end}}
2{{define "title"}}!{{.MR.Number}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} 1{{define "title"}}!{{.MR.Number}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
3{{define "content"}} 2{{define "content"}}
4{{$base := printf "/%s/%s/mrs/%d" .Repo.OwnerName .Repo.Name .MR.Number}} 3{{$base := printf "/%s/%s/mrs/%d" .Repo.OwnerName .Repo.Name .MR.Number}}
5<h1 class="issuetitle">{{.MR.Title}} <span class="issuenumber">!{{.MR.Number}}</span></h1> 4<h1 class="issuetitle">{{.MR.Title}} <span class="issuenumber">!{{.MR.Number}}</span></h1>
6<p class="issuemeta">{{if .MR.Draft}}<span class="chip chip-neutral">draft</span> {{end}}<span class="chip chip-{{.MR.State}}">{{.MR.State}}</span> 5<p class="issuemeta"><span class="chip chip-{{.MR.State}}">{{if eq .MR.State "source_gone"}}source gone{{else}}{{.MR.State}}{{end}}</span>{{if .MR.Draft}} <span class="chip chip-neutral">draft</span>{{end}}
7{{if and (eq .MR.State "merged") .MR.MergedAt}} 6 {{if and (eq .MR.State "merged") .MR.MergedBy}}merged by <a href="/{{.MR.MergedBy}}">{{.MR.MergedBy}}</a>{{with .MR.MergedAt}} on {{when .}}{{end}}
8 {{if .MR.MergedBy}}<a href="/{{.MR.MergedBy}}">{{.MR.MergedBy}}</a> merged{{else}}Merged{{end}} 7 {{else if and (eq .MR.State "closed") .MR.ClosedBy}}closed without merging by <a href="/{{.MR.ClosedBy}}">{{.MR.ClosedBy}}</a>{{with .MR.ClosedAt}} on {{when .}}{{end}}
9 {{template "mrrange" .MR}} on {{when .MR.MergedAt}} 8 {{else if or (eq .MR.State "merged") (eq .MR.State "closed")}}{{if eq .MR.State "merged"}}merged{{else}}closed without merging{{end}}
10{{else if and (eq .MR.State "closed") .MR.ClosedAt}} 9 {{else}}opened by <a href="/{{.MR.Author}}">{{.MR.Author}}</a> on {{when .MR.CreatedAt}}{{end}}
11 {{if .MR.ClosedBy}}<a href="/{{.MR.ClosedBy}}">{{.MR.ClosedBy}}</a> closed this{{else}}Closed{{end}} 10 · <code>{{.MR.SourceRef}}</code> into <code>{{.MR.TargetRef}}</code></p>
12 without merging {{template "mrrange" .MR}} on {{when .MR.ClosedAt}}
13{{else if or (eq .MR.State "merged") (eq .MR.State "closed")}}
14 {{template "mrrange" .MR}}
15{{else}}
16 <a href="/{{.MR.Author}}">{{.MR.Author}}</a> wants to merge {{template "mrrange" .MR}}
17{{end}}</p>
18{{with field . "StackedOn"}}<p class="meta">Stacked on <a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/mrs/{{.Number}}">!{{.Number}} {{.Title}}</a>: merges into its branch until that lands, then onto its target.</p>{{end}} 11{{with field . "StackedOn"}}<p class="meta">Stacked on <a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/mrs/{{.Number}}">!{{.Number}} {{.Title}}</a>: merges into its branch until that lands, then onto its target.</p>{{end}}
19{{with field . "Stacked"}}<p class="meta">Builds on this: {{range $i, $k := .}}{{if $i}}, {{end}}<a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/mrs/{{$k.Number}}">!{{$k.Number}} {{$k.Title}}</a>{{end}}. Merging with squash or rebase is refused while they are open.</p>{{end}} 12{{with field . "Stacked"}}<p class="meta">Builds on this: {{range $i, $k := .}}{{if $i}}, {{end}}<a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/mrs/{{$k.Number}}">!{{$k.Number}} {{$k.Title}}</a>{{end}}. Merging with squash or rebase is refused while they are open.</p>{{end}}
20 13
@@ -30,6 +23,7 @@
30</nav> 23</nav>
31 24
32{{if eq .View "conversation"}} 25{{if eq .View "conversation"}}
26<div class="prose">
33{{if .CanEdit}}<details class="editbox"><summary>Edit</summary> 27{{if .CanEdit}}<details class="editbox"><summary>Edit</summary>
34<form method="post" action="{{$base}}/edit" class="commentform"> 28<form method="post" action="{{$base}}/edit" class="commentform">
35<p><input type="text" name="title" aria-label="Title" value="{{.MR.Title}}" required></p> 29<p><input type="text" name="title" aria-label="Title" value="{{.MR.Title}}" required></p>
@@ -55,6 +49,7 @@
55<p><button type="submit" class="btn">Comment</button></p> 49<p><button type="submit" class="btn">Comment</button></p>
56</form> 50</form>
57{{end}} 51{{end}}
52</div>
58 53
59{{else if eq .View "commits"}} 54{{else if eq .View "commits"}}
60{{if gt .CommitsTotal (len .Commits)}}<p class="meta">first {{len .Commits}} of {{.CommitsTotal}} commits; the rest are in the branch</p>{{end}} 55{{if gt .CommitsTotal (len .Commits)}}<p class="meta">first {{len .Commits}} of {{.CommitsTotal}} commits; the rest are in the branch</p>{{end}}
@@ -74,9 +69,10 @@
74</ul> 69</ul>
75 70
76{{else}} 71{{else}}
77<p class="diffstat">{{.Stat.Files}} file{{if ne .Stat.Files 1}}s{{end}} changed, <span class="add">+{{.Stat.Adds}}</span> <span class="del">−{{.Stat.Dels}}</span>{{if .DiffTruncated}} · shown up to 4 MiB; the counts and the last file are partial{{end}}</p> 72{{if .DiffFiles}}<p class="diffstat">{{.Stat.Files}} file{{if ne .Stat.Files 1}}s{{end}} changed, <span class="add">+{{.Stat.Adds}}</span> <span class="del">−{{.Stat.Dels}}</span>{{if .DiffTruncated}} · shown up to 4 MiB; the counts and the last file are partial{{end}}</p>
78{{if .DiffTruncated}}<p class="error" role="alert">This diff is larger than 4 MiB and is cut off below. Fetch the branch to see all of it.</p>{{end}} 73{{if .DiffTruncated}}<p class="error" role="alert">This diff is larger than 4 MiB and is cut off below. Fetch the branch to see all of it.</p>{{end}}
79{{template "difffiles" dict "Files" .DiffFiles "Base" $base "Viewer" .Viewer}} 74{{template "difffiles" dict "Files" .DiffFiles "Base" $base "Viewer" .Viewer}}
75{{else}}<p class="empty-note">No changes between the source and target.{{if .HeadMerged}} The source branch was already merged or fast-forwarded into <code>{{.MR.TargetRef}}</code>.{{end}}</p>{{end}}
80{{end}} 76{{end}}
81 77
82</div> 78</div>
@@ -113,38 +109,6 @@
113 </form> 109 </form>
114 </div> 110 </div>
115 {{end}} 111 {{end}}
116 {{if and .CanEdit (or (eq .MR.State "open") (eq .MR.State "source_gone"))}}
117 <div class="grp">
118 <h2>Target</h2>
119 <form method="post" action="{{$base}}/retarget" class="actions">
120 <label class="none" for="target">Branch</label>
121 <select id="target" name="target">
122 {{range .Branches}}<option value="{{.Name}}"{{if eq .Name $.MR.TargetRef}} selected{{end}}>{{.Name}}</option>{{end}}
123 </select>
124 <button type="submit" class="btn">Retarget</button>
125 </form>
126 <p class="row none">Retargeting stales existing reviews.</p>
127 </div>
128 {{end}}
129 <div class="grp">
130 <h2>Reviewers</h2>
131 {{if .MR.ReviewRequests}}<p class="row">{{range .MR.ReviewRequests}}<a href="/{{.}}">{{.}}</a> {{end}}</p>
132 {{else}}<p class="none">nobody yet</p>{{end}}
133 {{if and .CanWrite (or (eq .MR.State "open") (eq .MR.State "source_gone"))}}
134 <form method="post" action="{{$base}}/review-request" class="actions">
135 <input type="text" name="add" aria-label="Add reviewers" placeholder="add, space-separated">
136 <input type="text" name="remove" aria-label="Remove reviewers" placeholder="remove">
137 <button type="submit" class="btn">Apply</button>
138 </form>
139 {{end}}
140 </div>
141 <div class="grp">
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>
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:
146 <code>gitbay mr range-diff {{.Repo.OwnerName}}/{{.Repo.Name}} {{.MR.Number}}</code></p>{{end}}
147 </div>
148 {{with .Gates}}<div class="grp"> 112 {{with .Gates}}<div class="grp">
149 <h2>Merge gates</h2> 113 <h2>Merge gates</h2>
150 {{if .Unmet}}{{range .Unmet}}<p class="row"><span class="dot bad"></span>{{.}}</p>{{end}} 114 {{if .Unmet}}{{range .Unmet}}<p class="row"><span class="dot bad"></span>{{.}}</p>{{end}}
@@ -160,9 +124,38 @@
160 {{else}}<p class="none">No checks reported</p>{{end}} 124 {{else}}<p class="none">No checks reported</p>{{end}}
161 </div> 125 </div>
162 <div class="grp"> 126 <div class="grp">
163 <h2>Source</h2> 127 <h2>Reviews</h2>
164 <p class="row"><code>{{if .MR.SourcePath}}{{.MR.SourcePath}}:{{end}}{{.MR.SourceRef}}</code></p> 128 {{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>
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> 129 {{else}}<p class="none">none yet</p>{{end}}
130 {{if gt (len .Revisions) 1}}<p class="row none">{{len .Revisions}} revisions pushed. What changed between the last two:
131 <code>gitbay mr range-diff {{.Repo.OwnerName}}/{{.Repo.Name}} {{.MR.Number}}</code></p>{{end}}
132 </div>
133 <div class="grp">
134 <h2>Reviewers</h2>
135 {{if .MR.ReviewRequests}}<p class="row">{{range .MR.ReviewRequests}}<a href="/{{.}}">{{.}}</a> {{end}}</p>
136 {{else}}<p class="none">nobody yet</p>{{end}}
137 {{if and .CanWrite (or (eq .MR.State "open") (eq .MR.State "source_gone"))}}
138 <form method="post" action="{{$base}}/review-request" class="actions">
139 <input type="text" name="add" aria-label="Add reviewers" placeholder="add, space-separated">
140 <input type="text" name="remove" aria-label="Remove reviewers" placeholder="remove">
141 <button type="submit" class="btn">Apply</button>
142 </form>
143 {{end}}
144 </div>
145 <div class="grp">
146 <h2>Source and target</h2>
147 <p class="row"><code>{{if .MR.SourcePath}}{{.MR.SourcePath}}:{{end}}{{.MR.SourceRef}}</code> into <code>{{.MR.TargetRef}}</code></p>
148 <p class="row none">{{if eq .MR.State "merged"}}merged at{{else}}head{{end}} <code>{{short .MR.HeadSHA}}</code>{{if .Base}} · base <code>{{short .Base}}</code>{{end}}{{if .SourceGone}} · <span class="chip chip-neutral">branch deleted</span>{{end}}</p>
149 {{if and .CanEdit (or (eq .MR.State "open") (eq .MR.State "source_gone"))}}
150 <form method="post" action="{{$base}}/retarget" class="actions">
151 <label class="none" for="target">Branch</label>
152 <select id="target" name="target">
153 {{range .Branches}}<option value="{{.Name}}"{{if eq .Name $.MR.TargetRef}} selected{{end}}>{{.Name}}</option>{{end}}
154 </select>
155 <button type="submit" class="btn">Retarget</button>
156 </form>
157 <p class="row none">Retargeting stales existing reviews.</p>
158 {{end}}
166 </div> 159 </div>
167 {{if .MR.Milestone}}<div class="grp"> 160 {{if .MR.Milestone}}<div class="grp">
168 <h2>Milestone</h2> 161 <h2>Milestone</h2>
internal/web/templates/mrnew.html +1
@@ -1,3 +1,4 @@
1{{define "width"}}bounded{{end}}
1{{define "title"}}new merge request · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} 2{{define "title"}}new merge request · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
2{{define "content"}} 3{{define "content"}}
3<h1>New merge request</h1> 4<h1>New merge request</h1>
internal/web/templates/new.html +1
@@ -1,3 +1,4 @@
1{{define "width"}}bounded{{end}}
1{{define "title"}}new repository · {{.Site}}{{end}} 2{{define "title"}}new repository · {{.Site}}{{end}}
2{{define "content"}} 3{{define "content"}}
3<h1>New repository</h1> 4<h1>New repository</h1>
internal/web/templates/privacy.html +1
@@ -1,3 +1,4 @@
1{{define "width"}}bounded{{end}}
1{{define "title"}}privacy · {{.Site}}{{end}} 2{{define "title"}}privacy · {{.Site}}{{end}}
2{{define "content"}} 3{{define "content"}}
3<div class="landing"> 4<div class="landing">
internal/web/templates/register.html +9 −9
@@ -1,18 +1,18 @@
1{{define "title"}}register · {{.Site}}{{end}} 1{{define "title"}}register · {{.Site}}{{end}}
2{{define "width"}}bounded{{end}}
2{{define "content"}} 3{{define "content"}}
3<div class="landing">
4<h1>Create an account</h1> 4<h1>Create an account</h1>
5{{if eq .Mode "invite"}}<p class="lede">This instance is invite-only: you need an invite code from an admin.</p> 5{{if eq .Mode "invite"}}<p class="lede">This instance is invite-only. You need an invite code from an admin.</p>
6{{else}}<p class="lede">Open registration — your account activates once you verify your email.</p>{{end}} 6{{else}}<p class="lede">Open registration. Your account activates once you verify your email.</p>{{end}}
7{{if .Error}}<p class="error" role="alert">{{.Error}}</p>{{end}} 7{{if .Error}}<p class="error" role="alert">{{.Error}}</p>{{end}}
8<form method="post" action="/register" class="signupform"> 8<form method="post" action="/register" class="signupform">
9<p><label>Username<br><input type="text" name="username" value="{{.Username}}" required autofocus></label></p> 9<div class="field"><label for="username">Username</label><input type="text" id="username" name="username" value="{{.Username}}" required autofocus></div>
10{{if eq .Mode "invite"}}<p><label>invite code<br><input type="text" name="invite" required></label></p> 10{{if eq .Mode "invite"}}<div class="field"><label for="invite">Invite code</label><input type="text" id="invite" name="invite" required></div>
11{{else}}<p><label>Email<br><input type="text" name="email" required></label></p>{{end}} 11{{else}}<div class="field"><label for="email">Email</label><input type="text" id="email" name="email" required></div>{{end}}
12<p><label>SSH public key — your key is your identity; paste e.g. <code>~/.ssh/id_ed25519.pub</code><br> 12<div class="field"><label for="key">SSH public key</label>
13<textarea name="key" aria-label="SSH public key" rows="3" required placeholder="ssh-ed25519 AAAA... you@host"></textarea></label></p> 13<p class="hint">Paste the contents of your public key file, usually <code>~/.ssh/id_ed25519.pub</code>. It starts with <code>ssh-ed25519</code> or <code>ssh-rsa</code>. No key yet? <a href="/krz/gitbay/wiki/SSH-keys">Make one</a>.</p>
14<textarea id="key" name="key" rows="3" required placeholder="ssh-ed25519 AAAA... you@host"></textarea></div>
14<p><button type="submit">Create account</button></p> 15<p><button type="submit">Create account</button></p>
15</form> 16</form>
16<p class="meta">Prefer the terminal? <code>ssh git@{{.Host}} register --username you {{if eq .Mode "invite"}}--invite &lt;code&gt;{{else}}--email you@example.org{{end}}</code></p> 17<p class="meta">Prefer the terminal? <code>ssh git@{{.Host}} register --username you {{if eq .Mode "invite"}}--invite &lt;code&gt;{{else}}--email you@example.org{{end}}</code></p>
17</div>
18{{end}} 18{{end}}
internal/web/templates/registered.html +1
@@ -1,3 +1,4 @@
1{{define "width"}}bounded{{end}}
1{{define "title"}}welcome · {{.Site}}{{end}} 2{{define "title"}}welcome · {{.Site}}{{end}}
2{{define "content"}} 3{{define "content"}}
3<div class="landing"> 4<div class="landing">
internal/web/templates/search.html +1
@@ -1,3 +1,4 @@
1{{define "width"}}wide{{end}}
1{{define "title"}}search · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} 2{{define "title"}}search · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
2{{define "content"}} 3{{define "content"}}
3<h1>Search</h1> 4<h1>Search</h1>
internal/web/templates/settings.html +81 −83
@@ -1,148 +1,143 @@
1{{define "width"}}bounded{{end}}
1{{define "title"}}settings · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} 2{{define "title"}}settings · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
2{{define "content"}} 3{{define "content"}}
3{{$base := printf "/%s/%s/settings" .Repo.OwnerName .Repo.Name}} 4{{$base := printf "/%s/%s/settings" .Repo.OwnerName .Repo.Name}}
4<h1>Settings</h1> 5<h1>Settings</h1>
5{{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}} 6{{if .Notice}}{{if .Saved}}<p class="notice" role="status">{{.Notice}}</p>{{else}}<p class="error" role="alert">{{.Notice}}</p>{{end}}{{end}}
7<nav class="sections" aria-label="Sections"><a href="#identity">Identity</a><a href="#access">Access</a><a href="#gates">Merge gates</a><a href="#branches">Protected branches</a><a href="#tags">Protected tags</a>{{if .DepsEnabled}}<a href="#deps">Dependencies</a>{{end}}<a href="#runners">Runners</a><a href="#lifecycle">Lifecycle</a></nav>
6 8
7<h2>Identity</h2> 9<section id="identity"><h2>Identity</h2>
8<form method="post" action="{{$base}}" class="setform"> 10<form method="post" action="{{$base}}" class="setform">
9 <input type="hidden" name="field" value="description"> 11 <input type="hidden" name="field" value="description">
10 <label for="description">Description</label> 12 <div><label for="description">Description</label></div>
11 <input type="text" id="description" name="description" value="{{.Desc}}" placeholder="one line, shown in listings"> 13 <div><input type="text" id="description" name="description" value="{{or (index .Submitted "description") .Desc}}" placeholder="one line, shown in listings"></div>
12 <button type="submit" class="btn">Save description</button> 14 <div><button type="submit">Save</button></div>
13</form> 15</form>
14<form method="post" action="{{$base}}" class="setform"> 16<form method="post" action="{{$base}}" class="setform">
15 <input type="hidden" name="field" value="website"> 17 <input type="hidden" name="field" value="website">
16 <label for="website">Website</label> 18 <div><label for="website">Website</label></div>
17 <input type="text" id="website" name="website" value="{{.Repo.Settings.Website}}" placeholder="https://example.org"> 19 <div><input type="text" id="website" name="website" value="{{or (index .Submitted "website") .Repo.Settings.Website}}" placeholder="https://example.org"></div>
18 <button type="submit" class="btn">Save website</button> 20 <div><button type="submit" class="btn">Save</button></div>
19</form> 21</form>
20{{if .Branches}}<form method="post" action="{{$base}}" class="setform"> 22<form method="post" action="{{$base}}" class="setform">
21 <input type="hidden" name="field" value="default-branch">
22 <label for="default-branch">Default branch</label>
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}}
25 </select>
26 <button type="submit" class="btn">Save default branch</button>
27</form>
28{{end}}<form method="post" action="{{$base}}" class="setform stack">
29 <input type="hidden" name="field" value="topics"> 23 <input type="hidden" name="field" value="topics">
30 <label for="topics-add">Topics to add</label> 24 <div><label for="topics">Topics</label><p class="hint">Comma separated, lower case.</p></div>
31 <input type="text" id="topics-add" name="add" placeholder="add, space-separated"> 25 <div><input type="text" id="topics" name="topics" value="{{or (index .Submitted "topics") (join .Topics ", ")}}"></div>
32 <label for="topics-remove">Topics to remove</label> 26 <div><button type="submit" class="btn">Save</button></div>
33 <input type="text" id="topics-remove" name="remove" placeholder="remove">
34 <button type="submit" class="btn">Apply</button>
35</form> 27</form>
36{{if .Topics}}<p class="meta">{{range .Topics}}<span class="chip topic">{{.}}</span> {{end}}</p>{{end}} 28{{if .Branches}}<form method="post" action="{{$base}}" class="setform">
29 <input type="hidden" name="field" value="default-branch">
30 <div><label for="default-branch">Default branch</label><p class="hint">What a clone checks out and what CI builds on trigger.</p></div>
31 <div><select id="default-branch" name="default-branch">{{$cur := .Repo.DefaultBranch}}{{range .Branches}}<option value="{{.Name}}"{{if eq .Name $cur}} selected{{end}}>{{.Name}}</option>{{end}}</select></div>
32 <div><button type="submit" class="btn">Save</button></div>
33</form>{{end}}
34</section>
37 35
38<h2>Access</h2> 36<section id="access"><h2>Access</h2>
39<form method="post" action="{{$base}}" class="setform"> 37<form method="post" action="{{$base}}" class="setform">
40 <input type="hidden" name="field" value="visibility"> 38 <input type="hidden" name="field" value="visibility">
41 <label for="visibility">Visibility</label> 39 <div><label>Visibility</label><p class="hint">Private repositories answer not found to everyone without access, including in search and on your profile.</p></div>
42 <select id="visibility" name="visibility"> 40 <div class="check">
43 <option value="public"{{if eq .Repo.Visibility "public"}} selected{{end}}>Public</option> 41 <label><input type="radio" name="visibility" value="public"{{if eq .Repo.Visibility "public"}} checked{{end}}> Public</label>
44 <option value="private"{{if eq .Repo.Visibility "private"}} selected{{end}}>Private</option> 42 <label><input type="radio" name="visibility" value="private"{{if eq .Repo.Visibility "private"}} checked{{end}}> Private</label>
45 </select> 43 </div>
46 <button type="submit" class="btn">Save visibility</button> 44 <div><button type="submit" class="btn">Save</button></div>
47</form> 45</form>
48<form method="post" action="{{$base}}" class="setform"> 46<form method="post" action="{{$base}}" class="setform">
49 <input type="hidden" name="field" value="git-daemon"> 47 <input type="hidden" name="field" value="git-daemon">
50 <label for="git-daemon">Serve over git://</label> 48 <div><label for="git-daemon">Serve over git://</label><p class="hint">Unauthenticated, unencrypted read access on port 9418. Public repositories only.</p></div>
51 <input type="checkbox" id="git-daemon" name="git-daemon" value="on"{{if .Repo.Settings.GitDaemon}} checked{{end}}> 49 <div class="check"><input type="checkbox" id="git-daemon" name="git-daemon" value="on"{{if .Repo.Settings.GitDaemon}} checked{{end}}></div>
52 <button type="submit" class="btn">Save git://</button> 50 <div><button type="submit" class="btn">Save</button></div>
53</form> 51</form>
52</section>
54 53
55<h2>Merge gates</h2> 54<section id="gates"><h2>Merge gates</h2>
56<p class="meta">Checked before a merge, in this order: checks, approvals, resolved threads, signatures.</p> 55<p class="meta">Checked before a merge, in this order: checks, approvals, resolved threads, signatures.</p>
57<form method="post" action="{{$base}}" class="setform"> 56<form method="post" action="{{$base}}" class="setform">
58 <input type="hidden" name="field" value="require-checks"> 57 <input type="hidden" name="field" value="require-checks">
59 <label for="require-checks">Require green checks</label> 58 <div><label for="require-checks">Required checks</label><p class="hint">A request waits until every CI job that would report on its head has succeeded.</p></div>
60 <input type="checkbox" id="require-checks" name="require-checks" value="on"{{if .Repo.Settings.RequireChecks}} checked{{end}}> 59 <div class="check"><input type="checkbox" id="require-checks" name="require-checks" value="on"{{if .Repo.Settings.RequireChecks}} checked{{end}}></div>
61 <button type="submit" class="btn">Save checks</button> 60 <div><button type="submit" class="btn">Save</button></div>
62</form> 61</form>
63<form method="post" action="{{$base}}" class="setform"> 62<form method="post" action="{{$base}}" class="setform">
64 <input type="hidden" name="field" value="require-approvals"> 63 <input type="hidden" name="field" value="require-approvals">
65 <label for="approvals">Required approvals</label> 64 <div><label for="approvals">Approvals</label><p class="hint">Approvals from people with write access. Zero means none required.</p></div>
66 <input type="number" id="approvals" name="approvals" min="0" max="10" value="{{.Repo.Settings.RequireApprovals}}"> 65 <div><input type="number" id="approvals" name="approvals" min="0" max="10" value="{{.Repo.Settings.RequireApprovals}}"></div>
67 <button type="submit" class="btn">Save approvals</button> 66 <div><button type="submit" class="btn">Save</button></div>
68</form> 67</form>
69<form method="post" action="{{$base}}" class="setform"> 68<form method="post" action="{{$base}}" class="setform">
70 <input type="hidden" name="field" value="require-resolved"> 69 <input type="hidden" name="field" value="require-resolved">
71 <label for="require-resolved">Require resolved threads</label> 70 <div><label for="require-resolved">Review threads</label><p class="hint">Every thread on the diff must be resolved before merging.</p></div>
72 <input type="checkbox" id="require-resolved" name="require-resolved" value="on"{{if .Repo.Settings.RequireResolved}} checked{{end}}> 71 <div class="check"><input type="checkbox" id="require-resolved" name="require-resolved" value="on"{{if .Repo.Settings.RequireResolved}} checked{{end}}></div>
73 <button type="submit" class="btn">Save threads</button> 72 <div><button type="submit" class="btn">Save</button></div>
74</form> 73</form>
75<form method="post" action="{{$base}}" class="setform"> 74<form method="post" action="{{$base}}" class="setform">
76 <input type="hidden" name="field" value="require-codeowners"> 75 <input type="hidden" name="field" value="require-codeowners">
77 <label for="require-codeowners">Require CODEOWNERS approval</label> 76 <div><label for="require-codeowners">CODEOWNERS</label><p class="hint">Owners of every touched path must approve.</p></div>
78 <input type="checkbox" id="require-codeowners" name="require-codeowners" value="on"{{if .Repo.Settings.RequireCodeowners}} checked{{end}}> 77 <div class="check"><input type="checkbox" id="require-codeowners" name="require-codeowners" value="on"{{if .Repo.Settings.RequireCodeowners}} checked{{end}}></div>
79 <button type="submit" class="btn">Save CODEOWNERS</button> 78 <div><button type="submit" class="btn">Save</button></div>
80</form> 79</form>
81<form method="post" action="{{$base}}" class="setform"> 80<form method="post" action="{{$base}}" class="setform">
82 <input type="hidden" name="field" value="require-signed"> 81 <input type="hidden" name="field" value="require-signed">
83 <label for="require-signed">Require signed commits</label> 82 <div><label for="require-signed">Signed commits</label><p class="hint">Every commit in the request must carry a verified signature. Squash and merge strategies are refused, since both mint an unsigned commit.</p></div>
84 <input type="checkbox" id="require-signed" name="require-signed" value="on"{{if .Repo.Settings.RequireSignedCommits}} checked{{end}}> 83 <div class="check"><input type="checkbox" id="require-signed" name="require-signed" value="on"{{if .Repo.Settings.RequireSignedCommits}} checked{{end}}></div>
85 <button type="submit" class="btn">Save signing</button> 84 <div><button type="submit" class="btn">Save</button></div>
86</form> 85</form>
86</section>
87 87
88<h2>Protected branches</h2> 88<section id="branches"><h2>Protected branches</h2>
89{{if .Repo.Settings.ProtectedBranches}} 89{{if .Repo.Settings.ProtectedBranches}}
90<ul class="protlist"> 90<ul class="protlist">
91{{range .Repo.Settings.ProtectedBranches}}<li><code>{{.}}</code> 91{{$mr := .Repo.Settings.RequireMR}}{{range .Repo.Settings.ProtectedBranches}}<li><code>{{.}}</code>{{if $mr}} <span class="hint">Direct pushes refused, merge requests only.</span>{{end}}
92 <form method="post" action="{{$base}}" class="inline"> 92 <form method="post" action="{{$base}}" class="inline">
93 <input type="hidden" name="field" value="unprotect"> 93 <input type="hidden" name="field" value="unprotect">
94 <input type="hidden" name="branch" value="{{.}}"> 94 <input type="hidden" name="branch" value="{{.}}">
95 <button type="submit" class="linklike">Unprotect</button> 95 <button type="submit" class="danger">Unprotect</button>
96 </form></li> 96 </form></li>
97{{end}} 97{{end}}
98</ul> 98</ul>
99{{else}}<p class="meta">No protected branches. A protected branch refuses deletion and force-pushes.</p>{{end}} 99{{else}}<p class="meta">No protected branches. A protected branch refuses deletion and force-pushes.</p>{{end}}
100<form method="post" action="{{$base}}" class="setform"> 100<form method="post" action="{{$base}}" class="setform">
101 <input type="hidden" name="field" value="protect"> 101 <input type="hidden" name="field" value="protect">
102 <label for="branch">Protect a branch</label> 102 <div><label for="branch">Protect a branch</label></div>
103 <select id="branch" name="branch"> 103 <div><select id="branch" name="branch">{{range .Branches}}<option value="{{.Name}}">{{.Name}}</option>{{end}}</select></div>
104 {{range .Branches}}<option value="{{.Name}}">{{.Name}}</option>{{end}} 104 <div><button type="submit" class="btn">Protect</button></div>
105 </select>
106 <button type="submit" class="btn">Protect</button>
107</form> 105</form>
108<form method="post" action="{{$base}}" class="setform"> 106<form method="post" action="{{$base}}" class="setform">
109 <input type="hidden" name="field" value="require-mr"> 107 <input type="hidden" name="field" value="require-mr">
110 <label for="require-mr">Merge requests only</label> 108 <div><label for="require-mr">Require merge requests</label><p class="hint">Every protected branch changes through a merge request. A direct push is refused in pre-receive.</p></div>
111 <input type="checkbox" id="require-mr" name="require-mr" value="on"{{if .Repo.Settings.RequireMR}} checked{{end}}> 109 <div class="check"><input type="checkbox" id="require-mr" name="require-mr" value="on"{{if .Repo.Settings.RequireMR}} checked{{end}}></div>
112 <button type="submit" class="btn">Save merge-only</button> 110 <div><button type="submit" class="btn">Save</button></div>
113</form> 111</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> 112</section>
115 113
116<h2>Protected tags</h2> 114<section id="tags"><h2>Protected tags</h2>
117{{if .Repo.Settings.ProtectedTags}} 115{{if .Repo.Settings.ProtectedTags}}
118<ul class="protlist"> 116<ul class="protlist">
119{{range .Repo.Settings.ProtectedTags}}<li><code>{{.}}</code> 117{{range .Repo.Settings.ProtectedTags}}<li><code>{{.}}</code>
120 <form method="post" action="{{$base}}" class="inline"> 118 <form method="post" action="{{$base}}" class="inline">
121 <input type="hidden" name="field" value="unprotect-tag"> 119 <input type="hidden" name="field" value="unprotect-tag">
122 <input type="hidden" name="glob" value="{{.}}"> 120 <input type="hidden" name="glob" value="{{.}}">
123 <button type="submit" class="linklike">Unprotect</button> 121 <button type="submit" class="danger">Unprotect</button>
124 </form></li> 122 </form></li>
125{{end}} 123{{end}}
126</ul> 124</ul>
127{{else}}<p class="meta">No protected tags. A tag matching a protected glob is created once and refuses moves and deletion. A tag a release is anchored to refuses both regardless.</p>{{end}} 125{{else}}<p class="meta">No protected tags. A tag matching a protected glob is created once and refuses moves and deletion. A tag a release is anchored to refuses both regardless.</p>{{end}}
128<form method="post" action="{{$base}}" class="setform"> 126<form method="post" action="{{$base}}" class="setform">
129 <input type="hidden" name="field" value="protect-tag"> 127 <input type="hidden" name="field" value="protect-tag">
130 <label for="glob">Protect tags matching</label> 128 <div><label for="glob">Protect tags matching</label></div>
131 <input type="text" id="glob" name="glob" placeholder="v*"> 129 <div><input type="text" id="glob" name="glob" placeholder="v*"></div>
132 <button type="submit" class="btn">Protect</button> 130 <div><button type="submit" class="btn">Protect</button></div>
133</form> 131</form>
132</section>
134 133
135<h2>Dependencies</h2> 134<section id="deps"><h2>Dependencies</h2>
136<form method="post" action="{{$base}}" class="setform"> 135<form method="post" action="{{$base}}" class="setform">
137 <input type="hidden" name="field" value="deps"> 136 <input type="hidden" name="field" value="deps">
138 <label for="deps">Check for updates</label> 137 <div><label for="deps">Check for updates</label><p class="hint">Compares the manifests on <code>{{.Repo.DefaultBranch}}</code> against proxy.golang.org, npm, crates.io, and PyPI once a day, and tracks what is behind in an issue. Checking a private repository tells those registries what it depends on.</p></div>
139 <input type="checkbox" id="deps" name="deps" value="on"{{if .DepsEnabled}} checked{{end}}> 138 <div class="check"><input type="checkbox" id="deps" name="deps" value="on"{{if .DepsEnabled}} checked{{end}}></div>
140 <button type="submit" class="btn">Save dependency checks</button> 139 <div><button type="submit" class="btn">Save</button></div>
141</form> 140</form>
142<p class="meta">Compares the manifests on <code>{{.Repo.DefaultBranch}}</code> against
143proxy.golang.org, npm, crates.io, and PyPI once a day, and tracks what is behind
144in an issue. Checking a private repository tells those registries what it
145depends on.</p>
146{{if .DepsEnabled}} 141{{if .DepsEnabled}}
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> 142<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}} 143{{if .Deps.LastError}}<p class="error" role="alert">{{.Deps.LastError}}</p>{{end}}
@@ -156,34 +151,37 @@ depends on.</p>
156{{end}}</table></div> 151{{end}}</table></div>
157{{else}}<p class="none">Nothing behind{{if not .Deps.LastCheck}} — the first check has not run yet{{end}}.</p>{{end}} 152{{else}}<p class="none">Nothing behind{{if not .Deps.LastCheck}} — the first check has not run yet{{end}}.</p>{{end}}
158{{end}} 153{{end}}
154</section>
159 155
160<h2>Runners</h2> 156<section id="runners"><h2>Runners</h2>
161{{if .Runners}} 157{{if .Runners}}
162<ul class="protlist"> 158<ul class="protlist">
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> 159{{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"> 160 <form method="post" action="{{$base}}" class="inline">
165 <input type="hidden" name="field" value="runner-remove"> 161 <input type="hidden" name="field" value="runner-remove">
166 <input type="hidden" name="fingerprint" value="{{.Fingerprint}}"> 162 <input type="hidden" name="fingerprint" value="{{.Fingerprint}}">
167 <button type="submit" class="linklike">Detach</button> 163 <button type="submit" class="danger">Detach</button>
168 </form></li> 164 </form></li>
169{{end}} 165{{end}}
170</ul> 166</ul>
171{{else}}<p class="meta">No runners attached. Builds for this repository run on the runners attached here; a repository with none queues builds nothing claims.</p>{{end}} 167{{else}}<p class="meta">No runners attached. Builds for this repository run on the runners attached here; a repository with none queues builds nothing claims.</p>{{end}}
172<form method="post" action="{{$base}}" class="setform"> 168<form method="post" action="{{$base}}" class="setform stack">
173 <input type="hidden" name="field" value="runner-add"> 169 <input type="hidden" name="field" value="runner-add">
174 <label for="runner-key">Attach a runner</label> 170 <label for="runner-key">Attach a runner</label>
171 <p class="hint">Install <code>gitbay-runner</code>, run <code>gitbay-runner init</code>, and paste the key it prints. The runner builds your commits with the repository's secrets; merge requests from forks wait unless it runs with <code>-untrusted</code>.</p>
175 <textarea id="runner-key" name="key" rows="3" placeholder="ssh-ed25519 AAAA… (from gitbay-runner init)"></textarea> 172 <textarea id="runner-key" name="key" rows="3" placeholder="ssh-ed25519 AAAA… (from gitbay-runner init)"></textarea>
176 <button type="submit" class="btn">Attach</button> 173 <button type="submit" class="btn">Attach</button>
177</form> 174</form>
178<p class="meta">Install <code>gitbay-runner</code>, run <code>gitbay-runner init</code>, and paste the key it prints. The runner builds your commits with the repository's secrets; merge requests from forks wait unless it runs with <code>-untrusted</code>.</p> 175</section>
179 176
180<h2>Lifecycle</h2> 177<section id="lifecycle"><h2>Lifecycle</h2>
181<form method="post" action="{{$base}}" class="setform"> 178<form method="post" action="{{$base}}" class="setform">
182 <input type="hidden" name="field" value="archive"> 179 <input type="hidden" name="field" value="archive">
183 <label for="archive">Archived (read-only)</label> 180 <div><label for="archive">Archived</label><p class="hint">Read-only for everyone. Issues and requests close to new activity. Reversible.</p></div>
184 <input type="checkbox" id="archive" name="archive" value="on"{{if .Repo.Settings.Archived}} checked{{end}}> 181 <div class="check"><input type="checkbox" id="archive" name="archive" value="on"{{if .Repo.Settings.Archived}} checked{{end}}></div>
185 <button type="submit" class="btn">Save archive</button> 182 <div><button type="submit" class="danger">Save</button></div>
186</form> 183</form>
187<p class="meta">Deleting or transferring a repository is a CLI operation: 184<p class="meta">Deleting or transferring a repository is a CLI operation:
188<code>gitbay repo delete {{.Repo.OwnerName}}/{{.Repo.Name}} --yes</code></p> 185<code>gitbay repo delete {{.Repo.OwnerName}}/{{.Repo.Name}} --yes</code></p>
186</section>
189{{end}} 187{{end}}
internal/web/templates/snippetnew.html +1
@@ -1,3 +1,4 @@
1{{define "width"}}bounded{{end}}
1{{define "title"}}new snippet · {{.Owner}}{{end}} 2{{define "title"}}new snippet · {{.Owner}}{{end}}
2{{define "content"}} 3{{define "content"}}
3<h1>New snippet</h1> 4<h1>New snippet</h1>
internal/web/templates/tree.html +29 −26
@@ -1,3 +1,4 @@
1{{define "width"}}wide{{end}}
1{{define "title"}}{{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} 2{{define "title"}}{{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
2{{define "content"}} 3{{define "content"}}
3{{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}} 4{{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}}
@@ -5,35 +6,15 @@
5<div class="pathbar"> 6<div class="pathbar">
6 {{template "refmenu" .}} 7 {{template "refmenu" .}}
7 <span class="crumbs"><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}">{{.Repo.Name}}</a>/{{range .Crumbs}}<a href="{{.URL}}">{{.Name}}</a>/{{end}}</span> 8 <span class="crumbs"><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}">{{.Repo.Name}}</a>/{{range .Crumbs}}<a href="{{.URL}}">{{.Name}}</a>/{{end}}</span>
8 <span class="spacer"></span> 9 <div class="acts">
9 <a class="act" href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/search">Find file</a> 10 <a class="button btn" href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/search">Search code</a>
10 <a class="act" href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/log/{{.Ref}}">History</a> 11 <a class="button btn" href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/log/{{.Ref}}">History</a>
11 <a class="act" href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/archive/{{.Ref}}.tar.gz">Download</a> 12 <a class="button btn" href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/archive/{{.Ref}}.tar.gz">Download</a>
13 </div>
12</div> 14</div>
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">
15 <p class="counts">
16 <a href="{{$r}}/log/{{.Ref}}"><strong>{{.Facts.Commits}}</strong> commit{{if ne .Facts.Commits 1}}s{{end}}</a>
17 <a href="{{$r}}/refs"><strong>{{.Facts.Branches}}</strong> branch{{if ne .Facts.Branches 1}}es{{end}}</a>
18 <a href="{{$r}}/refs"><strong>{{.Facts.Tags}}</strong> tag{{if ne .Facts.Tags 1}}s{{end}}</a>
19 {{if .Facts.Bookmarks}}<span><strong>{{.Facts.Bookmarks}}</strong> bookmark{{if ne .Facts.Bookmarks 1}}s{{end}}</span>{{end}}
20 {{with .Facts.License}}<span class="fact">{{.}}</span>{{end}}
21 {{with .Facts.Release}}<a href="{{$r}}/releases">latest <strong>{{.}}</strong></a>{{end}}
22 {{with .Facts.Build}}<a href="{{$r}}/builds">build <span class="badge badge-{{.}}">{{.}}</span></a>{{end}}
23 </p>
24 {{with .Facts.Languages}}<p class="langbar" aria-hidden="true">{{range .}}<span class="lang lang-{{slug .Name}}" style="width:{{pct .Percent}}%"></span>{{end}}</p>
25 <p class="langs">{{range .}}<span class="lang-name"><span class="dot lang-{{slug .Name}}"></span>{{.Name}} <span class="muted">{{pct .Percent}}%</span></span>{{end}}</p>{{end}}
26 {{with .Facts.Contributors}}<p class="contribs"><span class="label">{{len .}} contributor{{if ne (len .) 1}}s{{end}}</span>{{range .}}{{template "authorname" dict "Name" .Name "User" .User "Email" .Title}}{{end}}</p>{{end}}
27</div>{{end}}
28{{with .Tip}}{{if .SHA}}<div class="tipbar">
29 <span class="who">{{template "authorname" dict "Name" .Author "User" .User "Email" .Email}}</span>
30 <a class="subject" href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{.SHA}}">{{.Subject}}</a>
31 <span class="spacer"></span>
32 <a class="sha" href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{.SHA}}"><code>{{short .SHA}}</code></a>
33 <span class="age"{{if not .When.IsZero}} title="{{whenT .When}}"{{end}}>{{ago .When}}</span>
34</div>{{end}}{{end}}
35<div class="tablewrap"> 15<div class="tablewrap">
36<table class="tree"> 16<table class="tree">
17{{with .Tip}}{{if .SHA}}<tr class="tipbar"><td colspan="3"><div class="tip"><span class="who">{{template "authorname" dict "Name" .Author "User" .User "Email" .Email}}</span> <a class="subject" href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{.SHA}}">{{.Subject}}</a><span class="spacer"></span><a class="sha" href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{.SHA}}"><code>{{short .SHA}}</code></a> <span class="age"{{if not .When.IsZero}} title="{{whenT .When}}"{{end}}>{{ago .When}}</span></div></td></tr>{{end}}{{end}}
37<tr class="cols"><th scope="col">name</th><th scope="col" class="lastcommit">last commit</th><th scope="col" class="age">updated</th></tr> 18<tr class="cols"><th scope="col">name</th><th scope="col" class="lastcommit">last commit</th><th scope="col" class="age">updated</th></tr>
38{{range .Entries}}{{$c := index $.LastCommits .Name}}<tr> 19{{range .Entries}}{{$c := index $.LastCommits .Name}}<tr>
39 {{if eq .Type "tree"}}<td class="name dir"><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/tree/{{$.Ref}}/{{$.Prefix}}{{.Name}}">{{.Name}}/</a></td> 20 {{if eq .Type "tree"}}<td class="name dir"><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/tree/{{$.Ref}}/{{$.Prefix}}{{.Name}}">{{.Name}}/</a></td>
@@ -48,4 +29,26 @@
48<div class="cardhead"><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/blob/{{.Ref}}/{{.Prefix}}{{.ReadmeName}}">{{.ReadmeName}}</a></div> 29<div class="cardhead"><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/blob/{{.Ref}}/{{.Prefix}}{{.ReadmeName}}">{{.ReadmeName}}</a></div>
49<div class="rendered">{{.ReadmeHTML}}</div> 30<div class="rendered">{{.ReadmeHTML}}</div>
50</section>{{end}} 31</section>{{end}}
32{{if and .Entries (not .DirPath)}}<div class="facts">
33 <div class="clone">
34 <h2>Clone</h2>
35 <label>SSH</label><pre><code>git clone {{.SSHCloneURL}}</code></pre>
36 <label>HTTPS</label><pre><code>git clone {{.CloneURL}}</code></pre>
37 </div>
38 {{if .Facts.Commits}}{{$r := printf "/%s/%s" .Repo.OwnerName .Repo.Name}}<div class="about">
39 <h2>About</h2>
40 <div class="factgrid">
41 <a href="{{$r}}/log/{{.Ref}}"><b>{{.Facts.Commits}}</b> commit{{if ne .Facts.Commits 1}}s{{end}}</a>
42 <a href="{{$r}}/refs"><b>{{.Facts.Branches}}</b> branch{{if ne .Facts.Branches 1}}es{{end}}</a>
43 <a href="{{$r}}/refs"><b>{{.Facts.Tags}}</b> tag{{if ne .Facts.Tags 1}}s{{end}}</a>
44 {{if .Facts.Bookmarks}}<span><b>{{.Facts.Bookmarks}}</b> bookmark{{if ne .Facts.Bookmarks 1}}s{{end}}</span>{{end}}
45 {{with .Facts.License}}<span class="fact">{{.}}</span>{{end}}
46 {{with .Facts.Release}}<a href="{{$r}}/releases">latest <b>{{.}}</b></a>{{end}}
47 {{with .Facts.Build}}<a href="{{$r}}/builds">build <span class="badge badge-{{.}}">{{.}}</span></a>{{end}}
48 </div>
49 {{with .Facts.Languages}}<p class="langbar" aria-hidden="true">{{range .}}<span class="lang lang-{{slug .Name}}" style="width:{{pct .Percent}}%"></span>{{end}}</p>
50 <p class="langs">{{range .}}<span class="lang-name"><span class="dot lang-{{slug .Name}}"></span>{{.Name}} <span class="muted">{{pct .Percent}}%</span></span>{{end}}</p>{{end}}
51 {{with .Facts.Contributors}}<p class="contribs"><span class="label">{{len .}} contributor{{if ne (len .) 1}}s{{end}}</span>{{range .}}{{template "authorname" dict "Name" .Name "User" .User "Email" .Title}}{{end}}</p>{{end}}
52 </div>{{end}}
53</div>{{end}}
51{{end}} 54{{end}}
internal/web/web.go +1
@@ -62,6 +62,7 @@ var fullVersion = sync.OnceValue(func() string {
62var funcs = template.FuncMap{ 62var funcs = template.FuncMap{
63 "gitbayVersion": func() string { return version() }, 63 "gitbayVersion": func() string { return version() },
64 "gitbayCommit": func() string { return fullVersion() }, 64 "gitbayCommit": func() string { return fullVersion() },
65 "join": strings.Join,
65 // paragraphs splits plain text on blank lines for safe rich display. 66 // paragraphs splits plain text on blank lines for safe rich display.
66 "paragraphs": func(s string) []string { 67 "paragraphs": func(s string) []string {
67 var out []string 68 var out []string
internal/web/web_test.go +33
@@ -83,3 +83,36 @@ func TestWhenNamesTheZone(t *testing.T) {
83 t.Fatalf("passthrough: %q", got) 83 t.Fatalf("passthrough: %q", got)
84 } 84 }
85} 85}
86
87// TestMainWidthClass renders every page against an empty struct and
88// checks main carries exactly one width class, and that the pages the
89// spec calls wide or bounded say so.
90func TestMainWidthClass(t *testing.T) {
91 wide := map[string]bool{"tree.html": true, "blob.html": true, "blame.html": true, "log.html": true, "commit.html": true, "compare.html": true, "builds.html": true, "build.html": true, "search.html": true, "globalsearch.html": true}
92 bounded := map[string]bool{"landing.html": true, "login.html": true, "register.html": true, "registered.html": true, "new.html": true, "issuenew.html": true, "mrnew.html": true, "settings.html": true, "account.html": true, "admin.html": true, "edit.html": true, "snippetnew.html": true, "privacy.html": true, "404.html": true}
93 for _, name := range Pages() {
94 src, err := TemplateSource(name)
95 if err != nil {
96 t.Fatal(err)
97 }
98 has := strings.Contains(src, `{{define "width"}}`)
99 switch {
100 case wide[name] && !strings.Contains(src, `{{define "width"}}wide{{end}}`):
101 t.Errorf("%s: want width wide", name)
102 case bounded[name] && !strings.Contains(src, `{{define "width"}}bounded{{end}}`):
103 t.Errorf("%s: want width bounded", name)
104 case !wide[name] && !bounded[name] && has:
105 t.Errorf("%s: defines a width but the spec calls it reading", name)
106 }
107 }
108 layout, err := TemplateSource("layout.html")
109 if err != nil {
110 t.Fatal(err)
111 }
112 if !strings.Contains(layout, `<main id="content" class="content {{template "width" .}}">`) {
113 t.Error("layout.html: main does not carry the width block")
114 }
115 if !strings.Contains(layout, `{{define "width"}}reading{{end}}`) {
116 t.Error("layout.html: no default width")
117 }
118}