detached 0a9e349

#6 feat(web): review page polish and home page ordered by last activity

state
merged · 4ce8534 · squash
branch
feat/mr-header-card → main
opened
bnema ·
updated

Review page and home page polish, plus "last activity" ordering of the repositories.

Merge request page

  • Title, facts and description in one card; the header a shade lighter than the description. Issues share it.
  • Unified / Split / Collapse all in one view menu (the existing details.menu component); u, d and x still work, escape closes menus on every page and the focus goes back to the menu button.
  • Wide mode on every page with w (listed in ?), kept in localStorage; store.js loads without defer and sets it before the first paint.
  • Hunk headers and hidden-line rows are quieter (background and border), text keeps its contrast.
  • A shift + click range is one block (one border, none between its lines, per side in split); the form title names the whole range (a.go:21-149); lines loaded later from a gap inside the range are selected too.

Home page

  • Inbox button next to the logo, home page only, with a mailbox icon (Lucide) and a darker count badge.
  • Default order is the last activity: push (incl. merge), merge request opened / merged / closed / ready, comment on a merge request, issue opened / approved / closed / reopened / commented. Moderation (submissions, rejections) stays owner-only and does not count.
  • The row of a repository with activity moves to the top live (activity order only; CI updates do not move it).

Storage

  • Repo.ActiveAt (storage proto field 8; falls back to the later of pushed and created).
  • RepoMetaStore.Update is replaced by Mutate: a compare-and-swap on the version read, retried up to 8 times. The previous Update checked the version read at write time, so a read-modify-write could overwrite a concurrent change (e.g. a visibility change).

No change to ermite-api.

Checks: go vet ./..., go test ./..., go test -race on usecase and kvstore, JS tests (160), golden pages refreshed, manual checks on the dev server. Reviewed by Go and JS reviewers; findings fixed.

CommitSubjectAuthorDateSignatureStatus
0a9e349test: run the activity store test outside the usecase package and its home row subtests in parallelbriceGPG Verified
9ca7c68fix(repos): mutate metadata atomically and record approved issue activitybriceGPG Verified
32e9fe7fix(web): match loaded lines to a selection by file key, keep the focus on a closing menu, close menus with escape on every page and restore the hunk header contrastbriceGPG Verified
4277264feat(web): order the home page by last activity and move repositories up livebriceGPG Verified
4fc291efeat(web): move the inbox button of the home page next to the logo, with a mailbox icon and a darker count badgebriceGPG Verified
5f6d2c6test(app): refresh the web golden pages for the view menu, the w shortcut and the store scriptbriceGPG Verified
a772c1bfix(web): a selected line range is one block, its title names the whole range and its hidden lines stay selected once loadedbriceGPG Verified
502935erefactor(web): drop the Wide button of the diff bar, the w shortcut covers itbriceGPG Verified
a1f8e85feat(web): make the wide mode a global w shortcut, set before the first paint, and tighten the merge request headerbriceGPG Verified
fcc7703feat(web): one merge request card, a diff view menu, a wide diff mode and quieter hunk rowsbriceGPG Verified

Checks 4

92 files · +2426 -1392
4/4
M gen/ermite/storage/repo.pb.go +22 -11
M internal/adapters/kvstore/issues_test.go +4 -2
M internal/adapters/kvstore/mergerequests_test.go +4 -2
M internal/adapters/kvstore/repos.go +44 -22
A internal/adapters/kvstore/repos_mutate_test.go +87 -0
M internal/adapters/kvstore/store_test.go +8 -4
M internal/adapters/web/components.templ +2 -2
M internal/adapters/web/components_templ.go +119 -113
M internal/adapters/web/diff.templ +22 -4
M internal/adapters/web/diff_templ.go +523 -437
M internal/adapters/web/events.go +2 -0
M internal/adapters/web/format.go +8 -0
M internal/adapters/web/fragments.go +18 -0
M internal/adapters/web/home.go +24 -14
M internal/adapters/web/home_test.go +50 -1
M internal/adapters/web/issues.templ +24 -22
M internal/adapters/web/issues_templ.go +26 -26
M internal/adapters/web/jstest/a11y.test.mjs +25 -0
M internal/adapters/web/keys.templ +1 -0
M internal/adapters/web/keys_templ.go +1 -1
M internal/adapters/web/mr.go +6 -1
M internal/adapters/web/mr.templ +37 -39
M internal/adapters/web/mr_templ.go +107 -123
M internal/adapters/web/overlays.templ +1 -0
M internal/adapters/web/overlays_templ.go +12 -12
M internal/adapters/web/pages.templ +3 -9
M internal/adapters/web/pages_templ.go +205 -223
M internal/adapters/web/shell.templ +14 -1
M internal/adapters/web/shell_templ.go +158 -116
M internal/adapters/web/static/css/20-shell.css +9 -0
M internal/adapters/web/static/css/50-code.css +7 -4
M internal/adapters/web/static/css/55-diff.css +25 -0
M internal/adapters/web/static/css/60-mr.css +15 -1
A internal/adapters/web/static/icons/mailbox.svg +1 -0
M internal/adapters/web/static/js/ermite.js +70 -3
M internal/adapters/web/static/js/mr.js +1 -11
M internal/adapters/web/static/js/store.js +5 -0
M internal/adapters/web/view.go +6 -4
M internal/app/testdata/web/blob-binary.html +8 -1
M internal/app/testdata/web/blob-code.html +8 -1
M internal/app/testdata/web/blob-empty.html +8 -1
M internal/app/testdata/web/blob-image.html +8 -1
M internal/app/testdata/web/blob-markdown.html +8 -1
M internal/app/testdata/web/blob-svg.html +8 -1
M internal/app/testdata/web/blob-text.html +8 -1
M internal/app/testdata/web/blob-too-large.html +8 -1
M internal/app/testdata/web/blob-trojan.html +8 -1
M internal/app/testdata/web/commit-big.html +16 -2
M internal/app/testdata/web/commit-binary.html +16 -2
M internal/app/testdata/web/commit-merge.html +16 -2
M internal/app/testdata/web/commit-modify-split.html +17 -3
M internal/app/testdata/web/commit-modify.html +17 -3
M internal/app/testdata/web/commit-rename.html +17 -3
M internal/app/testdata/web/commit-root.html +20 -6
M internal/app/testdata/web/commit-trojan.html +16 -2
M internal/app/testdata/web/commits-page2.html +8 -1
M internal/app/testdata/web/commits-path.html +8 -1
M internal/app/testdata/web/commits-root.html +8 -1
M internal/app/testdata/web/commits-small.html +8 -1
M internal/app/testdata/web/empty.html +8 -1
M internal/app/testdata/web/home.html +40 -31
M internal/app/testdata/web/not-found-page.html +8 -1
M internal/app/testdata/web/not-found.html +8 -1
M internal/app/testdata/web/refs.html +8 -1
M internal/app/testdata/web/summary.html +8 -1
M internal/app/testdata/web/tree-nested.html +8 -1
M internal/app/testdata/web/tree-root.html +8 -1
M internal/app/testdata/web/tree-slash-ref.html +8 -1
M internal/app/testdata/web/tree-subdir.html +8 -1
M internal/app/web_events_test.go +2 -2
M internal/app/web_mr_test.go +2 -0
M internal/domain/repo.go +1 -0
M internal/ports/dto.go +1 -1
M internal/ports/mocks/RepoMetaStore.go +36 -21
M internal/ports/ports.go +7 -4
A internal/usecase/activity.go +43 -0
@@ -0,0 +1,43 @@
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+ repo, err := meta.Mutate(ctx, name, func(repo *domain.Repo) (bool, error) {
29+ changed := false
30+ if now.After(repo.ActiveAt) {
31+ repo.ActiveAt = now
32+ changed = true
33+ }
34+ return changed, nil
35+ })
36+ if errors.Is(err, domain.ErrNotFound) {
37+ return domain.Repo{}, nil
38+ }
39+ if err != nil {
40+ return repo, fmt.Errorf("update repository %q: %w", name, err)
41+ }
42+ return repo, nil
43+}
@@ -0,0 +1,43 @@
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+ repo, err := meta.Mutate(ctx, name, func(repo *domain.Repo) (bool, error) {
29+ changed := false
30+ if now.After(repo.ActiveAt) {
31+ repo.ActiveAt = now
32+ changed = true
33+ }
34+ return changed, nil
35+ })
36+ if errors.Is(err, domain.ErrNotFound) {
37+ return domain.Repo{}, nil
38+ }
39+ if err != nil {
40+ return repo, fmt.Errorf("update repository %q: %w", name, err)
41+ }
42+ return repo, nil
43+}
A internal/usecase/activity_store_test.go +46 -0
@@ -0,0 +1,46 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase_test
4+
5+import (
6+ "testing"
7+ "time"
8+
9+ "github.com/stretchr/testify/require"
10+
11+ "bnema.dev/ermite/internal/adapters/kvstore"
12+ "bnema.dev/ermite/internal/domain"
13+ "bnema.dev/ermite/internal/usecase"
14+)
15+
16+func TestActivityTimestampsOverStore(t *testing.T) {
17+ ctx := t.Context()
18+ s, err := kvstore.Open(ctx, t.TempDir())
19+ require.NoError(t, err)
20+ t.Cleanup(func() { require.NoError(t, s.Close(ctx)) })
21+ n, err := domain.ParseRepoName("proj")
22+ require.NoError(t, err)
23+ at := time.Date(2026, 10, 19, 12, 0, 0, 0, time.UTC)
24+ require.NoError(t, s.Create(ctx, domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, CreatedAt: at}))
25+ got, err := usecase.TouchPushedAt(ctx, s, n, at.Add(time.Hour))
26+ require.NoError(t, err)
27+ require.Equal(t, got.PushedAt, got.ActiveAt)
28+ _, err = usecase.TouchActiveAt(ctx, s, n, at.Add(3*time.Hour))
29+ require.NoError(t, err)
30+ _, err = usecase.TouchPushedAt(ctx, s, n, at.Add(2*time.Hour))
31+ require.NoError(t, err)
32+ _, err = usecase.TouchPushedAt(ctx, s, n, at)
33+ require.NoError(t, err)
34+ _, err = usecase.TouchActiveAt(ctx, s, n, at)
35+ require.NoError(t, err)
36+ got, err = s.Get(ctx, n)
37+ require.NoError(t, err)
38+ require.Equal(t, at.Add(2*time.Hour), got.PushedAt)
39+ require.Equal(t, at.Add(3*time.Hour), got.ActiveAt)
40+ require.Equal(t, domain.VisibilityPrivate, got.Visibility)
41+ require.NoError(t, s.Delete(ctx, n))
42+ _, err = usecase.TouchPushedAt(ctx, s, n, at)
43+ require.NoError(t, err)
44+ _, err = usecase.TouchActiveAt(ctx, s, n, at)
45+ require.NoError(t, err)
46+}
@@ -0,0 +1,46 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase_test
4+
5+import (
6+ "testing"
7+ "time"
8+
9+ "github.com/stretchr/testify/require"
10+
11+ "bnema.dev/ermite/internal/adapters/kvstore"
12+ "bnema.dev/ermite/internal/domain"
13+ "bnema.dev/ermite/internal/usecase"
14+)
15+
16+func TestActivityTimestampsOverStore(t *testing.T) {
17+ ctx := t.Context()
18+ s, err := kvstore.Open(ctx, t.TempDir())
19+ require.NoError(t, err)
20+ t.Cleanup(func() { require.NoError(t, s.Close(ctx)) })
21+ n, err := domain.ParseRepoName("proj")
22+ require.NoError(t, err)
23+ at := time.Date(2026, 10, 19, 12, 0, 0, 0, time.UTC)
24+ require.NoError(t, s.Create(ctx, domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, CreatedAt: at}))
25+ got, err := usecase.TouchPushedAt(ctx, s, n, at.Add(time.Hour))
26+ require.NoError(t, err)
27+ require.Equal(t, got.PushedAt, got.ActiveAt)
28+ _, err = usecase.TouchActiveAt(ctx, s, n, at.Add(3*time.Hour))
29+ require.NoError(t, err)
30+ _, err = usecase.TouchPushedAt(ctx, s, n, at.Add(2*time.Hour))
31+ require.NoError(t, err)
32+ _, err = usecase.TouchPushedAt(ctx, s, n, at)
33+ require.NoError(t, err)
34+ _, err = usecase.TouchActiveAt(ctx, s, n, at)
35+ require.NoError(t, err)
36+ got, err = s.Get(ctx, n)
37+ require.NoError(t, err)
38+ require.Equal(t, at.Add(2*time.Hour), got.PushedAt)
39+ require.Equal(t, at.Add(3*time.Hour), got.ActiveAt)
40+ require.Equal(t, domain.VisibilityPrivate, got.Visibility)
41+ require.NoError(t, s.Delete(ctx, n))
42+ _, err = usecase.TouchPushedAt(ctx, s, n, at)
43+ require.NoError(t, err)
44+ _, err = usecase.TouchActiveAt(ctx, s, n, at)
45+ require.NoError(t, err)
46+}
A internal/usecase/activity_test.go +47 -0
@@ -0,0 +1,47 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
6+ "context"
7+ "errors"
8+ "testing"
9+ "time"
10+
11+ "github.com/stretchr/testify/mock"
12+ "github.com/stretchr/testify/require"
13+
14+ "bnema.dev/ermite/internal/domain"
15+ "bnema.dev/ermite/internal/ports/mocks"
16+)
17+
18+func TestTouchActiveAt(t *testing.T) {
19+ name, err := domain.ParseRepoName("proj")
20+ require.NoError(t, err)
21+ now := time.Now().UTC()
22+ t.Run("preserves later activity", func(t *testing.T) {
23+ meta := mocks.NewRepoMetaStore(t)
24+ later := domain.Repo{Name: name, ActiveAt: now.Add(time.Hour)}
25+ meta.EXPECT().Mutate(mock.Anything, name, mock.Anything).RunAndReturn(func(_ context.Context, _ domain.RepoName, fn func(*domain.Repo) (bool, error)) (domain.Repo, error) {
26+ repo := later
27+ changed, err := fn(&repo)
28+ require.NoError(t, err)
29+ require.False(t, changed)
30+ return repo, nil
31+ })
32+ got, err := touchActiveAt(t.Context(), meta, name, now)
33+ require.NoError(t, err)
34+ require.Equal(t, later, got)
35+ })
36+ t.Run("deleted repository", func(t *testing.T) {
37+ meta := mocks.NewRepoMetaStore(t)
38+ meta.EXPECT().Mutate(mock.Anything, name, mock.Anything).Return(domain.Repo{}, domain.ErrNotFound)
39+ _, err := touchActiveAt(t.Context(), meta, name, now)
40+ require.NoError(t, err)
41+ })
42+ t.Run("best effort failure", func(t *testing.T) {
43+ meta := mocks.NewRepoMetaStore(t)
44+ meta.EXPECT().Mutate(mock.Anything, name, mock.Anything).Return(domain.Repo{}, errors.New("offline"))
45+ require.NotPanics(t, func() { recordActivity(t.Context(), meta, name, now) })
46+ })
47+}
@@ -0,0 +1,47 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
6+ "context"
7+ "errors"
8+ "testing"
9+ "time"
10+
11+ "github.com/stretchr/testify/mock"
12+ "github.com/stretchr/testify/require"
13+
14+ "bnema.dev/ermite/internal/domain"
15+ "bnema.dev/ermite/internal/ports/mocks"
16+)
17+
18+func TestTouchActiveAt(t *testing.T) {
19+ name, err := domain.ParseRepoName("proj")
20+ require.NoError(t, err)
21+ now := time.Now().UTC()
22+ t.Run("preserves later activity", func(t *testing.T) {
23+ meta := mocks.NewRepoMetaStore(t)
24+ later := domain.Repo{Name: name, ActiveAt: now.Add(time.Hour)}
25+ meta.EXPECT().Mutate(mock.Anything, name, mock.Anything).RunAndReturn(func(_ context.Context, _ domain.RepoName, fn func(*domain.Repo) (bool, error)) (domain.Repo, error) {
26+ repo := later
27+ changed, err := fn(&repo)
28+ require.NoError(t, err)
29+ require.False(t, changed)
30+ return repo, nil
31+ })
32+ got, err := touchActiveAt(t.Context(), meta, name, now)
33+ require.NoError(t, err)
34+ require.Equal(t, later, got)
35+ })
36+ t.Run("deleted repository", func(t *testing.T) {
37+ meta := mocks.NewRepoMetaStore(t)
38+ meta.EXPECT().Mutate(mock.Anything, name, mock.Anything).Return(domain.Repo{}, domain.ErrNotFound)
39+ _, err := touchActiveAt(t.Context(), meta, name, now)
40+ require.NoError(t, err)
41+ })
42+ t.Run("best effort failure", func(t *testing.T) {
43+ meta := mocks.NewRepoMetaStore(t)
44+ meta.EXPECT().Mutate(mock.Anything, name, mock.Anything).Return(domain.Repo{}, errors.New("offline"))
45+ require.NotPanics(t, func() { recordActivity(t.Context(), meta, name, now) })
46+ })
47+}
M internal/usecase/export_test.go +10 -0
@@ -19,6 +19,16 @@ import (
1919 // Sweep exposes the daily sweep to the tests.
2020 func (c *CI) Sweep(ctx context.Context) { c.sweep(ctx) }
2121
22+// TouchPushedAt exposes touchPushedAt to the tests.
23+func TouchPushedAt(ctx context.Context, meta ports.RepoMetaStore, name domain.RepoName, now time.Time) (domain.Repo, error) {
24+ return touchPushedAt(ctx, meta, name, now)
25+}
26+
27+// TouchActiveAt exposes touchActiveAt to the tests.
28+func TouchActiveAt(ctx context.Context, meta ports.RepoMetaStore, name domain.RepoName, now time.Time) (domain.Repo, error) {
29+ return touchActiveAt(ctx, meta, name, now)
30+}
31+
2232 // RunTitle gives the title of a run as startRun sets it: the evaluated
2333 // run-name, else commitTitle.
2434 func (c *CI) RunTitle(ctx context.Context, wf *workflow.Workflow, githubJSON, commitTitle string) string {
@@ -19,6 +19,16 @@ import (
19 // Sweep exposes the daily sweep to the tests.19 // Sweep exposes the daily sweep to the tests.
20 func (c *CI) Sweep(ctx context.Context) { c.sweep(ctx) }20 func (c *CI) Sweep(ctx context.Context) { c.sweep(ctx) }
21 21
22+// TouchPushedAt exposes touchPushedAt to the tests.
23+func TouchPushedAt(ctx context.Context, meta ports.RepoMetaStore, name domain.RepoName, now time.Time) (domain.Repo, error) {
24+ return touchPushedAt(ctx, meta, name, now)
25+}
26+
27+// TouchActiveAt exposes touchActiveAt to the tests.
28+func TouchActiveAt(ctx context.Context, meta ports.RepoMetaStore, name domain.RepoName, now time.Time) (domain.Repo, error) {
29+ return touchActiveAt(ctx, meta, name, now)
30+}
31+
22 // RunTitle gives the title of a run as startRun sets it: the evaluated32 // RunTitle gives the title of a run as startRun sets it: the evaluated
23 // run-name, else commitTitle.33 // run-name, else commitTitle.
24 func (c *CI) RunTitle(ctx context.Context, wf *workflow.Workflow, githubJSON, commitTitle string) string {34 func (c *CI) RunTitle(ctx context.Context, wf *workflow.Workflow, githubJSON, commitTitle string) string {
M internal/usecase/issue_moderation.go +1 -0
@@ -85,6 +85,7 @@ func (m *IssueModeration) Approve(ctx context.Context, p domain.Principal, repo
8585 log := zerowrap.FromCtx(ctx)
8686 log.Info().Str("repo", repo.String()).Int64("number", issue.Number).
8787 Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("pending issue approved")
88+ recordActivity(ctx, m.meta, repo, now)
8889 m.bus.Publish(ctx, domain.IssueOpened{Issue: issue, At: now, Visibility: r.Visibility, CreatedAt: r.CreatedAt})
8990 return issue, nil
9091 }
@@ -85,6 +85,7 @@ func (m *IssueModeration) Approve(ctx context.Context, p domain.Principal, repo
85 log := zerowrap.FromCtx(ctx)85 log := zerowrap.FromCtx(ctx)
86 log.Info().Str("repo", repo.String()).Int64("number", issue.Number).86 log.Info().Str("repo", repo.String()).Int64("number", issue.Number).
87 Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("pending issue approved")87 Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("pending issue approved")
88+ recordActivity(ctx, m.meta, repo, now)
88 m.bus.Publish(ctx, domain.IssueOpened{Issue: issue, At: now, Visibility: r.Visibility, CreatedAt: r.CreatedAt})89 m.bus.Publish(ctx, domain.IssueOpened{Issue: issue, At: now, Visibility: r.Visibility, CreatedAt: r.CreatedAt})
89 return issue, nil90 return issue, nil
90 }91 }
M internal/usecase/issue_moderation_test.go +1 -0
@@ -89,6 +89,7 @@ func TestIssueModerationApprove(t *testing.T) {
8989 u := newModerationUnit(t, domain.IssuesPublic)
9090 issue := domain.Issue{Repo: u.repo.Name, Number: 7, State: domain.IssueStateOpen}
9191 u.pending.EXPECT().Approve(mock.Anything, u.repo.Name, u.id, now).Return(issue, nil).Once()
92+ expectRepoMutation(t, u.meta, u.repo, mock.MatchedBy(func(r domain.Repo) bool { return r.ActiveAt.Equal(now) })).Once()
9293 u.bus.EXPECT().Publish(mock.Anything, domain.IssueOpened{Issue: issue, At: now, Visibility: u.repo.Visibility, CreatedAt: u.repo.CreatedAt}).Once()
9394 got, err := u.mod.Approve(ctx, domain.Owner(), u.repo.Name, u.id)
9495 require.NoError(t, err)
@@ -89,6 +89,7 @@ func TestIssueModerationApprove(t *testing.T) {
89 u := newModerationUnit(t, domain.IssuesPublic)89 u := newModerationUnit(t, domain.IssuesPublic)
90 issue := domain.Issue{Repo: u.repo.Name, Number: 7, State: domain.IssueStateOpen}90 issue := domain.Issue{Repo: u.repo.Name, Number: 7, State: domain.IssueStateOpen}
91 u.pending.EXPECT().Approve(mock.Anything, u.repo.Name, u.id, now).Return(issue, nil).Once()91 u.pending.EXPECT().Approve(mock.Anything, u.repo.Name, u.id, now).Return(issue, nil).Once()
92+ expectRepoMutation(t, u.meta, u.repo, mock.MatchedBy(func(r domain.Repo) bool { return r.ActiveAt.Equal(now) })).Once()
92 u.bus.EXPECT().Publish(mock.Anything, domain.IssueOpened{Issue: issue, At: now, Visibility: u.repo.Visibility, CreatedAt: u.repo.CreatedAt}).Once()93 u.bus.EXPECT().Publish(mock.Anything, domain.IssueOpened{Issue: issue, At: now, Visibility: u.repo.Visibility, CreatedAt: u.repo.CreatedAt}).Once()
93 got, err := u.mod.Approve(ctx, domain.Owner(), u.repo.Name, u.id)94 got, err := u.mod.Approve(ctx, domain.Owner(), u.repo.Name, u.id)
94 require.NoError(t, err)95 require.NoError(t, err)
M internal/usecase/issues.go +3 -0
@@ -124,6 +124,7 @@ func (i *Issues) Create(ctx context.Context, p domain.Principal, name domain.Rep
124124 log := zerowrap.FromCtx(ctx)
125125 log.Info().Str("repo", name.String()).Int64("number", issue.Number).
126126 Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("issue opened")
127+ recordActivity(ctx, i.meta, repo.Name, now)
127128 i.bus.Publish(ctx, domain.IssueOpened{Issue: issue, At: now, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
128129 return issue, nil
129130 }
@@ -211,6 +212,7 @@ func (i *Issues) AddComment(ctx context.Context, p domain.Principal, name domain
211212 log := zerowrap.FromCtx(ctx)
212213 log.Info().Str("repo", name.String()).Int64("number", number).Int64("comment", c.ID).
213214 Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("issue comment added")
215+ recordActivity(ctx, i.meta, name, c.CreatedAt)
214216 i.bus.Publish(ctx, domain.IssueCommented{
215217 Repo: name, Number: number, Comment: c, At: c.CreatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt,
216218 })
@@ -260,6 +262,7 @@ func (i *Issues) transition(ctx context.Context, p domain.Principal, name domain
260262 log := zerowrap.FromCtx(ctx)
261263 log.Info().Str("repo", name.String()).Int64("number", number).
262264 Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("issue " + verb + "d")
265+ recordActivity(ctx, i.meta, repo.Name, issue.UpdatedAt)
263266 if to == domain.IssueStateOpen {
264267 i.bus.Publish(ctx, domain.IssueReopened{Issue: issue, At: issue.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
265268 } else {
@@ -124,6 +124,7 @@ func (i *Issues) Create(ctx context.Context, p domain.Principal, name domain.Rep
124 log := zerowrap.FromCtx(ctx)124 log := zerowrap.FromCtx(ctx)
125 log.Info().Str("repo", name.String()).Int64("number", issue.Number).125 log.Info().Str("repo", name.String()).Int64("number", issue.Number).
126 Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("issue opened")126 Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("issue opened")
127+ recordActivity(ctx, i.meta, repo.Name, now)
127 i.bus.Publish(ctx, domain.IssueOpened{Issue: issue, At: now, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})128 i.bus.Publish(ctx, domain.IssueOpened{Issue: issue, At: now, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
128 return issue, nil129 return issue, nil
129 }130 }
@@ -211,6 +212,7 @@ func (i *Issues) AddComment(ctx context.Context, p domain.Principal, name domain
211 log := zerowrap.FromCtx(ctx)212 log := zerowrap.FromCtx(ctx)
212 log.Info().Str("repo", name.String()).Int64("number", number).Int64("comment", c.ID).213 log.Info().Str("repo", name.String()).Int64("number", number).Int64("comment", c.ID).
213 Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("issue comment added")214 Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("issue comment added")
215+ recordActivity(ctx, i.meta, name, c.CreatedAt)
214 i.bus.Publish(ctx, domain.IssueCommented{216 i.bus.Publish(ctx, domain.IssueCommented{
215 Repo: name, Number: number, Comment: c, At: c.CreatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt,217 Repo: name, Number: number, Comment: c, At: c.CreatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt,
216 })218 })
@@ -260,6 +262,7 @@ func (i *Issues) transition(ctx context.Context, p domain.Principal, name domain
260 log := zerowrap.FromCtx(ctx)262 log := zerowrap.FromCtx(ctx)
261 log.Info().Str("repo", name.String()).Int64("number", number).263 log.Info().Str("repo", name.String()).Int64("number", number).
262 Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("issue " + verb + "d")264 Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("issue " + verb + "d")
265+ recordActivity(ctx, i.meta, repo.Name, issue.UpdatedAt)
263 if to == domain.IssueStateOpen {266 if to == domain.IssueStateOpen {
264 i.bus.Publish(ctx, domain.IssueReopened{Issue: issue, At: issue.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})267 i.bus.Publish(ctx, domain.IssueReopened{Issue: issue, At: issue.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
265 } else {268 } else {
M internal/usecase/issues_integration_test.go +67 -0
@@ -6,14 +6,17 @@ import (
66 "context"
77 "path/filepath"
88 "testing"
9+ "time"
910
1011 "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/mock"
1113 "github.com/stretchr/testify/require"
1214
1315 "bnema.dev/ermite/internal/adapters/eventbus"
1416 "bnema.dev/ermite/internal/adapters/kvstore"
1517 "bnema.dev/ermite/internal/domain"
1618 "bnema.dev/ermite/internal/ports"
19+ "bnema.dev/ermite/internal/ports/mocks"
1720 "bnema.dev/ermite/internal/usecase"
1821 )
1922
@@ -46,6 +49,10 @@ func TestIssuesOverTheStore(t *testing.T) {
4649 require.NoError(t, err)
4750 assert.EqualValues(t, 2, issue.Number, "the number follows the merge request")
4851 assert.Equal(t, "bot", issue.Author)
52+ repo, err := store.Get(ctx, n)
53+ require.NoError(t, err)
54+ assert.True(t, repo.ActiveAt.Equal(now))
55+ assert.True(t, repo.PushedAt.IsZero())
4956
5057 kind, err := issues.Kind(ctx, domain.Anonymous(), n, 1)
5158 require.NoError(t, err)
@@ -85,3 +92,63 @@ func TestIssuesOverTheStore(t *testing.T) {
8592 }
8693 assert.Equal(t, []string{"issue.opened", "issue.commented", "issue.closed", "issue.commented", "issue.reopened"}, kinds)
8794 }
95+
96+// Moderated submissions are private work until approval makes them public activity.
97+func TestModeratedIssueActivityOverStore(t *testing.T) {
98+ ctx := t.Context()
99+ store, err := kvstore.Open(ctx, t.TempDir())
100+ require.NoError(t, err)
101+ t.Cleanup(func() { require.NoError(t, store.Close(ctx)) })
102+ n := name(t, "proj")
103+ initial := now.Add(-time.Hour)
104+ require.NoError(t, store.Create(ctx, domain.Repo{Name: n, Visibility: domain.VisibilityPublic, CreatedAt: initial, ActiveAt: initial, Settings: withIssues(domain.IssuesPublic)}))
105+ clock := mocks.NewClock(t)
106+ at := now
107+ clock.EXPECT().Now().RunAndReturn(func() time.Time { return at }).Maybe()
108+ keys, err := usecase.NewIssueKeys(ctx, store.IssueKeys())
109+ require.NoError(t, err)
110+ limiter := mocks.NewSubmissionLimiter(t)
111+ limiter.EXPECT().Recent(mock.Anything, mock.Anything).Return(0, 0).Maybe()
112+ limiter.EXPECT().Reserve(mock.Anything, mock.Anything).Return(true, time.Duration(0), func() {}).Maybe()
113+ bus := mocks.NewEventBus(t)
114+ var kinds []string
115+ bus.EXPECT().Publish(mock.Anything, mock.Anything).Run(func(_ context.Context, ev domain.Event) {
116+ repo, err := store.Get(ctx, n)
117+ require.NoError(t, err)
118+ if ev.EventKind() == "issue.opened" {
119+ require.Equal(t, at, repo.ActiveAt)
120+ } else {
121+ require.Equal(t, initial, repo.ActiveAt)
122+ }
123+ kinds = append(kinds, ev.EventKind())
124+ }).Maybe()
125+ submissions := usecase.NewIssueSubmissions(usecase.IssueSubmissionsConfig{Meta: store, Pending: store.PendingIssues(), Bans: store.IssueBans(), Limiter: limiter, Keys: keys, Bus: bus, Clock: clock, BaseDifficulty: testBits, MaxDifficulty: testBits})
126+ moderation := usecase.NewIssueModeration(usecase.IssueModerationConfig{Meta: store, Pending: store.PendingIssues(), Bans: store.IssueBans(), Bus: bus, Clock: clock})
127+ submit := func() domain.PendingIssueID {
128+ ch, err := submissions.Challenge(ctx, n, "client")
129+ require.NoError(t, err)
130+ sub := ports.IssueSubmission{Token: ch.Token, FormToken: ch.FormToken, Solution: solve(t, ch), Title: "Bug", Body: "Details"}
131+ at = at.Add(usecase.MinFillTime)
132+ require.NoError(t, submissions.Submit(ctx, n, "client", sub))
133+ repo, err := store.Get(ctx, n)
134+ require.NoError(t, err)
135+ require.Equal(t, initial, repo.ActiveAt)
136+ page, err := store.PendingIssues().List(ctx, n, ports.ListPendingParams{PageSize: 10})
137+ require.NoError(t, err)
138+ require.Len(t, page.Issues, 1)
139+ return page.Issues[0].ID
140+ }
141+ id := submit()
142+ require.NoError(t, moderation.Reject(ctx, domain.Owner(), n, id, false))
143+ repo, err := store.Get(ctx, n)
144+ require.NoError(t, err)
145+ require.Equal(t, initial, repo.ActiveAt)
146+ id = submit()
147+ _, err = moderation.Approve(ctx, domain.Owner(), n, id)
148+ require.NoError(t, err)
149+ repo, err = store.Get(ctx, n)
150+ require.NoError(t, err)
151+ require.Equal(t, at, repo.ActiveAt)
152+ require.True(t, repo.PushedAt.IsZero())
153+ require.Equal(t, []string{"issue.submitted", "issue.pending_removed", "issue.submitted", "issue.opened"}, kinds)
154+}
@@ -6,14 +6,17 @@ import (
6 "context"6 "context"
7 "path/filepath"7 "path/filepath"
8 "testing"8 "testing"
9+ "time"
9 10
10 "github.com/stretchr/testify/assert"11 "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/mock"
11 "github.com/stretchr/testify/require"13 "github.com/stretchr/testify/require"
12 14
13 "bnema.dev/ermite/internal/adapters/eventbus"15 "bnema.dev/ermite/internal/adapters/eventbus"
14 "bnema.dev/ermite/internal/adapters/kvstore"16 "bnema.dev/ermite/internal/adapters/kvstore"
15 "bnema.dev/ermite/internal/domain"17 "bnema.dev/ermite/internal/domain"
16 "bnema.dev/ermite/internal/ports"18 "bnema.dev/ermite/internal/ports"
19+ "bnema.dev/ermite/internal/ports/mocks"
17 "bnema.dev/ermite/internal/usecase"20 "bnema.dev/ermite/internal/usecase"
18 )21 )
19 22
@@ -46,6 +49,10 @@ func TestIssuesOverTheStore(t *testing.T) {
46 require.NoError(t, err)49 require.NoError(t, err)
47 assert.EqualValues(t, 2, issue.Number, "the number follows the merge request")50 assert.EqualValues(t, 2, issue.Number, "the number follows the merge request")
48 assert.Equal(t, "bot", issue.Author)51 assert.Equal(t, "bot", issue.Author)
52+ repo, err := store.Get(ctx, n)
53+ require.NoError(t, err)
54+ assert.True(t, repo.ActiveAt.Equal(now))
55+ assert.True(t, repo.PushedAt.IsZero())
49 56
50 kind, err := issues.Kind(ctx, domain.Anonymous(), n, 1)57 kind, err := issues.Kind(ctx, domain.Anonymous(), n, 1)
51 require.NoError(t, err)58 require.NoError(t, err)
@@ -85,3 +92,63 @@ func TestIssuesOverTheStore(t *testing.T) {
85 }92 }
86 assert.Equal(t, []string{"issue.opened", "issue.commented", "issue.closed", "issue.commented", "issue.reopened"}, kinds)93 assert.Equal(t, []string{"issue.opened", "issue.commented", "issue.closed", "issue.commented", "issue.reopened"}, kinds)
87 }94 }
95+
96+// Moderated submissions are private work until approval makes them public activity.
97+func TestModeratedIssueActivityOverStore(t *testing.T) {
98+ ctx := t.Context()
99+ store, err := kvstore.Open(ctx, t.TempDir())
100+ require.NoError(t, err)
101+ t.Cleanup(func() { require.NoError(t, store.Close(ctx)) })
102+ n := name(t, "proj")
103+ initial := now.Add(-time.Hour)
104+ require.NoError(t, store.Create(ctx, domain.Repo{Name: n, Visibility: domain.VisibilityPublic, CreatedAt: initial, ActiveAt: initial, Settings: withIssues(domain.IssuesPublic)}))
105+ clock := mocks.NewClock(t)
106+ at := now
107+ clock.EXPECT().Now().RunAndReturn(func() time.Time { return at }).Maybe()
108+ keys, err := usecase.NewIssueKeys(ctx, store.IssueKeys())
109+ require.NoError(t, err)
110+ limiter := mocks.NewSubmissionLimiter(t)
111+ limiter.EXPECT().Recent(mock.Anything, mock.Anything).Return(0, 0).Maybe()
112+ limiter.EXPECT().Reserve(mock.Anything, mock.Anything).Return(true, time.Duration(0), func() {}).Maybe()
113+ bus := mocks.NewEventBus(t)
114+ var kinds []string
115+ bus.EXPECT().Publish(mock.Anything, mock.Anything).Run(func(_ context.Context, ev domain.Event) {
116+ repo, err := store.Get(ctx, n)
117+ require.NoError(t, err)
118+ if ev.EventKind() == "issue.opened" {
119+ require.Equal(t, at, repo.ActiveAt)
120+ } else {
121+ require.Equal(t, initial, repo.ActiveAt)
122+ }
123+ kinds = append(kinds, ev.EventKind())
124+ }).Maybe()
125+ submissions := usecase.NewIssueSubmissions(usecase.IssueSubmissionsConfig{Meta: store, Pending: store.PendingIssues(), Bans: store.IssueBans(), Limiter: limiter, Keys: keys, Bus: bus, Clock: clock, BaseDifficulty: testBits, MaxDifficulty: testBits})
126+ moderation := usecase.NewIssueModeration(usecase.IssueModerationConfig{Meta: store, Pending: store.PendingIssues(), Bans: store.IssueBans(), Bus: bus, Clock: clock})
127+ submit := func() domain.PendingIssueID {
128+ ch, err := submissions.Challenge(ctx, n, "client")
129+ require.NoError(t, err)
130+ sub := ports.IssueSubmission{Token: ch.Token, FormToken: ch.FormToken, Solution: solve(t, ch), Title: "Bug", Body: "Details"}
131+ at = at.Add(usecase.MinFillTime)
132+ require.NoError(t, submissions.Submit(ctx, n, "client", sub))
133+ repo, err := store.Get(ctx, n)
134+ require.NoError(t, err)
135+ require.Equal(t, initial, repo.ActiveAt)
136+ page, err := store.PendingIssues().List(ctx, n, ports.ListPendingParams{PageSize: 10})
137+ require.NoError(t, err)
138+ require.Len(t, page.Issues, 1)
139+ return page.Issues[0].ID
140+ }
141+ id := submit()
142+ require.NoError(t, moderation.Reject(ctx, domain.Owner(), n, id, false))
143+ repo, err := store.Get(ctx, n)
144+ require.NoError(t, err)
145+ require.Equal(t, initial, repo.ActiveAt)
146+ id = submit()
147+ _, err = moderation.Approve(ctx, domain.Owner(), n, id)
148+ require.NoError(t, err)
149+ repo, err = store.Get(ctx, n)
150+ require.NoError(t, err)
151+ require.Equal(t, at, repo.ActiveAt)
152+ require.True(t, repo.PushedAt.IsZero())
153+ require.Equal(t, []string{"issue.submitted", "issue.pending_removed", "issue.submitted", "issue.opened"}, kinds)
154+}
M internal/usecase/issues_test.go +2 -0
@@ -39,6 +39,7 @@ func newIssueUnit(t *testing.T, vis domain.Visibility, mode domain.IssuesMode) *
3939 clock := mocks.NewClock(t)
4040 clock.EXPECT().Now().Return(now).Maybe()
4141 u.meta.EXPECT().Get(mock.Anything, u.repo.Name).Return(u.repo, nil).Maybe()
42+ expectRepoMutation(t, u.meta, u.repo, mock.MatchedBy(func(r domain.Repo) bool { return r.ActiveAt.Equal(now) && r.PushedAt.IsZero() })).Maybe()
4243 u.issues = usecase.NewIssues(usecase.IssuesConfig{Meta: u.meta, Store: u.store, MergeRequests: u.mrs, Bus: u.bus, Clock: clock})
4344 return u
4445 }
@@ -116,6 +117,7 @@ func TestIssueCreateAndTransitions(t *testing.T) {
116117 got, err := u.issues.Create(ctx, p, u.repo.Name, ports.CreateIssueParams{Title: " Crash ", Body: "why"})
117118 require.NoError(t, err)
118119 assert.EqualValues(t, 7, got.Number)
120+ u.meta.AssertCalled(t, "Mutate", mock.Anything, u.repo.Name, mock.Anything)
119121 })
120122
121123 t.Run("invalid text stores nothing", func(t *testing.T) {
@@ -39,6 +39,7 @@ func newIssueUnit(t *testing.T, vis domain.Visibility, mode domain.IssuesMode) *
39 clock := mocks.NewClock(t)39 clock := mocks.NewClock(t)
40 clock.EXPECT().Now().Return(now).Maybe()40 clock.EXPECT().Now().Return(now).Maybe()
41 u.meta.EXPECT().Get(mock.Anything, u.repo.Name).Return(u.repo, nil).Maybe()41 u.meta.EXPECT().Get(mock.Anything, u.repo.Name).Return(u.repo, nil).Maybe()
42+ expectRepoMutation(t, u.meta, u.repo, mock.MatchedBy(func(r domain.Repo) bool { return r.ActiveAt.Equal(now) && r.PushedAt.IsZero() })).Maybe()
42 u.issues = usecase.NewIssues(usecase.IssuesConfig{Meta: u.meta, Store: u.store, MergeRequests: u.mrs, Bus: u.bus, Clock: clock})43 u.issues = usecase.NewIssues(usecase.IssuesConfig{Meta: u.meta, Store: u.store, MergeRequests: u.mrs, Bus: u.bus, Clock: clock})
43 return u44 return u
44 }45 }
@@ -116,6 +117,7 @@ func TestIssueCreateAndTransitions(t *testing.T) {
116 got, err := u.issues.Create(ctx, p, u.repo.Name, ports.CreateIssueParams{Title: " Crash ", Body: "why"})117 got, err := u.issues.Create(ctx, p, u.repo.Name, ports.CreateIssueParams{Title: " Crash ", Body: "why"})
117 require.NoError(t, err)118 require.NoError(t, err)
118 assert.EqualValues(t, 7, got.Number)119 assert.EqualValues(t, 7, got.Number)
120+ u.meta.AssertCalled(t, "Mutate", mock.Anything, u.repo.Name, mock.Anything)
119 })121 })
120 122
121 t.Run("invalid text stores nothing", func(t *testing.T) {123 t.Run("invalid text stores nothing", func(t *testing.T) {
M internal/usecase/mergerequests.go +5 -0
@@ -254,6 +254,7 @@ func (m *MergeRequests) Create(ctx context.Context, p domain.Principal, name dom
254254 if err != nil {
255255 return domain.MergeRequest{}, err
256256 }
257+ recordActivity(ctx, m.meta, repo.Name, now)
257258 m.bus.Publish(ctx, domain.MergeRequestOpened{MergeRequest: mr, At: now, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
258259 if m.pulls != nil {
259260 // The commits the opening is for are the ones of now.
@@ -377,6 +378,7 @@ func (m *MergeRequests) MarkReady(ctx context.Context, p domain.Principal, name
377378 if err != nil {
378379 return domain.MergeRequest{}, fmt.Errorf("mark !%d of %q ready: %w", number, name, err)
379380 }
381+ recordActivity(ctx, m.meta, repo.Name, mr.UpdatedAt)
380382 m.bus.Publish(ctx, domain.MergeRequestReady{MergeRequest: mr, At: mr.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
381383 return m.withHead(ctx, mr)
382384 }
@@ -656,6 +658,7 @@ func (m *MergeRequests) AddComment(ctx context.Context, p domain.Principal, name
656658 log := zerowrap.FromCtx(ctx)
657659 log.Info().Str("repo", name.String()).Int64("number", number).Int64("comment", c.ID).
658660 Int64("reply_to", c.ReplyTo).Str("verdict", c.Verdict.String()).Bool("on_line", !general).Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("comment added")
661+ recordActivity(ctx, m.meta, name, c.CreatedAt)
659662 m.bus.Publish(ctx, domain.CommentAdded{
660663 Repo: name, Number: number, Comment: c, At: c.CreatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt,
661664 })
@@ -796,6 +799,7 @@ func (m *MergeRequests) Close(ctx context.Context, p domain.Principal, name doma
796799 log := zerowrap.FromCtx(ctx)
797800 log.Info().Str("repo", name.String()).Int64("number", number).
798801 Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("merge request closed")
802+ recordActivity(ctx, m.meta, repo.Name, mr.UpdatedAt)
799803 m.bus.Publish(ctx, domain.MergeRequestClosed{MergeRequest: mr, At: mr.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
800804 if m.pulls != nil {
801805 m.pulls.OnPullRequest(ctx, name, ports.PullRequestEvent{Number: number, Action: domain.PullClosed, Head: mr.HeadID, Base: mr.BaseID}, p)
@@ -998,6 +1002,7 @@ func (m *MergeRequests) merge(ctx context.Context, p domain.Principal, name doma
9981002 }
9991003 }
10001004 at := mr.UpdatedAt
1005+ recordActivity(ctx, m.meta, repo.Name, at)
10011006 m.bus.Publish(ctx, domain.MergeRequestMerged{MergeRequest: mr, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
10021007 m.bus.Publish(ctx, domain.PushEvent{
10031008 Repo: name, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt, Updates: updates,
@@ -254,6 +254,7 @@ func (m *MergeRequests) Create(ctx context.Context, p domain.Principal, name dom
254 if err != nil {254 if err != nil {
255 return domain.MergeRequest{}, err255 return domain.MergeRequest{}, err
256 }256 }
257+ recordActivity(ctx, m.meta, repo.Name, now)
257 m.bus.Publish(ctx, domain.MergeRequestOpened{MergeRequest: mr, At: now, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})258 m.bus.Publish(ctx, domain.MergeRequestOpened{MergeRequest: mr, At: now, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
258 if m.pulls != nil {259 if m.pulls != nil {
259 // The commits the opening is for are the ones of now.260 // The commits the opening is for are the ones of now.
@@ -377,6 +378,7 @@ func (m *MergeRequests) MarkReady(ctx context.Context, p domain.Principal, name
377 if err != nil {378 if err != nil {
378 return domain.MergeRequest{}, fmt.Errorf("mark !%d of %q ready: %w", number, name, err)379 return domain.MergeRequest{}, fmt.Errorf("mark !%d of %q ready: %w", number, name, err)
379 }380 }
381+ recordActivity(ctx, m.meta, repo.Name, mr.UpdatedAt)
380 m.bus.Publish(ctx, domain.MergeRequestReady{MergeRequest: mr, At: mr.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})382 m.bus.Publish(ctx, domain.MergeRequestReady{MergeRequest: mr, At: mr.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
381 return m.withHead(ctx, mr)383 return m.withHead(ctx, mr)
382 }384 }
@@ -656,6 +658,7 @@ func (m *MergeRequests) AddComment(ctx context.Context, p domain.Principal, name
656 log := zerowrap.FromCtx(ctx)658 log := zerowrap.FromCtx(ctx)
657 log.Info().Str("repo", name.String()).Int64("number", number).Int64("comment", c.ID).659 log.Info().Str("repo", name.String()).Int64("number", number).Int64("comment", c.ID).
658 Int64("reply_to", c.ReplyTo).Str("verdict", c.Verdict.String()).Bool("on_line", !general).Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("comment added")660 Int64("reply_to", c.ReplyTo).Str("verdict", c.Verdict.String()).Bool("on_line", !general).Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("comment added")
661+ recordActivity(ctx, m.meta, name, c.CreatedAt)
659 m.bus.Publish(ctx, domain.CommentAdded{662 m.bus.Publish(ctx, domain.CommentAdded{
660 Repo: name, Number: number, Comment: c, At: c.CreatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt,663 Repo: name, Number: number, Comment: c, At: c.CreatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt,
661 })664 })
@@ -796,6 +799,7 @@ func (m *MergeRequests) Close(ctx context.Context, p domain.Principal, name doma
796 log := zerowrap.FromCtx(ctx)799 log := zerowrap.FromCtx(ctx)
797 log.Info().Str("repo", name.String()).Int64("number", number).800 log.Info().Str("repo", name.String()).Int64("number", number).
798 Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("merge request closed")801 Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("merge request closed")
802+ recordActivity(ctx, m.meta, repo.Name, mr.UpdatedAt)
799 m.bus.Publish(ctx, domain.MergeRequestClosed{MergeRequest: mr, At: mr.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})803 m.bus.Publish(ctx, domain.MergeRequestClosed{MergeRequest: mr, At: mr.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
800 if m.pulls != nil {804 if m.pulls != nil {
801 m.pulls.OnPullRequest(ctx, name, ports.PullRequestEvent{Number: number, Action: domain.PullClosed, Head: mr.HeadID, Base: mr.BaseID}, p)805 m.pulls.OnPullRequest(ctx, name, ports.PullRequestEvent{Number: number, Action: domain.PullClosed, Head: mr.HeadID, Base: mr.BaseID}, p)
@@ -998,6 +1002,7 @@ func (m *MergeRequests) merge(ctx context.Context, p domain.Principal, name doma
998 }1002 }
999 }1003 }
1000 at := mr.UpdatedAt1004 at := mr.UpdatedAt
1005+ recordActivity(ctx, m.meta, repo.Name, at)
1001 m.bus.Publish(ctx, domain.MergeRequestMerged{MergeRequest: mr, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})1006 m.bus.Publish(ctx, domain.MergeRequestMerged{MergeRequest: mr, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
1002 m.bus.Publish(ctx, domain.PushEvent{1007 m.bus.Publish(ctx, domain.PushEvent{
1003 Repo: name, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt, Updates: updates,1008 Repo: name, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt, Updates: updates,
M internal/usecase/mergerequests_integration_test.go +11 -0
@@ -1448,3 +1448,14 @@ func TestNoteThreadsRangesAndVerdicts(t *testing.T) {
14481448 _, err = r.mrs.DeleteComment(r.ctx, root, r.repo, mr.Number, changes.ID)
14491449 assert.ErrorIs(t, err, domain.ErrPreconditionFailed)
14501450 }
1451+
1452+func TestMergeRequestRecordsRepositoryActivity(t *testing.T) {
1453+ r := newMRRig(t)
1454+ r.seed()
1455+ r.feature("activity")
1456+ _, err := r.mrs.Create(r.ctx, root, r.repo, ports.CreateMergeRequestParams{Title: "Activity", Source: "activity", Target: "main"})
1457+ require.NoError(t, err)
1458+ repo, err := r.store.Get(r.ctx, r.repo)
1459+ require.NoError(t, err)
1460+ assert.True(t, repo.ActiveAt.Equal(now))
1461+}
@@ -1448,3 +1448,14 @@ func TestNoteThreadsRangesAndVerdicts(t *testing.T) {
1448 _, err = r.mrs.DeleteComment(r.ctx, root, r.repo, mr.Number, changes.ID)1448 _, err = r.mrs.DeleteComment(r.ctx, root, r.repo, mr.Number, changes.ID)
1449 assert.ErrorIs(t, err, domain.ErrPreconditionFailed)1449 assert.ErrorIs(t, err, domain.ErrPreconditionFailed)
1450 }1450 }
1451+
1452+func TestMergeRequestRecordsRepositoryActivity(t *testing.T) {
1453+ r := newMRRig(t)
1454+ r.seed()
1455+ r.feature("activity")
1456+ _, err := r.mrs.Create(r.ctx, root, r.repo, ports.CreateMergeRequestParams{Title: "Activity", Source: "activity", Target: "main"})
1457+ require.NoError(t, err)
1458+ repo, err := r.store.Get(r.ctx, r.repo)
1459+ require.NoError(t, err)
1460+ assert.True(t, repo.ActiveAt.Equal(now))
1461+}
M internal/usecase/pushes.go +21 -29
@@ -15,9 +15,6 @@ import (
1515 "bnema.dev/ermite/internal/ports"
1616 )
1717
18-// pushRetries bounds the retries of a metadata update that loses a race.
19-const pushRetries = 5
20-
2118 // Pushes are the use cases around a Git push.
2219 type Pushes struct {
2320 meta ports.RepoMetaStore
@@ -209,7 +206,7 @@ func (u *Pushes) checkWorkflows(ctx context.Context, name domain.RepoName, updat
209206 return nil
210207 }
211208
212-// Record is the post-receive step of an accepted push: it sets pushed_at,
209+// Record is the post-receive step of an accepted push: it sets pushed_at and active_at,
213210 // repairs HEAD when it points to a missing branch, tells the reader that the
214211 // repository changed, queues the workflows the push triggers, publishes a
215212 // PushEvent and schedules maintenance. Every
@@ -245,36 +242,31 @@ func (u *Pushes) Record(ctx context.Context, p domain.Principal, name domain.Rep
245242 return errors.Join(errs...)
246243 }
247244
248-// touchPushedAt stores pushed_at, retrying when another write got in between.
245+// touchPushedAt stores pushed_at and active_at, preserving later timestamps
246+// and retrying when another write got in between.
249247 // 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-// merge, which moves a branch too, both record through it.
248+// as stored after mutation, or the zero value when it is unknown. A push
249+// (Pushes.Record) and a merge, which moves a branch too, both record through it.
252250 func touchPushedAt(ctx context.Context, meta ports.RepoMetaStore, name domain.RepoName, now time.Time) (domain.Repo, error) {
253- for range pushRetries {
254- repo, err := meta.Get(ctx, name)
255- if errors.Is(err, domain.ErrNotFound) {
256- return domain.Repo{}, nil
257- }
258- if err != nil {
259- return domain.Repo{}, fmt.Errorf("get repository %q: %w", name, err)
260- }
261- if !now.After(repo.PushedAt) {
262- return repo, nil // a later push already recorded
263- }
264- repo.PushedAt = now
265- err = meta.Update(ctx, repo)
266- if errors.Is(err, domain.ErrConflict) {
267- continue
251+ repo, err := meta.Mutate(ctx, name, func(repo *domain.Repo) (bool, error) {
252+ changed := false
253+ if now.After(repo.PushedAt) {
254+ repo.PushedAt = now
255+ changed = true
268256 }
269- if errors.Is(err, domain.ErrNotFound) {
270- return domain.Repo{}, nil
257+ if now.After(repo.ActiveAt) {
258+ repo.ActiveAt = now
259+ changed = true
271260 }
272- if err != nil {
273- return repo, fmt.Errorf("update repository %q: %w", name, err)
274- }
275- return repo, nil
261+ return changed, nil
262+ })
263+ if errors.Is(err, domain.ErrNotFound) {
264+ return domain.Repo{}, nil
265+ }
266+ if err != nil {
267+ return repo, fmt.Errorf("update repository %q: %w", name, err)
276268 }
277- return domain.Repo{}, fmt.Errorf("update pushed_at of %q: %w", name, domain.ErrConflict)
269+ return repo, nil
278270 }
279271
280272 // fixDefaultBranch points HEAD to a branch that exists when it points to a
@@ -15,9 +15,6 @@ import (
15 "bnema.dev/ermite/internal/ports"15 "bnema.dev/ermite/internal/ports"
16 )16 )
17 17
18-// pushRetries bounds the retries of a metadata update that loses a race.
19-const pushRetries = 5
20-
21 // Pushes are the use cases around a Git push.18 // Pushes are the use cases around a Git push.
22 type Pushes struct {19 type Pushes struct {
23 meta ports.RepoMetaStore20 meta ports.RepoMetaStore
@@ -209,7 +206,7 @@ func (u *Pushes) checkWorkflows(ctx context.Context, name domain.RepoName, updat
209 return nil206 return nil
210 }207 }
211 208
212-// Record is the post-receive step of an accepted push: it sets pushed_at,209+// 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 the210 // repairs HEAD when it points to a missing branch, tells the reader that the
214 // repository changed, queues the workflows the push triggers, publishes a211 // repository changed, queues the workflows the push triggers, publishes a
215 // PushEvent and schedules maintenance. Every212 // PushEvent and schedules maintenance. Every
@@ -245,36 +242,31 @@ func (u *Pushes) Record(ctx context.Context, p domain.Principal, name domain.Rep
245 return errors.Join(errs...)242 return errors.Join(errs...)
246 }243 }
247 244
248-// touchPushedAt stores pushed_at, retrying when another write got in between.245+// touchPushedAt stores pushed_at and active_at, preserving later timestamps
246+// and retrying when another write got in between.
249 // A repository deleted meanwhile is not an error. It returns the repository247 // 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 a248+// as stored after mutation, or the zero value when it is unknown. A push
251-// merge, which moves a branch too, both record through it.249+// (Pushes.Record) and a merge, which moves a branch too, both record through it.
252 func touchPushedAt(ctx context.Context, meta ports.RepoMetaStore, name domain.RepoName, now time.Time) (domain.Repo, error) {250 func touchPushedAt(ctx context.Context, meta ports.RepoMetaStore, name domain.RepoName, now time.Time) (domain.Repo, error) {
253- for range pushRetries {251+ repo, err := meta.Mutate(ctx, name, func(repo *domain.Repo) (bool, error) {
254- repo, err := meta.Get(ctx, name)252+ changed := false
255- if errors.Is(err, domain.ErrNotFound) {253+ if now.After(repo.PushedAt) {
256- return domain.Repo{}, nil254+ repo.PushedAt = now
257- }255+ changed = true
258- if err != nil {
259- return domain.Repo{}, fmt.Errorf("get repository %q: %w", name, err)
260- }
261- if !now.After(repo.PushedAt) {
262- return repo, nil // a later push already recorded
263- }
264- repo.PushedAt = now
265- err = meta.Update(ctx, repo)
266- if errors.Is(err, domain.ErrConflict) {
267- continue
268 }256 }
269- if errors.Is(err, domain.ErrNotFound) {257+ if now.After(repo.ActiveAt) {
270- return domain.Repo{}, nil258+ repo.ActiveAt = now
259+ changed = true
271 }260 }
272- if err != nil {261+ return changed, nil
273- return repo, fmt.Errorf("update repository %q: %w", name, err)262+ })
274- }263+ if errors.Is(err, domain.ErrNotFound) {
275- return repo, nil264+ return domain.Repo{}, nil
265+ }
266+ if err != nil {
267+ return repo, fmt.Errorf("update repository %q: %w", name, err)
276 }268 }
277- return domain.Repo{}, fmt.Errorf("update pushed_at of %q: %w", name, domain.ErrConflict)269+ return repo, nil
278 }270 }
279 271
280 // fixDefaultBranch points HEAD to a branch that exists when it points to a272 // fixDefaultBranch points HEAD to a branch that exists when it points to a
M internal/usecase/pushes_test.go +11 -16
@@ -305,8 +305,7 @@ func TestPushesRecord(t *testing.T) {
305305 f := newPushFixture(t)
306306 f.sched.EXPECT().Schedule(ctx, n).Return().Once()
307307 repo := domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, CreatedAt: now.Add(-time.Hour)}
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)
308+ expectRepoMutation(t, f.meta, repo, domain.Repo{Name: n, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt, PushedAt: now, ActiveAt: now}).Once()
310309 f.git.EXPECT().DefaultBranch(ctx, n).Return("main", true, nil)
311310 f.bus.EXPECT().Publish(ctx, domain.PushEvent{Repo: n, Updates: ups, At: now, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt}).Return()
312311
@@ -320,22 +319,18 @@ func TestPushesRecord(t *testing.T) {
320319 fresh := mocks.NewRepoFreshener(t)
321320 fresh.EXPECT().Refresh(ctx, n).Return().Once()
322321 f.pushes.WithFreshener(fresh)
323- f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{Name: n}, nil)
324- f.meta.EXPECT().Update(ctx, mock.Anything).Return(nil)
322+ expectPushMutation(t, f.meta, domain.Repo{Name: n}, nil)
325323 f.git.EXPECT().DefaultBranch(ctx, n).Return("main", true, nil)
326324 f.bus.EXPECT().Publish(ctx, mock.Anything).Return()
327325 require.NoError(t, f.pushes.Record(ctx, p, n, ups))
328326 })
329327
330- t.Run("pushed_at retries after a conflict and never goes backwards", func(t *testing.T) {
328+ t.Run("pushed_at preserves a later push", func(t *testing.T) {
331329 t.Parallel()
332330 f := newPushFixture(t)
333331 f.sched.EXPECT().Schedule(ctx, n).Return().Once()
334- repo := domain.Repo{Name: n}
335- f.meta.EXPECT().Get(ctx, n).Return(repo, nil).Once()
336- f.meta.EXPECT().Update(ctx, mock.Anything).Return(domain.ErrConflict).Once()
337- later := domain.Repo{Name: n, PushedAt: now.Add(time.Second)}
338- f.meta.EXPECT().Get(ctx, n).Return(later, nil).Once() // a later push recorded meanwhile
332+ later := domain.Repo{Name: n, PushedAt: now.Add(time.Second), ActiveAt: now.Add(time.Second)}
333+ expectPushMutation(t, f.meta, later, nil) // a later push recorded meanwhile
339334 f.git.EXPECT().DefaultBranch(ctx, n).Return("main", true, nil)
340335 f.bus.EXPECT().Publish(ctx, mock.Anything).Return()
341336 require.NoError(t, f.pushes.Record(ctx, p, n, ups))
@@ -345,7 +340,7 @@ func TestPushesRecord(t *testing.T) {
345340 t.Parallel()
346341 f := newPushFixture(t)
347342 f.sched.EXPECT().Schedule(ctx, n).Return().Once()
348- f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, domain.ErrNotFound)
343+ expectPushMutation(t, f.meta, domain.Repo{}, domain.ErrNotFound)
349344 f.git.EXPECT().DefaultBranch(ctx, n).Return("main", true, nil)
350345 f.bus.EXPECT().Publish(ctx, mock.Anything).Return()
351346 require.NoError(t, f.pushes.Record(ctx, p, n, ups))
@@ -355,7 +350,7 @@ func TestPushesRecord(t *testing.T) {
355350 t.Parallel()
356351 f := newPushFixture(t)
357352 f.sched.EXPECT().Schedule(ctx, n).Return().Once()
358- f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, domain.ErrNotFound)
353+ expectPushMutation(t, f.meta, domain.Repo{}, domain.ErrNotFound)
359354 f.git.EXPECT().DefaultBranch(ctx, n).Return("", false, domain.ErrNotFound)
360355 f.bus.EXPECT().Publish(ctx, mock.Anything).Return()
361356 require.NoError(t, f.pushes.Record(ctx, p, n, ups))
@@ -365,7 +360,7 @@ func TestPushesRecord(t *testing.T) {
365360 t.Parallel()
366361 f := newPushFixture(t)
367362 f.sched.EXPECT().Schedule(ctx, n).Return().Once()
368- f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, domain.ErrNotFound)
363+ expectPushMutation(t, f.meta, domain.Repo{}, domain.ErrNotFound)
369364 f.git.EXPECT().DefaultBranch(ctx, n).Return("trunk", false, nil)
370365 f.git.EXPECT().SetDefaultBranch(ctx, n, "main").Return(domain.ErrNotFound)
371366 f.bus.EXPECT().Publish(ctx, mock.Anything).Return()
@@ -377,7 +372,7 @@ func TestPushesRecord(t *testing.T) {
377372 f := newPushFixture(t)
378373 f.sched.EXPECT().Schedule(ctx, n).Return().Once()
379374 boom := errors.New("boom")
380- f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, boom)
375+ expectPushMutation(t, f.meta, domain.Repo{}, boom)
381376 f.git.EXPECT().DefaultBranch(ctx, n).Return("", false, boom)
382377 f.bus.EXPECT().Publish(ctx, mock.Anything).Return()
383378 err := f.pushes.Record(ctx, p, n, ups)
@@ -414,7 +409,7 @@ func TestPushesDefaultBranchRule(t *testing.T) {
414409 t.Parallel()
415410 f := newPushFixture(t)
416411 f.sched.EXPECT().Schedule(ctx, n).Return().Once()
417- f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{Name: n, PushedAt: now}, nil)
412+ expectPushMutation(t, f.meta, domain.Repo{Name: n, PushedAt: now, ActiveAt: now}, nil)
418413 f.git.EXPECT().DefaultBranch(ctx, n).Return(tt.head, tt.exists, nil)
419414 if tt.want != "" {
420415 f.git.EXPECT().SetDefaultBranch(ctx, n, tt.want).Return(nil)
@@ -429,7 +424,7 @@ func TestPushesDefaultBranchRule(t *testing.T) {
429424 f := newPushFixture(t)
430425 f.sched.EXPECT().Schedule(ctx, n).Return().Once()
431426 boom := errors.New("boom")
432- f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{Name: n, PushedAt: now}, nil)
427+ expectPushMutation(t, f.meta, domain.Repo{Name: n, PushedAt: now, ActiveAt: now}, nil)
433428 f.git.EXPECT().DefaultBranch(ctx, n).Return("main", false, nil)
434429 f.git.EXPECT().SetDefaultBranch(ctx, n, "dev").Return(boom)
435430 f.bus.EXPECT().Publish(ctx, mock.Anything).Return()
@@ -305,8 +305,7 @@ func TestPushesRecord(t *testing.T) {
305 f := newPushFixture(t)305 f := newPushFixture(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+ expectRepoMutation(t, f.meta, repo, domain.Repo{Name: n, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt, PushedAt: now, ActiveAt: now}).Once()
309- f.meta.EXPECT().Update(ctx, domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, CreatedAt: repo.CreatedAt, PushedAt: now}).Return(nil)
310 f.git.EXPECT().DefaultBranch(ctx, n).Return("main", true, nil)309 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()310 f.bus.EXPECT().Publish(ctx, domain.PushEvent{Repo: n, Updates: ups, At: now, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt}).Return()
312 311
@@ -320,22 +319,18 @@ func TestPushesRecord(t *testing.T) {
320 fresh := mocks.NewRepoFreshener(t)319 fresh := mocks.NewRepoFreshener(t)
321 fresh.EXPECT().Refresh(ctx, n).Return().Once()320 fresh.EXPECT().Refresh(ctx, n).Return().Once()
322 f.pushes.WithFreshener(fresh)321 f.pushes.WithFreshener(fresh)
323- f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{Name: n}, nil)322+ expectPushMutation(t, f.meta, domain.Repo{Name: n}, nil)
324- f.meta.EXPECT().Update(ctx, mock.Anything).Return(nil)
325 f.git.EXPECT().DefaultBranch(ctx, n).Return("main", true, nil)323 f.git.EXPECT().DefaultBranch(ctx, n).Return("main", true, nil)
326 f.bus.EXPECT().Publish(ctx, mock.Anything).Return()324 f.bus.EXPECT().Publish(ctx, mock.Anything).Return()
327 require.NoError(t, f.pushes.Record(ctx, p, n, ups))325 require.NoError(t, f.pushes.Record(ctx, p, n, ups))
328 })326 })
329 327
330- t.Run("pushed_at retries after a conflict and never goes backwards", func(t *testing.T) {328+ t.Run("pushed_at preserves a later push", func(t *testing.T) {
331 t.Parallel()329 t.Parallel()
332 f := newPushFixture(t)330 f := newPushFixture(t)
333 f.sched.EXPECT().Schedule(ctx, n).Return().Once()331 f.sched.EXPECT().Schedule(ctx, n).Return().Once()
334- repo := domain.Repo{Name: n}332+ later := domain.Repo{Name: n, PushedAt: now.Add(time.Second), ActiveAt: now.Add(time.Second)}
335- f.meta.EXPECT().Get(ctx, n).Return(repo, nil).Once()333+ expectPushMutation(t, f.meta, later, nil) // a later push recorded meanwhile
336- f.meta.EXPECT().Update(ctx, mock.Anything).Return(domain.ErrConflict).Once()
337- later := domain.Repo{Name: n, PushedAt: now.Add(time.Second)}
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)334 f.git.EXPECT().DefaultBranch(ctx, n).Return("main", true, nil)
340 f.bus.EXPECT().Publish(ctx, mock.Anything).Return()335 f.bus.EXPECT().Publish(ctx, mock.Anything).Return()
341 require.NoError(t, f.pushes.Record(ctx, p, n, ups))336 require.NoError(t, f.pushes.Record(ctx, p, n, ups))
@@ -345,7 +340,7 @@ func TestPushesRecord(t *testing.T) {
345 t.Parallel()340 t.Parallel()
346 f := newPushFixture(t)341 f := newPushFixture(t)
347 f.sched.EXPECT().Schedule(ctx, n).Return().Once()342 f.sched.EXPECT().Schedule(ctx, n).Return().Once()
348- f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, domain.ErrNotFound)343+ expectPushMutation(t, f.meta, domain.Repo{}, domain.ErrNotFound)
349 f.git.EXPECT().DefaultBranch(ctx, n).Return("main", true, nil)344 f.git.EXPECT().DefaultBranch(ctx, n).Return("main", true, nil)
350 f.bus.EXPECT().Publish(ctx, mock.Anything).Return()345 f.bus.EXPECT().Publish(ctx, mock.Anything).Return()
351 require.NoError(t, f.pushes.Record(ctx, p, n, ups))346 require.NoError(t, f.pushes.Record(ctx, p, n, ups))
@@ -355,7 +350,7 @@ func TestPushesRecord(t *testing.T) {
355 t.Parallel()350 t.Parallel()
356 f := newPushFixture(t)351 f := newPushFixture(t)
357 f.sched.EXPECT().Schedule(ctx, n).Return().Once()352 f.sched.EXPECT().Schedule(ctx, n).Return().Once()
358- f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, domain.ErrNotFound)353+ expectPushMutation(t, f.meta, domain.Repo{}, domain.ErrNotFound)
359 f.git.EXPECT().DefaultBranch(ctx, n).Return("", false, domain.ErrNotFound)354 f.git.EXPECT().DefaultBranch(ctx, n).Return("", false, domain.ErrNotFound)
360 f.bus.EXPECT().Publish(ctx, mock.Anything).Return()355 f.bus.EXPECT().Publish(ctx, mock.Anything).Return()
361 require.NoError(t, f.pushes.Record(ctx, p, n, ups))356 require.NoError(t, f.pushes.Record(ctx, p, n, ups))
@@ -365,7 +360,7 @@ func TestPushesRecord(t *testing.T) {
365 t.Parallel()360 t.Parallel()
366 f := newPushFixture(t)361 f := newPushFixture(t)
367 f.sched.EXPECT().Schedule(ctx, n).Return().Once()362 f.sched.EXPECT().Schedule(ctx, n).Return().Once()
368- f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, domain.ErrNotFound)363+ expectPushMutation(t, f.meta, domain.Repo{}, domain.ErrNotFound)
369 f.git.EXPECT().DefaultBranch(ctx, n).Return("trunk", false, nil)364 f.git.EXPECT().DefaultBranch(ctx, n).Return("trunk", false, nil)
370 f.git.EXPECT().SetDefaultBranch(ctx, n, "main").Return(domain.ErrNotFound)365 f.git.EXPECT().SetDefaultBranch(ctx, n, "main").Return(domain.ErrNotFound)
371 f.bus.EXPECT().Publish(ctx, mock.Anything).Return()366 f.bus.EXPECT().Publish(ctx, mock.Anything).Return()
@@ -377,7 +372,7 @@ func TestPushesRecord(t *testing.T) {
377 f := newPushFixture(t)372 f := newPushFixture(t)
378 f.sched.EXPECT().Schedule(ctx, n).Return().Once()373 f.sched.EXPECT().Schedule(ctx, n).Return().Once()
379 boom := errors.New("boom")374 boom := errors.New("boom")
380- f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, boom)375+ expectPushMutation(t, f.meta, domain.Repo{}, boom)
381 f.git.EXPECT().DefaultBranch(ctx, n).Return("", false, boom)376 f.git.EXPECT().DefaultBranch(ctx, n).Return("", false, boom)
382 f.bus.EXPECT().Publish(ctx, mock.Anything).Return()377 f.bus.EXPECT().Publish(ctx, mock.Anything).Return()
383 err := f.pushes.Record(ctx, p, n, ups)378 err := f.pushes.Record(ctx, p, n, ups)
@@ -414,7 +409,7 @@ func TestPushesDefaultBranchRule(t *testing.T) {
414 t.Parallel()409 t.Parallel()
415 f := newPushFixture(t)410 f := newPushFixture(t)
416 f.sched.EXPECT().Schedule(ctx, n).Return().Once()411 f.sched.EXPECT().Schedule(ctx, n).Return().Once()
417- f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{Name: n, PushedAt: now}, nil)412+ expectPushMutation(t, f.meta, domain.Repo{Name: n, PushedAt: now, ActiveAt: now}, nil)
418 f.git.EXPECT().DefaultBranch(ctx, n).Return(tt.head, tt.exists, nil)413 f.git.EXPECT().DefaultBranch(ctx, n).Return(tt.head, tt.exists, nil)
419 if tt.want != "" {414 if tt.want != "" {
420 f.git.EXPECT().SetDefaultBranch(ctx, n, tt.want).Return(nil)415 f.git.EXPECT().SetDefaultBranch(ctx, n, tt.want).Return(nil)
@@ -429,7 +424,7 @@ func TestPushesDefaultBranchRule(t *testing.T) {
429 f := newPushFixture(t)424 f := newPushFixture(t)
430 f.sched.EXPECT().Schedule(ctx, n).Return().Once()425 f.sched.EXPECT().Schedule(ctx, n).Return().Once()
431 boom := errors.New("boom")426 boom := errors.New("boom")
432- f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{Name: n, PushedAt: now}, nil)427+ expectPushMutation(t, f.meta, domain.Repo{Name: n, PushedAt: now, ActiveAt: now}, nil)
433 f.git.EXPECT().DefaultBranch(ctx, n).Return("main", false, nil)428 f.git.EXPECT().DefaultBranch(ctx, n).Return("main", false, nil)
434 f.git.EXPECT().SetDefaultBranch(ctx, n, "dev").Return(boom)429 f.git.EXPECT().SetDefaultBranch(ctx, n, "dev").Return(boom)
435 f.bus.EXPECT().Publish(ctx, mock.Anything).Return()430 f.bus.EXPECT().Publish(ctx, mock.Anything).Return()
A internal/usecase/repo_mutation_test.go +34 -0
@@ -0,0 +1,34 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase_test
4+
5+import (
6+ "context"
7+ "testing"
8+
9+ "github.com/stretchr/testify/mock"
10+ "github.com/stretchr/testify/require"
11+
12+ "bnema.dev/ermite/internal/domain"
13+ "bnema.dev/ermite/internal/ports/mocks"
14+)
15+
16+func expectRepoMutation(t *testing.T, meta *mocks.RepoMetaStore, repo domain.Repo, want any) *mocks.RepoMetaStore_Mutate_Call {
17+ t.Helper()
18+ return meta.EXPECT().Mutate(mock.Anything, repo.Name, mock.Anything).RunAndReturn(func(_ context.Context, _ domain.RepoName, fn func(*domain.Repo) (bool, error)) (domain.Repo, error) {
19+ result := repo
20+ _, err := fn(&result)
21+ require.NoError(t, err)
22+ _, differences := (mock.Arguments{want}).Diff([]any{result})
23+ require.Zero(t, differences, "unexpected mutated repository: %+v", result)
24+ return result, err
25+ })
26+}
27+
28+func expectPushMutation(t *testing.T, meta *mocks.RepoMetaStore, repo domain.Repo, err error) *mocks.RepoMetaStore_Mutate_Call {
29+ t.Helper()
30+ if err != nil {
31+ return meta.EXPECT().Mutate(mock.Anything, mock.Anything, mock.Anything).Return(repo, err)
32+ }
33+ return expectRepoMutation(t, meta, repo, mock.Anything)
34+}
@@ -0,0 +1,34 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase_test
4+
5+import (
6+ "context"
7+ "testing"
8+
9+ "github.com/stretchr/testify/mock"
10+ "github.com/stretchr/testify/require"
11+
12+ "bnema.dev/ermite/internal/domain"
13+ "bnema.dev/ermite/internal/ports/mocks"
14+)
15+
16+func expectRepoMutation(t *testing.T, meta *mocks.RepoMetaStore, repo domain.Repo, want any) *mocks.RepoMetaStore_Mutate_Call {
17+ t.Helper()
18+ return meta.EXPECT().Mutate(mock.Anything, repo.Name, mock.Anything).RunAndReturn(func(_ context.Context, _ domain.RepoName, fn func(*domain.Repo) (bool, error)) (domain.Repo, error) {
19+ result := repo
20+ _, err := fn(&result)
21+ require.NoError(t, err)
22+ _, differences := (mock.Arguments{want}).Diff([]any{result})
23+ require.Zero(t, differences, "unexpected mutated repository: %+v", result)
24+ return result, err
25+ })
26+}
27+
28+func expectPushMutation(t *testing.T, meta *mocks.RepoMetaStore, repo domain.Repo, err error) *mocks.RepoMetaStore_Mutate_Call {
29+ t.Helper()
30+ if err != nil {
31+ return meta.EXPECT().Mutate(mock.Anything, mock.Anything, mock.Anything).Return(repo, err)
32+ }
33+ return expectRepoMutation(t, meta, repo, mock.Anything)
34+}
M internal/usecase/repos_write.go +6 -14
@@ -155,11 +155,7 @@ func (r *Repos) Update(ctx context.Context, p domain.Principal, name domain.Repo
155155 // updateMeta applies the metadata fields, retrying when a write (a push
156156 // recording pushed_at) gets in between.
157157 func (r *Repos) updateMeta(ctx context.Context, name domain.RepoName, params ports.UpdateRepoParams) (domain.Repo, error) {
158- for range pushRetries {
159- repo, err := r.meta.Get(ctx, name)
160- if err != nil {
161- return domain.Repo{}, err
162- }
158+ repo, err := r.meta.Mutate(ctx, name, func(repo *domain.Repo) (bool, error) {
163159 if params.Description != nil {
164160 repo.Description = *params.Description
165161 }
@@ -181,16 +177,12 @@ func (r *Repos) updateMeta(ctx context.Context, name domain.RepoName, params por
181177 if params.Issues != nil {
182178 repo.Settings.Issues = *params.Issues
183179 }
184- err = r.meta.Update(ctx, repo)
185- if errors.Is(err, domain.ErrConflict) {
186- continue
187- }
188- if err != nil {
189- return domain.Repo{}, fmt.Errorf("update repository %q: %w", name, err)
190- }
191- return repo, nil
180+ return true, nil
181+ })
182+ if err != nil {
183+ return domain.Repo{}, fmt.Errorf("update repository %q: %w", name, err)
192184 }
193- return domain.Repo{}, fmt.Errorf("update repository %q: %w", name, domain.ErrConflict)
185+ return repo, nil
194186 }
195187
196188 // Delete moves the repository directory to the trash, removes its metadata
@@ -155,11 +155,7 @@ func (r *Repos) Update(ctx context.Context, p domain.Principal, name domain.Repo
155 // updateMeta applies the metadata fields, retrying when a write (a push155 // updateMeta applies the metadata fields, retrying when a write (a push
156 // recording pushed_at) gets in between.156 // recording pushed_at) gets in between.
157 func (r *Repos) updateMeta(ctx context.Context, name domain.RepoName, params ports.UpdateRepoParams) (domain.Repo, error) {157 func (r *Repos) updateMeta(ctx context.Context, name domain.RepoName, params ports.UpdateRepoParams) (domain.Repo, error) {
158- for range pushRetries {158+ repo, err := r.meta.Mutate(ctx, name, func(repo *domain.Repo) (bool, error) {
159- repo, err := r.meta.Get(ctx, name)
160- if err != nil {
161- return domain.Repo{}, err
162- }
163 if params.Description != nil {159 if params.Description != nil {
164 repo.Description = *params.Description160 repo.Description = *params.Description
165 }161 }
@@ -181,16 +177,12 @@ func (r *Repos) updateMeta(ctx context.Context, name domain.RepoName, params por
181 if params.Issues != nil {177 if params.Issues != nil {
182 repo.Settings.Issues = *params.Issues178 repo.Settings.Issues = *params.Issues
183 }179 }
184- err = r.meta.Update(ctx, repo)180+ return true, nil
185- if errors.Is(err, domain.ErrConflict) {181+ })
186- continue182+ if err != nil {
187- }183+ return domain.Repo{}, fmt.Errorf("update repository %q: %w", name, err)
188- if err != nil {
189- return domain.Repo{}, fmt.Errorf("update repository %q: %w", name, err)
190- }
191- return repo, nil
192 }184 }
193- return domain.Repo{}, fmt.Errorf("update repository %q: %w", name, domain.ErrConflict)185+ return repo, nil
194 }186 }
195 187
196 // Delete moves the repository directory to the trash, removes its metadata188 // Delete moves the repository directory to the trash, removes its metadata
M internal/usecase/repos_write_test.go +20 -21
@@ -30,9 +30,9 @@ func TestUpdate(t *testing.T) {
3030 t.Run("description and visibility are stored and RepoUpdated is published", func(t *testing.T) {
3131 t.Parallel()
3232 f := newFixture(t)
33- f.meta.EXPECT().Get(ctx, n).Return(priv, nil).Twice()
33+ f.meta.EXPECT().Get(ctx, n).Return(priv, nil).Once()
3434 want := domain.Repo{Name: n, Visibility: domain.VisibilityPublic, Description: "new", Settings: domain.DefaultRepoSettings()}
35- f.meta.EXPECT().Update(ctx, want).Return(nil)
35+ expectRepoMutation(t, f.meta, priv, want)
3636 f.bus.EXPECT().Publish(ctx, domain.RepoUpdated{
3737 Repo: n, At: now, Visibility: domain.VisibilityPublic, Previous: domain.VisibilityPrivate,
3838 }).Return().Once()
@@ -47,11 +47,11 @@ func TestUpdate(t *testing.T) {
4747 t.Run("topics are normalized and stored, and RepoUpdated is published", func(t *testing.T) {
4848 t.Parallel()
4949 f := newFixture(t)
50- f.meta.EXPECT().Get(ctx, n).Return(priv, nil).Twice()
50+ f.meta.EXPECT().Get(ctx, n).Return(priv, nil).Once()
5151 want := priv
5252 want.Topics = []string{"cli", "go"}
5353 want.Settings = domain.DefaultRepoSettings()
54- f.meta.EXPECT().Update(ctx, want).Return(nil)
54+ expectRepoMutation(t, f.meta, priv, want)
5555 f.bus.EXPECT().Publish(ctx, domain.RepoUpdated{
5656 Repo: n, At: now, Visibility: domain.VisibilityPrivate, Previous: domain.VisibilityPrivate,
5757 }).Return().Once()
@@ -66,10 +66,10 @@ func TestUpdate(t *testing.T) {
6666 f := newFixture(t)
6767 withTopics := priv
6868 withTopics.Topics = []string{"go"}
69- f.meta.EXPECT().Get(ctx, n).Return(withTopics, nil).Twice()
69+ f.meta.EXPECT().Get(ctx, n).Return(withTopics, nil).Once()
7070 want := priv
7171 want.Settings = domain.DefaultRepoSettings()
72- f.meta.EXPECT().Update(ctx, want).Return(nil)
72+ expectRepoMutation(t, f.meta, withTopics, want)
7373 f.bus.EXPECT().Publish(ctx, mock.Anything).Return().Once()
7474
7575 got, err := f.repos.Update(ctx, admin, n, ports.UpdateRepoParams{Topics: &[]string{}})
@@ -77,15 +77,12 @@ func TestUpdate(t *testing.T) {
7777 assert.Nil(t, got.Topics)
7878 })
7979
80- t.Run("a lost race on the metadata is retried", func(t *testing.T) {
81- t.Parallel()
80+ t.Run("exhausted mutation conflicts are returned", func(t *testing.T) {
8281 f := newFixture(t)
8382 f.meta.EXPECT().Get(ctx, n).Return(priv, nil)
84- f.meta.EXPECT().Update(ctx, mock.Anything).Return(domain.ErrConflict).Once()
85- f.meta.EXPECT().Update(ctx, mock.Anything).Return(nil).Once()
86- f.bus.EXPECT().Publish(ctx, mock.Anything).Return().Once()
83+ f.meta.EXPECT().Mutate(ctx, n, mock.Anything).Return(domain.Repo{}, domain.ErrConflict).Once()
8784 _, err := f.repos.Update(ctx, admin, n, ports.UpdateRepoParams{Description: ptr("x")})
88- require.NoError(t, err)
85+ require.ErrorIs(t, err, domain.ErrConflict)
8986 })
9087
9188 t.Run("the default branch is checked, then HEAD moves", func(t *testing.T) {
@@ -106,9 +103,11 @@ func TestUpdate(t *testing.T) {
106103 var order []string
107104 f.meta.EXPECT().Get(ctx, n).Return(priv, nil)
108105 f.git.EXPECT().HasBranch(ctx, n, "dev").Return(true, nil)
109- f.meta.EXPECT().Update(ctx, mock.Anything).RunAndReturn(func(context.Context, domain.Repo) error {
106+ f.meta.EXPECT().Mutate(ctx, n, mock.Anything).RunAndReturn(func(_ context.Context, _ domain.RepoName, fn func(*domain.Repo) (bool, error)) (domain.Repo, error) {
110107 order = append(order, "meta")
111- return nil
108+ repo := priv
109+ _, err := fn(&repo)
110+ return repo, err
112111 })
113112 f.git.EXPECT().SetDefaultBranch(ctx, n, "dev").RunAndReturn(func(context.Context, domain.RepoName, string) error {
114113 order = append(order, "head")
@@ -127,7 +126,7 @@ func TestUpdate(t *testing.T) {
127126 pub := domain.Repo{Name: n, Visibility: domain.VisibilityPublic, Description: "old"}
128127 f.meta.EXPECT().Get(ctx, n).Return(pub, nil)
129128 f.git.EXPECT().HasBranch(ctx, n, "dev").Return(true, nil)
130- f.meta.EXPECT().Update(ctx, mock.Anything).Return(nil)
129+ expectRepoMutation(t, f.meta, pub, mock.Anything)
131130 f.git.EXPECT().SetDefaultBranch(ctx, n, "dev").Return(boom)
132131 f.bus.EXPECT().Publish(ctx, domain.RepoUpdated{
133132 Repo: n, At: now, Visibility: domain.VisibilityPrivate, Previous: domain.VisibilityPublic,
@@ -250,10 +249,10 @@ func TestUpdateIssuesMode(t *testing.T) {
250249 t.Run("public is stored and published", func(t *testing.T) {
251250 t.Parallel()
252251 f := newFixture(t)
253- f.meta.EXPECT().Get(ctx, n).Return(priv, nil).Twice()
254- f.meta.EXPECT().Update(ctx, mock.MatchedBy(func(r domain.Repo) bool {
252+ f.meta.EXPECT().Get(ctx, n).Return(priv, nil).Once()
253+ expectRepoMutation(t, f.meta, priv, mock.MatchedBy(func(r domain.Repo) bool {
255254 return r.Settings.Issues == domain.IssuesPublic
256- })).Return(nil)
255+ }))
257256 f.bus.EXPECT().Publish(ctx, mock.Anything).Return().Once()
258257 repo, err := f.repos.Update(ctx, domain.Root(), n, ports.UpdateRepoParams{Issues: ptr(domain.IssuesPublic)})
259258 require.NoError(t, err)
@@ -271,10 +270,10 @@ func TestUpdateIssuesMode(t *testing.T) {
271270 t.Run("owner is stored and published", func(t *testing.T) {
272271 t.Parallel()
273272 f := newFixture(t)
274- f.meta.EXPECT().Get(ctx, n).Return(priv, nil).Twice()
275- f.meta.EXPECT().Update(ctx, mock.MatchedBy(func(r domain.Repo) bool {
273+ f.meta.EXPECT().Get(ctx, n).Return(priv, nil).Once()
274+ expectRepoMutation(t, f.meta, priv, mock.MatchedBy(func(r domain.Repo) bool {
276275 return r.Settings.Issues == domain.IssuesOwner && r.Settings.FailingChecksBlock
277- })).Return(nil)
276+ }))
278277 f.bus.EXPECT().Publish(ctx, mock.Anything).Return().Once()
279278 repo, err := f.repos.Update(ctx, domain.Root(), n, ports.UpdateRepoParams{Issues: ptr(domain.IssuesOwner)})
280279 require.NoError(t, err)
@@ -30,9 +30,9 @@ func TestUpdate(t *testing.T) {
30 t.Run("description and visibility are stored and RepoUpdated is published", func(t *testing.T) {30 t.Run("description and visibility are stored and RepoUpdated is published", func(t *testing.T) {
31 t.Parallel()31 t.Parallel()
32 f := newFixture(t)32 f := newFixture(t)
33- f.meta.EXPECT().Get(ctx, n).Return(priv, nil).Twice()33+ f.meta.EXPECT().Get(ctx, n).Return(priv, nil).Once()
34 want := domain.Repo{Name: n, Visibility: domain.VisibilityPublic, Description: "new", Settings: domain.DefaultRepoSettings()}34 want := domain.Repo{Name: n, Visibility: domain.VisibilityPublic, Description: "new", Settings: domain.DefaultRepoSettings()}
35- f.meta.EXPECT().Update(ctx, want).Return(nil)35+ expectRepoMutation(t, f.meta, priv, want)
36 f.bus.EXPECT().Publish(ctx, domain.RepoUpdated{36 f.bus.EXPECT().Publish(ctx, domain.RepoUpdated{
37 Repo: n, At: now, Visibility: domain.VisibilityPublic, Previous: domain.VisibilityPrivate,37 Repo: n, At: now, Visibility: domain.VisibilityPublic, Previous: domain.VisibilityPrivate,
38 }).Return().Once()38 }).Return().Once()
@@ -47,11 +47,11 @@ func TestUpdate(t *testing.T) {
47 t.Run("topics are normalized and stored, and RepoUpdated is published", func(t *testing.T) {47 t.Run("topics are normalized and stored, and RepoUpdated is published", func(t *testing.T) {
48 t.Parallel()48 t.Parallel()
49 f := newFixture(t)49 f := newFixture(t)
50- f.meta.EXPECT().Get(ctx, n).Return(priv, nil).Twice()50+ f.meta.EXPECT().Get(ctx, n).Return(priv, nil).Once()
51 want := priv51 want := priv
52 want.Topics = []string{"cli", "go"}52 want.Topics = []string{"cli", "go"}
53 want.Settings = domain.DefaultRepoSettings()53 want.Settings = domain.DefaultRepoSettings()
54- f.meta.EXPECT().Update(ctx, want).Return(nil)54+ expectRepoMutation(t, f.meta, priv, want)
55 f.bus.EXPECT().Publish(ctx, domain.RepoUpdated{55 f.bus.EXPECT().Publish(ctx, domain.RepoUpdated{
56 Repo: n, At: now, Visibility: domain.VisibilityPrivate, Previous: domain.VisibilityPrivate,56 Repo: n, At: now, Visibility: domain.VisibilityPrivate, Previous: domain.VisibilityPrivate,
57 }).Return().Once()57 }).Return().Once()
@@ -66,10 +66,10 @@ func TestUpdate(t *testing.T) {
66 f := newFixture(t)66 f := newFixture(t)
67 withTopics := priv67 withTopics := priv
68 withTopics.Topics = []string{"go"}68 withTopics.Topics = []string{"go"}
69- f.meta.EXPECT().Get(ctx, n).Return(withTopics, nil).Twice()69+ f.meta.EXPECT().Get(ctx, n).Return(withTopics, nil).Once()
70 want := priv70 want := priv
71 want.Settings = domain.DefaultRepoSettings()71 want.Settings = domain.DefaultRepoSettings()
72- f.meta.EXPECT().Update(ctx, want).Return(nil)72+ expectRepoMutation(t, f.meta, withTopics, want)
73 f.bus.EXPECT().Publish(ctx, mock.Anything).Return().Once()73 f.bus.EXPECT().Publish(ctx, mock.Anything).Return().Once()
74 74
75 got, err := f.repos.Update(ctx, admin, n, ports.UpdateRepoParams{Topics: &[]string{}})75 got, err := f.repos.Update(ctx, admin, n, ports.UpdateRepoParams{Topics: &[]string{}})
@@ -77,15 +77,12 @@ func TestUpdate(t *testing.T) {
77 assert.Nil(t, got.Topics)77 assert.Nil(t, got.Topics)
78 })78 })
79 79
80- t.Run("a lost race on the metadata is retried", func(t *testing.T) {80+ t.Run("exhausted mutation conflicts are returned", func(t *testing.T) {
81- t.Parallel()
82 f := newFixture(t)81 f := newFixture(t)
83 f.meta.EXPECT().Get(ctx, n).Return(priv, nil)82 f.meta.EXPECT().Get(ctx, n).Return(priv, nil)
84- f.meta.EXPECT().Update(ctx, mock.Anything).Return(domain.ErrConflict).Once()83+ f.meta.EXPECT().Mutate(ctx, n, mock.Anything).Return(domain.Repo{}, domain.ErrConflict).Once()
85- f.meta.EXPECT().Update(ctx, mock.Anything).Return(nil).Once()
86- f.bus.EXPECT().Publish(ctx, mock.Anything).Return().Once()
87 _, err := f.repos.Update(ctx, admin, n, ports.UpdateRepoParams{Description: ptr("x")})84 _, err := f.repos.Update(ctx, admin, n, ports.UpdateRepoParams{Description: ptr("x")})
88- require.NoError(t, err)85+ require.ErrorIs(t, err, domain.ErrConflict)
89 })86 })
90 87
91 t.Run("the default branch is checked, then HEAD moves", func(t *testing.T) {88 t.Run("the default branch is checked, then HEAD moves", func(t *testing.T) {
@@ -106,9 +103,11 @@ func TestUpdate(t *testing.T) {
106 var order []string103 var order []string
107 f.meta.EXPECT().Get(ctx, n).Return(priv, nil)104 f.meta.EXPECT().Get(ctx, n).Return(priv, nil)
108 f.git.EXPECT().HasBranch(ctx, n, "dev").Return(true, nil)105 f.git.EXPECT().HasBranch(ctx, n, "dev").Return(true, nil)
109- f.meta.EXPECT().Update(ctx, mock.Anything).RunAndReturn(func(context.Context, domain.Repo) error {106+ f.meta.EXPECT().Mutate(ctx, n, mock.Anything).RunAndReturn(func(_ context.Context, _ domain.RepoName, fn func(*domain.Repo) (bool, error)) (domain.Repo, error) {
110 order = append(order, "meta")107 order = append(order, "meta")
111- return nil108+ repo := priv
109+ _, err := fn(&repo)
110+ return repo, err
112 })111 })
113 f.git.EXPECT().SetDefaultBranch(ctx, n, "dev").RunAndReturn(func(context.Context, domain.RepoName, string) error {112 f.git.EXPECT().SetDefaultBranch(ctx, n, "dev").RunAndReturn(func(context.Context, domain.RepoName, string) error {
114 order = append(order, "head")113 order = append(order, "head")
@@ -127,7 +126,7 @@ func TestUpdate(t *testing.T) {
127 pub := domain.Repo{Name: n, Visibility: domain.VisibilityPublic, Description: "old"}126 pub := domain.Repo{Name: n, Visibility: domain.VisibilityPublic, Description: "old"}
128 f.meta.EXPECT().Get(ctx, n).Return(pub, nil)127 f.meta.EXPECT().Get(ctx, n).Return(pub, nil)
129 f.git.EXPECT().HasBranch(ctx, n, "dev").Return(true, nil)128 f.git.EXPECT().HasBranch(ctx, n, "dev").Return(true, nil)
130- f.meta.EXPECT().Update(ctx, mock.Anything).Return(nil)129+ expectRepoMutation(t, f.meta, pub, mock.Anything)
131 f.git.EXPECT().SetDefaultBranch(ctx, n, "dev").Return(boom)130 f.git.EXPECT().SetDefaultBranch(ctx, n, "dev").Return(boom)
132 f.bus.EXPECT().Publish(ctx, domain.RepoUpdated{131 f.bus.EXPECT().Publish(ctx, domain.RepoUpdated{
133 Repo: n, At: now, Visibility: domain.VisibilityPrivate, Previous: domain.VisibilityPublic,132 Repo: n, At: now, Visibility: domain.VisibilityPrivate, Previous: domain.VisibilityPublic,
@@ -250,10 +249,10 @@ func TestUpdateIssuesMode(t *testing.T) {
250 t.Run("public is stored and published", func(t *testing.T) {249 t.Run("public is stored and published", func(t *testing.T) {
251 t.Parallel()250 t.Parallel()
252 f := newFixture(t)251 f := newFixture(t)
253- f.meta.EXPECT().Get(ctx, n).Return(priv, nil).Twice()252+ f.meta.EXPECT().Get(ctx, n).Return(priv, nil).Once()
254- f.meta.EXPECT().Update(ctx, mock.MatchedBy(func(r domain.Repo) bool {253+ expectRepoMutation(t, f.meta, priv, mock.MatchedBy(func(r domain.Repo) bool {
255 return r.Settings.Issues == domain.IssuesPublic254 return r.Settings.Issues == domain.IssuesPublic
256- })).Return(nil)255+ }))
257 f.bus.EXPECT().Publish(ctx, mock.Anything).Return().Once()256 f.bus.EXPECT().Publish(ctx, mock.Anything).Return().Once()
258 repo, err := f.repos.Update(ctx, domain.Root(), n, ports.UpdateRepoParams{Issues: ptr(domain.IssuesPublic)})257 repo, err := f.repos.Update(ctx, domain.Root(), n, ports.UpdateRepoParams{Issues: ptr(domain.IssuesPublic)})
259 require.NoError(t, err)258 require.NoError(t, err)
@@ -271,10 +270,10 @@ func TestUpdateIssuesMode(t *testing.T) {
271 t.Run("owner is stored and published", func(t *testing.T) {270 t.Run("owner is stored and published", func(t *testing.T) {
272 t.Parallel()271 t.Parallel()
273 f := newFixture(t)272 f := newFixture(t)
274- f.meta.EXPECT().Get(ctx, n).Return(priv, nil).Twice()273+ f.meta.EXPECT().Get(ctx, n).Return(priv, nil).Once()
275- f.meta.EXPECT().Update(ctx, mock.MatchedBy(func(r domain.Repo) bool {274+ expectRepoMutation(t, f.meta, priv, mock.MatchedBy(func(r domain.Repo) bool {
276 return r.Settings.Issues == domain.IssuesOwner && r.Settings.FailingChecksBlock275 return r.Settings.Issues == domain.IssuesOwner && r.Settings.FailingChecksBlock
277- })).Return(nil)276+ }))
278 f.bus.EXPECT().Publish(ctx, mock.Anything).Return().Once()277 f.bus.EXPECT().Publish(ctx, mock.Anything).Return().Once()
279 repo, err := f.repos.Update(ctx, domain.Root(), n, ports.UpdateRepoParams{Issues: ptr(domain.IssuesOwner)})278 repo, err := f.repos.Update(ctx, domain.Root(), n, ports.UpdateRepoParams{Issues: ptr(domain.IssuesOwner)})
280 require.NoError(t, err)279 require.NoError(t, err)
M proto/ermite/storage/repo.proto +2 -0
@@ -32,6 +32,8 @@ message Repo {
3232 RepoSettings settings = 6;
3333 // Normalized and sorted; empty on repositories stored before topics.
3434 repeated string topics = 7;
35+ // Latest push, merge request or issue activity; unset until recorded.
36+ google.protobuf.Timestamp active_at = 8;
3537 }
3638
3739 // SchemaVersion is stored under the key "meta/schema".
@@ -32,6 +32,8 @@ message Repo {
32 RepoSettings settings = 6;32 RepoSettings settings = 6;
33 // Normalized and sorted; empty on repositories stored before topics.33 // Normalized and sorted; empty on repositories stored before topics.
34 repeated string topics = 7;34 repeated string topics = 7;
35+ // Latest push, merge request or issue activity; unset until recorded.
36+ google.protobuf.Timestamp active_at = 8;
35 }37 }
36 38
37 // SchemaVersion is stored under the key "meta/schema".39 // SchemaVersion is stored under the key "meta/schema".

Comments0 open / 0

no comments yet.

Keyboard shortcuts

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

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

Branches 2

Tags 44

↑↓ moveenter openesc close