detached 5336d8b

fix(releases): resolve the latest release from one snapshot and keep the index cheap to maintain

commit
5336d8b77ba1fc3916f4dc9c563dddd80bfe37ea
author
brice · · 2026-10-22 08:25 UTC
parent
66c8439
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
checks
4/4
10 files · +242 -154
M internal/adapters/kvstore/releases.go +19 -9
@@ -39,14 +39,22 @@ func relNameKey(a domain.ReleaseAsset) []byte {
3939 func relLatestKey(repo domain.RepoName) []byte { return []byte("rellatest/" + repo.String()) }
4040 func relUseKey(repo domain.RepoName) []byte { return []byte("reluse/" + repo.String()) }
4141
42-// LatestReleaseID reads the latest-release index without scanning release records.
43-func (c *Releases) LatestReleaseID(ctx context.Context, repo domain.RepoName) (int64, bool, error) {
44- item, ok := c.db.Get(ctx, relLatestKey(repo))
42+// GetLatestRelease resolves the index and release from the same snapshot.
43+func (c *Releases) GetLatestRelease(ctx context.Context, repo domain.RepoName) (domain.Release, error) {
44+ snap := c.db.Snapshot(ctx)
45+ item, ok := snap.Get(ctx, relLatestKey(repo))
4546 if !ok {
46- return 0, false, nil
47+ return domain.Release{}, domain.ErrNotFound
4748 }
4849 id, err := strconv.ParseInt(string(item.Value), 10, 64)
49- return id, err == nil, err
50+ if err != nil {
51+ return domain.Release{}, err
52+ }
53+ item, ok = snap.Get(ctx, relKey(repo, id))
54+ if !ok {
55+ return domain.Release{}, domain.ErrNotFound
56+ }
57+ return decodeRelease(item)
5058 }
5159 func encodeRelease(r domain.Release) []byte {
5260 return mustMarshal(&storagepb.Release{
@@ -296,10 +304,12 @@ func (c *Releases) ApplyReleases(ctx context.Context, w ports.ReleaseWrite) erro
296304 b.RequireVersion(relUseKey(w.Repo), w.UsageVersion)
297305 }
298306 b.Put(relUseKey(w.Repo), mustMarshal(&storagepb.ReleaseUsage{Used: w.Used}))
299- if w.LatestID > 0 {
300- b.Put(relLatestKey(w.Repo), []byte(strconv.FormatInt(w.LatestID, 10)))
301- } else {
302- b.Delete(relLatestKey(w.Repo))
307+ if w.SetLatest {
308+ if w.LatestID > 0 {
309+ b.Put(relLatestKey(w.Repo), []byte(strconv.FormatInt(w.LatestID, 10)))
310+ } else {
311+ b.Delete(relLatestKey(w.Repo))
312+ }
303313 }
304314 snap := c.db.Snapshot(ctx)
305315 for _, r := range w.DeleteReleases {
@@ -39,14 +39,22 @@ func relNameKey(a domain.ReleaseAsset) []byte {
39 func relLatestKey(repo domain.RepoName) []byte { return []byte("rellatest/" + repo.String()) }39 func relLatestKey(repo domain.RepoName) []byte { return []byte("rellatest/" + repo.String()) }
40 func relUseKey(repo domain.RepoName) []byte { return []byte("reluse/" + repo.String()) }40 func relUseKey(repo domain.RepoName) []byte { return []byte("reluse/" + repo.String()) }
41 41
42-// LatestReleaseID reads the latest-release index without scanning release records.42+// GetLatestRelease resolves the index and release from the same snapshot.
43-func (c *Releases) LatestReleaseID(ctx context.Context, repo domain.RepoName) (int64, bool, error) {43+func (c *Releases) GetLatestRelease(ctx context.Context, repo domain.RepoName) (domain.Release, error) {
44- item, ok := c.db.Get(ctx, relLatestKey(repo))44+ snap := c.db.Snapshot(ctx)
45+ item, ok := snap.Get(ctx, relLatestKey(repo))
45 if !ok {46 if !ok {
46- return 0, false, nil47+ return domain.Release{}, domain.ErrNotFound
47 }48 }
48 id, err := strconv.ParseInt(string(item.Value), 10, 64)49 id, err := strconv.ParseInt(string(item.Value), 10, 64)
49- return id, err == nil, err50+ if err != nil {
51+ return domain.Release{}, err
52+ }
53+ item, ok = snap.Get(ctx, relKey(repo, id))
54+ if !ok {
55+ return domain.Release{}, domain.ErrNotFound
56+ }
57+ return decodeRelease(item)
50 }58 }
51 func encodeRelease(r domain.Release) []byte {59 func encodeRelease(r domain.Release) []byte {
52 return mustMarshal(&storagepb.Release{60 return mustMarshal(&storagepb.Release{
@@ -296,10 +304,12 @@ func (c *Releases) ApplyReleases(ctx context.Context, w ports.ReleaseWrite) erro
296 b.RequireVersion(relUseKey(w.Repo), w.UsageVersion)304 b.RequireVersion(relUseKey(w.Repo), w.UsageVersion)
297 }305 }
298 b.Put(relUseKey(w.Repo), mustMarshal(&storagepb.ReleaseUsage{Used: w.Used}))306 b.Put(relUseKey(w.Repo), mustMarshal(&storagepb.ReleaseUsage{Used: w.Used}))
299- if w.LatestID > 0 {307+ if w.SetLatest {
300- b.Put(relLatestKey(w.Repo), []byte(strconv.FormatInt(w.LatestID, 10)))308+ if w.LatestID > 0 {
301- } else {309+ b.Put(relLatestKey(w.Repo), []byte(strconv.FormatInt(w.LatestID, 10)))
302- b.Delete(relLatestKey(w.Repo))310+ } else {
311+ b.Delete(relLatestKey(w.Repo))
312+ }
303 }313 }
304 snap := c.db.Snapshot(ctx)314 snap := c.db.Snapshot(ctx)
305 for _, r := range w.DeleteReleases {315 for _, r := range w.DeleteReleases {
M internal/adapters/kvstore/releases_index.go +30 -10
@@ -19,23 +19,43 @@ func (s *Store) buildReleaseLatestIndex(ctx context.Context) error {
1919 if _, ok := s.db.Get(ctx, []byte(releaseLatestIndexMarker)); ok {
2020 return nil
2121 }
22- releases := make(map[domain.RepoName][]domain.Release)
22+ var repo domain.RepoName
23+ var best domain.Release
24+ var eligible bool
25+ flush := func() error {
26+ var b kv.Batch
27+ if eligible {
28+ b.Put(relLatestKey(repo), []byte(strconv.FormatInt(best.ID, 10)))
29+ } else {
30+ b.Delete(relLatestKey(repo))
31+ }
32+ if _, err := s.db.Apply(ctx, &b); err != nil {
33+ return fmt.Errorf("build latest release index: %w", err)
34+ }
35+ return nil
36+ }
37+ haveRepo := false
2338 for _, item := range s.db.Snapshot(ctx).Prefix(ctx, []byte("rel/")) {
2439 rel, err := decodeRelease(item)
2540 if err != nil {
2641 return fmt.Errorf("build latest release index: %w", err)
2742 }
28- releases[rel.Repo] = append(releases[rel.Repo], rel)
29- }
30- for repo, all := range releases {
31- var b kv.Batch
32- if latest, ok := domain.LatestRelease(all); ok {
33- b.Put(relLatestKey(repo), []byte(strconv.FormatInt(latest.ID, 10)))
43+ if haveRepo && rel.Repo != repo {
44+ if err := flush(); err != nil {
45+ return err
46+ }
47+ best, eligible = domain.Release{}, false
48+ }
49+ repo, haveRepo = rel.Repo, true
50+ if eligible {
51+ best, eligible = domain.LatestRelease([]domain.Release{best, rel})
3452 } else {
35- b.Delete(relLatestKey(repo))
53+ best, eligible = domain.LatestRelease([]domain.Release{rel})
3654 }
37- if _, err := s.db.Apply(ctx, &b); err != nil {
38- return fmt.Errorf("build latest release index: %w", err)
55+ }
56+ if haveRepo {
57+ if err := flush(); err != nil {
58+ return err
3959 }
4060 }
4161 var b kv.Batch
@@ -19,23 +19,43 @@ func (s *Store) buildReleaseLatestIndex(ctx context.Context) error {
19 if _, ok := s.db.Get(ctx, []byte(releaseLatestIndexMarker)); ok {19 if _, ok := s.db.Get(ctx, []byte(releaseLatestIndexMarker)); ok {
20 return nil20 return nil
21 }21 }
22- releases := make(map[domain.RepoName][]domain.Release)22+ var repo domain.RepoName
23+ var best domain.Release
24+ var eligible bool
25+ flush := func() error {
26+ var b kv.Batch
27+ if eligible {
28+ b.Put(relLatestKey(repo), []byte(strconv.FormatInt(best.ID, 10)))
29+ } else {
30+ b.Delete(relLatestKey(repo))
31+ }
32+ if _, err := s.db.Apply(ctx, &b); err != nil {
33+ return fmt.Errorf("build latest release index: %w", err)
34+ }
35+ return nil
36+ }
37+ haveRepo := false
23 for _, item := range s.db.Snapshot(ctx).Prefix(ctx, []byte("rel/")) {38 for _, item := range s.db.Snapshot(ctx).Prefix(ctx, []byte("rel/")) {
24 rel, err := decodeRelease(item)39 rel, err := decodeRelease(item)
25 if err != nil {40 if err != nil {
26 return fmt.Errorf("build latest release index: %w", err)41 return fmt.Errorf("build latest release index: %w", err)
27 }42 }
28- releases[rel.Repo] = append(releases[rel.Repo], rel)43+ if haveRepo && rel.Repo != repo {
29- }44+ if err := flush(); err != nil {
30- for repo, all := range releases {45+ return err
31- var b kv.Batch46+ }
32- if latest, ok := domain.LatestRelease(all); ok {47+ best, eligible = domain.Release{}, false
33- b.Put(relLatestKey(repo), []byte(strconv.FormatInt(latest.ID, 10)))48+ }
49+ repo, haveRepo = rel.Repo, true
50+ if eligible {
51+ best, eligible = domain.LatestRelease([]domain.Release{best, rel})
34 } else {52 } else {
35- b.Delete(relLatestKey(repo))53+ best, eligible = domain.LatestRelease([]domain.Release{rel})
36 }54 }
37- if _, err := s.db.Apply(ctx, &b); err != nil {55+ }
38- return fmt.Errorf("build latest release index: %w", err)56+ if haveRepo {
57+ if err := flush(); err != nil {
58+ return err
39 }59 }
40 }60 }
41 var b kv.Batch61 var b kv.Batch
M internal/adapters/kvstore/releases_index_test.go +70 -27
@@ -2,12 +2,14 @@
22 package kvstore
33
44 import (
5+ "context"
6+ "testing"
7+ "time"
8+
59 kv "bnema.dev/ermite-kv"
610 "bnema.dev/ermite/internal/domain"
711 "bnema.dev/ermite/internal/ports"
8- "context"
912 "github.com/stretchr/testify/require"
10- "testing"
1113 )
1214
1315 func TestReleaseLatestIndexBackfill(t *testing.T) {
@@ -15,23 +17,27 @@ func TestReleaseLatestIndexBackfill(t *testing.T) {
1517 dir := t.TempDir()
1618 s, err := Open(ctx, dir)
1719 require.NoError(t, err)
18- repo, err := domain.ParseRepoName("backfill")
19- require.NoError(t, err)
20- require.NoError(t, s.Create(ctx, domain.Repo{Name: repo, Visibility: domain.VisibilityPublic}))
21- rel := domain.Release{ID: 1, Repo: repo, Tag: "v1"}
22- require.NoError(t, s.Releases().ApplyReleases(ctx, ports.ReleaseWrite{Repo: repo, PutReleases: []domain.Release{rel}, LatestID: 1}))
23- id, ok, err := s.Releases().LatestReleaseID(ctx, repo)
24- require.NoError(t, err)
25- require.True(t, ok)
26- require.EqualValues(t, 1, id)
27- _, version, err := s.Releases().ReleaseUsage(ctx, repo)
28- require.NoError(t, err)
29- require.NoError(t, s.Releases().ApplyReleases(ctx, ports.ReleaseWrite{Repo: repo, UsageVersion: version}))
30- _, ok, err = s.Releases().LatestReleaseID(ctx, repo)
31- require.NoError(t, err)
32- require.False(t, ok)
33- // Simulate pre-index data, then exercise the actual startup hook.
20+ var repos []domain.RepoName
21+ for _, raw := range []string{"backfill", "empty", "newest"} {
22+ repo, err := domain.ParseRepoName(raw)
23+ require.NoError(t, err)
24+ repos = append(repos, repo)
25+ }
26+ for _, repo := range repos {
27+ require.NoError(t, s.Create(ctx, domain.Repo{Name: repo, Visibility: domain.VisibilityPublic}))
28+ }
3429 var b kv.Batch
30+ for i, repo := range repos {
31+ for j := 1; j <= 5; j++ {
32+ rel := domain.Release{ID: int64(i*10 + j), Repo: repo, Tag: string(rune('a' + j)), CreatedAt: time.Unix(int64(j), 0)}
33+ rel.Draft = j == 5 || i == 1
34+ rel.Prerelease = j == 4
35+ rel.MakeLatest = j == 1 && i == 0
36+ b.Put(relKey(repo, rel.ID), encodeRelease(rel))
37+ }
38+ // A stale key must also be removed for a repo with no eligible release.
39+ b.Put(relLatestKey(repo), []byte("999"))
40+ }
3541 b.Delete([]byte(releaseLatestIndexMarker))
3642 _, err = s.db.Apply(ctx, &b)
3743 require.NoError(t, err)
@@ -39,16 +45,53 @@ func TestReleaseLatestIndexBackfill(t *testing.T) {
3945 s, err = Open(ctx, dir)
4046 require.NoError(t, err)
4147 defer func() { require.NoError(t, s.Close(context.Background())) }()
42- id, ok, err = s.Releases().LatestReleaseID(ctx, repo)
48+ for i, want := range []int64{1, 0, 23} {
49+ got, err := s.Releases().GetLatestRelease(ctx, repos[i])
50+ if want == 0 {
51+ require.ErrorIs(t, err, domain.ErrNotFound)
52+ } else {
53+ require.NoError(t, err)
54+ require.Equal(t, want, got.ID)
55+ }
56+ }
57+ require.NoError(t, s.buildReleaseLatestIndex(ctx))
58+ got, err := s.Releases().GetLatestRelease(ctx, repos[0])
4359 require.NoError(t, err)
44- require.True(t, ok)
45- require.EqualValues(t, 1, id)
46- require.NoError(t, s.Delete(ctx, repo))
47- _, ok, err = s.Releases().LatestReleaseID(ctx, repo)
60+ require.EqualValues(t, 1, got.ID)
61+ require.NoError(t, s.Delete(ctx, repos[0]))
62+ _, err = s.Releases().GetLatestRelease(ctx, repos[0])
63+ require.ErrorIs(t, err, domain.ErrNotFound)
64+}
65+
66+func TestGetLatestReleaseAtomicUpdate(t *testing.T) {
67+ ctx := t.Context()
68+ s, err := Open(ctx, t.TempDir())
4869 require.NoError(t, err)
49- require.False(t, ok)
50- require.NoError(t, s.buildReleaseLatestIndex(ctx))
51- _, ok, err = s.Releases().LatestReleaseID(ctx, repo)
70+ defer func() { require.NoError(t, s.Close(context.Background())) }()
71+ repo, err := domain.ParseRepoName("atomic")
72+ require.NoError(t, err)
73+ require.NoError(t, s.Create(ctx, domain.Repo{Name: repo, Visibility: domain.VisibilityPublic}))
74+ a := domain.Release{ID: 1, Repo: repo, Tag: "a"}
75+ b := domain.Release{ID: 2, Repo: repo, Tag: "b"}
76+ store := s.Releases()
77+ require.NoError(t, store.ApplyReleases(ctx, ports.ReleaseWrite{Repo: repo, PutReleases: []domain.Release{a, b}, SetLatest: true, LatestID: a.ID}))
78+ a, err = store.GetLatestRelease(ctx, repo)
79+ require.NoError(t, err)
80+ require.EqualValues(t, 1, a.ID)
81+ _, version, err := store.ReleaseUsage(ctx, repo)
82+ require.NoError(t, err)
83+ a.Prerelease = true
84+ require.NoError(t, store.ApplyReleases(ctx, ports.ReleaseWrite{Repo: repo, UsageVersion: version, PutReleases: []domain.Release{a}, SetLatest: true, LatestID: b.ID}))
85+ got, err := store.GetLatestRelease(ctx, repo)
5286 require.NoError(t, err)
53- require.False(t, ok)
87+ require.EqualValues(t, 2, got.ID)
88+ // An asset-only/usage write leaves the index untouched, including its version.
89+ before, ok := s.db.Get(ctx, relLatestKey(repo))
90+ require.True(t, ok)
91+ _, version, err = store.ReleaseUsage(ctx, repo)
92+ require.NoError(t, err)
93+ require.NoError(t, store.ApplyReleases(ctx, ports.ReleaseWrite{Repo: repo, UsageVersion: version, Used: 10}))
94+ after, ok := s.db.Get(ctx, relLatestKey(repo))
95+ require.True(t, ok)
96+ require.Equal(t, before, after)
5497 }
@@ -2,12 +2,14 @@
2 package kvstore2 package kvstore
3 3
4 import (4 import (
5+ "context"
6+ "testing"
7+ "time"
8+
5 kv "bnema.dev/ermite-kv"9 kv "bnema.dev/ermite-kv"
6 "bnema.dev/ermite/internal/domain"10 "bnema.dev/ermite/internal/domain"
7 "bnema.dev/ermite/internal/ports"11 "bnema.dev/ermite/internal/ports"
8- "context"
9 "github.com/stretchr/testify/require"12 "github.com/stretchr/testify/require"
10- "testing"
11 )13 )
12 14
13 func TestReleaseLatestIndexBackfill(t *testing.T) {15 func TestReleaseLatestIndexBackfill(t *testing.T) {
@@ -15,23 +17,27 @@ func TestReleaseLatestIndexBackfill(t *testing.T) {
15 dir := t.TempDir()17 dir := t.TempDir()
16 s, err := Open(ctx, dir)18 s, err := Open(ctx, dir)
17 require.NoError(t, err)19 require.NoError(t, err)
18- repo, err := domain.ParseRepoName("backfill")20+ var repos []domain.RepoName
19- require.NoError(t, err)21+ for _, raw := range []string{"backfill", "empty", "newest"} {
20- require.NoError(t, s.Create(ctx, domain.Repo{Name: repo, Visibility: domain.VisibilityPublic}))22+ repo, err := domain.ParseRepoName(raw)
21- rel := domain.Release{ID: 1, Repo: repo, Tag: "v1"}23+ require.NoError(t, err)
22- require.NoError(t, s.Releases().ApplyReleases(ctx, ports.ReleaseWrite{Repo: repo, PutReleases: []domain.Release{rel}, LatestID: 1}))24+ repos = append(repos, repo)
23- id, ok, err := s.Releases().LatestReleaseID(ctx, repo)25+ }
24- require.NoError(t, err)26+ for _, repo := range repos {
25- require.True(t, ok)27+ require.NoError(t, s.Create(ctx, domain.Repo{Name: repo, Visibility: domain.VisibilityPublic}))
26- require.EqualValues(t, 1, id)28+ }
27- _, version, err := s.Releases().ReleaseUsage(ctx, repo)
28- require.NoError(t, err)
29- require.NoError(t, s.Releases().ApplyReleases(ctx, ports.ReleaseWrite{Repo: repo, UsageVersion: version}))
30- _, ok, err = s.Releases().LatestReleaseID(ctx, repo)
31- require.NoError(t, err)
32- require.False(t, ok)
33- // Simulate pre-index data, then exercise the actual startup hook.
34 var b kv.Batch29 var b kv.Batch
30+ for i, repo := range repos {
31+ for j := 1; j <= 5; j++ {
32+ rel := domain.Release{ID: int64(i*10 + j), Repo: repo, Tag: string(rune('a' + j)), CreatedAt: time.Unix(int64(j), 0)}
33+ rel.Draft = j == 5 || i == 1
34+ rel.Prerelease = j == 4
35+ rel.MakeLatest = j == 1 && i == 0
36+ b.Put(relKey(repo, rel.ID), encodeRelease(rel))
37+ }
38+ // A stale key must also be removed for a repo with no eligible release.
39+ b.Put(relLatestKey(repo), []byte("999"))
40+ }
35 b.Delete([]byte(releaseLatestIndexMarker))41 b.Delete([]byte(releaseLatestIndexMarker))
36 _, err = s.db.Apply(ctx, &b)42 _, err = s.db.Apply(ctx, &b)
37 require.NoError(t, err)43 require.NoError(t, err)
@@ -39,16 +45,53 @@ func TestReleaseLatestIndexBackfill(t *testing.T) {
39 s, err = Open(ctx, dir)45 s, err = Open(ctx, dir)
40 require.NoError(t, err)46 require.NoError(t, err)
41 defer func() { require.NoError(t, s.Close(context.Background())) }()47 defer func() { require.NoError(t, s.Close(context.Background())) }()
42- id, ok, err = s.Releases().LatestReleaseID(ctx, repo)48+ for i, want := range []int64{1, 0, 23} {
49+ got, err := s.Releases().GetLatestRelease(ctx, repos[i])
50+ if want == 0 {
51+ require.ErrorIs(t, err, domain.ErrNotFound)
52+ } else {
53+ require.NoError(t, err)
54+ require.Equal(t, want, got.ID)
55+ }
56+ }
57+ require.NoError(t, s.buildReleaseLatestIndex(ctx))
58+ got, err := s.Releases().GetLatestRelease(ctx, repos[0])
43 require.NoError(t, err)59 require.NoError(t, err)
44- require.True(t, ok)60+ require.EqualValues(t, 1, got.ID)
45- require.EqualValues(t, 1, id)61+ require.NoError(t, s.Delete(ctx, repos[0]))
46- require.NoError(t, s.Delete(ctx, repo))62+ _, err = s.Releases().GetLatestRelease(ctx, repos[0])
47- _, ok, err = s.Releases().LatestReleaseID(ctx, repo)63+ require.ErrorIs(t, err, domain.ErrNotFound)
64+}
65+
66+func TestGetLatestReleaseAtomicUpdate(t *testing.T) {
67+ ctx := t.Context()
68+ s, err := Open(ctx, t.TempDir())
48 require.NoError(t, err)69 require.NoError(t, err)
49- require.False(t, ok)70+ defer func() { require.NoError(t, s.Close(context.Background())) }()
50- require.NoError(t, s.buildReleaseLatestIndex(ctx))71+ repo, err := domain.ParseRepoName("atomic")
51- _, ok, err = s.Releases().LatestReleaseID(ctx, repo)72+ require.NoError(t, err)
73+ require.NoError(t, s.Create(ctx, domain.Repo{Name: repo, Visibility: domain.VisibilityPublic}))
74+ a := domain.Release{ID: 1, Repo: repo, Tag: "a"}
75+ b := domain.Release{ID: 2, Repo: repo, Tag: "b"}
76+ store := s.Releases()
77+ require.NoError(t, store.ApplyReleases(ctx, ports.ReleaseWrite{Repo: repo, PutReleases: []domain.Release{a, b}, SetLatest: true, LatestID: a.ID}))
78+ a, err = store.GetLatestRelease(ctx, repo)
79+ require.NoError(t, err)
80+ require.EqualValues(t, 1, a.ID)
81+ _, version, err := store.ReleaseUsage(ctx, repo)
82+ require.NoError(t, err)
83+ a.Prerelease = true
84+ require.NoError(t, store.ApplyReleases(ctx, ports.ReleaseWrite{Repo: repo, UsageVersion: version, PutReleases: []domain.Release{a}, SetLatest: true, LatestID: b.ID}))
85+ got, err := store.GetLatestRelease(ctx, repo)
52 require.NoError(t, err)86 require.NoError(t, err)
53- require.False(t, ok)87+ require.EqualValues(t, 2, got.ID)
88+ // An asset-only/usage write leaves the index untouched, including its version.
89+ before, ok := s.db.Get(ctx, relLatestKey(repo))
90+ require.True(t, ok)
91+ _, version, err = store.ReleaseUsage(ctx, repo)
92+ require.NoError(t, err)
93+ require.NoError(t, store.ApplyReleases(ctx, ports.ReleaseWrite{Repo: repo, UsageVersion: version, Used: 10}))
94+ after, ok := s.db.Get(ctx, relLatestKey(repo))
95+ require.True(t, ok)
96+ require.Equal(t, before, after)
54 }97 }
M internal/app/web_issue_test.go +1 -1
@@ -28,7 +28,7 @@ func TestWebIssuePagesEndToEnd(t *testing.T) {
2828 // Off by default: no page, no tab, for the owner as well.
2929 assert.Equal(t, 404, b.get("/proj/issues", as(token)).status)
3030 assert.Equal(t, 404, b.get("/proj/issues/new", as(token)).status)
31- assert.NotContains(t, b.get("/proj", as(token)).body, `data-key="i"`)
31+ assert.NotContains(t, b.get("/proj", as(token)).body, `data-key="g i"`)
3232
3333 mode := domain.IssuesOwner
3434 _, err := r.forge.Repos.Update(r.ctx, domain.Root(), r.name("proj"), ports.UpdateRepoParams{Issues: &mode})
@@ -28,7 +28,7 @@ func TestWebIssuePagesEndToEnd(t *testing.T) {
28 // Off by default: no page, no tab, for the owner as well.28 // Off by default: no page, no tab, for the owner as well.
29 assert.Equal(t, 404, b.get("/proj/issues", as(token)).status)29 assert.Equal(t, 404, b.get("/proj/issues", as(token)).status)
30 assert.Equal(t, 404, b.get("/proj/issues/new", as(token)).status)30 assert.Equal(t, 404, b.get("/proj/issues/new", as(token)).status)
31- assert.NotContains(t, b.get("/proj", as(token)).body, `data-key="i"`)31+ assert.NotContains(t, b.get("/proj", as(token)).body, `data-key="g i"`)
32 32
33 mode := domain.IssuesOwner33 mode := domain.IssuesOwner
34 _, err := r.forge.Repos.Update(r.ctx, domain.Root(), r.name("proj"), ports.UpdateRepoParams{Issues: &mode})34 _, err := r.forge.Repos.Update(r.ctx, domain.Root(), r.name("proj"), ports.UpdateRepoParams{Issues: &mode})
M internal/app/web_mr_test.go +1 -1
@@ -93,7 +93,7 @@ func TestWebMergeRequestPagesRender(t *testing.T) {
9393 require.Equal(t, 200, list.status)
9494 assert.Contains(t, list.body, "no open merge requests")
9595 assert.Contains(t, list.body, `href="/proj/compare/feat/search"`)
96- assert.Contains(t, list.body, `data-key="m"`, "the section of the nav")
96+ assert.Contains(t, list.body, `data-key="g m"`, "the section of the nav")
9797 assert.NotContains(t, strings.ToLower(list.body), "proposal")
9898
9999 // Open one from the compare page.
@@ -93,7 +93,7 @@ func TestWebMergeRequestPagesRender(t *testing.T) {
93 require.Equal(t, 200, list.status)93 require.Equal(t, 200, list.status)
94 assert.Contains(t, list.body, "no open merge requests")94 assert.Contains(t, list.body, "no open merge requests")
95 assert.Contains(t, list.body, `href="/proj/compare/feat/search"`)95 assert.Contains(t, list.body, `href="/proj/compare/feat/search"`)
96- assert.Contains(t, list.body, `data-key="m"`, "the section of the nav")96+ assert.Contains(t, list.body, `data-key="g m"`, "the section of the nav")
97 assert.NotContains(t, strings.ToLower(list.body), "proposal")97 assert.NotContains(t, strings.ToLower(list.body), "proposal")
98 98
99 // Open one from the compare page.99 // Open one from the compare page.
M internal/ports/mocks/ReleaseStore.go +66 -72
@@ -311,6 +311,72 @@ func (_c *ReleaseStore_GetAssetByName_Call) RunAndReturn(run func(ctx context.Co
311311 return _c
312312 }
313313
314+// GetLatestRelease provides a mock function for the type ReleaseStore
315+func (_mock *ReleaseStore) GetLatestRelease(ctx context.Context, repo domain.RepoName) (domain.Release, error) {
316+ ret := _mock.Called(ctx, repo)
317+
318+ if len(ret) == 0 {
319+ panic("no return value specified for GetLatestRelease")
320+ }
321+
322+ var r0 domain.Release
323+ var r1 error
324+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName) (domain.Release, error)); ok {
325+ return returnFunc(ctx, repo)
326+ }
327+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName) domain.Release); ok {
328+ r0 = returnFunc(ctx, repo)
329+ } else {
330+ r0 = ret.Get(0).(domain.Release)
331+ }
332+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName) error); ok {
333+ r1 = returnFunc(ctx, repo)
334+ } else {
335+ r1 = ret.Error(1)
336+ }
337+ return r0, r1
338+}
339+
340+// ReleaseStore_GetLatestRelease_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetLatestRelease'
341+type ReleaseStore_GetLatestRelease_Call struct {
342+ *mock.Call
343+}
344+
345+// GetLatestRelease is a helper method to define mock.On call
346+// - ctx context.Context
347+// - repo domain.RepoName
348+func (_e *ReleaseStore_Expecter) GetLatestRelease(ctx any, repo any) *ReleaseStore_GetLatestRelease_Call {
349+ return &ReleaseStore_GetLatestRelease_Call{Call: _e.mock.On("GetLatestRelease", ctx, repo)}
350+}
351+
352+func (_c *ReleaseStore_GetLatestRelease_Call) Run(run func(ctx context.Context, repo domain.RepoName)) *ReleaseStore_GetLatestRelease_Call {
353+ _c.Call.Run(func(args mock.Arguments) {
354+ var arg0 context.Context
355+ if args[0] != nil {
356+ arg0 = args[0].(context.Context)
357+ }
358+ var arg1 domain.RepoName
359+ if args[1] != nil {
360+ arg1 = args[1].(domain.RepoName)
361+ }
362+ run(
363+ arg0,
364+ arg1,
365+ )
366+ })
367+ return _c
368+}
369+
370+func (_c *ReleaseStore_GetLatestRelease_Call) Return(release domain.Release, err error) *ReleaseStore_GetLatestRelease_Call {
371+ _c.Call.Return(release, err)
372+ return _c
373+}
374+
375+func (_c *ReleaseStore_GetLatestRelease_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName) (domain.Release, error)) *ReleaseStore_GetLatestRelease_Call {
376+ _c.Call.Return(run)
377+ return _c
378+}
379+
314380 // GetRelease provides a mock function for the type ReleaseStore
315381 func (_mock *ReleaseStore) GetRelease(ctx context.Context, repo domain.RepoName, id int64) (domain.Release, error) {
316382 ret := _mock.Called(ctx, repo, id)
@@ -455,78 +521,6 @@ func (_c *ReleaseStore_GetReleaseByTag_Call) RunAndReturn(run func(ctx context.C
455521 return _c
456522 }
457523
458-// LatestReleaseID provides a mock function for the type ReleaseStore
459-func (_mock *ReleaseStore) LatestReleaseID(ctx context.Context, repo domain.RepoName) (int64, bool, error) {
460- ret := _mock.Called(ctx, repo)
461-
462- if len(ret) == 0 {
463- panic("no return value specified for LatestReleaseID")
464- }
465-
466- var r0 int64
467- var r1 bool
468- var r2 error
469- if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName) (int64, bool, error)); ok {
470- return returnFunc(ctx, repo)
471- }
472- if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName) int64); ok {
473- r0 = returnFunc(ctx, repo)
474- } else {
475- r0 = ret.Get(0).(int64)
476- }
477- if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName) bool); ok {
478- r1 = returnFunc(ctx, repo)
479- } else {
480- r1 = ret.Get(1).(bool)
481- }
482- if returnFunc, ok := ret.Get(2).(func(context.Context, domain.RepoName) error); ok {
483- r2 = returnFunc(ctx, repo)
484- } else {
485- r2 = ret.Error(2)
486- }
487- return r0, r1, r2
488-}
489-
490-// ReleaseStore_LatestReleaseID_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'LatestReleaseID'
491-type ReleaseStore_LatestReleaseID_Call struct {
492- *mock.Call
493-}
494-
495-// LatestReleaseID is a helper method to define mock.On call
496-// - ctx context.Context
497-// - repo domain.RepoName
498-func (_e *ReleaseStore_Expecter) LatestReleaseID(ctx any, repo any) *ReleaseStore_LatestReleaseID_Call {
499- return &ReleaseStore_LatestReleaseID_Call{Call: _e.mock.On("LatestReleaseID", ctx, repo)}
500-}
501-
502-func (_c *ReleaseStore_LatestReleaseID_Call) Run(run func(ctx context.Context, repo domain.RepoName)) *ReleaseStore_LatestReleaseID_Call {
503- _c.Call.Run(func(args mock.Arguments) {
504- var arg0 context.Context
505- if args[0] != nil {
506- arg0 = args[0].(context.Context)
507- }
508- var arg1 domain.RepoName
509- if args[1] != nil {
510- arg1 = args[1].(domain.RepoName)
511- }
512- run(
513- arg0,
514- arg1,
515- )
516- })
517- return _c
518-}
519-
520-func (_c *ReleaseStore_LatestReleaseID_Call) Return(id int64, ok bool, err error) *ReleaseStore_LatestReleaseID_Call {
521- _c.Call.Return(id, ok, err)
522- return _c
523-}
524-
525-func (_c *ReleaseStore_LatestReleaseID_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName) (int64, bool, error)) *ReleaseStore_LatestReleaseID_Call {
526- _c.Call.Return(run)
527- return _c
528-}
529-
530524 // ListAssets provides a mock function for the type ReleaseStore
531525 func (_mock *ReleaseStore) ListAssets(ctx context.Context, repo domain.RepoName, releaseID int64) ([]domain.ReleaseAsset, error) {
532526 ret := _mock.Called(ctx, repo, releaseID)
@@ -311,6 +311,72 @@ func (_c *ReleaseStore_GetAssetByName_Call) RunAndReturn(run func(ctx context.Co
311 return _c311 return _c
312 }312 }
313 313
314+// GetLatestRelease provides a mock function for the type ReleaseStore
315+func (_mock *ReleaseStore) GetLatestRelease(ctx context.Context, repo domain.RepoName) (domain.Release, error) {
316+ ret := _mock.Called(ctx, repo)
317+
318+ if len(ret) == 0 {
319+ panic("no return value specified for GetLatestRelease")
320+ }
321+
322+ var r0 domain.Release
323+ var r1 error
324+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName) (domain.Release, error)); ok {
325+ return returnFunc(ctx, repo)
326+ }
327+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName) domain.Release); ok {
328+ r0 = returnFunc(ctx, repo)
329+ } else {
330+ r0 = ret.Get(0).(domain.Release)
331+ }
332+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName) error); ok {
333+ r1 = returnFunc(ctx, repo)
334+ } else {
335+ r1 = ret.Error(1)
336+ }
337+ return r0, r1
338+}
339+
340+// ReleaseStore_GetLatestRelease_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetLatestRelease'
341+type ReleaseStore_GetLatestRelease_Call struct {
342+ *mock.Call
343+}
344+
345+// GetLatestRelease is a helper method to define mock.On call
346+// - ctx context.Context
347+// - repo domain.RepoName
348+func (_e *ReleaseStore_Expecter) GetLatestRelease(ctx any, repo any) *ReleaseStore_GetLatestRelease_Call {
349+ return &ReleaseStore_GetLatestRelease_Call{Call: _e.mock.On("GetLatestRelease", ctx, repo)}
350+}
351+
352+func (_c *ReleaseStore_GetLatestRelease_Call) Run(run func(ctx context.Context, repo domain.RepoName)) *ReleaseStore_GetLatestRelease_Call {
353+ _c.Call.Run(func(args mock.Arguments) {
354+ var arg0 context.Context
355+ if args[0] != nil {
356+ arg0 = args[0].(context.Context)
357+ }
358+ var arg1 domain.RepoName
359+ if args[1] != nil {
360+ arg1 = args[1].(domain.RepoName)
361+ }
362+ run(
363+ arg0,
364+ arg1,
365+ )
366+ })
367+ return _c
368+}
369+
370+func (_c *ReleaseStore_GetLatestRelease_Call) Return(release domain.Release, err error) *ReleaseStore_GetLatestRelease_Call {
371+ _c.Call.Return(release, err)
372+ return _c
373+}
374+
375+func (_c *ReleaseStore_GetLatestRelease_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName) (domain.Release, error)) *ReleaseStore_GetLatestRelease_Call {
376+ _c.Call.Return(run)
377+ return _c
378+}
379+
314 // GetRelease provides a mock function for the type ReleaseStore380 // GetRelease provides a mock function for the type ReleaseStore
315 func (_mock *ReleaseStore) GetRelease(ctx context.Context, repo domain.RepoName, id int64) (domain.Release, error) {381 func (_mock *ReleaseStore) GetRelease(ctx context.Context, repo domain.RepoName, id int64) (domain.Release, error) {
316 ret := _mock.Called(ctx, repo, id)382 ret := _mock.Called(ctx, repo, id)
@@ -455,78 +521,6 @@ func (_c *ReleaseStore_GetReleaseByTag_Call) RunAndReturn(run func(ctx context.C
455 return _c521 return _c
456 }522 }
457 523
458-// LatestReleaseID provides a mock function for the type ReleaseStore
459-func (_mock *ReleaseStore) LatestReleaseID(ctx context.Context, repo domain.RepoName) (int64, bool, error) {
460- ret := _mock.Called(ctx, repo)
461-
462- if len(ret) == 0 {
463- panic("no return value specified for LatestReleaseID")
464- }
465-
466- var r0 int64
467- var r1 bool
468- var r2 error
469- if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName) (int64, bool, error)); ok {
470- return returnFunc(ctx, repo)
471- }
472- if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName) int64); ok {
473- r0 = returnFunc(ctx, repo)
474- } else {
475- r0 = ret.Get(0).(int64)
476- }
477- if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName) bool); ok {
478- r1 = returnFunc(ctx, repo)
479- } else {
480- r1 = ret.Get(1).(bool)
481- }
482- if returnFunc, ok := ret.Get(2).(func(context.Context, domain.RepoName) error); ok {
483- r2 = returnFunc(ctx, repo)
484- } else {
485- r2 = ret.Error(2)
486- }
487- return r0, r1, r2
488-}
489-
490-// ReleaseStore_LatestReleaseID_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'LatestReleaseID'
491-type ReleaseStore_LatestReleaseID_Call struct {
492- *mock.Call
493-}
494-
495-// LatestReleaseID is a helper method to define mock.On call
496-// - ctx context.Context
497-// - repo domain.RepoName
498-func (_e *ReleaseStore_Expecter) LatestReleaseID(ctx any, repo any) *ReleaseStore_LatestReleaseID_Call {
499- return &ReleaseStore_LatestReleaseID_Call{Call: _e.mock.On("LatestReleaseID", ctx, repo)}
500-}
501-
502-func (_c *ReleaseStore_LatestReleaseID_Call) Run(run func(ctx context.Context, repo domain.RepoName)) *ReleaseStore_LatestReleaseID_Call {
503- _c.Call.Run(func(args mock.Arguments) {
504- var arg0 context.Context
505- if args[0] != nil {
506- arg0 = args[0].(context.Context)
507- }
508- var arg1 domain.RepoName
509- if args[1] != nil {
510- arg1 = args[1].(domain.RepoName)
511- }
512- run(
513- arg0,
514- arg1,
515- )
516- })
517- return _c
518-}
519-
520-func (_c *ReleaseStore_LatestReleaseID_Call) Return(id int64, ok bool, err error) *ReleaseStore_LatestReleaseID_Call {
521- _c.Call.Return(id, ok, err)
522- return _c
523-}
524-
525-func (_c *ReleaseStore_LatestReleaseID_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName) (int64, bool, error)) *ReleaseStore_LatestReleaseID_Call {
526- _c.Call.Return(run)
527- return _c
528-}
529-
530 // ListAssets provides a mock function for the type ReleaseStore524 // ListAssets provides a mock function for the type ReleaseStore
531 func (_mock *ReleaseStore) ListAssets(ctx context.Context, repo domain.RepoName, releaseID int64) ([]domain.ReleaseAsset, error) {525 func (_mock *ReleaseStore) ListAssets(ctx context.Context, repo domain.RepoName, releaseID int64) ([]domain.ReleaseAsset, error) {
532 ret := _mock.Called(ctx, repo, releaseID)526 ret := _mock.Called(ctx, repo, releaseID)
M internal/ports/release.go +3 -2
@@ -13,7 +13,8 @@ import (
1313 // Used includes both uploading and uploaded assets.
1414 type ReleaseWrite struct {
1515 Repo domain.RepoName
16- LatestID int64 // zero means no eligible release
16+ SetLatest bool // false leaves the latest index unchanged
17+ LatestID int64 // zero means no eligible release when SetLatest
1718 Used int64
1819 UsageVersion uint64
1920 PutReleases, DeleteReleases []domain.Release
@@ -22,7 +23,7 @@ type ReleaseWrite struct {
2223
2324 // ReleaseStore keeps metadata, never contents. *kvstore.Releases implements it.
2425 type ReleaseStore interface {
25- LatestReleaseID(ctx context.Context, repo domain.RepoName) (id int64, ok bool, err error)
26+ GetLatestRelease(ctx context.Context, repo domain.RepoName) (domain.Release, error)
2627 NextReleaseID(ctx context.Context) (int64, error)
2728 ReleaseUsage(ctx context.Context, repo domain.RepoName) (used int64, version uint64, err error)
2829 GetRelease(ctx context.Context, repo domain.RepoName, id int64) (domain.Release, error)
@@ -13,7 +13,8 @@ import (
13 // Used includes both uploading and uploaded assets.13 // Used includes both uploading and uploaded assets.
14 type ReleaseWrite struct {14 type ReleaseWrite struct {
15 Repo domain.RepoName15 Repo domain.RepoName
16- LatestID int64 // zero means no eligible release16+ SetLatest bool // false leaves the latest index unchanged
17+ LatestID int64 // zero means no eligible release when SetLatest
17 Used int6418 Used int64
18 UsageVersion uint6419 UsageVersion uint64
19 PutReleases, DeleteReleases []domain.Release20 PutReleases, DeleteReleases []domain.Release
@@ -22,7 +23,7 @@ type ReleaseWrite struct {
22 23
23 // ReleaseStore keeps metadata, never contents. *kvstore.Releases implements it.24 // ReleaseStore keeps metadata, never contents. *kvstore.Releases implements it.
24 type ReleaseStore interface {25 type ReleaseStore interface {
25- LatestReleaseID(ctx context.Context, repo domain.RepoName) (id int64, ok bool, err error)26+ GetLatestRelease(ctx context.Context, repo domain.RepoName) (domain.Release, error)
26 NextReleaseID(ctx context.Context) (int64, error)27 NextReleaseID(ctx context.Context) (int64, error)
27 ReleaseUsage(ctx context.Context, repo domain.RepoName) (used int64, version uint64, err error)28 ReleaseUsage(ctx context.Context, repo domain.RepoName) (used int64, version uint64, err error)
28 GetRelease(ctx context.Context, repo domain.RepoName, id int64) (domain.Release, error)29 GetRelease(ctx context.Context, repo domain.RepoName, id int64) (domain.Release, error)
M internal/usecase/releases.go +24 -30
@@ -92,26 +92,29 @@ func (r *Releases) updateReleases(ctx context.Context, repo domain.RepoName, fn
9292 if err = fn(&w); err != nil {
9393 return err
9494 }
95- all, err := r.store.ListReleases(ctx, repo)
96- if err != nil {
97- return err
98- }
99- byID := make(map[int64]domain.Release, len(all)+len(w.PutReleases))
100- for _, rel := range all {
101- byID[rel.ID] = rel
102- }
103- for _, rel := range w.DeleteReleases {
104- delete(byID, rel.ID)
105- }
106- for _, rel := range w.PutReleases {
107- byID[rel.ID] = rel
108- }
109- all = all[:0]
110- for _, rel := range byID {
111- all = append(all, rel)
112- }
113- if latest, ok := domain.LatestRelease(all); ok {
114- w.LatestID = latest.ID
95+ if len(w.PutReleases) > 0 || len(w.DeleteReleases) > 0 {
96+ w.SetLatest = true
97+ all, err := r.store.ListReleases(ctx, repo)
98+ if err != nil {
99+ return err
100+ }
101+ byID := make(map[int64]domain.Release, len(all)+len(w.PutReleases))
102+ for _, rel := range all {
103+ byID[rel.ID] = rel
104+ }
105+ for _, rel := range w.DeleteReleases {
106+ delete(byID, rel.ID)
107+ }
108+ for _, rel := range w.PutReleases {
109+ byID[rel.ID] = rel
110+ }
111+ all = all[:0]
112+ for _, rel := range byID {
113+ all = append(all, rel)
114+ }
115+ if latest, ok := domain.LatestRelease(all); ok {
116+ w.LatestID = latest.ID
117+ }
115118 }
116119 err = r.store.ApplyReleases(ctx, w)
117120 if errors.Is(err, domain.ErrConflict) {
@@ -266,16 +269,7 @@ func (r *Releases) GetRelease(ctx context.Context, p domain.Principal, name doma
266269 case ref.Tag != "":
267270 rel, err = r.store.GetReleaseByTag(ctx, name, ref.Tag)
268271 default:
269- var id int64
270- var ok bool
271- id, ok, err = r.store.LatestReleaseID(ctx, name)
272- if err == nil {
273- if !ok {
274- err = domain.ErrNotFound
275- } else {
276- rel, err = r.store.GetRelease(ctx, name, id)
277- }
278- }
272+ rel, err = r.store.GetLatestRelease(ctx, name)
279273 }
280274 if err == nil && rel.Draft && !releaseWriter(p, name) {
281275 err = domain.ErrNotFound
@@ -92,26 +92,29 @@ func (r *Releases) updateReleases(ctx context.Context, repo domain.RepoName, fn
92 if err = fn(&w); err != nil {92 if err = fn(&w); err != nil {
93 return err93 return err
94 }94 }
95- all, err := r.store.ListReleases(ctx, repo)95+ if len(w.PutReleases) > 0 || len(w.DeleteReleases) > 0 {
96- if err != nil {96+ w.SetLatest = true
97- return err97+ all, err := r.store.ListReleases(ctx, repo)
98- }98+ if err != nil {
99- byID := make(map[int64]domain.Release, len(all)+len(w.PutReleases))99+ return err
100- for _, rel := range all {100+ }
101- byID[rel.ID] = rel101+ byID := make(map[int64]domain.Release, len(all)+len(w.PutReleases))
102- }102+ for _, rel := range all {
103- for _, rel := range w.DeleteReleases {103+ byID[rel.ID] = rel
104- delete(byID, rel.ID)104+ }
105- }105+ for _, rel := range w.DeleteReleases {
106- for _, rel := range w.PutReleases {106+ delete(byID, rel.ID)
107- byID[rel.ID] = rel107+ }
108- }108+ for _, rel := range w.PutReleases {
109- all = all[:0]109+ byID[rel.ID] = rel
110- for _, rel := range byID {110+ }
111- all = append(all, rel)111+ all = all[:0]
112- }112+ for _, rel := range byID {
113- if latest, ok := domain.LatestRelease(all); ok {113+ all = append(all, rel)
114- w.LatestID = latest.ID114+ }
115+ if latest, ok := domain.LatestRelease(all); ok {
116+ w.LatestID = latest.ID
117+ }
115 }118 }
116 err = r.store.ApplyReleases(ctx, w)119 err = r.store.ApplyReleases(ctx, w)
117 if errors.Is(err, domain.ErrConflict) {120 if errors.Is(err, domain.ErrConflict) {
@@ -266,16 +269,7 @@ func (r *Releases) GetRelease(ctx context.Context, p domain.Principal, name doma
266 case ref.Tag != "":269 case ref.Tag != "":
267 rel, err = r.store.GetReleaseByTag(ctx, name, ref.Tag)270 rel, err = r.store.GetReleaseByTag(ctx, name, ref.Tag)
268 default:271 default:
269- var id int64272+ rel, err = r.store.GetLatestRelease(ctx, name)
270- var ok bool
271- id, ok, err = r.store.LatestReleaseID(ctx, name)
272- if err == nil {
273- if !ok {
274- err = domain.ErrNotFound
275- } else {
276- rel, err = r.store.GetRelease(ctx, name, id)
277- }
278- }
279 }273 }
280 if err == nil && rel.Draft && !releaseWriter(p, name) {274 if err == nil && rel.Draft && !releaseWriter(p, name) {
281 err = domain.ErrNotFound275 err = domain.ErrNotFound
M internal/usecase/releases_integration_test.go +1 -2
@@ -387,8 +387,7 @@ func TestReleaseLatestUsesIndex(t *testing.T) {
387387 store := mocks.NewReleaseStore(t)
388388 meta := mocks.NewRepoMetaStore(t)
389389 meta.EXPECT().Get(ctx, repo).Return(domain.Repo{Name: repo, Visibility: domain.VisibilityPublic}, nil)
390- store.EXPECT().LatestReleaseID(ctx, repo).Return(int64(42), true, nil).Once()
391- store.EXPECT().GetRelease(ctx, repo, int64(42)).Return(domain.Release{ID: 42, Repo: repo}, nil).Once()
390+ store.EXPECT().GetLatestRelease(ctx, repo).Return(domain.Release{ID: 42, Repo: repo}, nil).Once()
392391 svc := usecase.NewReleases(store, nil, meta, nil, nil, nil, nil, nil, ports.ReleaseLimits{})
393392 got, err := svc.GetRelease(ctx, domain.Anonymous(), repo, ports.ReleaseRef{Latest: true})
394393 require.NoError(t, err)
@@ -387,8 +387,7 @@ func TestReleaseLatestUsesIndex(t *testing.T) {
387 store := mocks.NewReleaseStore(t)387 store := mocks.NewReleaseStore(t)
388 meta := mocks.NewRepoMetaStore(t)388 meta := mocks.NewRepoMetaStore(t)
389 meta.EXPECT().Get(ctx, repo).Return(domain.Repo{Name: repo, Visibility: domain.VisibilityPublic}, nil)389 meta.EXPECT().Get(ctx, repo).Return(domain.Repo{Name: repo, Visibility: domain.VisibilityPublic}, nil)
390- store.EXPECT().LatestReleaseID(ctx, repo).Return(int64(42), true, nil).Once()390+ store.EXPECT().GetLatestRelease(ctx, repo).Return(domain.Release{ID: 42, Repo: repo}, nil).Once()
391- store.EXPECT().GetRelease(ctx, repo, int64(42)).Return(domain.Release{ID: 42, Repo: repo}, nil).Once()
392 svc := usecase.NewReleases(store, nil, meta, nil, nil, nil, nil, nil, ports.ReleaseLimits{})391 svc := usecase.NewReleases(store, nil, meta, nil, nil, nil, nil, nil, ports.ReleaseLimits{})
393 got, err := svc.GetRelease(ctx, domain.Anonymous(), repo, ports.ReleaseRef{Latest: true})392 got, err := svc.GetRelease(ctx, domain.Anonymous(), repo, ports.ReleaseRef{Latest: true})
394 require.NoError(t, err)393 require.NoError(t, err)
A internal/usecase/releases_write_test.go +27 -0
@@ -0,0 +1,27 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+package usecase
3+
4+import (
5+ "testing"
6+
7+ "bnema.dev/ermite/internal/domain"
8+ "bnema.dev/ermite/internal/ports"
9+ "bnema.dev/ermite/internal/ports/mocks"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+// No ListReleases expectation: asset-only changes must not scan releases.
14+func TestAssetOnlyWriteDoesNotListReleases(t *testing.T) {
15+ ctx := t.Context()
16+ repo, err := domain.ParseRepoName("assets")
17+ require.NoError(t, err)
18+ store := mocks.NewReleaseStore(t)
19+ asset := domain.ReleaseAsset{ID: 1, Repo: repo}
20+ store.EXPECT().ReleaseUsage(ctx, repo).Return(int64(0), uint64(2), nil).Once()
21+ store.EXPECT().ApplyReleases(ctx, ports.ReleaseWrite{Repo: repo, UsageVersion: 2, PutAssets: []domain.ReleaseAsset{asset}}).Return(nil).Once()
22+ svc := &Releases{store: store}
23+ require.NoError(t, svc.updateReleases(ctx, repo, func(w *ports.ReleaseWrite) error {
24+ w.PutAssets = append(w.PutAssets, asset)
25+ return nil
26+ }))
27+}
@@ -0,0 +1,27 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+package usecase
3+
4+import (
5+ "testing"
6+
7+ "bnema.dev/ermite/internal/domain"
8+ "bnema.dev/ermite/internal/ports"
9+ "bnema.dev/ermite/internal/ports/mocks"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+// No ListReleases expectation: asset-only changes must not scan releases.
14+func TestAssetOnlyWriteDoesNotListReleases(t *testing.T) {
15+ ctx := t.Context()
16+ repo, err := domain.ParseRepoName("assets")
17+ require.NoError(t, err)
18+ store := mocks.NewReleaseStore(t)
19+ asset := domain.ReleaseAsset{ID: 1, Repo: repo}
20+ store.EXPECT().ReleaseUsage(ctx, repo).Return(int64(0), uint64(2), nil).Once()
21+ store.EXPECT().ApplyReleases(ctx, ports.ReleaseWrite{Repo: repo, UsageVersion: 2, PutAssets: []domain.ReleaseAsset{asset}}).Return(nil).Once()
22+ svc := &Releases{store: store}
23+ require.NoError(t, svc.updateReleases(ctx, repo, func(w *ports.ReleaseWrite) error {
24+ w.PutAssets = append(w.PutAssets, asset)
25+ return nil
26+ }))
27+}

Keyboard shortcuts

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

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

Branches 3

Tags 43

↑↓ moveenter openesc close