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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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) { | |||
| 208 | 208 | Visibility: vis, | |
| 209 | 209 | CreatedAt: encodeTime(r.CreatedAt), | |
| 210 | 210 | PushedAt: encodeTime(r.PushedAt), | |
| 211 | + | ActiveAt: encodeTime(r.ActiveAt), | |
| 211 | 212 | Settings: pbSettings, | |
| 212 | 213 | } | |
| 213 | 214 | return mustMarshal(pb), nil | |
| @@ -229,6 +230,7 @@ func decodeRepo(val []byte) (domain.Repo, error) { | |||
| 229 | 230 | Visibility: decodeVisibility(pb.GetVisibility()), | |
| 230 | 231 | CreatedAt: decodeTime(pb.GetCreatedAt()), | |
| 231 | 232 | PushedAt: decodeTime(pb.GetPushedAt()), | |
| 233 | + | ActiveAt: decodeTime(pb.GetActiveAt()), | |
| 232 | 234 | Settings: decodeSettings(pb.GetSettings()), | |
| 233 | 235 | }, nil | |
| 234 | 236 | } | |
| @@ -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), nil | 214 | 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 | }, nil | 235 | }, nil | ||
| 234 | } | 236 | } | ||
M internal/adapters/kvstore/store_test.go +2 -1
| @@ -43,7 +43,7 @@ func TestCreateGetListUpdateDelete(t *testing.T) { | |||
| 43 | 43 | s := openStore(t, t.TempDir()) | |
| 44 | 44 | ||
| 45 | 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 | 47 | require.NoError(t, s.Create(ctx, b)) | |
| 48 | 48 | require.NoError(t, s.Create(ctx, a)) | |
| 49 | 49 | ||
| @@ -54,6 +54,7 @@ func TestCreateGetListUpdateDelete(t *testing.T) { | |||
| 54 | 54 | require.NoError(t, err) | |
| 55 | 55 | assert.Equal(t, a, got) | |
| 56 | 56 | assert.True(t, got.PushedAt.Equal(a.PushedAt)) | |
| 57 | + | assert.True(t, got.ActiveAt.Equal(a.ActiveAt)) | |
| 57 | 58 | ||
| 58 | 59 | list, err := s.List(ctx) | |
| 59 | 60 | 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) { | |||
| 37 | 37 | // right its figures and CI state over its newest commits. Its id is the | |
| 38 | 38 | // target of SSE swaps; the name link comes first, `enter` follows it. | |
| 39 | 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 | 41 | <div class="repo-main"> | |
| 42 | 42 | <h2 class="repo-name"> | |
| 43 | 43 | <a href={ templ.SafeURL(r.Href) }>{ r.Name }</a> | |
| @@ -45,7 +45,7 @@ templ HomeRowView(r HomeRow) { | |||
| 45 | 45 | <span class="pill">private</span> | |
| 46 | 46 | } | |
| 47 | 47 | if r.Fresh { | |
| 48 | - | <span class="pill new">new push</span> | |
| 48 | + | <span class="pill new">new activity</span> | |
| 49 | 49 | } | |
| 50 | 50 | </h2> | |
| 51 | 51 | 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 the | 37 | // 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 ( | |||
| 50 | 50 | // | |
| 51 | 51 | // home-list data: <li id="repo-<name>">…</li>, or <li id="repo-<name>" data-gone></li> | |
| 52 | 52 | // 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 | 55 | // repo-last-push data: <dd id="last-push-<name>">…</dd> | |
| 54 | 56 | // refs-list data: <tbody id="ref-rows">…</tbody>, and again with | |
| 55 | 57 | // <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 visible | 52 | // 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 with | 56 | // refs-list data: <tbody id="ref-rows">…</tbody>, and again with | ||
| 55 | // <dl id="refs-kv">…</dl>, the facts of the rail | 57 | // <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, | |||
| 112 | 112 | name = ev.Repo.String() | |
| 113 | 113 | case domain.PushEvent: | |
| 114 | 114 | 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 | 133 | case domain.RunUpdated: | |
| 116 | 134 | name = ev.Run.Repo.String() // the CI state of the row | |
| 117 | 135 | 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(), true | 114 | 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 row | 134 | 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) { | |||
| 56 | 56 | // homeSorts are the orders the home list offers, in order. The query also | |
| 57 | 57 | // accepts "desc" and "branch", which the page does not link to. | |
| 58 | 58 | var homeSorts = []struct{ key, label string }{ | |
| 59 | - | {"push", "last push"}, | |
| 59 | + | {"activity", "last activity"}, | |
| 60 | 60 | {"name", "name"}, | |
| 61 | 61 | } | |
| 62 | 62 | ||
| 63 | 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 | 66 | // sortRepos orders repos by the column key; ties keep the order by name. | |
| 67 | 67 | func sortRepos(repos []ports.RepoView, key string, desc bool) { | |
| @@ -71,8 +71,8 @@ func sortRepos(repos []ports.RepoView, key string, desc bool) { | |||
| 71 | 71 | return strings.ToLower(a.Description) < strings.ToLower(b.Description) | |
| 72 | 72 | case "branch": | |
| 73 | 73 | 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)) | |
| 76 | 76 | } | |
| 77 | 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 first | |
| 87 | + | // home lists the repositories: /?sort=<col>&dir=asc|desc, newest activity first | |
| 88 | 88 | // by default. | |
| 89 | 89 | func (s *Server) home(ctx context.Context, r *http.Request) (templ.Component, int, error) { | |
| 90 | 90 | repos, err := s.listRepos(ctx) | |
| 91 | 91 | if err != nil { | |
| 92 | 92 | return nil, 0, err | |
| 93 | 93 | } | |
| 94 | - | key, desc := "push", true | |
| 94 | + | key, desc := "activity", true | |
| 95 | 95 | for _, k := range homeKeys { | |
| 96 | 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 | 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 | 103 | sortRepos(repos, key, desc) | |
| 104 | 104 | ||
| 105 | 105 | now := s.cfg.Now() | |
| 106 | - | p := HomePage{} | |
| 106 | + | p := HomePage{Sort: key, Desc: desc} | |
| 107 | 107 | for _, c := range homeSorts { | |
| 108 | 108 | sl := SortLink{Label: c.label} | |
| 109 | 109 | next := "asc" | |
| 110 | - | if c.key == "push" { | |
| 110 | + | if c.key == "activity" { | |
| 111 | 111 | next = "desc" | |
| 112 | 112 | } | |
| 113 | 113 | if c.key == key { | |
| @@ -267,8 +267,8 @@ func homeRow(r ports.RepoView, now time.Time, cursor, signed bool) HomeRow { | |||
| 267 | 267 | Desc: r.Description, Topics: r.Topics, Cursor: cursor, | |
| 268 | 268 | Private: signed && r.Visibility == domain.VisibilityPrivate, | |
| 269 | 269 | } | |
| 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) | |
| 272 | 272 | } | |
| 273 | 273 | return row | |
| 274 | 274 | } | |
| @@ -284,3 +284,14 @@ func repoCmds(repos []ports.RepoView, except string) []CmdItem { | |||
| 284 | 284 | } | |
| 285 | 285 | 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 | + | } | |
| @@ -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 also | 56 | // 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.DefaultBranch | 73 | 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 first | 87 | + | // 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, err | 92 | return nil, 0, err | ||
| 93 | } | 93 | } | ||
| 94 | - | key, desc := "push", true | 94 | + | 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 row | 273 | return row | ||
| 274 | } | 274 | } | ||
| @@ -284,3 +284,14 @@ func repoCmds(repos []ports.RepoView, except string) []CmdItem { | |||||
| 284 | } | 284 | } | ||
| 285 | return out | 285 | 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) { | |||
| 252 | 252 | assert.Equal(t, "home-list", got[0].event) | |
| 253 | 253 | assert.Contains(t, got[0].html, `id="repo-proj"`) | |
| 254 | 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 | 257 | // A job that ended refreshes the row too; one in progress does not. | |
| 258 | 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 | 344 | require.NotEqual(t, -1, store) | |
| 345 | 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 | + | } | |
| @@ -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'; | |||
| 8 | 8 | import { dom, El, el, flushFrames, installDom, settle } from './dom.mjs'; | |
| 9 | 9 | ||
| 10 | 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 | 12 | El.prototype.replaceWith = function (n) { | |
| 12 | 13 | const p = this.parent; | |
| 13 | 14 | const i = p.nodes.indexOf(this); | |
| @@ -333,3 +334,27 @@ test('the preference is read from storage when the page loads', async () => { | |||
| 333 | 334 | assert.deepEqual(dom.body.events, []); | |
| 334 | 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 | + | }); | |
| @@ -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 @@ | |||
| 2 | 2 | ||
| 3 | 3 | package web | |
| 4 | 4 | ||
| 5 | + | import "fmt" | |
| 6 | + | ||
| 5 | 7 | // Home is the repository list. | |
| 6 | 8 | templ Home(l Layout, p HomePage) { | |
| 7 | 9 | @Shell(l, nil) { | |
| @@ -49,7 +51,7 @@ templ Home(l Layout, p HomePage) { | |||
| 49 | 51 | </button> | |
| 50 | 52 | </div> | |
| 51 | 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 | 55 | for _, r := range p.Rows { | |
| 54 | 56 | @HomeRowView(r) | |
| 55 | 57 | } | |
| @@ -2,6 +2,8 @@ | |||||
| 2 | 2 | ||||
| 3 | package web | 3 | 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 @@ | |||
| 820 | 820 | // internal/adapters/web/events.go). A fragment is a top-level element with an | |
| 821 | 821 | // id: it replaces the element of the page that has the same id. A fragment | |
| 822 | 822 | // 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 | |
| 824 | 825 | // banner, a persistent status region) keeps its element and only gets the | |
| 825 | 826 | // children of the fragment, so that screen readers announce the change. A gap, | |
| 826 | 827 | // which says the page may have missed events or that what it shows changed, | |
| @@ -919,6 +920,9 @@ window.ermiteLive = (() => { | |||
| 919 | 920 | } | |
| 920 | 921 | if (old.hasAttribute('data-cursor')) el.setAttribute('data-cursor', ''); | |
| 921 | 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 | 926 | window.htmx?.process(el); | |
| 923 | 927 | fitIn(el); | |
| 924 | 928 | if (had) refocus(el, had); | |
| @@ -820,7 +820,8 @@ | |||||
| 820 | // internal/adapters/web/events.go). A fragment is a top-level element with an | 820 | // 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 fragment | 821 | // 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 not | 822 | // 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 | |||
| 824 | // banner, a persistent status region) keeps its element and only gets the | 825 | // 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 { | |||
| 96 | 96 | Stats *HomeStats | |
| 97 | 97 | // Private marks a private repository; only set for a signed-in visitor. | |
| 98 | 98 | 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 | 100 | Fresh bool | |
| 101 | 101 | // Commits are the newest of the default branch. NoCommits says there is | |
| 102 | 102 | // none (never pushed, or an empty branch); a failed read says nothing. | |
| @@ -138,6 +138,8 @@ type SortLink struct { | |||
| 138 | 138 | ||
| 139 | 139 | // HomePage is the repository list. | |
| 140 | 140 | type HomePage struct { | |
| 141 | + | Sort string | |
| 142 | + | Desc bool | |
| 141 | 143 | Sorts []SortLink | |
| 142 | 144 | Rows []HomeRow | |
| 143 | 145 | } | |
| @@ -96,7 +96,7 @@ type HomeRow struct { | |||||
| 96 | Stats *HomeStats | 96 | 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 bool | 98 | 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 bool | 100 | Fresh bool | ||
| 101 | // Commits are the newest of the default branch. NoCommits says there is | 101 | // 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 []SortLink | 143 | Sorts []SortLink | ||
| 142 | Rows []HomeRow | 144 | Rows []HomeRow | ||
| 143 | } | 145 | } | ||
M internal/app/testdata/web/home.html +32 -30
| @@ -61,7 +61,7 @@ | |||
| 61 | 61 | </div> | |
| 62 | 62 | <nav class="sort" aria-label="Sort repositories"> | |
| 63 | 63 | <span class="mute">sort</span> | |
| 64 | - | <a href="/?sort=push&dir=asc" aria-current="true">last push <span aria-hidden="true">▼</span> | |
| 64 | + | <a href="/?sort=activity&dir=asc" aria-current="true">last activity <span aria-hidden="true">▼</span> | |
| 65 | 65 | <span class="sr">, descending, select for ascending</span> | |
| 66 | 66 | </a> | |
| 67 | 67 | <a href="/?sort=name&dir=asc">name </a> | |
| @@ -81,8 +81,31 @@ | |||
| 81 | 81 | </button> | |
| 82 | 82 | </div> | |
| 83 | 83 | </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"> | |
| 86 | 109 | <div class="repo-main"> | |
| 87 | 110 | <h2 class="repo-name"> | |
| 88 | 111 | <a href="/demo">demo</a> | |
| @@ -148,27 +171,6 @@ | |||
| 148 | 171 | </li> | |
| 149 | 172 | </ul> | |
| 150 | 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 | 174 | </ul> | |
| 173 | 175 | </section> | |
| 174 | 176 | </div> | |
| @@ -343,15 +345,15 @@ | |||
| 343 | 345 | <span class="k">go</span> | |
| 344 | 346 | </a> | |
| 345 | 347 | </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> | |
| 349 | 351 | <span class="k">repo</span> | |
| 350 | 352 | </a> | |
| 351 | 353 | </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> | |
| 355 | 357 | <span class="k">repo</span> | |
| 356 | 358 | </a> | |
| 357 | 359 | </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&dir=asc" aria-current="true">last push <span aria-hidden="true">▼</span> | 64 | + | <a href="/?sort=activity&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&dir=asc">name </a> | 67 | <a href="/?sort=name&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) { | |||
| 123 | 123 | assert.Equal(t, "home-list", ev.name) | |
| 124 | 124 | assert.NotEmpty(t, ev.id) | |
| 125 | 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 | 127 | assert.Contains(t, ev.data, `href="/pub"`) | |
| 128 | 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 | 134 | assert.Equal(t, "home-list", ev.name) | |
| 135 | 135 | assert.Contains(t, ev.data, `<li id="repo-fresh"`) | |
| 136 | 136 | assert.Contains(t, ev.data, "a <b>fresh</b> one", "text is escaped") | |
| 137 | - | assert.NotContains(t, ev.data, "new push") | |
| 137 | + | assert.NotContains(t, ev.data, "new activity") | |
| 138 | 138 | ||
| 139 | 139 | // A repository that turns private is removed from the anonymous list. | |
| 140 | 140 | 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 <b>fresh</b> one", "text is escaped") | 136 | assert.Contains(t, ev.data, "a <b>fresh</b> 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.VisibilityPrivate | 140 | priv := domain.VisibilityPrivate | ||
M internal/domain/repo.go +1 -0
| @@ -42,6 +42,7 @@ type Repo struct { | |||
| 42 | 42 | Visibility Visibility | |
| 43 | 43 | CreatedAt time.Time | |
| 44 | 44 | PushedAt time.Time // zero until the first push | |
| 45 | + | ActiveAt time.Time // latest push, merge request or issue activity | |
| 45 | 46 | // Settings are the merge settings; the stores always fill them in, with | |
| 46 | 47 | // the defaults for a repository that never stored any. | |
| 47 | 48 | Settings RepoSettings | |
| @@ -42,6 +42,7 @@ type Repo struct { | |||||
| 42 | Visibility Visibility | 42 | Visibility Visibility | ||
| 43 | CreatedAt time.Time | 43 | CreatedAt time.Time | ||
| 44 | PushedAt time.Time // zero until the first push | 44 | 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, with | 46 | // 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 RepoSettings | 48 | Settings RepoSettings | ||
M internal/ports/dto.go +1 -1
| @@ -21,7 +21,7 @@ type RepoPage struct { | |||
| 21 | 21 | ||
| 22 | 22 | // RepoView is a repository with the Git state that belongs in its summary. | |
| 23 | 23 | type RepoView struct { | |
| 24 | - | domain.Repo | |
| 24 | + | domain.Repo // includes ActiveAt, the latest repository activity | |
| 25 | 25 | // DefaultBranch is the branch HEAD points to, even when it does not exist | |
| 26 | 26 | // yet; empty when HEAD is detached or the directory is missing. | |
| 27 | 27 | 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.Repo | 24 | + | domain.Repo // includes ActiveAt, the latest repository activity |
| 25 | // DefaultBranch is the branch HEAD points to, even when it does not exist | 25 | // 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 string | 27 | 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 | |||
| 124 | 124 | log := zerowrap.FromCtx(ctx) | |
| 125 | 125 | log.Info().Str("repo", name.String()).Int64("number", issue.Number). | |
| 126 | 126 | Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("issue opened") | |
| 127 | + | recordActivity(ctx, i.meta, repo.Name, now) | |
| 127 | 128 | i.bus.Publish(ctx, domain.IssueOpened{Issue: issue, At: now, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt}) | |
| 128 | 129 | return issue, nil | |
| 129 | 130 | } | |
| @@ -211,6 +212,7 @@ func (i *Issues) AddComment(ctx context.Context, p domain.Principal, name domain | |||
| 211 | 212 | log := zerowrap.FromCtx(ctx) | |
| 212 | 213 | log.Info().Str("repo", name.String()).Int64("number", number).Int64("comment", c.ID). | |
| 213 | 214 | Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("issue comment added") | |
| 215 | + | recordActivity(ctx, i.meta, name, c.CreatedAt) | |
| 214 | 216 | i.bus.Publish(ctx, domain.IssueCommented{ | |
| 215 | 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 | 262 | log := zerowrap.FromCtx(ctx) | |
| 261 | 263 | log.Info().Str("repo", name.String()).Int64("number", number). | |
| 262 | 264 | Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("issue " + verb + "d") | |
| 265 | + | recordActivity(ctx, i.meta, repo.Name, issue.UpdatedAt) | |
| 263 | 266 | if to == domain.IssueStateOpen { | |
| 264 | 267 | i.bus.Publish(ctx, domain.IssueReopened{Issue: issue, At: issue.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt}) | |
| 265 | 268 | } 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, nil | 129 | 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) { | |||
| 46 | 46 | require.NoError(t, err) | |
| 47 | 47 | assert.EqualValues(t, 2, issue.Number, "the number follows the merge request") | |
| 48 | 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 | 54 | kind, err := issues.Kind(ctx, domain.Anonymous(), n, 1) | |
| 51 | 55 | 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) * | |||
| 39 | 39 | clock := mocks.NewClock(t) | |
| 40 | 40 | clock.EXPECT().Now().Return(now).Maybe() | |
| 41 | 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 | 43 | u.issues = usecase.NewIssues(usecase.IssuesConfig{Meta: u.meta, Store: u.store, MergeRequests: u.mrs, Bus: u.bus, Clock: clock}) | |
| 43 | 44 | return u | |
| 44 | 45 | } | |
| @@ -116,6 +117,7 @@ func TestIssueCreateAndTransitions(t *testing.T) { | |||
| 116 | 117 | got, err := u.issues.Create(ctx, p, u.repo.Name, ports.CreateIssueParams{Title: " Crash ", Body: "why"}) | |
| 117 | 118 | require.NoError(t, err) | |
| 118 | 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 | 123 | 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 u | 44 | 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 | |||
| 254 | 254 | if err != nil { | |
| 255 | 255 | return domain.MergeRequest{}, err | |
| 256 | 256 | } | |
| 257 | + | recordActivity(ctx, m.meta, repo.Name, now) | |
| 257 | 258 | m.bus.Publish(ctx, domain.MergeRequestOpened{MergeRequest: mr, At: now, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt}) | |
| 258 | 259 | if m.pulls != nil { | |
| 259 | 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 | 378 | if err != nil { | |
| 378 | 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 | 382 | m.bus.Publish(ctx, domain.MergeRequestReady{MergeRequest: mr, At: mr.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt}) | |
| 381 | 383 | return m.withHead(ctx, mr) | |
| 382 | 384 | } | |
| @@ -656,6 +658,7 @@ func (m *MergeRequests) AddComment(ctx context.Context, p domain.Principal, name | |||
| 656 | 658 | log := zerowrap.FromCtx(ctx) | |
| 657 | 659 | log.Info().Str("repo", name.String()).Int64("number", number).Int64("comment", c.ID). | |
| 658 | 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 | 662 | m.bus.Publish(ctx, domain.CommentAdded{ | |
| 660 | 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 | 799 | log := zerowrap.FromCtx(ctx) | |
| 797 | 800 | log.Info().Str("repo", name.String()).Int64("number", number). | |
| 798 | 801 | Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("merge request closed") | |
| 802 | + | recordActivity(ctx, m.meta, repo.Name, mr.UpdatedAt) | |
| 799 | 803 | m.bus.Publish(ctx, domain.MergeRequestClosed{MergeRequest: mr, At: mr.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt}) | |
| 800 | 804 | if m.pulls != nil { | |
| 801 | 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 | 1004 | at := mr.UpdatedAt | |
| 1005 | + | recordActivity(ctx, m.meta, repo.Name, at) | |
| 1001 | 1006 | m.bus.Publish(ctx, domain.MergeRequestMerged{MergeRequest: mr, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt}) | |
| 1002 | 1007 | m.bus.Publish(ctx, domain.PushEvent{ | |
| 1003 | 1008 | 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{}, err | 255 | 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.UpdatedAt | 1004 | 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) { | |||
| 1448 | 1448 | _, err = r.mrs.DeleteComment(r.ctx, root, r.repo, mr.Number, changes.ID) | |
| 1449 | 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 | + | } | |
| @@ -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 | |||
| 209 | 209 | 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 | 213 | // repairs HEAD when it points to a missing branch, tells the reader that the | |
| 214 | 214 | // repository changed, queues the workflows the push triggers, publishes a | |
| 215 | 215 | // PushEvent and schedules maintenance. Every | |
| @@ -245,7 +245,8 @@ func (u *Pushes) Record(ctx context.Context, p domain.Principal, name domain.Rep | |||
| 245 | 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 | 250 | // A repository deleted meanwhile is not an error. It returns the repository | |
| 250 | 251 | // as read, or the zero value when it is unknown. A push (Pushes.Record) and a | |
| 251 | 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 | 259 | if err != nil { | |
| 259 | 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 | 263 | return repo, nil // a later push already recorded | |
| 263 | 264 | } | |
| 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 | + | } | |
| 265 | 271 | err = meta.Update(ctx, repo) | |
| 266 | 272 | if errors.Is(err, domain.ErrConflict) { | |
| 267 | 273 | continue | |
| @@ -209,7 +209,7 @@ func (u *Pushes) checkWorkflows(ctx context.Context, name domain.RepoName, updat | |||||
| 209 | return nil | 209 | 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 the | 213 | // repairs HEAD when it points to a missing branch, tells the reader that the | ||
| 214 | // repository changed, queues the workflows the push triggers, publishes a | 214 | // repository changed, queues the workflows the push triggers, publishes a | ||
| 215 | // PushEvent and schedules maintenance. Every | 215 | // 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 repository | 250 | // 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 a | 251 | // 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 recorded | 263 | return repo, nil // a later push already recorded | ||
| 263 | } | 264 | } | ||
| 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 | + | } | |||
| 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 | continue | 273 | continue | ||
M internal/usecase/pushes_test.go +4 -4
| @@ -306,7 +306,7 @@ func TestPushesRecord(t *testing.T) { | |||
| 306 | 306 | f.sched.EXPECT().Schedule(ctx, n).Return().Once() | |
| 307 | 307 | repo := domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, CreatedAt: now.Add(-time.Hour)} | |
| 308 | 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 | 310 | f.git.EXPECT().DefaultBranch(ctx, n).Return("main", true, nil) | |
| 311 | 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 | 334 | repo := domain.Repo{Name: n} | |
| 335 | 335 | f.meta.EXPECT().Get(ctx, n).Return(repo, nil).Once() | |
| 336 | 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 | 338 | f.meta.EXPECT().Get(ctx, n).Return(later, nil).Once() // a later push recorded meanwhile | |
| 339 | 339 | f.git.EXPECT().DefaultBranch(ctx, n).Return("main", true, nil) | |
| 340 | 340 | f.bus.EXPECT().Publish(ctx, mock.Anything).Return() | |
| @@ -414,7 +414,7 @@ func TestPushesDefaultBranchRule(t *testing.T) { | |||
| 414 | 414 | t.Parallel() | |
| 415 | 415 | f := newPushFixture(t) | |
| 416 | 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 | 418 | f.git.EXPECT().DefaultBranch(ctx, n).Return(tt.head, tt.exists, nil) | |
| 419 | 419 | if tt.want != "" { | |
| 420 | 420 | f.git.EXPECT().SetDefaultBranch(ctx, n, tt.want).Return(nil) | |
| @@ -429,7 +429,7 @@ func TestPushesDefaultBranchRule(t *testing.T) { | |||
| 429 | 429 | f := newPushFixture(t) | |
| 430 | 430 | f.sched.EXPECT().Schedule(ctx, n).Return().Once() | |
| 431 | 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 | 433 | f.git.EXPECT().DefaultBranch(ctx, n).Return("main", false, nil) | |
| 434 | 434 | f.git.EXPECT().SetDefaultBranch(ctx, n, "dev").Return(boom) | |
| 435 | 435 | 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 meanwhile | 338 | 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 { | |||
| 32 | 32 | RepoSettings settings = 6; | |
| 33 | 33 | // Normalized and sorted; empty on repositories stored before topics. | |
| 34 | 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 | 39 | // 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". | ||