web: link resolved author names to their profiles !70
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 | |||
| 4 | A CLI-first git forge. One binary, SQLite, and the system =git= — designed | 6 | A CLI-first git forge. One binary, SQLite, and the system =git= — designed |
| 5 | so the command line is the product and the web UI is a rendering of state | 7 | so the command line is the product and the web UI is a rendering of state |
| 6 | the CLI already manages. Runs at [[https://gitbay.org]]. | 8 | the 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. |
| 113 | func (a *authorNames) known(email string) bool { | 113 | func (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. | ||
| 127 | type 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. |
| 127 | func (s *Server) namedCommits(m map[string]gitutil.EntryCommit) map[string]gitutil.EntryCommit { | 134 | func (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. |
| 137 | func (s *Server) namedTip(c gitutil.EntryCommit) gitutil.EntryCommit { | 146 | func (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> <{{.AuthorEmail}}> · {{.Date}} | 8 | <p class="meta">{{template "authorname" dict "Name" .AuthorName "User" .AuthorUser "Email" .AuthorEmail}} <{{.AuthorEmail}}> · {{.Date}} |
| 9 | {{if .CommitterEmail}}<br>committer: <{{.CommitterEmail}}>{{end}}</p> | 9 | {{if .CommitterEmail}}<br>committer: <{{.CommitterEmail}}>{{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> |