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) {
6161 }
6262
6363 // 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") {
6565 t.Errorf("count not on the repo page:\n%s", body)
6666 }
6767
e2e/dashboard_test.go +2 −2
@@ -61,7 +61,7 @@ func TestDashboard(t *testing.T) {
6161
6262 // Anonymous homepage: landing with explore link, repos on /explore.
6363 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") {
6565 t.Fatalf("landing: %d", status)
6666 }
6767 if strings.Contains(body, "alice/app") {
@@ -93,7 +93,7 @@ func TestDashboard(t *testing.T) {
9393 t.Fatalf("dashboard: %d", status)
9494 }
9595 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
9797 "Waiting on your review", "Assigned to you", "Recent activity",
9898 "from bob", "alice/app!1", "todo one", "alice/app#1",
9999 `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) {
139139 t.Errorf("explore row missing %q", want)
140140 }
141141 }
142 // The repo header renders the same on every tab of the repo. It sits
143 // above the tab bar, so anything that appears on one tab and not
144 // another moves the navigation between clicks of that navigation.
145 for _, page := range []string{"", "/issues", "/mrs", "/releases"} {
146 _, body := inst.get(t, "/alice/site"+page)
147 if !strings.Contains(body, `href="/explore?q=web"`) {
148 t.Errorf("repo header topics missing on /alice/site%s", page)
142 // The description and topics belong to the code tab; task tabs render
143 // only the identity row and the tab bar.
144 for _, p := range []string{"/alice/site/issues", "/alice/site/mrs", "/alice/site/releases"} {
145 if _, body := inst.get(t, p); strings.Contains(body, `class="chip topic"`) {
146 t.Errorf("%s: header still carries topics on a task tab", p)
149147 }
150148 }
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 }
151178}
152179
153180func TestWebInteractions(t *testing.T) {
@@ -308,3 +335,37 @@ func TestAuthorNamesResolve(t *testing.T) {
308335 t.Fatalf("tipbar name is not a link:\n%s", tree)
309336 }
310337}
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) {
4646 t.Fatalf("repo home: %d", status)
4747 }
4848 for _, want := range []string{
49 "<strong>2</strong> commit", // both commits counted
50 "<strong>1</strong> branch",
51 "<strong>1</strong> tag",
49 "<b>2</b> commit", // both commits counted
50 "<b>1</b> branch",
51 "<b>1</b> tag",
5252 "0BSD", // license detected and surfaced
5353 "Go", // language census
5454 "Shell", // and it is not single-language
e2e/mirror_test.go +2 −2
@@ -119,10 +119,10 @@ func TestMirrors(t *testing.T) {
119119 if status, _ := browserGet(t, browser, inst.base()+loginPath); status != 200 {
120120 t.Fatalf("login: %d", status)
121121 }
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) {
123123 t.Fatalf("admin repo page missing mirror line:\n%s", body)
124124 }
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") {
126126 t.Fatalf("anonymous repo page shows mirror line:\n%s", body)
127127 }
128128
e2e/mrweb_test.go +38 −3
@@ -145,10 +145,10 @@ func TestMRWebReviewLoop(t *testing.T) {
145145 t.Fatalf("review carries no timestamp: %+v", merged.Reviews[0])
146146 }
147147 _, body = browserGet(t, alice, mrURL)
148 if strings.Contains(body, "wants to merge") {
149 t.Fatalf("merged MR still wants to merge:\n%s", body)
148 if strings.Contains(body, "opened by") {
149 t.Fatalf("merged MR still says it was opened:\n%s", body)
150150 }
151 if !strings.Contains(body, ">alice</a> merged") {
151 if !strings.Contains(body, "merged by <a href=\"/alice\">alice</a>") {
152152 t.Fatalf("merged MR does not name the merger:\n%s", body)
153153 }
154154 mustGit(t, dir, env, "pull", "-q", "origin", "main")
@@ -353,3 +353,38 @@ func (i *instance) mrThreads(t *testing.T, key, repo, n string) []mrThread {
353353 }
354354 return env.Data
355355}
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) {
3939 return body
4040 }
4141
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
4255 post(url.Values{"field": {"description"}, "description": {"a fine tool"}})
4356 post(url.Values{"field": {"website"}, "website": {"https://tool.example"}})
44 post(url.Values{"field": {"topics"}, "add": {"cli forge"}})
4557 post(url.Values{"field": {"require-checks"}, "require-checks": {"on"}})
4658 post(url.Values{"field": {"require-approvals"}, "approvals": {"2"}})
4759 post(url.Values{"field": {"protect"}, "branch": {"main"}})
4860
4961 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"`} {
5163 if !strings.Contains(out, want) {
5264 t.Fatalf("repo show missing %q:\n%s", want, out)
5365 }
@@ -83,12 +95,9 @@ func TestRepoSettingsWeb(t *testing.T) {
8395 t.Fatalf("still archived:\n%s", out)
8496 }
8597
86 // Unprotecting works, and a refusal surfaces the command's message.
98 // Unprotecting works.
8799 post(url.Values{"field": {"unprotect"}, "branch": {"main"}})
88100 if out, _, _ := inst.ssh(t, aliceKey, "", "repo", "settings", "show", "alice/app", "--json"); strings.Contains(out, `"protected_branches"`) {
89101 t.Fatalf("branch still protected:\n%s", out)
90102 }
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 }
94103}
e2e/web_test.go +1 −1
@@ -86,7 +86,7 @@ func TestWebUI(t *testing.T) {
8686 t.Fatalf("repo home: %d\n%s", status, body)
8787 }
8888 // 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>") {
9090 t.Fatalf("clone URLs missing:\n%s", body)
9191 }
9292 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 {
3434 DetachedThreads []diffThread
3535 Gates *control.GatesOut
3636 SourceGone bool
37 HeadMerged bool
38 Base string
3739}
3840
3941func renderMR(t *testing.T, m store.MR, reviews []store.MRReview, checks []store.Check) string {
@@ -60,12 +62,13 @@ func testMR(state string) store.MR {
6062 HeadSHA: "ff6271a9d4570cd46f169091637a9d2e40ad5c2b"}
6163}
6264
63// The header states what happened to the MR. "wants to merge" is only true
64// while it is still open.
65// The header states what happened to the MR: who opened, merged, or closed
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.
6568func TestMRHeaderByState(t *testing.T) {
6669 open := renderMR(t, testMR("open"), nil, nil)
67 if !strings.Contains(open, "wants to merge") {
68 t.Errorf("open MR does not say wants to merge:\n%s", open)
70 if !strings.Contains(open, "opened by") {
71 t.Errorf("open MR does not say who opened it:\n%s", open)
6972 }
7073
7174 merged := testMR("merged")
@@ -76,23 +79,34 @@ func TestMRHeaderByState(t *testing.T) {
7679 t.Errorf("merged header missing %q:\n%s", want, out)
7780 }
7881 }
79 if strings.Contains(out, "wants to merge") {
80 t.Errorf("merged MR still wants to merge:\n%s", out)
82 if strings.Contains(out, "opened by") {
83 t.Errorf("merged MR still says it was opened:\n%s", out)
8184 }
8285
8386 closed := testMR("closed")
8487 closed.ClosedAt, closed.ClosedBy = "2026-08-27T14:03:11.000Z", "cmc"
8588 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") {
8790 t.Errorf("closed header:\n%s", out)
8891 }
8992
9093 // 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.
9295 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") {
9497 t.Errorf("unstamped merged header:\n%s", out)
9598 }
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 }
96110}
97111
98112// 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
33import (
44 "fmt"
55 "net/http"
6 "net/url"
7 "slices"
68 "strings"
79
810 "gitbay.org/gitbay/internal/control"
@@ -23,9 +25,18 @@ type settingsPage struct {
2325 Deps control.DepsOut
2426 Runners []store.RepoRunner
2527 Notice string
28 Saved bool
29 Submitted map[string]string
2630}
2731
2832func (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) {
2940 repo, ok := s.repoForUser(w, r, u, policyCanAdmin)
3041 if !ok {
3142 return
@@ -44,11 +55,21 @@ func (s *Server) settingsForm(w http.ResponseWriter, r *http.Request, u store.Us
4455 s.runControlInto(u, []string{"repo", "deps", "status", repo.Path()}, &deps)
4556 var runners []store.RepoRunner
4657 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 }
4766 s.render(w, "settings.html", settingsPage{
4867 repoPage: p, Topics: topics, Branches: branches,
4968 DepsEnabled: deps.Enabled, Deps: deps,
50 Runners: runners,
51 Notice: s.takeFlash(w, r),
69 Runners: runners,
70 Notice: notice,
71 Saved: strings.HasPrefix(notice, "Saved "),
72 Submitted: subm,
5273 })
5374}
5475
@@ -63,9 +84,10 @@ func (s *Server) settingsRedirect(w http.ResponseWriter, r *http.Request, msg st
6384func (s *Server) settingsSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
6485 repo := r.PathValue("owner") + "/" + r.PathValue("repo")
6586 v := func(k string) string { return strings.TrimSpace(r.FormValue(k)) }
87 field := r.FormValue("field")
6688
6789 var argv []string
68 switch r.FormValue("field") {
90 switch field {
6991 case "description":
7092 argv = []string{"repo", "settings", "description", repo, v("description")}
7193 case "website":
@@ -109,12 +131,45 @@ func (s *Server) settingsSubmit(w http.ResponseWriter, r *http.Request, u store.
109131 }
110132 argv = []string{"repo", verb, repo}
111133 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 {
113170 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...)
116171 } else {
117 s.settingsRedirect(w, r, "name at least one topic")
172 s.settingsRedirect(w, r, "Saved the topics.")
118173 return
119174 }
120175 case "runner-add":
@@ -138,9 +193,54 @@ func (s *Server) settingsSubmit(w http.ResponseWriter, r *http.Request, u store.
138193
139194 _, msg, ok := s.runControl(u, argv)
140195 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
142243 }
143 s.settingsRedirect(w, r, msg)
144244}
145245
146246// 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) {
161161 s.cfg.Server.SiteURL, "https://"), "http://"), "/")
162162 s.render(w, "landing.html", struct {
163163 basePage
164 Host string
165 Accounts bool
166 Signup bool
164 Host string
165 Accounts bool
166 Signup bool
167 Picture bool
168 EmailLogin bool
167169 }{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()})
169172}
170173
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
171178func (s *Server) dashboard(w http.ResponseWriter, r *http.Request, viewer store.User) {
172179 pinned, _ := s.st.PinnedRepos(viewer.ID)
173180 var visible []store.Repo
@@ -1861,6 +1868,16 @@ func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
18611868 files, diffTruncated = parseDiff(patch), truncated
18621869 }
18631870 }
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 }
18641881 md := s.ugcFor(r, p.Repo)
18651882 canWrite := s.canWriteRepo(r, p.Repo)
18661883 var detachedThreads []diffThread
@@ -1956,10 +1973,12 @@ func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
19561973 Stacked []store.MR
19571974 Gates *control.GatesOut
19581975 SourceGone bool
1976 HeadMerged bool
1977 Base string
19591978 }{p, m, view, md(m.Body, m.BodyFormat), checks, combined, renderComments(comments, md),
19601979 reviewRows, files, diffTruncated, stat, commits, commitsTotal, branches, s.canEditItem(r, p.Repo, m.Author),
19611980 canWrite, unresolved, revisions, s.takeFlash(w, r), detachedThreads, stackedOn, stacked, gates,
1962 sourceGone(p, m)})
1981 sourceGone(p, m), headMerged, base})
19631982}
19641983
19651984// 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); }
417417 font-size: var(--fs-2);
418418 display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap;
419419}
420.repohead .topics { margin: var(--sp-2) 0 0; }
421420.repometa { margin: var(--sp-1) 0 0; color: var(--muted); font-size: var(--fs-1); }
422421.toggles { margin: var(--sp-2) 0 0; color: var(--muted); font-size: var(--fs-1); }
423422
@@ -788,12 +787,17 @@ td.name a:hover { color: var(--link); }
788787table.tree td.name { width: 25%; white-space: nowrap; }
789788table.tree td.name.dir a { color: var(--link); }
790789table.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}
791794table.tree td.lastcommit a {
792795 color: var(--muted);
793796 display: block;
794797 overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
795798}
796799table.tree td.lastcommit a:hover { color: var(--link); }
800table.tree th.age, table.tree td.age { width: 20%; }
797801td.age, th.age { text-align: right; white-space: nowrap; color: var(--muted); font-variant-numeric: tabular-nums; }
798802td.size, td.mode { color: var(--muted); white-space: nowrap; }
799803table.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
810814table.assets { margin-top: var(--sp-2); }
811815
812816/* 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 {
814824 display: flex;
815825 align-items: center;
816 gap: var(--sp-3);
817 padding: var(--sp-2) var(--sp-4);
818 background: var(--surface);
819 border: 1px solid var(--line);
820 border-bottom: 0;
821 border-radius: var(--r-card) var(--r-card) 0 0;
826 gap: var(--sp-2);
827 flex-wrap: wrap;
828 min-width: 0;
822829 font-size: var(--fs-2);
823830}
824831.tipbar .who { font-weight: 600; }
@@ -826,8 +833,6 @@ table.assets { margin-top: var(--sp-2); }
826833.tipbar .subject:hover { color: var(--link); }
827834.tipbar .spacer { flex: 1; }
828835.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); }
831836
832837.readme { margin-top: var(--sp-5); }
833838.readme .cardbody { max-width: 78ch; }
@@ -1263,6 +1268,7 @@ a.memberchip .role { color: var(--muted); }
12631268.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); }
12641269.pathbar .spacer { flex: 1; }
12651270.pathbar .actions { display: flex; gap: var(--sp-2); }
1271.pathbar .acts { display: flex; gap: var(--sp-2); }
12661272.crumbs { color: var(--muted); }
12671273.crumbs a { color: var(--link); }
12681274.crumbs strong { color: var(--fg); }
@@ -1324,6 +1330,7 @@ details.refmenu .refdrop a.allrefs {
13241330 color: var(--muted);
13251331}
13261332.clone { margin: 0 0 var(--sp-3); }
1333.facts .about { min-width: 0; }
13271334.clone label { font-size: var(--fs-1); color: var(--muted); font-weight: 500; margin-bottom: 2px; }
13281335.clone pre { margin-bottom: var(--sp-3); user-select: all; }
13291336.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 {
13691376 readable on the templates that do not set one yet */
13701377.landing { max-width: 48rem; padding: var(--sp-6) 0; }
13711378.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; }
13721380.lede { font-size: var(--fs-4); color: var(--muted); line-height: 1.5; max-width: 40rem; margin: 0 0 var(--sp-5); }
13731381pre.quickstart { margin: 0 0 var(--sp-4); }
13741382.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); }
13771385.facets h2 { font-size: var(--fs-3); margin: 0 0 var(--sp-1); }
13781386.facets p { color: var(--muted); font-size: var(--fs-2); margin: 0; }
13791387.routes { display: flex; gap: var(--sp-3); align-items: center; margin: var(--sp-5) 0 var(--sp-3); }
1380.explorelink { font-weight: 500; }
13811388
13821389/* ---- profile ---- */
13831390.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}}
12{{define "title"}}not found · {{.Site}}{{end}}
23{{define "content"}}
34<div class="notfound">
internal/web/templates/account.html +1
@@ -1,3 +1,4 @@
1{{define "width"}}bounded{{end}}
12{{define "title"}}account settings{{end}}
23{{define "content"}}
34<h1>Account settings</h1>
internal/web/templates/admin.html +1
@@ -1,3 +1,4 @@
1{{define "width"}}bounded{{end}}
12{{define "title"}}admin · {{.Site}}{{end}}
23{{define "content"}}
34<h1>Admin</h1>
internal/web/templates/blame.html +1
@@ -1,3 +1,4 @@
1{{define "width"}}wide{{end}}
12{{define "title"}}blame: {{.Path}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
23{{define "content"}}
34<h1 class="vh">Blame: {{.Path}}</h1>
internal/web/templates/blob.html +1
@@ -1,3 +1,4 @@
1{{define "width"}}wide{{end}}
12{{define "title"}}{{.Path}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
23{{define "content"}}
34<h1 class="vh">{{.Path}}</h1>
internal/web/templates/build.html +1
@@ -1,3 +1,4 @@
1{{define "width"}}wide{{end}}
12{{define "title"}}build {{.Build.Number}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
23{{define "content"}}
34{{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}}
12{{define "title"}}builds · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
23{{define "content"}}
34<h1>Builds</h1>
internal/web/templates/commit.html +1
@@ -1,3 +1,4 @@
1{{define "width"}}wide{{end}}
12{{define "title"}}{{.ShortSHA}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
23{{define "content"}}
34<div class="commithead">
internal/web/templates/compare.html +1
@@ -1,3 +1,4 @@
1{{define "width"}}wide{{end}}
12{{define "title"}}compare {{.Base}}...{{.Head}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
23{{define "content"}}
34<h1>Compare <code>{{.Base}}</code> … <code>{{.Head}}</code></h1>
internal/web/templates/dashboard.html −5
@@ -31,11 +31,6 @@
3131</div>
3232
3333<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>
3934 <div class="grp">
4035 <h2>Recent activity</h2>
4136 {{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}}
12{{define "title"}}edit {{.Path}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
23{{define "content"}}
34<h1>edit {{.Repo.OwnerName}}/{{.Repo.Name}} : {{.Path}} @ {{.Ref}}</h1>
internal/web/templates/globalsearch.html +1
@@ -1,3 +1,4 @@
1{{define "width"}}wide{{end}}
12{{define "title"}}search · {{.Site}}{{end}}
23{{define "content"}}
34<div class="listhead">
internal/web/templates/issuenew.html +1
@@ -1,3 +1,4 @@
1{{define "width"}}bounded{{end}}
12{{define "title"}}new issue · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
23{{define "content"}}
34<h1>New issue</h1>
internal/web/templates/landing.html +13 −10
@@ -1,18 +1,21 @@
11{{define "title"}}{{.Site}}{{end}}
2{{define "width"}}bounded{{end}}
23{{define "content"}}
34<div class="landing">
4<h1>{{.Site}}</h1>
5<p class="lede">A CLI-first git forge. SSH is the API: repositories, issues, and
6merge requests are managed from your terminal with nothing but OpenSSH —
7the web is a fast, readable rendering of that state.</p>
8<pre class="quickstart">ssh git@{{.Host}} help # every command, no client needed
5<h1>{{template "mark"}}{{.Site}}</h1>
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>
7<pre class="quickstart">ssh git@{{.Host}} help # every command, no client to install
98git 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}}
1013<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>
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>
13 <section><h2>Review</h2><p>The web is a rendering of the same state: read a diff, comment on a line, approve, merge. Everything it can do, the terminal can do first.</p></section>
14 <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>
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>
16 <section><h2>Review</h2><p>Read a diff, comment on a line, approve, merge, in the browser or the terminal.</p></section>
1417</div>
15<p><a class="explorelink" href="/explore">explore public repositories →</a></p>
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}}
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>
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}}
1720</div>
1821{{end}}
internal/web/templates/layout.html +9 −8
@@ -71,13 +71,13 @@
7171 <form method="post" action="/{{.OwnerName}}/{{.Name}}/fork" class="inline"><button type="submit" class="btn">Fork</button></form>{{end}}
7272 </div>
7373 {{$top := topTab (str $ "Tab")}}
74 {{/* The header is the same on every tab: it sits above the tab bar,
75 and anything shown on one tab and not another would move the
76 navigation between clicks of that navigation. */}}
77 {{with field $ "Desc"}}<p class="repodesc">{{.}}</p>{{end}}
78 {{with field $ "Topics"}}<p class="topics">{{range .}}<a class="chip topic" href="/explore?q={{.}}">{{.}}</a> {{end}}</p>{{end}}
79 {{with $.Repo.Settings.Website}}<p class="repometa"><a href="{{.}}" rel="nofollow">{{.}}</a></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>
74 {{/* Description, topics and metadata belong to the code tab. On task
75 tabs only the identity row and the tab bar render, and the header
76 is identical on every page within a tab. */}}
77 {{if eq $top "code"}}
78 <p class="repodesc">{{with field $ "Desc"}}{{.}}{{end}} {{with field $ "Topics"}}{{range .}}<a class="chip topic" href="/explore?q={{.}}">{{.}}</a> {{end}}{{end}}</p>
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 {{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}}
8181 {{end}}
8282 <nav class="tabs" aria-label="Repository">
8383 <a {{if eq $top "code"}}aria-current="page" {{end}}href="/{{.OwnerName}}/{{.Name}}">Code</a>
@@ -91,7 +91,7 @@
9191</header>
9292{{end}}
9393
94<main id="content" class="content">
94<main id="content" class="content {{template "width" .}}">
9595{{template "content" .}}
9696</main>
9797
@@ -104,6 +104,7 @@
104104</html>{{end}}
105105
106106{{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}}
107108
108109{{define "reporow"}}<li>
109110 <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}}
12{{define "title"}}log · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
23{{define "content"}}
34<h1 class="vh">Commits</h1>
internal/web/templates/login.html +3 −2
@@ -1,3 +1,4 @@
1{{define "width"}}bounded{{end}}
12{{define "title"}}login · {{.Site}}{{end}}
23{{define "content"}}
34<h1>Sign in</h1>
@@ -9,8 +10,8 @@ expires in fifteen minutes.</p>
910{{else}}
1011{{if .EmailLogin}}
1112<form method="post" action="/login">
12 <label for="identifier">Username or email address</label>
13 <input type="text" id="identifier" name="identifier" autocomplete="username" required>
13 <div class="field"><label for="identifier">Username or email address</label>
14 <input type="text" id="identifier" name="identifier" autocomplete="username" required></div>
1415 <button type="submit">Email me a link</button>
1516</form>
1617<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}}
21{{define "title"}}!{{.MR.Number}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
32{{define "content"}}
43{{$base := printf "/%s/%s/mrs/%d" .Repo.OwnerName .Repo.Name .MR.Number}}
54<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>
7{{if and (eq .MR.State "merged") .MR.MergedAt}}
8 {{if .MR.MergedBy}}<a href="/{{.MR.MergedBy}}">{{.MR.MergedBy}}</a> merged{{else}}Merged{{end}}
9 {{template "mrrange" .MR}} on {{when .MR.MergedAt}}
10{{else if and (eq .MR.State "closed") .MR.ClosedAt}}
11 {{if .MR.ClosedBy}}<a href="/{{.MR.ClosedBy}}">{{.MR.ClosedBy}}</a> closed this{{else}}Closed{{end}}
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>
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}}
6 {{if and (eq .MR.State "merged") .MR.MergedBy}}merged by <a href="/{{.MR.MergedBy}}">{{.MR.MergedBy}}</a>{{with .MR.MergedAt}} on {{when .}}{{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}}
8 {{else if or (eq .MR.State "merged") (eq .MR.State "closed")}}{{if eq .MR.State "merged"}}merged{{else}}closed without merging{{end}}
9 {{else}}opened by <a href="/{{.MR.Author}}">{{.MR.Author}}</a> on {{when .MR.CreatedAt}}{{end}}
10 · <code>{{.MR.SourceRef}}</code> into <code>{{.MR.TargetRef}}</code></p>
1811{{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}}
1912{{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}}
2013
@@ -30,6 +23,7 @@
3023</nav>
3124
3225{{if eq .View "conversation"}}
26<div class="prose">
3327{{if .CanEdit}}<details class="editbox"><summary>Edit</summary>
3428<form method="post" action="{{$base}}/edit" class="commentform">
3529<p><input type="text" name="title" aria-label="Title" value="{{.MR.Title}}" required></p>
@@ -55,6 +49,7 @@
5549<p><button type="submit" class="btn">Comment</button></p>
5650</form>
5751{{end}}
52</div>
5853
5954{{else if eq .View "commits"}}
6055{{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 @@
7469</ul>
7570
7671{{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>
7873{{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}}
7974{{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}}
8076{{end}}
8177
8278</div>
@@ -113,38 +109,6 @@
113109 </form>
114110 </div>
115111 {{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>
148112 {{with .Gates}}<div class="grp">
149113 <h2>Merge gates</h2>
150114 {{if .Unmet}}{{range .Unmet}}<p class="row"><span class="dot bad"></span>{{.}}</p>{{end}}
@@ -160,9 +124,38 @@
160124 {{else}}<p class="none">No checks reported</p>{{end}}
161125 </div>
162126 <div class="grp">
163 <h2>Source</h2>
164 <p class="row"><code>{{if .MR.SourcePath}}{{.MR.SourcePath}}:{{end}}{{.MR.SourceRef}}</code></p>
165 <p class="row none">into <code>{{.MR.TargetRef}}</code> · {{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>
127 <h2>Reviews</h2>
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>
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}}
166159 </div>
167160 {{if .MR.Milestone}}<div class="grp">
168161 <h2>Milestone</h2>
internal/web/templates/mrnew.html +1
@@ -1,3 +1,4 @@
1{{define "width"}}bounded{{end}}
12{{define "title"}}new merge request · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
23{{define "content"}}
34<h1>New merge request</h1>
internal/web/templates/new.html +1
@@ -1,3 +1,4 @@
1{{define "width"}}bounded{{end}}
12{{define "title"}}new repository · {{.Site}}{{end}}
23{{define "content"}}
34<h1>New repository</h1>
internal/web/templates/privacy.html +1
@@ -1,3 +1,4 @@
1{{define "width"}}bounded{{end}}
12{{define "title"}}privacy · {{.Site}}{{end}}
23{{define "content"}}
34<div class="landing">
internal/web/templates/register.html +9 −9
@@ -1,18 +1,18 @@
11{{define "title"}}register · {{.Site}}{{end}}
2{{define "width"}}bounded{{end}}
23{{define "content"}}
3<div class="landing">
44<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>
6{{else}}<p class="lede">Open registration — your account activates once you verify your email.</p>{{end}}
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}}
77{{if .Error}}<p class="error" role="alert">{{.Error}}</p>{{end}}
88<form method="post" action="/register" class="signupform">
9<p><label>Username<br><input type="text" name="username" value="{{.Username}}" required autofocus></label></p>
10{{if eq .Mode "invite"}}<p><label>invite code<br><input type="text" name="invite" required></label></p>
11{{else}}<p><label>Email<br><input type="text" name="email" required></label></p>{{end}}
12<p><label>SSH public key — your key is your identity; paste e.g. <code>~/.ssh/id_ed25519.pub</code><br>
13<textarea name="key" aria-label="SSH public key" rows="3" required placeholder="ssh-ed25519 AAAA... you@host"></textarea></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"}}<div class="field"><label for="invite">Invite code</label><input type="text" id="invite" name="invite" required></div>
11{{else}}<div class="field"><label for="email">Email</label><input type="text" id="email" name="email" required></div>{{end}}
12<div class="field"><label for="key">SSH public key</label>
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>
1415<p><button type="submit">Create account</button></p>
1516</form>
1617<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>
1818{{end}}
internal/web/templates/registered.html +1
@@ -1,3 +1,4 @@
1{{define "width"}}bounded{{end}}
12{{define "title"}}welcome · {{.Site}}{{end}}
23{{define "content"}}
34<div class="landing">
internal/web/templates/search.html +1
@@ -1,3 +1,4 @@
1{{define "width"}}wide{{end}}
12{{define "title"}}search · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
23{{define "content"}}
34<h1>Search</h1>
internal/web/templates/settings.html +81 −83
@@ -1,148 +1,143 @@
1{{define "width"}}bounded{{end}}
12{{define "title"}}settings · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
23{{define "content"}}
34{{$base := printf "/%s/%s/settings" .Repo.OwnerName .Repo.Name}}
45<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>
68
7<h2>Identity</h2>
9<section id="identity"><h2>Identity</h2>
810<form method="post" action="{{$base}}" class="setform">
911 <input type="hidden" name="field" value="description">
10 <label for="description">Description</label>
11 <input type="text" id="description" name="description" value="{{.Desc}}" placeholder="one line, shown in listings">
12 <button type="submit" class="btn">Save description</button>
12 <div><label for="description">Description</label></div>
13 <div><input type="text" id="description" name="description" value="{{or (index .Submitted "description") .Desc}}" placeholder="one line, shown in listings"></div>
14 <div><button type="submit">Save</button></div>
1315</form>
1416<form method="post" action="{{$base}}" class="setform">
1517 <input type="hidden" name="field" value="website">
16 <label for="website">Website</label>
17 <input type="text" id="website" name="website" value="{{.Repo.Settings.Website}}" placeholder="https://example.org">
18 <button type="submit" class="btn">Save website</button>
18 <div><label for="website">Website</label></div>
19 <div><input type="text" id="website" name="website" value="{{or (index .Submitted "website") .Repo.Settings.Website}}" placeholder="https://example.org"></div>
20 <div><button type="submit" class="btn">Save</button></div>
1921</form>
20{{if .Branches}}<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">
22<form method="post" action="{{$base}}" class="setform">
2923 <input type="hidden" name="field" value="topics">
30 <label for="topics-add">Topics to add</label>
31 <input type="text" id="topics-add" name="add" placeholder="add, space-separated">
32 <label for="topics-remove">Topics to remove</label>
33 <input type="text" id="topics-remove" name="remove" placeholder="remove">
34 <button type="submit" class="btn">Apply</button>
24 <div><label for="topics">Topics</label><p class="hint">Comma separated, lower case.</p></div>
25 <div><input type="text" id="topics" name="topics" value="{{or (index .Submitted "topics") (join .Topics ", ")}}"></div>
26 <div><button type="submit" class="btn">Save</button></div>
3527</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>
3735
38<h2>Access</h2>
36<section id="access"><h2>Access</h2>
3937<form method="post" action="{{$base}}" class="setform">
4038 <input type="hidden" name="field" value="visibility">
41 <label for="visibility">Visibility</label>
42 <select id="visibility" name="visibility">
43 <option value="public"{{if eq .Repo.Visibility "public"}} selected{{end}}>Public</option>
44 <option value="private"{{if eq .Repo.Visibility "private"}} selected{{end}}>Private</option>
45 </select>
46 <button type="submit" class="btn">Save visibility</button>
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>
40 <div class="check">
41 <label><input type="radio" name="visibility" value="public"{{if eq .Repo.Visibility "public"}} checked{{end}}> Public</label>
42 <label><input type="radio" name="visibility" value="private"{{if eq .Repo.Visibility "private"}} checked{{end}}> Private</label>
43 </div>
44 <div><button type="submit" class="btn">Save</button></div>
4745</form>
4846<form method="post" action="{{$base}}" class="setform">
4947 <input type="hidden" name="field" value="git-daemon">
50 <label for="git-daemon">Serve over git://</label>
51 <input type="checkbox" id="git-daemon" name="git-daemon" value="on"{{if .Repo.Settings.GitDaemon}} checked{{end}}>
52 <button type="submit" class="btn">Save git://</button>
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>
49 <div class="check"><input type="checkbox" id="git-daemon" name="git-daemon" value="on"{{if .Repo.Settings.GitDaemon}} checked{{end}}></div>
50 <div><button type="submit" class="btn">Save</button></div>
5351</form>
52</section>
5453
55<h2>Merge gates</h2>
54<section id="gates"><h2>Merge gates</h2>
5655<p class="meta">Checked before a merge, in this order: checks, approvals, resolved threads, signatures.</p>
5756<form method="post" action="{{$base}}" class="setform">
5857 <input type="hidden" name="field" value="require-checks">
59 <label for="require-checks">Require green checks</label>
60 <input type="checkbox" id="require-checks" name="require-checks" value="on"{{if .Repo.Settings.RequireChecks}} checked{{end}}>
61 <button type="submit" class="btn">Save checks</button>
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>
59 <div class="check"><input type="checkbox" id="require-checks" name="require-checks" value="on"{{if .Repo.Settings.RequireChecks}} checked{{end}}></div>
60 <div><button type="submit" class="btn">Save</button></div>
6261</form>
6362<form method="post" action="{{$base}}" class="setform">
6463 <input type="hidden" name="field" value="require-approvals">
65 <label for="approvals">Required approvals</label>
66 <input type="number" id="approvals" name="approvals" min="0" max="10" value="{{.Repo.Settings.RequireApprovals}}">
67 <button type="submit" class="btn">Save approvals</button>
64 <div><label for="approvals">Approvals</label><p class="hint">Approvals from people with write access. Zero means none required.</p></div>
65 <div><input type="number" id="approvals" name="approvals" min="0" max="10" value="{{.Repo.Settings.RequireApprovals}}"></div>
66 <div><button type="submit" class="btn">Save</button></div>
6867</form>
6968<form method="post" action="{{$base}}" class="setform">
7069 <input type="hidden" name="field" value="require-resolved">
71 <label for="require-resolved">Require resolved threads</label>
72 <input type="checkbox" id="require-resolved" name="require-resolved" value="on"{{if .Repo.Settings.RequireResolved}} checked{{end}}>
73 <button type="submit" class="btn">Save threads</button>
70 <div><label for="require-resolved">Review threads</label><p class="hint">Every thread on the diff must be resolved before merging.</p></div>
71 <div class="check"><input type="checkbox" id="require-resolved" name="require-resolved" value="on"{{if .Repo.Settings.RequireResolved}} checked{{end}}></div>
72 <div><button type="submit" class="btn">Save</button></div>
7473</form>
7574<form method="post" action="{{$base}}" class="setform">
7675 <input type="hidden" name="field" value="require-codeowners">
77 <label for="require-codeowners">Require CODEOWNERS approval</label>
78 <input type="checkbox" id="require-codeowners" name="require-codeowners" value="on"{{if .Repo.Settings.RequireCodeowners}} checked{{end}}>
79 <button type="submit" class="btn">Save CODEOWNERS</button>
76 <div><label for="require-codeowners">CODEOWNERS</label><p class="hint">Owners of every touched path must approve.</p></div>
77 <div class="check"><input type="checkbox" id="require-codeowners" name="require-codeowners" value="on"{{if .Repo.Settings.RequireCodeowners}} checked{{end}}></div>
78 <div><button type="submit" class="btn">Save</button></div>
8079</form>
8180<form method="post" action="{{$base}}" class="setform">
8281 <input type="hidden" name="field" value="require-signed">
83 <label for="require-signed">Require signed commits</label>
84 <input type="checkbox" id="require-signed" name="require-signed" value="on"{{if .Repo.Settings.RequireSignedCommits}} checked{{end}}>
85 <button type="submit" class="btn">Save signing</button>
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>
83 <div class="check"><input type="checkbox" id="require-signed" name="require-signed" value="on"{{if .Repo.Settings.RequireSignedCommits}} checked{{end}}></div>
84 <div><button type="submit" class="btn">Save</button></div>
8685</form>
86</section>
8787
88<h2>Protected branches</h2>
88<section id="branches"><h2>Protected branches</h2>
8989{{if .Repo.Settings.ProtectedBranches}}
9090<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}}
9292 <form method="post" action="{{$base}}" class="inline">
9393 <input type="hidden" name="field" value="unprotect">
9494 <input type="hidden" name="branch" value="{{.}}">
95 <button type="submit" class="linklike">Unprotect</button>
95 <button type="submit" class="danger">Unprotect</button>
9696 </form></li>
9797{{end}}
9898</ul>
9999{{else}}<p class="meta">No protected branches. A protected branch refuses deletion and force-pushes.</p>{{end}}
100100<form method="post" action="{{$base}}" class="setform">
101101 <input type="hidden" name="field" value="protect">
102 <label for="branch">Protect a branch</label>
103 <select id="branch" name="branch">
104 {{range .Branches}}<option value="{{.Name}}">{{.Name}}</option>{{end}}
105 </select>
106 <button type="submit" class="btn">Protect</button>
102 <div><label for="branch">Protect a branch</label></div>
103 <div><select id="branch" name="branch">{{range .Branches}}<option value="{{.Name}}">{{.Name}}</option>{{end}}</select></div>
104 <div><button type="submit" class="btn">Protect</button></div>
107105</form>
108106<form method="post" action="{{$base}}" class="setform">
109107 <input type="hidden" name="field" value="require-mr">
110 <label for="require-mr">Merge requests only</label>
111 <input type="checkbox" id="require-mr" name="require-mr" value="on"{{if .Repo.Settings.RequireMR}} checked{{end}}>
112 <button type="submit" class="btn">Save merge-only</button>
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>
109 <div class="check"><input type="checkbox" id="require-mr" name="require-mr" value="on"{{if .Repo.Settings.RequireMR}} checked{{end}}></div>
110 <div><button type="submit" class="btn">Save</button></div>
113111</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>
115113
116<h2>Protected tags</h2>
114<section id="tags"><h2>Protected tags</h2>
117115{{if .Repo.Settings.ProtectedTags}}
118116<ul class="protlist">
119117{{range .Repo.Settings.ProtectedTags}}<li><code>{{.}}</code>
120118 <form method="post" action="{{$base}}" class="inline">
121119 <input type="hidden" name="field" value="unprotect-tag">
122120 <input type="hidden" name="glob" value="{{.}}">
123 <button type="submit" class="linklike">Unprotect</button>
121 <button type="submit" class="danger">Unprotect</button>
124122 </form></li>
125123{{end}}
126124</ul>
127125{{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}}
128126<form method="post" action="{{$base}}" class="setform">
129127 <input type="hidden" name="field" value="protect-tag">
130 <label for="glob">Protect tags matching</label>
131 <input type="text" id="glob" name="glob" placeholder="v*">
132 <button type="submit" class="btn">Protect</button>
128 <div><label for="glob">Protect tags matching</label></div>
129 <div><input type="text" id="glob" name="glob" placeholder="v*"></div>
130 <div><button type="submit" class="btn">Protect</button></div>
133131</form>
132</section>
134133
135<h2>Dependencies</h2>
134<section id="deps"><h2>Dependencies</h2>
136135<form method="post" action="{{$base}}" class="setform">
137136 <input type="hidden" name="field" value="deps">
138 <label for="deps">Check for updates</label>
139 <input type="checkbox" id="deps" name="deps" value="on"{{if .DepsEnabled}} checked{{end}}>
140 <button type="submit" class="btn">Save dependency checks</button>
141</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>
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>
138 <div class="check"><input type="checkbox" id="deps" name="deps" value="on"{{if .DepsEnabled}} checked{{end}}></div>
139 <div><button type="submit" class="btn">Save</button></div>
140</form>
146141{{if .DepsEnabled}}
147142<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>
148143{{if .Deps.LastError}}<p class="error" role="alert">{{.Deps.LastError}}</p>{{end}}
@@ -156,34 +151,37 @@ depends on.</p>
156151{{end}}</table></div>
157152{{else}}<p class="none">Nothing behind{{if not .Deps.LastCheck}} — the first check has not run yet{{end}}.</p>{{end}}
158153{{end}}
154</section>
159155
160<h2>Runners</h2>
156<section id="runners"><h2>Runners</h2>
161157{{if .Runners}}
162158<ul class="protlist">
163159{{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>
164160 <form method="post" action="{{$base}}" class="inline">
165161 <input type="hidden" name="field" value="runner-remove">
166162 <input type="hidden" name="fingerprint" value="{{.Fingerprint}}">
167 <button type="submit" class="linklike">Detach</button>
163 <button type="submit" class="danger">Detach</button>
168164 </form></li>
169165{{end}}
170166</ul>
171167{{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">
173169 <input type="hidden" name="field" value="runner-add">
174170 <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>
175172 <textarea id="runner-key" name="key" rows="3" placeholder="ssh-ed25519 AAAA… (from gitbay-runner init)"></textarea>
176173 <button type="submit" class="btn">Attach</button>
177174</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>
179176
180<h2>Lifecycle</h2>
177<section id="lifecycle"><h2>Lifecycle</h2>
181178<form method="post" action="{{$base}}" class="setform">
182179 <input type="hidden" name="field" value="archive">
183 <label for="archive">Archived (read-only)</label>
184 <input type="checkbox" id="archive" name="archive" value="on"{{if .Repo.Settings.Archived}} checked{{end}}>
185 <button type="submit" class="btn">Save archive</button>
180 <div><label for="archive">Archived</label><p class="hint">Read-only for everyone. Issues and requests close to new activity. Reversible.</p></div>
181 <div class="check"><input type="checkbox" id="archive" name="archive" value="on"{{if .Repo.Settings.Archived}} checked{{end}}></div>
182 <div><button type="submit" class="danger">Save</button></div>
186183</form>
187184<p class="meta">Deleting or transferring a repository is a CLI operation:
188185<code>gitbay repo delete {{.Repo.OwnerName}}/{{.Repo.Name}} --yes</code></p>
186</section>
189187{{end}}
internal/web/templates/snippetnew.html +1
@@ -1,3 +1,4 @@
1{{define "width"}}bounded{{end}}
12{{define "title"}}new snippet · {{.Owner}}{{end}}
23{{define "content"}}
34<h1>New snippet</h1>
internal/web/templates/tree.html +29 −26
@@ -1,3 +1,4 @@
1{{define "width"}}wide{{end}}
12{{define "title"}}{{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
23{{define "content"}}
34{{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}}
@@ -5,35 +6,15 @@
56<div class="pathbar">
67 {{template "refmenu" .}}
78 <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 <a class="act" href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/search">Find file</a>
10 <a class="act" href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/log/{{.Ref}}">History</a>
11 <a class="act" href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/archive/{{.Ref}}.tar.gz">Download</a>
9 <div class="acts">
10 <a class="button btn" href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/search">Search code</a>
11 <a class="button btn" href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/log/{{.Ref}}">History</a>
12 <a class="button btn" href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/archive/{{.Ref}}.tar.gz">Download</a>
13 </div>
1214</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}}
3515<div class="tablewrap">
3616<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}}
3718<tr class="cols"><th scope="col">name</th><th scope="col" class="lastcommit">last commit</th><th scope="col" class="age">updated</th></tr>
3819{{range .Entries}}{{$c := index $.LastCommits .Name}}<tr>
3920 {{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 @@
4829<div class="cardhead"><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/blob/{{.Ref}}/{{.Prefix}}{{.ReadmeName}}">{{.ReadmeName}}</a></div>
4930<div class="rendered">{{.ReadmeHTML}}</div>
5031</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}}
5154{{end}}
internal/web/web.go +1
@@ -62,6 +62,7 @@ var fullVersion = sync.OnceValue(func() string {
6262var funcs = template.FuncMap{
6363 "gitbayVersion": func() string { return version() },
6464 "gitbayCommit": func() string { return fullVersion() },
65 "join": strings.Join,
6566 // paragraphs splits plain text on blank lines for safe rich display.
6667 "paragraphs": func(s string) []string {
6768 var out []string
internal/web/web_test.go +33
@@ -83,3 +83,36 @@ func TestWhenNamesTheZone(t *testing.T) {
8383 t.Fatalf("passthrough: %q", got)
8484 }
8585}
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}