web: link resolved author names to their profiles !70

merged merged by cmc on 2026-08-26 03:33 UTC · krz/gitbay:author-links into main

9 files changed, +60 −26

Layout: unified · split

README.org +2
@@ -1,6 +1,8 @@
1#+title: gitbay 1#+title: gitbay
2#+author: Christian Cleberg 2#+author: Christian Cleberg
3 3
4[[https://gitbay.org/krz/gitbay/builds][file:https://gitbay.org/krz/gitbay/badge/build.svg]]
5
4A CLI-first git forge. One binary, SQLite, and the system =git= — designed 6A CLI-first git forge. One binary, SQLite, and the system =git= — designed
5so the command line is the product and the web UI is a rendering of state 7so the command line is the product and the web UI is a rendering of state
6the CLI already manages. Runs at [[https://gitbay.org]]. 8the CLI already manages. Runs at [[https://gitbay.org]].
e2e/design_test.go +15
@@ -279,6 +279,10 @@ func TestAuthorNamesResolve(t *testing.T) {
279 os.WriteFile(filepath.Join(dir, "b.txt"), []byte("b\n"), 0o644) 279 os.WriteFile(filepath.Join(dir, "b.txt"), []byte("b\n"), 0o644)
280 mustGit(t, dir, stranger, "add", ".") 280 mustGit(t, dir, stranger, "add", ".")
281 mustGit(t, dir, stranger, "commit", "-q", "-m", "from a stranger") 281 mustGit(t, dir, stranger, "commit", "-q", "-m", "from a stranger")
282 // The tip is the account's, so the bar above the listing shows a link.
283 os.WriteFile(filepath.Join(dir, "c.txt"), []byte("c\n"), 0o644)
284 mustGit(t, dir, known, "add", ".")
285 mustGit(t, dir, known, "commit", "-q", "-m", "back to the account")
282 mustGit(t, dir, known, "push", "-q", "origin", "main") 286 mustGit(t, dir, known, "push", "-q", "origin", "main")
283 287
284 // The log shows the account name for the verified address only. 288 // The log shows the account name for the verified address only.
@@ -292,4 +296,15 @@ func TestAuthorNamesResolve(t *testing.T) {
292 if !strings.Contains(body, "alice") { 296 if !strings.Contains(body, "alice") {
293 t.Fatalf("log missing the account name:\n%s", body) 297 t.Fatalf("log missing the account name:\n%s", body)
294 } 298 }
299 // A resolved name links to the profile; an unknown one stays text.
300 if !strings.Contains(body, `class="authorlink" href="/alice"`) {
301 t.Fatalf("account name is not a link:\n%s", body)
302 }
303 if strings.Contains(body, `href="/Outside Person"`) {
304 t.Fatalf("unknown author was linked:\n%s", body)
305 }
306 // The tipbar resolves and links the same way when the tip is an account's.
307 if _, tree := inst.get(t, "/alice/app"); !strings.Contains(tree, `class="authorlink" href="/alice"`) {
308 t.Fatalf("tipbar name is not a link:\n%s", tree)
309 }
295} 310}
internal/httpd/control.go +23 −12
@@ -108,33 +108,44 @@ func (a *authorNames) name(email, fallback string) string {
108 return name 108 return name
109} 109}
110 110
111// known reports whether the address belongs to an account, so callers can 111// account returns the account name behind an address, if any, so callers
112// decide to link the name. 112// can link the displayed name to a profile.
113func (a *authorNames) known(email string) bool { 113func (a *authorNames) account(email string) (string, bool) {
114 if email == "" { 114 if email == "" {
115 return false 115 return "", false
116 } 116 }
117 if got, ok := a.cache[email]; ok { 117 if got, ok := a.cache[email]; ok {
118 return got != "" 118 return got, got != ""
119 } 119 }
120 name, _ := a.st.UsernameByVerifiedEmail(email) 120 name, _ := a.st.UsernameByVerifiedEmail(email)
121 a.cache[email] = name 121 a.cache[email] = name
122 return name != "" 122 return name, name != ""
123}
124
125// namedCommit is a listing commit plus the account behind its author
126// address, when there is one, so the name can link to a profile.
127type namedCommit struct {
128 gitutil.EntryCommit
129 User string
123} 130}
124 131
125// namedCommits rewrites listing authors to account names where the 132// namedCommits rewrites listing authors to account names where the
126// address is verified here. 133// address is verified here.
127func (s *Server) namedCommits(m map[string]gitutil.EntryCommit) map[string]gitutil.EntryCommit { 134func (s *Server) namedCommits(m map[string]gitutil.EntryCommit) map[string]namedCommit {
128 names := s.authorNames() 135 names := s.authorNames()
136 out := make(map[string]namedCommit, len(m))
129 for k, c := range m { 137 for k, c := range m {
138 user, _ := names.account(c.Email)
130 c.Author = names.name(c.Email, c.Author) 139 c.Author = names.name(c.Email, c.Author)
131 m[k] = c 140 out[k] = namedCommit{EntryCommit: c, User: user}
132 } 141 }
133 return m 142 return out
134} 143}
135 144
136// namedTip does the same for the single commit above a tree listing. 145// namedTip does the same for the single commit above a tree listing.
137func (s *Server) namedTip(c gitutil.EntryCommit) gitutil.EntryCommit { 146func (s *Server) namedTip(c gitutil.EntryCommit) namedCommit {
138 c.Author = s.authorNames().name(c.Email, c.Author) 147 names := s.authorNames()
139 return c 148 user, _ := names.account(c.Email)
149 c.Author = names.name(c.Email, c.Author)
150 return namedCommit{EntryCommit: c, User: user}
140} 151}
internal/httpd/web.go +14 −10
@@ -435,8 +435,8 @@ func (s *Server) renderTree(w http.ResponseWriter, r *http.Request, p repoPage,
435 Branches []gitutil.Ref 435 Branches []gitutil.Ref
436 ReadmeName string 436 ReadmeName string
437 ReadmeHTML template.HTML 437 ReadmeHTML template.HTML
438 LastCommits map[string]gitutil.EntryCommit 438 LastCommits map[string]namedCommit
439 Tip gitutil.EntryCommit 439 Tip namedCommit
440 }{repoPage: p, RefKind: "tree"}) 440 }{repoPage: p, RefKind: "tree"})
441 return 441 return
442 } 442 }
@@ -479,8 +479,8 @@ func (s *Server) renderTree(w http.ResponseWriter, r *http.Request, p repoPage,
479 Branches []gitutil.Ref 479 Branches []gitutil.Ref
480 ReadmeName string 480 ReadmeName string
481 ReadmeHTML template.HTML 481 ReadmeHTML template.HTML
482 LastCommits map[string]gitutil.EntryCommit 482 LastCommits map[string]namedCommit
483 Tip gitutil.EntryCommit 483 Tip namedCommit
484 }{p, crumbs(p, "tree", dirPath), prefix, dirPath, "tree", entries, branches, 484 }{p, crumbs(p, "tree", dirPath), prefix, dirPath, "tree", entries, branches,
485 readmeName, readmeHTML, 485 readmeName, readmeHTML,
486 s.namedCommits(gitutil.LastCommits(p.Dir, p.Ref, dirPath, names)), 486 s.namedCommits(gitutil.LastCommits(p.Dir, p.Ref, dirPath, names)),
@@ -1201,8 +1201,8 @@ func (s *Server) log(w http.ResponseWriter, r *http.Request) {
1201 shas = shas[:pageSize] 1201 shas = shas[:pageSize]
1202 } 1202 }
1203 type row struct { 1203 type row struct {
1204 SHA, ShortSHA, Subject, AuthorName, AuthorEmail, Date string 1204 SHA, ShortSHA, Subject, AuthorName, AuthorEmail, AuthorUser, Date string
1205 Sig sigView 1205 Sig sigView
1206 } 1206 }
1207 names := s.authorNames() 1207 names := s.authorNames()
1208 var rows []row 1208 var rows []row
@@ -1212,6 +1212,7 @@ func (s *Server) log(w http.ResponseWriter, r *http.Request) {
1212 if parsed != nil { 1212 if parsed != nil {
1213 rw.Subject = parsed.Subject 1213 rw.Subject = parsed.Subject
1214 rw.AuthorName = names.name(parsed.AuthorEmail, parsed.AuthorName) 1214 rw.AuthorName = names.name(parsed.AuthorEmail, parsed.AuthorName)
1215 rw.AuthorUser, _ = names.account(parsed.AuthorEmail)
1215 rw.AuthorEmail = parsed.AuthorEmail 1216 rw.AuthorEmail = parsed.AuthorEmail
1216 rw.Date = time.Unix(parsed.AuthorUnix, 0).UTC().Format("2006-01-02") 1217 rw.Date = time.Unix(parsed.AuthorUnix, 0).UTC().Format("2006-01-02")
1217 } 1218 }
@@ -1249,18 +1250,20 @@ func (s *Server) commit(w http.ResponseWriter, r *http.Request) {
1249 committerEmail = parsed.CommitterEmail 1250 committerEmail = parsed.CommitterEmail
1250 } 1251 }
1251 checks, _ := s.st.ListCommitStatuses(p.Repo.ID, full) 1252 checks, _ := s.st.ListCommitStatuses(p.Repo.ID, full)
1253 commitNames := s.authorNames()
1254 commitUser, _ := commitNames.account(parsed.AuthorEmail)
1252 msg := "" 1255 msg := ""
1253 if i := bytes.Index(parsed.Payload, []byte("\n\n")); i >= 0 { 1256 if i := bytes.Index(parsed.Payload, []byte("\n\n")); i >= 0 {
1254 msg = string(parsed.Payload[i+2:]) 1257 msg = string(parsed.Payload[i+2:])
1255 } 1258 }
1256 s.render(w, "commit.html", struct { 1259 s.render(w, "commit.html", struct {
1257 repoPage 1260 repoPage
1258 SHA, ShortSHA, AuthorName, AuthorEmail, CommitterEmail, Date, Message string 1261 SHA, ShortSHA, AuthorName, AuthorEmail, AuthorUser, CommitterEmail, Date, Message string
1259 Parents []string 1262 Parents []string
1260 Sig sigView 1263 Sig sigView
1261 Checks []store.CommitStatus 1264 Checks []store.CommitStatus
1262 DiffLines []diffLine 1265 DiffLines []diffLine
1263 }{p, full, full[:10], s.authorNames().name(parsed.AuthorEmail, parsed.AuthorName), parsed.AuthorEmail, committerEmail, 1266 }{p, full, full[:10], commitNames.name(parsed.AuthorEmail, parsed.AuthorName), parsed.AuthorEmail, commitUser, committerEmail,
1264 time.Unix(parsed.AuthorUnix, 0).UTC().Format(time.RFC3339), msg, 1267 time.Unix(parsed.AuthorUnix, 0).UTC().Format(time.RFC3339), msg,
1265 gitutil.Parents(p.Dir, full), v, checks, lines}) 1268 gitutil.Parents(p.Dir, full), v, checks, lines})
1266} 1269}
@@ -1482,8 +1485,8 @@ func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
1482 } 1485 }
1483 // The commits this MR carries: base..head, the same range as the diff. 1486 // The commits this MR carries: base..head, the same range as the diff.
1484 type commitRow struct { 1487 type commitRow struct {
1485 SHA, ShortSHA, Subject, AuthorName, Date string 1488 SHA, ShortSHA, Subject, AuthorName, AuthorUser, Date string
1486 Sig sigView 1489 Sig sigView
1487 } 1490 }
1488 mrNames := s.authorNames() 1491 mrNames := s.authorNames()
1489 var commits []commitRow 1492 var commits []commitRow
@@ -1499,6 +1502,7 @@ func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
1499 if parsed != nil { 1502 if parsed != nil {
1500 cr.Subject = parsed.Subject 1503 cr.Subject = parsed.Subject
1501 cr.AuthorName = mrNames.name(parsed.AuthorEmail, parsed.AuthorName) 1504 cr.AuthorName = mrNames.name(parsed.AuthorEmail, parsed.AuthorName)
1505 cr.AuthorUser, _ = mrNames.account(parsed.AuthorEmail)
1502 cr.Date = time.Unix(parsed.AuthorUnix, 0).UTC().Format("2006-01-02") 1506 cr.Date = time.Unix(parsed.AuthorUnix, 0).UTC().Format("2006-01-02")
1503 } 1507 }
1504 commits = append(commits, cr) 1508 commits = append(commits, cr)
internal/web/templates/commit.html +1 −1
@@ -5,7 +5,7 @@
5 <p class="meta"><code class="fullsha">{{.SHA}}</code></p> 5 <p class="meta"><code class="fullsha">{{.SHA}}</code></p>
6 {{if .Parents}}<p class="meta">parent{{if gt (len .Parents) 1}}s{{end}}:{{range .Parents}} <code><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{.}}">{{short .}}</a></code>{{end}}</p>{{end}} 6 {{if .Parents}}<p class="meta">parent{{if gt (len .Parents) 1}}s{{end}}:{{range .Parents}} <code><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{.}}">{{short .}}</a></code>{{end}}</p>{{end}}
7 <p>{{template "sigbadge" .Sig}}{{range .Checks}} <span class="badge check-{{.State}}">{{.Context}}: {{.State}}</span>{{end}}</p> 7 <p>{{template "sigbadge" .Sig}}{{range .Checks}} <span class="badge check-{{.State}}">{{.Context}}: {{.State}}</span>{{end}}</p>
8 <p class="meta"><span title="{{.AuthorEmail}}">{{.AuthorName}}</span> &lt;{{.AuthorEmail}}&gt; · {{.Date}} 8 <p class="meta">{{template "authorname" dict "Name" .AuthorName "User" .AuthorUser "Email" .AuthorEmail}} &lt;{{.AuthorEmail}}&gt; · {{.Date}}
9 {{if .CommitterEmail}}<br>committer: &lt;{{.CommitterEmail}}&gt;{{end}}</p> 9 {{if .CommitterEmail}}<br>committer: &lt;{{.CommitterEmail}}&gt;{{end}}</p>
10</div> 10</div>
11<pre class="message">{{.Message}}</pre> 11<pre class="message">{{.Message}}</pre>
internal/web/templates/layout.html +2
@@ -106,3 +106,5 @@
106{{define "branchicon"}}<svg class="icon" width="12" height="12" viewBox="0 0 16 16" aria-hidden="true" fill="currentColor"><path d="M9.5 3.25a2.25 2.25 0 1 1 3 2.122V6A2.5 2.5 0 0 1 10 8.5H6a1 1 0 0 0-1 1v1.128a2.251 2.251 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.5 0v1.836A2.493 2.493 0 0 1 6 7h4a1 1 0 0 0 1-1v-.628a2.25 2.25 0 0 1-1.5-2.122ZM4.25 12a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5ZM3.5 3.25a.75.75 0 1 1 1.5 0 .75.75 0 0 1-1.5 0Zm8.25-.75a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Z"/></svg>{{end}} 106{{define "branchicon"}}<svg class="icon" width="12" height="12" viewBox="0 0 16 16" aria-hidden="true" fill="currentColor"><path d="M9.5 3.25a2.25 2.25 0 1 1 3 2.122V6A2.5 2.5 0 0 1 10 8.5H6a1 1 0 0 0-1 1v1.128a2.251 2.251 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.5 0v1.836A2.493 2.493 0 0 1 6 7h4a1 1 0 0 0 1-1v-.628a2.25 2.25 0 0 1-1.5-2.122ZM4.25 12a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5ZM3.5 3.25a.75.75 0 1 1 1.5 0 .75.75 0 0 1-1.5 0Zm8.25-.75a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Z"/></svg>{{end}}
107 107
108{{define "sigbadge"}}<span class="badge badge-{{.State}}" title="{{.Fingerprint}}">{{sigLabel .State}}{{if .Signer}} · {{.Signer}}{{end}}</span>{{end}} 108{{define "sigbadge"}}<span class="badge badge-{{.State}}" title="{{.Fingerprint}}">{{sigLabel .State}}{{if .Signer}} · {{.Signer}}{{end}}</span>{{end}}
109
110{{define "authorname"}}{{if .User}}<a class="authorlink" href="/{{.User}}" title="{{.Email}}">{{.Name}}</a>{{else}}<span title="{{.Email}}">{{.Name}}</span>{{end}}{{end}}
internal/web/templates/log.html +1 −1
@@ -6,7 +6,7 @@
6{{range .Commits}}<li> 6{{range .Commits}}<li>
7 <div class="commitmain"> 7 <div class="commitmain">
8 <p class="subject"><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{.SHA}}">{{.Subject}}</a></p> 8 <p class="subject"><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{.SHA}}">{{.Subject}}</a></p>
9 <p class="meta"><span title="{{.AuthorEmail}}">{{.AuthorName}}</span> · {{.Date}}</p> 9 <p class="meta">{{template "authorname" dict "Name" .AuthorName "User" .AuthorUser "Email" .AuthorEmail}} · {{.Date}}</p>
10 </div> 10 </div>
11 <div class="commitside"> 11 <div class="commitside">
12 {{template "sigbadge" .Sig}} 12 {{template "sigbadge" .Sig}}
internal/web/templates/mr.html +1 −1
@@ -48,7 +48,7 @@
48{{range .Commits}}<li> 48{{range .Commits}}<li>
49 <div class="commitmain"> 49 <div class="commitmain">
50 <p class="subject"><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{.SHA}}">{{.Subject}}</a></p> 50 <p class="subject"><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{.SHA}}">{{.Subject}}</a></p>
51 <p class="meta">{{.AuthorName}} · {{.Date}}</p> 51 <p class="meta">{{template "authorname" dict "Name" .AuthorName "User" .AuthorUser "Email" ""}} · {{.Date}}</p>
52 </div> 52 </div>
53 <div class="commitside"> 53 <div class="commitside">
54 {{template "sigbadge" .Sig}} 54 {{template "sigbadge" .Sig}}
internal/web/templates/tree.html +1 −1
@@ -11,7 +11,7 @@
11</div> 11</div>
12{{if .Entries}}<p class="clone">Clone: <code>git clone {{.CloneURL}}</code></p>{{end}} 12{{if .Entries}}<p class="clone">Clone: <code>git clone {{.CloneURL}}</code></p>{{end}}
13{{with .Tip}}{{if .SHA}}<div class="tipbar"> 13{{with .Tip}}{{if .SHA}}<div class="tipbar">
14 <span class="who">{{.Author}}</span> 14 <span class="who">{{template "authorname" dict "Name" .Author "User" .User "Email" .Email}}</span>
15 <a class="subject" href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{.SHA}}">{{.Subject}}</a> 15 <a class="subject" href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{.SHA}}">{{.Subject}}</a>
16 <span class="spacer"></span> 16 <span class="spacer"></span>
17 <a class="sha" href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{.SHA}}"><code>{{short .SHA}}</code></a> 17 <a class="sha" href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{.SHA}}"><code>{{short .SHA}}</code></a>