A CLI-first git forge.

cli forge git self-hosted

https://gitbay.org

Commit 32a583340a

32a583340a8db2b242ed452180dd239fd118bc26

parent: e4fa403437

Verified · cmc ci/build: success

cmc <hello@cleberg.net> · 2026-08-26T03:23:40Z

web: a dashboard that answers "what needs me", badges, and author names

The dashboard leads with work waiting on the viewer: merge requests
they can review and have not, then issues assigned to them, then the
open items they are involved in. An aside carries pinned repositories
and an activity feed phrased from the event log, so the page is worth
keeping open rather than a list of everything.

Commit authors display the account name when the author address is one
an account has verified here, and their own name otherwise, so a repo
reads in the forge's names rather than whatever git config carried.

Repositories gain a build status badge at /badge/build.svg for public
repos, with ?job= to name one; private repositories 404 like every
other surface, so a badge cannot probe for them.

The repo tab strip scrolled vertically on touch because a single
overflow axis makes the other compute to auto; it now scrolls sideways
only.

Ref #35
e2e/accounts_test.go +3 −1
@@ -87,7 +87,9 @@ func TestWebAccounts(t *testing.T) {
8787
8888 browser := newBrowser(t)
8989 status, body := browserGet(t, browser, inst.base()+loginPath)
90 if status != 200 || !strings.Contains(body, `logged in as <a href="/alice">alice</a>`) {
90 // The rail's footer carries the signed-in account now.
91 if status != 200 || !strings.Contains(body, ">Dashboard</h1>") ||
92 !strings.Contains(body, `class="railuser" href="/alice"`) {
9193 t.Fatalf("login redirect landed wrong: %d\n%s", status, body)
9294 }
9395
e2e/badge_test.go added +34
@@ -0,0 +1,34 @@
1package e2e
2
3import (
4 "strings"
5 "testing"
6)
7
8// TestBuildBadge covers the badge endpoint: it reports the newest build,
9// says "unknown" before any build exists, and 404s for a private repo so
10// it cannot be used to probe for one.
11func TestBuildBadge(t *testing.T) {
12 inst := startInstance(t)
13 aliceKey := inst.newKey(t, "alice")
14 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub")
15 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 {
16 t.Fatalf("repo create: %s", errOut)
17 }
18
19 status, body := inst.get(t, "/alice/app/badge/build.svg")
20 if status != 200 || !strings.Contains(body, "unknown") || !strings.Contains(body, "<svg") {
21 t.Fatalf("badge before any build: %d\n%s", status, body)
22 }
23
24 // Private repositories have no badge, like every other surface.
25 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/vault", "--private"); code != 0 {
26 t.Fatal("private repo create failed")
27 }
28 if status, _ := inst.get(t, "/alice/vault/badge/build.svg"); status != 404 {
29 t.Fatalf("private badge: %d", status)
30 }
31 if status, _ := inst.get(t, "/alice/nope/badge/build.svg"); status != 404 {
32 t.Fatalf("missing repo badge: %d", status)
33 }
34}
e2e/dashboard_test.go +78 −3
@@ -89,12 +89,13 @@ func TestDashboard(t *testing.T) {
8989 t.Fatalf("login: %d", status)
9090 }
9191 status, body = browserGet(t, browser, inst.base()+"/")
92 if status != 200 || !strings.Contains(body, `logged in as <a href="/alice">alice</a>`) {
92 if status != 200 || !strings.Contains(body, ">Dashboard</h1>") {
9393 t.Fatalf("dashboard: %d", status)
9494 }
9595 for _, want := range []string{
96 ">Pinned</h2>", ">app<", // pinned card
97 "alice/app!1 from bob", "alice/app#1 todo one",
96 ">Pinned</h2>", ">app<", // pinned group in the aside
97 "Waiting on your review", "Assigned to you", "Recent activity",
98 "from bob", "alice/app!1", "todo one", "alice/app#1",
9899 `href="/alice/app/mrs/1"`, `href="/alice/app/issues/1"`,
99100 } {
100101 if !strings.Contains(body, want) {
@@ -117,3 +118,77 @@ func TestDashboard(t *testing.T) {
117118 t.Fatalf("diff view missing stat or patch:\n%s", body)
118119 }
119120 }
121
122// TestDashboardQueues covers the parts of the dashboard that answer "what
123// needs me": the review queue, assigned issues, and the activity feed.
124func TestDashboardQueues(t *testing.T) {
125 inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n")
126 aliceKey := inst.newKey(t, "alice")
127 bobKey := inst.newKey(t, "bob")
128 inst.admin(t, "admin", "user", "create", "alice",
129 "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
130 inst.admin(t, "admin", "user", "create", "bob",
131 "--key", bobKey+".pub", "--email", "bob@example.test", "--verified")
132
133 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 {
134 t.Fatalf("repo create: %s", errOut)
135 }
136 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "access", "grant", "alice/app", "bob", "write"); code != 0 {
137 t.Fatalf("grant: %s", errOut)
138 }
139 env := inst.gitEnv(aliceKey)
140 work := t.TempDir()
141 mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w")
142 dir := filepath.Join(work, "w")
143 os.WriteFile(filepath.Join(dir, "a.txt"), []byte("a\n"), 0o644)
144 mustGit(t, dir, env, "checkout", "-q", "-b", "main")
145 mustGit(t, dir, env, "add", ".")
146 mustGit(t, dir, env, "commit", "-q", "-m", "base")
147 mustGit(t, dir, env, "push", "-q", "origin", "main")
148
149 // Bob opens a merge request: it lands in alice's review queue.
150 bobEnv := inst.gitEnv(bobKey)
151 bobWork := t.TempDir()
152 mustGit(t, bobWork, bobEnv, "clone", inst.sshURL("alice/app"), "w")
153 bobDir := filepath.Join(bobWork, "w")
154 mustGit(t, bobDir, bobEnv, "checkout", "-q", "-b", "fix", "origin/main")
155 os.WriteFile(filepath.Join(bobDir, "b.txt"), []byte("b\n"), 0o644)
156 mustGit(t, bobDir, bobEnv, "add", ".")
157 mustGit(t, bobDir, bobEnv, "commit", "-q", "-m", "the fix")
158 mustGit(t, bobDir, bobEnv, "push", "-q", "origin", "fix")
159 if _, errOut, code := inst.ssh(t, bobKey, "", "mr", "create", "alice/app",
160 "--source", "fix", "--target", "main", "--title", "'needs a look'"); code != 0 {
161 t.Fatalf("mr create: %s", errOut)
162 }
163 // And an issue assigned to alice.
164 if _, errOut, code := inst.ssh(t, bobKey, "", "issue", "create", "alice/app", "--title", "'please handle'"); code != 0 {
165 t.Fatalf("issue create: %s", errOut)
166 }
167 if _, errOut, code := inst.ssh(t, aliceKey, "", "issue", "assign", "alice/app", "1", "--add", "alice"); code != 0 {
168 t.Fatalf("assign: %s", errOut)
169 }
170
171 _, body := browserGet(t, inst.login(t, aliceKey), inst.base()+"/")
172 if !strings.Contains(body, "needs a look") {
173 t.Fatalf("review queue missing the MR:\n%s", body)
174 }
175 if !strings.Contains(body, "please handle") {
176 t.Fatalf("assigned issues missing the issue:\n%s", body)
177 }
178 // The feed reports what happened, phrased and linked.
179 for _, want := range []string{"opened merge request", "opened issue", `href="/alice/app/mrs/1"`} {
180 if !strings.Contains(body, want) {
181 t.Fatalf("feed missing %q:\n%s", want, body)
182 }
183 }
184
185 // Once alice reviews, the merge request leaves her queue.
186 if _, errOut, code := inst.ssh(t, aliceKey, "", "mr", "review", "alice/app", "1", "--approve"); code != 0 {
187 t.Fatalf("review: %s", errOut)
188 }
189 _, after := browserGet(t, inst.login(t, aliceKey), inst.base()+"/")
190 queue := after[strings.Index(after, "Waiting on your review"):strings.Index(after, "Assigned to you")]
191 if strings.Contains(queue, "needs a look") {
192 t.Fatalf("reviewed MR still waiting:\n%s", queue)
193 }
194}
e2e/design_test.go +47 −1
@@ -189,7 +189,7 @@ func TestWebInteractions(t *testing.T) {
189189 if !strings.Contains(body, "★ Pinned") {
190190 t.Fatal("repo header not pinned")
191191 }
192 if _, body = browserGet(t, browser, inst.base()+"/"); !strings.Contains(body, "theorg<span") {
192 if _, body = browserGet(t, browser, inst.base()+"/"); !strings.Contains(body, ">theorg/</span>webborn") {
193193 t.Fatal("dashboard missing pinned repo")
194194 }
195195 browserPost(t, browser, inst.base()+"/theorg/webborn/pin", url.Values{})
@@ -247,3 +247,49 @@ func TestCommitParentLinks(t *testing.T) {
247247 t.Fatal("root commit shows a parent")
248248 }
249249 }
250
251// TestAuthorNamesResolve checks that a commit whose author address is
252// verified here displays the account's name rather than whatever git
253// config carried, and that an unknown address keeps its own name.
254func TestAuthorNamesResolve(t *testing.T) {
255 inst := startInstance(t)
256 aliceKey := inst.newKey(t, "alice")
257 inst.admin(t, "admin", "user", "create", "alice",
258 "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
259 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 {
260 t.Fatalf("repo create: %s", errOut)
261 }
262 work := t.TempDir()
263 env := inst.gitEnv(aliceKey)
264 mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w")
265 dir := filepath.Join(work, "w")
266
267 // One commit from the account's verified address under a different
268 // display name, one from an address nobody has proven.
269 known := append(append([]string{}, env...),
270 "GIT_AUTHOR_NAME=Alice Q. Longname", "GIT_AUTHOR_EMAIL=alice@example.test",
271 "GIT_COMMITTER_NAME=Alice Q. Longname", "GIT_COMMITTER_EMAIL=alice@example.test")
272 os.WriteFile(filepath.Join(dir, "a.txt"), []byte("a\n"), 0o644)
273 mustGit(t, dir, known, "checkout", "-q", "-b", "main")
274 mustGit(t, dir, known, "add", ".")
275 mustGit(t, dir, known, "commit", "-q", "-m", "from the account")
276 stranger := append(append([]string{}, env...),
277 "GIT_AUTHOR_NAME=Outside Person", "GIT_AUTHOR_EMAIL=outside@nowhere.test",
278 "GIT_COMMITTER_NAME=Outside Person", "GIT_COMMITTER_EMAIL=outside@nowhere.test")
279 os.WriteFile(filepath.Join(dir, "b.txt"), []byte("b\n"), 0o644)
280 mustGit(t, dir, stranger, "add", ".")
281 mustGit(t, dir, stranger, "commit", "-q", "-m", "from a stranger")
282 mustGit(t, dir, known, "push", "-q", "origin", "main")
283
284 // The log shows the account name for the verified address only.
285 _, body := inst.get(t, "/alice/app/log")
286 if strings.Contains(body, "Alice Q. Longname") {
287 t.Fatalf("log showed the git config name for a known address:\n%s", body)
288 }
289 if !strings.Contains(body, "Outside Person") {
290 t.Fatalf("log lost an unknown author's name:\n%s", body)
291 }
292 if !strings.Contains(body, "alice") {
293 t.Fatalf("log missing the account name:\n%s", body)
294 }
295}
internal/gitutil/lastcommit.go +8 −7
@@ -13,6 +13,7 @@ type EntryCommit struct {
1313 SHA string
1414 Subject string
1515 Author string
16 Email string
1617 When time.Time
1718 }
1819
@@ -44,7 +45,7 @@ func LastCommits(dir, ref, path string, names []string) map[string]EntryCommit {
4445 }
4546
4647 args := []string{"-C", dir, "log", "--first-parent", "--name-only",
47 "--format=%x1e%H%x1f%ct%x1f%an%x1f%s", "-n", strconv.Itoa(lastCommitScan), ref}
48 "--format=%x1e%H%x1f%ct%x1f%an%x1f%ae%x1f%s", "-n", strconv.Itoa(lastCommitScan), ref}
4849 if prefix != "" {
4950 args = append(args, "--", strings.TrimSuffix(prefix, "/"))
5051 }
@@ -89,14 +90,14 @@ func LastCommits(dir, ref, path string, names []string) map[string]EntryCommit {
8990 return out
9091 }
9192
92// parseCommitHeader reads sha, commit time, author, and subject, unit
93// separated so a subject containing spaces stays intact.
93// parseCommitHeader reads sha, commit time, author name and address, and
94// subject, unit separated so a subject containing spaces stays intact.
9495 func parseCommitHeader(s string) EntryCommit {
95 f := strings.SplitN(s, "\x1f", 4)
96 if len(f) != 4 {
96 f := strings.SplitN(s, "\x1f", 5)
97 if len(f) != 5 {
9798 return EntryCommit{}
9899 }
99 c := EntryCommit{SHA: f[0], Author: f[2], Subject: f[3]}
100 c := EntryCommit{SHA: f[0], Author: f[2], Email: f[3], Subject: f[4]}
100101 if n, err := strconv.ParseInt(f[1], 10, 64); err == nil {
101102 c.When = time.Unix(n, 0).UTC()
102103 }
@@ -107,7 +108,7 @@ func parseCommitHeader(s string) EntryCommit {
107108 // answers "who touched this repository last".
108109 func TipCommit(dir, ref string) EntryCommit {
109110 out, err := exec.Command("git", "-C", dir, "log", "-1",
110 "--format=%H%x1f%ct%x1f%an%x1f%s", ref).Output()
111 "--format=%H%x1f%ct%x1f%an%x1f%ae%x1f%s", ref).Output()
111112 if err != nil {
112113 return EntryCommit{}
113114 }
internal/httpd/badge.go added +89
@@ -0,0 +1,89 @@
1package httpd
2
3import (
4 "fmt"
5 "net/http"
6 "strings"
7)
8
9// Status badges. A badge is a small SVG served for public repositories,
10// so a README on any host can show whether the latest build passed.
11// Private repositories 404 like everywhere else — a badge must not leak
12// that a repo exists, let alone its state.
13
14// badgeColors are the shield fills per build state.
15var badgeColors = map[string]string{
16 "success": "#2da44e",
17 "failure": "#cf222e",
18 "running": "#bf8700",
19 "pending": "#bf8700",
20 "unknown": "#6b7280",
21}
22
23// badgeWidth approximates Verdana 11px advance so the pill fits its text
24// without shipping a font metric table.
25func badgeWidth(s string) int {
26 w := 0
27 for _, r := range s {
28 switch {
29 case strings.ContainsRune("iljtIJ1.,:;'", r):
30 w += 4
31 case strings.ContainsRune("mwMW", r):
32 w += 10
33 case r >= 'A' && r <= 'Z':
34 w += 8
35 default:
36 w += 7
37 }
38 }
39 return w + 10
40}
41
42// badgeSVG renders a two-part pill: a grey label and a coloured state.
43func badgeSVG(label, state string) string {
44 color := badgeColors[state]
45 if color == "" {
46 color = badgeColors["unknown"]
47 }
48 lw, sw := badgeWidth(label), badgeWidth(state)
49 total := lw + sw
50 return fmt.Sprintf(`<svg xmlns="http://www.w3.org/2000/svg" width="%d" height="20" role="img" aria-label="%s: %s">
51<title>%s: %s</title>
52<rect width="%d" height="20" rx="3" fill="#444d56"/>
53<rect x="%d" width="%d" height="20" rx="3" fill="%s"/>
54<rect x="%d" width="4" height="20" fill="%s"/>
55<g fill="#fff" text-anchor="middle" font-family="Verdana,DejaVu Sans,sans-serif" font-size="11">
56<text x="%d" y="14">%s</text>
57<text x="%d" y="14">%s</text>
58</g>
59</svg>`, total, label, state, label, state,
60 total, lw, sw, color, lw, color, lw/2, label, lw+sw/2, state)
61}
62
63// buildBadge answers GET /{owner}/{repo}/badge/build.svg[?job=name].
64func (s *Server) buildBadge(w http.ResponseWriter, r *http.Request) {
65 repo, ok := s.publicRepo(r.PathValue("owner"), strings.TrimSuffix(r.PathValue("repo"), ".git"))
66 if !ok {
67 http.NotFound(w, r)
68 return
69 }
70 job := r.URL.Query().Get("job")
71 label := "build"
72 if job != "" {
73 label = job
74 }
75 state := "unknown"
76 if b, err := s.st.LatestBuild(repo.ID, job); err == nil {
77 state = b.Status
78 }
79 writeBadge(w, label, state)
80}
81
82func writeBadge(w http.ResponseWriter, label, state string) {
83 w.Header().Set("Content-Type", "image/svg+xml; charset=utf-8")
84 // Badges are read by other people's caches; keep them briefly fresh
85 // rather than pinned to a stale result.
86 w.Header().Set("Cache-Control", "max-age=60, must-revalidate")
87 w.Header().Set("X-Content-Type-Options", "nosniff")
88 fmt.Fprint(w, badgeSVG(label, state))
89}
internal/httpd/control.go +65
@@ -6,6 +6,7 @@ import (
66 "strings"
77
88 "gitbay.org/gitbay/internal/control"
9 "gitbay.org/gitbay/internal/gitutil"
910 "gitbay.org/gitbay/internal/protocol"
1011 "gitbay.org/gitbay/internal/store"
1112 )
@@ -73,3 +74,67 @@ func (s *Server) runControlJSON(u store.User, argv []string) (data map[string]an
7374 }
7475 return env.Data, "", true
7576 }
77
78// authorNames maps commit author addresses to account names for one
79// request. A commit carries whatever name git was configured with; when
80// the address is a verified address here, the account's own name is the
81// truthful one to show, and it links somewhere.
82type authorNames struct {
83 st *store.Store
84 cache map[string]string
85}
86
87func (s *Server) authorNames() *authorNames {
88 return &authorNames{st: s.st, cache: map[string]string{}}
89}
90
91// name returns the account name for an address, or the commit's own
92// author name when no account has verified it.
93func (a *authorNames) name(email, fallback string) string {
94 if email == "" {
95 return fallback
96 }
97 if got, ok := a.cache[email]; ok {
98 if got == "" {
99 return fallback
100 }
101 return got
102 }
103 name, _ := a.st.UsernameByVerifiedEmail(email)
104 a.cache[email] = name
105 if name == "" {
106 return fallback
107 }
108 return name
109}
110
111// known reports whether the address belongs to an account, so callers can
112// decide to link the name.
113func (a *authorNames) known(email string) bool {
114 if email == "" {
115 return false
116 }
117 if got, ok := a.cache[email]; ok {
118 return got != ""
119 }
120 name, _ := a.st.UsernameByVerifiedEmail(email)
121 a.cache[email] = name
122 return name != ""
123}
124
125// namedCommits rewrites listing authors to account names where the
126// address is verified here.
127func (s *Server) namedCommits(m map[string]gitutil.EntryCommit) map[string]gitutil.EntryCommit {
128 names := s.authorNames()
129 for k, c := range m {
130 c.Author = names.name(c.Email, c.Author)
131 m[k] = c
132 }
133 return m
134}
135
136// namedTip does the same for the single commit above a tree listing.
137func (s *Server) namedTip(c gitutil.EntryCommit) gitutil.EntryCommit {
138 c.Author = s.authorNames().name(c.Email, c.Author)
139 return c
140}
internal/httpd/feed.go added +89
@@ -0,0 +1,89 @@
1package httpd
2
3import (
4 "encoding/json"
5 "fmt"
6 "strings"
7
8 "gitbay.org/gitbay/internal/store"
9)
10
11// feedLine is one activity entry, already phrased and linked.
12type feedLine struct {
13 Actor string
14 Verb string // "opened issue", "merged"
15 Ref string // "#12", "!35", "v0.4.0"
16 Repo string
17 URL string
18 When string
19}
20
21// feedLines turns stored events into readable lines. An unknown kind
22// still shows: the feed says what happened even for events added later.
23func feedLines(events []store.FeedEvent) []feedLine {
24 out := make([]feedLine, 0, len(events))
25 for _, e := range events {
26 var d struct {
27 Number int64 `json:"number"`
28 Job string `json:"job"`
29 Tag string `json:"tag"`
30 }
31 json.Unmarshal([]byte(e.Data), &d)
32
33 l := feedLine{Actor: e.Actor, Repo: e.RepoPath, When: e.CreatedAt}
34 if l.Actor == "" {
35 l.Actor = "gitbay"
36 }
37 kind, rest, _ := strings.Cut(e.Kind, ".")
38 switch kind {
39 case "issue":
40 l.Verb, l.Ref = issueVerb(rest), fmt.Sprintf("#%d", d.Number)
41 l.URL = fmt.Sprintf("/%s/issues/%d", e.RepoPath, d.Number)
42 case "mr":
43 l.Verb, l.Ref = mrVerb(rest), fmt.Sprintf("!%d", d.Number)
44 l.URL = fmt.Sprintf("/%s/mrs/%d", e.RepoPath, d.Number)
45 case "build":
46 l.Verb, l.Ref = "build "+rest, d.Job
47 l.URL = fmt.Sprintf("/%s/builds/%d", e.RepoPath, d.Number)
48 case "release":
49 l.Verb, l.Ref = "released", d.Tag
50 l.URL = fmt.Sprintf("/%s/releases", e.RepoPath)
51 case "repo":
52 l.Verb = "repository " + rest
53 l.URL = "/" + e.RepoPath
54 default:
55 l.Verb = e.Kind
56 l.URL = "/" + e.RepoPath
57 }
58 out = append(out, l)
59 }
60 return out
61}
62
63func issueVerb(s string) string {
64 switch s {
65 case "created":
66 return "opened issue"
67 case "closed":
68 return "closed issue"
69 case "reopened":
70 return "reopened issue"
71 case "commented":
72 return "commented on"
73 }
74 return "issue " + s
75}
76
77func mrVerb(s string) string {
78 switch s {
79 case "created":
80 return "opened merge request"
81 case "merged":
82 return "merged"
83 case "commented":
84 return "commented on"
85 case "closed":
86 return "closed merge request"
87 }
88 return "merge request " + s
89}
internal/httpd/routes.go +1
@@ -58,6 +58,7 @@ func (s *Server) Routes() []Route {
5858 Route{Method: "GET", Pattern: "/{owner}/{repo}/wiki/{page}", Handler: s.wiki},
5959 Route{Method: "GET", Pattern: "/{owner}/{repo}/releases", Handler: s.releases},
6060 Route{Method: "GET", Pattern: "/{owner}/{repo}/builds", Handler: s.builds},
61 Route{Method: "GET", Pattern: "/{owner}/{repo}/badge/build.svg", Handler: s.buildBadge},
6162 Route{Method: "GET", Pattern: "/{owner}/{repo}/builds/{n}", Handler: s.build},
6263 Route{Method: "GET", Pattern: "/{owner}/{repo}/releases/download/{tag}/{name}", Handler: s.releaseAsset},
6364 Route{Method: "GET", Pattern: "/{owner}/{repo}/raw/{ref}/{path...}", Handler: s.raw},
internal/httpd/web.go +17 −9
@@ -151,12 +151,18 @@ func (s *Server) dashboard(w http.ResponseWriter, r *http.Request, viewer store.
151151 }
152152 mrs, _ := s.st.DashboardMRs(viewer.ID)
153153 issues, _ := s.st.DashboardIssues(viewer.ID)
154 reviews, _ := s.st.ReviewQueue(viewer.ID)
155 assigned, _ := s.st.AssignedIssues(viewer.ID)
156 events, _ := s.st.RecentEvents(viewer.ID, 20)
154157 s.render(w, "dashboard.html", struct {
155158 basePage
156 Pinned []store.Repo
157 MRs []store.DashboardItem
158 Issues []store.DashboardItem
159 }{s.baseFor(viewer), visible, mrs, issues})
159 Pinned []store.Repo
160 Reviews []store.DashboardItem
161 Assigned []store.DashboardItem
162 MRs []store.DashboardItem
163 Issues []store.DashboardItem
164 Feed []feedLine
165 }{s.baseFor(viewer), visible, reviews, assigned, mrs, issues, feedLines(events)})
160166 }
161167
162168 func (s *Server) explore(w http.ResponseWriter, r *http.Request) {
@@ -477,8 +483,8 @@ func (s *Server) renderTree(w http.ResponseWriter, r *http.Request, p repoPage,
477483 Tip gitutil.EntryCommit
478484 }{p, crumbs(p, "tree", dirPath), prefix, dirPath, "tree", entries, branches,
479485 readmeName, readmeHTML,
480 gitutil.LastCommits(p.Dir, p.Ref, dirPath, names),
481 gitutil.TipCommit(p.Dir, p.Ref)})
486 s.namedCommits(gitutil.LastCommits(p.Dir, p.Ref, dirPath, names)),
487 s.namedTip(gitutil.TipCommit(p.Dir, p.Ref))})
482488 }
483489
484490 func (s *Server) blob(w http.ResponseWriter, r *http.Request) {
@@ -1198,13 +1204,14 @@ func (s *Server) log(w http.ResponseWriter, r *http.Request) {
11981204 SHA, ShortSHA, Subject, AuthorName, AuthorEmail, Date string
11991205 Sig sigView
12001206 }
1207 names := s.authorNames()
12011208 var rows []row
12021209 for _, sha := range shas {
12031210 v, parsed := s.sigFor(p.Repo, p.Dir, sha)
12041211 rw := row{SHA: sha, ShortSHA: sha[:10], Sig: v}
12051212 if parsed != nil {
12061213 rw.Subject = parsed.Subject
1207 rw.AuthorName = parsed.AuthorName
1214 rw.AuthorName = names.name(parsed.AuthorEmail, parsed.AuthorName)
12081215 rw.AuthorEmail = parsed.AuthorEmail
12091216 rw.Date = time.Unix(parsed.AuthorUnix, 0).UTC().Format("2006-01-02")
12101217 }
@@ -1253,7 +1260,7 @@ func (s *Server) commit(w http.ResponseWriter, r *http.Request) {
12531260 Sig sigView
12541261 Checks []store.CommitStatus
12551262 DiffLines []diffLine
1256 }{p, full, full[:10], parsed.AuthorName, parsed.AuthorEmail, committerEmail,
1263 }{p, full, full[:10], s.authorNames().name(parsed.AuthorEmail, parsed.AuthorName), parsed.AuthorEmail, committerEmail,
12571264 time.Unix(parsed.AuthorUnix, 0).UTC().Format(time.RFC3339), msg,
12581265 gitutil.Parents(p.Dir, full), v, checks, lines})
12591266 }
@@ -1478,6 +1485,7 @@ func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
14781485 SHA, ShortSHA, Subject, AuthorName, Date string
14791486 Sig sigView
14801487 }
1488 mrNames := s.authorNames()
14811489 var commits []commitRow
14821490 if base != "" {
14831491 const maxMRCommits = 100
@@ -1490,7 +1498,7 @@ func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
14901498 cr := commitRow{SHA: sha, ShortSHA: sha[:10], Sig: v}
14911499 if parsed != nil {
14921500 cr.Subject = parsed.Subject
1493 cr.AuthorName = parsed.AuthorName
1501 cr.AuthorName = mrNames.name(parsed.AuthorEmail, parsed.AuthorName)
14941502 cr.Date = time.Unix(parsed.AuthorUnix, 0).UTC().Format("2006-01-02")
14951503 }
14961504 commits = append(commits, cr)
internal/store/activity.go +14
@@ -18,6 +18,20 @@ func (s *Store) UserIDByVerifiedEmail(address string) (int64, bool) {
1818 return id, true
1919 }
2020
21// UsernameByVerifiedEmail resolves a commit author address to the account
22// that has proven it, so the forge can show its own name for a person
23// rather than whatever git config happened to be set.
24func (s *Store) UsernameByVerifiedEmail(address string) (string, bool) {
25 var name string
26 err := s.DB.QueryRow(`
27 SELECT u.username FROM emails e JOIN users u ON u.id = e.user_id
28 WHERE e.address = ? AND e.verified_at IS NOT NULL`, address).Scan(&name)
29 if err != nil {
30 return "", false
31 }
32 return name, true
33}
34
2135 // RecordCommitActivity is idempotent per (repo, sha); it reports whether
2236 // this call recorded a new row.
2337 func (s *Store) RecordCommitActivity(repoID int64, sha string, userID int64, day string) bool {
internal/store/builds.go +17
@@ -147,3 +147,20 @@ func (s *Store) BuildLog(id int64) ([]byte, error) {
147147 }
148148 return log, err
149149 }
150
151// LatestBuild returns the newest build for a repo, optionally narrowed to
152// one job. It is what a status badge reports.
153func (s *Store) LatestBuild(repoID int64, job string) (Build, error) {
154 q := buildSelect + " WHERE repo_id = ?"
155 args := []any{repoID}
156 if job != "" {
157 q += " AND job = ?"
158 args = append(args, job)
159 }
160 q += " ORDER BY number DESC LIMIT 1"
161 b, err := scanBuild(s.DB.QueryRow(q, args...))
162 if errors.Is(err, sql.ErrNoRows) {
163 return b, ErrNotFound
164 }
165 return b, err
166}
internal/store/dashboard.go +66
@@ -149,3 +149,69 @@ func (s *Store) OpenCounts(repoID int64) (issues, mrs int) {
149149 repoID).Scan(&mrs)
150150 return
151151 }
152
153// AssignedIssues returns open issues assigned to the user, wherever they
154// live. Assignment is a direct request for someone's attention, so it is
155// not narrowed by the involvement rule the other lists use.
156func (s *Store) AssignedIssues(userID int64) ([]DashboardItem, error) {
157 return s.dashboardQuery(`
158 SELECT COALESCE(u.username, o.name) || '/' || r.name,
159 x.number, x.title, au.username, x.state, x.updated_at
160 FROM issues x
161 JOIN repos r ON r.id = x.repo_id
162 LEFT JOIN users u ON r.owner_kind = 'user' AND u.id = r.owner_id
163 LEFT JOIN orgs o ON r.owner_kind = 'org' AND o.id = r.owner_id
164 JOIN users au ON au.id = x.author_id
165 WHERE x.state = 'open'
166 AND EXISTS (SELECT 1 FROM issue_assignees ia
167 WHERE ia.issue_id = x.id AND ia.user_id = ?1)
168 ORDER BY x.updated_at DESC LIMIT 20`, userID)
169}
170
171// FeedEvent is one line of the dashboard's activity feed.
172type FeedEvent struct {
173 RepoPath string
174 Actor string
175 Kind string
176 Data string
177 CreatedAt string
178}
179
180// RecentEvents returns activity on repositories the user can reach. Push
181// events are excluded: they repeat what the commit lists already show.
182func (s *Store) RecentEvents(userID int64, limit int) ([]FeedEvent, error) {
183 rows, err := s.DB.Query(`
184 SELECT COALESCE(u.username, o.name) || '/' || r.name,
185 COALESCE(ac.username, ''), e.kind, e.data_json, e.created_at
186 FROM events e
187 JOIN repos r ON r.id = e.repo_id
188 LEFT JOIN users u ON r.owner_kind = 'user' AND u.id = r.owner_id
189 LEFT JOIN orgs o ON r.owner_kind = 'org' AND o.id = r.owner_id
190 LEFT JOIN users ac ON ac.id = e.actor_id
191 WHERE e.kind <> 'push' AND (
192 (r.owner_kind = 'user' AND r.owner_id = ?1)
193 OR EXISTS (SELECT 1 FROM repo_access a
194 WHERE a.repo_id = r.id AND a.subject_kind = 'user' AND a.subject_id = ?1)
195 OR EXISTS (SELECT 1 FROM org_members mm
196 JOIN orgs oo ON oo.id = mm.org_id
197 WHERE r.owner_kind = 'org' AND mm.org_id = r.owner_id AND mm.user_id = ?1
198 AND (mm.role = 'admin' OR oo.members_role <> 'none'))
199 OR EXISTS (SELECT 1 FROM team_repos tr
200 JOIN team_members tm ON tm.team_id = tr.team_id AND tm.user_id = ?1
201 WHERE tr.repo_id = r.id)
202 )
203 ORDER BY e.id DESC LIMIT ?2`, userID, limit)
204 if err != nil {
205 return nil, err
206 }
207 defer rows.Close()
208 var out []FeedEvent
209 for rows.Next() {
210 var e FeedEvent
211 if err := rows.Scan(&e.RepoPath, &e.Actor, &e.Kind, &e.Data, &e.CreatedAt); err != nil {
212 return nil, err
213 }
214 out = append(out, e)
215 }
216 return out, rows.Err()
217}
internal/web/static/style.css +3
@@ -326,6 +326,9 @@ nav.tabs {
326326 gap: var(--sp-1);
327327 margin-top: var(--sp-4);
328328 overflow-x: auto;
329 /* with overflow-y left visible the browser computes it to auto, which
330 is a vertical drag on touch; the strip scrolls sideways only. */
331 overflow-y: hidden;
329332 scrollbar-width: none;
330333 }
331334 nav.tabs::-webkit-scrollbar { display: none; }
internal/web/templates/builds.html +5
@@ -1,6 +1,11 @@
11 {{define "title"}}builds · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
22 {{define "content"}}
33 <h1>Builds</h1>
4<details class="editbox"><summary>Status badge</summary>
5<p class="meta">Paste into a README; it shows the newest build's state.</p>
6<pre class="code">[![build](https://{{.Host}}/{{.Repo.OwnerName}}/{{.Repo.Name}}/badge/build.svg)](https://{{.Host}}/{{.Repo.OwnerName}}/{{.Repo.Name}}/builds)</pre>
7<p class="meta">Add <code>?job=name</code> for one job.</p>
8</details>
49 <ul class="loglist">
510 {{range .Builds}}<li>
611 <div class="commitmain">
internal/web/templates/dashboard.html +37 −24
@@ -1,33 +1,46 @@
11 {{define "title"}}dashboard · {{.Site}}{{end}}
2{{define "content"}}
3<div class="headrow">
4<h1>Dashboard</h1>
5<span class="spacer"></span>
6<p class="toolbar">logged in as <a href="/{{.Viewer}}">{{.Viewer}}</a></p>
7</div>
8{{if .Pinned}}<h2>Pinned</h2>
9<ul class="pinlist">
10{{range .Pinned}}<li><a href="/{{.OwnerName}}/{{.Name}}">{{.OwnerName}}<span class="sep">/</span><strong>{{.Name}}</strong></a>{{if eq .Visibility "private"}} <span class="chip chip-neutral">private</span>{{end}}</li>
11{{end}}
12</ul>{{end}}
13<h2>Open merge requests <span class="count">{{len .MRs}}</span></h2>
2{{define "itemlist"}}
143 <ul class="issuelist">
15{{range .MRs}}<li>
4{{range .Items}}<li>
165 <div class="issuemain">
17 <p class="title"><a href="/{{.RepoPath}}/mrs/{{.Number}}">{{.RepoPath}}!{{.Number}} {{.Title}}</a></p>
18 <p class="meta"><a href="/{{.Author}}">{{.Author}}</a> · {{when .UpdatedAt}}{{if eq .State "source_gone"}} · <span class="chip chip-source_gone">source gone</span>{{end}}</p>
6 <p class="title"><a href="/{{.RepoPath}}/{{$.Kind}}/{{.Number}}">{{.Title}}</a></p>
7 <p class="meta">{{.RepoPath}}{{if eq $.Kind "mrs"}}!{{else}}#{{end}}{{.Number}} · <a href="/{{.Author}}">{{.Author}}</a> · {{when .UpdatedAt}}{{if eq .State "source_gone"}} · <span class="chip chip-source_gone">source gone</span>{{end}}</p>
198 </div>
209 </li>
21{{else}}<li class="empty">no open merge requests</li>{{end}}
10{{else}}<li class="empty">{{$.Empty}}</li>{{end}}
2211 </ul>
12{{end}}
13{{define "content"}}
14<h1>Dashboard</h1>
15
16<div class="withaside">
17<div class="mainside">
18
19<h2>Waiting on your review <span class="count">{{len .Reviews}}</span></h2>
20{{template "itemlist" dict "Items" .Reviews "Kind" "mrs" "Empty" "Nothing waiting on you"}}
21
22<h2>Assigned to you <span class="count">{{len .Assigned}}</span></h2>
23{{template "itemlist" dict "Items" .Assigned "Kind" "issues" "Empty" "Nothing assigned to you"}}
24
25<h2>Open merge requests <span class="count">{{len .MRs}}</span></h2>
26{{template "itemlist" dict "Items" .MRs "Kind" "mrs" "Empty" "No open merge requests"}}
27
2328 <h2>Open issues <span class="count">{{len .Issues}}</span></h2>
24<ul class="issuelist">
25{{range .Issues}}<li>
26 <div class="issuemain">
27 <p class="title"><a href="/{{.RepoPath}}/issues/{{.Number}}">{{.RepoPath}}#{{.Number}} {{.Title}}</a></p>
28 <p class="meta"><a href="/{{.Author}}">{{.Author}}</a> · {{when .UpdatedAt}}</p>
29{{template "itemlist" dict "Items" .Issues "Kind" "issues" "Empty" "No open issues"}}
30
31</div>
32
33<aside class="aside">
34 <div class="grp">
35 <h2>Pinned</h2>
36 {{range .Pinned}}<p class="row"><a href="/{{.OwnerName}}/{{.Name}}"><span class="owner">{{.OwnerName}}/</span>{{.Name}}</a>{{if eq .Visibility "private"}} <span class="chip">Private</span>{{end}}</p>
37 {{else}}<p class="none">Pin a repository to keep it here</p>{{end}}
2938 </div>
30</li>
31{{else}}<li class="empty">no open issues</li>{{end}}
32</ul>
39 <div class="grp">
40 <h2>Recent activity</h2>
41 {{range .Feed}}<p class="row feedline"><a href="/{{.Actor}}">{{.Actor}}</a> {{.Verb}} <a href="{{.URL}}">{{.Ref}}</a><br><span class="none">{{.Repo}} · {{when .When}}</span></p>
42 {{else}}<p class="none">No activity yet</p>{{end}}
43 </div>
44</aside>
45</div>
3346 {{end}}