detached 7e0e489

feat(kvstore): grant mr:review and issue:write to full tokens in the schema 4 upgrade

commit
7e0e489acb1f3da7533a4e2363fedef07d741466
author
brice · · 2026-10-19 12:47 UTC
parent
fa37646
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +51 -12
M internal/adapters/kvstore/issues_test.go +31 -2
@@ -4,6 +4,7 @@ package kvstore_test
44
55 import (
66 "context"
7+ "slices"
78 "sort"
89 "strconv"
910 "sync"
@@ -266,8 +267,9 @@ func TestRepoSettingsKeepTheIssuesMode(t *testing.T) {
266267 assert.ErrorIs(t, s.Update(ctx, got), domain.ErrInvalid)
267268 }
268269
269-// TestSchema4UpgradesTheTokensWithAllScopes: the tokens that held every scope
270-// before issue:write existed keep their owner rights; no other token changes.
270+// TestSchema4UpgradesTheTokensWithAllScopes: the tokens that held every
271+// original scope keep their owner rights, gaining mr:review and issue:write
272+// (each only if missing); no other token changes.
271273 func TestSchema4UpgradesTheTokensWithAllScopes(t *testing.T) {
272274 ctx := context.Background()
273275 dir := t.TempDir()
@@ -279,6 +281,8 @@ func TestSchema4UpgradesTheTokensWithAllScopes(t *testing.T) {
279281 }
280282 tokens := map[string]*storagepb.Token{
281283 "full": {Id: "full", Scopes: all9},
284+ "full10": {Id: "full10", Scopes: append(slices.Clone(all9), "mr:review")},
285+ "reposmr": {Id: "reposmr", Scopes: append(slices.Clone(all9), "mr:review"), Repos: []string{"proj"}},
282286 "missing": {Id: "missing", Scopes: all9[:8]},
283287 "repos": {Id: "repos", Scopes: all9, Repos: []string{"proj"}},
284288 "prefix": {Id: "prefix", Scopes: all9, BranchPrefixes: []string{"agent/"}},
@@ -302,10 +306,22 @@ func TestSchema4UpgradesTheTokensWithAllScopes(t *testing.T) {
302306
303307 full := get("full")
304308 assert.Contains(t, full.Scopes, domain.ScopeIssueWrite)
309+ assert.Contains(t, full.Scopes, domain.ScopeMRReview)
310+ assert.Len(t, full.Scopes, 11)
311+ full10 := get("full10")
312+ assert.Len(t, full10.Scopes, 11)
313+ assert.Equal(t, 1, countScope(full10.Scopes, domain.ScopeMRReview))
314+ assert.Contains(t, full10.Scopes, domain.ScopeIssueWrite)
315+ assert.True(t, domain.TokenPrincipal(full10).HasOwnerRights())
316+ assert.Len(t, get("reposmr").Scopes, 10)
317+ assert.NotContains(t, get("reposmr").Scopes, domain.ScopeIssueWrite)
305318 assert.True(t, domain.TokenPrincipal(full).HasOwnerRights())
306319 assert.True(t, domain.TokenPrincipal(get("otherfull")).HasOwnerRights(), "the order of the scopes does not matter")
307320
308321 assert.NotContains(t, get("missing").Scopes, domain.ScopeIssueWrite)
322+ assert.NotContains(t, get("missing").Scopes, domain.ScopeMRReview)
323+ assert.NotContains(t, get("repos").Scopes, domain.ScopeMRReview)
324+ assert.NotContains(t, get("prefix").Scopes, domain.ScopeMRReview)
309325 assert.False(t, domain.TokenPrincipal(get("missing")).HasOwnerRights())
310326 assert.NotContains(t, get("repos").Scopes, domain.ScopeIssueWrite)
311327 assert.NotContains(t, get("prefix").Scopes, domain.ScopeIssueWrite)
@@ -325,6 +341,9 @@ func TestSchema4UpgradesTheTokensWithAllScopes(t *testing.T) {
325341 tok, err := s2.Tokens().Get(ctx, "full")
326342 require.NoError(t, err)
327343 assert.Equal(t, full.Scopes, tok.Scopes)
344+ tok10, err := s2.Tokens().Get(ctx, "full10")
345+ require.NoError(t, err)
346+ assert.Equal(t, full10.Scopes, tok10.Scopes)
328347 require.NoError(t, s2.Close(ctx))
329348 }
330349
@@ -530,3 +549,13 @@ func TestStoredPendingIssueStateIsCorrupt(t *testing.T) {
530549 _, err = is.List(ctx, n, ports.ListIssuesParams{State: domain.IssueStateOpen})
531550 assert.ErrorContains(t, err, "pending")
532551 }
552+
553+func countScope(scopes []domain.Scope, want domain.Scope) int {
554+ n := 0
555+ for _, s := range scopes {
556+ if s == want {
557+ n++
558+ }
559+ }
560+ return n
561+}
@@ -4,6 +4,7 @@ package kvstore_test
4 4
5 import (5 import (
6 "context"6 "context"
7+ "slices"
7 "sort"8 "sort"
8 "strconv"9 "strconv"
9 "sync"10 "sync"
@@ -266,8 +267,9 @@ func TestRepoSettingsKeepTheIssuesMode(t *testing.T) {
266 assert.ErrorIs(t, s.Update(ctx, got), domain.ErrInvalid)267 assert.ErrorIs(t, s.Update(ctx, got), domain.ErrInvalid)
267 }268 }
268 269
269-// TestSchema4UpgradesTheTokensWithAllScopes: the tokens that held every scope270+// TestSchema4UpgradesTheTokensWithAllScopes: the tokens that held every
270-// before issue:write existed keep their owner rights; no other token changes.271+// original scope keep their owner rights, gaining mr:review and issue:write
272+// (each only if missing); no other token changes.
271 func TestSchema4UpgradesTheTokensWithAllScopes(t *testing.T) {273 func TestSchema4UpgradesTheTokensWithAllScopes(t *testing.T) {
272 ctx := context.Background()274 ctx := context.Background()
273 dir := t.TempDir()275 dir := t.TempDir()
@@ -279,6 +281,8 @@ func TestSchema4UpgradesTheTokensWithAllScopes(t *testing.T) {
279 }281 }
280 tokens := map[string]*storagepb.Token{282 tokens := map[string]*storagepb.Token{
281 "full": {Id: "full", Scopes: all9},283 "full": {Id: "full", Scopes: all9},
284+ "full10": {Id: "full10", Scopes: append(slices.Clone(all9), "mr:review")},
285+ "reposmr": {Id: "reposmr", Scopes: append(slices.Clone(all9), "mr:review"), Repos: []string{"proj"}},
282 "missing": {Id: "missing", Scopes: all9[:8]},286 "missing": {Id: "missing", Scopes: all9[:8]},
283 "repos": {Id: "repos", Scopes: all9, Repos: []string{"proj"}},287 "repos": {Id: "repos", Scopes: all9, Repos: []string{"proj"}},
284 "prefix": {Id: "prefix", Scopes: all9, BranchPrefixes: []string{"agent/"}},288 "prefix": {Id: "prefix", Scopes: all9, BranchPrefixes: []string{"agent/"}},
@@ -302,10 +306,22 @@ func TestSchema4UpgradesTheTokensWithAllScopes(t *testing.T) {
302 306
303 full := get("full")307 full := get("full")
304 assert.Contains(t, full.Scopes, domain.ScopeIssueWrite)308 assert.Contains(t, full.Scopes, domain.ScopeIssueWrite)
309+ assert.Contains(t, full.Scopes, domain.ScopeMRReview)
310+ assert.Len(t, full.Scopes, 11)
311+ full10 := get("full10")
312+ assert.Len(t, full10.Scopes, 11)
313+ assert.Equal(t, 1, countScope(full10.Scopes, domain.ScopeMRReview))
314+ assert.Contains(t, full10.Scopes, domain.ScopeIssueWrite)
315+ assert.True(t, domain.TokenPrincipal(full10).HasOwnerRights())
316+ assert.Len(t, get("reposmr").Scopes, 10)
317+ assert.NotContains(t, get("reposmr").Scopes, domain.ScopeIssueWrite)
305 assert.True(t, domain.TokenPrincipal(full).HasOwnerRights())318 assert.True(t, domain.TokenPrincipal(full).HasOwnerRights())
306 assert.True(t, domain.TokenPrincipal(get("otherfull")).HasOwnerRights(), "the order of the scopes does not matter")319 assert.True(t, domain.TokenPrincipal(get("otherfull")).HasOwnerRights(), "the order of the scopes does not matter")
307 320
308 assert.NotContains(t, get("missing").Scopes, domain.ScopeIssueWrite)321 assert.NotContains(t, get("missing").Scopes, domain.ScopeIssueWrite)
322+ assert.NotContains(t, get("missing").Scopes, domain.ScopeMRReview)
323+ assert.NotContains(t, get("repos").Scopes, domain.ScopeMRReview)
324+ assert.NotContains(t, get("prefix").Scopes, domain.ScopeMRReview)
309 assert.False(t, domain.TokenPrincipal(get("missing")).HasOwnerRights())325 assert.False(t, domain.TokenPrincipal(get("missing")).HasOwnerRights())
310 assert.NotContains(t, get("repos").Scopes, domain.ScopeIssueWrite)326 assert.NotContains(t, get("repos").Scopes, domain.ScopeIssueWrite)
311 assert.NotContains(t, get("prefix").Scopes, domain.ScopeIssueWrite)327 assert.NotContains(t, get("prefix").Scopes, domain.ScopeIssueWrite)
@@ -325,6 +341,9 @@ func TestSchema4UpgradesTheTokensWithAllScopes(t *testing.T) {
325 tok, err := s2.Tokens().Get(ctx, "full")341 tok, err := s2.Tokens().Get(ctx, "full")
326 require.NoError(t, err)342 require.NoError(t, err)
327 assert.Equal(t, full.Scopes, tok.Scopes)343 assert.Equal(t, full.Scopes, tok.Scopes)
344+ tok10, err := s2.Tokens().Get(ctx, "full10")
345+ require.NoError(t, err)
346+ assert.Equal(t, full10.Scopes, tok10.Scopes)
328 require.NoError(t, s2.Close(ctx))347 require.NoError(t, s2.Close(ctx))
329 }348 }
330 349
@@ -530,3 +549,13 @@ func TestStoredPendingIssueStateIsCorrupt(t *testing.T) {
530 _, err = is.List(ctx, n, ports.ListIssuesParams{State: domain.IssueStateOpen})549 _, err = is.List(ctx, n, ports.ListIssuesParams{State: domain.IssueStateOpen})
531 assert.ErrorContains(t, err, "pending")550 assert.ErrorContains(t, err, "pending")
532 }551 }
552+
553+func countScope(scopes []domain.Scope, want domain.Scope) int {
554+ n := 0
555+ for _, s := range scopes {
556+ if s == want {
557+ n++
558+ }
559+ }
560+ return n
561+}
M internal/adapters/kvstore/store.go +20 -10
@@ -36,9 +36,10 @@ import (
3636 // scope, so it must refuse the data. It also adds the pending issues of the
3737 // public form (ispend/<repo>/<id>, removed with their repository), the bans of
3838 // their submitters (isban/<hex hash>) and the form's server key
39-// (meta/issuekey). The upgrade also adds issue:write to the
40-// stored tokens that held all nine earlier scopes with no repository list and
41-// no branch prefix, so that they keep the owner rights (see upgradeTokens).
39+// (meta/issuekey). The upgrade also adds mr:review and issue:write (each only
40+// if missing) to the stored tokens that held all nine original scopes with no
41+// repository list and no branch prefix, so that they keep the owner rights (see
42+// upgradeTokens).
4243 const SchemaVersion = 4
4344
4445 const schemaKey = "meta/schema"
@@ -171,7 +172,7 @@ func mustMarshal(m proto.Message) []byte {
171172 return b
172173 }
173174
174-// preIssueScopes are the nine scopes that existed before schema 4.
175+// preIssueScopes are the nine original scopes, before mr:review and issue:write.
175176 var preIssueScopes = []domain.Scope{
176177 domain.ScopeRepoRead, domain.ScopeRepoWrite, domain.ScopeRepoAdmin,
177178 domain.ScopeMRWrite, domain.ScopeMRMerge,
@@ -181,9 +182,10 @@ var preIssueScopes = []domain.Scope{
181182
182183 // upgradeTokens adds to b the schema 4 upgrade of the tokens: a token that
183184 // holds all of preIssueScopes, limited to no repository and no branch prefix,
184-// had the owner rights, which AllScopes now needs issue:write for, so it gets
185-// the scope (this is no escalation). Every other token is left as it is. The
186-// batch requires each token to be at the version it was read at.
185+// had the owner rights, which AllScopes now needs mr:review and issue:write
186+// for, so it gets each of them if missing (this is no escalation). Every other
187+// token is left as it is. The batch requires each token to be at the version it
188+// was read at.
187189 //
188190 //hexcheck:ignore suspicious-business-logic-in-adapter one-time storage upgrade of the stored tokens: a storage concern, no policy
189191 func (s *Store) upgradeTokens(ctx context.Context, b *kv.Batch) error {
@@ -192,8 +194,7 @@ func (s *Store) upgradeTokens(ctx context.Context, b *kv.Batch) error {
192194 if err := proto.Unmarshal(item.Value, pb); err != nil {
193195 return fmt.Errorf("upgrade token %s: %w", key, err)
194196 }
195- if len(pb.GetRepos()) > 0 || len(pb.GetBranchPrefixes()) > 0 ||
196- slices.Contains(pb.GetScopes(), domain.ScopeIssueWrite.String()) {
197+ if len(pb.GetRepos()) > 0 || len(pb.GetBranchPrefixes()) > 0 {
197198 continue
198199 }
199200 full := true
@@ -206,7 +207,16 @@ func (s *Store) upgradeTokens(ctx context.Context, b *kv.Batch) error {
206207 if !full {
207208 continue
208209 }
209- pb.Scopes = append(pb.Scopes, domain.ScopeIssueWrite.String())
210+ added := false
211+ for _, sc := range []domain.Scope{domain.ScopeMRReview, domain.ScopeIssueWrite} {
212+ if !slices.Contains(pb.GetScopes(), sc.String()) {
213+ pb.Scopes = append(pb.Scopes, sc.String())
214+ added = true
215+ }
216+ }
217+ if !added {
218+ continue
219+ }
210220 b.RequireVersion(slices.Clone(key), item.Version)
211221 b.Put(slices.Clone(key), mustMarshal(pb))
212222 }
@@ -36,9 +36,10 @@ import (
36 // scope, so it must refuse the data. It also adds the pending issues of the36 // scope, so it must refuse the data. It also adds the pending issues of the
37 // public form (ispend/<repo>/<id>, removed with their repository), the bans of37 // public form (ispend/<repo>/<id>, removed with their repository), the bans of
38 // their submitters (isban/<hex hash>) and the form's server key38 // their submitters (isban/<hex hash>) and the form's server key
39-// (meta/issuekey). The upgrade also adds issue:write to the39+// (meta/issuekey). The upgrade also adds mr:review and issue:write (each only
40-// stored tokens that held all nine earlier scopes with no repository list and40+// if missing) to the stored tokens that held all nine original scopes with no
41-// no branch prefix, so that they keep the owner rights (see upgradeTokens).41+// repository list and no branch prefix, so that they keep the owner rights (see
42+// upgradeTokens).
42 const SchemaVersion = 443 const SchemaVersion = 4
43 44
44 const schemaKey = "meta/schema"45 const schemaKey = "meta/schema"
@@ -171,7 +172,7 @@ func mustMarshal(m proto.Message) []byte {
171 return b172 return b
172 }173 }
173 174
174-// preIssueScopes are the nine scopes that existed before schema 4.175+// preIssueScopes are the nine original scopes, before mr:review and issue:write.
175 var preIssueScopes = []domain.Scope{176 var preIssueScopes = []domain.Scope{
176 domain.ScopeRepoRead, domain.ScopeRepoWrite, domain.ScopeRepoAdmin,177 domain.ScopeRepoRead, domain.ScopeRepoWrite, domain.ScopeRepoAdmin,
177 domain.ScopeMRWrite, domain.ScopeMRMerge,178 domain.ScopeMRWrite, domain.ScopeMRMerge,
@@ -181,9 +182,10 @@ var preIssueScopes = []domain.Scope{
181 182
182 // upgradeTokens adds to b the schema 4 upgrade of the tokens: a token that183 // upgradeTokens adds to b the schema 4 upgrade of the tokens: a token that
183 // holds all of preIssueScopes, limited to no repository and no branch prefix,184 // holds all of preIssueScopes, limited to no repository and no branch prefix,
184-// had the owner rights, which AllScopes now needs issue:write for, so it gets185+// had the owner rights, which AllScopes now needs mr:review and issue:write
185-// the scope (this is no escalation). Every other token is left as it is. The186+// for, so it gets each of them if missing (this is no escalation). Every other
186-// batch requires each token to be at the version it was read at.187+// token is left as it is. The batch requires each token to be at the version it
188+// was read at.
187 //189 //
188 //hexcheck:ignore suspicious-business-logic-in-adapter one-time storage upgrade of the stored tokens: a storage concern, no policy190 //hexcheck:ignore suspicious-business-logic-in-adapter one-time storage upgrade of the stored tokens: a storage concern, no policy
189 func (s *Store) upgradeTokens(ctx context.Context, b *kv.Batch) error {191 func (s *Store) upgradeTokens(ctx context.Context, b *kv.Batch) error {
@@ -192,8 +194,7 @@ func (s *Store) upgradeTokens(ctx context.Context, b *kv.Batch) error {
192 if err := proto.Unmarshal(item.Value, pb); err != nil {194 if err := proto.Unmarshal(item.Value, pb); err != nil {
193 return fmt.Errorf("upgrade token %s: %w", key, err)195 return fmt.Errorf("upgrade token %s: %w", key, err)
194 }196 }
195- if len(pb.GetRepos()) > 0 || len(pb.GetBranchPrefixes()) > 0 ||197+ if len(pb.GetRepos()) > 0 || len(pb.GetBranchPrefixes()) > 0 {
196- slices.Contains(pb.GetScopes(), domain.ScopeIssueWrite.String()) {
197 continue198 continue
198 }199 }
199 full := true200 full := true
@@ -206,7 +207,16 @@ func (s *Store) upgradeTokens(ctx context.Context, b *kv.Batch) error {
206 if !full {207 if !full {
207 continue208 continue
208 }209 }
209- pb.Scopes = append(pb.Scopes, domain.ScopeIssueWrite.String())210+ added := false
211+ for _, sc := range []domain.Scope{domain.ScopeMRReview, domain.ScopeIssueWrite} {
212+ if !slices.Contains(pb.GetScopes(), sc.String()) {
213+ pb.Scopes = append(pb.Scopes, sc.String())
214+ added = true
215+ }
216+ }
217+ if !added {
218+ continue
219+ }
210 b.RequireVersion(slices.Clone(key), item.Version)220 b.RequireVersion(slices.Clone(key), item.Version)
211 b.Put(slices.Clone(key), mustMarshal(pb))221 b.Put(slices.Clone(key), mustMarshal(pb))
212 }222 }

Keyboard shortcuts

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

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

Branches 2

Tags 43

↑↓ moveenter openesc close