Commit 4575477ef7
Verified · cmc
.gitbay/wiki/Parity.org +15 −6
| @@ -200,6 +200,7 @@ sit inside the diff, where a page-level preview has nowhere to go. | ||
| 200 | 200 | | release create, edit | yes | yes | yes | |
| 201 | 201 | | preview release notes | n/a | yes | no | |
| 202 | 202 | | build list | yes | yes | yes | |
| 203 | | build list paging (limit, cursor) | yes | yes | yes | | |
| 203 | 204 | | build list filters (ref, status, job) | yes | yes | yes | |
| 204 | 205 | | build show (one build) | yes | yes | yes | |
| 205 | 206 | | build log | yes | yes | yes | |
| @@ -286,6 +287,7 @@ now render the way the reference does. go-org is not yet on the corpus. | ||
| 286 | 287 | | search issues and merge requests | yes | yes | yes | |
| 287 | 288 | | browse all public repositories | yes | yes | yes | |
| 288 | 289 | | profile page | yes | yes | yes | |
| 290 | | profile sections as tabs | n/a | yes | n/a | | |
| 289 | 291 | | profile about and links | yes | yes | yes | |
| 290 | 292 | | profile about as a file | yes | yes | yes | |
| 291 | 293 | | activity feed | yes | yes | yes | |
| @@ -308,7 +310,12 @@ it as =about=, =about_format= and =about_path=. It reads with the | ||
| 308 | 310 | repository's own access, so a private =.gitbay= is a profile with no |
| 309 | 311 | about text to anyone but its owner and the admins. A repository whose |
| 310 | 312 | name starts with a dot stays out of =explore= and off the profile's |
| 311 | repository list. The iOS client decodes and renders both formats, | |
| 313 | repository list. On the web the about text is its own tab: =/<owner>= | |
| 314 | is the repository list, =/<owner>/-/about= the text, =/<owner>/-/activity= | |
| 315 | the year of squares, and =/<owner>/-/people= an organization's members | |
| 316 | and teams. A tab with nothing on it is not offered and its URL is a | |
| 317 | 404. The CLI's =profile show= is unchanged and still returns all of it | |
| 318 | at once. The iOS client decodes and renders both formats, | |
| 312 | 319 | through the same OrgSwift path a README takes. |
| 313 | 320 | |
| 314 | 321 | =help= lists the command registry. Bare it is an index, one line per |
| @@ -428,11 +435,13 @@ key, the other cannot be undone. | ||
| 428 | 435 | |
| 429 | 436 | * Pagination |
| 430 | 437 | |
| 431 | =issue list=, =mr list=, =repo list=, and =feed= take =--limit <n>= | |
| 432 | and =--cursor <c>=. Cursors are opaque; each page carries the next | |
| 433 | one. Without the flags a list stays complete, so existing scripts are | |
| 434 | unchanged. The web pages the issue and merge request lists at fifty | |
| 435 | with 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 | |
| 440 | the next one. Without the flags a list stays as it was, so existing | |
| 441 | scripts are unchanged — for =build list= that means the newest fifty | |
| 442 | matching builds, which is what the cursor now reaches past. The web | |
| 443 | pages the issue and merge request lists at fifty and the builds list at | |
| 444 | thirty with the same cursors; iOS pages with them too. | |
| 436 | 445 | |
| 437 | 446 | * CLI only, for now |
| 438 | 447 | |
internal/httpd/orgweb.go +3 −1
| @@ -43,9 +43,11 @@ func (s *Server) orgAdminView(viewer store.User, kind, name string) (teams []tea | ||
| 43 | 43 | // entries stay in one implementation. |
| 44 | 44 | func (s *Server) orgSubmit(w http.ResponseWriter, r *http.Request, u store.User) { |
| 45 | 45 | 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. | |
| 46 | 48 | back := func(msg string) { |
| 47 | 49 | s.setFlash(w, msg) |
| 48 | http.Redirect(w, r, "/"+owner, http.StatusSeeOther) | |
| 50 | http.Redirect(w, r, "/"+owner+"/-/people", http.StatusSeeOther) | |
| 49 | 51 | } |
| 50 | 52 | field := r.FormValue("field") |
| 51 | 53 | team := strings.TrimSpace(r.FormValue("team")) |
internal/httpd/ownerpage_test.go added +141
| @@ -0,0 +1,141 @@ | ||
| 1 | package httpd | |
| 2 | ||
| 3 | import ( | |
| 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 | ||
| 13 | func 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. | |
| 31 | func 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 | ||
| 56 | func 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 | ||
| 73 | func 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 | ||
| 94 | type ownerFixtureData struct { | |
| 95 | Kind string | |
| 96 | AboutHTML template.HTML | |
| 97 | CanAdmin bool | |
| 98 | Members []control.ProfileMember | |
| 99 | } | |
| 100 | ||
| 101 | func ownerFixture() ownerFixtureData { | |
| 102 | return ownerFixtureData{ | |
| 103 | Kind: "user", | |
| 104 | AboutHTML: template.HTML("<p><strong>Christian Cleberg</strong></p>"), | |
| 105 | } | |
| 106 | } | |
| 107 | ||
| 108 | func 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 { | ||
| 75 | 75 | Route{Method: "GET", Pattern: "/{owner}/{repo}/log.atom", Handler: s.logAtom}, |
| 76 | 76 | Route{Method: "GET", Pattern: "/{owner}/{repo}/log.atom/{ref...}", Handler: s.logAtom}, |
| 77 | 77 | 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}, | |
| 78 | 81 | Route{Method: "GET", Pattern: "/{owner}/-/labels", Handler: s.orgLabels}, |
| 79 | 82 | Route{Method: "GET", Pattern: "/{owner}/-/milestones", Handler: s.orgMilestones}, |
| 80 | 83 | 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 } | ||
| 426 | 426 | // ownerPage renders /{owner} for users and orgs: the repositories the |
| 427 | 427 | // viewer may see, org membership either direction. Owner names are not |
| 428 | 428 | // 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. | |
| 434 | func 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 | ||
| 429 | 446 | func (s *Server) ownerPage(w http.ResponseWriter, r *http.Request) { |
| 430 | 447 | name := r.PathValue("owner") |
| 431 | 448 | var viewer store.User |
| @@ -455,11 +472,21 @@ func (s *Server) ownerPage(w http.ResponseWriter, r *http.Request) { | ||
| 455 | 472 | weeks, activityTotal := activityGrid(counts) |
| 456 | 473 | |
| 457 | 474 | 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 | } | |
| 458 | 484 | profile := store.Profile{Description: d.Description, Website: d.Website, Links: d.Links} |
| 459 | 485 | s.render(w, "owner.html", struct { |
| 460 | 486 | basePage |
| 461 | 487 | Owner string |
| 462 | 488 | Kind string |
| 489 | Tab string | |
| 463 | 490 | Profile store.Profile |
| 464 | 491 | AboutHTML template.HTML |
| 465 | 492 | Repos []profileRepoRow |
| @@ -473,7 +500,7 @@ func (s *Server) ownerPage(w http.ResponseWriter, r *http.Request) { | ||
| 473 | 500 | Snippets int |
| 474 | 501 | Notice string |
| 475 | 502 | 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), | |
| 477 | 504 | d.Repos, d.Members, d.Orgs, |
| 478 | 505 | weeks, activityTotal, teams, canAdmin, |
| 479 | 506 | d.Kind == "user" && viewer.ID != 0 && strings.EqualFold(viewer.Username, name), |
internal/web/templates/owner.html +25 −8
| @@ -9,7 +9,29 @@ | ||
| 9 | 9 | {{if .Members}}<p class="meta">members {{range .Members}}<a class="memberchip" href="/{{.Name}}">{{.Name}} <span class="role">{{.Role}}</span></a> {{end}}</p>{{end}} |
| 10 | 10 | {{if and (eq .Kind "org") .Repos}}<p class="meta"><a href="/{{.Owner}}/-/labels">labels</a> · <a href="/{{.Owner}}/-/milestones">milestones</a></p>{{end}} |
| 11 | 11 | </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"}} | |
| 13 | 35 | <section class="activity"> |
| 14 | 36 | <h2>activity <span class="count">{{.ActivityTotal}} in the last year</span></h2> |
| 15 | 37 | <div class="actgraph-scroll"> |
| @@ -19,14 +41,9 @@ | ||
| 19 | 41 | </div> |
| 20 | 42 | <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> |
| 21 | 43 | </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}} | |
| 28 | 45 | |
| 29 | {{if .CanAdmin}}{{$org := .Owner}} | |
| 46 | {{if eq .Tab "people"}}{{$org := .Owner}} | |
| 30 | 47 | <h2>people <span class="count">{{len .Members}}</span></h2> |
| 31 | 48 | {{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}} |
| 32 | 49 | <div class="tablewrap"><table class="keys"> |