detached eba8ed7

fix(scopes): keep the owner rights of full tokens with mr:force-merge, refresh the owner's merge check controls live

commit
eba8ed78a9322e7b454e9d156553ffe57cc7d139
author
brice · · 2026-10-21 13:39 UTC
committer
brice · · 2026-10-21 13:44 UTC
parent
7348bb2
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
checks
0/4
8 files · +576 -377
M internal/adapters/kvstore/issues_test.go +55 -8
@@ -271,7 +271,7 @@ func TestRepoSettingsKeepTheIssuesMode(t *testing.T) {
271271
272272 // TestSchema4UpgradesTheTokensWithAllScopes: the tokens that held every
273273 // original scope keep their owner rights, gaining mr:review and issue:write
274-// (each only if missing); no other token changes.
274+// (each only if missing), then mr:force-merge in schema 5; no other token changes.
275275 func TestSchema4UpgradesTheTokensWithAllScopes(t *testing.T) {
276276 ctx := context.Background()
277277 dir := t.TempDir()
@@ -309,17 +309,20 @@ func TestSchema4UpgradesTheTokensWithAllScopes(t *testing.T) {
309309 full := get("full")
310310 assert.Contains(t, full.Scopes, domain.ScopeIssueWrite)
311311 assert.Contains(t, full.Scopes, domain.ScopeMRReview)
312- assert.Len(t, full.Scopes, 11)
312+ assert.Len(t, full.Scopes, 12)
313313 full10 := get("full10")
314- assert.Len(t, full10.Scopes, 11)
314+ assert.Len(t, full10.Scopes, 12)
315315 assert.Equal(t, 1, countScope(full10.Scopes, domain.ScopeMRReview))
316316 assert.Contains(t, full10.Scopes, domain.ScopeIssueWrite)
317- assert.False(t, domain.TokenPrincipal(full10).HasOwnerRights(), "historical migration does not grant mr:force-merge")
317+ assert.True(t, domain.TokenPrincipal(full10).HasOwnerRights())
318318 assert.Len(t, get("reposmr").Scopes, 10)
319319 assert.NotContains(t, get("reposmr").Scopes, domain.ScopeIssueWrite)
320- assert.False(t, domain.TokenPrincipal(full).HasOwnerRights(), "historical migration does not grant mr:force-merge")
321- assert.False(t, domain.TokenPrincipal(get("otherfull")).HasOwnerRights(), "historical migration does not grant mr:force-merge")
320+ assert.True(t, domain.TokenPrincipal(full).HasOwnerRights())
321+ assert.True(t, domain.TokenPrincipal(get("otherfull")).HasOwnerRights())
322322
323+ for _, id := range []string{"missing", "reposmr", "repos", "prefix", "small"} {
324+ assert.NotContains(t, get(id).Scopes, domain.ScopeMRForceMerge)
325+ }
323326 assert.NotContains(t, get("missing").Scopes, domain.ScopeIssueWrite)
324327 assert.NotContains(t, get("missing").Scopes, domain.ScopeMRReview)
325328 assert.NotContains(t, get("repos").Scopes, domain.ScopeMRReview)
@@ -335,7 +338,7 @@ func TestSchema4UpgradesTheTokensWithAllScopes(t *testing.T) {
335338 assert.Equal(t, "full", full.ID)
336339
337340 require.NoError(t, s.Close(ctx))
338- assert.Equal(t, uint32(4), readSchema(t, dir))
341+ assert.Equal(t, uint32(5), readSchema(t, dir))
339342
340343 // A second open changes nothing.
341344 s2, err := kvstore.Open(ctx, dir)
@@ -355,7 +358,7 @@ func TestSchemaNewerThanThisBuildStopsIssuesData(t *testing.T) {
355358 putRaw(t, dir, "meta/schema", mustProto(t, &storagepb.SchemaVersion{Version: kvstore.SchemaVersion + 1}))
356359 _, err := kvstore.Open(ctx, dir)
357360 require.ErrorIs(t, err, kvstore.ErrSchemaTooNew)
358- assert.Equal(t, uint32(4), uint32(kvstore.SchemaVersion))
361+ assert.Equal(t, uint32(5), uint32(kvstore.SchemaVersion))
359362 }
360363
361364 // A reopen racing the delete of the repository either lands before the delete
@@ -561,3 +564,47 @@ func countScope(scopes []domain.Scope, want domain.Scope) int {
561564 }
562565 return n
563566 }
567+
568+func TestSchema5UpgradesOwnerEquivalentTokens(t *testing.T) {
569+ ctx := context.Background()
570+ dir := t.TempDir()
571+ putRaw(t, dir, "meta/schema", mustProto(t, &storagepb.SchemaVersion{Version: 4}))
572+ var previous []string
573+ for _, sc := range domain.AllScopes() {
574+ if sc != domain.ScopeMRForceMerge {
575+ previous = append(previous, sc.String())
576+ }
577+ }
578+ tokens := map[string]*storagepb.Token{
579+ "owner": {Id: "owner", Scopes: previous},
580+ "repos": {Id: "repos", Scopes: previous, Repos: []string{"proj"}},
581+ "branch": {Id: "branch", Scopes: previous, BranchPrefixes: []string{"agent/"}},
582+ "partial": {Id: "partial", Scopes: previous[:10]},
583+ }
584+ for id, tok := range tokens {
585+ tok.Hash = make([]byte, 32)
586+ putRaw(t, dir, "token/"+id, mustProto(t, tok))
587+ }
588+ for range 2 {
589+ s, err := kvstore.Open(ctx, dir)
590+ require.NoError(t, err)
591+ for id, original := range tokens {
592+ tok, err := s.Tokens().Get(ctx, id)
593+ require.NoError(t, err)
594+ if id == "owner" {
595+ assert.True(t, domain.TokenPrincipal(tok).HasOwnerRights())
596+ assert.Equal(t, 1, countScope(tok.Scopes, domain.ScopeMRForceMerge))
597+ assert.Len(t, tok.Scopes, 12)
598+ } else {
599+ assert.NotContains(t, tok.Scopes, domain.ScopeMRForceMerge)
600+ var got []string
601+ for _, sc := range tok.Scopes {
602+ got = append(got, sc.String())
603+ }
604+ assert.Equal(t, original.Scopes, got)
605+ }
606+ }
607+ require.NoError(t, s.Close(ctx))
608+ assert.Equal(t, uint32(5), readSchema(t, dir))
609+ }
610+}
@@ -271,7 +271,7 @@ func TestRepoSettingsKeepTheIssuesMode(t *testing.T) {
271 271
272 // TestSchema4UpgradesTheTokensWithAllScopes: the tokens that held every272 // TestSchema4UpgradesTheTokensWithAllScopes: the tokens that held every
273 // original scope keep their owner rights, gaining mr:review and issue:write273 // original scope keep their owner rights, gaining mr:review and issue:write
274-// (each only if missing); no other token changes.274+// (each only if missing), then mr:force-merge in schema 5; no other token changes.
275 func TestSchema4UpgradesTheTokensWithAllScopes(t *testing.T) {275 func TestSchema4UpgradesTheTokensWithAllScopes(t *testing.T) {
276 ctx := context.Background()276 ctx := context.Background()
277 dir := t.TempDir()277 dir := t.TempDir()
@@ -309,17 +309,20 @@ func TestSchema4UpgradesTheTokensWithAllScopes(t *testing.T) {
309 full := get("full")309 full := get("full")
310 assert.Contains(t, full.Scopes, domain.ScopeIssueWrite)310 assert.Contains(t, full.Scopes, domain.ScopeIssueWrite)
311 assert.Contains(t, full.Scopes, domain.ScopeMRReview)311 assert.Contains(t, full.Scopes, domain.ScopeMRReview)
312- assert.Len(t, full.Scopes, 11)312+ assert.Len(t, full.Scopes, 12)
313 full10 := get("full10")313 full10 := get("full10")
314- assert.Len(t, full10.Scopes, 11)314+ assert.Len(t, full10.Scopes, 12)
315 assert.Equal(t, 1, countScope(full10.Scopes, domain.ScopeMRReview))315 assert.Equal(t, 1, countScope(full10.Scopes, domain.ScopeMRReview))
316 assert.Contains(t, full10.Scopes, domain.ScopeIssueWrite)316 assert.Contains(t, full10.Scopes, domain.ScopeIssueWrite)
317- assert.False(t, domain.TokenPrincipal(full10).HasOwnerRights(), "historical migration does not grant mr:force-merge")317+ assert.True(t, domain.TokenPrincipal(full10).HasOwnerRights())
318 assert.Len(t, get("reposmr").Scopes, 10)318 assert.Len(t, get("reposmr").Scopes, 10)
319 assert.NotContains(t, get("reposmr").Scopes, domain.ScopeIssueWrite)319 assert.NotContains(t, get("reposmr").Scopes, domain.ScopeIssueWrite)
320- assert.False(t, domain.TokenPrincipal(full).HasOwnerRights(), "historical migration does not grant mr:force-merge")320+ assert.True(t, domain.TokenPrincipal(full).HasOwnerRights())
321- assert.False(t, domain.TokenPrincipal(get("otherfull")).HasOwnerRights(), "historical migration does not grant mr:force-merge")321+ assert.True(t, domain.TokenPrincipal(get("otherfull")).HasOwnerRights())
322 322
323+ for _, id := range []string{"missing", "reposmr", "repos", "prefix", "small"} {
324+ assert.NotContains(t, get(id).Scopes, domain.ScopeMRForceMerge)
325+ }
323 assert.NotContains(t, get("missing").Scopes, domain.ScopeIssueWrite)326 assert.NotContains(t, get("missing").Scopes, domain.ScopeIssueWrite)
324 assert.NotContains(t, get("missing").Scopes, domain.ScopeMRReview)327 assert.NotContains(t, get("missing").Scopes, domain.ScopeMRReview)
325 assert.NotContains(t, get("repos").Scopes, domain.ScopeMRReview)328 assert.NotContains(t, get("repos").Scopes, domain.ScopeMRReview)
@@ -335,7 +338,7 @@ func TestSchema4UpgradesTheTokensWithAllScopes(t *testing.T) {
335 assert.Equal(t, "full", full.ID)338 assert.Equal(t, "full", full.ID)
336 339
337 require.NoError(t, s.Close(ctx))340 require.NoError(t, s.Close(ctx))
338- assert.Equal(t, uint32(4), readSchema(t, dir))341+ assert.Equal(t, uint32(5), readSchema(t, dir))
339 342
340 // A second open changes nothing.343 // A second open changes nothing.
341 s2, err := kvstore.Open(ctx, dir)344 s2, err := kvstore.Open(ctx, dir)
@@ -355,7 +358,7 @@ func TestSchemaNewerThanThisBuildStopsIssuesData(t *testing.T) {
355 putRaw(t, dir, "meta/schema", mustProto(t, &storagepb.SchemaVersion{Version: kvstore.SchemaVersion + 1}))358 putRaw(t, dir, "meta/schema", mustProto(t, &storagepb.SchemaVersion{Version: kvstore.SchemaVersion + 1}))
356 _, err := kvstore.Open(ctx, dir)359 _, err := kvstore.Open(ctx, dir)
357 require.ErrorIs(t, err, kvstore.ErrSchemaTooNew)360 require.ErrorIs(t, err, kvstore.ErrSchemaTooNew)
358- assert.Equal(t, uint32(4), uint32(kvstore.SchemaVersion))361+ assert.Equal(t, uint32(5), uint32(kvstore.SchemaVersion))
359 }362 }
360 363
361 // A reopen racing the delete of the repository either lands before the delete364 // A reopen racing the delete of the repository either lands before the delete
@@ -561,3 +564,47 @@ func countScope(scopes []domain.Scope, want domain.Scope) int {
561 }564 }
562 return n565 return n
563 }566 }
567+
568+func TestSchema5UpgradesOwnerEquivalentTokens(t *testing.T) {
569+ ctx := context.Background()
570+ dir := t.TempDir()
571+ putRaw(t, dir, "meta/schema", mustProto(t, &storagepb.SchemaVersion{Version: 4}))
572+ var previous []string
573+ for _, sc := range domain.AllScopes() {
574+ if sc != domain.ScopeMRForceMerge {
575+ previous = append(previous, sc.String())
576+ }
577+ }
578+ tokens := map[string]*storagepb.Token{
579+ "owner": {Id: "owner", Scopes: previous},
580+ "repos": {Id: "repos", Scopes: previous, Repos: []string{"proj"}},
581+ "branch": {Id: "branch", Scopes: previous, BranchPrefixes: []string{"agent/"}},
582+ "partial": {Id: "partial", Scopes: previous[:10]},
583+ }
584+ for id, tok := range tokens {
585+ tok.Hash = make([]byte, 32)
586+ putRaw(t, dir, "token/"+id, mustProto(t, tok))
587+ }
588+ for range 2 {
589+ s, err := kvstore.Open(ctx, dir)
590+ require.NoError(t, err)
591+ for id, original := range tokens {
592+ tok, err := s.Tokens().Get(ctx, id)
593+ require.NoError(t, err)
594+ if id == "owner" {
595+ assert.True(t, domain.TokenPrincipal(tok).HasOwnerRights())
596+ assert.Equal(t, 1, countScope(tok.Scopes, domain.ScopeMRForceMerge))
597+ assert.Len(t, tok.Scopes, 12)
598+ } else {
599+ assert.NotContains(t, tok.Scopes, domain.ScopeMRForceMerge)
600+ var got []string
601+ for _, sc := range tok.Scopes {
602+ got = append(got, sc.String())
603+ }
604+ assert.Equal(t, original.Scopes, got)
605+ }
606+ }
607+ require.NoError(t, s.Close(ctx))
608+ assert.Equal(t, uint32(5), readSchema(t, dir))
609+ }
610+}
M internal/adapters/kvstore/store.go +36 -8
@@ -39,8 +39,9 @@ import (
3939 // (meta/issuekey). The upgrade also adds mr:review and issue:write (each only
4040 // if missing) to the stored tokens that held all nine original scopes with no
4141 // repository list and no branch prefix, so that they keep the owner rights (see
42-// upgradeTokens).
43-const SchemaVersion = 4
42+// upgradeTokens). Version 5 grants mr:force-merge to unrestricted tokens
43+// holding all eleven previous scopes, preserving their owner rights.
44+const SchemaVersion = 5
4445
4546 const schemaKey = "meta/schema"
4647
@@ -109,7 +110,7 @@ func (s *Store) Check(ctx context.Context) error {
109110 func (s *Store) checkSchema(ctx context.Context) error {
110111 log := zerowrap.FromCtx(ctx)
111112 key := []byte(schemaKey)
112- for range 2 {
113+ for range 4 {
113114 item, ok := s.db.Get(ctx, key)
114115 if !ok {
115116 var b kv.Batch
@@ -140,6 +141,10 @@ func (s *Store) checkSchema(ctx context.Context) error {
140141 // the new version is recorded so that older builds refuse it.
141142 // The tokens are upgraded in the same batch, so that a crash
142143 // leaves the old version and the upgrade runs again.
144+ target := uint32(SchemaVersion)
145+ if sv.GetVersion() < 4 {
146+ target = 4
147+ }
143148 var b kv.Batch
144149 b.RequireVersion(key, item.Version)
145150 if sv.GetVersion() < 4 {
@@ -147,7 +152,12 @@ func (s *Store) checkSchema(ctx context.Context) error {
147152 return err
148153 }
149154 }
150- b.Put(key, mustMarshal(&storagepb.SchemaVersion{Version: SchemaVersion}))
155+ if sv.GetVersion() == 4 {
156+ if err := s.upgradeForceMergeTokens(ctx, &b); err != nil {
157+ return err
158+ }
159+ }
160+ b.Put(key, mustMarshal(&storagepb.SchemaVersion{Version: target}))
151161 _, err := s.db.Apply(ctx, &b)
152162 if errors.Is(err, kv.ErrConflict) {
153163 continue // changed meanwhile: read it again
@@ -155,7 +165,10 @@ func (s *Store) checkSchema(ctx context.Context) error {
155165 if err != nil {
156166 return fmt.Errorf("upgrade storage schema: %w", err)
157167 }
158- log.Info().Uint32("from", sv.GetVersion()).Uint32("schema", SchemaVersion).Msg("storage schema upgraded")
168+ log.Info().Uint32("from", sv.GetVersion()).Uint32("schema", target).Msg("storage schema upgraded")
169+ if target < SchemaVersion {
170+ continue
171+ }
159172 return nil
160173 }
161174 log.Info().Uint32("schema", sv.GetVersion()).Msg("storage schema checked")
@@ -182,11 +195,26 @@ var preIssueScopes = []domain.Scope{
182195
183196 // upgradeTokens adds to b the schema 4 upgrade of the tokens: a token that
184197 // holds all of preIssueScopes, limited to no repository and no branch prefix,
185-// had the owner rights, which AllScopes now needs mr:review and issue:write
198+// had the owner rights, which schema 4 needed mr:review and issue:write
186199 // for, so it gets each of them if missing (this is no escalation). Every other
187200 // token is left as it is. The batch requires each token to be at the version it
188201 // was read at.
189202 func (s *Store) upgradeTokens(ctx context.Context, b *kv.Batch) error {
203+ return s.upgradeTokenScopes(ctx, b, preIssueScopes, []domain.Scope{domain.ScopeMRReview, domain.ScopeIssueWrite})
204+}
205+
206+// preForceMergeScopes are the eleven scopes of schema 4, before mr:force-merge.
207+var preForceMergeScopes = append(slices.Clone(preIssueScopes), domain.ScopeMRReview, domain.ScopeIssueWrite)
208+
209+// upgradeForceMergeTokens adds to b the schema 5 upgrade of the tokens: a
210+// token that holds all of preForceMergeScopes, limited to no repository and no
211+// branch prefix, had the owner rights, which AllScopes also needs
212+// mr:force-merge for, so it gets it. Every other token is left as it is.
213+func (s *Store) upgradeForceMergeTokens(ctx context.Context, b *kv.Batch) error {
214+ return s.upgradeTokenScopes(ctx, b, preForceMergeScopes, []domain.Scope{domain.ScopeMRForceMerge})
215+}
216+
217+func (s *Store) upgradeTokenScopes(ctx context.Context, b *kv.Batch, required, addedScopes []domain.Scope) error {
190218 for key, item := range s.db.Snapshot(ctx).Prefix(ctx, []byte(tokenPrefix)) {
191219 pb := &storagepb.Token{}
192220 if err := proto.Unmarshal(item.Value, pb); err != nil {
@@ -196,7 +224,7 @@ func (s *Store) upgradeTokens(ctx context.Context, b *kv.Batch) error {
196224 continue
197225 }
198226 full := true
199- for _, sc := range preIssueScopes {
227+ for _, sc := range required {
200228 if !slices.Contains(pb.GetScopes(), sc.String()) {
201229 full = false
202230 break
@@ -206,7 +234,7 @@ func (s *Store) upgradeTokens(ctx context.Context, b *kv.Batch) error {
206234 continue
207235 }
208236 added := false
209- for _, sc := range []domain.Scope{domain.ScopeMRReview, domain.ScopeIssueWrite} {
237+ for _, sc := range addedScopes {
210238 if !slices.Contains(pb.GetScopes(), sc.String()) {
211239 pb.Scopes = append(pb.Scopes, sc.String())
212240 added = true
@@ -39,8 +39,9 @@ import (
39 // (meta/issuekey). The upgrade also adds mr:review and issue:write (each only39 // (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 no40 // 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 (see41 // repository list and no branch prefix, so that they keep the owner rights (see
42-// upgradeTokens).42+// upgradeTokens). Version 5 grants mr:force-merge to unrestricted tokens
43-const SchemaVersion = 443+// holding all eleven previous scopes, preserving their owner rights.
44+const SchemaVersion = 5
44 45
45 const schemaKey = "meta/schema"46 const schemaKey = "meta/schema"
46 47
@@ -109,7 +110,7 @@ func (s *Store) Check(ctx context.Context) error {
109 func (s *Store) checkSchema(ctx context.Context) error {110 func (s *Store) checkSchema(ctx context.Context) error {
110 log := zerowrap.FromCtx(ctx)111 log := zerowrap.FromCtx(ctx)
111 key := []byte(schemaKey)112 key := []byte(schemaKey)
112- for range 2 {113+ for range 4 {
113 item, ok := s.db.Get(ctx, key)114 item, ok := s.db.Get(ctx, key)
114 if !ok {115 if !ok {
115 var b kv.Batch116 var b kv.Batch
@@ -140,6 +141,10 @@ func (s *Store) checkSchema(ctx context.Context) error {
140 // the new version is recorded so that older builds refuse it.141 // the new version is recorded so that older builds refuse it.
141 // The tokens are upgraded in the same batch, so that a crash142 // The tokens are upgraded in the same batch, so that a crash
142 // leaves the old version and the upgrade runs again.143 // leaves the old version and the upgrade runs again.
144+ target := uint32(SchemaVersion)
145+ if sv.GetVersion() < 4 {
146+ target = 4
147+ }
143 var b kv.Batch148 var b kv.Batch
144 b.RequireVersion(key, item.Version)149 b.RequireVersion(key, item.Version)
145 if sv.GetVersion() < 4 {150 if sv.GetVersion() < 4 {
@@ -147,7 +152,12 @@ func (s *Store) checkSchema(ctx context.Context) error {
147 return err152 return err
148 }153 }
149 }154 }
150- b.Put(key, mustMarshal(&storagepb.SchemaVersion{Version: SchemaVersion}))155+ if sv.GetVersion() == 4 {
156+ if err := s.upgradeForceMergeTokens(ctx, &b); err != nil {
157+ return err
158+ }
159+ }
160+ b.Put(key, mustMarshal(&storagepb.SchemaVersion{Version: target}))
151 _, err := s.db.Apply(ctx, &b)161 _, err := s.db.Apply(ctx, &b)
152 if errors.Is(err, kv.ErrConflict) {162 if errors.Is(err, kv.ErrConflict) {
153 continue // changed meanwhile: read it again163 continue // changed meanwhile: read it again
@@ -155,7 +165,10 @@ func (s *Store) checkSchema(ctx context.Context) error {
155 if err != nil {165 if err != nil {
156 return fmt.Errorf("upgrade storage schema: %w", err)166 return fmt.Errorf("upgrade storage schema: %w", err)
157 }167 }
158- log.Info().Uint32("from", sv.GetVersion()).Uint32("schema", SchemaVersion).Msg("storage schema upgraded")168+ log.Info().Uint32("from", sv.GetVersion()).Uint32("schema", target).Msg("storage schema upgraded")
169+ if target < SchemaVersion {
170+ continue
171+ }
159 return nil172 return nil
160 }173 }
161 log.Info().Uint32("schema", sv.GetVersion()).Msg("storage schema checked")174 log.Info().Uint32("schema", sv.GetVersion()).Msg("storage schema checked")
@@ -182,11 +195,26 @@ var preIssueScopes = []domain.Scope{
182 195
183 // upgradeTokens adds to b the schema 4 upgrade of the tokens: a token that196 // upgradeTokens adds to b the schema 4 upgrade of the tokens: a token that
184 // holds all of preIssueScopes, limited to no repository and no branch prefix,197 // holds all of preIssueScopes, limited to no repository and no branch prefix,
185-// had the owner rights, which AllScopes now needs mr:review and issue:write198+// had the owner rights, which schema 4 needed mr:review and issue:write
186 // for, so it gets each of them if missing (this is no escalation). Every other199 // 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 it200 // token is left as it is. The batch requires each token to be at the version it
188 // was read at.201 // was read at.
189 func (s *Store) upgradeTokens(ctx context.Context, b *kv.Batch) error {202 func (s *Store) upgradeTokens(ctx context.Context, b *kv.Batch) error {
203+ return s.upgradeTokenScopes(ctx, b, preIssueScopes, []domain.Scope{domain.ScopeMRReview, domain.ScopeIssueWrite})
204+}
205+
206+// preForceMergeScopes are the eleven scopes of schema 4, before mr:force-merge.
207+var preForceMergeScopes = append(slices.Clone(preIssueScopes), domain.ScopeMRReview, domain.ScopeIssueWrite)
208+
209+// upgradeForceMergeTokens adds to b the schema 5 upgrade of the tokens: a
210+// token that holds all of preForceMergeScopes, limited to no repository and no
211+// branch prefix, had the owner rights, which AllScopes also needs
212+// mr:force-merge for, so it gets it. Every other token is left as it is.
213+func (s *Store) upgradeForceMergeTokens(ctx context.Context, b *kv.Batch) error {
214+ return s.upgradeTokenScopes(ctx, b, preForceMergeScopes, []domain.Scope{domain.ScopeMRForceMerge})
215+}
216+
217+func (s *Store) upgradeTokenScopes(ctx context.Context, b *kv.Batch, required, addedScopes []domain.Scope) error {
190 for key, item := range s.db.Snapshot(ctx).Prefix(ctx, []byte(tokenPrefix)) {218 for key, item := range s.db.Snapshot(ctx).Prefix(ctx, []byte(tokenPrefix)) {
191 pb := &storagepb.Token{}219 pb := &storagepb.Token{}
192 if err := proto.Unmarshal(item.Value, pb); err != nil {220 if err := proto.Unmarshal(item.Value, pb); err != nil {
@@ -196,7 +224,7 @@ func (s *Store) upgradeTokens(ctx context.Context, b *kv.Batch) error {
196 continue224 continue
197 }225 }
198 full := true226 full := true
199- for _, sc := range preIssueScopes {227+ for _, sc := range required {
200 if !slices.Contains(pb.GetScopes(), sc.String()) {228 if !slices.Contains(pb.GetScopes(), sc.String()) {
201 full = false229 full = false
202 break230 break
@@ -206,7 +234,7 @@ func (s *Store) upgradeTokens(ctx context.Context, b *kv.Batch) error {
206 continue234 continue
207 }235 }
208 added := false236 added := false
209- for _, sc := range []domain.Scope{domain.ScopeMRReview, domain.ScopeIssueWrite} {237+ for _, sc := range addedScopes {
210 if !slices.Contains(pb.GetScopes(), sc.String()) {238 if !slices.Contains(pb.GetScopes(), sc.String()) {
211 pb.Scopes = append(pb.Scopes, sc.String())239 pb.Scopes = append(pb.Scopes, sc.String())
212 added = true240 added = true
M internal/adapters/web/fragments.go +18 -1
@@ -36,6 +36,8 @@ type liveStream struct {
3636 // head shown (its checks are on that head); a job event does not carry
3737 // its commit, so its run is read once. One stream reads it, no lock.
3838 headRuns map[int64]bool
39+ // mergeChecksHTML is the last merge-form part sent, not sent again unchanged.
40+ mergeChecksHTML string
3941 }
4042
4143 // maxHeadRuns bounds headRuns: past it the map starts again.
@@ -625,7 +627,22 @@ func (l *liveStream) mrChecks(ctx context.Context) ([]fragment, error) {
625627 return nil, err
626628 }
627629 sum, err := renderFragment(ctx, "ci-run", MRChecksSum(checksSummary(name, l.head, checks)))
628- return []fragment{f, sum}, err
630+ if err != nil {
631+ return nil, err
632+ }
633+ // The merge form is the owner's: a visitor's stream never carries it.
634+ if !SignedIn(ctx) {
635+ return []fragment{f, sum}, nil
636+ }
637+ controls, err := renderFragment(ctx, "ci-run", MRMergeChecks(checksSummary(name, l.head, checks)))
638+ if err != nil {
639+ return nil, err
640+ }
641+ if controls.html == l.mergeChecksHTML {
642+ return []fragment{f, sum}, nil
643+ }
644+ l.mergeChecksHTML = controls.html
645+ return []fragment{f, sum, controls}, nil
629646 }
630647
631648 // recentActions is the "Recent actions" block of the summary rail, read again
@@ -36,6 +36,8 @@ type liveStream struct {
36 // head shown (its checks are on that head); a job event does not carry36 // head shown (its checks are on that head); a job event does not carry
37 // its commit, so its run is read once. One stream reads it, no lock.37 // its commit, so its run is read once. One stream reads it, no lock.
38 headRuns map[int64]bool38 headRuns map[int64]bool
39+ // mergeChecksHTML is the last merge-form part sent, not sent again unchanged.
40+ mergeChecksHTML string
39 }41 }
40 42
41 // maxHeadRuns bounds headRuns: past it the map starts again.43 // maxHeadRuns bounds headRuns: past it the map starts again.
@@ -625,7 +627,22 @@ func (l *liveStream) mrChecks(ctx context.Context) ([]fragment, error) {
625 return nil, err627 return nil, err
626 }628 }
627 sum, err := renderFragment(ctx, "ci-run", MRChecksSum(checksSummary(name, l.head, checks)))629 sum, err := renderFragment(ctx, "ci-run", MRChecksSum(checksSummary(name, l.head, checks)))
628- return []fragment{f, sum}, err630+ if err != nil {
631+ return nil, err
632+ }
633+ // The merge form is the owner's: a visitor's stream never carries it.
634+ if !SignedIn(ctx) {
635+ return []fragment{f, sum}, nil
636+ }
637+ controls, err := renderFragment(ctx, "ci-run", MRMergeChecks(checksSummary(name, l.head, checks)))
638+ if err != nil {
639+ return nil, err
640+ }
641+ if controls.html == l.mergeChecksHTML {
642+ return []fragment{f, sum}, nil
643+ }
644+ l.mergeChecksHTML = controls.html
645+ return []fragment{f, sum, controls}, nil
629 }646 }
630 647
631 // recentActions is the "Recent actions" block of the summary rail, read again648 // recentActions is the "Recent actions" block of the summary rail, read again
M internal/adapters/web/mr.templ +12 -4
@@ -545,10 +545,7 @@ templ mergeMenu(p MRPage) {
545545 </label>
546546 }
547547 </fieldset>
548- if p.ChecksSum.Kind == "bad" || p.ChecksSum.Kind == "running" {
549- <p class="d-note">{ p.ChecksSum.State + ": a failing or unfinished check blocks the merge." }</p>
550- <label class="d-check"><input type="checkbox" name="ignore_checks"/> Ignore checks</label>
551- }
548+ @MRMergeChecks(p.ChecksSum)
552549 if p.Box.CanDeleteBranch {
553550 <label class="d-check"><input type="checkbox" name="delete_branch" value="1"/> delete { p.Source } after the merge <kbd aria-hidden="true">D</kbd></label>
554551 }
@@ -770,3 +767,14 @@ templ compareRail(p ComparePage) {
770767 @CloneBlock(p.CLI)
771768 </section>
772769 }
770+
771+// MRMergeChecks is the part of the owner's merge form that depends on the
772+// checks: a live update replaces it and keeps "Ignore checks" ticked.
773+templ MRMergeChecks(c ChecksSummary) {
774+ <div id="mr-merge-checks" data-keep-checks>
775+ if c.Kind == "bad" || c.Kind == "running" {
776+ <p class="d-note">{ c.State + ": a failing or unfinished check blocks the merge." }</p>
777+ <label class="d-check"><input type="checkbox" name="ignore_checks"/> Ignore checks</label>
778+ }
779+ </div>
780+}
@@ -545,10 +545,7 @@ templ mergeMenu(p MRPage) {
545 </label>545 </label>
546 }546 }
547 </fieldset>547 </fieldset>
548- if p.ChecksSum.Kind == "bad" || p.ChecksSum.Kind == "running" {548+ @MRMergeChecks(p.ChecksSum)
549- <p class="d-note">{ p.ChecksSum.State + ": a failing or unfinished check blocks the merge." }</p>
550- <label class="d-check"><input type="checkbox" name="ignore_checks"/> Ignore checks</label>
551- }
552 if p.Box.CanDeleteBranch {549 if p.Box.CanDeleteBranch {
553 <label class="d-check"><input type="checkbox" name="delete_branch" value="1"/> delete { p.Source } after the merge <kbd aria-hidden="true">D</kbd></label>550 <label class="d-check"><input type="checkbox" name="delete_branch" value="1"/> delete { p.Source } after the merge <kbd aria-hidden="true">D</kbd></label>
554 }551 }
@@ -770,3 +767,14 @@ templ compareRail(p ComparePage) {
770 @CloneBlock(p.CLI)767 @CloneBlock(p.CLI)
771 </section>768 </section>
772 }769 }
770+
771+// MRMergeChecks is the part of the owner's merge form that depends on the
772+// checks: a live update replaces it and keeps "Ignore checks" ticked.
773+templ MRMergeChecks(c ChecksSummary) {
774+ <div id="mr-merge-checks" data-keep-checks>
775+ if c.Kind == "bad" || c.Kind == "running" {
776+ <p class="d-note">{ c.State + ": a failing or unfinished check blocks the merge." }</p>
777+ <label class="d-check"><input type="checkbox" name="ignore_checks"/> Ignore checks</label>
778+ }
779+ </div>
780+}
M internal/adapters/web/mr_templ.go +393 -354 generated file: collapsed
M internal/adapters/web/static/js/ermite.js +7 -0
@@ -966,6 +966,13 @@ window.ermiteLive = (() => {
966966 continue;
967967 }
968968 if (old.hasAttribute('data-cursor')) el.setAttribute('data-cursor', '');
969+ // data-keep-checks: the checkboxes the user ticked stay ticked.
970+ if (old.hasAttribute('data-keep-checks')) {
971+ for (const box of old.querySelectorAll('input[type=checkbox][name]:checked')) {
972+ const same = el.querySelector(`input[type=checkbox][name="${CSS.escape(box.name)}"]`);
973+ if (same) same.checked = true;
974+ }
975+ }
969976 old.replaceWith(el);
970977 const rows = document.getElementById('repo-rows');
971978 if (rows && el.parentElement === rows && rows.dataset.sort === 'activity' &&
@@ -966,6 +966,13 @@ window.ermiteLive = (() => {
966 continue;966 continue;
967 }967 }
968 if (old.hasAttribute('data-cursor')) el.setAttribute('data-cursor', '');968 if (old.hasAttribute('data-cursor')) el.setAttribute('data-cursor', '');
969+ // data-keep-checks: the checkboxes the user ticked stay ticked.
970+ if (old.hasAttribute('data-keep-checks')) {
971+ for (const box of old.querySelectorAll('input[type=checkbox][name]:checked')) {
972+ const same = el.querySelector(`input[type=checkbox][name="${CSS.escape(box.name)}"]`);
973+ if (same) same.checked = true;
974+ }
975+ }
969 old.replaceWith(el);976 old.replaceWith(el);
970 const rows = document.getElementById('repo-rows');977 const rows = document.getElementById('repo-rows');
971 if (rows && el.parentElement === rows && rows.dataset.sort === 'activity' &&978 if (rows && el.parentElement === rows && rows.dataset.sort === 'activity' &&
M internal/adapters/web/summary_actions_test.go +52 -0
@@ -4,6 +4,7 @@ package web
44
55 import (
66 "bytes"
7+ "context"
78 "errors"
89 "strings"
910 "testing"
@@ -125,3 +126,54 @@ func TestRunUpdatedOnSummaryWhenCIIsNotReadable(t *testing.T) {
125126 assert.Contains(t, got[0].html, "hidden", "the block empties and hides, no run leaks")
126127 assert.NotContains(t, got[0].html, "rr-name")
127128 }
129+
130+func TestMRChecksLiveMergeControls(t *testing.T) {
131+ t.Parallel()
132+ repo := repoName(t, "proj")
133+ head := strings.Repeat("a", domain.ObjectIDLen)
134+ for _, kind := range []string{"bad", "running", "ok", ""} {
135+ t.Run(kind, func(t *testing.T) {
136+ runs := mocks.NewRunService(t)
137+ var checks []ports.CheckView
138+ if kind != "" {
139+ check := domain.CICheck{Name: "ci", Status: domain.CICompleted, Conclusion: domain.CISuccess}
140+ if kind == "bad" {
141+ check.Conclusion = domain.CIFailure
142+ }
143+ if kind == "running" {
144+ check.Status, check.Conclusion = domain.CIQueued, ""
145+ }
146+ checks = []ports.CheckView{{CICheck: check}}
147+ }
148+ runs.EXPECT().ListChecks(mock.Anything, mock.Anything, repo, head).Return(ports.ChecksResult{Checks: checks}, nil)
149+ srv := newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "proj")), Browse: mocks.NewBrowseService(t), Runs: runs})
150+
151+ // A visitor's stream never carries the owner's merge form.
152+ visitor, err := (&liveStream{s: srv, repo: "proj", head: head, view: "mr"}).mrChecks(t.Context())
153+ require.NoError(t, err)
154+ require.Len(t, visitor, 2)
155+ for _, f := range visitor {
156+ assert.NotContains(t, f.html, `mr-merge-checks`)
157+ }
158+
159+ owner := context.WithValue(t.Context(), signedInKey{}, struct{}{})
160+ ls := &liveStream{s: srv, repo: "proj", head: head, view: "mr"}
161+ got, err := ls.mrChecks(owner)
162+ require.NoError(t, err)
163+ require.Len(t, got, 3)
164+ assert.Contains(t, got[2].html, `data-keep-checks`, "a swap keeps the ticked box")
165+ assert.Equal(t, "ci-run", got[2].event)
166+ assert.Contains(t, got[2].html, `id="mr-merge-checks"`)
167+ assert.Equal(t, renderToString(t, MRMergeChecks(checksSummary(repo, head, checks))), got[2].html)
168+ if kind == "bad" || kind == "running" {
169+ assert.Contains(t, got[2].html, `name="ignore_checks"`)
170+ } else {
171+ assert.NotContains(t, got[2].html, `name="ignore_checks"`)
172+ }
173+ // An unrelated job event with identical checks must not reset the checkbox.
174+ got, err = ls.mrChecks(owner)
175+ require.NoError(t, err)
176+ require.Len(t, got, 2)
177+ })
178+ }
179+}
@@ -4,6 +4,7 @@ package web
4 4
5 import (5 import (
6 "bytes"6 "bytes"
7+ "context"
7 "errors"8 "errors"
8 "strings"9 "strings"
9 "testing"10 "testing"
@@ -125,3 +126,54 @@ func TestRunUpdatedOnSummaryWhenCIIsNotReadable(t *testing.T) {
125 assert.Contains(t, got[0].html, "hidden", "the block empties and hides, no run leaks")126 assert.Contains(t, got[0].html, "hidden", "the block empties and hides, no run leaks")
126 assert.NotContains(t, got[0].html, "rr-name")127 assert.NotContains(t, got[0].html, "rr-name")
127 }128 }
129+
130+func TestMRChecksLiveMergeControls(t *testing.T) {
131+ t.Parallel()
132+ repo := repoName(t, "proj")
133+ head := strings.Repeat("a", domain.ObjectIDLen)
134+ for _, kind := range []string{"bad", "running", "ok", ""} {
135+ t.Run(kind, func(t *testing.T) {
136+ runs := mocks.NewRunService(t)
137+ var checks []ports.CheckView
138+ if kind != "" {
139+ check := domain.CICheck{Name: "ci", Status: domain.CICompleted, Conclusion: domain.CISuccess}
140+ if kind == "bad" {
141+ check.Conclusion = domain.CIFailure
142+ }
143+ if kind == "running" {
144+ check.Status, check.Conclusion = domain.CIQueued, ""
145+ }
146+ checks = []ports.CheckView{{CICheck: check}}
147+ }
148+ runs.EXPECT().ListChecks(mock.Anything, mock.Anything, repo, head).Return(ports.ChecksResult{Checks: checks}, nil)
149+ srv := newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "proj")), Browse: mocks.NewBrowseService(t), Runs: runs})
150+
151+ // A visitor's stream never carries the owner's merge form.
152+ visitor, err := (&liveStream{s: srv, repo: "proj", head: head, view: "mr"}).mrChecks(t.Context())
153+ require.NoError(t, err)
154+ require.Len(t, visitor, 2)
155+ for _, f := range visitor {
156+ assert.NotContains(t, f.html, `mr-merge-checks`)
157+ }
158+
159+ owner := context.WithValue(t.Context(), signedInKey{}, struct{}{})
160+ ls := &liveStream{s: srv, repo: "proj", head: head, view: "mr"}
161+ got, err := ls.mrChecks(owner)
162+ require.NoError(t, err)
163+ require.Len(t, got, 3)
164+ assert.Contains(t, got[2].html, `data-keep-checks`, "a swap keeps the ticked box")
165+ assert.Equal(t, "ci-run", got[2].event)
166+ assert.Contains(t, got[2].html, `id="mr-merge-checks"`)
167+ assert.Equal(t, renderToString(t, MRMergeChecks(checksSummary(repo, head, checks))), got[2].html)
168+ if kind == "bad" || kind == "running" {
169+ assert.Contains(t, got[2].html, `name="ignore_checks"`)
170+ } else {
171+ assert.NotContains(t, got[2].html, `name="ignore_checks"`)
172+ }
173+ // An unrelated job event with identical checks must not reset the checkbox.
174+ got, err = ls.mrChecks(owner)
175+ require.NoError(t, err)
176+ require.Len(t, got, 2)
177+ })
178+ }
179+}
M internal/usecase/mergerequests.go +3 -2
@@ -995,8 +995,9 @@ func (m *MergeRequests) MarkRead(ctx context.Context, p domain.Principal, name d
995995 // allowed by the settings of the repository, the source branch no longer
996996 // points to the head ("branch moved, read again") or is gone, the target is
997997 // gone, the mode's own condition does not hold (see below), or the branch
998-// brings nothing. Checks come with CI (CI-1): there are none yet, so the
999-// setting that lets failing checks block the merge has nothing to act on.
998+// brings nothing. Checks on the head must pass: none may be failed, queued
999+// or running; a head with no checks may be merged. params.IgnoreChecks bypasses
1000+// this requirement and needs mr:force-merge.
10001001 //
10011002 // The conditions are always checked with Git, under the lock, never from the
10021003 // stored mergeability. A fast-forward needs the target to be an ancestor of
@@ -995,8 +995,9 @@ func (m *MergeRequests) MarkRead(ctx context.Context, p domain.Principal, name d
995 // allowed by the settings of the repository, the source branch no longer995 // allowed by the settings of the repository, the source branch no longer
996 // points to the head ("branch moved, read again") or is gone, the target is996 // points to the head ("branch moved, read again") or is gone, the target is
997 // gone, the mode's own condition does not hold (see below), or the branch997 // gone, the mode's own condition does not hold (see below), or the branch
998-// brings nothing. Checks come with CI (CI-1): there are none yet, so the998+// brings nothing. Checks on the head must pass: none may be failed, queued
999-// setting that lets failing checks block the merge has nothing to act on.999+// or running; a head with no checks may be merged. params.IgnoreChecks bypasses
1000+// this requirement and needs mr:force-merge.
1000 //1001 //
1001 // The conditions are always checked with Git, under the lock, never from the1002 // The conditions are always checked with Git, under the lock, never from the
1002 // stored mergeability. A fast-forward needs the target to be an ancestor of1003 // stored mergeability. A fast-forward needs the target to be an ancestor of

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 41

↑↓ moveenter openesc close