web: dashboard queues, build badges, account author names !69

merged merged by cmc on 2026-08-26 03:23 UTC · krz/gitbay:web-polish into main

16 files changed, +573 −45

Layout: unified · split

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.
9495func 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".
108109func 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
162168func (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
484490func (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.
2337func (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}
331334nav.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}}