Commit 4e509e8b58
Verified · cmc ci/build: success ci/test: success ci/vuln: success
.gitbay/wiki/Parity.org +12 −6
| @@ -180,6 +180,7 @@ sit inside the diff, where a page-level preview has nowhere to go. | ||
| 180 | 180 | | pin | yes | yes | yes | |
| 181 | 181 | | bookmark | yes | yes | yes | |
| 182 | 182 | | bookmark list | yes | yes | yes | |
| 183 | | bookmarks on your profile | n/a | yes | no | | |
| 183 | 184 | | watch, unwatch | yes | yes | yes | |
| 184 | 185 | | mute | yes | no | yes | |
| 185 | 186 | | settings, protection | yes | yes | yes | |
| @@ -310,12 +311,17 @@ it as =about=, =about_format= and =about_path=. It reads with the | ||
| 310 | 311 | repository's own access, so a private =.gitbay= is a profile with no |
| 311 | 312 | about text to anyone but its owner and the admins. A repository whose |
| 312 | 313 | name starts with a dot stays out of =explore= and off the profile's |
| 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, | |
| 314 | repository list. On the web a profile is tabs: =/<owner>= is About — | |
| 315 | the text, the year of squares and a log of the newest thirty events — | |
| 316 | then =/<owner>/-/repositories=, =/<owner>/-/bookmarks=, | |
| 317 | =/<owner>/-/snippets=, and =/<owner>/-/people= for an organization's | |
| 318 | members and teams. A tab nobody may open is not offered and its URL is | |
| 319 | a 404: bookmarks are the viewer's own, only a user has snippets, and | |
| 320 | people is the org admin panel. The log counts what the graph over it | |
| 321 | counts — a user's own events, an organization's repositories' — on | |
| 322 | public repositories only; =activity.atom= is the feed that goes back | |
| 323 | further. The CLI's =profile show= is unchanged and still returns all of | |
| 324 | it at once. The iOS client decodes and renders both formats, | |
| 319 | 325 | through the same OrgSwift path a README takes. |
| 320 | 326 | |
| 321 | 327 | =help= lists the command registry. Bare it is an index, one line per |
CHANGELOG.org +21
| @@ -4,6 +4,27 @@ Versioning follows semver from v0.1.0. Database migrations run | ||
| 4 | 4 | automatically on daemon start; upgrade notes appear per release when |
| 5 | 5 | anything beyond "replace the binary and restart" is needed. |
| 6 | 6 | |
| 7 | * Unreleased | |
| 8 | ||
| 9 | The profile's tabs, reworked. | |
| 10 | ||
| 11 | - The bar reads About, Repositories, Bookmarks, Snippets, and People | |
| 12 | for an organization's admins. =/{owner}= is About; the rest hang off | |
| 13 | =/-/=. A tab nobody may open is not offered and its URL is a 404. | |
| 14 | - The activity graph moved inside About, with a log of the newest | |
| 15 | thirty events under it. The log counts what the graph over it counts: | |
| 16 | =UserPublicEvents= keys on the actor for a user, matching | |
| 17 | =ActivityByDay=, while an organization keeps =OwnerPublicEvents=. Both | |
| 18 | are public repositories only. =activity.atom= is still the feed that | |
| 19 | goes back further. | |
| 20 | - Bookmarks are a tab on your own profile. =repo bookmarks= takes no | |
| 21 | owner and lists the caller's, so the tab is not offered on anyone | |
| 22 | else's and its URL is a 404 there. =/bookmarks= redirects to it, and | |
| 23 | the rail points at the tab. | |
| 24 | - The snippet list renders inside the profile rather than on a page of | |
| 25 | its own, so it carries the header and the tab bar like every other | |
| 26 | section. A snippet itself is still its own page. | |
| 27 | ||
| 7 | 28 | * v1.33.0 — 2026-09-21 |
| 8 | 29 | |
| 9 | 30 | Five findings from the outside review of the web UI (#241–#245). |
e2e/activity_test.go +4 −4
| @@ -50,7 +50,7 @@ func TestActivityGraph(t *testing.T) { | ||
| 50 | 50 | if _, _, code := inst.ssh(t, aliceKey, "", "issue", "create", "alice/app", "--title", "'x'"); code != 0 { |
| 51 | 51 | t.Fatal("issue create failed") |
| 52 | 52 | } |
| 53 | status, body := inst.get(t, "/alice/-/activity") | |
| 53 | status, body := inst.get(t, "/alice") | |
| 54 | 54 | if status != 200 || !strings.Contains(body, `class="actgraph"`) { |
| 55 | 55 | t.Fatalf("graph missing: %d", status) |
| 56 | 56 | } |
| @@ -62,14 +62,14 @@ func TestActivityGraph(t *testing.T) { | ||
| 62 | 62 | t.Fatalf("alice total = %d, want >= 2", total) |
| 63 | 63 | } |
| 64 | 64 | // bob authored a commit but his email is unverified: zero activity. |
| 65 | _, bobBody := inst.get(t, "/bob/-/activity") | |
| 65 | _, bobBody := inst.get(t, "/bob") | |
| 66 | 66 | if bt := activityTotal(t, bobBody); bt != 0 { |
| 67 | 67 | t.Fatalf("unverified author got credit: total %d", bt) |
| 68 | 68 | } |
| 69 | 69 | |
| 70 | 70 | // Re-pushing the same history (force) does not double-count. |
| 71 | 71 | mustGit(t, dir, env, "push", "-q", "--force", "origin", "main") |
| 72 | _, body2 := inst.get(t, "/alice/-/activity") | |
| 72 | _, body2 := inst.get(t, "/alice") | |
| 73 | 73 | if body2 != body { |
| 74 | 74 | // Counts must be identical; compare just the graph cells. |
| 75 | 75 | if excerpt(body, "actgraph") != excerpt(body2, "actgraph") { |
| @@ -84,7 +84,7 @@ func TestActivityGraph(t *testing.T) { | ||
| 84 | 84 | if _, _, code := inst.ssh(t, aliceKey, "", "repo", "transfer", "alice/app", "theorg"); code != 0 { |
| 85 | 85 | t.Fatal("transfer failed") |
| 86 | 86 | } |
| 87 | _, orgBody := inst.get(t, "/theorg/-/activity") | |
| 87 | _, orgBody := inst.get(t, "/theorg") | |
| 88 | 88 | if !strings.Contains(orgBody, `class="actgraph"`) || strings.Contains(orgBody, "0 in the last year") { |
| 89 | 89 | t.Fatalf("org graph empty:\n%s", excerpt(orgBody, "activity")) |
| 90 | 90 | } |
e2e/description_test.go +3 −2
| @@ -48,8 +48,9 @@ func TestRepoDescriptions(t *testing.T) { | ||
| 48 | 48 | t.Fatalf("non-admin set: exit %d, want 4", code) |
| 49 | 49 | } |
| 50 | 50 | |
| 51 | // Web: explore, owner page, and repo header all show it. | |
| 52 | for _, path := range []string{"/explore", "/alice", "/alice/tool"} { | |
| 51 | // Web: explore, the profile's repositories tab, and the repo header | |
| 52 | // all show it. | |
| 53 | for _, path := range []string{"/explore", "/alice/-/repositories", "/alice/tool"} { | |
| 53 | 54 | status, body := inst.get(t, path) |
| 54 | 55 | if status != 200 || !strings.Contains(body, "better now") { |
| 55 | 56 | t.Fatalf("description missing at %s (%d)", path, status) |
e2e/org_test.go +1 −1
| @@ -179,7 +179,7 @@ func TestOrganizations(t *testing.T) { | ||
| 179 | 179 | if status, _ := inst.get(t, "/puborg/site"); status != 200 { |
| 180 | 180 | t.Fatalf("org repo page: %d", status) |
| 181 | 181 | } |
| 182 | status, body = inst.get(t, "/puborg") | |
| 182 | status, body = inst.get(t, "/puborg/-/repositories") | |
| 183 | 183 | if status != 200 || !strings.Contains(body, "org") || !strings.Contains(body, "alice") || !strings.Contains(body, ">site<") { |
| 184 | 184 | t.Fatalf("org owner page: %d\n%s", status, body) |
| 185 | 185 | } |
e2e/profile_test.go +13 −14
| @@ -76,7 +76,7 @@ func TestOwnerProfiles(t *testing.T) { | ||
| 76 | 76 | } |
| 77 | 77 | // The owner page renders those rows, having dispatched the same |
| 78 | 78 | // command rather than assembling them from the store again. |
| 79 | status, body := inst.get(t, "/alice") | |
| 79 | status, body := inst.get(t, "/alice/-/repositories") | |
| 80 | 80 | if status != 200 { |
| 81 | 81 | t.Fatalf("owner page: %d", status) |
| 82 | 82 | } |
| @@ -158,7 +158,7 @@ func TestOwnerProfiles(t *testing.T) { | ||
| 158 | 158 | } |
| 159 | 159 | // Org emphasis parsed, not left as literal slashes the way the |
| 160 | 160 | // markdown renderer would. |
| 161 | _, body = inst.get(t, "/alice/-/about") | |
| 161 | _, body = inst.get(t, "/alice") | |
| 162 | 162 | if !strings.Contains(body, "<em>small tools</em>") || strings.Contains(body, "/small tools/") { |
| 163 | 163 | t.Fatalf("org about not rendered as org: %s", body) |
| 164 | 164 | } |
| @@ -197,8 +197,9 @@ func TestOwnerProfiles(t *testing.T) { | ||
| 197 | 197 | t.Fatal("more than five links accepted") |
| 198 | 198 | } |
| 199 | 199 | |
| 200 | // The bare owner page is the repository list, with the description | |
| 201 | // and the link chips in the header above the tabs (#242). | |
| 200 | // The bare owner page is About: the text, the graph and the log, | |
| 201 | // with the description and the link chips in the header above the | |
| 202 | // tabs (#242). The repositories are one tab along. | |
| 202 | 203 | status, body = inst.get(t, "/alice") |
| 203 | 204 | if status != 200 || !strings.Contains(body, "tinkerer") { |
| 204 | 205 | t.Fatalf("user page profile: %d", status) |
| @@ -207,19 +208,17 @@ func TestOwnerProfiles(t *testing.T) { | ||
| 207 | 208 | !strings.Contains(body, ">Mastodon<") { |
| 208 | 209 | t.Fatalf("links not rendered: %s", body) |
| 209 | 210 | } |
| 210 | if !strings.Contains(body, `<ul class="repolist"`) { | |
| 211 | t.Error("the owner page is not the repository list") | |
| 211 | if !strings.Contains(body, "<em>small tools</em>") { | |
| 212 | t.Fatalf("about not rendered: %s", body) | |
| 212 | 213 | } |
| 213 | if strings.Contains(body, `class="activity"`) || strings.Contains(body, "<em>small tools</em>") { | |
| 214 | t.Error("the about text or the activity graph still sits on the repository page") | |
| 214 | if !strings.Contains(body, `class="activity"`) { | |
| 215 | t.Error("the about tab has no activity graph") | |
| 215 | 216 | } |
| 216 | // Each of them is one tab along, and the markdown renders there. | |
| 217 | _, aboutBody := inst.get(t, "/alice/-/about") | |
| 218 | if !strings.Contains(aboutBody, "<em>small tools</em>") { | |
| 219 | t.Fatalf("about not rendered: %s", aboutBody) | |
| 217 | if strings.Contains(body, `<ul class="repolist"`) { | |
| 218 | t.Error("the repository list still sits under the about text") | |
| 220 | 219 | } |
| 221 | if _, g := inst.get(t, "/alice/-/activity"); !strings.Contains(g, `class="activity"`) { | |
| 222 | t.Error("the activity tab has no graph") | |
| 220 | if _, repos := inst.get(t, "/alice/-/repositories"); !strings.Contains(repos, `<ul class="repolist"`) { | |
| 221 | t.Error("the repositories tab has no repository list") | |
| 223 | 222 | } |
| 224 | 223 | |
| 225 | 224 | // Clearing works the same way as the other fields. The about is not |
e2e/profileabout_test.go +2 −2
| @@ -40,7 +40,7 @@ func TestProfileAboutFromRepo(t *testing.T) { | ||
| 40 | 40 | t.Errorf("about_path missing: %s", out) |
| 41 | 41 | } |
| 42 | 42 | |
| 43 | _, body := inst.get(t, "/alice/-/about") | |
| 43 | _, body := inst.get(t, "/alice") | |
| 44 | 44 | if !strings.Contains(body, "hello from a file") { |
| 45 | 45 | t.Error("web profile does not render the about") |
| 46 | 46 | } |
| @@ -54,7 +54,7 @@ func TestProfileAboutFromRepo(t *testing.T) { | ||
| 54 | 54 | "--ref", "main", "--file", "-"); code != 0 { |
| 55 | 55 | t.Fatalf("committing bob's org about: %s", errOut) |
| 56 | 56 | } |
| 57 | if _, page := inst.get(t, "/bob/-/about"); !strings.Contains(page, "<em>note</em>") { | |
| 57 | if _, page := inst.get(t, "/bob"); !strings.Contains(page, "<em>note</em>") { | |
| 58 | 58 | t.Errorf("about did not render as org:\n%s", page) |
| 59 | 59 | } |
| 60 | 60 | } |
e2e/web_test.go +3 −2
| @@ -201,8 +201,9 @@ func TestWebUI(t *testing.T) { | ||
| 201 | 201 | t.Fatalf("txt README not escaped-plaintext:\n%s", body) |
| 202 | 202 | } |
| 203 | 203 | |
| 204 | // Owner page: lists visible repos only; unknown owners 404. | |
| 205 | status, body = inst.get(t, "/alice") | |
| 204 | // Owner page: the repositories tab lists visible repos only; | |
| 205 | // unknown owners 404. | |
| 206 | status, body = inst.get(t, "/alice/-/repositories") | |
| 206 | 207 | if status != 200 || !strings.Contains(body, ">site<") || !strings.Contains(body, "user") { |
| 207 | 208 | t.Fatalf("owner page: %d", status) |
| 208 | 209 | } |
internal/httpd/accounts.go +4 −7
| @@ -262,14 +262,11 @@ func (s *Server) bookmarkToggle(w http.ResponseWriter, r *http.Request, u store. | ||
| 262 | 262 | } |
| 263 | 263 | |
| 264 | 264 | // bookmarksPage lists what the viewer has saved. |
| 265 | // bookmarksPage keeps /bookmarks working: the list is a tab on the | |
| 266 | // viewer's own profile now, so there is one page of it rather than two | |
| 267 | // showing the same rows. | |
| 265 | 268 | func (s *Server) bookmarksPage(w http.ResponseWriter, r *http.Request, u store.User) { |
| 266 | var rows []control.BookmarkOut | |
| 267 | s.runControlInto(u, []string{"repo", "bookmarks"}, &rows) | |
| 268 | s.render(w, "bookmarks.html", struct { | |
| 269 | basePage | |
| 270 | Tab string | |
| 271 | Bookmarks []control.BookmarkOut | |
| 272 | }{s.baseFor(u), "bookmarks", rows}) | |
| 269 | http.Redirect(w, r, "/"+u.Username+"/-/bookmarks", http.StatusSeeOther) | |
| 273 | 270 | } |
| 274 | 271 | |
| 275 | 272 | // renderFork draws the fork form: where the copy lands and what it is |
internal/httpd/ownerpage_test.go +120 −71
| @@ -12,12 +12,13 @@ import ( | ||
| 12 | 12 | |
| 13 | 13 | func TestProfileTabFromPath(t *testing.T) { |
| 14 | 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", | |
| 15 | "/cmc": "about", | |
| 16 | "/cmc/-/repositories": "repos", | |
| 17 | "/cmc/-/bookmarks": "bookmarks", | |
| 18 | "/cmc/-/snippets": "snippets", | |
| 19 | "/cmc/-/people": "people", | |
| 20 | "/krz": "about", | |
| 21 | "/cmc/-/snippets/abcd": "about", | |
| 21 | 22 | } { |
| 22 | 23 | if got := profileTab(path); got != want { |
| 23 | 24 | t.Errorf("profileTab(%q) = %q, want %q", path, got, want) |
| @@ -25,48 +26,94 @@ func TestProfileTabFromPath(t *testing.T) { | ||
| 25 | 26 | } |
| 26 | 27 | } |
| 27 | 28 | |
| 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) | |
| 29 | // The profile is sections rather than one stack (#242): the bar reads | |
| 30 | // About, Repositories, Bookmarks, Snippets, and each holds one thing. | |
| 31 | func TestOwnerPageTabOrder(t *testing.T) { | |
| 32 | d := ownerFixture() | |
| 33 | d.Self, d.Snippets = true, 2 | |
| 34 | out := renderOwner(t, "about", d) | |
| 35 | order := []string{ | |
| 36 | `href="/cmc">About`, | |
| 37 | `href="/cmc/-/repositories">Repositories`, | |
| 38 | `href="/cmc/-/bookmarks">Bookmarks`, | |
| 39 | `href="/cmc/-/snippets">Snippets`, | |
| 40 | } | |
| 41 | at := -1 | |
| 42 | for _, want := range order { | |
| 43 | i := strings.Index(out, want) | |
| 44 | if i < 0 { | |
| 45 | t.Fatalf("tab bar missing %q:\n%s", want, out) | |
| 46 | } | |
| 47 | if i < at { | |
| 48 | t.Errorf("tab %q is out of order", want) | |
| 39 | 49 | } |
| 50 | at = i | |
| 40 | 51 | } |
| 41 | for _, want := range []string{ | |
| 42 | `aria-current="page" href="/cmc"`, | |
| 43 | `href="/cmc/-/about"`, | |
| 44 | `href="/cmc/-/activity"`, | |
| 45 | } { | |
| 52 | } | |
| 53 | ||
| 54 | // The graph moved inside About, with a log of the newest events under | |
| 55 | // it — not the whole history, which is what the atom feed is for. | |
| 56 | func TestOwnerPageAboutCarriesTheGraphAndLog(t *testing.T) { | |
| 57 | d := ownerFixture() | |
| 58 | d.Log = []feedLine{{Actor: "cmc", Verb: "opened issue", Ref: "#12", Repo: "krz/gitbay", URL: "/krz/gitbay/issues/12"}} | |
| 59 | out := renderOwner(t, "about", d) | |
| 60 | for _, want := range []string{"Christian Cleberg", "actgraph", "opened issue", "#12", "/cmc/activity.atom"} { | |
| 46 | 61 | if !strings.Contains(out, want) { |
| 47 | t.Errorf("tab bar missing %q:\n%s", want, out) | |
| 62 | t.Errorf("about tab missing %q:\n%s", want, out) | |
| 48 | 63 | } |
| 49 | 64 | } |
| 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) | |
| 65 | if strings.Contains(out, "reminiscecleberg.com") { | |
| 66 | t.Errorf("the about tab carries the repository list:\n%s", out) | |
| 67 | } | |
| 68 | // A profile with no About file still has the graph, so the tab is | |
| 69 | // never empty and never a 404. | |
| 70 | d.AboutHTML = "" | |
| 71 | if out := renderOwner(t, "about", d); !strings.Contains(out, "actgraph") { | |
| 72 | t.Errorf("a profile with no About file loses its graph:\n%s", out) | |
| 73 | } | |
| 74 | // Nothing to log reads as nothing, not as an empty block. | |
| 75 | d.Log = nil | |
| 76 | if out := renderOwner(t, "about", d); !strings.Contains(out, "Nothing yet.") { | |
| 77 | t.Errorf("an empty log says nothing:\n%s", out) | |
| 53 | 78 | } |
| 54 | 79 | } |
| 55 | 80 | |
| 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) | |
| 81 | func TestOwnerPageSectionsAreSeparate(t *testing.T) { | |
| 82 | d := ownerFixture() | |
| 83 | d.Self, d.Snippets = true, 1 | |
| 84 | d.Bookmarks = []control.BookmarkOut{{Path: "krz/hutch", Description: "a SourceHut client", Visibility: "public"}} | |
| 85 | d.SnippetRows = []snippetRow{{store.Snippet{PublicID: "ab12", Description: "a shell one-liner", Visibility: "public"}, "run.sh"}} | |
| 86 | ||
| 87 | repos := renderOwner(t, "repos", d) | |
| 88 | if !strings.Contains(repos, "reminiscecleberg.com") || strings.Contains(repos, "actgraph") { | |
| 89 | t.Errorf("repos tab is not the repository list alone:\n%s", repos) | |
| 90 | } | |
| 91 | marks := renderOwner(t, "bookmarks", d) | |
| 92 | if !strings.Contains(marks, "krz/hutch") || strings.Contains(marks, "reminiscecleberg.com") { | |
| 93 | t.Errorf("bookmarks tab is not the bookmark list alone:\n%s", marks) | |
| 60 | 94 | } |
| 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) | |
| 95 | // The snippet list renders here rather than on a page of its own. | |
| 96 | snips := renderOwner(t, "snippets", d) | |
| 97 | for _, want := range []string{"a shell one-liner", "run.sh", `href="/cmc/-/snippets/ab12"`, "new snippet"} { | |
| 98 | if !strings.Contains(snips, want) { | |
| 99 | t.Errorf("snippets tab missing %q:\n%s", want, snips) | |
| 100 | } | |
| 101 | } | |
| 102 | if !strings.Contains(snips, `href="/cmc/-/repositories">Repositories`) { | |
| 103 | t.Errorf("the snippets tab lost the profile's tab bar:\n%s", snips) | |
| 104 | } | |
| 105 | } | |
| 106 | ||
| 107 | // Bookmarks are the viewer's own: repo bookmarks takes no owner and | |
| 108 | // lists the caller's. The tab is not offered on anyone else's profile. | |
| 109 | func TestOwnerPageBookmarksTabIsSelfOnly(t *testing.T) { | |
| 110 | if out := renderOwner(t, "about", ownerFixture()); strings.Contains(out, "/-/bookmarks") { | |
| 111 | t.Errorf("a stranger's profile offers a bookmarks tab:\n%s", out) | |
| 64 | 112 | } |
| 65 | // A profile with no About file does not offer the tab. | |
| 66 | 113 | 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) | |
| 114 | d.Self = true | |
| 115 | if out := renderOwner(t, "about", d); !strings.Contains(out, "/-/bookmarks") { | |
| 116 | t.Errorf("own profile has no bookmarks tab:\n%s", out) | |
| 70 | 117 | } |
| 71 | 118 | } |
| 72 | 119 | |
| @@ -76,12 +123,16 @@ func TestOwnerPagePeopleTabHoldsTheAdminPanel(t *testing.T) { | ||
| 76 | 123 | d.CanAdmin = true |
| 77 | 124 | d.Members = []control.ProfileMember{{Name: "cmc", Role: "admin"}} |
| 78 | 125 | |
| 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) | |
| 126 | about := renderOwner(t, "about", d) | |
| 127 | if !strings.Contains(about, `href="/cmc/-/people"`) { | |
| 128 | t.Errorf("an admin gets no people tab:\n%s", about) | |
| 129 | } | |
| 130 | if strings.Contains(about, "Create a team") { | |
| 131 | t.Errorf("the admin forms still sit on another tab:\n%s", about) | |
| 82 | 132 | } |
| 83 | if strings.Contains(repos, "Create a team") { | |
| 84 | t.Errorf("the admin forms still sit under the repository list:\n%s", repos) | |
| 133 | // An org has no snippets, so it is not offered the tab. | |
| 134 | if strings.Contains(about, "/-/snippets") { | |
| 135 | t.Errorf("an org offers a snippets tab:\n%s", about) | |
| 85 | 136 | } |
| 86 | 137 | people := renderOwner(t, "people", d) |
| 87 | 138 | for _, want := range []string{"Create a team", "member-add", "org-rename"} { |
| @@ -92,10 +143,15 @@ func TestOwnerPagePeopleTabHoldsTheAdminPanel(t *testing.T) { | ||
| 92 | 143 | } |
| 93 | 144 | |
| 94 | 145 | type ownerFixtureData struct { |
| 95 | Kind string | |
| 96 | AboutHTML template.HTML | |
| 97 | CanAdmin bool | |
| 98 | Members []control.ProfileMember | |
| 146 | Kind string | |
| 147 | AboutHTML template.HTML | |
| 148 | CanAdmin bool | |
| 149 | Self bool | |
| 150 | Snippets int | |
| 151 | Members []control.ProfileMember | |
| 152 | Log []feedLine | |
| 153 | Bookmarks []control.BookmarkOut | |
| 154 | SnippetRows []snippetRow | |
| 99 | 155 | } |
| 100 | 156 | |
| 101 | 157 | func ownerFixture() ownerFixtureData { |
| @@ -108,31 +164,24 @@ func ownerFixture() ownerFixtureData { | ||
| 108 | 164 | func renderOwner(t *testing.T, tab string, d ownerFixtureData) string { |
| 109 | 165 | t.Helper() |
| 110 | 166 | 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", | |
| 167 | err := web.Render(&sb, "owner.html", ownerPage{ | |
| 168 | basePage: basePage{Site: "gitbay"}, | |
| 169 | Owner: "cmc", | |
| 170 | Kind: d.Kind, | |
| 171 | Tab: tab, | |
| 172 | Profile: store.Profile{Description: "Org-Mode · Self-Hosting · Privacy"}, | |
| 173 | AboutHTML: d.AboutHTML, | |
| 174 | Repos: []profileRepoRow{{control.ProfileRepo{Path: "cmc/reminiscecleberg.com", Description: "Personal placeholder site."}}}, | |
| 175 | Members: d.Members, | |
| 176 | Activity: []activityWeek{{Month: "Sep", Days: []activityDay{{Date: "2026-09-20", Count: 3, Level: 2}}}}, | |
| 177 | ActivityTotal: 6088, | |
| 178 | Log: d.Log, | |
| 179 | Bookmarks: d.Bookmarks, | |
| 180 | SnippetRows: d.SnippetRows, | |
| 181 | CanAdmin: d.CanAdmin, | |
| 182 | Self: d.Self, | |
| 183 | Snippets: d.Snippets, | |
| 184 | Feed: "/cmc/activity.atom", | |
| 136 | 185 | }) |
| 137 | 186 | if err != nil { |
| 138 | 187 | t.Fatalf("render: %v", err) |
internal/httpd/routes.go +5 −5
| @@ -59,7 +59,7 @@ func (s *Server) Routes() []Route { | ||
| 59 | 59 | } |
| 60 | 60 | routes = append(routes, |
| 61 | 61 | Route{Method: "GET", Pattern: "/favicon.svg", Handler: s.favicon}, |
| 62 | Route{Method: "GET", Pattern: "/{owner}", Handler: s.ownerPage}, | |
| 62 | Route{Method: "GET", Pattern: "/{owner}", Handler: s.ownerProfile}, | |
| 63 | 63 | Route{Method: "GET", Pattern: "/{owner}/{repo}", Handler: s.repoHome}, |
| 64 | 64 | Route{Method: "GET", Pattern: "/{owner}/{repo}/tree/{ref}/{path...}", Handler: s.tree}, |
| 65 | 65 | Route{Method: "GET", Pattern: "/{owner}/{repo}/blob/{ref}/{path...}", Handler: s.blob}, |
| @@ -75,12 +75,12 @@ 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 | Route{Method: "GET", Pattern: "/{owner}/-/repositories", Handler: s.ownerProfile}, | |
| 79 | Route{Method: "GET", Pattern: "/{owner}/-/bookmarks", Handler: s.ownerProfile}, | |
| 80 | Route{Method: "GET", Pattern: "/{owner}/-/people", Handler: s.ownerProfile}, | |
| 81 | 81 | Route{Method: "GET", Pattern: "/{owner}/-/labels", Handler: s.orgLabels}, |
| 82 | 82 | Route{Method: "GET", Pattern: "/{owner}/-/milestones", Handler: s.orgMilestones}, |
| 83 | Route{Method: "GET", Pattern: "/{owner}/-/snippets", Handler: s.snippetsPage}, | |
| 83 | Route{Method: "GET", Pattern: "/{owner}/-/snippets", Handler: s.ownerProfile}, | |
| 84 | 84 | Route{Method: "GET", Pattern: "/{owner}/-/snippets/{id}", Handler: s.snippetPage}, |
| 85 | 85 | Route{Method: "GET", Pattern: "/{owner}/-/snippets/{id}/raw/{name}", Handler: s.snippetRaw}, |
| 86 | 86 | Route{Method: "GET", Pattern: "/{owner}/{repo}/builds", Handler: s.builds}, |
internal/httpd/snippets.go +10 −15
| @@ -30,19 +30,21 @@ type snippetRow struct { | ||
| 30 | 30 | Names string |
| 31 | 31 | } |
| 32 | 32 | |
| 33 | func (s *Server) snippetsPage(w http.ResponseWriter, r *http.Request) { | |
| 34 | viewer := s.viewer(r) | |
| 35 | owner, err := s.st.UserByUsername(r.PathValue("owner")) | |
| 33 | // ownerSnippets lists an owner's snippets for the profile's Snippets | |
| 34 | // tab. The list is a section of the profile like the repositories are, | |
| 35 | // not a page of its own (a snippet itself still is). A private snippet | |
| 36 | // is in the list only for its owner and the admins. | |
| 37 | func (s *Server) ownerSnippets(w http.ResponseWriter, r *http.Request, viewer store.User, name string) ([]snippetRow, bool) { | |
| 38 | owner, err := s.st.UserByUsername(name) | |
| 36 | 39 | if err != nil { |
| 37 | 40 | s.notFound(w, r) |
| 38 | return | |
| 41 | return nil, false | |
| 39 | 42 | } |
| 40 | self := viewer.ID != 0 && viewer.ID == owner.ID | |
| 41 | all := self || viewer.IsAdmin | |
| 43 | all := viewer.IsAdmin || (viewer.ID != 0 && viewer.ID == owner.ID) | |
| 42 | 44 | list, err := s.st.ListSnippets(owner.ID, all, 0, 0) |
| 43 | 45 | if err != nil { |
| 44 | 46 | http.Error(w, "internal error", http.StatusInternalServerError) |
| 45 | return | |
| 47 | return nil, false | |
| 46 | 48 | } |
| 47 | 49 | rows := make([]snippetRow, 0, len(list)) |
| 48 | 50 | for _, sn := range list { |
| @@ -55,14 +57,7 @@ func (s *Server) snippetsPage(w http.ResponseWriter, r *http.Request) { | ||
| 55 | 57 | } |
| 56 | 58 | rows = append(rows, snippetRow{sn, names.String()}) |
| 57 | 59 | } |
| 58 | s.render(w, "snippets.html", struct { | |
| 59 | basePage | |
| 60 | Owner string | |
| 61 | Self bool | |
| 62 | All bool | |
| 63 | Snippets []snippetRow | |
| 64 | Notice string | |
| 65 | }{s.baseFor(viewer), owner.Username, self, all, rows, s.takeFlash(w, r)}) | |
| 60 | return rows, true | |
| 66 | 61 | } |
| 67 | 62 | |
| 68 | 63 | type snippetFileView struct { |
internal/httpd/web.go +121 −40
| @@ -427,23 +427,92 @@ func (p profileRepoRow) Desc() string { return p.Description } | ||
| 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 | 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. | |
| 430 | // /{owner} is About, the first tab; the rest hang off the /-/ namespace | |
| 431 | // the labels and milestones pages already use. What #242 asked for is | |
| 432 | // that the sections be separate pages rather than one stack a long | |
| 433 | // About pushes the repositories off the bottom of — not that any one of | |
| 434 | // them be the landing page. | |
| 434 | 435 | func profileTab(path string) string { |
| 435 | 436 | switch { |
| 436 | case strings.HasSuffix(path, "/-/about"): | |
| 437 | return "about" | |
| 438 | case strings.HasSuffix(path, "/-/activity"): | |
| 439 | return "activity" | |
| 437 | case strings.HasSuffix(path, "/-/repositories"): | |
| 438 | return "repos" | |
| 439 | case strings.HasSuffix(path, "/-/bookmarks"): | |
| 440 | return "bookmarks" | |
| 441 | case strings.HasSuffix(path, "/-/snippets"): | |
| 442 | return "snippets" | |
| 440 | 443 | case strings.HasSuffix(path, "/-/people"): |
| 441 | 444 | return "people" |
| 442 | 445 | } |
| 443 | return "repos" | |
| 446 | return "about" | |
| 444 | 447 | } |
| 445 | 448 | |
| 446 | func (s *Server) ownerPage(w http.ResponseWriter, r *http.Request) { | |
| 449 | // profileEvents is how many activity lines the About tab lists under the | |
| 450 | // graph. The graph is a year at a glance; the log is what happened | |
| 451 | // lately, and a fixed count keeps the page the same length whatever the | |
| 452 | // account's pace. | |
| 453 | const profileEvents = 30 | |
| 454 | ||
| 455 | // ownerFeed is the activity log under the graph on the About tab: the | |
| 456 | // newest of whatever the graph above it counts, on public repositories | |
| 457 | // only. That is the actor's own events for a user and the | |
| 458 | // organization's repositories' events for an org, matching | |
| 459 | // ActivityByDay and OrgActivityByDay respectively — a log that counted | |
| 460 | // something else would contradict the total printed over it. Only the | |
| 461 | // About tab renders it, so no other tab pays for the query. | |
| 462 | func (s *Server) ownerFeed(tab, kind, name string) []feedLine { | |
| 463 | if tab != "about" { | |
| 464 | return nil | |
| 465 | } | |
| 466 | var events []store.FeedEvent | |
| 467 | var err error | |
| 468 | switch kind { | |
| 469 | case "user": | |
| 470 | u, uerr := s.st.UserByUsername(name) | |
| 471 | if uerr != nil { | |
| 472 | return nil | |
| 473 | } | |
| 474 | events, err = s.st.UserPublicEvents(u.ID, profileEvents) | |
| 475 | case "org": | |
| 476 | o, oerr := s.st.OrgByName(name) | |
| 477 | if oerr != nil { | |
| 478 | return nil | |
| 479 | } | |
| 480 | events, err = s.st.OwnerPublicEvents("org", o.ID, profileEvents) | |
| 481 | } | |
| 482 | if err != nil { | |
| 483 | return nil | |
| 484 | } | |
| 485 | return feedLines(events) | |
| 486 | } | |
| 487 | ||
| 488 | // ownerPage is what owner.html renders against. It is a named type | |
| 489 | // because the handler and the tests must agree on it field for field, | |
| 490 | // and an anonymous struct in two places drifts. | |
| 491 | type ownerPage struct { | |
| 492 | basePage | |
| 493 | Owner string | |
| 494 | Kind string | |
| 495 | Tab string | |
| 496 | Profile store.Profile | |
| 497 | AboutHTML template.HTML | |
| 498 | Repos []profileRepoRow | |
| 499 | Members []control.ProfileMember | |
| 500 | Orgs []control.ProfileMember | |
| 501 | Activity []activityWeek | |
| 502 | ActivityTotal int | |
| 503 | Log []feedLine | |
| 504 | Bookmarks []control.BookmarkOut | |
| 505 | SnippetRows []snippetRow | |
| 506 | SnippetsAll bool | |
| 507 | Teams []teamView | |
| 508 | CanAdmin bool | |
| 509 | Self bool | |
| 510 | Snippets int | |
| 511 | Notice string | |
| 512 | Feed string | |
| 513 | } | |
| 514 | ||
| 515 | func (s *Server) ownerProfile(w http.ResponseWriter, r *http.Request) { | |
| 447 | 516 | name := r.PathValue("owner") |
| 448 | 517 | var viewer store.User |
| 449 | 518 | if s.cfg.Web.Mode == "accounts" { |
| @@ -472,40 +541,52 @@ func (s *Server) ownerPage(w http.ResponseWriter, r *http.Request) { | ||
| 472 | 541 | weeks, activityTotal := activityGrid(counts) |
| 473 | 542 | |
| 474 | 543 | teams, canAdmin := s.orgAdminView(viewer, d.Kind, name) |
| 544 | self := d.Kind == "user" && viewer.ID != 0 && strings.EqualFold(viewer.Username, name) | |
| 475 | 545 | 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 == "") { | |
| 546 | // A tab nobody may open is not a page: the people tab is the | |
| 547 | // organization admin panel, bookmarks are the viewer's own and | |
| 548 | // nobody else's, and only a user has snippets. Each answers the way | |
| 549 | // a missing page does rather than rendering empty. | |
| 550 | if (tab == "people" && !canAdmin) || (tab == "bookmarks" && !self) || | |
| 551 | (tab == "snippets" && d.Kind != "user") { | |
| 481 | 552 | s.notFound(w, r) |
| 482 | 553 | return |
| 483 | 554 | } |
| 484 | profile := store.Profile{Description: d.Description, Website: d.Website, Links: d.Links} | |
| 485 | s.render(w, "owner.html", struct { | |
| 486 | basePage | |
| 487 | Owner string | |
| 488 | Kind string | |
| 489 | Tab string | |
| 490 | Profile store.Profile | |
| 491 | AboutHTML template.HTML | |
| 492 | Repos []profileRepoRow | |
| 493 | Members []control.ProfileMember | |
| 494 | Orgs []control.ProfileMember | |
| 495 | Activity []activityWeek | |
| 496 | ActivityTotal int | |
| 497 | Teams []teamView | |
| 498 | CanAdmin bool | |
| 499 | Self bool | |
| 500 | Snippets int | |
| 501 | Notice string | |
| 502 | Feed string | |
| 503 | }{s.baseFor(viewer), name, d.Kind, tab, profile, aboutHTML(d.About, d.AboutFormat), | |
| 504 | d.Repos, d.Members, d.Orgs, | |
| 505 | weeks, activityTotal, teams, canAdmin, | |
| 506 | d.Kind == "user" && viewer.ID != 0 && strings.EqualFold(viewer.Username, name), | |
| 507 | d.Snippets, | |
| 508 | s.takeFlash(w, r), "/" + name + "/activity.atom"}) | |
| 555 | ||
| 556 | var bookmarks []control.BookmarkOut | |
| 557 | if tab == "bookmarks" { | |
| 558 | s.runControlInto(viewer, []string{"repo", "bookmarks"}, &bookmarks) | |
| 559 | } | |
| 560 | var snippets []snippetRow | |
| 561 | if tab == "snippets" { | |
| 562 | var ok bool | |
| 563 | if snippets, ok = s.ownerSnippets(w, r, viewer, name); !ok { | |
| 564 | return | |
| 565 | } | |
| 566 | } | |
| 567 | s.render(w, "owner.html", ownerPage{ | |
| 568 | basePage: s.baseFor(viewer), | |
| 569 | Owner: name, | |
| 570 | Kind: d.Kind, | |
| 571 | Tab: tab, | |
| 572 | Profile: store.Profile{Description: d.Description, Website: d.Website, Links: d.Links}, | |
| 573 | AboutHTML: aboutHTML(d.About, d.AboutFormat), | |
| 574 | Repos: d.Repos, | |
| 575 | Members: d.Members, | |
| 576 | Orgs: d.Orgs, | |
| 577 | Activity: weeks, | |
| 578 | ActivityTotal: activityTotal, | |
| 579 | Log: s.ownerFeed(tab, d.Kind, name), | |
| 580 | Bookmarks: bookmarks, | |
| 581 | SnippetRows: snippets, | |
| 582 | SnippetsAll: self || viewer.IsAdmin, | |
| 583 | Teams: teams, | |
| 584 | CanAdmin: canAdmin, | |
| 585 | Self: self, | |
| 586 | Snippets: d.Snippets, | |
| 587 | Notice: s.takeFlash(w, r), | |
| 588 | Feed: "/" + name + "/activity.atom", | |
| 589 | }) | |
| 509 | 590 | } |
| 510 | 591 | |
| 511 | 592 | func (s *Server) repoHome(w http.ResponseWriter, r *http.Request) { |
internal/store/dashboard.go +31
| @@ -323,6 +323,37 @@ func (s *Store) OwnerPublicEvents(ownerKind string, ownerID int64, limit int) ([ | ||
| 323 | 323 | return out, rows.Err() |
| 324 | 324 | } |
| 325 | 325 | |
| 326 | // UserPublicEvents returns what a user did on public repositories, | |
| 327 | // newest first. It keys on the actor, not the repository's owner, which | |
| 328 | // is what ActivityByDay counts for a user: the profile's log has to | |
| 329 | // agree with the total printed above its graph. Push events are | |
| 330 | // excluded as in RecentEvents. | |
| 331 | func (s *Store) UserPublicEvents(userID int64, limit int) ([]FeedEvent, error) { | |
| 332 | rows, err := s.DB.Query(` | |
| 333 | SELECT e.id, COALESCE(u.username, o.name) || '/' || r.name, | |
| 334 | COALESCE(ac.username, ''), e.kind, e.data_json, e.created_at | |
| 335 | FROM events e | |
| 336 | JOIN repos r ON r.id = e.repo_id | |
| 337 | LEFT JOIN users u ON r.owner_kind = 'user' AND u.id = r.owner_id | |
| 338 | LEFT JOIN orgs o ON r.owner_kind = 'org' AND o.id = r.owner_id | |
| 339 | LEFT JOIN users ac ON ac.id = e.actor_id | |
| 340 | WHERE e.kind <> 'push' AND r.visibility = 'public' AND e.actor_id = ? | |
| 341 | ORDER BY e.id DESC LIMIT ?`, userID, limit) | |
| 342 | if err != nil { | |
| 343 | return nil, err | |
| 344 | } | |
| 345 | defer rows.Close() | |
| 346 | var out []FeedEvent | |
| 347 | for rows.Next() { | |
| 348 | var e FeedEvent | |
| 349 | if err := rows.Scan(&e.ID, &e.RepoPath, &e.Actor, &e.Kind, &e.Data, &e.CreatedAt); err != nil { | |
| 350 | return nil, err | |
| 351 | } | |
| 352 | out = append(out, e) | |
| 353 | } | |
| 354 | return out, rows.Err() | |
| 355 | } | |
| 356 | ||
| 326 | 357 | // RecentEvents returns activity on repositories the user can reach. Push |
| 327 | 358 | // events are excluded: they repeat what the commit lists already show. |
| 328 | 359 | // before (an event id) starts the page strictly below it, matching the |
internal/store/ownerevents_test.go added +71
| @@ -0,0 +1,71 @@ | ||
| 1 | package store | |
| 2 | ||
| 3 | import "testing" | |
| 4 | ||
| 5 | // The profile's activity log has to count what the graph over it counts. | |
| 6 | // For a user that is what they did, wherever they did it — not what | |
| 7 | // happened on the repositories they happen to own. | |
| 8 | func TestUserPublicEventsKeyOnTheActor(t *testing.T) { | |
| 9 | s := open(t) | |
| 10 | if err := s.MigrateUp(); err != nil { | |
| 11 | t.Fatal(err) | |
| 12 | } | |
| 13 | ||
| 14 | alice, err := s.CreateUser("alice", false) | |
| 15 | if err != nil { | |
| 16 | t.Fatal(err) | |
| 17 | } | |
| 18 | bob, err := s.CreateUser("bob", false) | |
| 19 | if err != nil { | |
| 20 | t.Fatal(err) | |
| 21 | } | |
| 22 | // alice owns one public and one private repository; bob owns one. | |
| 23 | aliceRepo, err := s.CreateRepo("user", alice, "app", "public") | |
| 24 | if err != nil { | |
| 25 | t.Fatal(err) | |
| 26 | } | |
| 27 | secret, err := s.CreateRepo("user", alice, "secret", "private") | |
| 28 | if err != nil { | |
| 29 | t.Fatal(err) | |
| 30 | } | |
| 31 | bobRepo, err := s.CreateRepo("user", bob, "tool", "public") | |
| 32 | if err != nil { | |
| 33 | t.Fatal(err) | |
| 34 | } | |
| 35 | ||
| 36 | for _, e := range []struct { | |
| 37 | repo, actor int64 | |
| 38 | kind string | |
| 39 | }{ | |
| 40 | {aliceRepo, alice, "issue.opened"}, // hers, on her repo | |
| 41 | {bobRepo, alice, "issue.opened"}, // hers, on someone else's | |
| 42 | {aliceRepo, bob, "issue.opened"}, // his, on her repo | |
| 43 | {secret, alice, "issue.opened"}, // hers, but private | |
| 44 | {aliceRepo, alice, "push"}, // pushes never appear | |
| 45 | } { | |
| 46 | if err := s.RecordEvent(e.repo, e.actor, e.kind, "{}"); err != nil { | |
| 47 | t.Fatal(err) | |
| 48 | } | |
| 49 | } | |
| 50 | ||
| 51 | got, err := s.UserPublicEvents(alice, 10) | |
| 52 | if err != nil { | |
| 53 | t.Fatal(err) | |
| 54 | } | |
| 55 | if len(got) != 2 { | |
| 56 | t.Fatalf("want alice's two public events, got %d: %+v", len(got), got) | |
| 57 | } | |
| 58 | // Newest first, and both are hers. | |
| 59 | if got[0].RepoPath != "bob/tool" || got[1].RepoPath != "alice/app" { | |
| 60 | t.Errorf("wrong rows or order: %+v", got) | |
| 61 | } | |
| 62 | for _, e := range got { | |
| 63 | if e.Actor != "alice" { | |
| 64 | t.Errorf("an event alice did not do: %+v", e) | |
| 65 | } | |
| 66 | } | |
| 67 | ||
| 68 | if got, err := s.UserPublicEvents(alice, 1); err != nil || len(got) != 1 { | |
| 69 | t.Fatalf("limit not applied: %d %v", len(got), err) | |
| 70 | } | |
| 71 | } | |
internal/web/static/style.css +4
| @@ -1677,6 +1677,10 @@ h2 .count { background: none; color: var(--muted); font-weight: 400; font-size: | ||
| 1677 | 1677 | .actlegend { display: flex; align-items: center; gap: 3px; margin-top: var(--sp-2); font-size: var(--fs-0); color: var(--muted); } |
| 1678 | 1678 | .actlegend span:first-child { margin-right: var(--sp-1); } |
| 1679 | 1679 | .actlegend span:last-child { margin-left: var(--sp-1); } |
| 1680 | /* the activity log under the graph: the newest events, not the year */ | |
| 1681 | .logsub { margin: var(--sp-5) 0 var(--sp-2); font-size: var(--fs-2); color: var(--muted); text-transform: uppercase; letter-spacing: 0.04em; } | |
| 1682 | .ownerlog .feedline { font-size: var(--fs-2); } | |
| 1683 | .ownerlog .feedline .none { font-size: var(--fs-1); } | |
| 1680 | 1684 | |
| 1681 | 1685 | /* ---- wiki ---- */ |
| 1682 | 1686 | .wikilayout { display: flex; gap: var(--sp-5); align-items: flex-start; } |
internal/web/templates/bookmarks.html deleted −16
| @@ -1,16 +0,0 @@ | ||
| 1 | {{define "title"}}bookmarks · {{.Site}}{{end}} | |
| 2 | {{define "content"}} | |
| 3 | <h1>Bookmarks</h1> | |
| 4 | <p class="meta">Repositories you saved to come back to. A pin is private quick | |
| 5 | access to your own work; a bookmark is a public vote to save access to another | |
| 6 | repo.</p> | |
| 7 | <ul class="repolist"> | |
| 8 | {{range .Bookmarks}}<li> | |
| 9 | <p class="title"><a href="/{{.Path}}">{{.Path}}</a> | |
| 10 | {{if eq .Visibility "private"}}<span class="chip">Private</span>{{end}} | |
| 11 | <span class="count">{{.Bookmarks}}</span></p> | |
| 12 | {{if .Description}}<p class="desc">{{.Description}}</p>{{end}} | |
| 13 | </li> | |
| 14 | {{else}}<li class="empty">nothing bookmarked yet. open a repository and press Bookmark</li>{{end}} | |
| 15 | </ul> | |
| 16 | {{end}} | |
internal/web/templates/layout.html +2 −2
| @@ -22,7 +22,7 @@ | ||
| 22 | 22 | <li class="railopt">{{template "raillink" dict "Href" "/explore" "Icon" "compass" "Name" "Explore" "Current" (eq (str . "Tab") "explore")}}</li> |
| 23 | 23 | <li>{{template "raillink" dict "Href" "/search" "Icon" "search" "Name" "Search" "Current" (eq (str . "Tab") "sitesearch")}}</li> |
| 24 | 24 | {{if .Viewer}}<li>{{template "raillink" dict "Href" "/notifications" "Icon" "bell" "Name" "Notifications" "Current" (eq (str . "Tab") "notifications") "Count" .Rail.Unread}}</li> |
| 25 | <li class="railopt">{{template "raillink" dict "Href" "/bookmarks" "Icon" "bookmark" "Name" "Bookmarks" "Current" (eq (str . "Tab") "bookmarks")}}</li> | |
| 25 | <li class="railopt">{{template "raillink" dict "Href" (printf "/%s/-/bookmarks" .Viewer) "Icon" "bookmark" "Name" "Bookmarks" "Current" (eq (str . "Tab") "bookmarks")}}</li> | |
| 26 | 26 | <li class="railopt">{{template "raillink" dict "Href" (printf "/%s/-/snippets" .Viewer) "Icon" "snippet" "Name" "Snippets" "Current" (eq (str . "Tab") "snippets")}}</li> |
| 27 | 27 | <li class="railopt">{{template "raillink" dict "Href" "/new" "Icon" "plus" "Name" "New repository"}}</li>{{end}} |
| 28 | 28 | </ul> |
| @@ -36,7 +36,7 @@ | ||
| 36 | 36 | <summary class="railicon" aria-label="More" title="More">{{template "icon" "ellipsis"}}<span class="vh">More</span></summary> |
| 37 | 37 | <div class="raildrop"> |
| 38 | 38 | <a href="/explore">{{template "icon" "compass"}} Explore</a> |
| 39 | <a href="/bookmarks">{{template "icon" "bookmark"}} Bookmarks</a> | |
| 39 | <a href="/{{.Viewer}}/-/bookmarks">{{template "icon" "bookmark"}} Bookmarks</a> | |
| 40 | 40 | <a href="/{{.Viewer}}/-/snippets">{{template "icon" "snippet"}} Snippets</a> |
| 41 | 41 | <a href="/new">{{template "icon" "plus"}} New repository</a> |
| 42 | 42 | <a href="/settings">{{template "icon" "gear"}} Settings</a> |
internal/web/templates/owner.html +56 −18
| @@ -9,29 +9,20 @@ | ||
| 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 | {{/* 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). */}} | |
| 12 | {{/* The profile is sections rather than one stack: a long About used to | |
| 13 | push the repositories off the bottom of the page (#242). About is | |
| 14 | the bare /{owner}; the rest hang off /-/. A tab nobody may open is | |
| 15 | not offered and its URL is a 404. */}} | |
| 15 | 16 | <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}} | |
| 17 | <a {{if eq .Tab "about"}}aria-current="page" {{end}}href="/{{.Owner}}">About</a> | |
| 18 | <a {{if eq .Tab "repos"}}aria-current="page" {{end}}href="/{{.Owner}}/-/repositories">Repositories{{if .Repos}} <i>{{len .Repos}}</i>{{end}}</a> | |
| 19 | {{if .Self}}<a {{if eq .Tab "bookmarks"}}aria-current="page" {{end}}href="/{{.Owner}}/-/bookmarks">Bookmarks</a>{{end}} | |
| 20 | {{if and (eq .Kind "user") (or .Snippets .Self)}}<a {{if eq .Tab "snippets"}}aria-current="page" {{end}}href="/{{.Owner}}/-/snippets">Snippets{{if .Snippets}} <i>{{.Snippets}}</i>{{end}}</a>{{end}} | |
| 20 | 21 | {{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 | 22 | </nav> |
| 22 | 23 | |
| 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 | 24 | {{if eq .Tab "about"}} |
| 31 | <section class="readme"><div class="rendered">{{.AboutHTML}}</div></section> | |
| 32 | {{end}} | |
| 33 | ||
| 34 | {{if eq .Tab "activity"}} | |
| 25 | {{if .AboutHTML}}<section class="readme"><div class="rendered">{{.AboutHTML}}</div></section>{{end}} | |
| 35 | 26 | <section class="activity"> |
| 36 | 27 | <h2>activity <span class="count">{{.ActivityTotal}} in the last year</span></h2> |
| 37 | 28 | <div class="actgraph-scroll"> |
| @@ -40,9 +31,56 @@ | ||
| 40 | 31 | </div> |
| 41 | 32 | </div> |
| 42 | 33 | <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> |
| 34 | {{/* The graph is the year at a glance; the log is what happened lately. | |
| 35 | It is the newest events, not all of them — the atom feed is the one | |
| 36 | that goes back. */}} | |
| 37 | {{if .Log}}<h3 class="logsub">recent</h3> | |
| 38 | <div class="ownerlog"> | |
| 39 | {{range .Log}}<p class="feedline"><a href="/{{.Actor}}">{{.Actor}}</a> {{.Verb}} <a href="{{.URL}}"{{if .Jobs}} title="{{join .Jobs ", "}}"{{end}}>{{.Ref}}</a><br><span class="none">{{.Repo}} · <span title="{{whenT .WhenT}}">{{ago .WhenT}}</span></span></p> | |
| 40 | {{end}} | |
| 41 | </div> | |
| 42 | <p class="meta"><a href="{{.Feed}}">activity feed</a></p> | |
| 43 | {{else}}<p class="none">Nothing yet.</p>{{end}} | |
| 43 | 44 | </section> |
| 44 | 45 | {{end}} |
| 45 | 46 | |
| 47 | {{if eq .Tab "repos"}} | |
| 48 | <ul class="repolist"> | |
| 49 | {{range .Repos}}{{template "reporow" .}} | |
| 50 | {{else}}<li class="empty">no visible repositories</li>{{end}} | |
| 51 | </ul> | |
| 52 | {{end}} | |
| 53 | ||
| 54 | {{if eq .Tab "bookmarks"}} | |
| 55 | <p class="meta">Repositories you saved to come back to. A pin is private quick | |
| 56 | access to your own work; a bookmark is a public vote to save access to another | |
| 57 | repo.</p> | |
| 58 | <ul class="repolist"> | |
| 59 | {{range .Bookmarks}}<li> | |
| 60 | <p class="title"><a href="/{{.Path}}">{{.Path}}</a> | |
| 61 | {{if eq .Visibility "private"}}<span class="chip">Private</span>{{end}} | |
| 62 | <span class="count">{{.Bookmarks}}</span></p> | |
| 63 | {{if .Description}}<p class="desc">{{.Description}}</p>{{end}} | |
| 64 | </li> | |
| 65 | {{else}}<li class="empty">nothing bookmarked yet. open a repository and press Bookmark</li>{{end}} | |
| 66 | </ul> | |
| 67 | {{end}} | |
| 68 | ||
| 69 | {{if eq .Tab "snippets"}} | |
| 70 | {{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}} | |
| 71 | {{if .Self}}<p class="meta"><a href="/{{.Owner}}/-/snippets/new">new snippet</a> · or <code>gitbay snippet create <file> < file</code></p>{{end}} | |
| 72 | {{if .SnippetRows}}<div class="tablewrap"><table class="keys"> | |
| 73 | <tr class="cols"><th scope="col">snippet</th><th scope="col">files</th>{{if .SnippetsAll}}<th scope="col">visibility</th>{{end}}<th scope="col">updated</th></tr> | |
| 74 | {{range .SnippetRows}}<tr> | |
| 75 | <td><a href="/{{$.Owner}}/-/snippets/{{.PublicID}}">{{if .Description}}{{.Description}}{{else}}{{.PublicID}}{{end}}</a></td> | |
| 76 | <td><span class="mono">{{.Names}}</span></td> | |
| 77 | {{if $.SnippetsAll}}<td><span class="chip chip-neutral">{{.Visibility}}</span></td>{{end}} | |
| 78 | <td>{{when .UpdatedAt}}</td> | |
| 79 | </tr> | |
| 80 | {{end}}</table></div> | |
| 81 | {{else}}<p class="none">No snippets yet.</p>{{end}} | |
| 82 | {{end}} | |
| 83 | ||
| 46 | 84 | {{if eq .Tab "people"}}{{$org := .Owner}} |
| 47 | 85 | <h2>people <span class="count">{{len .Members}}</span></h2> |
| 48 | 86 | {{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}} |
internal/web/templates/snippets.html deleted −16
| @@ -1,16 +0,0 @@ | ||
| 1 | {{define "title"}}snippets · {{.Owner}}{{end}} | |
| 2 | {{define "content"}} | |
| 3 | <h1><a href="/{{.Owner}}">{{.Owner}}</a> snippets</h1> | |
| 4 | {{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}} | |
| 5 | {{if .Self}}<p class="meta"><a href="/{{.Owner}}/-/snippets/new">new snippet</a> · or <code>gitbay snippet create <file> < file</code></p>{{end}} | |
| 6 | {{if .Snippets}}<div class="tablewrap"><table class="keys"> | |
| 7 | <tr class="cols"><th scope="col">snippet</th><th scope="col">files</th>{{if .All}}<th scope="col">visibility</th>{{end}}<th scope="col">updated</th></tr> | |
| 8 | {{range .Snippets}}<tr> | |
| 9 | <td><a href="/{{$.Owner}}/-/snippets/{{.PublicID}}">{{if .Description}}{{.Description}}{{else}}{{.PublicID}}{{end}}</a></td> | |
| 10 | <td><span class="mono">{{.Names}}</span></td> | |
| 11 | {{if $.All}}<td><span class="chip chip-neutral">{{.Visibility}}</span></td>{{end}} | |
| 12 | <td>{{when .UpdatedAt}}</td> | |
| 13 | </tr> | |
| 14 | {{end}}</table></div> | |
| 15 | {{else}}<p class="none">No snippets yet.</p>{{end}} | |
| 16 | {{end}} | |