The five web findings: builds, profile, explore, landing !453
26 files changed, +741 −59
Layout: unified · split
.gitbay/wiki/Parity.org +15 −6
| @@ -200,6 +200,7 @@ sit inside the diff, where a page-level preview has nowhere to go. | |||
| 200 | | release create, edit | yes | yes | yes | | 200 | | release create, edit | yes | yes | yes | |
| 201 | | preview release notes | n/a | yes | no | | 201 | | preview release notes | n/a | yes | no | |
| 202 | | build list | yes | yes | yes | | 202 | | build list | yes | yes | yes | |
| 203 | | build list paging (limit, cursor) | yes | yes | yes | | ||
| 203 | | build list filters (ref, status, job) | yes | yes | yes | | 204 | | build list filters (ref, status, job) | yes | yes | yes | |
| 204 | | build show (one build) | yes | yes | yes | | 205 | | build show (one build) | yes | yes | yes | |
| 205 | | build log | yes | yes | yes | | 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 | | search issues and merge requests | yes | yes | yes | | 287 | | search issues and merge requests | yes | yes | yes | |
| 287 | | browse all public repositories | yes | yes | yes | | 288 | | browse all public repositories | yes | yes | yes | |
| 288 | | profile page | yes | yes | yes | | 289 | | profile page | yes | yes | yes | |
| 290 | | profile sections as tabs | n/a | yes | n/a | | ||
| 289 | | profile about and links | yes | yes | yes | | 291 | | profile about and links | yes | yes | yes | |
| 290 | | profile about as a file | yes | yes | yes | | 292 | | profile about as a file | yes | yes | yes | |
| 291 | | activity feed | yes | yes | yes | | 293 | | activity feed | yes | yes | yes | |
| @@ -308,7 +310,12 @@ it as =about=, =about_format= and =about_path=. It reads with the | |||
| 308 | repository's own access, so a private =.gitbay= is a profile with no | 310 | repository's own access, so a private =.gitbay= is a profile with no |
| 309 | about text to anyone but its owner and the admins. A repository whose | 311 | about text to anyone but its owner and the admins. A repository whose |
| 310 | name starts with a dot stays out of =explore= and off the profile's | 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 | through the same OrgSwift path a README takes. | 319 | through the same OrgSwift path a README takes. |
| 313 | 320 | ||
| 314 | =help= lists the command registry. Bare it is an index, one line per | 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 | * Pagination | 436 | * Pagination |
| 430 | 437 | ||
| 431 | =issue list=, =mr list=, =repo list=, and =feed= take =--limit <n>= | 438 | =issue list=, =mr list=, =repo list=, =feed= and =build list= take |
| 432 | and =--cursor <c>=. Cursors are opaque; each page carries the next | 439 | =--limit <n>= and =--cursor <c>=. Cursors are opaque; each page carries |
| 433 | one. Without the flags a list stays complete, so existing scripts are | 440 | the next one. Without the flags a list stays as it was, so existing |
| 434 | unchanged. The web pages the issue and merge request lists at fifty | 441 | scripts are unchanged — for =build list= that means the newest fifty |
| 435 | with the same cursors; iOS pages with them too. | 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 | * CLI only, for now | 446 | * CLI only, for now |
| 438 | 447 | ||
CHANGELOG.org +36
| @@ -4,6 +4,42 @@ Versioning follows semver from v0.1.0. Database migrations run | |||
| 4 | automatically on daemon start; upgrade notes appear per release when | 4 | automatically on daemon start; upgrade notes appear per release when |
| 5 | anything beyond "replace the binary and restart" is needed. | 5 | anything beyond "replace the binary and restart" is needed. |
| 6 | 6 | ||
| 7 | * v1.33.0 — 2026-09-21 | ||
| 8 | |||
| 9 | Five findings from the outside review of the web UI (#241–#245). | ||
| 10 | |||
| 11 | - A run row on the builds page led with a ten-character sha, so | ||
| 12 | recognising a build meant opening the commit or already knowing the | ||
| 13 | sha (#241). =build list= resolves each distinct commit's subject in | ||
| 14 | one =git log= and returns it as =subject= on every build, so the CLI, | ||
| 15 | the API and the web all name the commit. The row leads with the | ||
| 16 | subject and the sha follows as metadata; a build whose commit is gone | ||
| 17 | falls back to the sha. | ||
| 18 | - =build list= takes =--limit <n>= and =--cursor <c>=, the keyset | ||
| 19 | cursor =issue list=, =mr list=, =repo list= and =feed= already have | ||
| 20 | (#244). Without the flags it returns the newest fifty matching builds | ||
| 21 | as before, so existing scripts are unchanged — the cursor is what | ||
| 22 | reaches past that window. The page asks for thirty, offers "older" | ||
| 23 | carrying every filter, and says its count is this page's when there | ||
| 24 | is another. Before, filtering to a sparse status reached much further | ||
| 25 | back within the same cap and appeared to raise the run count. | ||
| 26 | - The profile is sections rather than one stack (#242). =/{owner}= is | ||
| 27 | the repository list; About, Activity and an organization's People are | ||
| 28 | tabs beside it at =/{owner}/-/about=, =/-/activity= and =/-/people=. | ||
| 29 | A tab with nothing on it is not offered and its URL is a 404. The | ||
| 30 | About text and the year of activity squares used to sit above the | ||
| 31 | repository list and push it below the fold. =profile show= is | ||
| 32 | unchanged. | ||
| 33 | - Explore names its filter: the number of repositories, the active | ||
| 34 | query and a link that clears it, the line the builds page already | ||
| 35 | carried (#243). An empty filtered list says nothing matched rather | ||
| 36 | than that the instance has no public repositories. | ||
| 37 | - The landing page's picture was !315, closed without merging, with an | ||
| 38 | empty review panel and alt text promising reviewers that were not | ||
| 39 | there (#245). It is !450: merged, two CI checks reporting success | ||
| 40 | with how long each ran, and an approval. The picture is a figure with | ||
| 41 | a caption saying what it shows. | ||
| 42 | |||
| 7 | * v1.32.1 — 2026-09-20 | 43 | * v1.32.1 — 2026-09-20 |
| 8 | 44 | ||
| 9 | A push alert names the account it is for (#89). | 45 | A push alert names the account it is for (#89). |
e2e/activity_test.go +4 −4
| @@ -49,7 +49,7 @@ func TestActivityGraph(t *testing.T) { | |||
| 49 | if _, _, code := inst.ssh(t, aliceKey, "", "issue", "create", "alice/app", "--title", "'x'"); code != 0 { | 49 | if _, _, code := inst.ssh(t, aliceKey, "", "issue", "create", "alice/app", "--title", "'x'"); code != 0 { |
| 50 | t.Fatal("issue create failed") | 50 | t.Fatal("issue create failed") |
| 51 | } | 51 | } |
| 52 | status, body := inst.get(t, "/alice") | 52 | status, body := inst.get(t, "/alice/-/activity") |
| 53 | if status != 200 || !strings.Contains(body, `class="actgraph"`) { | 53 | if status != 200 || !strings.Contains(body, `class="actgraph"`) { |
| 54 | t.Fatalf("graph missing: %d", status) | 54 | t.Fatalf("graph missing: %d", status) |
| 55 | } | 55 | } |
| @@ -61,14 +61,14 @@ func TestActivityGraph(t *testing.T) { | |||
| 61 | t.Fatalf("alice total = %d, want >= 2", total) | 61 | t.Fatalf("alice total = %d, want >= 2", total) |
| 62 | } | 62 | } |
| 63 | // bob authored a commit but his email is unverified: zero activity. | 63 | // bob authored a commit but his email is unverified: zero activity. |
| 64 | _, bobBody := inst.get(t, "/bob") | 64 | _, bobBody := inst.get(t, "/bob/-/activity") |
| 65 | if bt := activityTotal(t, bobBody); bt != 0 { | 65 | if bt := activityTotal(t, bobBody); bt != 0 { |
| 66 | t.Fatalf("unverified author got credit: total %d", bt) | 66 | t.Fatalf("unverified author got credit: total %d", bt) |
| 67 | } | 67 | } |
| 68 | 68 | ||
| 69 | // Re-pushing the same history (force) does not double-count. | 69 | // Re-pushing the same history (force) does not double-count. |
| 70 | mustGit(t, dir, env, "push", "-q", "--force", "origin", "main") | 70 | mustGit(t, dir, env, "push", "-q", "--force", "origin", "main") |
| 71 | _, body2 := inst.get(t, "/alice") | 71 | _, body2 := inst.get(t, "/alice/-/activity") |
| 72 | if body2 != body { | 72 | if body2 != body { |
| 73 | // Counts must be identical; compare just the graph cells. | 73 | // Counts must be identical; compare just the graph cells. |
| 74 | if excerpt(body, "actgraph") != excerpt(body2, "actgraph") { | 74 | if excerpt(body, "actgraph") != excerpt(body2, "actgraph") { |
| @@ -83,7 +83,7 @@ func TestActivityGraph(t *testing.T) { | |||
| 83 | if _, _, code := inst.ssh(t, aliceKey, "", "repo", "transfer", "alice/app", "theorg"); code != 0 { | 83 | if _, _, code := inst.ssh(t, aliceKey, "", "repo", "transfer", "alice/app", "theorg"); code != 0 { |
| 84 | t.Fatal("transfer failed") | 84 | t.Fatal("transfer failed") |
| 85 | } | 85 | } |
| 86 | _, orgBody := inst.get(t, "/theorg") | 86 | _, orgBody := inst.get(t, "/theorg/-/activity") |
| 87 | if !strings.Contains(orgBody, `class="actgraph"`) || strings.Contains(orgBody, "0 in the last year") { | 87 | if !strings.Contains(orgBody, `class="actgraph"`) || strings.Contains(orgBody, "0 in the last year") { |
| 88 | t.Fatalf("org graph empty:\n%s", excerpt(orgBody, "activity")) | 88 | t.Fatalf("org graph empty:\n%s", excerpt(orgBody, "activity")) |
| 89 | } | 89 | } |
e2e/orgweb_test.go +9 −6
| @@ -28,8 +28,11 @@ func TestOrgManagementWeb(t *testing.T) { | |||
| 28 | 28 | ||
| 29 | // The management sections are admin-only: bob is not even a member. | 29 | // The management sections are admin-only: bob is not even a member. |
| 30 | bob := loginBrowser(t, inst, bobKey) | 30 | bob := loginBrowser(t, inst, bobKey) |
| 31 | if _, body := browserGet(t, bob, inst.base()+"/acme"); strings.Contains(body, `value="member-add"`) { | 31 | // The people tab is the admin panel, so an outsider gets the 404 a |
| 32 | t.Fatal("a non-member sees organization controls") | 32 | // page nobody has rather than a page with the controls hidden. |
| 33 | if status, body := browserGet(t, bob, inst.base()+"/acme/-/people"); status != 404 || | ||
| 34 | strings.Contains(body, `value="member-add"`) { | ||
| 35 | t.Fatalf("a non-member reaches the organization controls: %d", status) | ||
| 33 | } | 36 | } |
| 34 | // And POSTing anyway is refused by the command, not by the template. | 37 | // And POSTing anyway is refused by the command, not by the template. |
| 35 | browserPost(t, bob, inst.base()+"/acme", url.Values{ | 38 | browserPost(t, bob, inst.base()+"/acme", url.Values{ |
| @@ -39,7 +42,7 @@ func TestOrgManagementWeb(t *testing.T) { | |||
| 39 | t.Fatalf("non-admin added themselves: %v", members) | 42 | t.Fatalf("non-admin added themselves: %v", members) |
| 40 | } | 43 | } |
| 41 | 44 | ||
| 42 | status, body := browserGet(t, alice, inst.base()+"/acme") | 45 | status, body := browserGet(t, alice, inst.base()+"/acme/-/people") |
| 43 | if status != 200 || !strings.Contains(body, `value="member-add"`) { | 46 | if status != 200 || !strings.Contains(body, `value="member-add"`) { |
| 44 | t.Fatalf("admin sees no controls: %d", status) | 47 | t.Fatalf("admin sees no controls: %d", status) |
| 45 | } | 48 | } |
| @@ -73,8 +76,8 @@ func TestOrgManagementWeb(t *testing.T) { | |||
| 73 | t.Fatalf("team grant did not confer access: %s", errOut) | 76 | t.Fatalf("team grant did not confer access: %s", errOut) |
| 74 | } | 77 | } |
| 75 | 78 | ||
| 76 | // The page shows what was built. | 79 | // The people tab shows what was built. |
| 77 | _, body = browserGet(t, alice, inst.base()+"/acme") | 80 | _, body = browserGet(t, alice, inst.base()+"/acme/-/people") |
| 78 | for _, want := range []string{"builders", "acme/widget", "1 member"} { | 81 | for _, want := range []string{"builders", "acme/widget", "1 member"} { |
| 79 | if !strings.Contains(body, want) { | 82 | if !strings.Contains(body, want) { |
| 80 | t.Errorf("org page missing %q", want) | 83 | t.Errorf("org page missing %q", want) |
| @@ -180,7 +183,7 @@ func TestOrgLifecycleWeb(t *testing.T) { | |||
| 180 | } | 183 | } |
| 181 | 184 | ||
| 182 | // Rename is offered to its admin, and the org moves. | 185 | // Rename is offered to its admin, and the org moves. |
| 183 | _, body := browserGet(t, alice, inst.base()+"/acmeco") | 186 | _, body := browserGet(t, alice, inst.base()+"/acmeco/-/people") |
| 184 | if !strings.Contains(body, `value="org-rename"`) { | 187 | if !strings.Contains(body, `value="org-rename"`) { |
| 185 | t.Fatalf("no rename form for the org admin:\n%s", body) | 188 | t.Fatalf("no rename form for the org admin:\n%s", body) |
| 186 | } | 189 | } |
e2e/profile_test.go +15 −11
| @@ -157,7 +157,7 @@ func TestOwnerProfiles(t *testing.T) { | |||
| 157 | } | 157 | } |
| 158 | // Org emphasis parsed, not left as literal slashes the way the | 158 | // Org emphasis parsed, not left as literal slashes the way the |
| 159 | // markdown renderer would. | 159 | // markdown renderer would. |
| 160 | _, body = inst.get(t, "/alice") | 160 | _, body = inst.get(t, "/alice/-/about") |
| 161 | if !strings.Contains(body, "<em>small tools</em>") || strings.Contains(body, "/small tools/") { | 161 | if !strings.Contains(body, "<em>small tools</em>") || strings.Contains(body, "/small tools/") { |
| 162 | t.Fatalf("org about not rendered as org: %s", body) | 162 | t.Fatalf("org about not rendered as org: %s", body) |
| 163 | } | 163 | } |
| @@ -196,25 +196,29 @@ func TestOwnerProfiles(t *testing.T) { | |||
| 196 | t.Fatal("more than five links accepted") | 196 | t.Fatal("more than five links accepted") |
| 197 | } | 197 | } |
| 198 | 198 | ||
| 199 | // Owner pages render description and website link. | 199 | // The bare owner page is the repository list, with the description |
| 200 | // and the link chips in the header above the tabs (#242). | ||
| 200 | status, body = inst.get(t, "/alice") | 201 | status, body = inst.get(t, "/alice") |
| 201 | if status != 200 || !strings.Contains(body, "tinkerer") { | 202 | if status != 200 || !strings.Contains(body, "tinkerer") { |
| 202 | t.Fatalf("user page profile: %d", status) | 203 | t.Fatalf("user page profile: %d", status) |
| 203 | } | 204 | } |
| 204 | // About renders as markdown between the header and the activity graph; | ||
| 205 | // links render as chips. | ||
| 206 | if !strings.Contains(body, "<em>small tools</em>") { | ||
| 207 | t.Fatalf("about not rendered: %s", body) | ||
| 208 | } | ||
| 209 | if !strings.Contains(body, `href="https://fosstodon.example/@alice"`) || | 205 | if !strings.Contains(body, `href="https://fosstodon.example/@alice"`) || |
| 210 | !strings.Contains(body, ">Mastodon<") { | 206 | !strings.Contains(body, ">Mastodon<") { |
| 211 | t.Fatalf("links not rendered: %s", body) | 207 | t.Fatalf("links not rendered: %s", body) |
| 212 | } | 208 | } |
| 213 | if strings.Index(body, "<em>small tools</em>") > strings.Index(body, `class="activity"`) { | 209 | if !strings.Contains(body, `<ul class="repolist"`) { |
| 214 | t.Error("about renders below the activity graph") | 210 | t.Error("the owner page is not the repository list") |
| 211 | } | ||
| 212 | if strings.Contains(body, `class="activity"`) || strings.Contains(body, "<em>small tools</em>") { | ||
| 213 | t.Error("the about text or the activity graph still sits on the repository page") | ||
| 214 | } | ||
| 215 | // Each of them is one tab along, and the markdown renders there. | ||
| 216 | _, aboutBody := inst.get(t, "/alice/-/about") | ||
| 217 | if !strings.Contains(aboutBody, "<em>small tools</em>") { | ||
| 218 | t.Fatalf("about not rendered: %s", aboutBody) | ||
| 215 | } | 219 | } |
| 216 | if strings.Index(body, `<ul class="repolist"`) < strings.Index(body, `class="activity"`) { | 220 | if _, g := inst.get(t, "/alice/-/activity"); !strings.Contains(g, `class="activity"`) { |
| 217 | t.Error("repositories render above the activity graph") | 221 | t.Error("the activity tab has no graph") |
| 218 | } | 222 | } |
| 219 | 223 | ||
| 220 | // Clearing works the same way as the other fields. The about is not | 224 | // Clearing works the same way as the other fields. The about is not |
e2e/profileabout_test.go +2 −2
| @@ -39,7 +39,7 @@ func TestProfileAboutFromRepo(t *testing.T) { | |||
| 39 | t.Errorf("about_path missing: %s", out) | 39 | t.Errorf("about_path missing: %s", out) |
| 40 | } | 40 | } |
| 41 | 41 | ||
| 42 | _, body := inst.get(t, "/alice") | 42 | _, body := inst.get(t, "/alice/-/about") |
| 43 | if !strings.Contains(body, "hello from a file") { | 43 | if !strings.Contains(body, "hello from a file") { |
| 44 | t.Error("web profile does not render the about") | 44 | t.Error("web profile does not render the about") |
| 45 | } | 45 | } |
| @@ -53,7 +53,7 @@ func TestProfileAboutFromRepo(t *testing.T) { | |||
| 53 | "--ref", "main", "--file", "-"); code != 0 { | 53 | "--ref", "main", "--file", "-"); code != 0 { |
| 54 | t.Fatalf("committing bob's org about: %s", errOut) | 54 | t.Fatalf("committing bob's org about: %s", errOut) |
| 55 | } | 55 | } |
| 56 | if _, page := inst.get(t, "/bob"); !strings.Contains(page, "<em>note</em>") { | 56 | if _, page := inst.get(t, "/bob/-/about"); !strings.Contains(page, "<em>note</em>") { |
| 57 | t.Errorf("about did not render as org:\n%s", page) | 57 | t.Errorf("about did not render as org:\n%s", page) |
| 58 | } | 58 | } |
| 59 | } | 59 | } |
internal/control/build.go +46 −5
| @@ -22,7 +22,7 @@ import ( | |||
| 22 | func init() { | 22 | func init() { |
| 23 | register(Command{Path: []string{"build", "list"}, | 23 | register(Command{Path: []string{"build", "list"}, |
| 24 | Summary: "list recent builds", | 24 | Summary: "list recent builds", |
| 25 | Usage: "build list <owner/name> [--ref <branch>] [--status <state>] [--job <name>]", ReadOnly: true, Run: runBuildList}) | 25 | Usage: "build list <owner/name> [--ref <branch>] [--status <state>] [--job <name>] [--limit <n>] [--cursor <c>]", ReadOnly: true, Run: runBuildList}) |
| 26 | register(Command{Path: []string{"build", "show"}, | 26 | register(Command{Path: []string{"build", "show"}, |
| 27 | Summary: "show one build", | 27 | Summary: "show one build", |
| 28 | Usage: "build show <owner/name> <n>", ReadOnly: true, Run: runBuildShow}) | 28 | Usage: "build show <owner/name> <n>", ReadOnly: true, Run: runBuildShow}) |
| @@ -78,10 +78,15 @@ type BuildOut struct { | |||
| 78 | Ref string `json:"ref"` | 78 | Ref string `json:"ref"` |
| 79 | CreatedAt string `json:"created_at"` | 79 | CreatedAt string `json:"created_at"` |
| 80 | FinishedAt string `json:"finished_at,omitempty"` | 80 | FinishedAt string `json:"finished_at,omitempty"` |
| 81 | // Subject is the first line of the commit's message, so a build | ||
| 82 | // names what it ran on rather than only its sha (#241). It is empty | ||
| 83 | // when the commit is no longer in the repository. | ||
| 84 | Subject string `json:"subject,omitempty"` | ||
| 81 | } | 85 | } |
| 82 | 86 | ||
| 83 | func buildToOut(b store.Build) BuildOut { | 87 | func buildToOut(b store.Build) BuildOut { |
| 84 | return BuildOut{b.Number, b.Job, b.Status, b.SHA, b.Ref, b.CreatedAt, b.FinishedAt} | 88 | return BuildOut{Number: b.Number, Job: b.Job, Status: b.Status, SHA: b.SHA, |
| 89 | Ref: b.Ref, CreatedAt: b.CreatedAt, FinishedAt: b.FinishedAt} | ||
| 85 | } | 90 | } |
| 86 | 91 | ||
| 87 | func buildRef(c *Ctx, args []string) (store.Repo, store.Build, int) { | 92 | func buildRef(c *Ctx, args []string) (store.Repo, store.Build, int) { |
| @@ -107,7 +112,16 @@ func buildRef(c *Ctx, args []string) (store.Repo, store.Build, int) { | |||
| 107 | // is told to pick from. | 112 | // is told to pick from. |
| 108 | var buildStatuses = []string{"pending", "running", "success", "failure", "cancelled"} | 113 | var buildStatuses = []string{"pending", "running", "success", "failure", "cancelled"} |
| 109 | 114 | ||
| 115 | // buildPage is how many builds one page of build list returns when no | ||
| 116 | // --limit is given. The cap has always been there; what it is now | ||
| 117 | // reachable past, with --cursor (#244). | ||
| 118 | const buildPage = 50 | ||
| 119 | |||
| 110 | func runBuildList(c *Ctx, args []string) int { | 120 | func runBuildList(c *Ctx, args []string) int { |
| 121 | args, p, code := parsePageFlags(c, args, "build", true) | ||
| 122 | if code >= 0 { | ||
| 123 | return code | ||
| 124 | } | ||
| 111 | f, err := parseFlags(args, flagSpec{Values: []string{"--ref", "--status", "--job"}, MaxPos: 1, Usage: c.Cmd.Usage}) | 125 | f, err := parseFlags(args, flagSpec{Values: []string{"--ref", "--status", "--job"}, MaxPos: 1, Usage: c.Cmd.Usage}) |
| 112 | if err != nil { | 126 | if err != nil { |
| 113 | return c.fail(protocol.ExitUsage, "%v", err) | 127 | return c.fail(protocol.ExitUsage, "%v", err) |
| @@ -124,21 +138,48 @@ func runBuildList(c *Ctx, args []string) int { | |||
| 124 | if code >= 0 { | 138 | if code >= 0 { |
| 125 | return code | 139 | return code |
| 126 | } | 140 | } |
| 127 | builds, err := c.Store.ListBuilds(repo.ID, store.BuildFilter{Ref: f.Value("--ref"), Status: status, Job: f.Value("--job")}, 50) | 141 | limit := p.queryLimit() |
| 142 | if limit == 0 { | ||
| 143 | limit = buildPage | ||
| 144 | } | ||
| 145 | filter := store.BuildFilter{Ref: f.Value("--ref"), Status: status, Job: f.Value("--job"), Before: p.keyInt()} | ||
| 146 | builds, err := c.Store.ListBuilds(repo.ID, filter, limit) | ||
| 128 | if err != nil { | 147 | if err != nil { |
| 129 | return c.fail(protocol.ExitFailure, "%v", err) | 148 | return c.fail(protocol.ExitFailure, "%v", err) |
| 130 | } | 149 | } |
| 150 | builds, next := trimPage(p, builds, "build", func(b store.Build) string { | ||
| 151 | return strconv.FormatInt(b.Number, 10) | ||
| 152 | }) | ||
| 131 | var ds []BuildOut | 153 | var ds []BuildOut |
| 132 | for _, b := range builds { | 154 | for _, b := range builds { |
| 133 | ds = append(ds, buildToOut(b)) | 155 | ds = append(ds, buildToOut(b)) |
| 134 | } | 156 | } |
| 135 | return c.emit(ds, func(w io.Writer) { | 157 | subjects := buildSubjects(c, repo, ds) |
| 158 | for i := range ds { | ||
| 159 | ds[i].Subject = subjects[ds[i].SHA] | ||
| 160 | } | ||
| 161 | return c.emitPage(p, ds, next, func(w io.Writer) { | ||
| 136 | for _, d := range ds { | 162 | for _, d := range ds { |
| 137 | fmt.Fprintf(w, "%d\t%s\t%s\t%.10s\t%s\n", d.Number, d.Job, d.Status, d.SHA, d.Ref) | 163 | fmt.Fprintf(w, "%d\t%s\t%s\t%.10s\t%s\t%s\n", d.Number, d.Job, d.Status, d.SHA, d.Ref, d.Subject) |
| 138 | } | 164 | } |
| 139 | }) | 165 | }) |
| 140 | } | 166 | } |
| 141 | 167 | ||
| 168 | // buildSubjects reads the commit subject of each distinct sha on a page | ||
| 169 | // of builds. Several jobs of one push share a commit, so the set is | ||
| 170 | // usually far smaller than the page. | ||
| 171 | func buildSubjects(c *Ctx, repo store.Repo, ds []BuildOut) map[string]string { | ||
| 172 | seen := map[string]bool{} | ||
| 173 | var shas []string | ||
| 174 | for _, d := range ds { | ||
| 175 | if d.SHA != "" && !seen[d.SHA] { | ||
| 176 | seen[d.SHA] = true | ||
| 177 | shas = append(shas, d.SHA) | ||
| 178 | } | ||
| 179 | } | ||
| 180 | return gitutil.Subjects(RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name), shas) | ||
| 181 | } | ||
| 182 | |||
| 142 | func runBuildShow(c *Ctx, args []string) int { | 183 | func runBuildShow(c *Ctx, args []string) int { |
| 143 | _, b, code := buildRef(c, args) | 184 | _, b, code := buildRef(c, args) |
| 144 | if code >= 0 { | 185 | if code >= 0 { |
internal/control/buildlistpage_test.go added +139
| @@ -0,0 +1,139 @@ | |||
| 1 | package control | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "bytes" | ||
| 5 | "encoding/json" | ||
| 6 | "os" | ||
| 7 | "path/filepath" | ||
| 8 | "strings" | ||
| 9 | "testing" | ||
| 10 | |||
| 11 | "gitbay.org/gitbay/internal/protocol" | ||
| 12 | "gitbay.org/gitbay/internal/store" | ||
| 13 | ) | ||
| 14 | |||
| 15 | // A run row led with a sha and said nothing about what the commit was | ||
| 16 | // (#241). build list carries the subject now, for every surface at once. | ||
| 17 | func TestBuildListCarriesCommitSubjects(t *testing.T) { | ||
| 18 | st, repo, uid := newQueueTestRepo(t) | ||
| 19 | git := gitRunner(t) | ||
| 20 | root := t.TempDir() | ||
| 21 | |||
| 22 | src := filepath.Join(root, "src") | ||
| 23 | os.MkdirAll(src, 0o755) | ||
| 24 | git(root, "init", "-q", "-b", "main", "src") | ||
| 25 | os.WriteFile(filepath.Join(src, "a"), []byte("one\n"), 0o644) | ||
| 26 | git(src, "add", ".") | ||
| 27 | git(src, "commit", "-qm", "runner: cap a build's container") | ||
| 28 | sha := strings.TrimSpace(git(src, "rev-parse", "HEAD")) | ||
| 29 | |||
| 30 | dir := RepoDir(root, repo.OwnerName, repo.Name) | ||
| 31 | os.MkdirAll(filepath.Dir(dir), 0o755) | ||
| 32 | git(root, "clone", "-q", "--bare", src, dir) | ||
| 33 | |||
| 34 | if _, err := st.CreateBuild(repo.ID, "unit", sha, "main", `["true"]`, "", "", true); err != nil { | ||
| 35 | t.Fatal(err) | ||
| 36 | } | ||
| 37 | // A build whose commit is gone, as a force-push leaves behind. | ||
| 38 | if _, err := st.CreateBuild(repo.ID, "lint", strings.Repeat("1", 40), "main", `["true"]`, "", "", true); err != nil { | ||
| 39 | t.Fatal(err) | ||
| 40 | } | ||
| 41 | |||
| 42 | c, errOut := pruneCtx(st, root, store.User{ID: uid}) | ||
| 43 | c.JSON = true | ||
| 44 | if code := Dispatch(c, []string{"build", "list", repo.Path()}); code != protocol.ExitOK { | ||
| 45 | t.Fatalf("build list: exit %d: %s", code, errOut.String()) | ||
| 46 | } | ||
| 47 | var got []BuildOut | ||
| 48 | decodeData(t, c.Stdout.(*bytes.Buffer).Bytes(), &got) | ||
| 49 | if len(got) != 2 { | ||
| 50 | t.Fatalf("builds: %+v", got) | ||
| 51 | } | ||
| 52 | for _, b := range got { | ||
| 53 | switch b.Job { | ||
| 54 | case "unit": | ||
| 55 | if b.Subject != "runner: cap a build's container" { | ||
| 56 | t.Errorf("unit subject = %q", b.Subject) | ||
| 57 | } | ||
| 58 | case "lint": | ||
| 59 | if b.Subject != "" { | ||
| 60 | t.Errorf("a build whose commit is gone claims a subject: %q", b.Subject) | ||
| 61 | } | ||
| 62 | } | ||
| 63 | } | ||
| 64 | } | ||
| 65 | |||
| 66 | // The builds list was capped at 50 with no way past it, so filtering to a | ||
| 67 | // sparse status reached further back and appeared to raise the total | ||
| 68 | // (#244). --limit and --cursor page it like every other list command. | ||
| 69 | func TestBuildListPages(t *testing.T) { | ||
| 70 | st, repo, uid := newQueueTestRepo(t) | ||
| 71 | for i := 0; i < 5; i++ { | ||
| 72 | if _, err := st.CreateBuild(repo.ID, "unit", "aaa", "main", `["true"]`, "", "", true); err != nil { | ||
| 73 | t.Fatal(err) | ||
| 74 | } | ||
| 75 | } | ||
| 76 | c, errOut := pruneCtx(st, t.TempDir(), store.User{ID: uid}) | ||
| 77 | c.JSON = true | ||
| 78 | |||
| 79 | page := func(args ...string) (nums []int64, next string) { | ||
| 80 | t.Helper() | ||
| 81 | out := c.Stdout.(*bytes.Buffer) | ||
| 82 | out.Reset() | ||
| 83 | errOut.Reset() | ||
| 84 | argv := append([]string{"build", "list", repo.Path()}, args...) | ||
| 85 | if code := Dispatch(c, argv); code != protocol.ExitOK { | ||
| 86 | t.Fatalf("build list %v: exit %d: %s", args, code, errOut.String()) | ||
| 87 | } | ||
| 88 | var got struct { | ||
| 89 | Items []BuildOut `json:"items"` | ||
| 90 | Next string `json:"next"` | ||
| 91 | } | ||
| 92 | decodeData(t, out.Bytes(), &got) | ||
| 93 | for _, b := range got.Items { | ||
| 94 | nums = append(nums, b.Number) | ||
| 95 | } | ||
| 96 | return nums, got.Next | ||
| 97 | } | ||
| 98 | |||
| 99 | nums, next := page("--limit", "2") | ||
| 100 | if len(nums) != 2 || nums[0] != 5 || nums[1] != 4 { | ||
| 101 | t.Fatalf("first page: %v", nums) | ||
| 102 | } | ||
| 103 | if next == "" { | ||
| 104 | t.Fatal("first page offers no cursor with three builds left") | ||
| 105 | } | ||
| 106 | nums, next = page("--limit", "2", "--cursor", next) | ||
| 107 | if len(nums) != 2 || nums[0] != 3 || nums[1] != 2 { | ||
| 108 | t.Fatalf("second page: %v", nums) | ||
| 109 | } | ||
| 110 | nums, next = page("--limit", "2", "--cursor", next) | ||
| 111 | if len(nums) != 1 || nums[0] != 1 { | ||
| 112 | t.Fatalf("last page: %v", nums) | ||
| 113 | } | ||
| 114 | if next != "" { | ||
| 115 | t.Errorf("last page offers a cursor: %q", next) | ||
| 116 | } | ||
| 117 | |||
| 118 | // A cursor minted by another command is not a build cursor. | ||
| 119 | out := c.Stdout.(*bytes.Buffer) | ||
| 120 | out.Reset() | ||
| 121 | errOut.Reset() | ||
| 122 | if code := Dispatch(c, []string{"build", "list", repo.Path(), "--cursor", encodeCursor("issue", "3")}); code != protocol.ExitUsage { | ||
| 123 | t.Fatalf("foreign cursor: exit %d, want %d", code, protocol.ExitUsage) | ||
| 124 | } | ||
| 125 | } | ||
| 126 | |||
| 127 | // decodeData unwraps the protocol envelope the JSON emitters write. | ||
| 128 | func decodeData(t *testing.T, b []byte, into any) { | ||
| 129 | t.Helper() | ||
| 130 | var env struct { | ||
| 131 | Data json.RawMessage `json:"data"` | ||
| 132 | } | ||
| 133 | if err := json.Unmarshal(b, &env); err != nil { | ||
| 134 | t.Fatalf("envelope: %v\n%s", err, b) | ||
| 135 | } | ||
| 136 | if err := json.Unmarshal(env.Data, into); err != nil { | ||
| 137 | t.Fatalf("data: %v\n%s", err, env.Data) | ||
| 138 | } | ||
| 139 | } | ||
internal/gitutil/messages.go +25
| @@ -109,3 +109,28 @@ func RevListMessages(dir, old, new string, max int) ([]CommitMsg, error) { | |||
| 109 | } | 109 | } |
| 110 | return msgs, nil | 110 | return msgs, nil |
| 111 | } | 111 | } |
| 112 | |||
| 113 | // Subjects returns the first line of each named commit's message, keyed | ||
| 114 | // by sha. One git log for the whole set rather than one per sha: a page | ||
| 115 | // of builds names a handful of distinct commits and a subprocess each | ||
| 116 | // would show. --ignore-missing keeps a sha git cannot resolve from | ||
| 117 | // failing the rest, because a build outlives the commit it ran on once a | ||
| 118 | // branch is force-pushed; such a sha is simply absent from the map. | ||
| 119 | func Subjects(dir string, shas []string) map[string]string { | ||
| 120 | if len(shas) == 0 { | ||
| 121 | return nil | ||
| 122 | } | ||
| 123 | args := append([]string{"-C", dir, "log", "--no-walk=unsorted", "--ignore-missing", "--format=%H%x00%s"}, shas...) | ||
| 124 | args = append(args, "--") | ||
| 125 | out, err := exec.Command(toolpath.Look("git"), args...).Output() | ||
| 126 | if err != nil { | ||
| 127 | return nil | ||
| 128 | } | ||
| 129 | subjects := map[string]string{} | ||
| 130 | for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { | ||
| 131 | if sha, subject, ok := strings.Cut(line, "\x00"); ok { | ||
| 132 | subjects[sha] = subject | ||
| 133 | } | ||
| 134 | } | ||
| 135 | return subjects | ||
| 136 | } | ||
internal/gitutil/subjects_test.go added +50
| @@ -0,0 +1,50 @@ | |||
| 1 | package gitutil | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "os/exec" | ||
| 5 | "strings" | ||
| 6 | "testing" | ||
| 7 | ) | ||
| 8 | |||
| 9 | func TestSubjects(t *testing.T) { | ||
| 10 | dir := t.TempDir() | ||
| 11 | git(t, dir, "init", "-q", "-b", "main") | ||
| 12 | |||
| 13 | write(t, dir, "a", "one\n") | ||
| 14 | git(t, dir, "add", ".") | ||
| 15 | git(t, dir, "commit", "-qm", "first thing") | ||
| 16 | first := rev(t, dir, "HEAD") | ||
| 17 | |||
| 18 | write(t, dir, "a", "two\n") | ||
| 19 | git(t, dir, "commit", "-qam", "second thing") | ||
| 20 | second := rev(t, dir, "HEAD") | ||
| 21 | |||
| 22 | got := Subjects(dir, []string{second, first}) | ||
| 23 | if got[first] != "first thing" || got[second] != "second thing" { | ||
| 24 | t.Fatalf("Subjects = %v", got) | ||
| 25 | } | ||
| 26 | |||
| 27 | // A build outlives the commit it ran on when a branch is | ||
| 28 | // force-pushed. The shas that do resolve still come back. | ||
| 29 | gone := strings.Repeat("1", 40) | ||
| 30 | got = Subjects(dir, []string{gone, first}) | ||
| 31 | if got[first] != "first thing" { | ||
| 32 | t.Errorf("a missing sha lost the rest: %v", got) | ||
| 33 | } | ||
| 34 | if _, ok := got[gone]; ok { | ||
| 35 | t.Errorf("resolved a sha that is not there: %v", got) | ||
| 36 | } | ||
| 37 | |||
| 38 | if Subjects(dir, nil) != nil { | ||
| 39 | t.Error("empty sha list should not run git") | ||
| 40 | } | ||
| 41 | } | ||
| 42 | |||
| 43 | func rev(t *testing.T, dir, ref string) string { | ||
| 44 | t.Helper() | ||
| 45 | out, err := exec.Command("git", "-C", dir, "rev-parse", ref).Output() | ||
| 46 | if err != nil { | ||
| 47 | t.Fatal(err) | ||
| 48 | } | ||
| 49 | return strings.TrimSpace(string(out)) | ||
| 50 | } | ||
internal/httpd/buildpages_test.go +82 −2
| @@ -36,11 +36,12 @@ func TestBuildsPageRendersCommandOutput(t *testing.T) { | |||
| 36 | Filter buildFilter | 36 | Filter buildFilter |
| 37 | Facets []facetGroup | 37 | Facets []facetGroup |
| 38 | Refs []string | 38 | Refs []string |
| 39 | Older string | ||
| 39 | CanWrite bool | 40 | CanWrite bool |
| 40 | Notice string | 41 | Notice string |
| 41 | }{ | 42 | }{ |
| 42 | testRepoPage(), builds, jobs, groupRuns(builds), filter, nil, | 43 | testRepoPage(), builds, jobs, groupRuns(builds), filter, nil, |
| 43 | distinctRefs(builds, filter.Ref), true, "", | 44 | distinctRefs(builds, filter.Ref), "", true, "", |
| 44 | }) | 45 | }) |
| 45 | if err != nil { | 46 | if err != nil { |
| 46 | t.Fatalf("render: %v", err) | 47 | t.Fatalf("render: %v", err) |
| @@ -111,11 +112,12 @@ func TestBuildsPageCountsBuildsAndRuns(t *testing.T) { | |||
| 111 | Filter buildFilter | 112 | Filter buildFilter |
| 112 | Facets []facetGroup | 113 | Facets []facetGroup |
| 113 | Refs []string | 114 | Refs []string |
| 115 | Older string | ||
| 114 | CanWrite bool | 116 | CanWrite bool |
| 115 | Notice string | 117 | Notice string |
| 116 | }{ | 118 | }{ |
| 117 | testRepoPage(), builds, nil, groupRuns(builds), filter, nil, | 119 | testRepoPage(), builds, nil, groupRuns(builds), filter, nil, |
| 118 | distinctRefs(builds, filter.Ref), true, "", | 120 | distinctRefs(builds, filter.Ref), "", true, "", |
| 119 | }) | 121 | }) |
| 120 | if err != nil { | 122 | if err != nil { |
| 121 | t.Fatalf("render: %v", err) | 123 | t.Fatalf("render: %v", err) |
| @@ -124,3 +126,81 @@ func TestBuildsPageCountsBuildsAndRuns(t *testing.T) { | |||
| 124 | t.Errorf("builds.html count line: %q", sb.String()) | 126 | t.Errorf("builds.html count line: %q", sb.String()) |
| 125 | } | 127 | } |
| 126 | } | 128 | } |
| 129 | |||
| 130 | // A run row led with a ten-character sha and nothing said what the commit | ||
| 131 | // was (#241). The subject leads now, the sha follows as metadata, and a | ||
| 132 | // build whose commit is gone falls back to the sha alone. | ||
| 133 | func TestBuildsPageLeadsWithTheSubject(t *testing.T) { | ||
| 134 | builds := []control.BuildOut{ | ||
| 135 | {Number: 2, Job: "unit", Status: "success", SHA: "ff6271a9d4570cd46f169091637a9d2e40ad5c2b", | ||
| 136 | Ref: "main", CreatedAt: "2026-09-20T06:00:00Z", Subject: "runner: cap a build's container"}, | ||
| 137 | {Number: 1, Job: "unit", Status: "failure", SHA: "aa11bb22cc33dd44ee55ff6677889900aabbccdd", | ||
| 138 | Ref: "main", CreatedAt: "2026-09-19T06:00:00Z"}, | ||
| 139 | } | ||
| 140 | out := renderBuilds(t, builds, buildFilter{}, "?cursor=abc") | ||
| 141 | if !strings.Contains(out, "runner: cap a build's container") { | ||
| 142 | t.Errorf("builds.html does not lead with the subject:\n%s", out) | ||
| 143 | } | ||
| 144 | if !strings.Contains(out, "<code>ff6271a9d4</code>") { | ||
| 145 | t.Errorf("builds.html drops the sha from the metadata line:\n%s", out) | ||
| 146 | } | ||
| 147 | // The commit is gone, so the sha is all there is to name the row by. | ||
| 148 | if !strings.Contains(out, ">aa11bb22cc</a>") { | ||
| 149 | t.Errorf("a subjectless run does not fall back to its sha:\n%s", out) | ||
| 150 | } | ||
| 151 | } | ||
| 152 | |||
| 153 | // Filtering appeared to raise the run count because the list was capped | ||
| 154 | // at 50 with the window unstated (#244). The page is paged now: the count | ||
| 155 | // says it counts this page, and the link to the next one carries every | ||
| 156 | // filter. | ||
| 157 | func TestBuildsPagePagesAndSaysSo(t *testing.T) { | ||
| 158 | builds := []control.BuildOut{{Number: 1, Job: "unit", Status: "failure", SHA: "aaa", Ref: "main", CreatedAt: "2026-09-19T06:00:00Z"}} | ||
| 159 | out := renderBuilds(t, builds, buildFilter{Status: "failure"}, "?cursor=abc&status=failure") | ||
| 160 | if !strings.Contains(out, "1 build in 1 run on this page") { | ||
| 161 | t.Errorf("count line does not name the page:\n%s", out) | ||
| 162 | } | ||
| 163 | if !strings.Contains(out, `href="?cursor=abc&status=failure"`) { | ||
| 164 | t.Errorf("pager link missing or drops the filter:\n%s", out) | ||
| 165 | } | ||
| 166 | // With everything on one page the count is the whole count. | ||
| 167 | if out := renderBuilds(t, builds, buildFilter{}, ""); strings.Contains(out, "on this page") { | ||
| 168 | t.Errorf("an unpaged listing still hedges the count:\n%s", out) | ||
| 169 | } | ||
| 170 | } | ||
| 171 | |||
| 172 | func TestOlderBuildsCarriesFilters(t *testing.T) { | ||
| 173 | if got := olderBuilds(buildFilter{Ref: "main"}, ""); got != "" { | ||
| 174 | t.Errorf("no next cursor should mean no link, got %q", got) | ||
| 175 | } | ||
| 176 | got := olderBuilds(buildFilter{Ref: "feature/x", Status: "failure", Job: "unit"}, "c1") | ||
| 177 | for _, want := range []string{"cursor=c1", "ref=feature%2Fx", "status=failure", "job=unit"} { | ||
| 178 | if !strings.Contains(got, want) { | ||
| 179 | t.Errorf("olderBuilds = %q, missing %q", got, want) | ||
| 180 | } | ||
| 181 | } | ||
| 182 | } | ||
| 183 | |||
| 184 | func renderBuilds(t *testing.T, builds []control.BuildOut, filter buildFilter, older string) string { | ||
| 185 | t.Helper() | ||
| 186 | var sb strings.Builder | ||
| 187 | err := web.Render(&sb, "builds.html", struct { | ||
| 188 | repoPage | ||
| 189 | Builds []control.BuildOut | ||
| 190 | Jobs []control.JobOut | ||
| 191 | Runs []buildRun | ||
| 192 | Filter buildFilter | ||
| 193 | Facets []facetGroup | ||
| 194 | Refs []string | ||
| 195 | Older string | ||
| 196 | CanWrite bool | ||
| 197 | Notice string | ||
| 198 | }{ | ||
| 199 | testRepoPage(), builds, nil, groupRuns(builds), filter, nil, | ||
| 200 | distinctRefs(builds, filter.Ref), older, true, "", | ||
| 201 | }) | ||
| 202 | if err != nil { | ||
| 203 | t.Fatalf("render: %v", err) | ||
| 204 | } | ||
| 205 | return sb.String() | ||
| 206 | } | ||
internal/httpd/builds.go +35 −5
| @@ -132,6 +132,7 @@ func distinctRefs(builds []control.BuildOut, current string) []string { | |||
| 132 | // one queueing of a commit, not the commit — see groupRuns (#240). | 132 | // one queueing of a commit, not the commit — see groupRuns (#240). |
| 133 | type buildRun struct { | 133 | type buildRun struct { |
| 134 | SHA string | 134 | SHA string |
| 135 | Subject string | ||
| 135 | Ref string | 136 | Ref string |
| 136 | CreatedAt string | 137 | CreatedAt string |
| 137 | Status string | 138 | Status string |
| @@ -194,7 +195,7 @@ func groupRuns(builds []control.BuildOut) []buildRun { | |||
| 194 | runs[n-1].Builds = append(runs[n-1].Builds, b) | 195 | runs[n-1].Builds = append(runs[n-1].Builds, b) |
| 195 | continue | 196 | continue |
| 196 | } | 197 | } |
| 197 | runs = append(runs, buildRun{SHA: b.SHA, Ref: b.Ref, CreatedAt: b.CreatedAt, Builds: []control.BuildOut{b}}) | 198 | runs = append(runs, buildRun{SHA: b.SHA, Subject: b.Subject, Ref: b.Ref, CreatedAt: b.CreatedAt, Builds: []control.BuildOut{b}}) |
| 198 | } | 199 | } |
| 199 | for i := range runs { | 200 | for i := range runs { |
| 200 | runs[i].Status = combinedStatus(runs[i].Builds) | 201 | runs[i].Status = combinedStatus(runs[i].Builds) |
| @@ -202,6 +203,27 @@ func groupRuns(builds []control.BuildOut) []buildRun { | |||
| 202 | return runs | 203 | return runs |
| 203 | } | 204 | } |
| 204 | 205 | ||
| 206 | // buildsPerPage is how many builds one page of the builds tab asks for. | ||
| 207 | // Fewer than the command's own default, because the page folds them into | ||
| 208 | // runs and a run is several builds tall (#244). | ||
| 209 | const buildsPerPage = 30 | ||
| 210 | |||
| 211 | // olderBuilds is the link to the page after this one: the command's own | ||
| 212 | // keyset cursor with the three filters carried along, so paging never | ||
| 213 | // drops a filter and a filter never lands on page two. | ||
| 214 | func olderBuilds(f buildFilter, next string) string { | ||
| 215 | if next == "" { | ||
| 216 | return "" | ||
| 217 | } | ||
| 218 | q := url.Values{"cursor": {next}} | ||
| 219 | for k, v := range map[string]string{"ref": f.Ref, "status": f.Status, "job": f.Job} { | ||
| 220 | if v != "" { | ||
| 221 | q.Set(k, v) | ||
| 222 | } | ||
| 223 | } | ||
| 224 | return "?" + q.Encode() | ||
| 225 | } | ||
| 226 | |||
| 205 | func (s *Server) builds(w http.ResponseWriter, r *http.Request) { | 227 | func (s *Server) builds(w http.ResponseWriter, r *http.Request) { |
| 206 | p, ok := s.repoFor(w, r, "") | 228 | p, ok := s.repoFor(w, r, "") |
| 207 | if !ok { | 229 | if !ok { |
| @@ -212,7 +234,7 @@ func (s *Server) builds(w http.ResponseWriter, r *http.Request) { | |||
| 212 | 234 | ||
| 213 | qv := r.URL.Query() | 235 | qv := r.URL.Query() |
| 214 | filter := buildFilter{Ref: qv.Get("ref"), Status: qv.Get("status"), Job: qv.Get("job")} | 236 | filter := buildFilter{Ref: qv.Get("ref"), Status: qv.Get("status"), Job: qv.Get("job")} |
| 215 | argv := []string{"build", "list", p.Repo.Path()} | 237 | argv := []string{"build", "list", p.Repo.Path(), "--limit", strconv.Itoa(buildsPerPage)} |
| 216 | if filter.Ref != "" { | 238 | if filter.Ref != "" { |
| 217 | argv = append(argv, "--ref", filter.Ref) | 239 | argv = append(argv, "--ref", filter.Ref) |
| 218 | } | 240 | } |
| @@ -222,9 +244,16 @@ func (s *Server) builds(w http.ResponseWriter, r *http.Request) { | |||
| 222 | if filter.Job != "" { | 244 | if filter.Job != "" { |
| 223 | argv = append(argv, "--job", filter.Job) | 245 | argv = append(argv, "--job", filter.Job) |
| 224 | } | 246 | } |
| 247 | if cursor := qv.Get("cursor"); cursor != "" { | ||
| 248 | argv = append(argv, "--cursor", cursor) | ||
| 249 | } | ||
| 225 | 250 | ||
| 226 | var builds []control.BuildOut | 251 | var page struct { |
| 227 | s.runControlInto(viewer, argv, &builds) | 252 | Items []control.BuildOut `json:"items"` |
| 253 | Next string `json:"next"` | ||
| 254 | } | ||
| 255 | s.runControlInto(viewer, argv, &page) | ||
| 256 | builds := page.Items | ||
| 228 | 257 | ||
| 229 | // The jobs a trigger can name. A repo without a CI config has none; | 258 | // The jobs a trigger can name. A repo without a CI config has none; |
| 230 | // that is not an error for this page. | 259 | // that is not an error for this page. |
| @@ -241,10 +270,11 @@ func (s *Server) builds(w http.ResponseWriter, r *http.Request) { | |||
| 241 | Filter buildFilter | 270 | Filter buildFilter |
| 242 | Facets []facetGroup | 271 | Facets []facetGroup |
| 243 | Refs []string | 272 | Refs []string |
| 273 | Older string | ||
| 244 | CanWrite bool | 274 | CanWrite bool |
| 245 | Notice string | 275 | Notice string |
| 246 | }{p, builds, jobs, groupRuns(builds), filter, buildFacets(filter, jobs, refs), refs, | 276 | }{p, builds, jobs, groupRuns(builds), filter, buildFacets(filter, jobs, refs), refs, |
| 247 | s.canWriteRepo(r, p.Repo), s.takeFlash(w, r)}) | 277 | olderBuilds(filter, page.Next), s.canWriteRepo(r, p.Repo), s.takeFlash(w, r)}) |
| 248 | } | 278 | } |
| 249 | 279 | ||
| 250 | func (s *Server) build(w http.ResponseWriter, r *http.Request) { | 280 | func (s *Server) build(w http.ResponseWriter, r *http.Request) { |
internal/httpd/explorepage_test.go added +56
| @@ -0,0 +1,56 @@ | |||
| 1 | package httpd | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "strings" | ||
| 5 | "testing" | ||
| 6 | |||
| 7 | "gitbay.org/gitbay/internal/store" | ||
| 8 | "gitbay.org/gitbay/internal/web" | ||
| 9 | ) | ||
| 10 | |||
| 11 | // A filtered explore showed a shorter list and nothing else: no count, no | ||
| 12 | // restatement of the query, and no way back to the unfiltered view (#243). | ||
| 13 | func TestExplorePageSummarisesTheFilter(t *testing.T) { | ||
| 14 | repos := []describedRepo{ | ||
| 15 | {Repo: store.Repo{OwnerName: "krz", Name: "gitbay"}, Desc: "A CLI-first git forge."}, | ||
| 16 | } | ||
| 17 | out := renderExplore(t, "forge", repos) | ||
| 18 | for _, want := range []string{"1 repository", "matching <strong>forge</strong>", `href="/explore"`, "clear filter"} { | ||
| 19 | if !strings.Contains(out, want) { | ||
| 20 | t.Errorf("explore.html missing %q:\n%s", want, out) | ||
| 21 | } | ||
| 22 | } | ||
| 23 | |||
| 24 | // Nothing to clear when nothing is filtered. | ||
| 25 | if out := renderExplore(t, "", repos); strings.Contains(out, "clear filter") { | ||
| 26 | t.Errorf("unfiltered explore offers a clear:\n%s", out) | ||
| 27 | } else if !strings.Contains(out, "1 repository") { | ||
| 28 | t.Errorf("unfiltered explore has no count:\n%s", out) | ||
| 29 | } | ||
| 30 | |||
| 31 | // A filter that matched nothing says so rather than claiming the | ||
| 32 | // instance has no public repositories. | ||
| 33 | out = renderExplore(t, "nothing", nil) | ||
| 34 | if !strings.Contains(out, "nothing matches that filter") { | ||
| 35 | t.Errorf("empty filtered list reads as an empty instance:\n%s", out) | ||
| 36 | } | ||
| 37 | if !strings.Contains(out, "0 repositories") { | ||
| 38 | t.Errorf("empty filtered list has no count:\n%s", out) | ||
| 39 | } | ||
| 40 | } | ||
| 41 | |||
| 42 | func renderExplore(t *testing.T, q string, repos []describedRepo) string { | ||
| 43 | t.Helper() | ||
| 44 | var sb strings.Builder | ||
| 45 | err := web.Render(&sb, "explore.html", struct { | ||
| 46 | basePage | ||
| 47 | Tab string | ||
| 48 | Query string | ||
| 49 | Facets []facetGroup | ||
| 50 | Repos []describedRepo | ||
| 51 | }{basePage{Site: "gitbay"}, "explore", q, nil, repos}) | ||
| 52 | if err != nil { | ||
| 53 | t.Fatalf("render: %v", err) | ||
| 54 | } | ||
| 55 | return sb.String() | ||
| 56 | } | ||
internal/httpd/orgweb.go +3 −1
| @@ -43,9 +43,11 @@ func (s *Server) orgAdminView(viewer store.User, kind, name string) (teams []tea | |||
| 43 | // entries stay in one implementation. | 43 | // entries stay in one implementation. |
| 44 | func (s *Server) orgSubmit(w http.ResponseWriter, r *http.Request, u store.User) { | 44 | func (s *Server) orgSubmit(w http.ResponseWriter, r *http.Request, u store.User) { |
| 45 | owner := r.PathValue("owner") | 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 | back := func(msg string) { | 48 | back := func(msg string) { |
| 47 | s.setFlash(w, msg) | 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 | field := r.FormValue("field") | 52 | field := r.FormValue("field") |
| 51 | team := strings.TrimSpace(r.FormValue("team")) | 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/repohead_test.go +2 −1
| @@ -26,9 +26,10 @@ func TestRepoHeaderTwoRows(t *testing.T) { | |||
| 26 | Filter buildFilter | 26 | Filter buildFilter |
| 27 | Facets []facetGroup | 27 | Facets []facetGroup |
| 28 | Refs []string | 28 | Refs []string |
| 29 | Older string | ||
| 29 | CanWrite bool | 30 | CanWrite bool |
| 30 | Notice string | 31 | Notice string |
| 31 | }{p, nil, nil, nil, buildFilter{}, nil, nil, true, ""}) | 32 | }{p, nil, nil, nil, buildFilter{}, nil, nil, "", true, ""}) |
| 32 | if err != nil { | 33 | if err != nil { |
| 33 | t.Fatal(err) | 34 | t.Fatal(err) |
| 34 | } | 35 | } |
internal/httpd/routes.go +3
| @@ -75,6 +75,9 @@ func (s *Server) Routes() []Route { | |||
| 75 | Route{Method: "GET", Pattern: "/{owner}/{repo}/log.atom", Handler: s.logAtom}, | 75 | Route{Method: "GET", Pattern: "/{owner}/{repo}/log.atom", Handler: s.logAtom}, |
| 76 | Route{Method: "GET", Pattern: "/{owner}/{repo}/log.atom/{ref...}", Handler: s.logAtom}, | 76 | Route{Method: "GET", Pattern: "/{owner}/{repo}/log.atom/{ref...}", Handler: s.logAtom}, |
| 77 | Route{Method: "GET", Pattern: "/{owner}/activity.atom", Handler: s.ownerAtom}, | 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}/-/labels", Handler: s.orgLabels}, | 81 | Route{Method: "GET", Pattern: "/{owner}/-/labels", Handler: s.orgLabels}, |
| 79 | Route{Method: "GET", Pattern: "/{owner}/-/milestones", Handler: s.orgMilestones}, | 82 | Route{Method: "GET", Pattern: "/{owner}/-/milestones", Handler: s.orgMilestones}, |
| 80 | Route{Method: "GET", Pattern: "/{owner}/-/snippets", Handler: s.snippetsPage}, | 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 | // ownerPage renders /{owner} for users and orgs: the repositories the | 426 | // ownerPage renders /{owner} for users and orgs: the repositories the |
| 427 | // viewer may see, org membership either direction. Owner names are not | 427 | // viewer may see, org membership either direction. Owner names are not |
| 428 | // secret (they are on every commit); repository visibility rules hold. | 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 | func (s *Server) ownerPage(w http.ResponseWriter, r *http.Request) { | 446 | func (s *Server) ownerPage(w http.ResponseWriter, r *http.Request) { |
| 430 | name := r.PathValue("owner") | 447 | name := r.PathValue("owner") |
| 431 | var viewer store.User | 448 | var viewer store.User |
| @@ -455,11 +472,21 @@ func (s *Server) ownerPage(w http.ResponseWriter, r *http.Request) { | |||
| 455 | weeks, activityTotal := activityGrid(counts) | 472 | weeks, activityTotal := activityGrid(counts) |
| 456 | 473 | ||
| 457 | teams, canAdmin := s.orgAdminView(viewer, d.Kind, name) | 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 | profile := store.Profile{Description: d.Description, Website: d.Website, Links: d.Links} | 484 | profile := store.Profile{Description: d.Description, Website: d.Website, Links: d.Links} |
| 459 | s.render(w, "owner.html", struct { | 485 | s.render(w, "owner.html", struct { |
| 460 | basePage | 486 | basePage |
| 461 | Owner string | 487 | Owner string |
| 462 | Kind string | 488 | Kind string |
| 489 | Tab string | ||
| 463 | Profile store.Profile | 490 | Profile store.Profile |
| 464 | AboutHTML template.HTML | 491 | AboutHTML template.HTML |
| 465 | Repos []profileRepoRow | 492 | Repos []profileRepoRow |
| @@ -473,7 +500,7 @@ func (s *Server) ownerPage(w http.ResponseWriter, r *http.Request) { | |||
| 473 | Snippets int | 500 | Snippets int |
| 474 | Notice string | 501 | Notice string |
| 475 | Feed string | 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 | d.Repos, d.Members, d.Orgs, | 504 | d.Repos, d.Members, d.Orgs, |
| 478 | weeks, activityTotal, teams, canAdmin, | 505 | weeks, activityTotal, teams, canAdmin, |
| 479 | d.Kind == "user" && viewer.ID != 0 && strings.EqualFold(viewer.Username, name), | 506 | d.Kind == "user" && viewer.ID != 0 && strings.EqualFold(viewer.Username, name), |
internal/store/builds.go +7
| @@ -275,10 +275,13 @@ func (s *Store) BuildByNumber(repoID, number int64) (Build, error) { | |||
| 275 | } | 275 | } |
| 276 | 276 | ||
| 277 | // BuildFilter narrows ListBuilds to builds matching every non-empty field. | 277 | // BuildFilter narrows ListBuilds to builds matching every non-empty field. |
| 278 | // Before is the keyset cursor: only builds numbered below it, which with | ||
| 279 | // the newest-first order is the page after the one that ended there. | ||
| 278 | type BuildFilter struct { | 280 | type BuildFilter struct { |
| 279 | Ref string | 281 | Ref string |
| 280 | Status string | 282 | Status string |
| 281 | Job string | 283 | Job string |
| 284 | Before int64 | ||
| 282 | } | 285 | } |
| 283 | 286 | ||
| 284 | func (s *Store) ListBuilds(repoID int64, f BuildFilter, limit int) ([]Build, error) { | 287 | func (s *Store) ListBuilds(repoID int64, f BuildFilter, limit int) ([]Build, error) { |
| @@ -296,6 +299,10 @@ func (s *Store) ListBuilds(repoID int64, f BuildFilter, limit int) ([]Build, err | |||
| 296 | q += " AND job = ?" | 299 | q += " AND job = ?" |
| 297 | args = append(args, f.Job) | 300 | args = append(args, f.Job) |
| 298 | } | 301 | } |
| 302 | if f.Before > 0 { | ||
| 303 | q += " AND number < ?" | ||
| 304 | args = append(args, f.Before) | ||
| 305 | } | ||
| 299 | q += " ORDER BY number DESC LIMIT ?" | 306 | q += " ORDER BY number DESC LIMIT ?" |
| 300 | args = append(args, limit) | 307 | args = append(args, limit) |
| 301 | rows, err := s.DB.Query(q, args...) | 308 | rows, err := s.DB.Query(q, args...) |
internal/web/static/img/mr-dark.png
Binary file not shown.
internal/web/static/img/mr-light.png
Binary file not shown.
internal/web/static/style.css +7
| @@ -1643,6 +1643,13 @@ details.refmenu .refdrop a.allrefs { | |||
| 1643 | pre.quickstart { margin: 0 0 var(--sp-4); } | 1643 | pre.quickstart { margin: 0 0 var(--sp-4); } |
| 1644 | .shot { border: 1px solid var(--line); border-radius: var(--r-card); overflow: hidden; margin: var(--sp-5) 0; } | 1644 | .shot { border: 1px solid var(--line); border-radius: var(--r-card); overflow: hidden; margin: var(--sp-5) 0; } |
| 1645 | .shot img { display: block; width: 100%; height: auto; max-height: 420px; object-fit: cover; object-position: top; } | 1645 | .shot img { display: block; width: 100%; height: auto; max-height: 420px; object-fit: cover; object-position: top; } |
| 1646 | .shot figcaption { | ||
| 1647 | border-top: 1px solid var(--line); | ||
| 1648 | background: var(--surface); | ||
| 1649 | padding: var(--sp-2) var(--sp-3); | ||
| 1650 | color: var(--muted); | ||
| 1651 | font-size: var(--fs-1); | ||
| 1652 | } | ||
| 1646 | .facets { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--sp-5); margin: var(--sp-5) 0; } | 1653 | .facets { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--sp-5); margin: var(--sp-5) 0; } |
| 1647 | .facets h2 { font-size: var(--fs-3); margin: 0 0 var(--sp-1); } | 1654 | .facets h2 { font-size: var(--fs-3); margin: 0 0 var(--sp-1); } |
| 1648 | .facets p { color: var(--muted); font-size: var(--fs-2); margin: 0; } | 1655 | .facets p { color: var(--muted); font-size: var(--fs-2); margin: 0; } |
internal/web/templates/builds.html +4 −3
| @@ -38,12 +38,12 @@ | |||
| 38 | <pre class="code" tabindex="0">[](https://{{.Host}}/{{.Repo.OwnerName}}/{{.Repo.Name}}/builds)</pre> | 38 | <pre class="code" tabindex="0">[](https://{{.Host}}/{{.Repo.OwnerName}}/{{.Repo.Name}}/builds)</pre> |
| 39 | <p class="meta">Add <code>?job=name</code> for one job.</p> | 39 | <p class="meta">Add <code>?job=name</code> for one job.</p> |
| 40 | </details> | 40 | </details> |
| 41 | <p class="meta">{{len .Builds}} build{{if ne (len .Builds) 1}}s{{end}} in {{len .Runs}} run{{if ne (len .Runs) 1}}s{{end}}{{if or .Filter.Ref .Filter.Status .Filter.Job}}, <a href="?">clear filters</a>{{end}}</p> | 41 | <p class="meta">{{len .Builds}} build{{if ne (len .Builds) 1}}s{{end}} in {{len .Runs}} run{{if ne (len .Runs) 1}}s{{end}}{{if .Older}} on this page{{end}}{{if or .Filter.Ref .Filter.Status .Filter.Job}}, <a href="?">clear filters</a>{{end}}</p> |
| 42 | <ul class="loglist rows"> | 42 | <ul class="loglist rows"> |
| 43 | {{range .Runs}}<li> | 43 | {{range .Runs}}<li> |
| 44 | <div class="commitmain"> | 44 | <div class="commitmain"> |
| 45 | <p class="subject"><code><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{.SHA}}">{{printf "%.10s" .SHA}}</a></code></p> | 45 | <p class="subject"><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{.SHA}}">{{if .Subject}}{{.Subject}}{{else}}{{printf "%.10s" .SHA}}{{end}}</a></p> |
| 46 | <p class="meta">{{.Ref}} · {{when .CreatedAt}}</p> | 46 | <p class="meta">{{if .Subject}}<code>{{printf "%.10s" .SHA}}</code> · {{end}}{{.Ref}} · {{when .CreatedAt}}</p> |
| 47 | </div> | 47 | </div> |
| 48 | <div class="commitside"> | 48 | <div class="commitside"> |
| 49 | <span class="badge check-{{.Status}}">{{.Status}}</span> | 49 | <span class="badge check-{{.Status}}">{{.Status}}</span> |
| @@ -52,6 +52,7 @@ | |||
| 52 | </li> | 52 | </li> |
| 53 | {{else}}<li class="empty">no builds — push a commit with a <code>.gitbay/ci.yml</code></li>{{end}} | 53 | {{else}}<li class="empty">no builds — push a commit with a <code>.gitbay/ci.yml</code></li>{{end}} |
| 54 | </ul> | 54 | </ul> |
| 55 | {{if .Older}}<p class="pager"><a href="{{.Older}}">older →</a></p>{{end}} | ||
| 55 | </div> | 56 | </div> |
| 56 | </div> | 57 | </div> |
| 57 | {{end}} | 58 | {{end}} |
internal/web/templates/explore.html +2 −1
| @@ -12,9 +12,10 @@ | |||
| 12 | </form> | 12 | </form> |
| 13 | <span class="spacer"></span> | 13 | <span class="spacer"></span> |
| 14 | </div> | 14 | </div> |
| 15 | <p class="meta">{{len .Repos}} repositor{{if eq (len .Repos) 1}}y{{else}}ies{{end}}{{if .Query}} matching <strong>{{.Query}}</strong>, <a href="/explore">clear filter</a>{{end}}</p> | ||
| 15 | <ul class="repolist rows"> | 16 | <ul class="repolist rows"> |
| 16 | {{range .Repos}}{{template "reporow" .}} | 17 | {{range .Repos}}{{template "reporow" .}} |
| 17 | {{else}}<li class="empty">no public repositories yet</li>{{end}} | 18 | {{else}}<li class="empty">{{if .Query}}nothing matches that filter{{else}}no public repositories yet{{end}}</li>{{end}} |
| 18 | </ul> | 19 | </ul> |
| 19 | </div> | 20 | </div> |
| 20 | </div> | 21 | </div> |
internal/web/templates/landing.html +5 −3
| @@ -7,10 +7,12 @@ | |||
| 7 | <pre class="quickstart" tabindex="0">ssh git@{{.Host}} help # every command, no client to install | 7 | <pre class="quickstart" tabindex="0">ssh git@{{.Host}} help # every command, no client to install |
| 8 | git clone ssh://git@{{.Host}}/owner/repo.git</pre> | 8 | git clone ssh://git@{{.Host}}/owner/repo.git</pre> |
| 9 | <div class="routes"><a class="button primary" href="/explore">Explore repositories</a>{{if .Signup}}<a class="button btn" href="/register">Create an account</a>{{end}}</div> | 9 | <div class="routes"><a class="button primary" href="/explore">Explore repositories</a>{{if .Signup}}<a class="button btn" href="/register">Create an account</a>{{end}}</div> |
| 10 | <div class="shot"><picture> | 10 | <figure class="shot"><picture> |
| 11 | <source srcset="/static/img/mr-dark.png" media="(prefers-color-scheme: dark)"> | 11 | <source srcset="/static/img/mr-dark.png" media="(prefers-color-scheme: dark)"> |
| 12 | <img src="/static/img/mr-light.png" width="1280" height="900" alt="A merge request page: the conversation on the left, checks and reviewers on the right."> | 12 | <img src="/static/img/mr-light.png" width="1280" height="900" alt="A merged merge request: the description on the left, and on the right two CI checks reporting success with how long each ran, an approval, and the source and target branches."> |
| 13 | </picture></div> | 13 | </picture> |
| 14 | <figcaption>A merged merge request on this instance. The description is on the left; the column beside it carries the CI checks, the approval that let it merge, and the branches it went between.</figcaption> | ||
| 15 | </figure> | ||
| 14 | <div class="facets"> | 16 | <div class="facets"> |
| 15 | <section><h2>Read</h2><p>Browse and clone any public repository over HTTPS or <code>git://</code>, no account. Every commit shows whether its signature verified.</p></section> | 17 | <section><h2>Read</h2><p>Browse and clone any public repository over HTTPS or <code>git://</code>, no account. Every commit shows whether its signature verified.</p></section> |
| 16 | <section><h2>Write</h2><p>Push over SSH with the key you already have. Create a repository, file an issue, open and merge a request, all as commands.</p></section> | 18 | <section><h2>Write</h2><p>Push over SSH with the key you already have. Create a repository, file an issue, open and merge a request, all as commands.</p></section> |
internal/web/templates/owner.html +25 −8
| @@ -9,7 +9,29 @@ | |||
| 9 | {{if .Members}}<p class="meta">members {{range .Members}}<a class="memberchip" href="/{{.Name}}">{{.Name}} <span class="role">{{.Role}}</span></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}} |
| 10 | {{if and (eq .Kind "org") .Repos}}<p class="meta"><a href="/{{.Owner}}/-/labels">labels</a> · <a href="/{{.Owner}}/-/milestones">milestones</a></p>{{end}} | 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 | </section> | 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 | <section class="activity"> | 35 | <section class="activity"> |
| 14 | <h2>activity <span class="count">{{.ActivityTotal}} in the last year</span></h2> | 36 | <h2>activity <span class="count">{{.ActivityTotal}} in the last year</span></h2> |
| 15 | <div class="actgraph-scroll"> | 37 | <div class="actgraph-scroll"> |
| @@ -19,14 +41,9 @@ | |||
| 19 | </div> | 41 | </div> |
| 20 | <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> | 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 | </section> | 43 | </section> |
| 22 | <h2>repositories <span class="count">{{len .Repos}}</span></h2> | 44 | {{end}} |
| 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}} | ||
| 28 | 45 | ||
| 29 | {{if .CanAdmin}}{{$org := .Owner}} | 46 | {{if eq .Tab "people"}}{{$org := .Owner}} |
| 30 | <h2>people <span class="count">{{len .Members}}</span></h2> | 47 | <h2>people <span class="count">{{len .Members}}</span></h2> |
| 31 | {{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}} | 48 | {{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}} |
| 32 | <div class="tablewrap"><table class="keys"> | 49 | <div class="tablewrap"><table class="keys"> |