detached 4277264

feat(web): order the home page by last activity and move repositories up live

commit
427726483415e5fdfc0b1c4aae86aea88a80a1fa
author
brice · · 2026-10-19 15:48 UTC
parent
4fc291e
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
28 files · +816 -525
M gen/ermite/storage/repo.pb.go +22 -11 generated file: collapsed
M internal/adapters/kvstore/repos.go +2 -0
@@ -208,6 +208,7 @@ func encodeRepo(r domain.Repo) ([]byte, error) {
208208 Visibility: vis,
209209 CreatedAt: encodeTime(r.CreatedAt),
210210 PushedAt: encodeTime(r.PushedAt),
211+ ActiveAt: encodeTime(r.ActiveAt),
211212 Settings: pbSettings,
212213 }
213214 return mustMarshal(pb), nil
@@ -229,6 +230,7 @@ func decodeRepo(val []byte) (domain.Repo, error) {
229230 Visibility: decodeVisibility(pb.GetVisibility()),
230231 CreatedAt: decodeTime(pb.GetCreatedAt()),
231232 PushedAt: decodeTime(pb.GetPushedAt()),
233+ ActiveAt: decodeTime(pb.GetActiveAt()),
232234 Settings: decodeSettings(pb.GetSettings()),
233235 }, nil
234236 }
@@ -208,6 +208,7 @@ func encodeRepo(r domain.Repo) ([]byte, error) {
208 Visibility: vis,208 Visibility: vis,
209 CreatedAt: encodeTime(r.CreatedAt),209 CreatedAt: encodeTime(r.CreatedAt),
210 PushedAt: encodeTime(r.PushedAt),210 PushedAt: encodeTime(r.PushedAt),
211+ ActiveAt: encodeTime(r.ActiveAt),
211 Settings: pbSettings,212 Settings: pbSettings,
212 }213 }
213 return mustMarshal(pb), nil214 return mustMarshal(pb), nil
@@ -229,6 +230,7 @@ func decodeRepo(val []byte) (domain.Repo, error) {
229 Visibility: decodeVisibility(pb.GetVisibility()),230 Visibility: decodeVisibility(pb.GetVisibility()),
230 CreatedAt: decodeTime(pb.GetCreatedAt()),231 CreatedAt: decodeTime(pb.GetCreatedAt()),
231 PushedAt: decodeTime(pb.GetPushedAt()),232 PushedAt: decodeTime(pb.GetPushedAt()),
233+ ActiveAt: decodeTime(pb.GetActiveAt()),
232 Settings: decodeSettings(pb.GetSettings()),234 Settings: decodeSettings(pb.GetSettings()),
233 }, nil235 }, nil
234 }236 }
M internal/adapters/kvstore/store_test.go +2 -1
@@ -43,7 +43,7 @@ func TestCreateGetListUpdateDelete(t *testing.T) {
4343 s := openStore(t, t.TempDir())
4444
4545 b := domain.Repo{Name: name(t, "b"), Description: "second", Topics: []string{"cli", "go"}, Visibility: domain.VisibilityPublic, CreatedAt: created, Settings: domain.DefaultRepoSettings()}
46- a := domain.Repo{Name: name(t, "a"), Visibility: domain.VisibilityPrivate, CreatedAt: created, PushedAt: created.Add(time.Hour), Settings: domain.DefaultRepoSettings()}
46+ a := domain.Repo{Name: name(t, "a"), Visibility: domain.VisibilityPrivate, CreatedAt: created, PushedAt: created.Add(time.Hour), ActiveAt: created.Add(2 * time.Hour), Settings: domain.DefaultRepoSettings()}
4747 require.NoError(t, s.Create(ctx, b))
4848 require.NoError(t, s.Create(ctx, a))
4949
@@ -54,6 +54,7 @@ func TestCreateGetListUpdateDelete(t *testing.T) {
5454 require.NoError(t, err)
5555 assert.Equal(t, a, got)
5656 assert.True(t, got.PushedAt.Equal(a.PushedAt))
57+ assert.True(t, got.ActiveAt.Equal(a.ActiveAt))
5758
5859 list, err := s.List(ctx)
5960 require.NoError(t, err)
@@ -43,7 +43,7 @@ func TestCreateGetListUpdateDelete(t *testing.T) {
43 s := openStore(t, t.TempDir())43 s := openStore(t, t.TempDir())
44 44
45 b := domain.Repo{Name: name(t, "b"), Description: "second", Topics: []string{"cli", "go"}, Visibility: domain.VisibilityPublic, CreatedAt: created, Settings: domain.DefaultRepoSettings()}45 b := domain.Repo{Name: name(t, "b"), Description: "second", Topics: []string{"cli", "go"}, Visibility: domain.VisibilityPublic, CreatedAt: created, Settings: domain.DefaultRepoSettings()}
46- a := domain.Repo{Name: name(t, "a"), Visibility: domain.VisibilityPrivate, CreatedAt: created, PushedAt: created.Add(time.Hour), Settings: domain.DefaultRepoSettings()}46+ a := domain.Repo{Name: name(t, "a"), Visibility: domain.VisibilityPrivate, CreatedAt: created, PushedAt: created.Add(time.Hour), ActiveAt: created.Add(2 * time.Hour), Settings: domain.DefaultRepoSettings()}
47 require.NoError(t, s.Create(ctx, b))47 require.NoError(t, s.Create(ctx, b))
48 require.NoError(t, s.Create(ctx, a))48 require.NoError(t, s.Create(ctx, a))
49 49
@@ -54,6 +54,7 @@ func TestCreateGetListUpdateDelete(t *testing.T) {
54 require.NoError(t, err)54 require.NoError(t, err)
55 assert.Equal(t, a, got)55 assert.Equal(t, a, got)
56 assert.True(t, got.PushedAt.Equal(a.PushedAt))56 assert.True(t, got.PushedAt.Equal(a.PushedAt))
57+ assert.True(t, got.ActiveAt.Equal(a.ActiveAt))
57 58
58 list, err := s.List(ctx)59 list, err := s.List(ctx)
59 require.NoError(t, err)60 require.NoError(t, err)
M internal/adapters/web/components.templ +2 -2
@@ -37,7 +37,7 @@ templ CloneBlock(c Clone) {
3737 // right its figures and CI state over its newest commits. Its id is the
3838 // target of SSE swaps; the name link comes first, `enter` follows it.
3939 templ HomeRowView(r HomeRow) {
40- <li id={ r.ID } class="repo" data-cursor?={ r.Cursor }>
40+ <li id={ r.ID } class="repo" data-cursor?={ r.Cursor } data-activity?={ r.Fresh }>
4141 <div class="repo-main">
4242 <h2 class="repo-name">
4343 <a href={ templ.SafeURL(r.Href) }>{ r.Name }</a>
@@ -45,7 +45,7 @@ templ HomeRowView(r HomeRow) {
4545 <span class="pill">private</span>
4646 }
4747 if r.Fresh {
48- <span class="pill new">new push</span>
48+ <span class="pill new">new activity</span>
4949 }
5050 </h2>
5151 if r.Desc != "" {
@@ -37,7 +37,7 @@ templ CloneBlock(c Clone) {
37 // right its figures and CI state over its newest commits. Its id is the37 // right its figures and CI state over its newest commits. Its id is the
38 // target of SSE swaps; the name link comes first, `enter` follows it.38 // target of SSE swaps; the name link comes first, `enter` follows it.
39 templ HomeRowView(r HomeRow) {39 templ HomeRowView(r HomeRow) {
40- <li id={ r.ID } class="repo" data-cursor?={ r.Cursor }>40+ <li id={ r.ID } class="repo" data-cursor?={ r.Cursor } data-activity?={ r.Fresh }>
41 <div class="repo-main">41 <div class="repo-main">
42 <h2 class="repo-name">42 <h2 class="repo-name">
43 <a href={ templ.SafeURL(r.Href) }>{ r.Name }</a>43 <a href={ templ.SafeURL(r.Href) }>{ r.Name }</a>
@@ -45,7 +45,7 @@ templ HomeRowView(r HomeRow) {
45 <span class="pill">private</span>45 <span class="pill">private</span>
46 }46 }
47 if r.Fresh {47 if r.Fresh {
48- <span class="pill new">new push</span>48+ <span class="pill new">new activity</span>
49 }49 }
50 </h2>50 </h2>
51 if r.Desc != "" {51 if r.Desc != "" {
M internal/adapters/web/components_templ.go +119 -113 generated file: collapsed
M internal/adapters/web/events.go +2 -0
@@ -50,6 +50,8 @@ import (
5050 //
5151 // home-list data: <li id="repo-<name>">…</li>, or <li id="repo-<name>" data-gone></li>
5252 // for a repository that is deleted or no longer visible
53+// Activity rows carry data-activity: push, MR lifecycle/comment,
54+// or issue lifecycle/comment (never pending moderation or CI).
5355 // repo-last-push data: <dd id="last-push-<name>">…</dd>
5456 // refs-list data: <tbody id="ref-rows">…</tbody>, and again with
5557 // <dl id="refs-kv">…</dl>, the facts of the rail
@@ -50,6 +50,8 @@ import (
50 //50 //
51 // home-list data: <li id="repo-<name>">…</li>, or <li id="repo-<name>" data-gone></li>51 // home-list data: <li id="repo-<name>">…</li>, or <li id="repo-<name>" data-gone></li>
52 // for a repository that is deleted or no longer visible52 // for a repository that is deleted or no longer visible
53+// Activity rows carry data-activity: push, MR lifecycle/comment,
54+// or issue lifecycle/comment (never pending moderation or CI).
53 // repo-last-push data: <dd id="last-push-<name>">…</dd>55 // repo-last-push data: <dd id="last-push-<name>">…</dd>
54 // refs-list data: <tbody id="ref-rows">…</tbody>, and again with56 // refs-list data: <tbody id="ref-rows">…</tbody>, and again with
55 // <dl id="refs-kv">…</dl>, the facts of the rail57 // <dl id="refs-kv">…</dl>, the facts of the rail
M internal/adapters/web/fragments.go +18 -0
@@ -112,6 +112,24 @@ func (l *liveStream) home(ctx context.Context, we ports.WatchEvent) ([]fragment,
112112 name = ev.Repo.String()
113113 case domain.PushEvent:
114114 name, fresh = ev.Repo.String(), true
115+ case domain.MergeRequestOpened:
116+ name, fresh = ev.MergeRequest.Repo.String(), true
117+ case domain.MergeRequestMerged:
118+ name, fresh = ev.MergeRequest.Repo.String(), true
119+ case domain.MergeRequestClosed:
120+ name, fresh = ev.MergeRequest.Repo.String(), true
121+ case domain.MergeRequestReady:
122+ name, fresh = ev.MergeRequest.Repo.String(), true
123+ case domain.CommentAdded:
124+ name, fresh = ev.Repo.String(), true
125+ case domain.IssueOpened:
126+ name, fresh = ev.Issue.Repo.String(), true
127+ case domain.IssueClosed:
128+ name, fresh = ev.Issue.Repo.String(), true
129+ case domain.IssueReopened:
130+ name, fresh = ev.Issue.Repo.String(), true
131+ case domain.IssueCommented:
132+ name, fresh = ev.Repo.String(), true
115133 case domain.RunUpdated:
116134 name = ev.Run.Repo.String() // the CI state of the row
117135 case domain.JobUpdated:
@@ -112,6 +112,24 @@ func (l *liveStream) home(ctx context.Context, we ports.WatchEvent) ([]fragment,
112 name = ev.Repo.String()112 name = ev.Repo.String()
113 case domain.PushEvent:113 case domain.PushEvent:
114 name, fresh = ev.Repo.String(), true114 name, fresh = ev.Repo.String(), true
115+ case domain.MergeRequestOpened:
116+ name, fresh = ev.MergeRequest.Repo.String(), true
117+ case domain.MergeRequestMerged:
118+ name, fresh = ev.MergeRequest.Repo.String(), true
119+ case domain.MergeRequestClosed:
120+ name, fresh = ev.MergeRequest.Repo.String(), true
121+ case domain.MergeRequestReady:
122+ name, fresh = ev.MergeRequest.Repo.String(), true
123+ case domain.CommentAdded:
124+ name, fresh = ev.Repo.String(), true
125+ case domain.IssueOpened:
126+ name, fresh = ev.Issue.Repo.String(), true
127+ case domain.IssueClosed:
128+ name, fresh = ev.Issue.Repo.String(), true
129+ case domain.IssueReopened:
130+ name, fresh = ev.Issue.Repo.String(), true
131+ case domain.IssueCommented:
132+ name, fresh = ev.Repo.String(), true
115 case domain.RunUpdated:133 case domain.RunUpdated:
116 name = ev.Run.Repo.String() // the CI state of the row134 name = ev.Run.Repo.String() // the CI state of the row
117 case domain.JobUpdated:135 case domain.JobUpdated:
M internal/adapters/web/home.go +22 -11
@@ -56,12 +56,12 @@ func (s *Server) listRepos(ctx context.Context) ([]ports.RepoView, error) {
5656 // homeSorts are the orders the home list offers, in order. The query also
5757 // accepts "desc" and "branch", which the page does not link to.
5858 var homeSorts = []struct{ key, label string }{
59- {"push", "last push"},
59+ {"activity", "last activity"},
6060 {"name", "name"},
6161 }
6262
6363 // homeKeys are the keys of ?sort= that sortRepos knows.
64-var homeKeys = []string{"push", "name", "desc", "branch"}
64+var homeKeys = []string{"activity", "name", "desc", "branch"}
6565
6666 // sortRepos orders repos by the column key; ties keep the order by name.
6767 func sortRepos(repos []ports.RepoView, key string, desc bool) {
@@ -71,8 +71,8 @@ func sortRepos(repos []ports.RepoView, key string, desc bool) {
7171 return strings.ToLower(a.Description) < strings.ToLower(b.Description)
7272 case "branch":
7373 return a.DefaultBranch < b.DefaultBranch
74- case "push":
75- return a.PushedAt.Before(b.PushedAt)
74+ case "activity":
75+ return repoActiveAt(a).Before(repoActiveAt(b))
7676 }
7777 return a.Name.String() < b.Name.String()
7878 }
@@ -84,17 +84,17 @@ func sortRepos(repos []ports.RepoView, key string, desc bool) {
8484 })
8585 }
8686
87-// home lists the repositories: /?sort=<col>&dir=asc|desc, newest push first
87+// home lists the repositories: /?sort=<col>&dir=asc|desc, newest activity first
8888 // by default.
8989 func (s *Server) home(ctx context.Context, r *http.Request) (templ.Component, int, error) {
9090 repos, err := s.listRepos(ctx)
9191 if err != nil {
9292 return nil, 0, err
9393 }
94- key, desc := "push", true
94+ key, desc := "activity", true
9595 for _, k := range homeKeys {
9696 if r.URL.Query().Get("sort") == k {
97- key, desc = k, k == "push"
97+ key, desc = k, k == "activity"
9898 }
9999 }
100100 if d := r.URL.Query().Get("dir"); d == "asc" || d == "desc" {
@@ -103,11 +103,11 @@ func (s *Server) home(ctx context.Context, r *http.Request) (templ.Component, in
103103 sortRepos(repos, key, desc)
104104
105105 now := s.cfg.Now()
106- p := HomePage{}
106+ p := HomePage{Sort: key, Desc: desc}
107107 for _, c := range homeSorts {
108108 sl := SortLink{Label: c.label}
109109 next := "asc"
110- if c.key == "push" {
110+ if c.key == "activity" {
111111 next = "desc"
112112 }
113113 if c.key == key {
@@ -267,8 +267,8 @@ func homeRow(r ports.RepoView, now time.Time, cursor, signed bool) HomeRow {
267267 Desc: r.Description, Topics: r.Topics, Cursor: cursor,
268268 Private: signed && r.Visibility == domain.VisibilityPrivate,
269269 }
270- if !r.PushedAt.IsZero() {
271- row.Active, row.ActiveAt = ago(now, r.PushedAt), rfc3339(r.PushedAt)
270+ if at := repoActiveAt(r); !at.IsZero() {
271+ row.Active, row.ActiveAt = ago(now, at), rfc3339(at)
272272 }
273273 return row
274274 }
@@ -284,3 +284,14 @@ func repoCmds(repos []ports.RepoView, except string) []CmdItem {
284284 }
285285 return out
286286 }
287+
288+// repoActiveAt falls back for repositories without recorded activity.
289+func repoActiveAt(r ports.RepoView) time.Time {
290+ if !r.ActiveAt.IsZero() {
291+ return r.ActiveAt
292+ }
293+ if r.PushedAt.After(r.CreatedAt) {
294+ return r.PushedAt
295+ }
296+ return r.CreatedAt
297+}
@@ -56,12 +56,12 @@ func (s *Server) listRepos(ctx context.Context) ([]ports.RepoView, error) {
56 // homeSorts are the orders the home list offers, in order. The query also56 // homeSorts are the orders the home list offers, in order. The query also
57 // accepts "desc" and "branch", which the page does not link to.57 // accepts "desc" and "branch", which the page does not link to.
58 var homeSorts = []struct{ key, label string }{58 var homeSorts = []struct{ key, label string }{
59- {"push", "last push"},59+ {"activity", "last activity"},
60 {"name", "name"},60 {"name", "name"},
61 }61 }
62 62
63 // homeKeys are the keys of ?sort= that sortRepos knows.63 // homeKeys are the keys of ?sort= that sortRepos knows.
64-var homeKeys = []string{"push", "name", "desc", "branch"}64+var homeKeys = []string{"activity", "name", "desc", "branch"}
65 65
66 // sortRepos orders repos by the column key; ties keep the order by name.66 // sortRepos orders repos by the column key; ties keep the order by name.
67 func sortRepos(repos []ports.RepoView, key string, desc bool) {67 func sortRepos(repos []ports.RepoView, key string, desc bool) {
@@ -71,8 +71,8 @@ func sortRepos(repos []ports.RepoView, key string, desc bool) {
71 return strings.ToLower(a.Description) < strings.ToLower(b.Description)71 return strings.ToLower(a.Description) < strings.ToLower(b.Description)
72 case "branch":72 case "branch":
73 return a.DefaultBranch < b.DefaultBranch73 return a.DefaultBranch < b.DefaultBranch
74- case "push":74+ case "activity":
75- return a.PushedAt.Before(b.PushedAt)75+ return repoActiveAt(a).Before(repoActiveAt(b))
76 }76 }
77 return a.Name.String() < b.Name.String()77 return a.Name.String() < b.Name.String()
78 }78 }
@@ -84,17 +84,17 @@ func sortRepos(repos []ports.RepoView, key string, desc bool) {
84 })84 })
85 }85 }
86 86
87-// home lists the repositories: /?sort=<col>&dir=asc|desc, newest push first87+// home lists the repositories: /?sort=<col>&dir=asc|desc, newest activity first
88 // by default.88 // by default.
89 func (s *Server) home(ctx context.Context, r *http.Request) (templ.Component, int, error) {89 func (s *Server) home(ctx context.Context, r *http.Request) (templ.Component, int, error) {
90 repos, err := s.listRepos(ctx)90 repos, err := s.listRepos(ctx)
91 if err != nil {91 if err != nil {
92 return nil, 0, err92 return nil, 0, err
93 }93 }
94- key, desc := "push", true94+ key, desc := "activity", true
95 for _, k := range homeKeys {95 for _, k := range homeKeys {
96 if r.URL.Query().Get("sort") == k {96 if r.URL.Query().Get("sort") == k {
97- key, desc = k, k == "push"97+ key, desc = k, k == "activity"
98 }98 }
99 }99 }
100 if d := r.URL.Query().Get("dir"); d == "asc" || d == "desc" {100 if d := r.URL.Query().Get("dir"); d == "asc" || d == "desc" {
@@ -103,11 +103,11 @@ func (s *Server) home(ctx context.Context, r *http.Request) (templ.Component, in
103 sortRepos(repos, key, desc)103 sortRepos(repos, key, desc)
104 104
105 now := s.cfg.Now()105 now := s.cfg.Now()
106- p := HomePage{}106+ p := HomePage{Sort: key, Desc: desc}
107 for _, c := range homeSorts {107 for _, c := range homeSorts {
108 sl := SortLink{Label: c.label}108 sl := SortLink{Label: c.label}
109 next := "asc"109 next := "asc"
110- if c.key == "push" {110+ if c.key == "activity" {
111 next = "desc"111 next = "desc"
112 }112 }
113 if c.key == key {113 if c.key == key {
@@ -267,8 +267,8 @@ func homeRow(r ports.RepoView, now time.Time, cursor, signed bool) HomeRow {
267 Desc: r.Description, Topics: r.Topics, Cursor: cursor,267 Desc: r.Description, Topics: r.Topics, Cursor: cursor,
268 Private: signed && r.Visibility == domain.VisibilityPrivate,268 Private: signed && r.Visibility == domain.VisibilityPrivate,
269 }269 }
270- if !r.PushedAt.IsZero() {270+ if at := repoActiveAt(r); !at.IsZero() {
271- row.Active, row.ActiveAt = ago(now, r.PushedAt), rfc3339(r.PushedAt)271+ row.Active, row.ActiveAt = ago(now, at), rfc3339(at)
272 }272 }
273 return row273 return row
274 }274 }
@@ -284,3 +284,14 @@ func repoCmds(repos []ports.RepoView, except string) []CmdItem {
284 }284 }
285 return out285 return out
286 }286 }
287+
288+// repoActiveAt falls back for repositories without recorded activity.
289+func repoActiveAt(r ports.RepoView) time.Time {
290+ if !r.ActiveAt.IsZero() {
291+ return r.ActiveAt
292+ }
293+ if r.PushedAt.After(r.CreatedAt) {
294+ return r.PushedAt
295+ }
296+ return r.CreatedAt
297+}
M internal/adapters/web/home_test.go +49 -1
@@ -252,7 +252,7 @@ func TestRunUpdatedRefreshesTheHomeRow(t *testing.T) {
252252 assert.Equal(t, "home-list", got[0].event)
253253 assert.Contains(t, got[0].html, `id="repo-proj"`)
254254 assert.Contains(t, got[0].html, "passing")
255- assert.NotContains(t, got[0].html, "new push", "a run is not a push")
255+ assert.NotContains(t, got[0].html, "new activity", "a run is not a push")
256256
257257 // A job that ended refreshes the row too; one in progress does not.
258258 got, err = (&liveStream{s: srv}).render(t.Context(), ports.WatchEvent{Event: domain.JobUpdated{Job: domain.CIJob{Repo: repo, Phase: domain.CIPhaseRunning}}})
@@ -344,3 +344,51 @@ func TestHomeOffersTheLayouts(t *testing.T) {
344344 require.NotEqual(t, -1, store)
345345 assert.Less(t, store, app, "the store loads before the app script")
346346 }
347+
348+func TestHomeActivityOrderAndFallback(t *testing.T) {
349+ t.Parallel()
350+ now := time.Now().UTC()
351+ a, b, c := publicRepo(t, "a"), publicRepo(t, "b"), publicRepo(t, "c")
352+ a.PushedAt = now.Add(-time.Hour)
353+ a.ActiveAt = now
354+ b.PushedAt = now.Add(-time.Minute)
355+ c.CreatedAt = now.Add(-30 * time.Minute)
356+ repos := []ports.RepoView{b, c, a}
357+ sortRepos(repos, "activity", true)
358+ assert.Equal(t, []ports.RepoView{a, b, c}, repos)
359+ sortRepos(repos, "activity", false)
360+ assert.Equal(t, []ports.RepoView{c, b, a}, repos)
361+ assert.Equal(t, rfc3339(now), homeRow(a, now, false, false).ActiveAt)
362+ c.PushedAt = c.CreatedAt.Add(-time.Hour)
363+ assert.Equal(t, c.CreatedAt, repoActiveAt(c), "fallback is the later of creation and push")
364+}
365+
366+func TestActivityEventsRefreshTheHomeRow(t *testing.T) {
367+ t.Parallel()
368+ repo := publicRepo(t, "proj")
369+ srv := newTestServer(t, Config{Repos: reposServing(t, repo), Browse: homeBrowse(t)})
370+ mr := domain.MergeRequest{Repo: repo.Name}
371+ issue := domain.Issue{Repo: repo.Name}
372+ for _, ev := range []domain.Event{
373+ domain.PushEvent{Repo: repo.Name},
374+ domain.MergeRequestOpened{MergeRequest: mr}, domain.MergeRequestMerged{MergeRequest: mr},
375+ domain.MergeRequestClosed{MergeRequest: mr}, domain.MergeRequestReady{MergeRequest: mr},
376+ domain.CommentAdded{Repo: repo.Name}, domain.IssueOpened{Issue: issue},
377+ domain.IssueClosed{Issue: issue}, domain.IssueReopened{Issue: issue}, domain.IssueCommented{Repo: repo.Name},
378+ } {
379+ t.Run(ev.EventKind(), func(t *testing.T) {
380+ got, err := (&liveStream{s: srv}).home(t.Context(), ports.WatchEvent{Event: ev})
381+ require.NoError(t, err)
382+ require.Len(t, got, 1)
383+ assert.Equal(t, "home-list", got[0].event)
384+ assert.Contains(t, got[0].html, `id="repo-proj"`)
385+ assert.Contains(t, got[0].html, `data-activity`)
386+ assert.Contains(t, got[0].html, "new activity")
387+ })
388+ }
389+ for _, ev := range []domain.Event{domain.IssueSubmitted{Repo: repo.Name}, domain.IssuePendingRemoved{Repo: repo.Name}} {
390+ got, err := (&liveStream{s: srv}).home(t.Context(), ports.WatchEvent{Event: ev})
391+ require.NoError(t, err)
392+ assert.Empty(t, got)
393+ }
394+}
@@ -252,7 +252,7 @@ func TestRunUpdatedRefreshesTheHomeRow(t *testing.T) {
252 assert.Equal(t, "home-list", got[0].event)252 assert.Equal(t, "home-list", got[0].event)
253 assert.Contains(t, got[0].html, `id="repo-proj"`)253 assert.Contains(t, got[0].html, `id="repo-proj"`)
254 assert.Contains(t, got[0].html, "passing")254 assert.Contains(t, got[0].html, "passing")
255- assert.NotContains(t, got[0].html, "new push", "a run is not a push")255+ assert.NotContains(t, got[0].html, "new activity", "a run is not a push")
256 256
257 // A job that ended refreshes the row too; one in progress does not.257 // A job that ended refreshes the row too; one in progress does not.
258 got, err = (&liveStream{s: srv}).render(t.Context(), ports.WatchEvent{Event: domain.JobUpdated{Job: domain.CIJob{Repo: repo, Phase: domain.CIPhaseRunning}}})258 got, err = (&liveStream{s: srv}).render(t.Context(), ports.WatchEvent{Event: domain.JobUpdated{Job: domain.CIJob{Repo: repo, Phase: domain.CIPhaseRunning}}})
@@ -344,3 +344,51 @@ func TestHomeOffersTheLayouts(t *testing.T) {
344 require.NotEqual(t, -1, store)344 require.NotEqual(t, -1, store)
345 assert.Less(t, store, app, "the store loads before the app script")345 assert.Less(t, store, app, "the store loads before the app script")
346 }346 }
347+
348+func TestHomeActivityOrderAndFallback(t *testing.T) {
349+ t.Parallel()
350+ now := time.Now().UTC()
351+ a, b, c := publicRepo(t, "a"), publicRepo(t, "b"), publicRepo(t, "c")
352+ a.PushedAt = now.Add(-time.Hour)
353+ a.ActiveAt = now
354+ b.PushedAt = now.Add(-time.Minute)
355+ c.CreatedAt = now.Add(-30 * time.Minute)
356+ repos := []ports.RepoView{b, c, a}
357+ sortRepos(repos, "activity", true)
358+ assert.Equal(t, []ports.RepoView{a, b, c}, repos)
359+ sortRepos(repos, "activity", false)
360+ assert.Equal(t, []ports.RepoView{c, b, a}, repos)
361+ assert.Equal(t, rfc3339(now), homeRow(a, now, false, false).ActiveAt)
362+ c.PushedAt = c.CreatedAt.Add(-time.Hour)
363+ assert.Equal(t, c.CreatedAt, repoActiveAt(c), "fallback is the later of creation and push")
364+}
365+
366+func TestActivityEventsRefreshTheHomeRow(t *testing.T) {
367+ t.Parallel()
368+ repo := publicRepo(t, "proj")
369+ srv := newTestServer(t, Config{Repos: reposServing(t, repo), Browse: homeBrowse(t)})
370+ mr := domain.MergeRequest{Repo: repo.Name}
371+ issue := domain.Issue{Repo: repo.Name}
372+ for _, ev := range []domain.Event{
373+ domain.PushEvent{Repo: repo.Name},
374+ domain.MergeRequestOpened{MergeRequest: mr}, domain.MergeRequestMerged{MergeRequest: mr},
375+ domain.MergeRequestClosed{MergeRequest: mr}, domain.MergeRequestReady{MergeRequest: mr},
376+ domain.CommentAdded{Repo: repo.Name}, domain.IssueOpened{Issue: issue},
377+ domain.IssueClosed{Issue: issue}, domain.IssueReopened{Issue: issue}, domain.IssueCommented{Repo: repo.Name},
378+ } {
379+ t.Run(ev.EventKind(), func(t *testing.T) {
380+ got, err := (&liveStream{s: srv}).home(t.Context(), ports.WatchEvent{Event: ev})
381+ require.NoError(t, err)
382+ require.Len(t, got, 1)
383+ assert.Equal(t, "home-list", got[0].event)
384+ assert.Contains(t, got[0].html, `id="repo-proj"`)
385+ assert.Contains(t, got[0].html, `data-activity`)
386+ assert.Contains(t, got[0].html, "new activity")
387+ })
388+ }
389+ for _, ev := range []domain.Event{domain.IssueSubmitted{Repo: repo.Name}, domain.IssuePendingRemoved{Repo: repo.Name}} {
390+ got, err := (&liveStream{s: srv}).home(t.Context(), ports.WatchEvent{Event: ev})
391+ require.NoError(t, err)
392+ assert.Empty(t, got)
393+ }
394+}
M internal/adapters/web/jstest/a11y.test.mjs +25 -0
@@ -8,6 +8,7 @@ import { test } from 'node:test';
88 import { dom, El, el, flushFrames, installDom, settle } from './dom.mjs';
99
1010 // The few things ermite.js uses that dom.mjs does not have.
11+El.prototype.prepend = function (...nodes) { this.nodes.unshift(...this.adopt(nodes)); };
1112 El.prototype.replaceWith = function (n) {
1213 const p = this.parent;
1314 const i = p.nodes.indexOf(this);
@@ -333,3 +334,27 @@ test('the preference is read from storage when the page loads', async () => {
333334 assert.deepEqual(dom.body.events, []);
334335 store.set('keys-off', '');
335336 });
337+
338+
339+test('activity moves a home row up only in newest activity order, preserving focus and cursor', () => {
340+ for (const [sort, desc, activity, moves] of [
341+ ['activity', 'true', true, true],
342+ ['activity', 'false', true, false],
343+ ['name', 'true', true, false],
344+ ['name', 'false', true, false],
345+ ['activity', 'true', false, false], // CI or metadata, not activity
346+ ]) {
347+ const r = rows();
348+ r.list.dataset.sort = sort;
349+ r.list.dataset.desc = desc;
350+ const old = r.list.children[1];
351+ old.setAttribute('data-cursor', '');
352+ active = old.children[0];
353+ const fresh = r.mk('repo-b', '/b');
354+ if (activity) fresh.setAttribute('data-activity', '');
355+ swapWith(`activity-${sort}-${desc}-${activity}`, fresh);
356+ assert.equal(r.list.children.indexOf(fresh), moves ? 0 : 1);
357+ assert.equal(active, fresh.children[0]);
358+ assert.ok(fresh.hasAttribute('data-cursor'));
359+ }
360+});
@@ -8,6 +8,7 @@ import { test } from 'node:test';
8 import { dom, El, el, flushFrames, installDom, settle } from './dom.mjs';8 import { dom, El, el, flushFrames, installDom, settle } from './dom.mjs';
9 9
10 // The few things ermite.js uses that dom.mjs does not have.10 // The few things ermite.js uses that dom.mjs does not have.
11+El.prototype.prepend = function (...nodes) { this.nodes.unshift(...this.adopt(nodes)); };
11 El.prototype.replaceWith = function (n) {12 El.prototype.replaceWith = function (n) {
12 const p = this.parent;13 const p = this.parent;
13 const i = p.nodes.indexOf(this);14 const i = p.nodes.indexOf(this);
@@ -333,3 +334,27 @@ test('the preference is read from storage when the page loads', async () => {
333 assert.deepEqual(dom.body.events, []);334 assert.deepEqual(dom.body.events, []);
334 store.set('keys-off', '');335 store.set('keys-off', '');
335 });336 });
337+
338+
339+test('activity moves a home row up only in newest activity order, preserving focus and cursor', () => {
340+ for (const [sort, desc, activity, moves] of [
341+ ['activity', 'true', true, true],
342+ ['activity', 'false', true, false],
343+ ['name', 'true', true, false],
344+ ['name', 'false', true, false],
345+ ['activity', 'true', false, false], // CI or metadata, not activity
346+ ]) {
347+ const r = rows();
348+ r.list.dataset.sort = sort;
349+ r.list.dataset.desc = desc;
350+ const old = r.list.children[1];
351+ old.setAttribute('data-cursor', '');
352+ active = old.children[0];
353+ const fresh = r.mk('repo-b', '/b');
354+ if (activity) fresh.setAttribute('data-activity', '');
355+ swapWith(`activity-${sort}-${desc}-${activity}`, fresh);
356+ assert.equal(r.list.children.indexOf(fresh), moves ? 0 : 1);
357+ assert.equal(active, fresh.children[0]);
358+ assert.ok(fresh.hasAttribute('data-cursor'));
359+ }
360+});
M internal/adapters/web/pages.templ +3 -1
@@ -2,6 +2,8 @@
22
33 package web
44
5+import "fmt"
6+
57 // Home is the repository list.
68 templ Home(l Layout, p HomePage) {
79 @Shell(l, nil) {
@@ -49,7 +51,7 @@ templ Home(l Layout, p HomePage) {
4951 </button>
5052 </div>
5153 </div>
52- <ul class="repos" id="repo-rows" data-rows>
54+ <ul class="repos" id="repo-rows" data-rows data-sort={ p.Sort } data-desc={ fmt.Sprint(p.Desc) }>
5355 for _, r := range p.Rows {
5456 @HomeRowView(r)
5557 }
@@ -2,6 +2,8 @@
2 2
3 package web3 package web
4 4
5+import "fmt"
6+
5 // Home is the repository list.7 // Home is the repository list.
6 templ Home(l Layout, p HomePage) {8 templ Home(l Layout, p HomePage) {
7 @Shell(l, nil) {9 @Shell(l, nil) {
@@ -49,7 +51,7 @@ templ Home(l Layout, p HomePage) {
49 </button>51 </button>
50 </div>52 </div>
51 </div>53 </div>
52- <ul class="repos" id="repo-rows" data-rows>54+ <ul class="repos" id="repo-rows" data-rows data-sort={ p.Sort } data-desc={ fmt.Sprint(p.Desc) }>
53 for _, r := range p.Rows {55 for _, r := range p.Rows {
54 @HomeRowView(r)56 @HomeRowView(r)
55 }57 }
M internal/adapters/web/pages_templ.go +370 -342 generated file: collapsed
M internal/adapters/web/static/js/ermite.js +5 -1
@@ -820,7 +820,8 @@
820820 // internal/adapters/web/events.go). A fragment is a top-level element with an
821821 // id: it replaces the element of the page that has the same id. A fragment
822822 // marked data-gone removes it; a row of a repository the home page does not
823-// list yet is added at the top of the list. A slot (data-slot: the place of a
823+// list yet is added at the top of the list. Activity updates also move existing
824+// home rows to the top when sorted by newest activity; CI updates stay put. A slot (data-slot: the place of a
824825 // banner, a persistent status region) keeps its element and only gets the
825826 // children of the fragment, so that screen readers announce the change. A gap,
826827 // which says the page may have missed events or that what it shows changed,
@@ -919,6 +920,9 @@ window.ermiteLive = (() => {
919920 }
920921 if (old.hasAttribute('data-cursor')) el.setAttribute('data-cursor', '');
921922 old.replaceWith(el);
923+ const rows = document.getElementById('repo-rows');
924+ if (rows && el.parentElement === rows && rows.dataset.sort === 'activity' &&
925+ rows.dataset.desc === 'true' && el.hasAttribute('data-activity')) rows.prepend(el);
922926 window.htmx?.process(el);
923927 fitIn(el);
924928 if (had) refocus(el, had);
@@ -820,7 +820,8 @@
820 // internal/adapters/web/events.go). A fragment is a top-level element with an820 // internal/adapters/web/events.go). A fragment is a top-level element with an
821 // id: it replaces the element of the page that has the same id. A fragment821 // id: it replaces the element of the page that has the same id. A fragment
822 // marked data-gone removes it; a row of a repository the home page does not822 // marked data-gone removes it; a row of a repository the home page does not
823-// list yet is added at the top of the list. A slot (data-slot: the place of a823+// list yet is added at the top of the list. Activity updates also move existing
824+// home rows to the top when sorted by newest activity; CI updates stay put. A slot (data-slot: the place of a
824 // banner, a persistent status region) keeps its element and only gets the825 // banner, a persistent status region) keeps its element and only gets the
825 // children of the fragment, so that screen readers announce the change. A gap,826 // children of the fragment, so that screen readers announce the change. A gap,
826 // which says the page may have missed events or that what it shows changed,827 // which says the page may have missed events or that what it shows changed,
@@ -919,6 +920,9 @@ window.ermiteLive = (() => {
919 }920 }
920 if (old.hasAttribute('data-cursor')) el.setAttribute('data-cursor', '');921 if (old.hasAttribute('data-cursor')) el.setAttribute('data-cursor', '');
921 old.replaceWith(el);922 old.replaceWith(el);
923+ const rows = document.getElementById('repo-rows');
924+ if (rows && el.parentElement === rows && rows.dataset.sort === 'activity' &&
925+ rows.dataset.desc === 'true' && el.hasAttribute('data-activity')) rows.prepend(el);
922 window.htmx?.process(el);926 window.htmx?.process(el);
923 fitIn(el);927 fitIn(el);
924 if (had) refocus(el, had);928 if (had) refocus(el, had);
M internal/adapters/web/view.go +3 -1
@@ -96,7 +96,7 @@ type HomeRow struct {
9696 Stats *HomeStats
9797 // Private marks a private repository; only set for a signed-in visitor.
9898 Private bool
99- // Fresh marks a row that a push just updated (live updates only).
99+ // Fresh marks a row that activity just updated (live updates only).
100100 Fresh bool
101101 // Commits are the newest of the default branch. NoCommits says there is
102102 // none (never pushed, or an empty branch); a failed read says nothing.
@@ -138,6 +138,8 @@ type SortLink struct {
138138
139139 // HomePage is the repository list.
140140 type HomePage struct {
141+ Sort string
142+ Desc bool
141143 Sorts []SortLink
142144 Rows []HomeRow
143145 }
@@ -96,7 +96,7 @@ type HomeRow struct {
96 Stats *HomeStats96 Stats *HomeStats
97 // Private marks a private repository; only set for a signed-in visitor.97 // Private marks a private repository; only set for a signed-in visitor.
98 Private bool98 Private bool
99- // Fresh marks a row that a push just updated (live updates only).99+ // Fresh marks a row that activity just updated (live updates only).
100 Fresh bool100 Fresh bool
101 // Commits are the newest of the default branch. NoCommits says there is101 // Commits are the newest of the default branch. NoCommits says there is
102 // none (never pushed, or an empty branch); a failed read says nothing.102 // none (never pushed, or an empty branch); a failed read says nothing.
@@ -138,6 +138,8 @@ type SortLink struct {
138 138
139 // HomePage is the repository list.139 // HomePage is the repository list.
140 type HomePage struct {140 type HomePage struct {
141+ Sort string
142+ Desc bool
141 Sorts []SortLink143 Sorts []SortLink
142 Rows []HomeRow144 Rows []HomeRow
143 }145 }
M internal/app/testdata/web/home.html +32 -30
@@ -61,7 +61,7 @@
6161 </div>
6262 <nav class="sort" aria-label="Sort repositories">
6363 <span class="mute">sort</span>
64-<a href="/?sort=push&amp;dir=asc" aria-current="true">last push <span aria-hidden="true">▼</span>
64+<a href="/?sort=activity&amp;dir=asc" aria-current="true">last activity <span aria-hidden="true">▼</span>
6565 <span class="sr">, descending, select for ascending</span>
6666 </a>
6767 <a href="/?sort=name&amp;dir=asc">name </a>
@@ -81,8 +81,31 @@
8181 </button>
8282 </div>
8383 </div>
84-<ul class="repos" id="repo-rows" data-rows>
85-<li id="repo-demo" class="repo" data-cursor>
84+<ul class="repos" id="repo-rows" data-rows data-sort="activity" data-desc="true">
85+<li id="repo-quiet" class="repo" data-cursor>
86+<div class="repo-main">
87+<h2 class="repo-name">
88+<a href="/quiet">quiet</a>
89+</h2>
90+<p class="repo-desc mute">no description</p>
91+<div class="repo-topics" aria-hidden="true">
92+</div>
93+<div class="repo-pulse" aria-hidden="true">
94+</div>
95+</div>
96+<div class="repo-head">
97+<dl class="repo-stats" aria-label="Figures of quiet">
98+<div class="rs-active">
99+<dt>last activity</dt>
100+<dd>
101+<time datetime="STAMP" title="STAMP">AGO</time>
102+</dd>
103+</div>
104+</dl>
105+</div>
106+<p class="repo-commits mute">no commits yet</p>
107+</li>
108+<li id="repo-demo" class="repo">
86109 <div class="repo-main">
87110 <h2 class="repo-name">
88111 <a href="/demo">demo</a>
@@ -148,27 +171,6 @@
148171 </li>
149172 </ul>
150173 </li>
151-<li id="repo-quiet" class="repo">
152-<div class="repo-main">
153-<h2 class="repo-name">
154-<a href="/quiet">quiet</a>
155-</h2>
156-<p class="repo-desc mute">no description</p>
157-<div class="repo-topics" aria-hidden="true">
158-</div>
159-<div class="repo-pulse" aria-hidden="true">
160-</div>
161-</div>
162-<div class="repo-head">
163-<dl class="repo-stats" aria-label="Figures of quiet">
164-<div class="rs-active">
165-<dt>last activity</dt>
166-<dd class="mute">never</dd>
167-</div>
168-</dl>
169-</div>
170-<p class="repo-commits mute">no commits yet</p>
171-</li>
172174 </ul>
173175 </section>
174176 </div>
@@ -343,15 +345,15 @@
343345 <span class="k">go</span>
344346 </a>
345347 </li>
346-<li id="cmd-o1" role="option" aria-selected="false" data-k="repo demo">
347-<a href="/demo" tabindex="-1">
348-<span class="lbl">demo</span>
348+<li id="cmd-o1" role="option" aria-selected="false" data-k="repo quiet">
349+<a href="/quiet" tabindex="-1">
350+<span class="lbl">quiet</span>
349351 <span class="k">repo</span>
350352 </a>
351353 </li>
352-<li id="cmd-o2" role="option" aria-selected="false" data-k="repo quiet">
353-<a href="/quiet" tabindex="-1">
354-<span class="lbl">quiet</span>
354+<li id="cmd-o2" role="option" aria-selected="false" data-k="repo demo">
355+<a href="/demo" tabindex="-1">
356+<span class="lbl">demo</span>
355357 <span class="k">repo</span>
356358 </a>
357359 </li>
@@ -61,7 +61,7 @@
61 </div>61 </div>
62 <nav class="sort" aria-label="Sort repositories">62 <nav class="sort" aria-label="Sort repositories">
63 <span class="mute">sort</span>63 <span class="mute">sort</span>
64-<a href="/?sort=push&amp;dir=asc" aria-current="true">last push <span aria-hidden="true">▼</span>64+<a href="/?sort=activity&amp;dir=asc" aria-current="true">last activity <span aria-hidden="true">▼</span>
65 <span class="sr">, descending, select for ascending</span>65 <span class="sr">, descending, select for ascending</span>
66 </a>66 </a>
67 <a href="/?sort=name&amp;dir=asc">name </a>67 <a href="/?sort=name&amp;dir=asc">name </a>
@@ -81,8 +81,31 @@
81 </button>81 </button>
82 </div>82 </div>
83 </div>83 </div>
84-<ul class="repos" id="repo-rows" data-rows>84+<ul class="repos" id="repo-rows" data-rows data-sort="activity" data-desc="true">
85-<li id="repo-demo" class="repo" data-cursor>85+<li id="repo-quiet" class="repo" data-cursor>
86+<div class="repo-main">
87+<h2 class="repo-name">
88+<a href="/quiet">quiet</a>
89+</h2>
90+<p class="repo-desc mute">no description</p>
91+<div class="repo-topics" aria-hidden="true">
92+</div>
93+<div class="repo-pulse" aria-hidden="true">
94+</div>
95+</div>
96+<div class="repo-head">
97+<dl class="repo-stats" aria-label="Figures of quiet">
98+<div class="rs-active">
99+<dt>last activity</dt>
100+<dd>
101+<time datetime="STAMP" title="STAMP">AGO</time>
102+</dd>
103+</div>
104+</dl>
105+</div>
106+<p class="repo-commits mute">no commits yet</p>
107+</li>
108+<li id="repo-demo" class="repo">
86 <div class="repo-main">109 <div class="repo-main">
87 <h2 class="repo-name">110 <h2 class="repo-name">
88 <a href="/demo">demo</a>111 <a href="/demo">demo</a>
@@ -148,27 +171,6 @@
148 </li>171 </li>
149 </ul>172 </ul>
150 </li>173 </li>
151-<li id="repo-quiet" class="repo">
152-<div class="repo-main">
153-<h2 class="repo-name">
154-<a href="/quiet">quiet</a>
155-</h2>
156-<p class="repo-desc mute">no description</p>
157-<div class="repo-topics" aria-hidden="true">
158-</div>
159-<div class="repo-pulse" aria-hidden="true">
160-</div>
161-</div>
162-<div class="repo-head">
163-<dl class="repo-stats" aria-label="Figures of quiet">
164-<div class="rs-active">
165-<dt>last activity</dt>
166-<dd class="mute">never</dd>
167-</div>
168-</dl>
169-</div>
170-<p class="repo-commits mute">no commits yet</p>
171-</li>
172 </ul>174 </ul>
173 </section>175 </section>
174 </div>176 </div>
@@ -343,15 +345,15 @@
343 <span class="k">go</span>345 <span class="k">go</span>
344 </a>346 </a>
345 </li>347 </li>
346-<li id="cmd-o1" role="option" aria-selected="false" data-k="repo demo">348+<li id="cmd-o1" role="option" aria-selected="false" data-k="repo quiet">
347-<a href="/demo" tabindex="-1">349+<a href="/quiet" tabindex="-1">
348-<span class="lbl">demo</span>350+<span class="lbl">quiet</span>
349 <span class="k">repo</span>351 <span class="k">repo</span>
350 </a>352 </a>
351 </li>353 </li>
352-<li id="cmd-o2" role="option" aria-selected="false" data-k="repo quiet">354+<li id="cmd-o2" role="option" aria-selected="false" data-k="repo demo">
353-<a href="/quiet" tabindex="-1">355+<a href="/demo" tabindex="-1">
354-<span class="lbl">quiet</span>356+<span class="lbl">demo</span>
355 <span class="k">repo</span>357 <span class="k">repo</span>
356 </a>358 </a>
357 </li>359 </li>
M internal/app/web_events_test.go +2 -2
@@ -123,7 +123,7 @@ func TestWebEventsHome(t *testing.T) {
123123 assert.Equal(t, "home-list", ev.name)
124124 assert.NotEmpty(t, ev.id)
125125 assert.Contains(t, ev.data, `<li id="repo-pub"`)
126- assert.Contains(t, ev.data, `new push`)
126+ assert.Contains(t, ev.data, `new activity`)
127127 assert.Contains(t, ev.data, `href="/pub"`)
128128 assert.Contains(t, ev.data, `<ul class="repo-commits" aria-label="Recent commits of pub">`, "the live row lists the commits too")
129129
@@ -134,7 +134,7 @@ func TestWebEventsHome(t *testing.T) {
134134 assert.Equal(t, "home-list", ev.name)
135135 assert.Contains(t, ev.data, `<li id="repo-fresh"`)
136136 assert.Contains(t, ev.data, "a &lt;b&gt;fresh&lt;/b&gt; one", "text is escaped")
137- assert.NotContains(t, ev.data, "new push")
137+ assert.NotContains(t, ev.data, "new activity")
138138
139139 // A repository that turns private is removed from the anonymous list.
140140 priv := domain.VisibilityPrivate
@@ -123,7 +123,7 @@ func TestWebEventsHome(t *testing.T) {
123 assert.Equal(t, "home-list", ev.name)123 assert.Equal(t, "home-list", ev.name)
124 assert.NotEmpty(t, ev.id)124 assert.NotEmpty(t, ev.id)
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 activity`)
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 assert.Contains(t, ev.data, `<ul class="repo-commits" aria-label="Recent commits of pub">`, "the live row lists the commits too")
129 129
@@ -134,7 +134,7 @@ func TestWebEventsHome(t *testing.T) {
134 assert.Equal(t, "home-list", ev.name)134 assert.Equal(t, "home-list", ev.name)
135 assert.Contains(t, ev.data, `<li id="repo-fresh"`)135 assert.Contains(t, ev.data, `<li id="repo-fresh"`)
136 assert.Contains(t, ev.data, "a &lt;b&gt;fresh&lt;/b&gt; one", "text is escaped")136 assert.Contains(t, ev.data, "a &lt;b&gt;fresh&lt;/b&gt; one", "text is escaped")
137- assert.NotContains(t, ev.data, "new push")137+ assert.NotContains(t, ev.data, "new activity")
138 138
139 // A repository that turns private is removed from the anonymous list.139 // A repository that turns private is removed from the anonymous list.
140 priv := domain.VisibilityPrivate140 priv := domain.VisibilityPrivate
M internal/domain/repo.go +1 -0
@@ -42,6 +42,7 @@ type Repo struct {
4242 Visibility Visibility
4343 CreatedAt time.Time
4444 PushedAt time.Time // zero until the first push
45+ ActiveAt time.Time // latest push, merge request or issue activity
4546 // Settings are the merge settings; the stores always fill them in, with
4647 // the defaults for a repository that never stored any.
4748 Settings RepoSettings
@@ -42,6 +42,7 @@ type Repo struct {
42 Visibility Visibility42 Visibility Visibility
43 CreatedAt time.Time43 CreatedAt time.Time
44 PushedAt time.Time // zero until the first push44 PushedAt time.Time // zero until the first push
45+ ActiveAt time.Time // latest push, merge request or issue activity
45 // Settings are the merge settings; the stores always fill them in, with46 // Settings are the merge settings; the stores always fill them in, with
46 // the defaults for a repository that never stored any.47 // the defaults for a repository that never stored any.
47 Settings RepoSettings48 Settings RepoSettings
M internal/ports/dto.go +1 -1
@@ -21,7 +21,7 @@ type RepoPage struct {
2121
2222 // RepoView is a repository with the Git state that belongs in its summary.
2323 type RepoView struct {
24- domain.Repo
24+ domain.Repo // includes ActiveAt, the latest repository activity
2525 // DefaultBranch is the branch HEAD points to, even when it does not exist
2626 // yet; empty when HEAD is detached or the directory is missing.
2727 DefaultBranch string
@@ -21,7 +21,7 @@ type RepoPage struct {
21 21
22 // RepoView is a repository with the Git state that belongs in its summary.22 // RepoView is a repository with the Git state that belongs in its summary.
23 type RepoView struct {23 type RepoView struct {
24- domain.Repo24+ domain.Repo // includes ActiveAt, the latest repository activity
25 // DefaultBranch is the branch HEAD points to, even when it does not exist25 // DefaultBranch is the branch HEAD points to, even when it does not exist
26 // yet; empty when HEAD is detached or the directory is missing.26 // yet; empty when HEAD is detached or the directory is missing.
27 DefaultBranch string27 DefaultBranch string
A internal/usecase/activity.go +53 -0
@@ -0,0 +1,53 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
6+ "context"
7+ "errors"
8+ "fmt"
9+ "time"
10+
11+ "github.com/bnema/zerowrap"
12+
13+ "bnema.dev/ermite/internal/domain"
14+ "bnema.dev/ermite/internal/ports"
15+)
16+
17+// recordActivity is best-effort: a metadata failure never fails the action.
18+func recordActivity(ctx context.Context, meta ports.RepoMetaStore, name domain.RepoName, at time.Time) {
19+ if _, err := touchActiveAt(ctx, meta, name, at); err != nil {
20+ log := zerowrap.FromCtx(ctx)
21+ log.Error().Err(err).Str("repo", name.String()).Msg("record repository activity")
22+ }
23+}
24+
25+// touchActiveAt preserves later activity and retries concurrent metadata writes.
26+// A repository deleted meanwhile is not an error.
27+func touchActiveAt(ctx context.Context, meta ports.RepoMetaStore, name domain.RepoName, now time.Time) (domain.Repo, error) {
28+ for range pushRetries {
29+ repo, err := meta.Get(ctx, name)
30+ if errors.Is(err, domain.ErrNotFound) {
31+ return domain.Repo{}, nil
32+ }
33+ if err != nil {
34+ return domain.Repo{}, fmt.Errorf("get repository %q: %w", name, err)
35+ }
36+ if !now.After(repo.ActiveAt) {
37+ return repo, nil // a later activity already recorded
38+ }
39+ repo.ActiveAt = now
40+ err = meta.Update(ctx, repo)
41+ if errors.Is(err, domain.ErrConflict) {
42+ continue
43+ }
44+ if errors.Is(err, domain.ErrNotFound) {
45+ return domain.Repo{}, nil
46+ }
47+ if err != nil {
48+ return repo, fmt.Errorf("update repository %q: %w", name, err)
49+ }
50+ return repo, nil
51+ }
52+ return domain.Repo{}, fmt.Errorf("update active_at of %q: %w", name, domain.ErrConflict)
53+}
@@ -0,0 +1,53 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
6+ "context"
7+ "errors"
8+ "fmt"
9+ "time"
10+
11+ "github.com/bnema/zerowrap"
12+
13+ "bnema.dev/ermite/internal/domain"
14+ "bnema.dev/ermite/internal/ports"
15+)
16+
17+// recordActivity is best-effort: a metadata failure never fails the action.
18+func recordActivity(ctx context.Context, meta ports.RepoMetaStore, name domain.RepoName, at time.Time) {
19+ if _, err := touchActiveAt(ctx, meta, name, at); err != nil {
20+ log := zerowrap.FromCtx(ctx)
21+ log.Error().Err(err).Str("repo", name.String()).Msg("record repository activity")
22+ }
23+}
24+
25+// touchActiveAt preserves later activity and retries concurrent metadata writes.
26+// A repository deleted meanwhile is not an error.
27+func touchActiveAt(ctx context.Context, meta ports.RepoMetaStore, name domain.RepoName, now time.Time) (domain.Repo, error) {
28+ for range pushRetries {
29+ repo, err := meta.Get(ctx, name)
30+ if errors.Is(err, domain.ErrNotFound) {
31+ return domain.Repo{}, nil
32+ }
33+ if err != nil {
34+ return domain.Repo{}, fmt.Errorf("get repository %q: %w", name, err)
35+ }
36+ if !now.After(repo.ActiveAt) {
37+ return repo, nil // a later activity already recorded
38+ }
39+ repo.ActiveAt = now
40+ err = meta.Update(ctx, repo)
41+ if errors.Is(err, domain.ErrConflict) {
42+ continue
43+ }
44+ if errors.Is(err, domain.ErrNotFound) {
45+ return domain.Repo{}, nil
46+ }
47+ if err != nil {
48+ return repo, fmt.Errorf("update repository %q: %w", name, err)
49+ }
50+ return repo, nil
51+ }
52+ return domain.Repo{}, fmt.Errorf("update active_at of %q: %w", name, domain.ErrConflict)
53+}
A internal/usecase/activity_test.go +42 -0
@@ -0,0 +1,42 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
6+ "errors"
7+ "testing"
8+ "time"
9+
10+ "github.com/stretchr/testify/mock"
11+ "github.com/stretchr/testify/require"
12+
13+ "bnema.dev/ermite/internal/domain"
14+ "bnema.dev/ermite/internal/ports/mocks"
15+)
16+
17+func TestTouchActiveAt(t *testing.T) {
18+ name, err := domain.ParseRepoName("proj")
19+ require.NoError(t, err)
20+ now := time.Now().UTC()
21+ t.Run("retries and preserves later activity", func(t *testing.T) {
22+ meta := mocks.NewRepoMetaStore(t)
23+ meta.EXPECT().Get(mock.Anything, name).Return(domain.Repo{Name: name}, nil).Once()
24+ meta.EXPECT().Update(mock.Anything, mock.MatchedBy(func(r domain.Repo) bool { return r.ActiveAt.Equal(now) && r.PushedAt.IsZero() })).Return(domain.ErrConflict).Once()
25+ later := domain.Repo{Name: name, ActiveAt: now.Add(time.Hour)}
26+ meta.EXPECT().Get(mock.Anything, name).Return(later, nil).Once()
27+ got, err := touchActiveAt(t.Context(), meta, name, now)
28+ require.NoError(t, err)
29+ require.Equal(t, later, got)
30+ })
31+ t.Run("deleted repository", func(t *testing.T) {
32+ meta := mocks.NewRepoMetaStore(t)
33+ meta.EXPECT().Get(mock.Anything, name).Return(domain.Repo{}, domain.ErrNotFound)
34+ _, err := touchActiveAt(t.Context(), meta, name, now)
35+ require.NoError(t, err)
36+ })
37+ t.Run("best effort failure", func(t *testing.T) {
38+ meta := mocks.NewRepoMetaStore(t)
39+ meta.EXPECT().Get(mock.Anything, name).Return(domain.Repo{}, errors.New("offline"))
40+ require.NotPanics(t, func() { recordActivity(t.Context(), meta, name, now) })
41+ })
42+}
@@ -0,0 +1,42 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
6+ "errors"
7+ "testing"
8+ "time"
9+
10+ "github.com/stretchr/testify/mock"
11+ "github.com/stretchr/testify/require"
12+
13+ "bnema.dev/ermite/internal/domain"
14+ "bnema.dev/ermite/internal/ports/mocks"
15+)
16+
17+func TestTouchActiveAt(t *testing.T) {
18+ name, err := domain.ParseRepoName("proj")
19+ require.NoError(t, err)
20+ now := time.Now().UTC()
21+ t.Run("retries and preserves later activity", func(t *testing.T) {
22+ meta := mocks.NewRepoMetaStore(t)
23+ meta.EXPECT().Get(mock.Anything, name).Return(domain.Repo{Name: name}, nil).Once()
24+ meta.EXPECT().Update(mock.Anything, mock.MatchedBy(func(r domain.Repo) bool { return r.ActiveAt.Equal(now) && r.PushedAt.IsZero() })).Return(domain.ErrConflict).Once()
25+ later := domain.Repo{Name: name, ActiveAt: now.Add(time.Hour)}
26+ meta.EXPECT().Get(mock.Anything, name).Return(later, nil).Once()
27+ got, err := touchActiveAt(t.Context(), meta, name, now)
28+ require.NoError(t, err)
29+ require.Equal(t, later, got)
30+ })
31+ t.Run("deleted repository", func(t *testing.T) {
32+ meta := mocks.NewRepoMetaStore(t)
33+ meta.EXPECT().Get(mock.Anything, name).Return(domain.Repo{}, domain.ErrNotFound)
34+ _, err := touchActiveAt(t.Context(), meta, name, now)
35+ require.NoError(t, err)
36+ })
37+ t.Run("best effort failure", func(t *testing.T) {
38+ meta := mocks.NewRepoMetaStore(t)
39+ meta.EXPECT().Get(mock.Anything, name).Return(domain.Repo{}, errors.New("offline"))
40+ require.NotPanics(t, func() { recordActivity(t.Context(), meta, name, now) })
41+ })
42+}
M internal/usecase/issues.go +3 -0
@@ -124,6 +124,7 @@ func (i *Issues) Create(ctx context.Context, p domain.Principal, name domain.Rep
124124 log := zerowrap.FromCtx(ctx)
125125 log.Info().Str("repo", name.String()).Int64("number", issue.Number).
126126 Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("issue opened")
127+ recordActivity(ctx, i.meta, repo.Name, now)
127128 i.bus.Publish(ctx, domain.IssueOpened{Issue: issue, At: now, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
128129 return issue, nil
129130 }
@@ -211,6 +212,7 @@ func (i *Issues) AddComment(ctx context.Context, p domain.Principal, name domain
211212 log := zerowrap.FromCtx(ctx)
212213 log.Info().Str("repo", name.String()).Int64("number", number).Int64("comment", c.ID).
213214 Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("issue comment added")
215+ recordActivity(ctx, i.meta, name, c.CreatedAt)
214216 i.bus.Publish(ctx, domain.IssueCommented{
215217 Repo: name, Number: number, Comment: c, At: c.CreatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt,
216218 })
@@ -260,6 +262,7 @@ func (i *Issues) transition(ctx context.Context, p domain.Principal, name domain
260262 log := zerowrap.FromCtx(ctx)
261263 log.Info().Str("repo", name.String()).Int64("number", number).
262264 Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("issue " + verb + "d")
265+ recordActivity(ctx, i.meta, repo.Name, issue.UpdatedAt)
263266 if to == domain.IssueStateOpen {
264267 i.bus.Publish(ctx, domain.IssueReopened{Issue: issue, At: issue.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
265268 } else {
@@ -124,6 +124,7 @@ func (i *Issues) Create(ctx context.Context, p domain.Principal, name domain.Rep
124 log := zerowrap.FromCtx(ctx)124 log := zerowrap.FromCtx(ctx)
125 log.Info().Str("repo", name.String()).Int64("number", issue.Number).125 log.Info().Str("repo", name.String()).Int64("number", issue.Number).
126 Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("issue opened")126 Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("issue opened")
127+ recordActivity(ctx, i.meta, repo.Name, now)
127 i.bus.Publish(ctx, domain.IssueOpened{Issue: issue, At: now, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})128 i.bus.Publish(ctx, domain.IssueOpened{Issue: issue, At: now, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
128 return issue, nil129 return issue, nil
129 }130 }
@@ -211,6 +212,7 @@ func (i *Issues) AddComment(ctx context.Context, p domain.Principal, name domain
211 log := zerowrap.FromCtx(ctx)212 log := zerowrap.FromCtx(ctx)
212 log.Info().Str("repo", name.String()).Int64("number", number).Int64("comment", c.ID).213 log.Info().Str("repo", name.String()).Int64("number", number).Int64("comment", c.ID).
213 Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("issue comment added")214 Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("issue comment added")
215+ recordActivity(ctx, i.meta, name, c.CreatedAt)
214 i.bus.Publish(ctx, domain.IssueCommented{216 i.bus.Publish(ctx, domain.IssueCommented{
215 Repo: name, Number: number, Comment: c, At: c.CreatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt,217 Repo: name, Number: number, Comment: c, At: c.CreatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt,
216 })218 })
@@ -260,6 +262,7 @@ func (i *Issues) transition(ctx context.Context, p domain.Principal, name domain
260 log := zerowrap.FromCtx(ctx)262 log := zerowrap.FromCtx(ctx)
261 log.Info().Str("repo", name.String()).Int64("number", number).263 log.Info().Str("repo", name.String()).Int64("number", number).
262 Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("issue " + verb + "d")264 Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("issue " + verb + "d")
265+ recordActivity(ctx, i.meta, repo.Name, issue.UpdatedAt)
263 if to == domain.IssueStateOpen {266 if to == domain.IssueStateOpen {
264 i.bus.Publish(ctx, domain.IssueReopened{Issue: issue, At: issue.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})267 i.bus.Publish(ctx, domain.IssueReopened{Issue: issue, At: issue.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
265 } else {268 } else {
M internal/usecase/issues_integration_test.go +4 -0
@@ -46,6 +46,10 @@ func TestIssuesOverTheStore(t *testing.T) {
4646 require.NoError(t, err)
4747 assert.EqualValues(t, 2, issue.Number, "the number follows the merge request")
4848 assert.Equal(t, "bot", issue.Author)
49+ repo, err := store.Get(ctx, n)
50+ require.NoError(t, err)
51+ assert.True(t, repo.ActiveAt.Equal(now))
52+ assert.True(t, repo.PushedAt.IsZero())
4953
5054 kind, err := issues.Kind(ctx, domain.Anonymous(), n, 1)
5155 require.NoError(t, err)
@@ -46,6 +46,10 @@ func TestIssuesOverTheStore(t *testing.T) {
46 require.NoError(t, err)46 require.NoError(t, err)
47 assert.EqualValues(t, 2, issue.Number, "the number follows the merge request")47 assert.EqualValues(t, 2, issue.Number, "the number follows the merge request")
48 assert.Equal(t, "bot", issue.Author)48 assert.Equal(t, "bot", issue.Author)
49+ repo, err := store.Get(ctx, n)
50+ require.NoError(t, err)
51+ assert.True(t, repo.ActiveAt.Equal(now))
52+ assert.True(t, repo.PushedAt.IsZero())
49 53
50 kind, err := issues.Kind(ctx, domain.Anonymous(), n, 1)54 kind, err := issues.Kind(ctx, domain.Anonymous(), n, 1)
51 require.NoError(t, err)55 require.NoError(t, err)
M internal/usecase/issues_test.go +2 -0
@@ -39,6 +39,7 @@ func newIssueUnit(t *testing.T, vis domain.Visibility, mode domain.IssuesMode) *
3939 clock := mocks.NewClock(t)
4040 clock.EXPECT().Now().Return(now).Maybe()
4141 u.meta.EXPECT().Get(mock.Anything, u.repo.Name).Return(u.repo, nil).Maybe()
42+ u.meta.EXPECT().Update(mock.Anything, mock.MatchedBy(func(r domain.Repo) bool { return r.Name == u.repo.Name && r.ActiveAt.Equal(now) && r.PushedAt.IsZero() })).Return(nil).Maybe()
4243 u.issues = usecase.NewIssues(usecase.IssuesConfig{Meta: u.meta, Store: u.store, MergeRequests: u.mrs, Bus: u.bus, Clock: clock})
4344 return u
4445 }
@@ -116,6 +117,7 @@ func TestIssueCreateAndTransitions(t *testing.T) {
116117 got, err := u.issues.Create(ctx, p, u.repo.Name, ports.CreateIssueParams{Title: " Crash ", Body: "why"})
117118 require.NoError(t, err)
118119 assert.EqualValues(t, 7, got.Number)
120+ u.meta.AssertCalled(t, "Update", mock.Anything, mock.MatchedBy(func(r domain.Repo) bool { return r.ActiveAt.Equal(now) && r.PushedAt.IsZero() }))
119121 })
120122
121123 t.Run("invalid text stores nothing", func(t *testing.T) {
@@ -39,6 +39,7 @@ func newIssueUnit(t *testing.T, vis domain.Visibility, mode domain.IssuesMode) *
39 clock := mocks.NewClock(t)39 clock := mocks.NewClock(t)
40 clock.EXPECT().Now().Return(now).Maybe()40 clock.EXPECT().Now().Return(now).Maybe()
41 u.meta.EXPECT().Get(mock.Anything, u.repo.Name).Return(u.repo, nil).Maybe()41 u.meta.EXPECT().Get(mock.Anything, u.repo.Name).Return(u.repo, nil).Maybe()
42+ u.meta.EXPECT().Update(mock.Anything, mock.MatchedBy(func(r domain.Repo) bool { return r.Name == u.repo.Name && r.ActiveAt.Equal(now) && r.PushedAt.IsZero() })).Return(nil).Maybe()
42 u.issues = usecase.NewIssues(usecase.IssuesConfig{Meta: u.meta, Store: u.store, MergeRequests: u.mrs, Bus: u.bus, Clock: clock})43 u.issues = usecase.NewIssues(usecase.IssuesConfig{Meta: u.meta, Store: u.store, MergeRequests: u.mrs, Bus: u.bus, Clock: clock})
43 return u44 return u
44 }45 }
@@ -116,6 +117,7 @@ func TestIssueCreateAndTransitions(t *testing.T) {
116 got, err := u.issues.Create(ctx, p, u.repo.Name, ports.CreateIssueParams{Title: " Crash ", Body: "why"})117 got, err := u.issues.Create(ctx, p, u.repo.Name, ports.CreateIssueParams{Title: " Crash ", Body: "why"})
117 require.NoError(t, err)118 require.NoError(t, err)
118 assert.EqualValues(t, 7, got.Number)119 assert.EqualValues(t, 7, got.Number)
120+ u.meta.AssertCalled(t, "Update", mock.Anything, mock.MatchedBy(func(r domain.Repo) bool { return r.ActiveAt.Equal(now) && r.PushedAt.IsZero() }))
119 })121 })
120 122
121 t.Run("invalid text stores nothing", func(t *testing.T) {123 t.Run("invalid text stores nothing", func(t *testing.T) {
M internal/usecase/mergerequests.go +5 -0
@@ -254,6 +254,7 @@ func (m *MergeRequests) Create(ctx context.Context, p domain.Principal, name dom
254254 if err != nil {
255255 return domain.MergeRequest{}, err
256256 }
257+ recordActivity(ctx, m.meta, repo.Name, now)
257258 m.bus.Publish(ctx, domain.MergeRequestOpened{MergeRequest: mr, At: now, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
258259 if m.pulls != nil {
259260 // The commits the opening is for are the ones of now.
@@ -377,6 +378,7 @@ func (m *MergeRequests) MarkReady(ctx context.Context, p domain.Principal, name
377378 if err != nil {
378379 return domain.MergeRequest{}, fmt.Errorf("mark !%d of %q ready: %w", number, name, err)
379380 }
381+ recordActivity(ctx, m.meta, repo.Name, mr.UpdatedAt)
380382 m.bus.Publish(ctx, domain.MergeRequestReady{MergeRequest: mr, At: mr.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
381383 return m.withHead(ctx, mr)
382384 }
@@ -656,6 +658,7 @@ func (m *MergeRequests) AddComment(ctx context.Context, p domain.Principal, name
656658 log := zerowrap.FromCtx(ctx)
657659 log.Info().Str("repo", name.String()).Int64("number", number).Int64("comment", c.ID).
658660 Int64("reply_to", c.ReplyTo).Str("verdict", c.Verdict.String()).Bool("on_line", !general).Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("comment added")
661+ recordActivity(ctx, m.meta, name, c.CreatedAt)
659662 m.bus.Publish(ctx, domain.CommentAdded{
660663 Repo: name, Number: number, Comment: c, At: c.CreatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt,
661664 })
@@ -796,6 +799,7 @@ func (m *MergeRequests) Close(ctx context.Context, p domain.Principal, name doma
796799 log := zerowrap.FromCtx(ctx)
797800 log.Info().Str("repo", name.String()).Int64("number", number).
798801 Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("merge request closed")
802+ recordActivity(ctx, m.meta, repo.Name, mr.UpdatedAt)
799803 m.bus.Publish(ctx, domain.MergeRequestClosed{MergeRequest: mr, At: mr.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
800804 if m.pulls != nil {
801805 m.pulls.OnPullRequest(ctx, name, ports.PullRequestEvent{Number: number, Action: domain.PullClosed, Head: mr.HeadID, Base: mr.BaseID}, p)
@@ -998,6 +1002,7 @@ func (m *MergeRequests) merge(ctx context.Context, p domain.Principal, name doma
9981002 }
9991003 }
10001004 at := mr.UpdatedAt
1005+ recordActivity(ctx, m.meta, repo.Name, at)
10011006 m.bus.Publish(ctx, domain.MergeRequestMerged{MergeRequest: mr, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
10021007 m.bus.Publish(ctx, domain.PushEvent{
10031008 Repo: name, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt, Updates: updates,
@@ -254,6 +254,7 @@ func (m *MergeRequests) Create(ctx context.Context, p domain.Principal, name dom
254 if err != nil {254 if err != nil {
255 return domain.MergeRequest{}, err255 return domain.MergeRequest{}, err
256 }256 }
257+ recordActivity(ctx, m.meta, repo.Name, now)
257 m.bus.Publish(ctx, domain.MergeRequestOpened{MergeRequest: mr, At: now, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})258 m.bus.Publish(ctx, domain.MergeRequestOpened{MergeRequest: mr, At: now, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
258 if m.pulls != nil {259 if m.pulls != nil {
259 // The commits the opening is for are the ones of now.260 // The commits the opening is for are the ones of now.
@@ -377,6 +378,7 @@ func (m *MergeRequests) MarkReady(ctx context.Context, p domain.Principal, name
377 if err != nil {378 if err != nil {
378 return domain.MergeRequest{}, fmt.Errorf("mark !%d of %q ready: %w", number, name, err)379 return domain.MergeRequest{}, fmt.Errorf("mark !%d of %q ready: %w", number, name, err)
379 }380 }
381+ recordActivity(ctx, m.meta, repo.Name, mr.UpdatedAt)
380 m.bus.Publish(ctx, domain.MergeRequestReady{MergeRequest: mr, At: mr.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})382 m.bus.Publish(ctx, domain.MergeRequestReady{MergeRequest: mr, At: mr.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
381 return m.withHead(ctx, mr)383 return m.withHead(ctx, mr)
382 }384 }
@@ -656,6 +658,7 @@ func (m *MergeRequests) AddComment(ctx context.Context, p domain.Principal, name
656 log := zerowrap.FromCtx(ctx)658 log := zerowrap.FromCtx(ctx)
657 log.Info().Str("repo", name.String()).Int64("number", number).Int64("comment", c.ID).659 log.Info().Str("repo", name.String()).Int64("number", number).Int64("comment", c.ID).
658 Int64("reply_to", c.ReplyTo).Str("verdict", c.Verdict.String()).Bool("on_line", !general).Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("comment added")660 Int64("reply_to", c.ReplyTo).Str("verdict", c.Verdict.String()).Bool("on_line", !general).Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("comment added")
661+ recordActivity(ctx, m.meta, name, c.CreatedAt)
659 m.bus.Publish(ctx, domain.CommentAdded{662 m.bus.Publish(ctx, domain.CommentAdded{
660 Repo: name, Number: number, Comment: c, At: c.CreatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt,663 Repo: name, Number: number, Comment: c, At: c.CreatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt,
661 })664 })
@@ -796,6 +799,7 @@ func (m *MergeRequests) Close(ctx context.Context, p domain.Principal, name doma
796 log := zerowrap.FromCtx(ctx)799 log := zerowrap.FromCtx(ctx)
797 log.Info().Str("repo", name.String()).Int64("number", number).800 log.Info().Str("repo", name.String()).Int64("number", number).
798 Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("merge request closed")801 Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("merge request closed")
802+ recordActivity(ctx, m.meta, repo.Name, mr.UpdatedAt)
799 m.bus.Publish(ctx, domain.MergeRequestClosed{MergeRequest: mr, At: mr.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})803 m.bus.Publish(ctx, domain.MergeRequestClosed{MergeRequest: mr, At: mr.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
800 if m.pulls != nil {804 if m.pulls != nil {
801 m.pulls.OnPullRequest(ctx, name, ports.PullRequestEvent{Number: number, Action: domain.PullClosed, Head: mr.HeadID, Base: mr.BaseID}, p)805 m.pulls.OnPullRequest(ctx, name, ports.PullRequestEvent{Number: number, Action: domain.PullClosed, Head: mr.HeadID, Base: mr.BaseID}, p)
@@ -998,6 +1002,7 @@ func (m *MergeRequests) merge(ctx context.Context, p domain.Principal, name doma
998 }1002 }
999 }1003 }
1000 at := mr.UpdatedAt1004 at := mr.UpdatedAt
1005+ recordActivity(ctx, m.meta, repo.Name, at)
1001 m.bus.Publish(ctx, domain.MergeRequestMerged{MergeRequest: mr, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})1006 m.bus.Publish(ctx, domain.MergeRequestMerged{MergeRequest: mr, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
1002 m.bus.Publish(ctx, domain.PushEvent{1007 m.bus.Publish(ctx, domain.PushEvent{
1003 Repo: name, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt, Updates: updates,1008 Repo: name, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt, Updates: updates,
M internal/usecase/mergerequests_integration_test.go +11 -0
@@ -1448,3 +1448,14 @@ func TestNoteThreadsRangesAndVerdicts(t *testing.T) {
14481448 _, err = r.mrs.DeleteComment(r.ctx, root, r.repo, mr.Number, changes.ID)
14491449 assert.ErrorIs(t, err, domain.ErrPreconditionFailed)
14501450 }
1451+
1452+func TestMergeRequestRecordsRepositoryActivity(t *testing.T) {
1453+ r := newMRRig(t)
1454+ r.seed()
1455+ r.feature("activity")
1456+ _, err := r.mrs.Create(r.ctx, root, r.repo, ports.CreateMergeRequestParams{Title: "Activity", Source: "activity", Target: "main"})
1457+ require.NoError(t, err)
1458+ repo, err := r.store.Get(r.ctx, r.repo)
1459+ require.NoError(t, err)
1460+ assert.True(t, repo.ActiveAt.Equal(now))
1461+}
@@ -1448,3 +1448,14 @@ func TestNoteThreadsRangesAndVerdicts(t *testing.T) {
1448 _, err = r.mrs.DeleteComment(r.ctx, root, r.repo, mr.Number, changes.ID)1448 _, err = r.mrs.DeleteComment(r.ctx, root, r.repo, mr.Number, changes.ID)
1449 assert.ErrorIs(t, err, domain.ErrPreconditionFailed)1449 assert.ErrorIs(t, err, domain.ErrPreconditionFailed)
1450 }1450 }
1451+
1452+func TestMergeRequestRecordsRepositoryActivity(t *testing.T) {
1453+ r := newMRRig(t)
1454+ r.seed()
1455+ r.feature("activity")
1456+ _, err := r.mrs.Create(r.ctx, root, r.repo, ports.CreateMergeRequestParams{Title: "Activity", Source: "activity", Target: "main"})
1457+ require.NoError(t, err)
1458+ repo, err := r.store.Get(r.ctx, r.repo)
1459+ require.NoError(t, err)
1460+ assert.True(t, repo.ActiveAt.Equal(now))
1461+}
M internal/usecase/pushes.go +10 -4
@@ -209,7 +209,7 @@ func (u *Pushes) checkWorkflows(ctx context.Context, name domain.RepoName, updat
209209 return nil
210210 }
211211
212-// Record is the post-receive step of an accepted push: it sets pushed_at,
212+// Record is the post-receive step of an accepted push: it sets pushed_at and active_at,
213213 // repairs HEAD when it points to a missing branch, tells the reader that the
214214 // repository changed, queues the workflows the push triggers, publishes a
215215 // PushEvent and schedules maintenance. Every
@@ -245,7 +245,8 @@ func (u *Pushes) Record(ctx context.Context, p domain.Principal, name domain.Rep
245245 return errors.Join(errs...)
246246 }
247247
248-// touchPushedAt stores pushed_at, retrying when another write got in between.
248+// touchPushedAt stores pushed_at and active_at, preserving later timestamps
249+// and retrying when another write got in between.
249250 // A repository deleted meanwhile is not an error. It returns the repository
250251 // as read, or the zero value when it is unknown. A push (Pushes.Record) and a
251252 // merge, which moves a branch too, both record through it.
@@ -258,10 +259,15 @@ func touchPushedAt(ctx context.Context, meta ports.RepoMetaStore, name domain.Re
258259 if err != nil {
259260 return domain.Repo{}, fmt.Errorf("get repository %q: %w", name, err)
260261 }
261- if !now.After(repo.PushedAt) {
262+ if !now.After(repo.PushedAt) && !now.After(repo.ActiveAt) {
262263 return repo, nil // a later push already recorded
263264 }
264- repo.PushedAt = now
265+ if now.After(repo.PushedAt) {
266+ repo.PushedAt = now
267+ }
268+ if now.After(repo.ActiveAt) {
269+ repo.ActiveAt = now
270+ }
265271 err = meta.Update(ctx, repo)
266272 if errors.Is(err, domain.ErrConflict) {
267273 continue
@@ -209,7 +209,7 @@ func (u *Pushes) checkWorkflows(ctx context.Context, name domain.RepoName, updat
209 return nil209 return nil
210 }210 }
211 211
212-// Record is the post-receive step of an accepted push: it sets pushed_at,212+// Record is the post-receive step of an accepted push: it sets pushed_at and active_at,
213 // repairs HEAD when it points to a missing branch, tells the reader that the213 // repairs HEAD when it points to a missing branch, tells the reader that the
214 // repository changed, queues the workflows the push triggers, publishes a214 // repository changed, queues the workflows the push triggers, publishes a
215 // PushEvent and schedules maintenance. Every215 // PushEvent and schedules maintenance. Every
@@ -245,7 +245,8 @@ func (u *Pushes) Record(ctx context.Context, p domain.Principal, name domain.Rep
245 return errors.Join(errs...)245 return errors.Join(errs...)
246 }246 }
247 247
248-// touchPushedAt stores pushed_at, retrying when another write got in between.248+// touchPushedAt stores pushed_at and active_at, preserving later timestamps
249+// and retrying when another write got in between.
249 // A repository deleted meanwhile is not an error. It returns the repository250 // A repository deleted meanwhile is not an error. It returns the repository
250 // as read, or the zero value when it is unknown. A push (Pushes.Record) and a251 // as read, or the zero value when it is unknown. A push (Pushes.Record) and a
251 // merge, which moves a branch too, both record through it.252 // merge, which moves a branch too, both record through it.
@@ -258,10 +259,15 @@ func touchPushedAt(ctx context.Context, meta ports.RepoMetaStore, name domain.Re
258 if err != nil {259 if err != nil {
259 return domain.Repo{}, fmt.Errorf("get repository %q: %w", name, err)260 return domain.Repo{}, fmt.Errorf("get repository %q: %w", name, err)
260 }261 }
261- if !now.After(repo.PushedAt) {262+ if !now.After(repo.PushedAt) && !now.After(repo.ActiveAt) {
262 return repo, nil // a later push already recorded263 return repo, nil // a later push already recorded
263 }264 }
264- repo.PushedAt = now265+ if now.After(repo.PushedAt) {
266+ repo.PushedAt = now
267+ }
268+ if now.After(repo.ActiveAt) {
269+ repo.ActiveAt = now
270+ }
265 err = meta.Update(ctx, repo)271 err = meta.Update(ctx, repo)
266 if errors.Is(err, domain.ErrConflict) {272 if errors.Is(err, domain.ErrConflict) {
267 continue273 continue
M internal/usecase/pushes_test.go +4 -4
@@ -306,7 +306,7 @@ func TestPushesRecord(t *testing.T) {
306306 f.sched.EXPECT().Schedule(ctx, n).Return().Once()
307307 repo := domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, CreatedAt: now.Add(-time.Hour)}
308308 f.meta.EXPECT().Get(ctx, n).Return(repo, nil)
309- f.meta.EXPECT().Update(ctx, domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, CreatedAt: repo.CreatedAt, PushedAt: now}).Return(nil)
309+ f.meta.EXPECT().Update(ctx, domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, CreatedAt: repo.CreatedAt, PushedAt: now, ActiveAt: now}).Return(nil)
310310 f.git.EXPECT().DefaultBranch(ctx, n).Return("main", true, nil)
311311 f.bus.EXPECT().Publish(ctx, domain.PushEvent{Repo: n, Updates: ups, At: now, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt}).Return()
312312
@@ -334,7 +334,7 @@ func TestPushesRecord(t *testing.T) {
334334 repo := domain.Repo{Name: n}
335335 f.meta.EXPECT().Get(ctx, n).Return(repo, nil).Once()
336336 f.meta.EXPECT().Update(ctx, mock.Anything).Return(domain.ErrConflict).Once()
337- later := domain.Repo{Name: n, PushedAt: now.Add(time.Second)}
337+ later := domain.Repo{Name: n, PushedAt: now.Add(time.Second), ActiveAt: now.Add(time.Second)}
338338 f.meta.EXPECT().Get(ctx, n).Return(later, nil).Once() // a later push recorded meanwhile
339339 f.git.EXPECT().DefaultBranch(ctx, n).Return("main", true, nil)
340340 f.bus.EXPECT().Publish(ctx, mock.Anything).Return()
@@ -414,7 +414,7 @@ func TestPushesDefaultBranchRule(t *testing.T) {
414414 t.Parallel()
415415 f := newPushFixture(t)
416416 f.sched.EXPECT().Schedule(ctx, n).Return().Once()
417- f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{Name: n, PushedAt: now}, nil)
417+ f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{Name: n, PushedAt: now, ActiveAt: now}, nil)
418418 f.git.EXPECT().DefaultBranch(ctx, n).Return(tt.head, tt.exists, nil)
419419 if tt.want != "" {
420420 f.git.EXPECT().SetDefaultBranch(ctx, n, tt.want).Return(nil)
@@ -429,7 +429,7 @@ func TestPushesDefaultBranchRule(t *testing.T) {
429429 f := newPushFixture(t)
430430 f.sched.EXPECT().Schedule(ctx, n).Return().Once()
431431 boom := errors.New("boom")
432- f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{Name: n, PushedAt: now}, nil)
432+ f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{Name: n, PushedAt: now, ActiveAt: now}, nil)
433433 f.git.EXPECT().DefaultBranch(ctx, n).Return("main", false, nil)
434434 f.git.EXPECT().SetDefaultBranch(ctx, n, "dev").Return(boom)
435435 f.bus.EXPECT().Publish(ctx, mock.Anything).Return()
@@ -306,7 +306,7 @@ func TestPushesRecord(t *testing.T) {
306 f.sched.EXPECT().Schedule(ctx, n).Return().Once()306 f.sched.EXPECT().Schedule(ctx, n).Return().Once()
307 repo := domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, CreatedAt: now.Add(-time.Hour)}307 repo := domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, CreatedAt: now.Add(-time.Hour)}
308 f.meta.EXPECT().Get(ctx, n).Return(repo, nil)308 f.meta.EXPECT().Get(ctx, n).Return(repo, nil)
309- f.meta.EXPECT().Update(ctx, domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, CreatedAt: repo.CreatedAt, PushedAt: now}).Return(nil)309+ f.meta.EXPECT().Update(ctx, domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, CreatedAt: repo.CreatedAt, PushedAt: now, ActiveAt: now}).Return(nil)
310 f.git.EXPECT().DefaultBranch(ctx, n).Return("main", true, nil)310 f.git.EXPECT().DefaultBranch(ctx, n).Return("main", true, nil)
311 f.bus.EXPECT().Publish(ctx, domain.PushEvent{Repo: n, Updates: ups, At: now, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt}).Return()311 f.bus.EXPECT().Publish(ctx, domain.PushEvent{Repo: n, Updates: ups, At: now, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt}).Return()
312 312
@@ -334,7 +334,7 @@ func TestPushesRecord(t *testing.T) {
334 repo := domain.Repo{Name: n}334 repo := domain.Repo{Name: n}
335 f.meta.EXPECT().Get(ctx, n).Return(repo, nil).Once()335 f.meta.EXPECT().Get(ctx, n).Return(repo, nil).Once()
336 f.meta.EXPECT().Update(ctx, mock.Anything).Return(domain.ErrConflict).Once()336 f.meta.EXPECT().Update(ctx, mock.Anything).Return(domain.ErrConflict).Once()
337- later := domain.Repo{Name: n, PushedAt: now.Add(time.Second)}337+ later := domain.Repo{Name: n, PushedAt: now.Add(time.Second), ActiveAt: now.Add(time.Second)}
338 f.meta.EXPECT().Get(ctx, n).Return(later, nil).Once() // a later push recorded meanwhile338 f.meta.EXPECT().Get(ctx, n).Return(later, nil).Once() // a later push recorded meanwhile
339 f.git.EXPECT().DefaultBranch(ctx, n).Return("main", true, nil)339 f.git.EXPECT().DefaultBranch(ctx, n).Return("main", true, nil)
340 f.bus.EXPECT().Publish(ctx, mock.Anything).Return()340 f.bus.EXPECT().Publish(ctx, mock.Anything).Return()
@@ -414,7 +414,7 @@ func TestPushesDefaultBranchRule(t *testing.T) {
414 t.Parallel()414 t.Parallel()
415 f := newPushFixture(t)415 f := newPushFixture(t)
416 f.sched.EXPECT().Schedule(ctx, n).Return().Once()416 f.sched.EXPECT().Schedule(ctx, n).Return().Once()
417- f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{Name: n, PushedAt: now}, nil)417+ f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{Name: n, PushedAt: now, ActiveAt: now}, nil)
418 f.git.EXPECT().DefaultBranch(ctx, n).Return(tt.head, tt.exists, nil)418 f.git.EXPECT().DefaultBranch(ctx, n).Return(tt.head, tt.exists, nil)
419 if tt.want != "" {419 if tt.want != "" {
420 f.git.EXPECT().SetDefaultBranch(ctx, n, tt.want).Return(nil)420 f.git.EXPECT().SetDefaultBranch(ctx, n, tt.want).Return(nil)
@@ -429,7 +429,7 @@ func TestPushesDefaultBranchRule(t *testing.T) {
429 f := newPushFixture(t)429 f := newPushFixture(t)
430 f.sched.EXPECT().Schedule(ctx, n).Return().Once()430 f.sched.EXPECT().Schedule(ctx, n).Return().Once()
431 boom := errors.New("boom")431 boom := errors.New("boom")
432- f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{Name: n, PushedAt: now}, nil)432+ f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{Name: n, PushedAt: now, ActiveAt: now}, nil)
433 f.git.EXPECT().DefaultBranch(ctx, n).Return("main", false, nil)433 f.git.EXPECT().DefaultBranch(ctx, n).Return("main", false, nil)
434 f.git.EXPECT().SetDefaultBranch(ctx, n, "dev").Return(boom)434 f.git.EXPECT().SetDefaultBranch(ctx, n, "dev").Return(boom)
435 f.bus.EXPECT().Publish(ctx, mock.Anything).Return()435 f.bus.EXPECT().Publish(ctx, mock.Anything).Return()
M proto/ermite/storage/repo.proto +2 -0
@@ -32,6 +32,8 @@ message Repo {
3232 RepoSettings settings = 6;
3333 // Normalized and sorted; empty on repositories stored before topics.
3434 repeated string topics = 7;
35+ // Latest push, merge request or issue activity; unset until recorded.
36+ google.protobuf.Timestamp active_at = 8;
3537 }
3638
3739 // SchemaVersion is stored under the key "meta/schema".
@@ -32,6 +32,8 @@ message Repo {
32 RepoSettings settings = 6;32 RepoSettings settings = 6;
33 // Normalized and sorted; empty on repositories stored before topics.33 // Normalized and sorted; empty on repositories stored before topics.
34 repeated string topics = 7;34 repeated string topics = 7;
35+ // Latest push, merge request or issue activity; unset until recorded.
36+ google.protobuf.Timestamp active_at = 8;
35 }37 }
36 38
37 // SchemaVersion is stored under the key "meta/schema".39 // SchemaVersion is stored under the key "meta/schema".

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 44

↑↓ moveenter openesc close