dashboard: build runs in the feed, relative times; the rail strip tidied !409

merged merged by cmc on 2026-09-18 20:16 UTC · krz/gitbay:feed-222 into main

9 files changed, +323 −18

Layout: unified · split

e2e/dashboard_test.go +53
@@ -356,3 +356,56 @@ func TestDashboardQueues(t *testing.T) {
356356 t.Fatalf("reviewed MR still waiting:\n%s", after)
357357 }
358358}
359
360// D04/D05: two jobs on one commit fold into a single feed line, marked
361// with the worse of the two outcomes, and shown with a relative time
362// carrying the exact UTC time in its title.
363func TestDashboardFeedFoldsBuildRun(t *testing.T) {
364 inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n")
365 inst.runner = buildRunner(t)
366 aliceKey := inst.newKey(t, "alice")
367 inst.admin(t, "admin", "user", "create", "alice",
368 "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
369 runnerKey := inst.newKey(t, "ci")
370 inst.admin(t, "admin", "user", "create", "ci", "--key", runnerKey+".pub", "--admin")
371
372 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 {
373 t.Fatalf("repo create: %s", errOut)
374 }
375 work := t.TempDir()
376 env := inst.gitEnv(aliceKey)
377 mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w")
378 dir := filepath.Join(work, "w")
379 os.MkdirAll(filepath.Join(dir, ".gitbay"), 0o755)
380 os.WriteFile(filepath.Join(dir, ".gitbay", "ci.yml"), []byte(
381 "jobs:\n ok:\n steps:\n - echo fine\n broken:\n steps:\n - \"false\"\n"), 0o644)
382 mustGit(t, dir, env, "checkout", "-q", "-b", "main")
383 mustGit(t, dir, env, "add", ".")
384 mustGit(t, dir, env, "commit", "-q", "-m", "base")
385 mustGit(t, dir, env, "push", "-q", "origin", "main")
386 sha := strings.TrimSpace(mustGit(t, dir, env, "rev-parse", "HEAD"))
387
388 // The runner processes both jobs ("broken" sorts first).
389 inst.runnerOnce(t, runnerKey)
390 inst.runnerOnce(t, runnerKey)
391
392 _, body := browserGet(t, inst.login(t, aliceKey), inst.base()+"/")
393 if !strings.Contains(body, "ran 2 jobs on") {
394 t.Fatalf("feed did not fold the two jobs into one run:\n%s", body)
395 }
396 if !strings.Contains(body, `class="dot bad"`) {
397 t.Fatalf("feed did not mark the run with the worse (failure) status:\n%s", body)
398 }
399 if !strings.Contains(body, sha[:10]) {
400 t.Fatalf("feed missing the short sha %q:\n%s", sha[:10], body)
401 }
402 // A build reported moments ago renders as "just now"; ago() only
403 // switches to "N ago" past a minute, so either form proves the
404 // relative-time rendering rather than the raw timestamp.
405 if !strings.Contains(body, ">just now<") && !strings.Contains(body, " ago<") {
406 t.Fatalf("feed missing a relative time:\n%s", body)
407 }
408 if !strings.Contains(body, "title=\"") || !strings.Contains(body, " UTC\"") {
409 t.Fatalf("feed missing the exact time in a title:\n%s", body)
410 }
411}
internal/control/build.go +3 −3
@@ -593,7 +593,7 @@ func runRunnerDone(c *Ctx, args []string) int {
593593 return c.fail(protocol.ExitFailure, "%v", err)
594594 }
595595 c.Store.RecordEvent(repo.ID, c.User.ID, "build."+args[1],
596 fmt.Sprintf(`{"number":%d,"job":%q}`, b.Number, b.Job))
596 fmt.Sprintf(`{"number":%d,"job":%q,"sha":%q}`, b.Number, b.Job, b.SHA))
597597 // A red build mails the repo's notify targets with the log tail — a
598598 // failed scheduled job must not wait to be noticed.
599599 if args[1] == "failure" {
@@ -829,7 +829,7 @@ func cancelOrphanedBuild(c *Ctx, repo store.Repo, b store.Build) int {
829829 c.Store.AppendBuildLog(b.ID, []byte(fmt.Sprintf(
830830 "cancelled: %.10s is not reachable from any ref; the sha was likely orphaned by a force-push\n", b.SHA)))
831831 resolveCancelledCommitStatus(c, repo, b)
832 c.Store.RecordEvent(repo.ID, c.User.ID, "build.cancelled", fmt.Sprintf(`{"number":%d,"job":%q}`, b.Number, b.Job))
832 c.Store.RecordEvent(repo.ID, c.User.ID, "build.cancelled", fmt.Sprintf(`{"number":%d,"job":%q,"sha":%q}`, b.Number, b.Job, b.SHA))
833833 return -1
834834}
835835
@@ -858,7 +858,7 @@ func runBuildCancel(c *Ctx, args []string) int {
858858 }
859859 // The queued status replaced whatever the commit had for this job.
860860 resolveCancelledCommitStatus(c, repo, b)
861 c.Store.RecordEvent(repo.ID, c.User.ID, "build.cancelled", fmt.Sprintf(`{"number":%d,"job":%q}`, b.Number, b.Job))
861 c.Store.RecordEvent(repo.ID, c.User.ID, "build.cancelled", fmt.Sprintf(`{"number":%d,"job":%q,"sha":%q}`, b.Number, b.Job, b.SHA))
862862 return c.emit(map[string]any{"number": b.Number, "job": b.Job, "status": "cancelled", "was": b.Status}, func(w io.Writer) {
863863 if b.Status == "running" {
864864 fmt.Fprintf(w, "cancelled %s build %d (%s); the runner stops at its next check\n", repo.Path(), b.Number, b.Job)
internal/httpd/builds.go +20 −2
@@ -99,15 +99,33 @@ var runStatusPriority = []string{"failure", "cancelled", "running", "pending"}
9999// combinedStatus is the run's status: the worst of its builds' statuses,
100100// success only when every one of them is.
101101func combinedStatus(builds []control.BuildOut) string {
102 statuses := make([]string, len(builds))
103 for i, b := range builds {
104 statuses[i] = b.Status
105 }
106 return worstStatus(statuses)
107}
108
109// worstStatus is combinedStatus's ordering rule, factored out so the
110// dashboard feed can apply the same worst-first precedence to a folded
111// build run (D04). A status outside runStatusPriority (a future state
112// such as "skipped") is still not "success": it is returned unchanged
113// rather than falling through and reading as green.
114func worstStatus(statuses []string) string {
102115 has := map[string]bool{}
103 for _, b := range builds {
104 has[b.Status] = true
116 for _, s := range statuses {
117 has[s] = true
105118 }
106119 for _, s := range runStatusPriority {
107120 if has[s] {
108121 return s
109122 }
110123 }
124 for _, s := range statuses {
125 if s != "success" {
126 return s
127 }
128 }
111129 return "success"
112130}
113131
internal/httpd/builds_test.go +4
@@ -55,6 +55,10 @@ func TestGroupRunsCombinedStatusPriority(t *testing.T) {
5555 {[]string{"running", "cancelled"}, "cancelled"},
5656 {[]string{"cancelled", "failure"}, "failure"},
5757 {[]string{"success", "success", "failure"}, "failure"},
58 // A status outside runStatusPriority (a future state such as
59 // "skipped") is still not success: it must not fall through to
60 // the "success" default and read as green.
61 {[]string{"success", "skipped"}, "skipped"},
5862 }
5963 for _, tc := range cases {
6064 var builds []control.BuildOut
internal/httpd/feed.go +47 −5
@@ -4,6 +4,7 @@ import (
44 "encoding/json"
55 "fmt"
66 "strings"
7 "time"
78
89 "gitbay.org/gitbay/internal/store"
910)
@@ -11,30 +12,53 @@ import (
1112// feedLine is one activity entry, already phrased and linked.
1213type feedLine struct {
1314 Actor string
14 Verb string // "opened issue", "merged"
15 Ref string // "#12", "!35", "v0.4.0"
15 Verb string // "opened issue", "merged", "ran 2 jobs on"
16 Ref string // "#12", "!35", "v0.4.0", a short sha
1617 Repo string
1718 URL string
18 When string
19 When string // the stored timestamp, for anything still reading it raw
20 WhenT time.Time // parsed from When, for ago/whenT rendering
21 State string // a build run's combined status; empty for anything else
22 Jobs []string // job names folded into a build run
23 sha string // the commit a build event fired on, for fold-matching
1924}
2025
2126// feedLines turns stored events into readable lines. An unknown kind
2227// still shows: the feed says what happened even for events added later.
28// Build events on the same commit, adjacent in the input, fold into one
29// "run" line (D04): its State is the worst of the folded jobs' outcomes,
30// via worstStatus — the same rule the builds tab uses for a run's status.
2331func feedLines(events []store.FeedEvent) []feedLine {
2432 out := make([]feedLine, 0, len(events))
33 statuses := make([][]string, 0, len(events))
2534 for _, e := range events {
2635 var d struct {
2736 Number int64 `json:"number"`
2837 Job string `json:"job"`
2938 Tag string `json:"tag"`
39 SHA string `json:"sha"`
3040 }
3141 json.Unmarshal([]byte(e.Data), &d)
42 kind, rest, _ := strings.Cut(e.Kind, ".")
43
44 if kind == "build" && d.SHA != "" {
45 if n := len(out); n > 0 && out[n-1].sha == d.SHA && out[n-1].Repo == e.RepoPath {
46 i := n - 1
47 out[i].Jobs = append(out[i].Jobs, d.Job)
48 statuses[i] = append(statuses[i], rest)
49 out[i].Verb = fmt.Sprintf("ran %d jobs on", len(out[i].Jobs))
50 out[i].Ref = fmt.Sprintf("%.10s", d.SHA)
51 out[i].URL = fmt.Sprintf("/%s/commit/%s", e.RepoPath, d.SHA)
52 out[i].State = worstStatus(statuses[i])
53 continue
54 }
55 }
3256
33 l := feedLine{Actor: e.Actor, Repo: e.RepoPath, When: e.CreatedAt}
57 l := feedLine{Actor: e.Actor, Repo: e.RepoPath, When: e.CreatedAt, WhenT: parseEventTime(e.CreatedAt)}
3458 if l.Actor == "" {
3559 l.Actor = "gitbay"
3660 }
37 kind, rest, _ := strings.Cut(e.Kind, ".")
61 var st []string
3862 switch kind {
3963 case "issue":
4064 l.Verb, l.Ref = issueVerb(rest), fmt.Sprintf("#%d", d.Number)
@@ -45,6 +69,12 @@ func feedLines(events []store.FeedEvent) []feedLine {
4569 case "build":
4670 l.Verb, l.Ref = "build "+rest, d.Job
4771 l.URL = fmt.Sprintf("/%s/builds/%d", e.RepoPath, d.Number)
72 if d.SHA != "" {
73 l.sha = d.SHA
74 l.Jobs = []string{d.Job}
75 l.State = rest
76 st = []string{rest}
77 }
4878 case "release":
4979 l.Verb, l.Ref = "released", d.Tag
5080 l.URL = fmt.Sprintf("/%s/releases", e.RepoPath)
@@ -56,10 +86,22 @@ func feedLines(events []store.FeedEvent) []feedLine {
5686 l.URL = "/" + e.RepoPath
5787 }
5888 out = append(out, l)
89 statuses = append(statuses, st)
5990 }
6091 return out
6192}
6293
94// parseEventTime parses a stored RFC3339 timestamp for ago/whenT
95// rendering; an unparseable value (or none) comes back zero rather than
96// guessing.
97func parseEventTime(s string) time.Time {
98 t, err := time.Parse(time.RFC3339Nano, s)
99 if err != nil {
100 return time.Time{}
101 }
102 return t
103}
104
63105func issueVerb(s string) string {
64106 switch s {
65107 case "created":
internal/httpd/feed_test.go added +156
@@ -0,0 +1,156 @@
1package httpd
2
3import (
4 "testing"
5 "time"
6
7 "gitbay.org/gitbay/internal/store"
8)
9
10// D04: build events on the same commit fold into one feed line, a "run",
11// whose State is the worst of the folded jobs' outcomes.
12func TestFeedLinesFoldsBuildRunsBySHA(t *testing.T) {
13 events := []store.FeedEvent{
14 {RepoPath: "alice/app", Actor: "alice", Kind: "build.success",
15 Data: `{"number":1,"job":"unit","sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}`},
16 {RepoPath: "alice/app", Actor: "alice", Kind: "build.failure",
17 Data: `{"number":2,"job":"lint","sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}`},
18 }
19 lines := feedLines(events)
20 if len(lines) != 1 {
21 t.Fatalf("feedLines returned %d lines, want 1: %+v", len(lines), lines)
22 }
23 l := lines[0]
24 if l.State != "failure" {
25 t.Errorf("State = %q, want failure", l.State)
26 }
27 if l.Verb != "ran 2 jobs on" {
28 t.Errorf("Verb = %q, want %q", l.Verb, "ran 2 jobs on")
29 }
30 if l.Ref != "aaaaaaaaaa" {
31 t.Errorf("Ref = %q, want short sha", l.Ref)
32 }
33 if l.URL != "/alice/app/commit/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" {
34 t.Errorf("URL = %q", l.URL)
35 }
36 if len(l.Jobs) != 2 || l.Jobs[0] != "unit" || l.Jobs[1] != "lint" {
37 t.Errorf("Jobs = %+v", l.Jobs)
38 }
39}
40
41// A single build event with a sha still gets a State, and keeps its
42// ordinary verb/ref/url — a run of one job reads the same as before.
43func TestFeedLinesSingleBuildGetsState(t *testing.T) {
44 events := []store.FeedEvent{
45 {RepoPath: "alice/app", Actor: "alice", Kind: "build.success",
46 Data: `{"number":1,"job":"unit","sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}`},
47 }
48 lines := feedLines(events)
49 if len(lines) != 1 {
50 t.Fatalf("feedLines returned %d lines, want 1", len(lines))
51 }
52 l := lines[0]
53 if l.State != "success" {
54 t.Errorf("State = %q, want success", l.State)
55 }
56 if l.Verb != "build success" || l.Ref != "unit" || l.URL != "/alice/app/builds/1" {
57 t.Errorf("single build line changed shape: %+v", l)
58 }
59}
60
61// Two different commits never fold, even back to back.
62func TestFeedLinesDoesNotFoldAcrossDifferentSHAs(t *testing.T) {
63 events := []store.FeedEvent{
64 {RepoPath: "alice/app", Actor: "alice", Kind: "build.success",
65 Data: `{"number":1,"job":"unit","sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}`},
66 {RepoPath: "alice/app", Actor: "alice", Kind: "build.success",
67 Data: `{"number":2,"job":"lint","sha":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}`},
68 }
69 lines := feedLines(events)
70 if len(lines) != 2 {
71 t.Fatalf("feedLines returned %d lines, want 2: %+v", len(lines), lines)
72 }
73}
74
75// An event with no sha (an older event recorded before this field existed)
76// never folds into anything, even when it shares a repo with an adjacent
77// build event.
78func TestFeedLinesNoSHANeverFolds(t *testing.T) {
79 events := []store.FeedEvent{
80 {RepoPath: "alice/app", Actor: "alice", Kind: "build.success", Data: `{"number":1,"job":"unit"}`},
81 {RepoPath: "alice/app", Actor: "alice", Kind: "build.success", Data: `{"number":2,"job":"lint"}`},
82 }
83 lines := feedLines(events)
84 if len(lines) != 2 {
85 t.Fatalf("feedLines returned %d lines, want 2: %+v", len(lines), lines)
86 }
87 if lines[0].State != "" || lines[1].State != "" {
88 t.Errorf("shaless build lines got a State: %+v", lines)
89 }
90}
91
92// A non-build event between two builds of the same commit breaks the
93// fold: only adjacent build events on the same commit combine.
94func TestFeedLinesNonBuildEventBreaksFold(t *testing.T) {
95 events := []store.FeedEvent{
96 {RepoPath: "alice/app", Actor: "alice", Kind: "build.success",
97 Data: `{"number":1,"job":"unit","sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}`},
98 {RepoPath: "alice/app", Actor: "bob", Kind: "issue.created", Data: `{"number":1}`},
99 {RepoPath: "alice/app", Actor: "alice", Kind: "build.failure",
100 Data: `{"number":2,"job":"lint","sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}`},
101 }
102 lines := feedLines(events)
103 if len(lines) != 3 {
104 t.Fatalf("feedLines returned %d lines, want 3: %+v", len(lines), lines)
105 }
106}
107
108// worstStatus governs the run's combined State the same way it governs
109// combinedStatus for the builds tab.
110func TestFeedLinesRunStatePrecedence(t *testing.T) {
111 cases := []struct {
112 statuses []string
113 want string
114 }{
115 {[]string{"success", "success"}, "success"},
116 {[]string{"success", "pending"}, "pending"},
117 {[]string{"pending", "running"}, "running"},
118 {[]string{"running", "cancelled"}, "cancelled"},
119 {[]string{"cancelled", "failure"}, "failure"},
120 }
121 for _, tc := range cases {
122 events := make([]store.FeedEvent, len(tc.statuses))
123 for i, s := range tc.statuses {
124 events[i] = store.FeedEvent{RepoPath: "alice/app", Actor: "alice", Kind: "build." + s,
125 Data: `{"number":1,"job":"j","sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}`}
126 }
127 lines := feedLines(events)
128 if len(lines) != 1 || lines[0].State != tc.want {
129 t.Errorf("statuses %v: got %+v, want State %q", tc.statuses, lines, tc.want)
130 }
131 }
132}
133
134// D05: feedLines parses the stored RFC3339 timestamp into WhenT for the
135// template's relative-time rendering; an unparseable value leaves it zero
136// rather than panicking or guessing.
137func TestFeedLinesParsesWhenT(t *testing.T) {
138 events := []store.FeedEvent{
139 {RepoPath: "alice/app", Actor: "alice", Kind: "issue.created",
140 Data: `{"number":1}`, CreatedAt: "2026-09-10T12:00:00Z"},
141 {RepoPath: "alice/app", Actor: "alice", Kind: "issue.created",
142 Data: `{"number":2}`, CreatedAt: "not-a-time"},
143 }
144 lines := feedLines(events)
145 want, _ := time.Parse(time.RFC3339Nano, "2026-09-10T12:00:00Z")
146 if !lines[0].WhenT.Equal(want) {
147 t.Errorf("WhenT = %v, want %v", lines[0].WhenT, want)
148 }
149 if !lines[1].WhenT.IsZero() {
150 t.Errorf("WhenT for bad timestamp = %v, want zero", lines[1].WhenT)
151 }
152 // When is preserved for anything that still reads the raw string.
153 if lines[0].When != "2026-09-10T12:00:00Z" {
154 t.Errorf("When = %q", lines[0].When)
155 }
156}
internal/web/static/style.css +38 −6
@@ -306,6 +306,18 @@ ul.raillist a[aria-current] {
306306 font-weight: 600;
307307}
308308ul.raillist .owner { color: var(--shell-muted); }
309ul.raillist a.newrepo {
310 display: inline-flex;
311 width: 28px;
312 height: 28px;
313 align-items: center;
314 justify-content: center;
315 padding: 0;
316 border: 1px solid var(--shell-line);
317 border-radius: var(--r-ctl);
318 font-size: var(--fs-3);
319 font-weight: 600;
320}
309321ul.raillist.wide a { white-space: normal; display: block; }
310322ul.raillist.wide b { display: block; font-weight: 500; font-size: var(--fs-1); }
311323ul.raillist.wide .owner { display: block; font-size: var(--fs-0); }
@@ -1461,28 +1473,48 @@ svg.icon { vertical-align: -0.125em; }
14611473 height: auto;
14621474 position: static;
14631475 flex-direction: row;
1464 align-items: center;
1476 align-items: flex-start;
14651477 gap: var(--sp-2);
14661478 border-right: 0;
14671479 border-bottom: 1px solid var(--shell-line);
14681480 overflow-x: auto;
14691481 }
1470 a.brand { padding: var(--sp-2) var(--sp-3); }
1482 a.brand { padding: var(--sp-2) var(--sp-3); min-height: 39px; display: flex; align-items: center; }
14711483 .railbody { flex: 1; overflow: visible; padding: 0; display: flex; flex-direction: column; }
14721484 .railtop { display: flex; align-items: center; gap: var(--sp-2); overflow-x: auto; }
14731485 .railtop > * { flex-shrink: 0; }
14741486 form.railsearch { margin: 0 var(--sp-2); }
14751487 form.railsearch input[type="search"] { min-width: 8rem; }
14761488 /* the strip hides the review queue, reachable from the dashboard, and
1477 shows pinned repositories as a second row of the strip */
1489 shows pinned repositories as a second, quieter row of the strip */
14781490 .railgroup { display: none; }
14791491 .railgroup.pinned {
1480 display: block;
1492 display: flex;
1493 align-items: center;
1494 gap: var(--sp-2);
1495 width: 100%;
14811496 border-top: 1px solid var(--shell-line);
14821497 padding: 0 var(--sp-2);
1498 min-height: 32px;
1499 }
1500 .railgroup.pinned .raillabel {
1501 display: block;
1502 margin: 0;
1503 font-size: var(--fs-0);
1504 color: var(--shell-muted);
1505 text-transform: uppercase;
1506 letter-spacing: .08em;
1507 flex: none;
1508 }
1509 .railgroup.pinned ul.raillist {
1510 display: flex;
1511 overflow-x: auto;
1512 margin: 0;
1513 flex: 1;
1514 min-width: 0;
14831515 }
1484 .railgroup.pinned .raillabel { display: none; }
1485 .railgroup.pinned ul.raillist { overflow-x: auto; }
1516 .railgroup.pinned ul.raillist a { font-size: var(--fs-1); padding: var(--sp-1) var(--sp-2); }
1517 .railgroup.pinned ul.raillist a .owner { display: none; }
14861518 ul.raillist { display: flex; margin: 0; }
14871519 ul.raillist a { border-radius: 0; border-bottom: 2px solid transparent; padding: var(--sp-2) var(--sp-3); }
14881520 ul.raillist a[aria-current] { box-shadow: none; border-bottom-color: var(--shell-mark); }
internal/web/templates/dashboard.html +1 −1
@@ -36,7 +36,7 @@
3636<aside class="aside">
3737 <div class="grp">
3838 <h2>Recent activity</h2>
39 {{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>
39 {{range .Feed}}<p class="row feedline">{{if eq .State "failure"}}<span class="dot bad"></span>{{else if eq .State "success"}}<span class="dot ok"></span>{{else if .State}}<span class="dot pend"></span>{{end}}<a href="/{{.Actor}}">{{.Actor}}</a> {{.Verb}} <a href="{{.URL}}"{{if .Jobs}} title="{{join .Jobs ", "}}"{{end}}>{{.Ref}}</a><br><span class="none">{{.Repo}} · <span title="{{whenT .WhenT}}">{{ago .WhenT}}</span></span></p>
4040 {{else}}<p class="none">No activity yet</p>{{end}}
4141 </div>
4242</aside>
internal/web/templates/layout.html +1 −1
@@ -25,7 +25,7 @@
2525 <li><a {{if eq (str . "Tab") "explore"}}aria-current="page" {{end}}href="/explore">Explore</a></li>
2626 {{if .Viewer}}<li><a {{if eq (str . "Tab") "notifications"}}aria-current="page" {{end}}href="/notifications">Notifications{{with .Rail.Unread}} <b class="count">{{.}}</b>{{end}}</a></li>
2727 <li><a {{if eq (str . "Tab") "bookmarks"}}aria-current="page" {{end}}href="/bookmarks">Bookmarks</a></li>
28 <li><a href="/new">New repository</a></li>{{end}}
28 <li><a class="newrepo" href="/new" aria-label="New repository" title="New repository">+</a></li>{{end}}
2929 </ul>
3030 </div>
3131 {{with .Rail.Pinned}}