A CLI-first git forge.

cli forge git self-hosted

https://gitbay.org

web: the owner page dispatches profile show !122

merged cmc wants to merge krz/gitbay:owner-profile-dispatch into main

10 files changed, +207 −108

e2e/profile_test.go +41 −1
@@ -1,6 +1,8 @@
11 package e2e
22
33 import (
4 "os"
5 "path/filepath"
46 "strings"
57 "testing"
68 )
@@ -46,6 +48,44 @@ func TestOwnerProfiles(t *testing.T) {
4648 }
4749 }
4850
51 // A profile's repository rows carry the listing metadata the web
52 // shows: topics, license, default branch, last commit. Without them
53 // the web had to decorate the rows itself, which is what kept it
54 // reading the store (krz/gitbay#47).
55 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "topics", "add", "alice/public-tool", "cli", "go"); code != 0 {
56 t.Fatalf("topics add: %s", errOut)
57 }
58 env := inst.gitEnv(aliceKey)
59 work := t.TempDir()
60 mustGit(t, work, env, "clone", inst.sshURL("alice/public-tool"), "w")
61 dir := filepath.Join(work, "w")
62 os.WriteFile(filepath.Join(dir, "LICENSE"), []byte("MIT License\n\nPermission is hereby granted, free of charge\n"), 0o644)
63 mustGit(t, dir, env, "checkout", "-q", "-b", "main")
64 mustGit(t, dir, env, "add", ".")
65 mustGit(t, dir, env, "commit", "-q", "-m", "add license")
66 mustGit(t, dir, env, "push", "-q", "origin", "main")
67
68 out, _, _ = inst.ssh(t, bobKey, "", "profile", "show", "alice", "--json")
69 for _, want := range []string{`"cli"`, `"go"`, `"license":"MIT"`, `"default_branch":"main"`, `"updated"`} {
70 if !strings.Contains(out, want) {
71 t.Errorf("profile repo row missing %q: %s", want, out)
72 }
73 }
74 // The owner page renders those rows, having dispatched the same
75 // command rather than assembling them from the store again.
76 status, body := inst.get(t, "/alice")
77 if status != 200 {
78 t.Fatalf("owner page: %d", status)
79 }
80 for _, want := range []string{">public-tool<", "?q=cli", "MIT", "updated "} {
81 if !strings.Contains(body, want) {
82 t.Errorf("owner page missing %q:\n%s", want, body)
83 }
84 }
85 if strings.Contains(body, "secret") {
86 t.Fatal("owner page leaks a private repo")
87 }
88
4989 // A stranger sees the public repository and not the private one.
5090 out, _, _ = inst.ssh(t, bobKey, "", "profile", "show", "alice", "--json")
5191 if !strings.Contains(out, `"path":"alice/public-tool"`) {
@@ -123,7 +163,7 @@ func TestOwnerProfiles(t *testing.T) {
123163 }
124164 // Org emphasis parsed, not left as literal slashes the way the
125165 // markdown renderer would.
126 status, body := inst.get(t, "/alice")
166 _, body = inst.get(t, "/alice")
127167 if !strings.Contains(body, "<em>small tools</em>") || strings.Contains(body, "/small tools/") {
128168 t.Fatalf("org about not rendered as org: %s", body)
129169 }
internal/control/license.go added +50
@@ -0,0 +1,50 @@
1package control
2
3import (
4 "strings"
5
6 "gitbay.org/gitbay/internal/gitutil"
7)
8
9// licenseNames maps content markers to display names, checked in order.
10var licenseChecks = []struct{ marker, name string }{
11 {"MIT License", "MIT"},
12 {"Permission is hereby granted, free of charge", "MIT"},
13 {"Apache License", "Apache-2.0"},
14 {"GNU AFFERO GENERAL PUBLIC LICENSE", "AGPL-3.0"},
15 {"GNU GENERAL PUBLIC LICENSE", "GPL"},
16 {"GNU LESSER GENERAL PUBLIC LICENSE", "LGPL"},
17 {"Mozilla Public License", "MPL-2.0"},
18 {"BSD 3-Clause", "BSD-3-Clause"},
19 {"BSD 2-Clause", "BSD-2-Clause"},
20 {"Redistribution and use in source and binary forms", "BSD"},
21 {"BSD Zero Clause License", "0BSD"},
22 {"Zero Clause BSD", "0BSD"},
23 // ISC is the 0BSD grant plus the notice-retention clause; match on the
24 // clause so title-less 0BSD files don't read as ISC.
25 {"hereby granted, provided that the above copyright notice", "ISC"},
26 {"Permission to use, copy, modify, and/or distribute", "0BSD"},
27 {"This is free and unencumbered software", "Unlicense"},
28 {"CC0", "CC0"},
29}
30
31// DetectLicense reports the repo's license name from a conventional file
32// at the ref root, or "". It lives here because profile show and the
33// web's repo listings both report it.
34func DetectLicense(dir, ref string) string {
35 for _, name := range []string{"LICENSE", "LICENSE.md", "LICENSE.txt", "COPYING", "UNLICENSE"} {
36 raw, err := gitutil.ReadBlob(dir, ref, name, 2048)
37 if err != nil {
38 continue
39 }
40 // Collapse whitespace so markers match across line wraps.
41 text := strings.Join(strings.Fields(string(raw)), " ")
42 for _, c := range licenseChecks {
43 if strings.Contains(text, c.marker) {
44 return c.name
45 }
46 }
47 return "license"
48 }
49 return ""
50}
internal/control/profile.go +25 −8
@@ -184,11 +184,19 @@ type profileMember struct {
184184 Role string `json:"role,omitempty"`
185185 }
186186
187// profileRepo is one repository as a profile lists it. The listing
188// metadata — topics, license, last commit — is here because a profile is
189// a listing: a client that renders repositories without it is showing
190// less than the web does, which is why the web kept its own copy.
187191 type profileRepo struct {
188 Path string `json:"path"`
189 Visibility string `json:"visibility"`
190 Description string `json:"description,omitempty"`
191 Archived bool `json:"archived,omitempty"`
192 Path string `json:"path"`
193 Visibility string `json:"visibility"`
194 Description string `json:"description,omitempty"`
195 DefaultBranch string `json:"default_branch"`
196 Topics []string `json:"topics,omitempty"`
197 License string `json:"license,omitempty"`
198 Updated string `json:"updated,omitempty"`
199 Archived bool `json:"archived,omitempty"`
192200 }
193201
194202 // activityDay is one day's contribution count. Days with nothing are
@@ -293,11 +301,20 @@ func runProfileShow(c *Ctx, args []string) int {
293301 if !policy.CanRead(c.User, repo, grant) {
294302 continue
295303 }
304 dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
305 topics, err := c.Store.ListTopics(repo.ID)
306 if err != nil {
307 return c.fail(protocol.ExitFailure, "%v", err)
308 }
296309 d.Repos = append(d.Repos, profileRepo{
297 Path: repo.Path(),
298 Visibility: repo.Visibility,
299 Description: gitutil.ReadDescription(RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)),
300 Archived: repo.Settings.Archived,
310 Path: repo.Path(),
311 Visibility: repo.Visibility,
312 Description: gitutil.ReadDescription(dir),
313 DefaultBranch: repo.DefaultBranch,
314 Topics: topics,
315 License: DetectLicense(dir, repo.DefaultBranch),
316 Updated: gitutil.LastCommitDate(dir, repo.DefaultBranch),
317 Archived: repo.Settings.Archived,
301318 })
302319 }
303320
internal/httpd/activity.go +1 −10
@@ -1,10 +1,6 @@
11 package httpd
22
3import (
4 "time"
5
6 "gitbay.org/gitbay/internal/control"
7)
3import "time"
84
95 // activityDay is one cell of the graph; Level buckets Count into the five
106 // intensity classes the stylesheet colors.
@@ -59,8 +55,3 @@ func activityLevel(n int) int {
5955 return 4
6056 }
6157 }
62
63// activitySince is the first day the grid can show, for the query bound.
64// activitySince is control.ActivityWindow: the profile command and the
65// web must report the same year, so the span has one definition.
66func activitySince() string { return control.ActivityWindow() }
internal/httpd/control.go +15 −3
@@ -70,6 +70,18 @@ func (s *Server) runControlStdin(u store.User, argv []string, stdin string) (msg
7070 // target. Read handlers use it so the web renders exactly what the CLI
7171 // and the API return, rather than reaching past the registry into git.
7272 func (s *Server) runControlInto(u store.User, argv []string, target any) (msg string, ok bool) {
73 code, msg := s.dispatchInto(u, argv, target)
74 return msg, code == protocol.ExitOK
75}
76
77// runControlIntoCode is runControlInto for handlers that have to tell
78// "no such thing" from "that failed": a profile page 404s on the first
79// and errors on the second.
80func (s *Server) runControlIntoCode(u store.User, argv []string, target any) (code int, msg string) {
81 return s.dispatchInto(u, argv, target)
82}
83
84func (s *Server) dispatchInto(u store.User, argv []string, target any) (int, string) {
7385 var stdout, stderr bytes.Buffer
7486 ctx := &control.Ctx{
7587 User: u,
@@ -94,14 +106,14 @@ func (s *Server) runControlInto(u store.User, argv []string, target any) (msg st
94106 if m == "" {
95107 m = strings.TrimSpace(stderr.String())
96108 }
97 return m, false
109 return code, m
98110 }
99111 if len(env.Data) > 0 {
100112 if err := json.Unmarshal(env.Data, target); err != nil {
101 return "unreadable response", false
113 return protocol.ExitFailure, "unreadable response"
102114 }
103115 }
104 return "", true
116 return protocol.ExitOK, ""
105117 }
106118
107119 // runControlJSON runs a command in JSON mode and returns its data object.
internal/httpd/facts.go +2 −1
@@ -1,6 +1,7 @@
11 package httpd
22
33 import (
4 "gitbay.org/gitbay/internal/control"
45 "gitbay.org/gitbay/internal/gitutil"
56 )
67
@@ -29,7 +30,7 @@ const maxContributors = 12
2930 func (s *Server) factsFor(p repoPage) repoFacts {
3031 f := repoFacts{
3132 Commits: gitutil.CountCommits(p.Dir, p.Ref),
32 License: detectLicense(p.Dir, p.Ref),
33 License: control.DetectLicense(p.Dir, p.Ref),
3334 }
3435 if heads, err := gitutil.Refs(p.Dir, "heads"); err == nil {
3536 f.Branches = len(heads)
internal/httpd/readme.go −42
@@ -11,48 +11,6 @@ import (
1111 "gitbay.org/gitbay/internal/gitutil"
1212 )
1313
14// licenseNames maps content markers to display names, checked in order.
15var licenseChecks = []struct{ marker, name string }{
16 {"MIT License", "MIT"},
17 {"Permission is hereby granted, free of charge", "MIT"},
18 {"Apache License", "Apache-2.0"},
19 {"GNU AFFERO GENERAL PUBLIC LICENSE", "AGPL-3.0"},
20 {"GNU GENERAL PUBLIC LICENSE", "GPL"},
21 {"GNU LESSER GENERAL PUBLIC LICENSE", "LGPL"},
22 {"Mozilla Public License", "MPL-2.0"},
23 {"BSD 3-Clause", "BSD-3-Clause"},
24 {"BSD 2-Clause", "BSD-2-Clause"},
25 {"Redistribution and use in source and binary forms", "BSD"},
26 {"BSD Zero Clause License", "0BSD"},
27 {"Zero Clause BSD", "0BSD"},
28 // ISC is the 0BSD grant plus the notice-retention clause; match on the
29 // clause so title-less 0BSD files don't read as ISC.
30 {"hereby granted, provided that the above copyright notice", "ISC"},
31 {"Permission to use, copy, modify, and/or distribute", "0BSD"},
32 {"This is free and unencumbered software", "Unlicense"},
33 {"CC0", "CC0"},
34}
35
36// detectLicense reports the repo's license name from a conventional file
37// at the ref root, or "".
38func detectLicense(dir, ref string) string {
39 for _, name := range []string{"LICENSE", "LICENSE.md", "LICENSE.txt", "COPYING", "UNLICENSE"} {
40 raw, err := gitutil.ReadBlob(dir, ref, name, 2048)
41 if err != nil {
42 continue
43 }
44 // Collapse whitespace so markers match across line wraps.
45 text := strings.Join(strings.Fields(string(raw)), " ")
46 for _, c := range licenseChecks {
47 if strings.Contains(text, c.marker) {
48 return c.name
49 }
50 }
51 return "license"
52 }
53 return ""
54}
55
5614 // rewriteRelativeLinks makes relative hrefs and srcs in rendered repo
5715 // content resolve on the forge: links go to blob pages, images to raw.
5816 // go-org exports .org links as .html, so an .html target whose .org (or
internal/httpd/web.go +68 −38
@@ -11,6 +11,7 @@ import (
1111 "path/filepath"
1212
1313 "gitbay.org/gitbay/internal/policy"
14 "gitbay.org/gitbay/internal/protocol"
1415 "html/template"
1516 "net/http"
1617 "net/url"
@@ -107,6 +108,10 @@ type describedRepo struct {
107108 Updated string
108109 }
109110
111// Archived flattens the settings flag so the reporow partial can read the
112// same field name from a describedRepo and from a profile's repo row.
113func (d describedRepo) Archived() bool { return d.Settings.Archived }
114
110115 func (s *Server) describeAll(repos []store.Repo) []describedRepo {
111116 var out []describedRepo
112117 for _, r := range repos {
@@ -114,7 +119,7 @@ func (s *Server) describeAll(repos []store.Repo) []describedRepo {
114119 d := describedRepo{
115120 Repo: r,
116121 Desc: gitutil.ReadDescription(dir),
117 License: detectLicense(dir, r.DefaultBranch),
122 License: control.DetectLicense(dir, r.DefaultBranch),
118123 Updated: gitutil.LastCommitDate(dir, r.DefaultBranch),
119124 }
120125 d.Topics, _ = s.st.ListTopics(r.ID)
@@ -346,6 +351,47 @@ func crumbs(p repoPage, kind, filePath string) []crumb {
346351 return cs
347352 }
348353
354// profileView is profile show's payload, shaped for the templates. The
355// repo rows carry the same names the reporow partial reads, so a profile
356// listing renders identically to explore's.
357type profileView struct {
358 Name string `json:"name"`
359 Kind string `json:"kind"`
360 Description string `json:"description"`
361 Website string `json:"website"`
362 About string `json:"about"`
363 AboutFormat string `json:"about_format"`
364 Links []store.ProfileLink `json:"links"`
365 Orgs []profileMember `json:"orgs"`
366 Members []profileMember `json:"members"`
367 Repos []profileRepoRow `json:"repos"`
368 Activity []struct {
369 Date string `json:"date"`
370 Count int `json:"count"`
371 } `json:"activity"`
372}
373
374type profileMember struct {
375 Name string `json:"name"`
376 Role string `json:"role"`
377}
378
379// profileRepoRow is one repository row on a profile. Path arrives as
380// owner/name; OwnerName and Name are split out for the partial.
381type profileRepoRow struct {
382 Path string `json:"path"`
383 Visibility string `json:"visibility"`
384 Desc string `json:"description"`
385 DefaultBranch string `json:"default_branch"`
386 Topics []string `json:"topics"`
387 License string `json:"license"`
388 Updated string `json:"updated"`
389 Archived bool `json:"archived"`
390}
391
392func (p profileRepoRow) OwnerName() string { owner, _, _ := strings.Cut(p.Path, "/"); return owner }
393func (p profileRepoRow) Name() string { _, name, _ := strings.Cut(p.Path, "/"); return name }
394
349395 // ownerPage renders /{owner} for users and orgs: the repositories the
350396 // viewer may see, org membership either direction. Owner names are not
351397 // secret (they are on every commit); repository visibility rules hold.
@@ -356,62 +402,46 @@ func (s *Server) ownerPage(w http.ResponseWriter, r *http.Request) {
356402 viewer = s.viewer(r)
357403 }
358404
359 kind := "user"
360 var ownerID int64
361 var members []store.OrgMember
362 var orgs []store.OrgMember
363 if u, err := s.st.UserByUsername(name); err == nil {
364 ownerID = u.ID
365 orgs, _ = s.st.ListOrgsForUser(u.ID)
366 } else if o, err := s.st.OrgByName(name); err == nil {
367 kind, ownerID = "org", o.ID
368 members, _ = s.st.OrgMembers(o.ID)
369 } else {
405 // Everything on this page — membership, the repositories this viewer
406 // may see, the activity year — comes from profile show, so the page
407 // and the command cannot report different things.
408 var d profileView
409 code, msg := s.runControlIntoCode(viewer, []string{"profile", "show", name}, &d)
410 switch {
411 case code == protocol.ExitNotFound:
370412 s.notFound(w, r)
371413 return
372 }
373 profile, _ := s.st.OwnerProfile(kind, ownerID)
374
375 all, err := s.st.ListReposForOwner(kind, ownerID)
376 if err != nil {
414 case code != protocol.ExitOK:
415 log.Printf("profile %s: %s", name, msg)
377416 http.Error(w, "internal error", http.StatusInternalServerError)
378417 return
379418 }
380 var visible []store.Repo
381 for _, repo := range all {
382 grant := ""
383 if viewer.ID != 0 {
384 grant, _ = s.st.AccessRole(repo.ID, viewer.ID)
385 }
386 if policy.CanRead(viewer, repo, grant) {
387 visible = append(visible, repo)
388 }
389 }
390 var counts map[string]int
391 if kind == "user" {
392 counts, _ = s.st.ActivityByDay(ownerID, activitySince())
393 } else {
394 counts, _ = s.st.OrgActivityByDay(ownerID, activitySince())
419
420 counts := make(map[string]int, len(d.Activity))
421 for _, day := range d.Activity {
422 counts[day.Date] = day.Count
395423 }
396424 weeks, activityTotal := activityGrid(counts)
397425
398 teams, canAdmin := s.orgAdminView(viewer, kind, name)
426 teams, canAdmin := s.orgAdminView(viewer, d.Kind, name)
427 profile := store.Profile{Description: d.Description, Website: d.Website,
428 About: d.About, AboutFormat: d.AboutFormat, Links: d.Links}
399429 s.render(w, "owner.html", struct {
400430 basePage
401431 Owner string
402432 Kind string
403433 Profile store.Profile
404434 AboutHTML template.HTML
405 Repos []describedRepo
406 Members []store.OrgMember
407 Orgs []store.OrgMember
435 Repos []profileRepoRow
436 Members []profileMember
437 Orgs []profileMember
408438 Activity []activityWeek
409439 ActivityTotal int
410440 Teams []teamView
411441 CanAdmin bool
412442 Notice string
413 }{s.baseFor(viewer), name, kind, profile, aboutHTML(profile),
414 s.describeAll(visible), members, orgs,
443 }{s.baseFor(viewer), name, d.Kind, profile, aboutHTML(profile),
444 d.Repos, d.Members, d.Orgs,
415445 weeks, activityTotal, teams, canAdmin, r.URL.Query().Get("e")})
416446 }
417447
internal/web/templates/layout.html +1 −1
@@ -93,7 +93,7 @@
9393 {{define "mark"}}<svg class="mark" width="19" height="19" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 2.25 18.75 12H5.25z" fill="#ff6b3d"/><rect x="3" y="13.5" width="18" height="3" fill="currentColor"/><rect x="7.5" y="18" width="9" height="3" fill="currentColor"/></svg>{{end}}
9494
9595 {{define "reporow"}}<li>
96 <p class="reponame"><a href="/{{.OwnerName}}/{{.Name}}">{{.OwnerName}}<span class="sep">/</span><strong>{{.Name}}</strong></a>{{if eq .Visibility "private"}} <span class="chip">Private</span>{{end}}{{if .Settings.Archived}} <span class="chip">Archived</span>{{end}}</p>
96 <p class="reponame"><a href="/{{.OwnerName}}/{{.Name}}">{{.OwnerName}}<span class="sep">/</span><strong>{{.Name}}</strong></a>{{if eq .Visibility "private"}} <span class="chip">Private</span>{{end}}{{if .Archived}} <span class="chip">Archived</span>{{end}}</p>
9797 {{if .Desc}}<p class="desc">{{.Desc}}</p>{{end}}
9898 {{if .Topics}}<p class="topics">{{range .Topics}}<a class="chip topic" href="/explore?q={{.}}">{{.}}</a> {{end}}</p>{{end}}
9999 <p class="meta">{{template "branchicon"}} {{.DefaultBranch}}{{if .License}} · {{.License}}{{end}}{{if .Updated}} · updated {{.Updated}}{{end}}</p>
internal/web/templates/owner.html +4 −4
@@ -5,8 +5,8 @@
55 {{if .Profile.Description}}<p class="desc lede">{{.Profile.Description}}</p>{{end}}
66 {{if .Profile.Website}}<p class="meta"><a href="{{.Profile.Website}}" rel="nofollow me">{{.Profile.Website}}</a></p>{{end}}
77 {{if .Profile.Links}}<p class="meta">{{range $i, $l := .Profile.Links}}{{if $i}} · {{end}}<a href="{{$l.URL}}" rel="nofollow me">{{if $l.Label}}{{$l.Label}}{{else}}{{$l.URL}}{{end}}</a>{{end}}</p>{{end}}
8{{if .Orgs}}<p class="meta">member of {{range .Orgs}}<a class="memberchip" href="/{{.Username}}">{{.Username}}</a> {{end}}</p>{{end}}
9{{if .Members}}<p class="meta">members {{range .Members}}<a class="memberchip" href="/{{.Username}}">{{.Username}} <span class="role">{{.Role}}</span></a> {{end}}</p>{{end}}
8{{if .Orgs}}<p class="meta">member of {{range .Orgs}}<a class="memberchip" href="/{{.Name}}">{{.Name}}</a> {{end}}</p>{{end}}
9{{if .Members}}<p class="meta">members {{range .Members}}<a class="memberchip" href="/{{.Name}}">{{.Name}} <span class="role">{{.Role}}</span></a> {{end}}</p>{{end}}
1010 </section>
1111 {{if .AboutHTML}}<section class="readme"><div class="rendered">{{.AboutHTML}}</div></section>{{end}}
1212 <section class="activity">
@@ -29,9 +29,9 @@
2929 <div class="tablewrap"><table class="keys">
3030 <tr class="cols"><th scope="col">member</th><th scope="col">role</th><th scope="col"></th></tr>
3131 {{range .Members}}<tr>
32 <td><a href="/{{.Username}}">{{.Username}}</a></td>
32 <td><a href="/{{.Name}}">{{.Name}}</a></td>
3333 <td>{{.Role}}</td>
34 <td class="act"><form method="post" action="/{{$org}}"><input type="hidden" name="field" value="member-remove"><input type="hidden" name="user" value="{{.Username}}"><button type="submit" class="linklike">Remove</button></form></td>
34 <td class="act"><form method="post" action="/{{$org}}"><input type="hidden" name="field" value="member-remove"><input type="hidden" name="user" value="{{.Name}}"><button type="submit" class="linklike">Remove</button></form></td>
3535 </tr>
3636 {{end}}</table></div>
3737 <details class="editbox">