The five web findings: builds, profile, explore, landing !453

merged merged by cmc on 2026-09-21 21:18 UTC · krz/gitbay:web-findings-241-245 into main

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.
200200| release create, edit | yes | yes | yes |
201201| preview release notes | n/a | yes | no |
202202| build list | yes | yes | yes |
203| build list paging (limit, cursor) | yes | yes | yes |
203204| build list filters (ref, status, job) | yes | yes | yes |
204205| build show (one build) | yes | yes | yes |
205206| build log | yes | yes | yes |
@@ -286,6 +287,7 @@ now render the way the reference does. go-org is not yet on the corpus.
286287| search issues and merge requests | yes | yes | yes |
287288| browse all public repositories | yes | yes | yes |
288289| profile page | yes | yes | yes |
290| profile sections as tabs | n/a | yes | n/a |
289291| profile about and links | yes | yes | yes |
290292| profile about as a file | yes | yes | yes |
291293| activity feed | yes | yes | yes |
@@ -308,7 +310,12 @@ it as =about=, =about_format= and =about_path=. It reads with the
308310repository's own access, so a private =.gitbay= is a profile with no
309311about text to anyone but its owner and the admins. A repository whose
310312name starts with a dot stays out of =explore= and off the profile's
311repository list. The iOS client decodes and renders both formats,
313repository list. On the web the about text is its own tab: =/<owner>=
314is the repository list, =/<owner>/-/about= the text, =/<owner>/-/activity=
315the year of squares, and =/<owner>/-/people= an organization's members
316and teams. A tab with nothing on it is not offered and its URL is a
317404. The CLI's =profile show= is unchanged and still returns all of it
318at once. The iOS client decodes and renders both formats,
312319through the same OrgSwift path a README takes.
313320
314321=help= lists the command registry. Bare it is an index, one line per
@@ -428,11 +435,13 @@ key, the other cannot be undone.
428435
429436* Pagination
430437
431=issue list=, =mr list=, =repo list=, and =feed= take =--limit <n>=
432and =--cursor <c>=. Cursors are opaque; each page carries the next
433one. Without the flags a list stays complete, so existing scripts are
434unchanged. The web pages the issue and merge request lists at fifty
435with the same cursors; iOS pages with them too.
438=issue list=, =mr list=, =repo list=, =feed= and =build list= take
439=--limit <n>= and =--cursor <c>=. Cursors are opaque; each page carries
440the next one. Without the flags a list stays as it was, so existing
441scripts are unchanged — for =build list= that means the newest fifty
442matching builds, which is what the cursor now reaches past. The web
443pages the issue and merge request lists at fifty and the builds list at
444thirty with the same cursors; iOS pages with them too.
436445
437446* CLI only, for now
438447
CHANGELOG.org +36
@@ -4,6 +4,42 @@ Versioning follows semver from v0.1.0. Database migrations run
44automatically on daemon start; upgrade notes appear per release when
55anything beyond "replace the binary and restart" is needed.
66
7* v1.33.0 — 2026-09-21
8
9Five 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
743* v1.32.1 — 2026-09-20
844
945A push alert names the account it is for (#89).
e2e/activity_test.go +4 −4
@@ -49,7 +49,7 @@ func TestActivityGraph(t *testing.T) {
4949 if _, _, code := inst.ssh(t, aliceKey, "", "issue", "create", "alice/app", "--title", "'x'"); code != 0 {
5050 t.Fatal("issue create failed")
5151 }
52 status, body := inst.get(t, "/alice")
52 status, body := inst.get(t, "/alice/-/activity")
5353 if status != 200 || !strings.Contains(body, `class="actgraph"`) {
5454 t.Fatalf("graph missing: %d", status)
5555 }
@@ -61,14 +61,14 @@ func TestActivityGraph(t *testing.T) {
6161 t.Fatalf("alice total = %d, want >= 2", total)
6262 }
6363 // bob authored a commit but his email is unverified: zero activity.
64 _, bobBody := inst.get(t, "/bob")
64 _, bobBody := inst.get(t, "/bob/-/activity")
6565 if bt := activityTotal(t, bobBody); bt != 0 {
6666 t.Fatalf("unverified author got credit: total %d", bt)
6767 }
6868
6969 // Re-pushing the same history (force) does not double-count.
7070 mustGit(t, dir, env, "push", "-q", "--force", "origin", "main")
71 _, body2 := inst.get(t, "/alice")
71 _, body2 := inst.get(t, "/alice/-/activity")
7272 if body2 != body {
7373 // Counts must be identical; compare just the graph cells.
7474 if excerpt(body, "actgraph") != excerpt(body2, "actgraph") {
@@ -83,7 +83,7 @@ func TestActivityGraph(t *testing.T) {
8383 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "transfer", "alice/app", "theorg"); code != 0 {
8484 t.Fatal("transfer failed")
8585 }
86 _, orgBody := inst.get(t, "/theorg")
86 _, orgBody := inst.get(t, "/theorg/-/activity")
8787 if !strings.Contains(orgBody, `class="actgraph"`) || strings.Contains(orgBody, "0 in the last year") {
8888 t.Fatalf("org graph empty:\n%s", excerpt(orgBody, "activity"))
8989 }
e2e/orgweb_test.go +9 −6
@@ -28,8 +28,11 @@ func TestOrgManagementWeb(t *testing.T) {
2828
2929 // The management sections are admin-only: bob is not even a member.
3030 bob := loginBrowser(t, inst, bobKey)
31 if _, body := browserGet(t, bob, inst.base()+"/acme"); strings.Contains(body, `value="member-add"`) {
32 t.Fatal("a non-member sees organization controls")
31 // The people tab is the admin panel, so an outsider gets the 404 a
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)
3336 }
3437 // And POSTing anyway is refused by the command, not by the template.
3538 browserPost(t, bob, inst.base()+"/acme", url.Values{
@@ -39,7 +42,7 @@ func TestOrgManagementWeb(t *testing.T) {
3942 t.Fatalf("non-admin added themselves: %v", members)
4043 }
4144
42 status, body := browserGet(t, alice, inst.base()+"/acme")
45 status, body := browserGet(t, alice, inst.base()+"/acme/-/people")
4346 if status != 200 || !strings.Contains(body, `value="member-add"`) {
4447 t.Fatalf("admin sees no controls: %d", status)
4548 }
@@ -73,8 +76,8 @@ func TestOrgManagementWeb(t *testing.T) {
7376 t.Fatalf("team grant did not confer access: %s", errOut)
7477 }
7578
76 // The page shows what was built.
77 _, body = browserGet(t, alice, inst.base()+"/acme")
79 // The people tab shows what was built.
80 _, body = browserGet(t, alice, inst.base()+"/acme/-/people")
7881 for _, want := range []string{"builders", "acme/widget", "1 member"} {
7982 if !strings.Contains(body, want) {
8083 t.Errorf("org page missing %q", want)
@@ -180,7 +183,7 @@ func TestOrgLifecycleWeb(t *testing.T) {
180183 }
181184
182185 // 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")
184187 if !strings.Contains(body, `value="org-rename"`) {
185188 t.Fatalf("no rename form for the org admin:\n%s", body)
186189 }
e2e/profile_test.go +15 −11
@@ -157,7 +157,7 @@ func TestOwnerProfiles(t *testing.T) {
157157 }
158158 // Org emphasis parsed, not left as literal slashes the way the
159159 // markdown renderer would.
160 _, body = inst.get(t, "/alice")
160 _, body = inst.get(t, "/alice/-/about")
161161 if !strings.Contains(body, "<em>small tools</em>") || strings.Contains(body, "/small tools/") {
162162 t.Fatalf("org about not rendered as org: %s", body)
163163 }
@@ -196,25 +196,29 @@ func TestOwnerProfiles(t *testing.T) {
196196 t.Fatal("more than five links accepted")
197197 }
198198
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).
200201 status, body = inst.get(t, "/alice")
201202 if status != 200 || !strings.Contains(body, "tinkerer") {
202203 t.Fatalf("user page profile: %d", status)
203204 }
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 }
209205 if !strings.Contains(body, `href="https://fosstodon.example/@alice"`) ||
210206 !strings.Contains(body, ">Mastodon<") {
211207 t.Fatalf("links not rendered: %s", body)
212208 }
213 if strings.Index(body, "<em>small tools</em>") > strings.Index(body, `class="activity"`) {
214 t.Error("about renders below the activity graph")
209 if !strings.Contains(body, `<ul class="repolist"`) {
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)
215219 }
216 if strings.Index(body, `<ul class="repolist"`) < strings.Index(body, `class="activity"`) {
217 t.Error("repositories render above the activity graph")
220 if _, g := inst.get(t, "/alice/-/activity"); !strings.Contains(g, `class="activity"`) {
221 t.Error("the activity tab has no graph")
218222 }
219223
220224 // 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) {
3939 t.Errorf("about_path missing: %s", out)
4040 }
4141
42 _, body := inst.get(t, "/alice")
42 _, body := inst.get(t, "/alice/-/about")
4343 if !strings.Contains(body, "hello from a file") {
4444 t.Error("web profile does not render the about")
4545 }
@@ -53,7 +53,7 @@ func TestProfileAboutFromRepo(t *testing.T) {
5353 "--ref", "main", "--file", "-"); code != 0 {
5454 t.Fatalf("committing bob's org about: %s", errOut)
5555 }
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>") {
5757 t.Errorf("about did not render as org:\n%s", page)
5858 }
5959}
internal/control/build.go +46 −5
@@ -22,7 +22,7 @@ import (
2222func init() {
2323 register(Command{Path: []string{"build", "list"},
2424 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})
2626 register(Command{Path: []string{"build", "show"},
2727 Summary: "show one build",
2828 Usage: "build show <owner/name> <n>", ReadOnly: true, Run: runBuildShow})
@@ -78,10 +78,15 @@ type BuildOut struct {
7878 Ref string `json:"ref"`
7979 CreatedAt string `json:"created_at"`
8080 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"`
8185}
8286
8387func 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}
8590}
8691
8792func 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) {
107112// is told to pick from.
108113var buildStatuses = []string{"pending", "running", "success", "failure", "cancelled"}
109114
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).
118const buildPage = 50
119
110120func runBuildList(c *Ctx, args []string) int {
121 args, p, code := parsePageFlags(c, args, "build", true)
122 if code >= 0 {
123 return code
124 }
111125 f, err := parseFlags(args, flagSpec{Values: []string{"--ref", "--status", "--job"}, MaxPos: 1, Usage: c.Cmd.Usage})
112126 if err != nil {
113127 return c.fail(protocol.ExitUsage, "%v", err)
@@ -124,21 +138,48 @@ func runBuildList(c *Ctx, args []string) int {
124138 if code >= 0 {
125139 return code
126140 }
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)
128147 if err != nil {
129148 return c.fail(protocol.ExitFailure, "%v", err)
130149 }
150 builds, next := trimPage(p, builds, "build", func(b store.Build) string {
151 return strconv.FormatInt(b.Number, 10)
152 })
131153 var ds []BuildOut
132154 for _, b := range builds {
133155 ds = append(ds, buildToOut(b))
134156 }
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) {
136162 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)
138164 }
139165 })
140166}
141167
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.
171func 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
142183func runBuildShow(c *Ctx, args []string) int {
143184 _, b, code := buildRef(c, args)
144185 if code >= 0 {
internal/control/buildlistpage_test.go added +139
@@ -0,0 +1,139 @@
1package control
2
3import (
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.
17func 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.
69func 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.
128func 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) {
109109 }
110110 return msgs, nil
111111}
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.
119func 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 @@
1package gitutil
2
3import (
4 "os/exec"
5 "strings"
6 "testing"
7)
8
9func 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
43func 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) {
3636 Filter buildFilter
3737 Facets []facetGroup
3838 Refs []string
39 Older string
3940 CanWrite bool
4041 Notice string
4142 }{
4243 testRepoPage(), builds, jobs, groupRuns(builds), filter, nil,
43 distinctRefs(builds, filter.Ref), true, "",
44 distinctRefs(builds, filter.Ref), "", true, "",
4445 })
4546 if err != nil {
4647 t.Fatalf("render: %v", err)
@@ -111,11 +112,12 @@ func TestBuildsPageCountsBuildsAndRuns(t *testing.T) {
111112 Filter buildFilter
112113 Facets []facetGroup
113114 Refs []string
115 Older string
114116 CanWrite bool
115117 Notice string
116118 }{
117119 testRepoPage(), builds, nil, groupRuns(builds), filter, nil,
118 distinctRefs(builds, filter.Ref), true, "",
120 distinctRefs(builds, filter.Ref), "", true, "",
119121 })
120122 if err != nil {
121123 t.Fatalf("render: %v", err)
@@ -124,3 +126,81 @@ func TestBuildsPageCountsBuildsAndRuns(t *testing.T) {
124126 t.Errorf("builds.html count line: %q", sb.String())
125127 }
126128}
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.
133func 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&#39;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.
157func 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&amp;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
172func 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
184func 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 {
132132// one queueing of a commit, not the commit — see groupRuns (#240).
133133type buildRun struct {
134134 SHA string
135 Subject string
135136 Ref string
136137 CreatedAt string
137138 Status string
@@ -194,7 +195,7 @@ func groupRuns(builds []control.BuildOut) []buildRun {
194195 runs[n-1].Builds = append(runs[n-1].Builds, b)
195196 continue
196197 }
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}})
198199 }
199200 for i := range runs {
200201 runs[i].Status = combinedStatus(runs[i].Builds)
@@ -202,6 +203,27 @@ func groupRuns(builds []control.BuildOut) []buildRun {
202203 return runs
203204}
204205
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).
209const 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.
214func 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
205227func (s *Server) builds(w http.ResponseWriter, r *http.Request) {
206228 p, ok := s.repoFor(w, r, "")
207229 if !ok {
@@ -212,7 +234,7 @@ func (s *Server) builds(w http.ResponseWriter, r *http.Request) {
212234
213235 qv := r.URL.Query()
214236 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)}
216238 if filter.Ref != "" {
217239 argv = append(argv, "--ref", filter.Ref)
218240 }
@@ -222,9 +244,16 @@ func (s *Server) builds(w http.ResponseWriter, r *http.Request) {
222244 if filter.Job != "" {
223245 argv = append(argv, "--job", filter.Job)
224246 }
247 if cursor := qv.Get("cursor"); cursor != "" {
248 argv = append(argv, "--cursor", cursor)
249 }
225250
226 var builds []control.BuildOut
227 s.runControlInto(viewer, argv, &builds)
251 var page struct {
252 Items []control.BuildOut `json:"items"`
253 Next string `json:"next"`
254 }
255 s.runControlInto(viewer, argv, &page)
256 builds := page.Items
228257
229258 // The jobs a trigger can name. A repo without a CI config has none;
230259 // that is not an error for this page.
@@ -241,10 +270,11 @@ func (s *Server) builds(w http.ResponseWriter, r *http.Request) {
241270 Filter buildFilter
242271 Facets []facetGroup
243272 Refs []string
273 Older string
244274 CanWrite bool
245275 Notice string
246276 }{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)})
248278}
249279
250280func (s *Server) build(w http.ResponseWriter, r *http.Request) {
internal/httpd/explorepage_test.go added +56
@@ -0,0 +1,56 @@
1package httpd
2
3import (
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).
13func 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
42func 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
4343// entries stay in one implementation.
4444func (s *Server) orgSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
4545 owner := r.PathValue("owner")
46 // The member and team forms live on the profile's people tab (#242),
47 // which is where a result has to land for its flash to render.
4648 back := func(msg string) {
4749 s.setFlash(w, msg)
48 http.Redirect(w, r, "/"+owner, http.StatusSeeOther)
50 http.Redirect(w, r, "/"+owner+"/-/people", http.StatusSeeOther)
4951 }
5052 field := r.FormValue("field")
5153 team := strings.TrimSpace(r.FormValue("team"))
internal/httpd/ownerpage_test.go added +141
@@ -0,0 +1,141 @@
1package httpd
2
3import (
4 "html/template"
5 "strings"
6 "testing"
7
8 "gitbay.org/gitbay/internal/control"
9 "gitbay.org/gitbay/internal/store"
10 "gitbay.org/gitbay/internal/web"
11)
12
13func TestProfileTabFromPath(t *testing.T) {
14 for path, want := range map[string]string{
15 "/cmc": "repos",
16 "/cmc/-/about": "about",
17 "/cmc/-/activity": "activity",
18 "/cmc/-/people": "people",
19 "/krz": "repos",
20 "/cmc/-/snippets": "repos",
21 } {
22 if got := profileTab(path); got != want {
23 t.Errorf("profileTab(%q) = %q, want %q", path, got, want)
24 }
25 }
26}
27
28// The About text and the year of squares sat above the repository list
29// and pushed it below the fold (#242). Repositories are the bare
30// /{owner} now and the rest are tabs beside them.
31func TestOwnerPageLeadsWithRepositories(t *testing.T) {
32 out := renderOwner(t, "repos", ownerFixture())
33 if !strings.Contains(out, "reminiscecleberg.com") {
34 t.Errorf("the repository list is not on the default tab:\n%s", out)
35 }
36 for _, unwanted := range []string{"actgraph", "Christian Cleberg"} {
37 if strings.Contains(out, unwanted) {
38 t.Errorf("the default tab still carries %q:\n%s", unwanted, out)
39 }
40 }
41 for _, want := range []string{
42 `aria-current="page" href="/cmc"`,
43 `href="/cmc/-/about"`,
44 `href="/cmc/-/activity"`,
45 } {
46 if !strings.Contains(out, want) {
47 t.Errorf("tab bar missing %q:\n%s", want, out)
48 }
49 }
50 // Nobody administers this profile, so it offers no people tab.
51 if strings.Contains(out, "/-/people") {
52 t.Errorf("a profile nobody admins offers a people tab:\n%s", out)
53 }
54}
55
56func TestOwnerPageTabsCarryOneSectionEach(t *testing.T) {
57 if out := renderOwner(t, "about", ownerFixture()); !strings.Contains(out, "Christian Cleberg") ||
58 strings.Contains(out, "actgraph") || strings.Contains(out, "reminiscecleberg.com") {
59 t.Errorf("about tab is not the About file alone:\n%s", out)
60 }
61 if out := renderOwner(t, "activity", ownerFixture()); !strings.Contains(out, "actgraph") ||
62 strings.Contains(out, "reminiscecleberg.com") {
63 t.Errorf("activity tab is not the graph alone:\n%s", out)
64 }
65 // A profile with no About file does not offer the tab.
66 d := ownerFixture()
67 d.AboutHTML = ""
68 if out := renderOwner(t, "repos", d); strings.Contains(out, "/-/about") {
69 t.Errorf("a profile with no About file offers the tab:\n%s", out)
70 }
71}
72
73func TestOwnerPagePeopleTabHoldsTheAdminPanel(t *testing.T) {
74 d := ownerFixture()
75 d.Kind = "org"
76 d.CanAdmin = true
77 d.Members = []control.ProfileMember{{Name: "cmc", Role: "admin"}}
78
79 repos := renderOwner(t, "repos", d)
80 if !strings.Contains(repos, `href="/cmc/-/people"`) {
81 t.Errorf("an admin gets no people tab:\n%s", repos)
82 }
83 if strings.Contains(repos, "Create a team") {
84 t.Errorf("the admin forms still sit under the repository list:\n%s", repos)
85 }
86 people := renderOwner(t, "people", d)
87 for _, want := range []string{"Create a team", "member-add", "org-rename"} {
88 if !strings.Contains(people, want) {
89 t.Errorf("people tab missing %q:\n%s", want, people)
90 }
91 }
92}
93
94type ownerFixtureData struct {
95 Kind string
96 AboutHTML template.HTML
97 CanAdmin bool
98 Members []control.ProfileMember
99}
100
101func ownerFixture() ownerFixtureData {
102 return ownerFixtureData{
103 Kind: "user",
104 AboutHTML: template.HTML("<p><strong>Christian Cleberg</strong></p>"),
105 }
106}
107
108func renderOwner(t *testing.T, tab string, d ownerFixtureData) string {
109 t.Helper()
110 var sb strings.Builder
111 err := web.Render(&sb, "owner.html", struct {
112 basePage
113 Owner string
114 Kind string
115 Tab string
116 Profile store.Profile
117 AboutHTML template.HTML
118 Repos []profileRepoRow
119 Members []control.ProfileMember
120 Orgs []control.ProfileMember
121 Activity []activityWeek
122 ActivityTotal int
123 Teams []teamView
124 CanAdmin bool
125 Self bool
126 Snippets int
127 Notice string
128 Feed string
129 }{
130 basePage{Site: "gitbay"}, "cmc", d.Kind, tab,
131 store.Profile{Description: "Org-Mode · Self-Hosting · Privacy"}, d.AboutHTML,
132 []profileRepoRow{{control.ProfileRepo{Path: "cmc/reminiscecleberg.com", Description: "Personal placeholder site."}}},
133 d.Members, nil,
134 []activityWeek{{Month: "Sep", Days: []activityDay{{Date: "2026-09-20", Count: 3, Level: 2}}}}, 6088,
135 nil, d.CanAdmin, false, 0, "", "/cmc/activity.atom",
136 })
137 if err != nil {
138 t.Fatalf("render: %v", err)
139 }
140 return sb.String()
141}
internal/httpd/repohead_test.go +2 −1
@@ -26,9 +26,10 @@ func TestRepoHeaderTwoRows(t *testing.T) {
2626 Filter buildFilter
2727 Facets []facetGroup
2828 Refs []string
29 Older string
2930 CanWrite bool
3031 Notice string
31 }{p, nil, nil, nil, buildFilter{}, nil, nil, true, ""})
32 }{p, nil, nil, nil, buildFilter{}, nil, nil, "", true, ""})
3233 if err != nil {
3334 t.Fatal(err)
3435 }
internal/httpd/routes.go +3
@@ -75,6 +75,9 @@ func (s *Server) Routes() []Route {
7575 Route{Method: "GET", Pattern: "/{owner}/{repo}/log.atom", Handler: s.logAtom},
7676 Route{Method: "GET", Pattern: "/{owner}/{repo}/log.atom/{ref...}", Handler: s.logAtom},
7777 Route{Method: "GET", Pattern: "/{owner}/activity.atom", Handler: s.ownerAtom},
78 Route{Method: "GET", Pattern: "/{owner}/-/about", Handler: s.ownerPage},
79 Route{Method: "GET", Pattern: "/{owner}/-/activity", Handler: s.ownerPage},
80 Route{Method: "GET", Pattern: "/{owner}/-/people", Handler: s.ownerPage},
7881 Route{Method: "GET", Pattern: "/{owner}/-/labels", Handler: s.orgLabels},
7982 Route{Method: "GET", Pattern: "/{owner}/-/milestones", Handler: s.orgMilestones},
8083 Route{Method: "GET", Pattern: "/{owner}/-/snippets", Handler: s.snippetsPage},
internal/httpd/web.go +28 −1
@@ -426,6 +426,23 @@ func (p profileRepoRow) Desc() string { return p.Description }
426426// ownerPage renders /{owner} for users and orgs: the repositories the
427427// viewer may see, org membership either direction. Owner names are not
428428// secret (they are on every commit); repository visibility rules hold.
429// profileTab is which section of a profile a URL asks for. The bare
430// /{owner} is the repository list, because a profile's job is to lead to
431// the projects and the About text used to push them below the fold
432// (#242). The rest hang off the /-/ namespace the labels, milestones and
433// snippet pages already use.
434func profileTab(path string) string {
435 switch {
436 case strings.HasSuffix(path, "/-/about"):
437 return "about"
438 case strings.HasSuffix(path, "/-/activity"):
439 return "activity"
440 case strings.HasSuffix(path, "/-/people"):
441 return "people"
442 }
443 return "repos"
444}
445
429446func (s *Server) ownerPage(w http.ResponseWriter, r *http.Request) {
430447 name := r.PathValue("owner")
431448 var viewer store.User
@@ -455,11 +472,21 @@ func (s *Server) ownerPage(w http.ResponseWriter, r *http.Request) {
455472 weeks, activityTotal := activityGrid(counts)
456473
457474 teams, canAdmin := s.orgAdminView(viewer, d.Kind, name)
475 tab := profileTab(r.URL.Path)
476 // Neither tab is offered when there is nothing on it: the people tab
477 // is the organization admin panel, and the About tab is a file the
478 // owner may not have written. Both answer the way a missing page does
479 // rather than rendering empty.
480 if (tab == "people" && !canAdmin) || (tab == "about" && d.About == "") {
481 s.notFound(w, r)
482 return
483 }
458484 profile := store.Profile{Description: d.Description, Website: d.Website, Links: d.Links}
459485 s.render(w, "owner.html", struct {
460486 basePage
461487 Owner string
462488 Kind string
489 Tab string
463490 Profile store.Profile
464491 AboutHTML template.HTML
465492 Repos []profileRepoRow
@@ -473,7 +500,7 @@ func (s *Server) ownerPage(w http.ResponseWriter, r *http.Request) {
473500 Snippets int
474501 Notice string
475502 Feed string
476 }{s.baseFor(viewer), name, d.Kind, profile, aboutHTML(d.About, d.AboutFormat),
503 }{s.baseFor(viewer), name, d.Kind, tab, profile, aboutHTML(d.About, d.AboutFormat),
477504 d.Repos, d.Members, d.Orgs,
478505 weeks, activityTotal, teams, canAdmin,
479506 d.Kind == "user" && viewer.ID != 0 && strings.EqualFold(viewer.Username, name),
internal/store/builds.go +7
@@ -275,10 +275,13 @@ func (s *Store) BuildByNumber(repoID, number int64) (Build, error) {
275275}
276276
277277// 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.
278280type BuildFilter struct {
279281 Ref string
280282 Status string
281283 Job string
284 Before int64
282285}
283286
284287func (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
296299 q += " AND job = ?"
297300 args = append(args, f.Job)
298301 }
302 if f.Before > 0 {
303 q += " AND number < ?"
304 args = append(args, f.Before)
305 }
299306 q += " ORDER BY number DESC LIMIT ?"
300307 args = append(args, limit)
301308 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 {
16431643pre.quickstart { margin: 0 0 var(--sp-4); }
16441644.shot { border: 1px solid var(--line); border-radius: var(--r-card); overflow: hidden; margin: var(--sp-5) 0; }
16451645.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}
16461653.facets { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--sp-5); margin: var(--sp-5) 0; }
16471654.facets h2 { font-size: var(--fs-3); margin: 0 0 var(--sp-1); }
16481655.facets p { color: var(--muted); font-size: var(--fs-2); margin: 0; }
internal/web/templates/builds.html +4 −3
@@ -38,12 +38,12 @@
3838<pre class="code" tabindex="0">[![build](https://{{.Host}}/{{.Repo.OwnerName}}/{{.Repo.Name}}/badge/build.svg)](https://{{.Host}}/{{.Repo.OwnerName}}/{{.Repo.Name}}/builds)</pre>
3939<p class="meta">Add <code>?job=name</code> for one job.</p>
4040</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>
4242<ul class="loglist rows">
4343{{range .Runs}}<li>
4444 <div class="commitmain">
45 <p class="subject"><code><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{.SHA}}">{{printf "%.10s" .SHA}}</a></code></p>
46 <p class="meta">{{.Ref}} · {{when .CreatedAt}}</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">{{if .Subject}}<code>{{printf "%.10s" .SHA}}</code> · {{end}}{{.Ref}} · {{when .CreatedAt}}</p>
4747 </div>
4848 <div class="commitside">
4949 <span class="badge check-{{.Status}}">{{.Status}}</span>
@@ -52,6 +52,7 @@
5252</li>
5353{{else}}<li class="empty">no builds — push a commit with a <code>.gitbay/ci.yml</code></li>{{end}}
5454</ul>
55{{if .Older}}<p class="pager"><a href="{{.Older}}">older →</a></p>{{end}}
5556</div>
5657</div>
5758{{end}}
internal/web/templates/explore.html +2 −1
@@ -12,9 +12,10 @@
1212</form>
1313<span class="spacer"></span>
1414</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>
1516<ul class="repolist rows">
1617{{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}}
1819</ul>
1920</div>
2021</div>
internal/web/templates/landing.html +5 −3
@@ -7,10 +7,12 @@
77<pre class="quickstart" tabindex="0">ssh git@{{.Host}} help # every command, no client to install
88git clone ssh://git@{{.Host}}/owner/repo.git</pre>
99<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>
1111 <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.">
13</picture></div>
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>
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>
1416<div class="facets">
1517 <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>
1618 <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 @@
99{{if .Members}}<p class="meta">members {{range .Members}}<a class="memberchip" href="/{{.Name}}">{{.Name}} <span class="role">{{.Role}}</span></a> {{end}}</p>{{end}}
1010{{if and (eq .Kind "org") .Repos}}<p class="meta"><a href="/{{.Owner}}/-/labels">labels</a> · <a href="/{{.Owner}}/-/milestones">milestones</a></p>{{end}}
1111</section>
12{{if .AboutHTML}}<section class="readme"><div class="rendered">{{.AboutHTML}}</div></section>{{end}}
12{{/* The repository list is the bare /{owner}, because a profile's job is
13 to lead to the projects: the About text and the year of squares used
14 to sit above the list and push it below the fold (#242). */}}
15<nav class="tabs" aria-label="Profile">
16 <a {{if eq .Tab "repos"}}aria-current="page" {{end}}href="/{{.Owner}}">Repositories{{if .Repos}} <i>{{len .Repos}}</i>{{end}}</a>
17 {{if .AboutHTML}}<a {{if eq .Tab "about"}}aria-current="page" {{end}}href="/{{.Owner}}/-/about">About</a>{{end}}
18 <a {{if eq .Tab "activity"}}aria-current="page" {{end}}href="/{{.Owner}}/-/activity">Activity{{if .ActivityTotal}} <i>{{.ActivityTotal}}</i>{{end}}</a>
19 {{if or .Snippets .Self}}<a href="/{{.Owner}}/-/snippets">Snippets{{if .Snippets}} <i>{{.Snippets}}</i>{{end}}</a>{{end}}
20 {{if .CanAdmin}}<a {{if eq .Tab "people"}}aria-current="page" {{end}}href="/{{.Owner}}/-/people">People{{if .Members}} <i>{{len .Members}}</i>{{end}}</a>{{end}}
21</nav>
22
23{{if eq .Tab "repos"}}
24<ul class="repolist">
25{{range .Repos}}{{template "reporow" .}}
26{{else}}<li class="empty">no visible repositories</li>{{end}}
27</ul>
28{{end}}
29
30{{if eq .Tab "about"}}
31<section class="readme"><div class="rendered">{{.AboutHTML}}</div></section>
32{{end}}
33
34{{if eq .Tab "activity"}}
1335<section class="activity">
1436<h2>activity <span class="count">{{.ActivityTotal}} in the last year</span></h2>
1537<div class="actgraph-scroll">
@@ -19,14 +41,9 @@
1941</div>
2042<p class="actlegend"><span>Less</span><span class="actday l0"></span><span class="actday l1"></span><span class="actday l2"></span><span class="actday l3"></span><span class="actday l4"></span><span>More</span></p>
2143</section>
22<h2>repositories <span class="count">{{len .Repos}}</span></h2>
23<ul class="repolist">
24{{range .Repos}}{{template "reporow" .}}
25{{else}}<li class="empty">no visible repositories</li>{{end}}
26</ul>
27{{if or .Snippets .Self}}<p class="meta"><a href="/{{.Owner}}/-/snippets">snippets{{if .Snippets}} <span class="count">{{.Snippets}}</span>{{end}}</a></p>{{end}}
44{{end}}
2845
29{{if .CanAdmin}}{{$org := .Owner}}
46{{if eq .Tab "people"}}{{$org := .Owner}}
3047<h2>people <span class="count">{{len .Members}}</span></h2>
3148{{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}}
3249<div class="tablewrap"><table class="keys">