detached f61e6a6

#23 feat(merge): mr ready, passing checks to merge, mr:force-merge and --ignore-checks

state
merged · f61e6a6 · fast-forward
branch
feat/mr-ready-merge-checks → main
opened
bnema ·
updated

Closes #22.

  • ermite mr ready <repo> <n> (MarkReady RPC on the existing use case): removes the WIP:/[WIP] prefix; prints the URL, --json.
  • Every merge needs passing checks on the head: none failed, queued or running; a head with no checks can merge. Auto-merge is unchanged.
  • New scope mr:force-merge: ermite mr merge --ignore-checks (not with --auto/--cancel-auto), and an "Ignore checks" box on the owner's merge form when checks block (refreshed live, owner streams only). cli preset has it, agent does not.
  • Storage schema 5 gives mr:force-merge to unrestricted full tokens so they keep the owner rights (login, session revocation).
  • failing_checks_block removed (setting, repo edit flag, storage field reserved).
  • Docs and skill updated.

Depends on ermite-api!10. Before merge: tag ermite-api v0.15.0 and replace the local replace in go.mod with it.

CommitSubjectAuthorDateSignatureStatus
f61e6a6fix(store): keep failing_checks_block as a deprecated storage field, run the new subtests in parallelbriceGPG Verified
923f868build(deps): use ermite-api v0.15.0briceGPG Verified
eba8ed7fix(scopes): keep the owner rights of full tokens with mr:force-merge, refresh the owner's merge check controls livebriceGPG Verified
7348bb2feat(web): offer ignore checks on the merge form, document mr ready and the merge check rulesbriceGPG Verified
5d0c190feat(cli): add mr ready and mr merge --ignore-checks, implement the MarkReady RPCbriceGPG Verified
cfc292cfeat(merge): require passing checks to merge, add mr:force-merge and ignore_checks, drop failing_checks_blockbriceGPG Verified

Checks 8

56 files · +645 -265
8/8
M docs/ci.md +1 -1
M docs/cli.md +4 -2
M docs/merge-requests.md +5 -3
M gen/ermite/storage/merge_request.pb.go +9 -5
M go.mod +1 -1
M go.sum +2 -2
M internal/adapters/client/features.go +4 -0
M internal/adapters/client/scopes.go +3 -0
M internal/adapters/client/scopes_test.go +1 -0
M internal/adapters/kvstore/issues_test.go +53 -6
M internal/adapters/kvstore/mergerequests.go +2 -2
M internal/adapters/kvstore/mergerequests_test.go +2 -2
M internal/adapters/kvstore/store.go +36 -8
M internal/adapters/rpc/api.go +1 -1
M internal/adapters/rpc/convert.go +2 -1
M internal/adapters/rpc/convert_test.go +9 -9
M internal/adapters/rpc/mergerequest.go +13 -1
M internal/adapters/rpc/mergerequest_test.go +31 -1
M internal/adapters/rpc/repo.go +2 -5
M internal/adapters/rpc/repo_test.go +1 -7
M internal/adapters/web/checks.go +1 -3
M internal/adapters/web/fragments.go +18 -1
M internal/adapters/web/mr.go +1 -1
M internal/adapters/web/mr.templ +13 -1
M internal/adapters/web/mr_post.go +1 -0
M internal/adapters/web/mr_templ.go +111 -53
M internal/adapters/web/mr_test.go +35 -1
M internal/adapters/web/static/js/ermite.js +7 -0
M internal/adapters/web/summary_actions_test.go +53 -0
M internal/app/api_test.go +1 -1
M internal/app/ci_e2e_test.go +3 -9
M internal/app/cli_e2e_test.go +2 -2
M internal/app/cli_issue_test.go +1 -1
M internal/app/cli_mr.go +45 -3
M internal/app/cli_mr_e2e_test.go +38 -14
M internal/app/cli_repo.go +17 -29
M internal/app/cli_repo_test.go +17 -27
M internal/app/cli_watch_test.go +1 -1
M internal/app/issue_api_test.go +0 -1
M internal/app/mr_api_test.go +9 -9
M internal/domain/ci.go +1 -1
M internal/domain/mergerequest.go +3 -5
M internal/domain/mergerequest_test.go +0 -1
M internal/domain/token.go +4 -2
M internal/domain/token_test.go +1 -1
M internal/ports/dto.go +2 -2
M internal/usecase/access.go +1 -1
M internal/usecase/automerge_test.go +1 -4
M internal/usecase/ci_unit_test.go +37 -6
M internal/usecase/mergerequests.go +12 -5
M internal/usecase/mergerequests_integration_test.go +5 -5
@@ -1205,19 +1205,19 @@ func TestUpdateRepoSettings(t *testing.T) {
12051205
12061206 modes := []domain.MergeMode{domain.MergeModeSquash, domain.MergeModeSquash}
12071207 off := false
1208- got, err := r.repos.Update(r.ctx, root, r.repo, ports.UpdateRepoParams{AllowedMergeModes: &modes, FailingChecksBlock: &off})
1208+ got, err := r.repos.Update(r.ctx, root, r.repo, ports.UpdateRepoParams{AllowedMergeModes: &modes, AllowAutoMerge: &off})
12091209 require.NoError(t, err)
1210- assert.Equal(t, domain.RepoSettings{AllowedMergeModes: []domain.MergeMode{domain.MergeModeSquash}, FailingChecksBlock: false, Issues: domain.IssuesOff}, got.Settings)
1210+ assert.Equal(t, domain.RepoSettings{AllowedMergeModes: []domain.MergeMode{domain.MergeModeSquash}, AllowAutoMerge: false, Issues: domain.IssuesOff}, got.Settings)
12111211 again, err := r.repos.Get(r.ctx, root, r.repo)
12121212 require.NoError(t, err)
12131213 assert.Equal(t, got.Settings, again.Settings)
12141214
12151215 // Only the named setting changes.
12161216 on := true
1217- got, err = r.repos.Update(r.ctx, root, r.repo, ports.UpdateRepoParams{FailingChecksBlock: &on})
1217+ got, err = r.repos.Update(r.ctx, root, r.repo, ports.UpdateRepoParams{AllowAutoMerge: &on})
12181218 require.NoError(t, err)
12191219 assert.Equal(t, []domain.MergeMode{domain.MergeModeSquash}, got.Settings.AllowedMergeModes)
1220- assert.True(t, got.Settings.FailingChecksBlock)
1220+ assert.True(t, got.Settings.AllowAutoMerge)
12211221
12221222 for _, bad := range [][]domain.MergeMode{{}, nil, {"rebase"}} {
12231223 bad := bad
@@ -1229,7 +1229,7 @@ func TestUpdateRepoSettings(t *testing.T) {
12291229 assert.Equal(t, got.Settings, again.Settings, "a refused change stores nothing")
12301230
12311231 // repo:admin is needed, like for any other field.
1232- _, err = r.repos.Update(r.ctx, writer(domain.ScopeRepoRead, domain.ScopeMRMerge), r.repo, ports.UpdateRepoParams{FailingChecksBlock: &off})
1232+ _, err = r.repos.Update(r.ctx, writer(domain.ScopeRepoRead, domain.ScopeMRMerge), r.repo, ports.UpdateRepoParams{AllowAutoMerge: &off})
12331233 assert.ErrorIs(t, err, domain.ErrForbidden)
12341234 }
12351235
@@ -1205,19 +1205,19 @@ func TestUpdateRepoSettings(t *testing.T) {
1205 1205
1206 modes := []domain.MergeMode{domain.MergeModeSquash, domain.MergeModeSquash}1206 modes := []domain.MergeMode{domain.MergeModeSquash, domain.MergeModeSquash}
1207 off := false1207 off := false
1208- got, err := r.repos.Update(r.ctx, root, r.repo, ports.UpdateRepoParams{AllowedMergeModes: &modes, FailingChecksBlock: &off})1208+ got, err := r.repos.Update(r.ctx, root, r.repo, ports.UpdateRepoParams{AllowedMergeModes: &modes, AllowAutoMerge: &off})
1209 require.NoError(t, err)1209 require.NoError(t, err)
1210- assert.Equal(t, domain.RepoSettings{AllowedMergeModes: []domain.MergeMode{domain.MergeModeSquash}, FailingChecksBlock: false, Issues: domain.IssuesOff}, got.Settings)1210+ assert.Equal(t, domain.RepoSettings{AllowedMergeModes: []domain.MergeMode{domain.MergeModeSquash}, AllowAutoMerge: false, Issues: domain.IssuesOff}, got.Settings)
1211 again, err := r.repos.Get(r.ctx, root, r.repo)1211 again, err := r.repos.Get(r.ctx, root, r.repo)
1212 require.NoError(t, err)1212 require.NoError(t, err)
1213 assert.Equal(t, got.Settings, again.Settings)1213 assert.Equal(t, got.Settings, again.Settings)
1214 1214
1215 // Only the named setting changes.1215 // Only the named setting changes.
1216 on := true1216 on := true
1217- got, err = r.repos.Update(r.ctx, root, r.repo, ports.UpdateRepoParams{FailingChecksBlock: &on})1217+ got, err = r.repos.Update(r.ctx, root, r.repo, ports.UpdateRepoParams{AllowAutoMerge: &on})
1218 require.NoError(t, err)1218 require.NoError(t, err)
1219 assert.Equal(t, []domain.MergeMode{domain.MergeModeSquash}, got.Settings.AllowedMergeModes)1219 assert.Equal(t, []domain.MergeMode{domain.MergeModeSquash}, got.Settings.AllowedMergeModes)
1220- assert.True(t, got.Settings.FailingChecksBlock)1220+ assert.True(t, got.Settings.AllowAutoMerge)
1221 1221
1222 for _, bad := range [][]domain.MergeMode{{}, nil, {"rebase"}} {1222 for _, bad := range [][]domain.MergeMode{{}, nil, {"rebase"}} {
1223 bad := bad1223 bad := bad
@@ -1229,7 +1229,7 @@ func TestUpdateRepoSettings(t *testing.T) {
1229 assert.Equal(t, got.Settings, again.Settings, "a refused change stores nothing")1229 assert.Equal(t, got.Settings, again.Settings, "a refused change stores nothing")
1230 1230
1231 // repo:admin is needed, like for any other field.1231 // repo:admin is needed, like for any other field.
1232- _, err = r.repos.Update(r.ctx, writer(domain.ScopeRepoRead, domain.ScopeMRMerge), r.repo, ports.UpdateRepoParams{FailingChecksBlock: &off})1232+ _, err = r.repos.Update(r.ctx, writer(domain.ScopeRepoRead, domain.ScopeMRMerge), r.repo, ports.UpdateRepoParams{AllowAutoMerge: &off})
1233 assert.ErrorIs(t, err, domain.ErrForbidden)1233 assert.ErrorIs(t, err, domain.ErrForbidden)
1234 }1234 }
1235 1235
M internal/usecase/repos_write.go +4 -7
@@ -62,7 +62,7 @@ func (r *Repos) Update(ctx context.Context, p domain.Principal, name domain.Repo
6262 }
6363 before := repo
6464 if params.Description == nil && params.Topics == nil && params.Visibility == nil && params.DefaultBranch == nil &&
65- params.AllowedMergeModes == nil && params.FailingChecksBlock == nil && params.AllowAutoMerge == nil && params.Issues == nil {
65+ params.AllowedMergeModes == nil && params.AllowAutoMerge == nil && params.Issues == nil {
6666 return domain.Repo{}, fmt.Errorf("%w: nothing to update", domain.ErrInvalid)
6767 }
6868 if params.AllowedMergeModes != nil {
@@ -116,12 +116,12 @@ func (r *Repos) Update(ctx context.Context, p domain.Principal, name domain.Repo
116116 // branch was checked above, so the remaining causes are a vanished
117117 // repository or an I/O failure.
118118 metaDone := false
119- if params.Description != nil || params.Topics != nil || params.Visibility != nil || params.AllowedMergeModes != nil || params.FailingChecksBlock != nil || params.AllowAutoMerge != nil || params.Issues != nil {
119+ if params.Description != nil || params.Topics != nil || params.Visibility != nil || params.AllowedMergeModes != nil || params.AllowAutoMerge != nil || params.Issues != nil {
120120 update := func() error {
121121 repo, err = r.updateMeta(ctx, name, params)
122122 return err
123123 }
124- if r.mergeGate != nil && (params.AllowedMergeModes != nil || params.FailingChecksBlock != nil || params.AllowAutoMerge != nil) {
124+ if r.mergeGate != nil && (params.AllowedMergeModes != nil || params.AllowAutoMerge != nil) {
125125 err = r.mergeGate.WithMergeLock(ctx, name, update)
126126 } else {
127127 err = update()
@@ -154,7 +154,7 @@ func (r *Repos) Update(ctx context.Context, p domain.Principal, name domain.Repo
154154 Bool("topics", params.Topics != nil).
155155 Bool("visibility", params.Visibility != nil).
156156 Bool("default_branch", params.DefaultBranch != nil).
157- Bool("settings", params.AllowedMergeModes != nil || params.FailingChecksBlock != nil || params.AllowAutoMerge != nil || params.Issues != nil).
157+ Bool("settings", params.AllowedMergeModes != nil || params.AllowAutoMerge != nil || params.Issues != nil).
158158 Str("principal", p.Label()).Str("token_id", p.TokenID()).
159159 Msg("repository updated")
160160 r.bus.Publish(ctx, domain.RepoUpdated{
@@ -184,9 +184,6 @@ func (r *Repos) updateMeta(ctx context.Context, name domain.RepoName, params por
184184 if params.AllowedMergeModes != nil {
185185 repo.Settings.AllowedMergeModes = *params.AllowedMergeModes
186186 }
187- if params.FailingChecksBlock != nil {
188- repo.Settings.FailingChecksBlock = *params.FailingChecksBlock
189- }
190187 if params.AllowAutoMerge != nil {
191188 repo.Settings.AllowAutoMerge = *params.AllowAutoMerge
192189 }
@@ -62,7 +62,7 @@ func (r *Repos) Update(ctx context.Context, p domain.Principal, name domain.Repo
62 }62 }
63 before := repo63 before := repo
64 if params.Description == nil && params.Topics == nil && params.Visibility == nil && params.DefaultBranch == nil &&64 if params.Description == nil && params.Topics == nil && params.Visibility == nil && params.DefaultBranch == nil &&
65- params.AllowedMergeModes == nil && params.FailingChecksBlock == nil && params.AllowAutoMerge == nil && params.Issues == nil {65+ params.AllowedMergeModes == nil && params.AllowAutoMerge == nil && params.Issues == nil {
66 return domain.Repo{}, fmt.Errorf("%w: nothing to update", domain.ErrInvalid)66 return domain.Repo{}, fmt.Errorf("%w: nothing to update", domain.ErrInvalid)
67 }67 }
68 if params.AllowedMergeModes != nil {68 if params.AllowedMergeModes != nil {
@@ -116,12 +116,12 @@ func (r *Repos) Update(ctx context.Context, p domain.Principal, name domain.Repo
116 // branch was checked above, so the remaining causes are a vanished116 // branch was checked above, so the remaining causes are a vanished
117 // repository or an I/O failure.117 // repository or an I/O failure.
118 metaDone := false118 metaDone := false
119- if params.Description != nil || params.Topics != nil || params.Visibility != nil || params.AllowedMergeModes != nil || params.FailingChecksBlock != nil || params.AllowAutoMerge != nil || params.Issues != nil {119+ if params.Description != nil || params.Topics != nil || params.Visibility != nil || params.AllowedMergeModes != nil || params.AllowAutoMerge != nil || params.Issues != nil {
120 update := func() error {120 update := func() error {
121 repo, err = r.updateMeta(ctx, name, params)121 repo, err = r.updateMeta(ctx, name, params)
122 return err122 return err
123 }123 }
124- if r.mergeGate != nil && (params.AllowedMergeModes != nil || params.FailingChecksBlock != nil || params.AllowAutoMerge != nil) {124+ if r.mergeGate != nil && (params.AllowedMergeModes != nil || params.AllowAutoMerge != nil) {
125 err = r.mergeGate.WithMergeLock(ctx, name, update)125 err = r.mergeGate.WithMergeLock(ctx, name, update)
126 } else {126 } else {
127 err = update()127 err = update()
@@ -154,7 +154,7 @@ func (r *Repos) Update(ctx context.Context, p domain.Principal, name domain.Repo
154 Bool("topics", params.Topics != nil).154 Bool("topics", params.Topics != nil).
155 Bool("visibility", params.Visibility != nil).155 Bool("visibility", params.Visibility != nil).
156 Bool("default_branch", params.DefaultBranch != nil).156 Bool("default_branch", params.DefaultBranch != nil).
157- Bool("settings", params.AllowedMergeModes != nil || params.FailingChecksBlock != nil || params.AllowAutoMerge != nil || params.Issues != nil).157+ Bool("settings", params.AllowedMergeModes != nil || params.AllowAutoMerge != nil || params.Issues != nil).
158 Str("principal", p.Label()).Str("token_id", p.TokenID()).158 Str("principal", p.Label()).Str("token_id", p.TokenID()).
159 Msg("repository updated")159 Msg("repository updated")
160 r.bus.Publish(ctx, domain.RepoUpdated{160 r.bus.Publish(ctx, domain.RepoUpdated{
@@ -184,9 +184,6 @@ func (r *Repos) updateMeta(ctx context.Context, name domain.RepoName, params por
184 if params.AllowedMergeModes != nil {184 if params.AllowedMergeModes != nil {
185 repo.Settings.AllowedMergeModes = *params.AllowedMergeModes185 repo.Settings.AllowedMergeModes = *params.AllowedMergeModes
186 }186 }
187- if params.FailingChecksBlock != nil {
188- repo.Settings.FailingChecksBlock = *params.FailingChecksBlock
189- }
190 if params.AllowAutoMerge != nil {187 if params.AllowAutoMerge != nil {
191 repo.Settings.AllowAutoMerge = *params.AllowAutoMerge188 repo.Settings.AllowAutoMerge = *params.AllowAutoMerge
192 }189 }
M internal/usecase/repos_write_test.go +1 -3
@@ -224,13 +224,11 @@ func TestUpdate(t *testing.T) {
224224 t.Run("a token limited to branches may not change any setting", func(t *testing.T) {
225225 t.Parallel()
226226 modes := []domain.MergeMode{domain.MergeModeSquash}
227- block := true
228227 for name, params := range map[string]ports.UpdateRepoParams{
229228 "default branch": {DefaultBranch: ptr("dev")},
230229 "visibility": {Visibility: ptr(domain.VisibilityPublic)},
231230 "description": {Description: ptr("x")},
232231 "merge modes": {AllowedMergeModes: &modes},
233- "checks rule": {FailingChecksBlock: &block},
234232 "issues": {Issues: ptr(domain.IssuesOwner)},
235233 } {
236234 f := newFixture(t) // no HasBranch, no Update, no SetDefaultBranch, no Publish
@@ -289,7 +287,7 @@ func TestUpdateIssuesMode(t *testing.T) {
289287 f := newFixture(t)
290288 f.meta.EXPECT().Get(ctx, n).Return(priv, nil).Once()
291289 expectRepoMutation(t, f.meta, priv, mock.MatchedBy(func(r domain.Repo) bool {
292- return r.Settings.Issues == domain.IssuesOwner && r.Settings.FailingChecksBlock
290+ return r.Settings.Issues == domain.IssuesOwner
293291 }))
294292 f.bus.EXPECT().Publish(ctx, mock.Anything).Return().Once()
295293 repo, err := f.repos.Update(ctx, domain.Root(), n, ports.UpdateRepoParams{Issues: ptr(domain.IssuesOwner)})
@@ -224,13 +224,11 @@ func TestUpdate(t *testing.T) {
224 t.Run("a token limited to branches may not change any setting", func(t *testing.T) {224 t.Run("a token limited to branches may not change any setting", func(t *testing.T) {
225 t.Parallel()225 t.Parallel()
226 modes := []domain.MergeMode{domain.MergeModeSquash}226 modes := []domain.MergeMode{domain.MergeModeSquash}
227- block := true
228 for name, params := range map[string]ports.UpdateRepoParams{227 for name, params := range map[string]ports.UpdateRepoParams{
229 "default branch": {DefaultBranch: ptr("dev")},228 "default branch": {DefaultBranch: ptr("dev")},
230 "visibility": {Visibility: ptr(domain.VisibilityPublic)},229 "visibility": {Visibility: ptr(domain.VisibilityPublic)},
231 "description": {Description: ptr("x")},230 "description": {Description: ptr("x")},
232 "merge modes": {AllowedMergeModes: &modes},231 "merge modes": {AllowedMergeModes: &modes},
233- "checks rule": {FailingChecksBlock: &block},
234 "issues": {Issues: ptr(domain.IssuesOwner)},232 "issues": {Issues: ptr(domain.IssuesOwner)},
235 } {233 } {
236 f := newFixture(t) // no HasBranch, no Update, no SetDefaultBranch, no Publish234 f := newFixture(t) // no HasBranch, no Update, no SetDefaultBranch, no Publish
@@ -289,7 +287,7 @@ func TestUpdateIssuesMode(t *testing.T) {
289 f := newFixture(t)287 f := newFixture(t)
290 f.meta.EXPECT().Get(ctx, n).Return(priv, nil).Once()288 f.meta.EXPECT().Get(ctx, n).Return(priv, nil).Once()
291 expectRepoMutation(t, f.meta, priv, mock.MatchedBy(func(r domain.Repo) bool {289 expectRepoMutation(t, f.meta, priv, mock.MatchedBy(func(r domain.Repo) bool {
292- return r.Settings.Issues == domain.IssuesOwner && r.Settings.FailingChecksBlock290+ return r.Settings.Issues == domain.IssuesOwner
293 }))291 }))
294 f.bus.EXPECT().Publish(ctx, mock.Anything).Return().Once()292 f.bus.EXPECT().Publish(ctx, mock.Anything).Return().Once()
295 repo, err := f.repos.Update(ctx, domain.Root(), n, ports.UpdateRepoParams{Issues: ptr(domain.IssuesOwner)})293 repo, err := f.repos.Update(ctx, domain.Root(), n, ports.UpdateRepoParams{Issues: ptr(domain.IssuesOwner)})
M proto/ermite/storage/merge_request.proto +2 -1
@@ -22,7 +22,8 @@ enum MergeMode {
2222 message RepoSettings {
2323 // Never empty once stored.
2424 repeated MergeMode allowed_merge_modes = 1;
25- bool failing_checks_block = 2;
25+ // Unused: never read, never written.
26+ bool failing_checks_block = 2 [deprecated = true];
2627 // Who may write issues; unspecified reads as off.
2728 IssuesMode issues = 3;
2829 // Set on every write since merge commits exist: the modes are the explicit
@@ -22,7 +22,8 @@ enum MergeMode {
22 message RepoSettings {22 message RepoSettings {
23 // Never empty once stored.23 // Never empty once stored.
24 repeated MergeMode allowed_merge_modes = 1;24 repeated MergeMode allowed_merge_modes = 1;
25- bool failing_checks_block = 2;25+ // Unused: never read, never written.
26+ bool failing_checks_block = 2 [deprecated = true];
26 // Who may write issues; unspecified reads as off.27 // Who may write issues; unspecified reads as off.
27 IssuesMode issues = 3;28 IssuesMode issues = 3;
28 // Set on every write since merge commits exist: the modes are the explicit29 // Set on every write since merge commits exist: the modes are the explicit
M skills/ermite/CLI.md +10 -4
@@ -11,10 +11,10 @@ Errors are one line on standard error (`ermite: ...`) with exit status 1. `--jso
1111 | `repo list [--json]` | none | repositories you can see |
1212 | `repo show <name> [--json]` | none | |
1313 | `repo create <name> [--description TEXT] [--visibility public]` | `repo:write` | private by default |
14-| `repo edit <name> [--description TEXT] [--visibility V] [--default-branch B] [--merge-modes fast-forward,squash,merge] [--failing-checks-block=false] [--allow-auto-merge]` | `repo:admin` | only the given flags change; every merge mode is allowed by default, list the ones to keep; `--description ""` clears it; the branch must exist |
14+| `repo edit <name> [--description TEXT] [--visibility V] [--default-branch B] [--merge-modes fast-forward,squash,merge] [--allow-auto-merge]` | `repo:admin` | only the given flags change; every merge mode is allowed by default, list the ones to keep; `--description ""` clears it; the branch must exist |
1515 | `repo delete <name> [--yes]` | `repo:admin` | |
1616
17-Repository JSON: `name`, `description`, `visibility`, `default_branch`, `created_at`, `pushed_at` (null before the first push), `merge_modes`, `failing_checks_block`, `allow_auto_merge`.
17+Repository JSON: `name`, `description`, `visibility`, `default_branch`, `created_at`, `pushed_at` (null before the first push), `merge_modes`, `allow_auto_merge`.
1818
1919 ## Tokens
2020
@@ -28,7 +28,7 @@ Presets add up with `--scope` (repeatable or comma-separated):
2828
2929 | Preset | Scopes |
3030 |---|---|
31-| `cli` | every scope (including `mr:review`), for the owner's daily use; a token created before `mr:review` existed lacks it and loses owner rights: recreate it with `--preset cli` using the root token or from a signed-in owner web session (an old token lacking `mr:review` cannot grant it), then replace the stored CLI secret and revoke the old token |
31+| `cli` | every scope, including `mr:review` and `mr:force-merge`, for the owner's daily use |
3232 | `agent` | `repo:write`, `mr:write` |
3333 | `ci` | `repo:read` |
3434
@@ -42,6 +42,7 @@ Scopes:
4242 | `mr:write` | open, comment on, reply, edit and delete own notes, resolve, close, mark read |
4343 | `mr:review` | give a verdict (`--approve`, `--request-changes`) |
4444 | `mr:merge` | merge, and delete the merged source branch with `--delete-branch` |
45+| `mr:force-merge` | bypass failing or unfinished checks with `--ignore-checks` |
4546 | `issue:write` | open, comment on, close and reopen issues |
4647 | `token:admin` | create, list and revoke tokens |
4748 | `ci:write`, `secrets:write`, `runner:register` | reserved for CI |
@@ -50,6 +51,8 @@ A token can never grant more than the token that creates it. `--repo` (repeatabl
5051
5152 `--branch-prefix` (repeatable, up to 8, each ending with `/`, for example `agent/claude/`) limits the branches the token may write; the default is all. A limited token pushes, force-pushes and deletes only `refs/heads/<prefix>…`: any other ref (`main`, tags, notes) refuses the whole push, naming the ref. It cannot create or delete a repository or change its settings (`repo edit`: default branch, visibility, description, merge modes), merges only into branches under its prefixes (and deletes only a source branch under them), never has owner rights, and creates only tokens within its own prefixes. A token for an agent: `ermite token create claude --preset agent --scope repo:read --repo <repo> --branch-prefix agent/claude/`. If you hold such a token, work on a branch under your prefix and open a merge request; do not try `main` or tags.
5253
54+The `agent` preset lacks `mr:force-merge`; adding `--scope mr:merge` lets an agent merge only with passing checks.
55+
5356 ## Merge requests
5457
5558 | Command | Scope | Notes |
@@ -61,10 +64,13 @@ A token can never grant more than the token that creates it. `--repo` (repeatabl
6164 | `mr edit-comment <repo> <n> <comment-id> -m TEXT\|-` | `mr:write` | author only, open merge request; the verdict is unchanged |
6265 | `mr delete-comment <repo> <n> <comment-id>` | `mr:write` | author only; the text is lost, the verdict is kept |
6366 | `mr resolve <repo> <n> <comment-id> [--undo]` | `mr:write` | on the thread root, covers its replies; doing it twice is not an error |
64-| `mr merge <repo> <n> --head <full-id> [--mode fast-forward\|squash\|merge] [--delete-branch]` | `mr:merge` | refused if the branch moved, if `fast-forward` and the target is not an ancestor of the head (behind: use `squash` or `merge`), or if `squash`/`merge` conflict; squash and merge commits are signed by the server key (`signing-key show`); answers once the target moved; `--delete-branch` deletes the source after the merge (refused for the default branch or a branch another open request uses; JSON adds `source_branch_deleted`) |
67+| `mr merge <repo> <n> --head <full-id> [--mode fast-forward\|squash\|merge] [--delete-branch] [--ignore-checks]` | `mr:merge` | refused if the branch moved, if `fast-forward` and the target is not an ancestor of the head (behind: use `squash` or `merge`), or if `squash`/`merge` conflict; squash and merge commits are signed by the server key (`signing-key show`); answers once the target moved; `--delete-branch` deletes the source after the merge (refused for the default branch or a branch another open request uses; JSON adds `source_branch_deleted`) |
6568 | `mr merge <repo> <n> --head <full-id> --auto [--mode M]` / `mr merge <repo> <n> --cancel-auto` | `mr:merge` | the server merges as you once CI settled on the head with a passed check and no failing one; needs `repo edit --allow-auto-merge`; failing checks or conflicts wait with auto-merge on; a new head cancels it, as do a deleted source, a disallowed mode, the setting turned off or your token losing merge rights; a manual merge supersedes it (`merge_request_merged`, `auto=false`, no cancelled event); a manual close clears it (`merge_request_closed` then cancelled with reason `closed`); JSON `auto_merge` (null unless pending), `auto_merged` |
69+| `mr ready <repo> <n>` | `mr:write` | removes the `WIP:` or `[WIP]` title prefix; needs an open draft |
6670 | `mr close <repo> <n>` | `mr:write` | a closed merge request cannot be reopened |
6771
72+A merge needs passing checks on the head: none failed, queued or running. A head with no checks can merge. `mr merge --ignore-checks` bypasses checks, needs `mr:force-merge`, and cannot be combined with `--auto` or `--cancel-auto`.
73+
6874 `ermite mr create` and `ermite mr close` print a confirmation followed by the full
6975 merge request web URL on its own line. With `--json`, merge request objects
7076 (create, show, list, close and merge) include `url` alongside the other fields.
@@ -11,10 +11,10 @@ Errors are one line on standard error (`ermite: ...`) with exit status 1. `--jso
11 | `repo list [--json]` | none | repositories you can see |11 | `repo list [--json]` | none | repositories you can see |
12 | `repo show <name> [--json]` | none | |12 | `repo show <name> [--json]` | none | |
13 | `repo create <name> [--description TEXT] [--visibility public]` | `repo:write` | private by default |13 | `repo create <name> [--description TEXT] [--visibility public]` | `repo:write` | private by default |
14-| `repo edit <name> [--description TEXT] [--visibility V] [--default-branch B] [--merge-modes fast-forward,squash,merge] [--failing-checks-block=false] [--allow-auto-merge]` | `repo:admin` | only the given flags change; every merge mode is allowed by default, list the ones to keep; `--description ""` clears it; the branch must exist |14+| `repo edit <name> [--description TEXT] [--visibility V] [--default-branch B] [--merge-modes fast-forward,squash,merge] [--allow-auto-merge]` | `repo:admin` | only the given flags change; every merge mode is allowed by default, list the ones to keep; `--description ""` clears it; the branch must exist |
15 | `repo delete <name> [--yes]` | `repo:admin` | |15 | `repo delete <name> [--yes]` | `repo:admin` | |
16 16
17-Repository JSON: `name`, `description`, `visibility`, `default_branch`, `created_at`, `pushed_at` (null before the first push), `merge_modes`, `failing_checks_block`, `allow_auto_merge`.17+Repository JSON: `name`, `description`, `visibility`, `default_branch`, `created_at`, `pushed_at` (null before the first push), `merge_modes`, `allow_auto_merge`.
18 18
19 ## Tokens19 ## Tokens
20 20
@@ -28,7 +28,7 @@ Presets add up with `--scope` (repeatable or comma-separated):
28 28
29 | Preset | Scopes |29 | Preset | Scopes |
30 |---|---|30 |---|---|
31-| `cli` | every scope (including `mr:review`), for the owner's daily use; a token created before `mr:review` existed lacks it and loses owner rights: recreate it with `--preset cli` using the root token or from a signed-in owner web session (an old token lacking `mr:review` cannot grant it), then replace the stored CLI secret and revoke the old token |31+| `cli` | every scope, including `mr:review` and `mr:force-merge`, for the owner's daily use |
32 | `agent` | `repo:write`, `mr:write` |32 | `agent` | `repo:write`, `mr:write` |
33 | `ci` | `repo:read` |33 | `ci` | `repo:read` |
34 34
@@ -42,6 +42,7 @@ Scopes:
42 | `mr:write` | open, comment on, reply, edit and delete own notes, resolve, close, mark read |42 | `mr:write` | open, comment on, reply, edit and delete own notes, resolve, close, mark read |
43 | `mr:review` | give a verdict (`--approve`, `--request-changes`) |43 | `mr:review` | give a verdict (`--approve`, `--request-changes`) |
44 | `mr:merge` | merge, and delete the merged source branch with `--delete-branch` |44 | `mr:merge` | merge, and delete the merged source branch with `--delete-branch` |
45+| `mr:force-merge` | bypass failing or unfinished checks with `--ignore-checks` |
45 | `issue:write` | open, comment on, close and reopen issues |46 | `issue:write` | open, comment on, close and reopen issues |
46 | `token:admin` | create, list and revoke tokens |47 | `token:admin` | create, list and revoke tokens |
47 | `ci:write`, `secrets:write`, `runner:register` | reserved for CI |48 | `ci:write`, `secrets:write`, `runner:register` | reserved for CI |
@@ -50,6 +51,8 @@ A token can never grant more than the token that creates it. `--repo` (repeatabl
50 51
51 `--branch-prefix` (repeatable, up to 8, each ending with `/`, for example `agent/claude/`) limits the branches the token may write; the default is all. A limited token pushes, force-pushes and deletes only `refs/heads/<prefix>…`: any other ref (`main`, tags, notes) refuses the whole push, naming the ref. It cannot create or delete a repository or change its settings (`repo edit`: default branch, visibility, description, merge modes), merges only into branches under its prefixes (and deletes only a source branch under them), never has owner rights, and creates only tokens within its own prefixes. A token for an agent: `ermite token create claude --preset agent --scope repo:read --repo <repo> --branch-prefix agent/claude/`. If you hold such a token, work on a branch under your prefix and open a merge request; do not try `main` or tags.52 `--branch-prefix` (repeatable, up to 8, each ending with `/`, for example `agent/claude/`) limits the branches the token may write; the default is all. A limited token pushes, force-pushes and deletes only `refs/heads/<prefix>…`: any other ref (`main`, tags, notes) refuses the whole push, naming the ref. It cannot create or delete a repository or change its settings (`repo edit`: default branch, visibility, description, merge modes), merges only into branches under its prefixes (and deletes only a source branch under them), never has owner rights, and creates only tokens within its own prefixes. A token for an agent: `ermite token create claude --preset agent --scope repo:read --repo <repo> --branch-prefix agent/claude/`. If you hold such a token, work on a branch under your prefix and open a merge request; do not try `main` or tags.
52 53
54+The `agent` preset lacks `mr:force-merge`; adding `--scope mr:merge` lets an agent merge only with passing checks.
55+
53 ## Merge requests56 ## Merge requests
54 57
55 | Command | Scope | Notes |58 | Command | Scope | Notes |
@@ -61,10 +64,13 @@ A token can never grant more than the token that creates it. `--repo` (repeatabl
61 | `mr edit-comment <repo> <n> <comment-id> -m TEXT\|-` | `mr:write` | author only, open merge request; the verdict is unchanged |64 | `mr edit-comment <repo> <n> <comment-id> -m TEXT\|-` | `mr:write` | author only, open merge request; the verdict is unchanged |
62 | `mr delete-comment <repo> <n> <comment-id>` | `mr:write` | author only; the text is lost, the verdict is kept |65 | `mr delete-comment <repo> <n> <comment-id>` | `mr:write` | author only; the text is lost, the verdict is kept |
63 | `mr resolve <repo> <n> <comment-id> [--undo]` | `mr:write` | on the thread root, covers its replies; doing it twice is not an error |66 | `mr resolve <repo> <n> <comment-id> [--undo]` | `mr:write` | on the thread root, covers its replies; doing it twice is not an error |
64-| `mr merge <repo> <n> --head <full-id> [--mode fast-forward\|squash\|merge] [--delete-branch]` | `mr:merge` | refused if the branch moved, if `fast-forward` and the target is not an ancestor of the head (behind: use `squash` or `merge`), or if `squash`/`merge` conflict; squash and merge commits are signed by the server key (`signing-key show`); answers once the target moved; `--delete-branch` deletes the source after the merge (refused for the default branch or a branch another open request uses; JSON adds `source_branch_deleted`) |67+| `mr merge <repo> <n> --head <full-id> [--mode fast-forward\|squash\|merge] [--delete-branch] [--ignore-checks]` | `mr:merge` | refused if the branch moved, if `fast-forward` and the target is not an ancestor of the head (behind: use `squash` or `merge`), or if `squash`/`merge` conflict; squash and merge commits are signed by the server key (`signing-key show`); answers once the target moved; `--delete-branch` deletes the source after the merge (refused for the default branch or a branch another open request uses; JSON adds `source_branch_deleted`) |
65 | `mr merge <repo> <n> --head <full-id> --auto [--mode M]` / `mr merge <repo> <n> --cancel-auto` | `mr:merge` | the server merges as you once CI settled on the head with a passed check and no failing one; needs `repo edit --allow-auto-merge`; failing checks or conflicts wait with auto-merge on; a new head cancels it, as do a deleted source, a disallowed mode, the setting turned off or your token losing merge rights; a manual merge supersedes it (`merge_request_merged`, `auto=false`, no cancelled event); a manual close clears it (`merge_request_closed` then cancelled with reason `closed`); JSON `auto_merge` (null unless pending), `auto_merged` |68 | `mr merge <repo> <n> --head <full-id> --auto [--mode M]` / `mr merge <repo> <n> --cancel-auto` | `mr:merge` | the server merges as you once CI settled on the head with a passed check and no failing one; needs `repo edit --allow-auto-merge`; failing checks or conflicts wait with auto-merge on; a new head cancels it, as do a deleted source, a disallowed mode, the setting turned off or your token losing merge rights; a manual merge supersedes it (`merge_request_merged`, `auto=false`, no cancelled event); a manual close clears it (`merge_request_closed` then cancelled with reason `closed`); JSON `auto_merge` (null unless pending), `auto_merged` |
69+| `mr ready <repo> <n>` | `mr:write` | removes the `WIP:` or `[WIP]` title prefix; needs an open draft |
66 | `mr close <repo> <n>` | `mr:write` | a closed merge request cannot be reopened |70 | `mr close <repo> <n>` | `mr:write` | a closed merge request cannot be reopened |
67 71
72+A merge needs passing checks on the head: none failed, queued or running. A head with no checks can merge. `mr merge --ignore-checks` bypasses checks, needs `mr:force-merge`, and cannot be combined with `--auto` or `--cancel-auto`.
73+
68 `ermite mr create` and `ermite mr close` print a confirmation followed by the full74 `ermite mr create` and `ermite mr close` print a confirmation followed by the full
69 merge request web URL on its own line. With `--json`, merge request objects75 merge request web URL on its own line. With `--json`, merge request objects
70 (create, show, list, close and merge) include `url` alongside the other fields.76 (create, show, list, close and merge) include `url` alongside the other fields.
M skills/ermite/SKILL.md +5 -3
@@ -37,9 +37,11 @@ git push https://git.example.com/<repo>.git main # password = token,
3737 2. Read: `ermite mr show <repo> <n> --diff --json`. Keep `head_id`; take line numbers from the hunks.
3838 3. Comment on a line: `ermite mr comment <repo> <n> -m "text" --path <file> --line <n> --side new`; `--line 10-14` covers a range on one side. Without `--path` the comment is general. The line must appear in the diff. Notes are Markdown; `^N` refers to note N.
3939 4. End a review with a verdict (needs `mr:review`): `--approve` or `--request-changes`, alone or with a message. Verdicts are informative and cannot be edited; a new verdict replaces the previous one.
40-5. Merge only when asked: `ermite mr merge <repo> <n> --head <head_id>`, with `--mode squash` for one commit or `--mode merge` for a merge commit (both signed by the server's key, see `ermite signing-key show`) and `--delete-branch` to delete the source branch afterwards.
40+5. Merge only when the human asks explicitly. If the request is a draft, run `ermite mr ready <repo> <n>` first (removes the `WIP:` or `[WIP]` prefix; needs `mr:write`). Merge: `ermite mr merge <repo> <n> --head <head_id>`, with `--mode squash` for one commit or `--mode merge` for a merge commit (both signed by the server's key, see `ermite signing-key show`) and `--delete-branch` to delete the source branch afterwards.
4141
42-To merge once CI passed instead of now, add `--auto` (`ermite mr merge <repo> <n> --head <head_id> --auto --mode squash`; needs `repo edit --allow-auto-merge`); `--cancel-auto` cancels it.
42+To merge once CI passed automatically, add `--auto` (`ermite mr merge <repo> <n> --head <head_id> --auto --mode squash`; needs `repo edit --allow-auto-merge`); `--cancel-auto` cancels it.
43+
44+A merge needs passing checks on the head (none failed, queued or running); a head with no checks can merge. Never use `--ignore-checks` unless the human asks for it. It needs `mr:force-merge` and cannot be combined with `--auto` or `--cancel-auto`.
4345
4446 The merge is refused when the branch moved since `head_id` (review again) or, for `fast-forward`, when the target is not an ancestor of the head (rebase and push again, or use `squash`/`merge`), or when `squash`/`merge` conflict. `ermite mr show` prints the `mergeability` line (up to date, behind by N, conflict with paths). The repository may allow only some merge modes.
4547
@@ -63,7 +65,7 @@ Open one: `ermite mr create <repo> --source <branch> --title "..."`. After new p
6365 ermite token create my-agent --preset agent --repo <repo> --expires 30d
6466 ```
6567
66-The secret is printed once. Presets: `agent` (push, open and comment on merge requests; no verdicts), `ci` (read), `cli` (everything). Add `--scope mr:review` to let an agent give verdicts, `--scope mr:merge` to let it merge. A token can never grant more than the token that creates it. Full scope list: [CLI.md](CLI.md).
68+The secret is printed once. Presets: `agent` (push, open and comment on merge requests; no verdicts), `ci` (read), `cli` (every scope, including `mr:force-merge`). Add `--scope mr:review` to let an agent give verdicts, `--scope mr:merge` to let it merge only with passing checks. The `agent` preset does not include `mr:force-merge`. A token can never grant more than the token that creates it. Full scope list: [CLI.md](CLI.md).
6769
6870 ## Follow events
6971
@@ -37,9 +37,11 @@ git push https://git.example.com/<repo>.git main # password = token,
37 2. Read: `ermite mr show <repo> <n> --diff --json`. Keep `head_id`; take line numbers from the hunks.37 2. Read: `ermite mr show <repo> <n> --diff --json`. Keep `head_id`; take line numbers from the hunks.
38 3. Comment on a line: `ermite mr comment <repo> <n> -m "text" --path <file> --line <n> --side new`; `--line 10-14` covers a range on one side. Without `--path` the comment is general. The line must appear in the diff. Notes are Markdown; `^N` refers to note N.38 3. Comment on a line: `ermite mr comment <repo> <n> -m "text" --path <file> --line <n> --side new`; `--line 10-14` covers a range on one side. Without `--path` the comment is general. The line must appear in the diff. Notes are Markdown; `^N` refers to note N.
39 4. End a review with a verdict (needs `mr:review`): `--approve` or `--request-changes`, alone or with a message. Verdicts are informative and cannot be edited; a new verdict replaces the previous one.39 4. End a review with a verdict (needs `mr:review`): `--approve` or `--request-changes`, alone or with a message. Verdicts are informative and cannot be edited; a new verdict replaces the previous one.
40-5. Merge only when asked: `ermite mr merge <repo> <n> --head <head_id>`, with `--mode squash` for one commit or `--mode merge` for a merge commit (both signed by the server's key, see `ermite signing-key show`) and `--delete-branch` to delete the source branch afterwards.40+5. Merge only when the human asks explicitly. If the request is a draft, run `ermite mr ready <repo> <n>` first (removes the `WIP:` or `[WIP]` prefix; needs `mr:write`). Merge: `ermite mr merge <repo> <n> --head <head_id>`, with `--mode squash` for one commit or `--mode merge` for a merge commit (both signed by the server's key, see `ermite signing-key show`) and `--delete-branch` to delete the source branch afterwards.
41 41
42-To merge once CI passed instead of now, add `--auto` (`ermite mr merge <repo> <n> --head <head_id> --auto --mode squash`; needs `repo edit --allow-auto-merge`); `--cancel-auto` cancels it.42+To merge once CI passed automatically, add `--auto` (`ermite mr merge <repo> <n> --head <head_id> --auto --mode squash`; needs `repo edit --allow-auto-merge`); `--cancel-auto` cancels it.
43+
44+A merge needs passing checks on the head (none failed, queued or running); a head with no checks can merge. Never use `--ignore-checks` unless the human asks for it. It needs `mr:force-merge` and cannot be combined with `--auto` or `--cancel-auto`.
43 45
44 The merge is refused when the branch moved since `head_id` (review again) or, for `fast-forward`, when the target is not an ancestor of the head (rebase and push again, or use `squash`/`merge`), or when `squash`/`merge` conflict. `ermite mr show` prints the `mergeability` line (up to date, behind by N, conflict with paths). The repository may allow only some merge modes.46 The merge is refused when the branch moved since `head_id` (review again) or, for `fast-forward`, when the target is not an ancestor of the head (rebase and push again, or use `squash`/`merge`), or when `squash`/`merge` conflict. `ermite mr show` prints the `mergeability` line (up to date, behind by N, conflict with paths). The repository may allow only some merge modes.
45 47
@@ -63,7 +65,7 @@ Open one: `ermite mr create <repo> --source <branch> --title "..."`. After new p
63 ermite token create my-agent --preset agent --repo <repo> --expires 30d65 ermite token create my-agent --preset agent --repo <repo> --expires 30d
64 ```66 ```
65 67
66-The secret is printed once. Presets: `agent` (push, open and comment on merge requests; no verdicts), `ci` (read), `cli` (everything). Add `--scope mr:review` to let an agent give verdicts, `--scope mr:merge` to let it merge. A token can never grant more than the token that creates it. Full scope list: [CLI.md](CLI.md).68+The secret is printed once. Presets: `agent` (push, open and comment on merge requests; no verdicts), `ci` (read), `cli` (every scope, including `mr:force-merge`). Add `--scope mr:review` to let an agent give verdicts, `--scope mr:merge` to let it merge only with passing checks. The `agent` preset does not include `mr:force-merge`. A token can never grant more than the token that creates it. Full scope list: [CLI.md](CLI.md).
67 69
68 ## Follow events70 ## Follow events
69 71

Comments2 open / 2

bnema outdated

CI fails on purpose for now: go.mod has a local replace for ermite-api (ermite-api!10). Once ermite-api!10 is merged and tagged v0.15.0, I replace it with the tag and CI should go green. ermite-api keeps failing_checks_block as a deprecated, ignored field (buf breaking).

bnema

go.mod now uses ermite-api v0.15.0 (no local replace). The storage field failing_checks_block is kept as deprecated (buf breaking). CI green (run 159).

Keyboard shortcuts

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

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

Branches 2

Tags 44

↑↓ moveenter openesc close