detached 4c072b1

feat(web): the home page lists repositories as rows with recent commits and CI state (#61)

commit
4c072b15186634e3703e8c5f82e779ca62845221
author
brice · · 2026-10-18 09:14 UTC
committer
bnema · · 2026-10-18 09:14 UTC
parent
c117990
signature
SSH UnverifiedSHA256:VOCA5lYARHcbKDZ7GhTV45VmcjWnJ6ZQIBazj17LivA The key is unknown
refs
tag: v0.22.0
12 files · +863 -173
M internal/adapters/web/components.templ +48 -24
@@ -32,34 +32,58 @@ templ CloneBlock(c Clone) {
3232 </div>
3333 }
3434
35-// HomeRowView is the card of a repository in the home list. Its id is the
36-// target of SSE swaps; the name link comes first, `enter` follows it.
35+// HomeRowView is the row of a repository in the home list: what it is, its
36+// latest run, and its newest commits. Its id is the target of SSE swaps; the
37+// name link comes first, `enter` follows it.
3738 templ HomeRowView(r HomeRow) {
3839 <li id={ r.ID } class="repo" data-cursor?={ r.Cursor }>
39- <h2 class="repo-name">
40- <a href={ templ.SafeURL(r.Href) }>{ r.Name }</a>
41- if r.Private {
42- <span class="pill">private</span>
43- }
44- if r.Fresh {
45- <span class="pill new">new push</span>
46- }
47- </h2>
48- if r.Desc != "" {
49- <p class="repo-desc">{ r.Desc }</p>
50- } else {
51- <p class="repo-desc mute">no description</p>
52- }
53- <p class="repo-meta">
54- if r.Branch != "" {
55- <span class="repo-branch"><span class="ico i-branch" aria-hidden="true"></span>{ r.Branch }</span>
56- }
57- if r.PushedAt != "" {
58- <span>pushed <time datetime={ r.PushedAt } title={ r.PushedAt }>{ r.Pushed }</time></span>
40+ <div class="repo-main">
41+ <h2 class="repo-name">
42+ <a href={ templ.SafeURL(r.Href) }>{ r.Name }</a>
43+ if r.Private {
44+ <span class="pill">private</span>
45+ }
46+ if r.Fresh {
47+ <span class="pill new">new push</span>
48+ }
49+ </h2>
50+ if r.Desc != "" {
51+ <p class="repo-desc">{ r.Desc }</p>
5952 } else {
60- <span>never pushed</span>
53+ <p class="repo-desc mute">no description</p>
6154 }
62- </p>
55+ <p class="repo-meta">
56+ if r.Branch != "" {
57+ <span class="repo-branch"><span class="ico i-branch" aria-hidden="true"></span>{ r.Branch }</span>
58+ }
59+ if r.PushedAt != "" {
60+ <span>pushed <time datetime={ r.PushedAt } title={ r.PushedAt }>{ r.Pushed }</time></span>
61+ } else {
62+ <span>never pushed</span>
63+ }
64+ </p>
65+ </div>
66+ if r.CI != nil {
67+ <a class="repo-ci" href={ templ.SafeURL(r.CI.Href) } title={ r.CI.Title }>
68+ <span class={ "ck", "ck-" + r.CI.Kind } aria-hidden="true"></span>
69+ <span class="sr">CI: </span>{ r.CI.Label }
70+ </a>
71+ }
72+ if len(r.Commits) > 0 {
73+ <ul class="repo-commits" aria-label={ "Recent commits of " + r.Name }>
74+ for _, c := range r.Commits {
75+ <li>
76+ <a class="rc-sha" href={ templ.SafeURL(c.CHref) }>{ c.Short }</a>
77+ <span class="rc-subject" title={ c.Subject }>{ c.Subject }</span>
78+ if c.WhenAt != "" {
79+ <time class="dim" datetime={ c.WhenAt } title={ c.WhenAt }>{ c.When }</time>
80+ }
81+ </li>
82+ }
83+ </ul>
84+ } else if r.NoCommits {
85+ <p class="repo-commits mute">no commits yet</p>
86+ }
6387 </li>
6488 }
6589
@@ -32,34 +32,58 @@ templ CloneBlock(c Clone) {
32 </div>32 </div>
33 }33 }
34 34
35-// HomeRowView is the card of a repository in the home list. Its id is the35+// HomeRowView is the row of a repository in the home list: what it is, its
36-// target of SSE swaps; the name link comes first, `enter` follows it.36+// latest run, and its newest commits. Its id is the target of SSE swaps; the
37+// name link comes first, `enter` follows it.
37 templ HomeRowView(r HomeRow) {38 templ HomeRowView(r HomeRow) {
38 <li id={ r.ID } class="repo" data-cursor?={ r.Cursor }>39 <li id={ r.ID } class="repo" data-cursor?={ r.Cursor }>
39- <h2 class="repo-name">40+ <div class="repo-main">
40- <a href={ templ.SafeURL(r.Href) }>{ r.Name }</a>41+ <h2 class="repo-name">
41- if r.Private {42+ <a href={ templ.SafeURL(r.Href) }>{ r.Name }</a>
42- <span class="pill">private</span>43+ if r.Private {
43- }44+ <span class="pill">private</span>
44- if r.Fresh {45+ }
45- <span class="pill new">new push</span>46+ if r.Fresh {
46- }47+ <span class="pill new">new push</span>
47- </h2>48+ }
48- if r.Desc != "" {49+ </h2>
49- <p class="repo-desc">{ r.Desc }</p>50+ if r.Desc != "" {
50- } else {51+ <p class="repo-desc">{ r.Desc }</p>
51- <p class="repo-desc mute">no description</p>
52- }
53- <p class="repo-meta">
54- if r.Branch != "" {
55- <span class="repo-branch"><span class="ico i-branch" aria-hidden="true"></span>{ r.Branch }</span>
56- }
57- if r.PushedAt != "" {
58- <span>pushed <time datetime={ r.PushedAt } title={ r.PushedAt }>{ r.Pushed }</time></span>
59 } else {52 } else {
60- <span>never pushed</span>53+ <p class="repo-desc mute">no description</p>
61 }54 }
62- </p>55+ <p class="repo-meta">
56+ if r.Branch != "" {
57+ <span class="repo-branch"><span class="ico i-branch" aria-hidden="true"></span>{ r.Branch }</span>
58+ }
59+ if r.PushedAt != "" {
60+ <span>pushed <time datetime={ r.PushedAt } title={ r.PushedAt }>{ r.Pushed }</time></span>
61+ } else {
62+ <span>never pushed</span>
63+ }
64+ </p>
65+ </div>
66+ if r.CI != nil {
67+ <a class="repo-ci" href={ templ.SafeURL(r.CI.Href) } title={ r.CI.Title }>
68+ <span class={ "ck", "ck-" + r.CI.Kind } aria-hidden="true"></span>
69+ <span class="sr">CI: </span>{ r.CI.Label }
70+ </a>
71+ }
72+ if len(r.Commits) > 0 {
73+ <ul class="repo-commits" aria-label={ "Recent commits of " + r.Name }>
74+ for _, c := range r.Commits {
75+ <li>
76+ <a class="rc-sha" href={ templ.SafeURL(c.CHref) }>{ c.Short }</a>
77+ <span class="rc-subject" title={ c.Subject }>{ c.Subject }</span>
78+ if c.WhenAt != "" {
79+ <time class="dim" datetime={ c.WhenAt } title={ c.WhenAt }>{ c.When }</time>
80+ }
81+ </li>
82+ }
83+ </ul>
84+ } else if r.NoCommits {
85+ <p class="repo-commits mute">no commits yet</p>
86+ }
63 </li>87 </li>
64 }88 }
65 89
M internal/adapters/web/components_templ.go +331 -124 generated file: collapsed
M internal/adapters/web/fragments.go +8 -1
@@ -106,6 +106,13 @@ func (l *liveStream) home(ctx context.Context, we ports.WatchEvent) ([]fragment,
106106 name = ev.Repo.String()
107107 case domain.PushEvent:
108108 name, fresh = ev.Repo.String(), true
109+ case domain.RunUpdated:
110+ name = ev.Run.Repo.String() // the CI state of the row
111+ case domain.JobUpdated:
112+ if ev.Job.Phase != domain.CIPhaseEnded {
113+ return nil, nil
114+ }
115+ name = ev.Job.Repo.String() // a failed job turns the state to failing
109116 default:
110117 return nil, nil
111118 }
@@ -116,7 +123,7 @@ func (l *liveStream) home(ctx context.Context, we ports.WatchEvent) ([]fragment,
116123 case err != nil:
117124 return nil, err
118125 }
119- row := homeRow(view, l.s.cfg.Now(), false, SignedIn(ctx))
126+ row := l.s.homeRowOf(ctx, view, l.s.cfg.Now(), false)
120127 row.Fresh = fresh
121128 f, err := renderFragment(ctx, "home-list", HomeRowView(row))
122129 return []fragment{f}, err
@@ -106,6 +106,13 @@ func (l *liveStream) home(ctx context.Context, we ports.WatchEvent) ([]fragment,
106 name = ev.Repo.String()106 name = ev.Repo.String()
107 case domain.PushEvent:107 case domain.PushEvent:
108 name, fresh = ev.Repo.String(), true108 name, fresh = ev.Repo.String(), true
109+ case domain.RunUpdated:
110+ name = ev.Run.Repo.String() // the CI state of the row
111+ case domain.JobUpdated:
112+ if ev.Job.Phase != domain.CIPhaseEnded {
113+ return nil, nil
114+ }
115+ name = ev.Job.Repo.String() // a failed job turns the state to failing
109 default:116 default:
110 return nil, nil117 return nil, nil
111 }118 }
@@ -116,7 +123,7 @@ func (l *liveStream) home(ctx context.Context, we ports.WatchEvent) ([]fragment,
116 case err != nil:123 case err != nil:
117 return nil, err124 return nil, err
118 }125 }
119- row := homeRow(view, l.s.cfg.Now(), false, SignedIn(ctx))126+ row := l.s.homeRowOf(ctx, view, l.s.cfg.Now(), false)
120 row.Fresh = fresh127 row.Fresh = fresh
121 f, err := renderFragment(ctx, "home-list", HomeRowView(row))128 f, err := renderFragment(ctx, "home-list", HomeRowView(row))
122 return []fragment{f}, err129 return []fragment{f}, err
M internal/adapters/web/home.go +103 -6
@@ -4,12 +4,16 @@ package web
44
55 import (
66 "context"
7+ "errors"
78 "net/http"
9+ "slices"
810 "sort"
911 "strings"
12+ "sync"
1013 "time"
1114
1215 "github.com/a-h/templ"
16+ "github.com/bnema/zerowrap"
1317
1418 "bnema.dev/ermite/internal/domain"
1519 "bnema.dev/ermite/internal/ports"
@@ -99,7 +103,6 @@ func (s *Server) home(ctx context.Context, r *http.Request) (templ.Component, in
99103 sortRepos(repos, key, desc)
100104
101105 now := s.cfg.Now()
102- signed := SignedIn(ctx)
103106 p := HomePage{}
104107 for _, c := range homeSorts {
105108 sl := SortLink{Label: c.label}
@@ -117,9 +120,7 @@ func (s *Server) home(ctx context.Context, r *http.Request) (templ.Component, in
117120 sl.Href = "/?sort=" + c.key + "&dir=" + next
118121 p.Sorts = append(p.Sorts, sl)
119122 }
120- for i, repo := range repos {
121- p.Rows = append(p.Rows, homeRow(repo, now, i == 0, signed))
122- }
123+ p.Rows = s.homeRows(ctx, repos, now)
123124
124125 l := s.base()
125126 l.Title = s.instance
@@ -128,8 +129,104 @@ func (s *Server) home(ctx context.Context, r *http.Request) (templ.Component, in
128129 return Home(l, p), http.StatusOK, nil
129130 }
130131
131-// homeRow is the card of a repository in the home list. The private badge is
132-// for a signed-in visitor: an anonymous one never gets a private repository.
132+// homeRecent is how many commits a row of the home list shows.
133+const homeRecent = 5
134+
135+// homeWorkers bounds the repositories whose commits and run are read at once.
136+const homeWorkers = 8
137+
138+// homeRows builds the rows of repos, in order: each repository costs a read
139+// of its commits and of its latest run, so they are read a few at a time. A
140+// repository whose reads fail still has its row.
141+func (s *Server) homeRows(ctx context.Context, repos []ports.RepoView, now time.Time) []HomeRow {
142+ rows := make([]HomeRow, len(repos))
143+ var (
144+ sem = make(chan struct{}, homeWorkers)
145+ wg sync.WaitGroup
146+ )
147+ for i, repo := range repos {
148+ sem <- struct{}{}
149+ wg.Add(1)
150+ go func() {
151+ defer func() { <-sem; wg.Done() }()
152+ rows[i] = s.homeRowOf(ctx, repo, now, i == 0)
153+ }()
154+ }
155+ wg.Wait()
156+ return rows
157+}
158+
159+// homeRowOf is the row of a repository with what the visitor may read of it:
160+// the newest commits of its default branch and the state of its latest run.
161+func (s *Server) homeRowOf(ctx context.Context, r ports.RepoView, now time.Time, cursor bool) HomeRow {
162+ row := homeRow(r, now, cursor, SignedIn(ctx))
163+ if r.PushedAt.IsZero() || r.DefaultBranch == "" {
164+ row.NoCommits = r.PushedAt.IsZero()
165+ return row
166+ }
167+ var head string
168+ row.Commits, head, row.NoCommits = s.homeCommits(ctx, r, now)
169+ if head != "" {
170+ row.CI = s.homeCI(ctx, r, head)
171+ }
172+ return row
173+}
174+
175+// homeCommits reads the newest commits of the default branch. It reports an
176+// empty branch; a failed read is logged and gives neither commits nor that
177+// note, since the row does not depend on them.
178+func (s *Server) homeCommits(ctx context.Context, r ports.RepoView, now time.Time) (rows []CommitRow, head string, none bool) {
179+ // By its full name, so that a tag of the same name is not read.
180+ page, err := s.commitList(ctx, r.Name, "refs/heads/"+r.DefaultBranch, "", homeRecent, "")
181+ switch {
182+ case errors.Is(err, domain.ErrNotFound):
183+ return nil, "", true
184+ case err != nil:
185+ log := zerowrap.FromCtx(ctx)
186+ log.Warn().Err(err).Str("repo", r.Name.String()).Msg("read the recent commits of the home page")
187+ return nil, "", false
188+ }
189+ for _, c := range page.Commits[:min(len(page.Commits), homeRecent)] {
190+ rows = append(rows, commitRow(r.Name.String(), c, now))
191+ }
192+ if len(page.Commits) > 0 {
193+ head = page.Commits[0].ID.String()
194+ }
195+ return rows, head, len(rows) == 0
196+}
197+
198+// homeCI is the state of the CI on the head of the default branch, as the
199+// caller of the request sees it: the combined checks of that commit, read by
200+// its id so the cost does not grow with the CI history. nil when the server
201+// runs no CI, the caller cannot read it, the commit has no check, or the read
202+// fails (checksFor logs it).
203+func (s *Server) homeCI(ctx context.Context, r ports.RepoView, head string) *HomeCI {
204+ checks := s.checksFor(ctx, r.Name, head)
205+ if len(checks) == 0 {
206+ return nil
207+ }
208+ sum := checksSummary(r.Name, head, checks)
209+ ci := &HomeCI{Kind: sum.Kind, Label: ciLabel(sum.Kind), Title: sum.Title, Href: repoHref(r.Name.String(), "commit", head)}
210+ if run := checks[0].RunID; !slices.ContainsFunc(checks, func(c ports.CheckView) bool { return c.RunID != run }) {
211+ ci.Href = runHref(r.Name.String(), run) // one run: straight to it
212+ }
213+ return ci
214+}
215+
216+// ciLabel is the word next to the mark of the CI state on the home page.
217+func ciLabel(kind string) string {
218+ switch kind {
219+ case "ok":
220+ return "passing"
221+ case "bad":
222+ return "failing"
223+ }
224+ return "running"
225+}
226+
227+// homeRow is the row of a repository in the home list, without its commits
228+// and run. The private badge is for a signed-in visitor: an anonymous one
229+// never gets a private repository.
133230 func homeRow(r ports.RepoView, now time.Time, cursor, signed bool) HomeRow {
134231 name := r.Name.String()
135232 row := HomeRow{
@@ -4,12 +4,16 @@ package web
4 4
5 import (5 import (
6 "context"6 "context"
7+ "errors"
7 "net/http"8 "net/http"
9+ "slices"
8 "sort"10 "sort"
9 "strings"11 "strings"
12+ "sync"
10 "time"13 "time"
11 14
12 "github.com/a-h/templ"15 "github.com/a-h/templ"
16+ "github.com/bnema/zerowrap"
13 17
14 "bnema.dev/ermite/internal/domain"18 "bnema.dev/ermite/internal/domain"
15 "bnema.dev/ermite/internal/ports"19 "bnema.dev/ermite/internal/ports"
@@ -99,7 +103,6 @@ func (s *Server) home(ctx context.Context, r *http.Request) (templ.Component, in
99 sortRepos(repos, key, desc)103 sortRepos(repos, key, desc)
100 104
101 now := s.cfg.Now()105 now := s.cfg.Now()
102- signed := SignedIn(ctx)
103 p := HomePage{}106 p := HomePage{}
104 for _, c := range homeSorts {107 for _, c := range homeSorts {
105 sl := SortLink{Label: c.label}108 sl := SortLink{Label: c.label}
@@ -117,9 +120,7 @@ func (s *Server) home(ctx context.Context, r *http.Request) (templ.Component, in
117 sl.Href = "/?sort=" + c.key + "&dir=" + next120 sl.Href = "/?sort=" + c.key + "&dir=" + next
118 p.Sorts = append(p.Sorts, sl)121 p.Sorts = append(p.Sorts, sl)
119 }122 }
120- for i, repo := range repos {123+ p.Rows = s.homeRows(ctx, repos, now)
121- p.Rows = append(p.Rows, homeRow(repo, now, i == 0, signed))
122- }
123 124
124 l := s.base()125 l := s.base()
125 l.Title = s.instance126 l.Title = s.instance
@@ -128,8 +129,104 @@ func (s *Server) home(ctx context.Context, r *http.Request) (templ.Component, in
128 return Home(l, p), http.StatusOK, nil129 return Home(l, p), http.StatusOK, nil
129 }130 }
130 131
131-// homeRow is the card of a repository in the home list. The private badge is132+// homeRecent is how many commits a row of the home list shows.
132-// for a signed-in visitor: an anonymous one never gets a private repository.133+const homeRecent = 5
134+
135+// homeWorkers bounds the repositories whose commits and run are read at once.
136+const homeWorkers = 8
137+
138+// homeRows builds the rows of repos, in order: each repository costs a read
139+// of its commits and of its latest run, so they are read a few at a time. A
140+// repository whose reads fail still has its row.
141+func (s *Server) homeRows(ctx context.Context, repos []ports.RepoView, now time.Time) []HomeRow {
142+ rows := make([]HomeRow, len(repos))
143+ var (
144+ sem = make(chan struct{}, homeWorkers)
145+ wg sync.WaitGroup
146+ )
147+ for i, repo := range repos {
148+ sem <- struct{}{}
149+ wg.Add(1)
150+ go func() {
151+ defer func() { <-sem; wg.Done() }()
152+ rows[i] = s.homeRowOf(ctx, repo, now, i == 0)
153+ }()
154+ }
155+ wg.Wait()
156+ return rows
157+}
158+
159+// homeRowOf is the row of a repository with what the visitor may read of it:
160+// the newest commits of its default branch and the state of its latest run.
161+func (s *Server) homeRowOf(ctx context.Context, r ports.RepoView, now time.Time, cursor bool) HomeRow {
162+ row := homeRow(r, now, cursor, SignedIn(ctx))
163+ if r.PushedAt.IsZero() || r.DefaultBranch == "" {
164+ row.NoCommits = r.PushedAt.IsZero()
165+ return row
166+ }
167+ var head string
168+ row.Commits, head, row.NoCommits = s.homeCommits(ctx, r, now)
169+ if head != "" {
170+ row.CI = s.homeCI(ctx, r, head)
171+ }
172+ return row
173+}
174+
175+// homeCommits reads the newest commits of the default branch. It reports an
176+// empty branch; a failed read is logged and gives neither commits nor that
177+// note, since the row does not depend on them.
178+func (s *Server) homeCommits(ctx context.Context, r ports.RepoView, now time.Time) (rows []CommitRow, head string, none bool) {
179+ // By its full name, so that a tag of the same name is not read.
180+ page, err := s.commitList(ctx, r.Name, "refs/heads/"+r.DefaultBranch, "", homeRecent, "")
181+ switch {
182+ case errors.Is(err, domain.ErrNotFound):
183+ return nil, "", true
184+ case err != nil:
185+ log := zerowrap.FromCtx(ctx)
186+ log.Warn().Err(err).Str("repo", r.Name.String()).Msg("read the recent commits of the home page")
187+ return nil, "", false
188+ }
189+ for _, c := range page.Commits[:min(len(page.Commits), homeRecent)] {
190+ rows = append(rows, commitRow(r.Name.String(), c, now))
191+ }
192+ if len(page.Commits) > 0 {
193+ head = page.Commits[0].ID.String()
194+ }
195+ return rows, head, len(rows) == 0
196+}
197+
198+// homeCI is the state of the CI on the head of the default branch, as the
199+// caller of the request sees it: the combined checks of that commit, read by
200+// its id so the cost does not grow with the CI history. nil when the server
201+// runs no CI, the caller cannot read it, the commit has no check, or the read
202+// fails (checksFor logs it).
203+func (s *Server) homeCI(ctx context.Context, r ports.RepoView, head string) *HomeCI {
204+ checks := s.checksFor(ctx, r.Name, head)
205+ if len(checks) == 0 {
206+ return nil
207+ }
208+ sum := checksSummary(r.Name, head, checks)
209+ ci := &HomeCI{Kind: sum.Kind, Label: ciLabel(sum.Kind), Title: sum.Title, Href: repoHref(r.Name.String(), "commit", head)}
210+ if run := checks[0].RunID; !slices.ContainsFunc(checks, func(c ports.CheckView) bool { return c.RunID != run }) {
211+ ci.Href = runHref(r.Name.String(), run) // one run: straight to it
212+ }
213+ return ci
214+}
215+
216+// ciLabel is the word next to the mark of the CI state on the home page.
217+func ciLabel(kind string) string {
218+ switch kind {
219+ case "ok":
220+ return "passing"
221+ case "bad":
222+ return "failing"
223+ }
224+ return "running"
225+}
226+
227+// homeRow is the row of a repository in the home list, without its commits
228+// and run. The private badge is for a signed-in visitor: an anonymous one
229+// never gets a private repository.
133 func homeRow(r ports.RepoView, now time.Time, cursor, signed bool) HomeRow {230 func homeRow(r ports.RepoView, now time.Time, cursor, signed bool) HomeRow {
134 name := r.Name.String()231 name := r.Name.String()
135 row := HomeRow{232 row := HomeRow{
A internal/adapters/web/home_test.go +246 -0
@@ -0,0 +1,246 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "bytes"
7+ "context"
8+ "errors"
9+ "fmt"
10+ "net/http"
11+ "net/http/httptest"
12+ "strings"
13+ "sync/atomic"
14+ "testing"
15+ "time"
16+
17+ "github.com/stretchr/testify/assert"
18+ "github.com/stretchr/testify/mock"
19+ "github.com/stretchr/testify/require"
20+
21+ "bnema.dev/ermite/internal/domain"
22+ "bnema.dev/ermite/internal/ports"
23+ "bnema.dev/ermite/internal/ports/mocks"
24+)
25+
26+// pushedRepo is a public repository that was pushed to.
27+func pushedRepo(t testing.TB, name string) ports.RepoView {
28+ t.Helper()
29+ v := publicRepo(t, name)
30+ v.PushedAt = time.Now().Add(-time.Hour)
31+ return v
32+}
33+
34+// homeCommits are n commits, newest first, with distinct ids and subjects.
35+func homeCommits(t testing.TB, n int) []domain.Commit {
36+ t.Helper()
37+ var out []domain.Commit
38+ for i := n; i >= 1; i-- {
39+ out = append(out, domain.Commit{
40+ ID: objectID(t, fmt.Sprintf("%040x", i)),
41+ Summary: fmt.Sprintf("subject number %d", i),
42+ Committer: domain.Signature{Time: time.Now().Add(-time.Duration(i) * time.Minute)},
43+ })
44+ }
45+ return out
46+}
47+
48+func renderRow(t testing.TB, r HomeRow) string {
49+ t.Helper()
50+ var b bytes.Buffer
51+ require.NoError(t, HomeRowView(r).Render(context.Background(), &b))
52+ return b.String()
53+}
54+
55+func mustRevision(t testing.TB, s string) domain.Revision {
56+ t.Helper()
57+ r, err := domain.ParseRevision(s)
58+ require.NoError(t, err)
59+ return r
60+}
61+
62+// Each row lists at most five commits of the default branch, read by the full
63+// name of the branch.
64+func TestHomeRowShowsFiveCommits(t *testing.T) {
65+ t.Parallel()
66+ browse := mocks.NewBrowseService(t)
67+ browse.EXPECT().ListCommits(mock.Anything, mock.Anything, repoName(t, "proj"), ports.ListCommitsParams{
68+ Revision: mustRevision(t, "refs/heads/main"), PageSize: homeRecent,
69+ }).Return(ports.HistoryPage{Commits: homeCommits(t, 7)}, nil).Once()
70+ srv := newTestServer(t, Config{Repos: reposServing(t, pushedRepo(t, "proj")), Browse: browse})
71+
72+ row := srv.homeRowOf(context.Background(), pushedRepo(t, "proj"), time.Now(), false)
73+ require.Len(t, row.Commits, homeRecent)
74+ assert.Equal(t, "subject number 7", row.Commits[0].Subject)
75+ assert.False(t, row.NoCommits)
76+ assert.Nil(t, row.CI, "a server without CI shows no state")
77+
78+ out := renderRow(t, row)
79+ assert.Equal(t, homeRecent, strings.Count(out, `class="rc-sha"`))
80+ assert.Contains(t, out, `href="/proj/commit/`+fmt.Sprintf("%040x", 7)+`"`)
81+ assert.Contains(t, out, `aria-label="Recent commits of proj"`)
82+ assert.NotContains(t, out, `class="repo-ci"`)
83+}
84+
85+func TestHomeRowEmptyRepository(t *testing.T) {
86+ t.Parallel()
87+ // Never pushed: nothing is read.
88+ srv := newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "new")), Browse: mocks.NewBrowseService(t), Runs: mocks.NewRunService(t)})
89+ row := srv.homeRowOf(context.Background(), publicRepo(t, "new"), time.Now(), false)
90+ assert.True(t, row.NoCommits)
91+ out := renderRow(t, row)
92+ assert.Contains(t, out, "no commits yet")
93+ assert.Contains(t, out, "never pushed")
94+ assert.NotContains(t, out, "repo-commits\" aria-label")
95+}
96+
97+func TestHomeRowCI(t *testing.T) {
98+ t.Parallel()
99+ repo := repoName(t, "proj")
100+ head := fmt.Sprintf("%040x", 2) // the newest of homeCommits(t, 2)
101+ check := func(run int64, status domain.CIStatus, c domain.CIConclusion) ports.CheckView {
102+ return ports.CheckView{CICheck: domain.CICheck{Repo: repo, Sha: head, Name: "ci / test", RunID: run, Status: status, Conclusion: c}}
103+ }
104+ tests := []struct {
105+ name string
106+ checks []ports.CheckView
107+ err error
108+ label string // "" when no state is shown
109+ href string
110+ }{
111+ {"passing", []ports.CheckView{check(9, domain.CICompleted, domain.CISuccess)}, nil, "passing", "/proj/actions/runs/9"},
112+ {"failing", []ports.CheckView{check(9, domain.CICompleted, domain.CISuccess), check(9, domain.CICompleted, domain.CIFailure)}, nil, "failing", "/proj/actions/runs/9"},
113+ {"running", []ports.CheckView{check(9, domain.CIInProgress, "")}, nil, "running", "/proj/actions/runs/9"},
114+ {"several runs link the commit", []ports.CheckView{check(9, domain.CICompleted, domain.CISuccess), check(10, domain.CICompleted, domain.CISuccess)}, nil, "passing", "/proj/commit/" + head},
115+ {"no check on the head", nil, nil, "", ""},
116+ {"the viewer cannot read CI", nil, domain.ErrNotFound, "", ""},
117+ {"the read fails", nil, errors.New("boom"), "", ""},
118+ }
119+ for _, tt := range tests {
120+ t.Run(tt.name, func(t *testing.T) {
121+ t.Parallel()
122+ browse := mocks.NewBrowseService(t)
123+ browse.EXPECT().ListCommits(mock.Anything, mock.Anything, repo, mock.Anything).Return(ports.HistoryPage{Commits: homeCommits(t, 2)}, nil)
124+ runs := mocks.NewRunService(t)
125+ runs.EXPECT().ListChecks(mock.Anything, mock.Anything, repo, head).
126+ Return(ports.ChecksResult{Checks: tt.checks}, tt.err).Once()
127+ srv := newTestServer(t, Config{Repos: reposServing(t, pushedRepo(t, "proj")), Browse: browse, Runs: runs})
128+
129+ row := srv.homeRowOf(context.Background(), pushedRepo(t, "proj"), time.Now(), false)
130+ require.Len(t, row.Commits, 2, "the commits do not depend on CI")
131+ out := renderRow(t, row)
132+ if tt.label == "" {
133+ assert.Nil(t, row.CI)
134+ assert.NotContains(t, out, "repo-ci")
135+ return
136+ }
137+ require.NotNil(t, row.CI)
138+ assert.Contains(t, out, `class="repo-ci" href="`+tt.href+`"`)
139+ assert.Contains(t, out, tt.label, "the state has a word, not only a mark")
140+ assert.Contains(t, out, `<span class="sr">CI: </span>`)
141+ })
142+ }
143+}
144+
145+// The private badge is for the signed-in owner only.
146+func TestHomeRowPrivateBadge(t *testing.T) {
147+ t.Parallel()
148+ priv := pushedRepo(t, "secret")
149+ priv.Visibility = domain.VisibilityPrivate
150+ browse := mocks.NewBrowseService(t)
151+ browse.EXPECT().ListCommits(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(ports.HistoryPage{}, nil).Maybe()
152+ srv := newTestServer(t, Config{Repos: reposServing(t, priv, pushedRepo(t, "open")), Browse: browse})
153+
154+ signed := context.WithValue(context.Background(), signedInKey{}, struct{}{})
155+ assert.Contains(t, renderRow(t, srv.homeRowOf(signed, priv, time.Now(), false)), `<span class="pill">private</span>`)
156+ assert.NotContains(t, renderRow(t, srv.homeRowOf(context.Background(), priv, time.Now(), false)), "private")
157+ assert.NotContains(t, renderRow(t, srv.homeRowOf(signed, pushedRepo(t, "open"), time.Now(), false)), "private")
158+}
159+
160+// A run update re-renders the home row, so its CI state follows the run.
161+func TestRunUpdatedRefreshesTheHomeRow(t *testing.T) {
162+ t.Parallel()
163+ repo := repoName(t, "proj")
164+ head := fmt.Sprintf("%040x", 2)
165+ browse := mocks.NewBrowseService(t)
166+ browse.EXPECT().ListCommits(mock.Anything, mock.Anything, repo, mock.Anything).Return(ports.HistoryPage{Commits: homeCommits(t, 2)}, nil)
167+ runs := mocks.NewRunService(t)
168+ runs.EXPECT().ListChecks(mock.Anything, mock.Anything, repo, head).Return(ports.ChecksResult{Checks: []ports.CheckView{
169+ {CICheck: domain.CICheck{Repo: repo, Sha: head, RunID: 9, Status: domain.CICompleted, Conclusion: domain.CISuccess}},
170+ }}, nil).Once()
171+ srv := newTestServer(t, Config{Repos: reposServing(t, pushedRepo(t, "proj")), Browse: browse, Runs: runs})
172+
173+ got, err := (&liveStream{s: srv}).render(t.Context(), ports.WatchEvent{Event: domain.RunUpdated{Run: domain.CIRun{Repo: repo, ID: 9}}})
174+ require.NoError(t, err)
175+ require.Len(t, got, 1)
176+ assert.Equal(t, "home-list", got[0].event)
177+ assert.Contains(t, got[0].html, `id="repo-proj"`)
178+ assert.Contains(t, got[0].html, "passing")
179+ assert.NotContains(t, got[0].html, "new push", "a run is not a push")
180+
181+ // A job that ended refreshes the row too; one in progress does not.
182+ got, err = (&liveStream{s: srv}).render(t.Context(), ports.WatchEvent{Event: domain.JobUpdated{Job: domain.CIJob{Repo: repo, Phase: domain.CIPhaseRunning}}})
183+ require.NoError(t, err)
184+ assert.Empty(t, got)
185+ runs.EXPECT().ListChecks(mock.Anything, mock.Anything, repo, head).Return(ports.ChecksResult{Checks: []ports.CheckView{
186+ {CICheck: domain.CICheck{Repo: repo, Sha: head, RunID: 9, Status: domain.CICompleted, Conclusion: domain.CIFailure}},
187+ {CICheck: domain.CICheck{Repo: repo, Sha: head, RunID: 9, Status: domain.CIInProgress}},
188+ }}, nil).Once()
189+ got, err = (&liveStream{s: srv}).render(t.Context(), ports.WatchEvent{Event: domain.JobUpdated{Job: domain.CIJob{Repo: repo, Phase: domain.CIPhaseEnded}}})
190+ require.NoError(t, err)
191+ require.Len(t, got, 1)
192+ assert.Contains(t, got[0].html, "failing", "a failed job shows before its run ends")
193+}
194+
195+// A repository whose commits cannot be read keeps its row, and the page is
196+// served; the others are not affected.
197+func TestHomeSurvivesABrokenRepository(t *testing.T) {
198+ t.Parallel()
199+ browse := mocks.NewBrowseService(t)
200+ browse.EXPECT().ListCommits(mock.Anything, mock.Anything, repoName(t, "broken"), mock.Anything).
201+ Return(ports.HistoryPage{}, errors.New("git exploded")).Once()
202+ browse.EXPECT().ListCommits(mock.Anything, mock.Anything, repoName(t, "fine"), mock.Anything).
203+ Return(ports.HistoryPage{Commits: homeCommits(t, 3)}, nil).Once()
204+ srv := newTestServer(t, Config{Repos: reposServing(t, pushedRepo(t, "broken"), pushedRepo(t, "fine")), Browse: browse})
205+
206+ rec := httptest.NewRecorder()
207+ srv.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/?sort=name", nil))
208+ require.Equal(t, http.StatusOK, rec.Code)
209+ body := rec.Body.String()
210+ assert.Contains(t, body, `id="repo-broken"`)
211+ assert.Contains(t, body, `id="repo-fine"`)
212+ assert.Equal(t, 3, strings.Count(body, `class="rc-sha"`), "only the readable repository lists commits")
213+ assert.NotContains(t, body, "no commits yet", "a failed read is not an empty branch")
214+}
215+
216+// The rows are read a few at a time, in the order of the list.
217+func TestHomeRowsAreBoundedAndOrdered(t *testing.T) {
218+ t.Parallel()
219+ const n = 40
220+ var views []ports.RepoView
221+ for i := range n {
222+ views = append(views, pushedRepo(t, fmt.Sprintf("r%02d", i)))
223+ }
224+ var running, peak atomic.Int32
225+ browse := mocks.NewBrowseService(t)
226+ browse.EXPECT().ListCommits(mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
227+ func(context.Context, domain.Principal, domain.RepoName, ports.ListCommitsParams) (ports.HistoryPage, error) {
228+ cur := running.Add(1)
229+ for p := peak.Load(); cur > p && !peak.CompareAndSwap(p, cur); p = peak.Load() {
230+ }
231+ time.Sleep(2 * time.Millisecond)
232+ running.Add(-1)
233+ return ports.HistoryPage{Commits: homeCommits(t, 1)}, nil
234+ })
235+ srv := newTestServer(t, Config{Repos: reposServing(t, views...), Browse: browse})
236+
237+ rows := srv.homeRows(context.Background(), views, time.Now())
238+ require.Len(t, rows, n)
239+ for i, r := range rows {
240+ assert.Equal(t, fmt.Sprintf("r%02d", i), r.Name)
241+ assert.Len(t, r.Commits, 1)
242+ }
243+ assert.True(t, rows[0].Cursor)
244+ assert.LessOrEqual(t, int(peak.Load()), homeWorkers)
245+ assert.Greater(t, int(peak.Load()), 1, "the reads overlap")
246+}
@@ -0,0 +1,246 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "bytes"
7+ "context"
8+ "errors"
9+ "fmt"
10+ "net/http"
11+ "net/http/httptest"
12+ "strings"
13+ "sync/atomic"
14+ "testing"
15+ "time"
16+
17+ "github.com/stretchr/testify/assert"
18+ "github.com/stretchr/testify/mock"
19+ "github.com/stretchr/testify/require"
20+
21+ "bnema.dev/ermite/internal/domain"
22+ "bnema.dev/ermite/internal/ports"
23+ "bnema.dev/ermite/internal/ports/mocks"
24+)
25+
26+// pushedRepo is a public repository that was pushed to.
27+func pushedRepo(t testing.TB, name string) ports.RepoView {
28+ t.Helper()
29+ v := publicRepo(t, name)
30+ v.PushedAt = time.Now().Add(-time.Hour)
31+ return v
32+}
33+
34+// homeCommits are n commits, newest first, with distinct ids and subjects.
35+func homeCommits(t testing.TB, n int) []domain.Commit {
36+ t.Helper()
37+ var out []domain.Commit
38+ for i := n; i >= 1; i-- {
39+ out = append(out, domain.Commit{
40+ ID: objectID(t, fmt.Sprintf("%040x", i)),
41+ Summary: fmt.Sprintf("subject number %d", i),
42+ Committer: domain.Signature{Time: time.Now().Add(-time.Duration(i) * time.Minute)},
43+ })
44+ }
45+ return out
46+}
47+
48+func renderRow(t testing.TB, r HomeRow) string {
49+ t.Helper()
50+ var b bytes.Buffer
51+ require.NoError(t, HomeRowView(r).Render(context.Background(), &b))
52+ return b.String()
53+}
54+
55+func mustRevision(t testing.TB, s string) domain.Revision {
56+ t.Helper()
57+ r, err := domain.ParseRevision(s)
58+ require.NoError(t, err)
59+ return r
60+}
61+
62+// Each row lists at most five commits of the default branch, read by the full
63+// name of the branch.
64+func TestHomeRowShowsFiveCommits(t *testing.T) {
65+ t.Parallel()
66+ browse := mocks.NewBrowseService(t)
67+ browse.EXPECT().ListCommits(mock.Anything, mock.Anything, repoName(t, "proj"), ports.ListCommitsParams{
68+ Revision: mustRevision(t, "refs/heads/main"), PageSize: homeRecent,
69+ }).Return(ports.HistoryPage{Commits: homeCommits(t, 7)}, nil).Once()
70+ srv := newTestServer(t, Config{Repos: reposServing(t, pushedRepo(t, "proj")), Browse: browse})
71+
72+ row := srv.homeRowOf(context.Background(), pushedRepo(t, "proj"), time.Now(), false)
73+ require.Len(t, row.Commits, homeRecent)
74+ assert.Equal(t, "subject number 7", row.Commits[0].Subject)
75+ assert.False(t, row.NoCommits)
76+ assert.Nil(t, row.CI, "a server without CI shows no state")
77+
78+ out := renderRow(t, row)
79+ assert.Equal(t, homeRecent, strings.Count(out, `class="rc-sha"`))
80+ assert.Contains(t, out, `href="/proj/commit/`+fmt.Sprintf("%040x", 7)+`"`)
81+ assert.Contains(t, out, `aria-label="Recent commits of proj"`)
82+ assert.NotContains(t, out, `class="repo-ci"`)
83+}
84+
85+func TestHomeRowEmptyRepository(t *testing.T) {
86+ t.Parallel()
87+ // Never pushed: nothing is read.
88+ srv := newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "new")), Browse: mocks.NewBrowseService(t), Runs: mocks.NewRunService(t)})
89+ row := srv.homeRowOf(context.Background(), publicRepo(t, "new"), time.Now(), false)
90+ assert.True(t, row.NoCommits)
91+ out := renderRow(t, row)
92+ assert.Contains(t, out, "no commits yet")
93+ assert.Contains(t, out, "never pushed")
94+ assert.NotContains(t, out, "repo-commits\" aria-label")
95+}
96+
97+func TestHomeRowCI(t *testing.T) {
98+ t.Parallel()
99+ repo := repoName(t, "proj")
100+ head := fmt.Sprintf("%040x", 2) // the newest of homeCommits(t, 2)
101+ check := func(run int64, status domain.CIStatus, c domain.CIConclusion) ports.CheckView {
102+ return ports.CheckView{CICheck: domain.CICheck{Repo: repo, Sha: head, Name: "ci / test", RunID: run, Status: status, Conclusion: c}}
103+ }
104+ tests := []struct {
105+ name string
106+ checks []ports.CheckView
107+ err error
108+ label string // "" when no state is shown
109+ href string
110+ }{
111+ {"passing", []ports.CheckView{check(9, domain.CICompleted, domain.CISuccess)}, nil, "passing", "/proj/actions/runs/9"},
112+ {"failing", []ports.CheckView{check(9, domain.CICompleted, domain.CISuccess), check(9, domain.CICompleted, domain.CIFailure)}, nil, "failing", "/proj/actions/runs/9"},
113+ {"running", []ports.CheckView{check(9, domain.CIInProgress, "")}, nil, "running", "/proj/actions/runs/9"},
114+ {"several runs link the commit", []ports.CheckView{check(9, domain.CICompleted, domain.CISuccess), check(10, domain.CICompleted, domain.CISuccess)}, nil, "passing", "/proj/commit/" + head},
115+ {"no check on the head", nil, nil, "", ""},
116+ {"the viewer cannot read CI", nil, domain.ErrNotFound, "", ""},
117+ {"the read fails", nil, errors.New("boom"), "", ""},
118+ }
119+ for _, tt := range tests {
120+ t.Run(tt.name, func(t *testing.T) {
121+ t.Parallel()
122+ browse := mocks.NewBrowseService(t)
123+ browse.EXPECT().ListCommits(mock.Anything, mock.Anything, repo, mock.Anything).Return(ports.HistoryPage{Commits: homeCommits(t, 2)}, nil)
124+ runs := mocks.NewRunService(t)
125+ runs.EXPECT().ListChecks(mock.Anything, mock.Anything, repo, head).
126+ Return(ports.ChecksResult{Checks: tt.checks}, tt.err).Once()
127+ srv := newTestServer(t, Config{Repos: reposServing(t, pushedRepo(t, "proj")), Browse: browse, Runs: runs})
128+
129+ row := srv.homeRowOf(context.Background(), pushedRepo(t, "proj"), time.Now(), false)
130+ require.Len(t, row.Commits, 2, "the commits do not depend on CI")
131+ out := renderRow(t, row)
132+ if tt.label == "" {
133+ assert.Nil(t, row.CI)
134+ assert.NotContains(t, out, "repo-ci")
135+ return
136+ }
137+ require.NotNil(t, row.CI)
138+ assert.Contains(t, out, `class="repo-ci" href="`+tt.href+`"`)
139+ assert.Contains(t, out, tt.label, "the state has a word, not only a mark")
140+ assert.Contains(t, out, `<span class="sr">CI: </span>`)
141+ })
142+ }
143+}
144+
145+// The private badge is for the signed-in owner only.
146+func TestHomeRowPrivateBadge(t *testing.T) {
147+ t.Parallel()
148+ priv := pushedRepo(t, "secret")
149+ priv.Visibility = domain.VisibilityPrivate
150+ browse := mocks.NewBrowseService(t)
151+ browse.EXPECT().ListCommits(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(ports.HistoryPage{}, nil).Maybe()
152+ srv := newTestServer(t, Config{Repos: reposServing(t, priv, pushedRepo(t, "open")), Browse: browse})
153+
154+ signed := context.WithValue(context.Background(), signedInKey{}, struct{}{})
155+ assert.Contains(t, renderRow(t, srv.homeRowOf(signed, priv, time.Now(), false)), `<span class="pill">private</span>`)
156+ assert.NotContains(t, renderRow(t, srv.homeRowOf(context.Background(), priv, time.Now(), false)), "private")
157+ assert.NotContains(t, renderRow(t, srv.homeRowOf(signed, pushedRepo(t, "open"), time.Now(), false)), "private")
158+}
159+
160+// A run update re-renders the home row, so its CI state follows the run.
161+func TestRunUpdatedRefreshesTheHomeRow(t *testing.T) {
162+ t.Parallel()
163+ repo := repoName(t, "proj")
164+ head := fmt.Sprintf("%040x", 2)
165+ browse := mocks.NewBrowseService(t)
166+ browse.EXPECT().ListCommits(mock.Anything, mock.Anything, repo, mock.Anything).Return(ports.HistoryPage{Commits: homeCommits(t, 2)}, nil)
167+ runs := mocks.NewRunService(t)
168+ runs.EXPECT().ListChecks(mock.Anything, mock.Anything, repo, head).Return(ports.ChecksResult{Checks: []ports.CheckView{
169+ {CICheck: domain.CICheck{Repo: repo, Sha: head, RunID: 9, Status: domain.CICompleted, Conclusion: domain.CISuccess}},
170+ }}, nil).Once()
171+ srv := newTestServer(t, Config{Repos: reposServing(t, pushedRepo(t, "proj")), Browse: browse, Runs: runs})
172+
173+ got, err := (&liveStream{s: srv}).render(t.Context(), ports.WatchEvent{Event: domain.RunUpdated{Run: domain.CIRun{Repo: repo, ID: 9}}})
174+ require.NoError(t, err)
175+ require.Len(t, got, 1)
176+ assert.Equal(t, "home-list", got[0].event)
177+ assert.Contains(t, got[0].html, `id="repo-proj"`)
178+ assert.Contains(t, got[0].html, "passing")
179+ assert.NotContains(t, got[0].html, "new push", "a run is not a push")
180+
181+ // A job that ended refreshes the row too; one in progress does not.
182+ got, err = (&liveStream{s: srv}).render(t.Context(), ports.WatchEvent{Event: domain.JobUpdated{Job: domain.CIJob{Repo: repo, Phase: domain.CIPhaseRunning}}})
183+ require.NoError(t, err)
184+ assert.Empty(t, got)
185+ runs.EXPECT().ListChecks(mock.Anything, mock.Anything, repo, head).Return(ports.ChecksResult{Checks: []ports.CheckView{
186+ {CICheck: domain.CICheck{Repo: repo, Sha: head, RunID: 9, Status: domain.CICompleted, Conclusion: domain.CIFailure}},
187+ {CICheck: domain.CICheck{Repo: repo, Sha: head, RunID: 9, Status: domain.CIInProgress}},
188+ }}, nil).Once()
189+ got, err = (&liveStream{s: srv}).render(t.Context(), ports.WatchEvent{Event: domain.JobUpdated{Job: domain.CIJob{Repo: repo, Phase: domain.CIPhaseEnded}}})
190+ require.NoError(t, err)
191+ require.Len(t, got, 1)
192+ assert.Contains(t, got[0].html, "failing", "a failed job shows before its run ends")
193+}
194+
195+// A repository whose commits cannot be read keeps its row, and the page is
196+// served; the others are not affected.
197+func TestHomeSurvivesABrokenRepository(t *testing.T) {
198+ t.Parallel()
199+ browse := mocks.NewBrowseService(t)
200+ browse.EXPECT().ListCommits(mock.Anything, mock.Anything, repoName(t, "broken"), mock.Anything).
201+ Return(ports.HistoryPage{}, errors.New("git exploded")).Once()
202+ browse.EXPECT().ListCommits(mock.Anything, mock.Anything, repoName(t, "fine"), mock.Anything).
203+ Return(ports.HistoryPage{Commits: homeCommits(t, 3)}, nil).Once()
204+ srv := newTestServer(t, Config{Repos: reposServing(t, pushedRepo(t, "broken"), pushedRepo(t, "fine")), Browse: browse})
205+
206+ rec := httptest.NewRecorder()
207+ srv.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/?sort=name", nil))
208+ require.Equal(t, http.StatusOK, rec.Code)
209+ body := rec.Body.String()
210+ assert.Contains(t, body, `id="repo-broken"`)
211+ assert.Contains(t, body, `id="repo-fine"`)
212+ assert.Equal(t, 3, strings.Count(body, `class="rc-sha"`), "only the readable repository lists commits")
213+ assert.NotContains(t, body, "no commits yet", "a failed read is not an empty branch")
214+}
215+
216+// The rows are read a few at a time, in the order of the list.
217+func TestHomeRowsAreBoundedAndOrdered(t *testing.T) {
218+ t.Parallel()
219+ const n = 40
220+ var views []ports.RepoView
221+ for i := range n {
222+ views = append(views, pushedRepo(t, fmt.Sprintf("r%02d", i)))
223+ }
224+ var running, peak atomic.Int32
225+ browse := mocks.NewBrowseService(t)
226+ browse.EXPECT().ListCommits(mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
227+ func(context.Context, domain.Principal, domain.RepoName, ports.ListCommitsParams) (ports.HistoryPage, error) {
228+ cur := running.Add(1)
229+ for p := peak.Load(); cur > p && !peak.CompareAndSwap(p, cur); p = peak.Load() {
230+ }
231+ time.Sleep(2 * time.Millisecond)
232+ running.Add(-1)
233+ return ports.HistoryPage{Commits: homeCommits(t, 1)}, nil
234+ })
235+ srv := newTestServer(t, Config{Repos: reposServing(t, views...), Browse: browse})
236+
237+ rows := srv.homeRows(context.Background(), views, time.Now())
238+ require.Len(t, rows, n)
239+ for i, r := range rows {
240+ assert.Equal(t, fmt.Sprintf("r%02d", i), r.Name)
241+ assert.Len(t, r.Commits, 1)
242+ }
243+ assert.True(t, rows[0].Cursor)
244+ assert.LessOrEqual(t, int(peak.Load()), homeWorkers)
245+ assert.Greater(t, int(peak.Load()), 1, "the reads overlap")
246+}
M internal/adapters/web/static/css/30-list.css +27 -10
@@ -28,8 +28,8 @@
2828 & .c-sub { color: var(--ink-dim); }
2929 }
3030
31- /* home: the repositories as cards in a grid (one column on a phone); the
32- page is a stack of sections, so others can sit beside or below */
31+ /* home: the repositories as rows in a vertical list; the page is a stack of
32+ sections, so others can sit beside or below */
3333 .home {
3434 display: flex; flex-direction: column; gap: var(--s5);
3535 & .home-bar { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: var(--s3) var(--s4); margin-bottom: var(--s4); }
@@ -43,32 +43,49 @@
4343 & .sort a:hover { color: var(--ink-hi); background: var(--bg-raised); }
4444 & .sort a[aria-current="true"] { color: var(--ink-hi); background: var(--bg-raised); border-color: var(--border-strong); }
4545 }
46+ /* a row: name, description and facts on the left; the state of the latest
47+ run at the top right, the newest commits under it. Stacked on a phone. */
4648 .repos {
47- display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 380px), 1fr)); gap: var(--s3);
49+ display: flex; flex-direction: column; gap: var(--s3);
4850 margin: 0; padding: 0; list-style: none;
4951 & .repo {
50- position: relative; display: flex; flex-direction: column; gap: var(--s2); min-width: 0;
52+ display: grid; grid-template-columns: minmax(0, 5fr) minmax(0, 7fr); grid-template-areas: "main ci" "main recent";
53+ grid-template-rows: auto 1fr; column-gap: var(--s6); min-width: 0;
5154 padding: var(--s4); background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--r);
5255 }
5356 & .repo:hover { background: var(--bg-raised); border-color: var(--border-strong); }
5457 & .repo[data-cursor] { background: var(--bg-raised); border-color: var(--ink-mute); box-shadow: inset 3px 0 0 var(--ink-hi); }
5558 & .repo[hidden] { display: none; }
56- & .repo-name { display: flex; align-items: center; flex-wrap: wrap; gap: var(--s1) var(--s2); font-size: 17px; line-height: 26px; overflow-wrap: anywhere; }
59+ & .repo-main { grid-area: main; display: flex; flex-direction: column; gap: var(--s2); min-width: 0; }
60+ & .repo-name { display: flex; align-items: center; flex-wrap: wrap; gap: var(--s1) var(--s2); font-family: var(--mono); font-size: 17px; line-height: 26px; overflow-wrap: anywhere; }
5761 & .repo-name a { color: var(--ink-hi); text-decoration: none; }
58- /* the whole card is the target of the name link */
59- & .repo-name a::after { content: ""; position: absolute; inset: 0; border-radius: var(--r); }
60- & .repo-name a:focus-visible { outline: none; }
61- & .repo-name a:focus-visible::after { outline: 2px solid var(--focus); outline-offset: 1px; }
6262 & .repo:hover .repo-name a, & .repo[data-cursor] .repo-name a { text-decoration: underline; }
6363 & .repo-name .pill { margin-left: 0; }
6464 & .repo-desc {
65- flex: 1; color: var(--ink-dim);
65+ color: var(--ink-dim);
6666 display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; line-clamp: 2; overflow: hidden;
6767 }
6868 & .repo-desc.mute { color: var(--ink-mute); font-style: italic; }
6969 & .repo-meta { display: flex; flex-wrap: wrap; align-items: center; gap: var(--s1) var(--s4); font-size: var(--fs-s); color: var(--ink-mute); }
7070 & .repo-meta time { color: var(--ink-dim); }
7171 & .repo-branch { display: inline-flex; align-items: center; gap: 6px; font-family: var(--mono); color: var(--ink-dim); }
72+ & .repo-ci {
73+ grid-area: ci; justify-self: end; display: inline-flex; align-items: center; gap: var(--s1); margin-bottom: var(--s2);
74+ font-size: var(--fs-s); color: var(--ink-dim); text-decoration: none;
75+ }
76+ & .repo-ci:hover { color: var(--ink-hi); text-decoration: underline; }
77+ & .repo-ci .ck { margin: 0; }
78+ & .repo-commits { grid-area: recent; min-width: 0; margin: 0; padding: 0; list-style: none; font-size: var(--fs-s); }
79+ & p.repo-commits { font-style: italic; }
80+ & .repo-commits li { display: flex; align-items: baseline; gap: var(--s2); min-width: 0; padding: 2px 0; }
81+ & .rc-sha { flex: none; font-family: var(--mono); color: var(--ink-dim); text-decoration: none; }
82+ & .rc-sha:hover { color: var(--ink-hi); text-decoration: underline; }
83+ & .rc-subject { flex: 1 1 0; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--ink-hi); }
84+ & .repo-commits time { flex: none; white-space: nowrap; }
85+ @media (max-width: 720px) {
86+ & .repo { grid-template-columns: minmax(0, 1fr); grid-template-areas: "main" "ci" "recent"; grid-template-rows: none; row-gap: var(--s3); }
87+ & .repo-ci { justify-self: start; margin-bottom: 0; }
88+ }
7289 }
7390 .list.passkeys {
7491 & .c-name { width: auto; font-weight: 600; }
@@ -28,8 +28,8 @@
28 & .c-sub { color: var(--ink-dim); }28 & .c-sub { color: var(--ink-dim); }
29 }29 }
30 30
31- /* home: the repositories as cards in a grid (one column on a phone); the31+ /* home: the repositories as rows in a vertical list; the page is a stack of
32- page is a stack of sections, so others can sit beside or below */32+ sections, so others can sit beside or below */
33 .home {33 .home {
34 display: flex; flex-direction: column; gap: var(--s5);34 display: flex; flex-direction: column; gap: var(--s5);
35 & .home-bar { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: var(--s3) var(--s4); margin-bottom: var(--s4); }35 & .home-bar { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: var(--s3) var(--s4); margin-bottom: var(--s4); }
@@ -43,32 +43,49 @@
43 & .sort a:hover { color: var(--ink-hi); background: var(--bg-raised); }43 & .sort a:hover { color: var(--ink-hi); background: var(--bg-raised); }
44 & .sort a[aria-current="true"] { color: var(--ink-hi); background: var(--bg-raised); border-color: var(--border-strong); }44 & .sort a[aria-current="true"] { color: var(--ink-hi); background: var(--bg-raised); border-color: var(--border-strong); }
45 }45 }
46+ /* a row: name, description and facts on the left; the state of the latest
47+ run at the top right, the newest commits under it. Stacked on a phone. */
46 .repos {48 .repos {
47- display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 380px), 1fr)); gap: var(--s3);49+ display: flex; flex-direction: column; gap: var(--s3);
48 margin: 0; padding: 0; list-style: none;50 margin: 0; padding: 0; list-style: none;
49 & .repo {51 & .repo {
50- position: relative; display: flex; flex-direction: column; gap: var(--s2); min-width: 0;52+ display: grid; grid-template-columns: minmax(0, 5fr) minmax(0, 7fr); grid-template-areas: "main ci" "main recent";
53+ grid-template-rows: auto 1fr; column-gap: var(--s6); min-width: 0;
51 padding: var(--s4); background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--r);54 padding: var(--s4); background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--r);
52 }55 }
53 & .repo:hover { background: var(--bg-raised); border-color: var(--border-strong); }56 & .repo:hover { background: var(--bg-raised); border-color: var(--border-strong); }
54 & .repo[data-cursor] { background: var(--bg-raised); border-color: var(--ink-mute); box-shadow: inset 3px 0 0 var(--ink-hi); }57 & .repo[data-cursor] { background: var(--bg-raised); border-color: var(--ink-mute); box-shadow: inset 3px 0 0 var(--ink-hi); }
55 & .repo[hidden] { display: none; }58 & .repo[hidden] { display: none; }
56- & .repo-name { display: flex; align-items: center; flex-wrap: wrap; gap: var(--s1) var(--s2); font-size: 17px; line-height: 26px; overflow-wrap: anywhere; }59+ & .repo-main { grid-area: main; display: flex; flex-direction: column; gap: var(--s2); min-width: 0; }
60+ & .repo-name { display: flex; align-items: center; flex-wrap: wrap; gap: var(--s1) var(--s2); font-family: var(--mono); font-size: 17px; line-height: 26px; overflow-wrap: anywhere; }
57 & .repo-name a { color: var(--ink-hi); text-decoration: none; }61 & .repo-name a { color: var(--ink-hi); text-decoration: none; }
58- /* the whole card is the target of the name link */
59- & .repo-name a::after { content: ""; position: absolute; inset: 0; border-radius: var(--r); }
60- & .repo-name a:focus-visible { outline: none; }
61- & .repo-name a:focus-visible::after { outline: 2px solid var(--focus); outline-offset: 1px; }
62 & .repo:hover .repo-name a, & .repo[data-cursor] .repo-name a { text-decoration: underline; }62 & .repo:hover .repo-name a, & .repo[data-cursor] .repo-name a { text-decoration: underline; }
63 & .repo-name .pill { margin-left: 0; }63 & .repo-name .pill { margin-left: 0; }
64 & .repo-desc {64 & .repo-desc {
65- flex: 1; color: var(--ink-dim);65+ color: var(--ink-dim);
66 display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; line-clamp: 2; overflow: hidden;66 display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; line-clamp: 2; overflow: hidden;
67 }67 }
68 & .repo-desc.mute { color: var(--ink-mute); font-style: italic; }68 & .repo-desc.mute { color: var(--ink-mute); font-style: italic; }
69 & .repo-meta { display: flex; flex-wrap: wrap; align-items: center; gap: var(--s1) var(--s4); font-size: var(--fs-s); color: var(--ink-mute); }69 & .repo-meta { display: flex; flex-wrap: wrap; align-items: center; gap: var(--s1) var(--s4); font-size: var(--fs-s); color: var(--ink-mute); }
70 & .repo-meta time { color: var(--ink-dim); }70 & .repo-meta time { color: var(--ink-dim); }
71 & .repo-branch { display: inline-flex; align-items: center; gap: 6px; font-family: var(--mono); color: var(--ink-dim); }71 & .repo-branch { display: inline-flex; align-items: center; gap: 6px; font-family: var(--mono); color: var(--ink-dim); }
72+ & .repo-ci {
73+ grid-area: ci; justify-self: end; display: inline-flex; align-items: center; gap: var(--s1); margin-bottom: var(--s2);
74+ font-size: var(--fs-s); color: var(--ink-dim); text-decoration: none;
75+ }
76+ & .repo-ci:hover { color: var(--ink-hi); text-decoration: underline; }
77+ & .repo-ci .ck { margin: 0; }
78+ & .repo-commits { grid-area: recent; min-width: 0; margin: 0; padding: 0; list-style: none; font-size: var(--fs-s); }
79+ & p.repo-commits { font-style: italic; }
80+ & .repo-commits li { display: flex; align-items: baseline; gap: var(--s2); min-width: 0; padding: 2px 0; }
81+ & .rc-sha { flex: none; font-family: var(--mono); color: var(--ink-dim); text-decoration: none; }
82+ & .rc-sha:hover { color: var(--ink-hi); text-decoration: underline; }
83+ & .rc-subject { flex: 1 1 0; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--ink-hi); }
84+ & .repo-commits time { flex: none; white-space: nowrap; }
85+ @media (max-width: 720px) {
86+ & .repo { grid-template-columns: minmax(0, 1fr); grid-template-areas: "main" "ci" "recent"; grid-template-rows: none; row-gap: var(--s3); }
87+ & .repo-ci { justify-self: start; margin-bottom: 0; }
88+ }
72 }89 }
73 .list.passkeys {90 .list.passkeys {
74 & .c-name { width: auto; font-weight: 600; }91 & .c-name { width: auto; font-weight: 600; }
M internal/adapters/web/view.go +15 -0
@@ -88,6 +88,21 @@ type HomeRow struct {
8888 Private bool
8989 // Fresh marks a row that a push just updated (live updates only).
9090 Fresh bool
91+ // Commits are the newest of the default branch. NoCommits says there is
92+ // none (never pushed, or an empty branch); a failed read says nothing.
93+ Commits []CommitRow
94+ NoCommits bool
95+ // CI is the latest run of the default branch, nil when there is none to
96+ // show.
97+ CI *HomeCI
98+}
99+
100+// HomeCI is the state of the latest run of the default branch of a repository.
101+type HomeCI struct {
102+ Href string
103+ Kind string // the mark: ok, bad, running, queued, skipped, cancelled
104+ Label string // the word next to it
105+ Title string
91106 }
92107
93108 // SortLink is one way to order the home list; it links to that order.
@@ -88,6 +88,21 @@ type HomeRow struct {
88 Private bool88 Private bool
89 // Fresh marks a row that a push just updated (live updates only).89 // Fresh marks a row that a push just updated (live updates only).
90 Fresh bool90 Fresh bool
91+ // Commits are the newest of the default branch. NoCommits says there is
92+ // none (never pushed, or an empty branch); a failed read says nothing.
93+ Commits []CommitRow
94+ NoCommits bool
95+ // CI is the latest run of the default branch, nil when there is none to
96+ // show.
97+ CI *HomeCI
98+}
99+
100+// HomeCI is the state of the latest run of the default branch of a repository.
101+type HomeCI struct {
102+ Href string
103+ Kind string // the mark: ok, bad, running, queued, skipped, cancelled
104+ Label string // the word next to it
105+ Title string
91 }106 }
92 107
93 // SortLink is one way to order the home list; it links to that order.108 // SortLink is one way to order the home list; it links to that order.
M internal/app/ci_flow_test.go +21 -3
@@ -518,7 +518,9 @@ func TestCIRepositoryDeletionRemovesRunsAndLogs(t *testing.T) {
518518 assert.Greater(t, c.runs()[0].GetId(), int64(1), "run ids are never reused")
519519 }
520520
521-// The events of a run and its jobs are published on the repository topic.
521+// The events of a run and its jobs are published on the repository topic;
522+// "home", which shows the CI state of each repository, gets the runs and the
523+// jobs that ended only.
522524 func TestCIEventsAreStreamedOnTheRepositoryTopic(t *testing.T) {
523525 c := newCIHarness(t, nil)
524526 runner := c.registerRunner("alpha", "ubuntu-latest")
@@ -549,11 +551,27 @@ func TestCIEventsAreStreamedOnTheRepositoryTopic(t *testing.T) {
549551 assert.GreaterOrEqual(t, runUpdates, 2)
550552 assert.GreaterOrEqual(t, jobUpdates, 2)
551553
552- // They are never sent on "home".
553554 home := c.watch(c.root, "", "home")
554555 _, err := c.root.run.RerunRun(c.ctx, connect.NewRequest(&pb.RerunRunRequest{Repo: "proj", RunId: lastRun.GetId()}))
555556 require.NoError(t, err)
556- c.sentinel(t, home, "sentinel")
557+ ev := home.next(t, watchWait)
558+ require.NotNil(t, ev.GetRunUpdated(), "home gets the run: %v", ev)
559+ assert.Equal(t, "proj", ev.GetRunUpdated().GetRun().GetRepo())
560+ // The rest of the rerun before the sentinel: runs, no job in progress.
561+ _, err = c.forge.Repos.Create(c.ctx, domain.Root(), ports.CreateRepoParams{Name: c.name("sentinel"), Visibility: domain.VisibilityPublic})
562+ require.NoError(t, err)
563+ for {
564+ ev = home.next(t, watchWait)
565+ if ev.GetRepoCreated() != nil {
566+ assert.Equal(t, "sentinel", ev.GetRepoCreated().GetRepo().GetName())
567+ break
568+ }
569+ if j := ev.GetJobUpdated(); j != nil {
570+ assert.Equal(t, pb.CiStatus_CI_STATUS_COMPLETED, j.GetJob().GetStatus(), "a job in progress does not reach home")
571+ continue
572+ }
573+ require.NotNil(t, ev.GetRunUpdated(), "only CI state reaches home: %v", ev)
574+ }
557575 }
558576
559577 // A new branch at a commit that already exists, and a branch rewound to an
@@ -518,7 +518,9 @@ func TestCIRepositoryDeletionRemovesRunsAndLogs(t *testing.T) {
518 assert.Greater(t, c.runs()[0].GetId(), int64(1), "run ids are never reused")518 assert.Greater(t, c.runs()[0].GetId(), int64(1), "run ids are never reused")
519 }519 }
520 520
521-// The events of a run and its jobs are published on the repository topic.521+// The events of a run and its jobs are published on the repository topic;
522+// "home", which shows the CI state of each repository, gets the runs and the
523+// jobs that ended only.
522 func TestCIEventsAreStreamedOnTheRepositoryTopic(t *testing.T) {524 func TestCIEventsAreStreamedOnTheRepositoryTopic(t *testing.T) {
523 c := newCIHarness(t, nil)525 c := newCIHarness(t, nil)
524 runner := c.registerRunner("alpha", "ubuntu-latest")526 runner := c.registerRunner("alpha", "ubuntu-latest")
@@ -549,11 +551,27 @@ func TestCIEventsAreStreamedOnTheRepositoryTopic(t *testing.T) {
549 assert.GreaterOrEqual(t, runUpdates, 2)551 assert.GreaterOrEqual(t, runUpdates, 2)
550 assert.GreaterOrEqual(t, jobUpdates, 2)552 assert.GreaterOrEqual(t, jobUpdates, 2)
551 553
552- // They are never sent on "home".
553 home := c.watch(c.root, "", "home")554 home := c.watch(c.root, "", "home")
554 _, err := c.root.run.RerunRun(c.ctx, connect.NewRequest(&pb.RerunRunRequest{Repo: "proj", RunId: lastRun.GetId()}))555 _, err := c.root.run.RerunRun(c.ctx, connect.NewRequest(&pb.RerunRunRequest{Repo: "proj", RunId: lastRun.GetId()}))
555 require.NoError(t, err)556 require.NoError(t, err)
556- c.sentinel(t, home, "sentinel")557+ ev := home.next(t, watchWait)
558+ require.NotNil(t, ev.GetRunUpdated(), "home gets the run: %v", ev)
559+ assert.Equal(t, "proj", ev.GetRunUpdated().GetRun().GetRepo())
560+ // The rest of the rerun before the sentinel: runs, no job in progress.
561+ _, err = c.forge.Repos.Create(c.ctx, domain.Root(), ports.CreateRepoParams{Name: c.name("sentinel"), Visibility: domain.VisibilityPublic})
562+ require.NoError(t, err)
563+ for {
564+ ev = home.next(t, watchWait)
565+ if ev.GetRepoCreated() != nil {
566+ assert.Equal(t, "sentinel", ev.GetRepoCreated().GetRepo().GetName())
567+ break
568+ }
569+ if j := ev.GetJobUpdated(); j != nil {
570+ assert.Equal(t, pb.CiStatus_CI_STATUS_COMPLETED, j.GetJob().GetStatus(), "a job in progress does not reach home")
571+ continue
572+ }
573+ require.NotNil(t, ev.GetRunUpdated(), "only CI state reaches home: %v", ev)
574+ }
557 }575 }
558 576
559 // A new branch at a commit that already exists, and a branch rewound to an577 // A new branch at a commit that already exists, and a branch rewound to an
M internal/app/testdata/web/home.html +17 -0
@@ -67,6 +67,7 @@
6767 </div>
6868 <ul class="repos" id="repo-rows" data-rows>
6969 <li id="repo-demo" class="repo" data-cursor>
70+<div class="repo-main">
7071 <h2 class="repo-name">
7172 <a href="/demo">demo</a>
7273 </h2>
@@ -78,8 +79,22 @@
7879 <span>pushed <time datetime="STAMP" title="STAMP">AGO</time>
7980 </span>
8081 </p>
82+</div>
83+<ul class="repo-commits" aria-label="Recent commits of demo">
84+<li>
85+<a class="rc-sha" href="/demo/commit/SHA40">SHA7</a>
86+<span class="rc-subject" title="add main.go">add main.go</span>
87+<time class="dim" datetime="STAMP" title="STAMP">AGO</time>
88+</li>
89+<li>
90+<a class="rc-sha" href="/demo/commit/SHA40">SHA7</a>
91+<span class="rc-subject" title="first commit">first commit</span>
92+<time class="dim" datetime="STAMP" title="STAMP">AGO</time>
93+</li>
94+</ul>
8195 </li>
8296 <li id="repo-quiet" class="repo">
97+<div class="repo-main">
8398 <h2 class="repo-name">
8499 <a href="/quiet">quiet</a>
85100 </h2>
@@ -90,6 +105,8 @@
90105 </span>main</span>
91106 <span>never pushed</span>
92107 </p>
108+</div>
109+<p class="repo-commits mute">no commits yet</p>
93110 </li>
94111 </ul>
95112 </section>
@@ -67,6 +67,7 @@
67 </div>67 </div>
68 <ul class="repos" id="repo-rows" data-rows>68 <ul class="repos" id="repo-rows" data-rows>
69 <li id="repo-demo" class="repo" data-cursor>69 <li id="repo-demo" class="repo" data-cursor>
70+<div class="repo-main">
70 <h2 class="repo-name">71 <h2 class="repo-name">
71 <a href="/demo">demo</a>72 <a href="/demo">demo</a>
72 </h2>73 </h2>
@@ -78,8 +79,22 @@
78 <span>pushed <time datetime="STAMP" title="STAMP">AGO</time>79 <span>pushed <time datetime="STAMP" title="STAMP">AGO</time>
79 </span>80 </span>
80 </p>81 </p>
82+</div>
83+<ul class="repo-commits" aria-label="Recent commits of demo">
84+<li>
85+<a class="rc-sha" href="/demo/commit/SHA40">SHA7</a>
86+<span class="rc-subject" title="add main.go">add main.go</span>
87+<time class="dim" datetime="STAMP" title="STAMP">AGO</time>
88+</li>
89+<li>
90+<a class="rc-sha" href="/demo/commit/SHA40">SHA7</a>
91+<span class="rc-subject" title="first commit">first commit</span>
92+<time class="dim" datetime="STAMP" title="STAMP">AGO</time>
93+</li>
94+</ul>
81 </li>95 </li>
82 <li id="repo-quiet" class="repo">96 <li id="repo-quiet" class="repo">
97+<div class="repo-main">
83 <h2 class="repo-name">98 <h2 class="repo-name">
84 <a href="/quiet">quiet</a>99 <a href="/quiet">quiet</a>
85 </h2>100 </h2>
@@ -90,6 +105,8 @@
90 </span>main</span>105 </span>main</span>
91 <span>never pushed</span>106 <span>never pushed</span>
92 </p>107 </p>
108+</div>
109+<p class="repo-commits mute">no commits yet</p>
93 </li>110 </li>
94 </ul>111 </ul>
95 </section>112 </section>
M internal/app/web_events_test.go +1 -0
@@ -125,6 +125,7 @@ func TestWebEventsHome(t *testing.T) {
125125 assert.Contains(t, ev.data, `<li id="repo-pub"`)
126126 assert.Contains(t, ev.data, `new push`)
127127 assert.Contains(t, ev.data, `href="/pub"`)
128+ assert.Contains(t, ev.data, `<ul class="repo-commits" aria-label="Recent commits of pub">`, "the live row lists the commits too")
128129
129130 // A new repository is a row; so is a repository created by a push.
130131 _, err := h.forge.Repos.Create(h.ctx, domain.Root(), ports.CreateRepoParams{Name: h.name("fresh"), Description: "a <b>fresh</b> one", Visibility: domain.VisibilityPublic})
@@ -125,6 +125,7 @@ func TestWebEventsHome(t *testing.T) {
125 assert.Contains(t, ev.data, `<li id="repo-pub"`)125 assert.Contains(t, ev.data, `<li id="repo-pub"`)
126 assert.Contains(t, ev.data, `new push`)126 assert.Contains(t, ev.data, `new push`)
127 assert.Contains(t, ev.data, `href="/pub"`)127 assert.Contains(t, ev.data, `href="/pub"`)
128+ assert.Contains(t, ev.data, `<ul class="repo-commits" aria-label="Recent commits of pub">`, "the live row lists the commits too")
128 129
129 // A new repository is a row; so is a repository created by a push.130 // A new repository is a row; so is a repository created by a push.
130 _, err := h.forge.Repos.Create(h.ctx, domain.Root(), ports.CreateRepoParams{Name: h.name("fresh"), Description: "a <b>fresh</b> one", Visibility: domain.VisibilityPublic})131 _, err := h.forge.Repos.Create(h.ctx, domain.Root(), ports.CreateRepoParams{Name: h.name("fresh"), Description: "a <b>fresh</b> one", Visibility: domain.VisibilityPublic})
M internal/usecase/events.go +5 -5
@@ -488,13 +488,13 @@ func (e *Events) deliver(ctx context.Context, w *watch, be bufferedEvent, send f
488488 case domain.CommentAdded:
489489 return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)
490490 case domain.RunUpdated:
491- // CI events go on the topic of their repository only, never on home.
492- if !w.repos[ev.Run.Repo] {
493- return nil
494- }
491+ // Home shows the CI state of each repository.
495492 return e.deliverRepo(ctx, w, ev.Run.Repo, false, be, out, send)
496493 case domain.JobUpdated:
497- if !w.repos[ev.Job.Repo] {
494+ // Home gets a job when it ends only: a failed job turns the state to
495+ // failing before its run ends. Its progress stays on the topic of its
496+ // repository.
497+ if !w.repos[ev.Job.Repo] && ev.Job.Phase != domain.CIPhaseEnded {
498498 return nil
499499 }
500500 return e.deliverRepo(ctx, w, ev.Job.Repo, false, be, out, send)
@@ -488,13 +488,13 @@ func (e *Events) deliver(ctx context.Context, w *watch, be bufferedEvent, send f
488 case domain.CommentAdded:488 case domain.CommentAdded:
489 return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)489 return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)
490 case domain.RunUpdated:490 case domain.RunUpdated:
491- // CI events go on the topic of their repository only, never on home.491+ // Home shows the CI state of each repository.
492- if !w.repos[ev.Run.Repo] {
493- return nil
494- }
495 return e.deliverRepo(ctx, w, ev.Run.Repo, false, be, out, send)492 return e.deliverRepo(ctx, w, ev.Run.Repo, false, be, out, send)
496 case domain.JobUpdated:493 case domain.JobUpdated:
497- if !w.repos[ev.Job.Repo] {494+ // Home gets a job when it ends only: a failed job turns the state to
495+ // failing before its run ends. Its progress stays on the topic of its
496+ // repository.
497+ if !w.repos[ev.Job.Repo] && ev.Job.Phase != domain.CIPhaseEnded {
498 return nil498 return nil
499 }499 }
500 return e.deliverRepo(ctx, w, ev.Job.Repo, false, be, out, send)500 return e.deliverRepo(ctx, w, ev.Job.Repo, false, be, out, send)
M internal/usecase/events_test.go +41 -0
@@ -881,3 +881,44 @@ func TestPushEventsKeepTheirSnapshot(t *testing.T) {
881881 })
882882 })
883883 }
884+
885+// Home shows the CI state of each repository: a run update reaches it, for the
886+// watchers who may read the repository, and so does a job that ended; the
887+// progress of a job stays on the repository topic.
888+func TestWatchHomeGetsRunUpdates(t *testing.T) {
889+ t.Parallel()
890+ synctest.Test(t, func(t *testing.T) {
891+ r := newEventRig(t, 0)
892+ pub := r.repo("pub", domain.VisibilityPublic)
893+ priv := r.repo("priv", domain.VisibilityPrivate)
894+ ctx, cancel := context.WithCancel(context.Background())
895+ defer cancel()
896+
897+ anon := r.watch(ctx, domain.Anonymous(), ports.WatchParams{Topics: []string{"home"}})
898+ root := r.watch(ctx, domain.Root(), ports.WatchParams{Topics: []string{"home"}})
899+ synctest.Wait()
900+ require.Equal(t, 2, r.events.Watchers())
901+
902+ run := func(n domain.RepoName, vis domain.Visibility) domain.RunUpdated {
903+ return domain.RunUpdated{Run: domain.CIRun{Repo: n, ID: 1}, At: now, Visibility: vis, CreatedAt: now}
904+ }
905+ job := func(n domain.RepoName, vis domain.Visibility, phase domain.CIJobPhase) domain.JobUpdated {
906+ return domain.JobUpdated{Job: domain.CIJob{Repo: n, Phase: phase}, At: now, Visibility: vis, CreatedAt: now}
907+ }
908+ r.feed <- job(pub, domain.VisibilityPublic, domain.CIPhaseRunning)
909+ r.feed <- run(priv, domain.VisibilityPrivate)
910+ r.feed <- job(priv, domain.VisibilityPrivate, domain.CIPhaseEnded)
911+ r.feed <- run(pub, domain.VisibilityPublic)
912+ r.feed <- job(pub, domain.VisibilityPublic, domain.CIPhaseEnded)
913+
914+ assert.Equal(t, pub, anon.next(t).Event.(domain.RunUpdated).Run.Repo, "anonymous: no private run, no running job")
915+ assert.Equal(t, pub, anon.next(t).Event.(domain.JobUpdated).Job.Repo)
916+ anon.quiet(t, r, pub)
917+
918+ assert.Equal(t, priv, root.next(t).Event.(domain.RunUpdated).Run.Repo)
919+ assert.Equal(t, priv, root.next(t).Event.(domain.JobUpdated).Job.Repo)
920+ assert.Equal(t, pub, root.next(t).Event.(domain.RunUpdated).Run.Repo)
921+ assert.Equal(t, pub, root.next(t).Event.(domain.JobUpdated).Job.Repo)
922+ root.quiet(t, r, pub)
923+ })
924+}
@@ -881,3 +881,44 @@ func TestPushEventsKeepTheirSnapshot(t *testing.T) {
881 })881 })
882 })882 })
883 }883 }
884+
885+// Home shows the CI state of each repository: a run update reaches it, for the
886+// watchers who may read the repository, and so does a job that ended; the
887+// progress of a job stays on the repository topic.
888+func TestWatchHomeGetsRunUpdates(t *testing.T) {
889+ t.Parallel()
890+ synctest.Test(t, func(t *testing.T) {
891+ r := newEventRig(t, 0)
892+ pub := r.repo("pub", domain.VisibilityPublic)
893+ priv := r.repo("priv", domain.VisibilityPrivate)
894+ ctx, cancel := context.WithCancel(context.Background())
895+ defer cancel()
896+
897+ anon := r.watch(ctx, domain.Anonymous(), ports.WatchParams{Topics: []string{"home"}})
898+ root := r.watch(ctx, domain.Root(), ports.WatchParams{Topics: []string{"home"}})
899+ synctest.Wait()
900+ require.Equal(t, 2, r.events.Watchers())
901+
902+ run := func(n domain.RepoName, vis domain.Visibility) domain.RunUpdated {
903+ return domain.RunUpdated{Run: domain.CIRun{Repo: n, ID: 1}, At: now, Visibility: vis, CreatedAt: now}
904+ }
905+ job := func(n domain.RepoName, vis domain.Visibility, phase domain.CIJobPhase) domain.JobUpdated {
906+ return domain.JobUpdated{Job: domain.CIJob{Repo: n, Phase: phase}, At: now, Visibility: vis, CreatedAt: now}
907+ }
908+ r.feed <- job(pub, domain.VisibilityPublic, domain.CIPhaseRunning)
909+ r.feed <- run(priv, domain.VisibilityPrivate)
910+ r.feed <- job(priv, domain.VisibilityPrivate, domain.CIPhaseEnded)
911+ r.feed <- run(pub, domain.VisibilityPublic)
912+ r.feed <- job(pub, domain.VisibilityPublic, domain.CIPhaseEnded)
913+
914+ assert.Equal(t, pub, anon.next(t).Event.(domain.RunUpdated).Run.Repo, "anonymous: no private run, no running job")
915+ assert.Equal(t, pub, anon.next(t).Event.(domain.JobUpdated).Job.Repo)
916+ anon.quiet(t, r, pub)
917+
918+ assert.Equal(t, priv, root.next(t).Event.(domain.RunUpdated).Run.Repo)
919+ assert.Equal(t, priv, root.next(t).Event.(domain.JobUpdated).Job.Repo)
920+ assert.Equal(t, pub, root.next(t).Event.(domain.RunUpdated).Run.Repo)
921+ assert.Equal(t, pub, root.next(t).Event.(domain.JobUpdated).Job.Repo)
922+ root.quiet(t, r, pub)
923+ })
924+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 41

↑↓ moveenter openesc close