Commit 4575477ef7

4575477ef77fd17c6e6ed35bacc3dda9d47bbfc6

parent: c600d4a8ab

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-21 20:44 UTC

profile: repositories are the page, the rest are tabs

The rendered About file and a year of activity squares sat above the
repository list, so a profile with a long About pushed the projects
below the fold and the fixed graph kept them there.

/{owner} is the repository list now. About, Activity and an
organization's People hang off the /-/ namespace beside it, one
section each, sharing the handler and the template because all four
read the same profile show. A tab with nothing on it is not offered
and its URL is a 404: no About file, or no rights to administer the
organization. The org member and team forms moved with the People tab,
so their results redirect there.

Closes #242
.gitbay/wiki/Parity.org +15 −6
@@ -200,6 +200,7 @@ sit inside the diff, where a page-level preview has nowhere to go.
200200| release create, edit | yes | yes | yes |
201201| preview release notes | n/a | yes | no |
202202| build list | yes | yes | yes |
203| build list paging (limit, cursor) | yes | yes | yes |
203204| build list filters (ref, status, job) | yes | yes | yes |
204205| build show (one build) | yes | yes | yes |
205206| build log | yes | yes | yes |
@@ -286,6 +287,7 @@ now render the way the reference does. go-org is not yet on the corpus.
286287| search issues and merge requests | yes | yes | yes |
287288| browse all public repositories | yes | yes | yes |
288289| profile page | yes | yes | yes |
290| profile sections as tabs | n/a | yes | n/a |
289291| profile about and links | yes | yes | yes |
290292| profile about as a file | yes | yes | yes |
291293| activity feed | yes | yes | yes |
@@ -308,7 +310,12 @@ it as =about=, =about_format= and =about_path=. It reads with the
308310repository's own access, so a private =.gitbay= is a profile with no
309311about text to anyone but its owner and the admins. A repository whose
310312name starts with a dot stays out of =explore= and off the profile's
311repository list. The iOS client decodes and renders both formats,
313repository list. On the web the about text is its own tab: =/<owner>=
314is the repository list, =/<owner>/-/about= the text, =/<owner>/-/activity=
315the year of squares, and =/<owner>/-/people= an organization's members
316and teams. A tab with nothing on it is not offered and its URL is a
317404. The CLI's =profile show= is unchanged and still returns all of it
318at once. The iOS client decodes and renders both formats,
312319through the same OrgSwift path a README takes.
313320
314321=help= lists the command registry. Bare it is an index, one line per
@@ -428,11 +435,13 @@ key, the other cannot be undone.
428435
429436* Pagination
430437
431=issue list=, =mr list=, =repo list=, and =feed= take =--limit <n>=
432and =--cursor <c>=. Cursors are opaque; each page carries the next
433one. Without the flags a list stays complete, so existing scripts are
434unchanged. The web pages the issue and merge request lists at fifty
435with the same cursors; iOS pages with them too.
438=issue list=, =mr list=, =repo list=, =feed= and =build list= take
439=--limit <n>= and =--cursor <c>=. Cursors are opaque; each page carries
440the next one. Without the flags a list stays as it was, so existing
441scripts are unchanged — for =build list= that means the newest fifty
442matching builds, which is what the cursor now reaches past. The web
443pages the issue and merge request lists at fifty and the builds list at
444thirty with the same cursors; iOS pages with them too.
436445
437446* CLI only, for now
438447
internal/httpd/orgweb.go +3 −1
@@ -43,9 +43,11 @@ func (s *Server) orgAdminView(viewer store.User, kind, name string) (teams []tea
4343// entries stay in one implementation.
4444func (s *Server) orgSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
4545 owner := r.PathValue("owner")
46 // The member and team forms live on the profile's people tab (#242),
47 // which is where a result has to land for its flash to render.
4648 back := func(msg string) {
4749 s.setFlash(w, msg)
48 http.Redirect(w, r, "/"+owner, http.StatusSeeOther)
50 http.Redirect(w, r, "/"+owner+"/-/people", http.StatusSeeOther)
4951 }
5052 field := r.FormValue("field")
5153 team := strings.TrimSpace(r.FormValue("team"))
internal/httpd/ownerpage_test.go added +141
@@ -0,0 +1,141 @@
1package httpd
2
3import (
4 "html/template"
5 "strings"
6 "testing"
7
8 "gitbay.org/gitbay/internal/control"
9 "gitbay.org/gitbay/internal/store"
10 "gitbay.org/gitbay/internal/web"
11)
12
13func TestProfileTabFromPath(t *testing.T) {
14 for path, want := range map[string]string{
15 "/cmc": "repos",
16 "/cmc/-/about": "about",
17 "/cmc/-/activity": "activity",
18 "/cmc/-/people": "people",
19 "/krz": "repos",
20 "/cmc/-/snippets": "repos",
21 } {
22 if got := profileTab(path); got != want {
23 t.Errorf("profileTab(%q) = %q, want %q", path, got, want)
24 }
25 }
26}
27
28// The About text and the year of squares sat above the repository list
29// and pushed it below the fold (#242). Repositories are the bare
30// /{owner} now and the rest are tabs beside them.
31func TestOwnerPageLeadsWithRepositories(t *testing.T) {
32 out := renderOwner(t, "repos", ownerFixture())
33 if !strings.Contains(out, "reminiscecleberg.com") {
34 t.Errorf("the repository list is not on the default tab:\n%s", out)
35 }
36 for _, unwanted := range []string{"actgraph", "Christian Cleberg"} {
37 if strings.Contains(out, unwanted) {
38 t.Errorf("the default tab still carries %q:\n%s", unwanted, out)
39 }
40 }
41 for _, want := range []string{
42 `aria-current="page" href="/cmc"`,
43 `href="/cmc/-/about"`,
44 `href="/cmc/-/activity"`,
45 } {
46 if !strings.Contains(out, want) {
47 t.Errorf("tab bar missing %q:\n%s", want, out)
48 }
49 }
50 // Nobody administers this profile, so it offers no people tab.
51 if strings.Contains(out, "/-/people") {
52 t.Errorf("a profile nobody admins offers a people tab:\n%s", out)
53 }
54}
55
56func TestOwnerPageTabsCarryOneSectionEach(t *testing.T) {
57 if out := renderOwner(t, "about", ownerFixture()); !strings.Contains(out, "Christian Cleberg") ||
58 strings.Contains(out, "actgraph") || strings.Contains(out, "reminiscecleberg.com") {
59 t.Errorf("about tab is not the About file alone:\n%s", out)
60 }
61 if out := renderOwner(t, "activity", ownerFixture()); !strings.Contains(out, "actgraph") ||
62 strings.Contains(out, "reminiscecleberg.com") {
63 t.Errorf("activity tab is not the graph alone:\n%s", out)
64 }
65 // A profile with no About file does not offer the tab.
66 d := ownerFixture()
67 d.AboutHTML = ""
68 if out := renderOwner(t, "repos", d); strings.Contains(out, "/-/about") {
69 t.Errorf("a profile with no About file offers the tab:\n%s", out)
70 }
71}
72
73func TestOwnerPagePeopleTabHoldsTheAdminPanel(t *testing.T) {
74 d := ownerFixture()
75 d.Kind = "org"
76 d.CanAdmin = true
77 d.Members = []control.ProfileMember{{Name: "cmc", Role: "admin"}}
78
79 repos := renderOwner(t, "repos", d)
80 if !strings.Contains(repos, `href="/cmc/-/people"`) {
81 t.Errorf("an admin gets no people tab:\n%s", repos)
82 }
83 if strings.Contains(repos, "Create a team") {
84 t.Errorf("the admin forms still sit under the repository list:\n%s", repos)
85 }
86 people := renderOwner(t, "people", d)
87 for _, want := range []string{"Create a team", "member-add", "org-rename"} {
88 if !strings.Contains(people, want) {
89 t.Errorf("people tab missing %q:\n%s", want, people)
90 }
91 }
92}
93
94type ownerFixtureData struct {
95 Kind string
96 AboutHTML template.HTML
97 CanAdmin bool
98 Members []control.ProfileMember
99}
100
101func ownerFixture() ownerFixtureData {
102 return ownerFixtureData{
103 Kind: "user",
104 AboutHTML: template.HTML("<p><strong>Christian Cleberg</strong></p>"),
105 }
106}
107
108func renderOwner(t *testing.T, tab string, d ownerFixtureData) string {
109 t.Helper()
110 var sb strings.Builder
111 err := web.Render(&sb, "owner.html", struct {
112 basePage
113 Owner string
114 Kind string
115 Tab string
116 Profile store.Profile
117 AboutHTML template.HTML
118 Repos []profileRepoRow
119 Members []control.ProfileMember
120 Orgs []control.ProfileMember
121 Activity []activityWeek
122 ActivityTotal int
123 Teams []teamView
124 CanAdmin bool
125 Self bool
126 Snippets int
127 Notice string
128 Feed string
129 }{
130 basePage{Site: "gitbay"}, "cmc", d.Kind, tab,
131 store.Profile{Description: "Org-Mode · Self-Hosting · Privacy"}, d.AboutHTML,
132 []profileRepoRow{{control.ProfileRepo{Path: "cmc/reminiscecleberg.com", Description: "Personal placeholder site."}}},
133 d.Members, nil,
134 []activityWeek{{Month: "Sep", Days: []activityDay{{Date: "2026-09-20", Count: 3, Level: 2}}}}, 6088,
135 nil, d.CanAdmin, false, 0, "", "/cmc/activity.atom",
136 })
137 if err != nil {
138 t.Fatalf("render: %v", err)
139 }
140 return sb.String()
141}
internal/httpd/routes.go +3
@@ -75,6 +75,9 @@ func (s *Server) Routes() []Route {
7575 Route{Method: "GET", Pattern: "/{owner}/{repo}/log.atom", Handler: s.logAtom},
7676 Route{Method: "GET", Pattern: "/{owner}/{repo}/log.atom/{ref...}", Handler: s.logAtom},
7777 Route{Method: "GET", Pattern: "/{owner}/activity.atom", Handler: s.ownerAtom},
78 Route{Method: "GET", Pattern: "/{owner}/-/about", Handler: s.ownerPage},
79 Route{Method: "GET", Pattern: "/{owner}/-/activity", Handler: s.ownerPage},
80 Route{Method: "GET", Pattern: "/{owner}/-/people", Handler: s.ownerPage},
7881 Route{Method: "GET", Pattern: "/{owner}/-/labels", Handler: s.orgLabels},
7982 Route{Method: "GET", Pattern: "/{owner}/-/milestones", Handler: s.orgMilestones},
8083 Route{Method: "GET", Pattern: "/{owner}/-/snippets", Handler: s.snippetsPage},
internal/httpd/web.go +28 −1
@@ -426,6 +426,23 @@ func (p profileRepoRow) Desc() string { return p.Description }
426426// ownerPage renders /{owner} for users and orgs: the repositories the
427427// viewer may see, org membership either direction. Owner names are not
428428// secret (they are on every commit); repository visibility rules hold.
429// profileTab is which section of a profile a URL asks for. The bare
430// /{owner} is the repository list, because a profile's job is to lead to
431// the projects and the About text used to push them below the fold
432// (#242). The rest hang off the /-/ namespace the labels, milestones and
433// snippet pages already use.
434func profileTab(path string) string {
435 switch {
436 case strings.HasSuffix(path, "/-/about"):
437 return "about"
438 case strings.HasSuffix(path, "/-/activity"):
439 return "activity"
440 case strings.HasSuffix(path, "/-/people"):
441 return "people"
442 }
443 return "repos"
444}
445
429446func (s *Server) ownerPage(w http.ResponseWriter, r *http.Request) {
430447 name := r.PathValue("owner")
431448 var viewer store.User
@@ -455,11 +472,21 @@ func (s *Server) ownerPage(w http.ResponseWriter, r *http.Request) {
455472 weeks, activityTotal := activityGrid(counts)
456473
457474 teams, canAdmin := s.orgAdminView(viewer, d.Kind, name)
475 tab := profileTab(r.URL.Path)
476 // Neither tab is offered when there is nothing on it: the people tab
477 // is the organization admin panel, and the About tab is a file the
478 // owner may not have written. Both answer the way a missing page does
479 // rather than rendering empty.
480 if (tab == "people" && !canAdmin) || (tab == "about" && d.About == "") {
481 s.notFound(w, r)
482 return
483 }
458484 profile := store.Profile{Description: d.Description, Website: d.Website, Links: d.Links}
459485 s.render(w, "owner.html", struct {
460486 basePage
461487 Owner string
462488 Kind string
489 Tab string
463490 Profile store.Profile
464491 AboutHTML template.HTML
465492 Repos []profileRepoRow
@@ -473,7 +500,7 @@ func (s *Server) ownerPage(w http.ResponseWriter, r *http.Request) {
473500 Snippets int
474501 Notice string
475502 Feed string
476 }{s.baseFor(viewer), name, d.Kind, profile, aboutHTML(d.About, d.AboutFormat),
503 }{s.baseFor(viewer), name, d.Kind, tab, profile, aboutHTML(d.About, d.AboutFormat),
477504 d.Repos, d.Members, d.Orgs,
478505 weeks, activityTotal, teams, canAdmin,
479506 d.Kind == "user" && viewer.ID != 0 && strings.EqualFold(viewer.Username, name),
internal/web/templates/owner.html +25 −8
@@ -9,7 +9,29 @@
99{{if .Members}}<p class="meta">members {{range .Members}}<a class="memberchip" href="/{{.Name}}">{{.Name}} <span class="role">{{.Role}}</span></a> {{end}}</p>{{end}}
1010{{if and (eq .Kind "org") .Repos}}<p class="meta"><a href="/{{.Owner}}/-/labels">labels</a> · <a href="/{{.Owner}}/-/milestones">milestones</a></p>{{end}}
1111</section>
12{{if .AboutHTML}}<section class="readme"><div class="rendered">{{.AboutHTML}}</div></section>{{end}}
12{{/* The repository list is the bare /{owner}, because a profile's job is
13 to lead to the projects: the About text and the year of squares used
14 to sit above the list and push it below the fold (#242). */}}
15<nav class="tabs" aria-label="Profile">
16 <a {{if eq .Tab "repos"}}aria-current="page" {{end}}href="/{{.Owner}}">Repositories{{if .Repos}} <i>{{len .Repos}}</i>{{end}}</a>
17 {{if .AboutHTML}}<a {{if eq .Tab "about"}}aria-current="page" {{end}}href="/{{.Owner}}/-/about">About</a>{{end}}
18 <a {{if eq .Tab "activity"}}aria-current="page" {{end}}href="/{{.Owner}}/-/activity">Activity{{if .ActivityTotal}} <i>{{.ActivityTotal}}</i>{{end}}</a>
19 {{if or .Snippets .Self}}<a href="/{{.Owner}}/-/snippets">Snippets{{if .Snippets}} <i>{{.Snippets}}</i>{{end}}</a>{{end}}
20 {{if .CanAdmin}}<a {{if eq .Tab "people"}}aria-current="page" {{end}}href="/{{.Owner}}/-/people">People{{if .Members}} <i>{{len .Members}}</i>{{end}}</a>{{end}}
21</nav>
22
23{{if eq .Tab "repos"}}
24<ul class="repolist">
25{{range .Repos}}{{template "reporow" .}}
26{{else}}<li class="empty">no visible repositories</li>{{end}}
27</ul>
28{{end}}
29
30{{if eq .Tab "about"}}
31<section class="readme"><div class="rendered">{{.AboutHTML}}</div></section>
32{{end}}
33
34{{if eq .Tab "activity"}}
1335<section class="activity">
1436<h2>activity <span class="count">{{.ActivityTotal}} in the last year</span></h2>
1537<div class="actgraph-scroll">
@@ -19,14 +41,9 @@
1941</div>
2042<p class="actlegend"><span>Less</span><span class="actday l0"></span><span class="actday l1"></span><span class="actday l2"></span><span class="actday l3"></span><span class="actday l4"></span><span>More</span></p>
2143</section>
22<h2>repositories <span class="count">{{len .Repos}}</span></h2>
23<ul class="repolist">
24{{range .Repos}}{{template "reporow" .}}
25{{else}}<li class="empty">no visible repositories</li>{{end}}
26</ul>
27{{if or .Snippets .Self}}<p class="meta"><a href="/{{.Owner}}/-/snippets">snippets{{if .Snippets}} <span class="count">{{.Snippets}}</span>{{end}}</a></p>{{end}}
44{{end}}
2845
29{{if .CanAdmin}}{{$org := .Owner}}
46{{if eq .Tab "people"}}{{$org := .Owner}}
3047<h2>people <span class="count">{{len .Members}}</span></h2>
3148{{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}}
3249<div class="tablewrap"><table class="keys">