Commit 32a583340a

32a583340a8db2b242ed452180dd239fd118bc26

parent: e4fa403437

Verified · cmc ci/build: success

cmc <hello@cleberg.net> · 2026-08-26 03:23 UTC

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

Layout: unified · split

e2e/accounts_test.go +3 −1
@@ -87,7 +87,9 @@ func TestWebAccounts(t *testing.T) {
87 87
88 browser := newBrowser(t) 88 browser := newBrowser(t)
89 status, body := browserGet(t, browser, inst.base()+loginPath) 89 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"`) {
91 t.Fatalf("login redirect landed wrong: %d\n%s", status, body) 93 t.Fatalf("login redirect landed wrong: %d\n%s", status, body)
92 } 94 }
93 95
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) {
89 t.Fatalf("login: %d", status) 89 t.Fatalf("login: %d", status)
90 } 90 }
91 status, body = browserGet(t, browser, inst.base()+"/") 91 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>") {
93 t.Fatalf("dashboard: %d", status) 93 t.Fatalf("dashboard: %d", status)
94 } 94 }
95 for _, want := range []string{ 95 for _, want := range []string{
96 ">Pinned</h2>", ">app<", // pinned card 96 ">Pinned</h2>", ">app<", // pinned group in the aside
97 "alice/app!1 from bob", "alice/app#1 todo one", 97 "Waiting on your review", "Assigned to you", "Recent activity",
98 "from bob", "alice/app!1", "todo one", "alice/app#1",
98 `href="/alice/app/mrs/1"`, `href="/alice/app/issues/1"`, 99 `href="/alice/app/mrs/1"`, `href="/alice/app/issues/1"`,
99 } { 100 } {
100 if !strings.Contains(body, want) { 101 if !strings.Contains(body, want) {
@@ -117,3 +118,77 @@ func TestDashboard(t *testing.T) {
117 t.Fatalf("diff view missing stat or patch:\n%s", body) 118 t.Fatalf("diff view missing stat or patch:\n%s", body)
118 } 119 }
119} 120}
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) {
189 if !strings.Contains(body, "★ Pinned") { 189 if !strings.Contains(body, "★ Pinned") {
190 t.Fatal("repo header not pinned") 190 t.Fatal("repo header not pinned")
191 } 191 }
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") {
193 t.Fatal("dashboard missing pinned repo") 193 t.Fatal("dashboard missing pinned repo")
194 } 194 }
195 browserPost(t, browser, inst.base()+"/theorg/webborn/pin", url.Values{}) 195 browserPost(t, browser, inst.base()+"/theorg/webborn/pin", url.Values{})
@@ -247,3 +247,49 @@ func TestCommitParentLinks(t *testing.T) {
247 t.Fatal("root commit shows a parent") 247 t.Fatal("root commit shows a parent")
248 } 248 }
249} 249}
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 {
13 SHA string 13 SHA string
14 Subject string 14 Subject string
15 Author string 15 Author string
16 Email string
16 When time.Time 17 When time.Time
17} 18}
18 19
@@ -44,7 +45,7 @@ func LastCommits(dir, ref, path string, names []string) map[string]EntryCommit {
44 } 45 }
45 46
46 args := []string{"-C", dir, "log", "--first-parent", "--name-only", 47 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}
48 if prefix != "" { 49 if prefix != "" {
49 args = append(args, "--", strings.TrimSuffix(prefix, "/")) 50 args = append(args, "--", strings.TrimSuffix(prefix, "/"))
50 } 51 }
@@ -89,14 +90,14 @@ func LastCommits(dir, ref, path string, names []string) map[string]EntryCommit {
89 return out 90 return out
90} 91}
91 92
92// parseCommitHeader reads sha, commit time, author, and subject, unit 93// parseCommitHeader reads sha, commit time, author name and address, and
93// separated so a subject containing spaces stays intact. 94// subject, unit separated so a subject containing spaces stays intact.
94func parseCommitHeader(s string) EntryCommit { 95func parseCommitHeader(s string) EntryCommit {
95 f := strings.SplitN(s, "\x1f", 4) 96 f := strings.SplitN(s, "\x1f", 5)
96 if len(f) != 4 { 97 if len(f) != 5 {
97 return EntryCommit{} 98 return EntryCommit{}
98 } 99 }
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]}
100 if n, err := strconv.ParseInt(f[1], 10, 64); err == nil { 101 if n, err := strconv.ParseInt(f[1], 10, 64); err == nil {
101 c.When = time.Unix(n, 0).UTC() 102 c.When = time.Unix(n, 0).UTC()
102 } 103 }
@@ -107,7 +108,7 @@ func parseCommitHeader(s string) EntryCommit {
107// answers "who touched this repository last". 108// answers "who touched this repository last".
108func TipCommit(dir, ref string) EntryCommit { 109func TipCommit(dir, ref string) EntryCommit {
109 out, err := exec.Command("git", "-C", dir, "log", "-1", 110 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()
111 if err != nil { 112 if err != nil {
112 return EntryCommit{} 113 return EntryCommit{}
113 } 114 }
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 (
6 "strings" 6 "strings"
7 7
8 "gitbay.org/gitbay/internal/control" 8 "gitbay.org/gitbay/internal/control"
9 "gitbay.org/gitbay/internal/gitutil"
9 "gitbay.org/gitbay/internal/protocol" 10 "gitbay.org/gitbay/internal/protocol"
10 "gitbay.org/gitbay/internal/store" 11 "gitbay.org/gitbay/internal/store"
11) 12)
@@ -73,3 +74,67 @@ func (s *Server) runControlJSON(u store.User, argv []string) (data map[string]an
73 } 74 }
74 return env.Data, "", true 75 return env.Data, "", true
75} 76}
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 {
58 Route{Method: "GET", Pattern: "/{owner}/{repo}/wiki/{page}", Handler: s.wiki}, 58 Route{Method: "GET", Pattern: "/{owner}/{repo}/wiki/{page}", Handler: s.wiki},
59 Route{Method: "GET", Pattern: "/{owner}/{repo}/releases", Handler: s.releases}, 59 Route{Method: "GET", Pattern: "/{owner}/{repo}/releases", Handler: s.releases},
60 Route{Method: "GET", Pattern: "/{owner}/{repo}/builds", Handler: s.builds}, 60 Route{Method: "GET", Pattern: "/{owner}/{repo}/builds", Handler: s.builds},
61 Route{Method: "GET", Pattern: "/{owner}/{repo}/badge/build.svg", Handler: s.buildBadge},
61 Route{Method: "GET", Pattern: "/{owner}/{repo}/builds/{n}", Handler: s.build}, 62 Route{Method: "GET", Pattern: "/{owner}/{repo}/builds/{n}", Handler: s.build},
62 Route{Method: "GET", Pattern: "/{owner}/{repo}/releases/download/{tag}/{name}", Handler: s.releaseAsset}, 63 Route{Method: "GET", Pattern: "/{owner}/{repo}/releases/download/{tag}/{name}", Handler: s.releaseAsset},
63 Route{Method: "GET", Pattern: "/{owner}/{repo}/raw/{ref}/{path...}", Handler: s.raw}, 64 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.
151 } 151 }
152 mrs, _ := s.st.DashboardMRs(viewer.ID) 152 mrs, _ := s.st.DashboardMRs(viewer.ID)
153 issues, _ := s.st.DashboardIssues(viewer.ID) 153 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)
154 s.render(w, "dashboard.html", struct { 157 s.render(w, "dashboard.html", struct {
155 basePage 158 basePage
156 Pinned []store.Repo 159 Pinned []store.Repo
157 MRs []store.DashboardItem 160 Reviews []store.DashboardItem
158 Issues []store.DashboardItem 161 Assigned []store.DashboardItem
159 }{s.baseFor(viewer), visible, mrs, issues}) 162 MRs []store.DashboardItem
163 Issues []store.DashboardItem
164 Feed []feedLine
165 }{s.baseFor(viewer), visible, reviews, assigned, mrs, issues, feedLines(events)})
160} 166}
161 167
162func (s *Server) explore(w http.ResponseWriter, r *http.Request) { 168func (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,
477 Tip gitutil.EntryCommit 483 Tip gitutil.EntryCommit
478 }{p, crumbs(p, "tree", dirPath), prefix, dirPath, "tree", entries, branches, 484 }{p, crumbs(p, "tree", dirPath), prefix, dirPath, "tree", entries, branches,
479 readmeName, readmeHTML, 485 readmeName, readmeHTML,
480 gitutil.LastCommits(p.Dir, p.Ref, dirPath, names), 486 s.namedCommits(gitutil.LastCommits(p.Dir, p.Ref, dirPath, names)),
481 gitutil.TipCommit(p.Dir, p.Ref)}) 487 s.namedTip(gitutil.TipCommit(p.Dir, p.Ref))})
482} 488}
483 489
484func (s *Server) blob(w http.ResponseWriter, r *http.Request) { 490func (s *Server) blob(w http.ResponseWriter, r *http.Request) {
@@ -1198,13 +1204,14 @@ func (s *Server) log(w http.ResponseWriter, r *http.Request) {
1198 SHA, ShortSHA, Subject, AuthorName, AuthorEmail, Date string 1204 SHA, ShortSHA, Subject, AuthorName, AuthorEmail, Date string
1199 Sig sigView 1205 Sig sigView
1200 } 1206 }
1207 names := s.authorNames()
1201 var rows []row 1208 var rows []row
1202 for _, sha := range shas { 1209 for _, sha := range shas {
1203 v, parsed := s.sigFor(p.Repo, p.Dir, sha) 1210 v, parsed := s.sigFor(p.Repo, p.Dir, sha)
1204 rw := row{SHA: sha, ShortSHA: sha[:10], Sig: v} 1211 rw := row{SHA: sha, ShortSHA: sha[:10], Sig: v}
1205 if parsed != nil { 1212 if parsed != nil {
1206 rw.Subject = parsed.Subject 1213 rw.Subject = parsed.Subject
1207 rw.AuthorName = parsed.AuthorName 1214 rw.AuthorName = names.name(parsed.AuthorEmail, parsed.AuthorName)
1208 rw.AuthorEmail = parsed.AuthorEmail 1215 rw.AuthorEmail = parsed.AuthorEmail
1209 rw.Date = time.Unix(parsed.AuthorUnix, 0).UTC().Format("2006-01-02") 1216 rw.Date = time.Unix(parsed.AuthorUnix, 0).UTC().Format("2006-01-02")
1210 } 1217 }
@@ -1253,7 +1260,7 @@ func (s *Server) commit(w http.ResponseWriter, r *http.Request) {
1253 Sig sigView 1260 Sig sigView
1254 Checks []store.CommitStatus 1261 Checks []store.CommitStatus
1255 DiffLines []diffLine 1262 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,
1257 time.Unix(parsed.AuthorUnix, 0).UTC().Format(time.RFC3339), msg, 1264 time.Unix(parsed.AuthorUnix, 0).UTC().Format(time.RFC3339), msg,
1258 gitutil.Parents(p.Dir, full), v, checks, lines}) 1265 gitutil.Parents(p.Dir, full), v, checks, lines})
1259} 1266}
@@ -1478,6 +1485,7 @@ func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
1478 SHA, ShortSHA, Subject, AuthorName, Date string 1485 SHA, ShortSHA, Subject, AuthorName, Date string
1479 Sig sigView 1486 Sig sigView
1480 } 1487 }
1488 mrNames := s.authorNames()
1481 var commits []commitRow 1489 var commits []commitRow
1482 if base != "" { 1490 if base != "" {
1483 const maxMRCommits = 100 1491 const maxMRCommits = 100
@@ -1490,7 +1498,7 @@ func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
1490 cr := commitRow{SHA: sha, ShortSHA: sha[:10], Sig: v} 1498 cr := commitRow{SHA: sha, ShortSHA: sha[:10], Sig: v}
1491 if parsed != nil { 1499 if parsed != nil {
1492 cr.Subject = parsed.Subject 1500 cr.Subject = parsed.Subject
1493 cr.AuthorName = parsed.AuthorName 1501 cr.AuthorName = mrNames.name(parsed.AuthorEmail, parsed.AuthorName)
1494 cr.Date = time.Unix(parsed.AuthorUnix, 0).UTC().Format("2006-01-02") 1502 cr.Date = time.Unix(parsed.AuthorUnix, 0).UTC().Format("2006-01-02")
1495 } 1503 }
1496 commits = append(commits, cr) 1504 commits = append(commits, cr)
internal/store/activity.go +14
@@ -18,6 +18,20 @@ func (s *Store) UserIDByVerifiedEmail(address string) (int64, bool) {
18 return id, true 18 return id, true
19} 19}
20 20
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
21// RecordCommitActivity is idempotent per (repo, sha); it reports whether 35// RecordCommitActivity is idempotent per (repo, sha); it reports whether
22// this call recorded a new row. 36// this call recorded a new row.
23func (s *Store) RecordCommitActivity(repoID int64, sha string, userID int64, day string) bool { 37func (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) {
147 } 147 }
148 return log, err 148 return log, err
149} 149}
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) {
149 repoID).Scan(&mrs) 149 repoID).Scan(&mrs)
150 return 150 return
151} 151}
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 {
326 gap: var(--sp-1); 326 gap: var(--sp-1);
327 margin-top: var(--sp-4); 327 margin-top: var(--sp-4);
328 overflow-x: auto; 328 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;
329 scrollbar-width: none; 332 scrollbar-width: none;
330} 333}
331nav.tabs::-webkit-scrollbar { display: none; } 334nav.tabs::-webkit-scrollbar { display: none; }
internal/web/templates/builds.html +5
@@ -1,6 +1,11 @@
1{{define "title"}}builds · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} 1{{define "title"}}builds · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
2{{define "content"}} 2{{define "content"}}
3<h1>Builds</h1> 3<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>
4<ul class="loglist"> 9<ul class="loglist">
5{{range .Builds}}<li> 10{{range .Builds}}<li>
6 <div class="commitmain"> 11 <div class="commitmain">
internal/web/templates/dashboard.html +37 −24
@@ -1,33 +1,46 @@
1{{define "title"}}dashboard · {{.Site}}{{end}} 1{{define "title"}}dashboard · {{.Site}}{{end}}
2{{define "content"}} 2{{define "itemlist"}}
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>
14<ul class="issuelist"> 3<ul class="issuelist">
15{{range .MRs}}<li> 4{{range .Items}}<li>
16 <div class="issuemain"> 5 <div class="issuemain">
17 <p class="title"><a href="/{{.RepoPath}}/mrs/{{.Number}}">{{.RepoPath}}!{{.Number}} {{.Title}}</a></p> 6 <p class="title"><a href="/{{.RepoPath}}/{{$.Kind}}/{{.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> 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>
19 </div> 8 </div>
20</li> 9</li>
21{{else}}<li class="empty">no open merge requests</li>{{end}} 10{{else}}<li class="empty">{{$.Empty}}</li>{{end}}
22</ul> 11</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
23<h2>Open issues <span class="count">{{len .Issues}}</span></h2> 28<h2>Open issues <span class="count">{{len .Issues}}</span></h2>
24<ul class="issuelist"> 29{{template "itemlist" dict "Items" .Issues "Kind" "issues" "Empty" "No open issues"}}
25{{range .Issues}}<li> 30
26 <div class="issuemain"> 31</div>
27 <p class="title"><a href="/{{.RepoPath}}/issues/{{.Number}}">{{.RepoPath}}#{{.Number}} {{.Title}}</a></p> 32
28 <p class="meta"><a href="/{{.Author}}">{{.Author}}</a> · {{when .UpdatedAt}}</p> 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}}
29 </div> 38 </div>
30</li> 39 <div class="grp">
31{{else}}<li class="empty">no open issues</li>{{end}} 40 <h2>Recent activity</h2>
32</ul> 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>
33{{end}} 46{{end}}