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
@@ -44,7 +44,7 @@ Like `gh workflow disable`: a disabled workflow starts no run for any event (`pu
4444 - A disabled **reusable** workflow (`on: workflow_call`) can still be called with `uses:` from an enabled workflow: it then runs inside the caller's run, not as a run of its own, as on GitHub. Disable the caller to stop it.
4545 - The state is checked again in the transaction that stores a run, so a disable takes effect for every run not yet stored, even one being planned at that moment.
4646
47-The **Actions** tab of a repository shows the same. With `failing_checks_block` on (default), a merge waits for every check to pass. Turn it off with `ermite repo edit <repo> --failing-checks-block=false`.
47+The **Actions** tab of a repository shows the same. A merge needs passing checks on the head: none failed, queued or running. A head with no checks can merge. `ermite mr merge <repo> <n> --head <id> --ignore-checks` bypasses checks, needs `mr:force-merge`, and cannot be combined with `--auto`.
4848
4949 ## Trust
5050
@@ -44,7 +44,7 @@ Like `gh workflow disable`: a disabled workflow starts no run for any event (`pu
44 - A disabled **reusable** workflow (`on: workflow_call`) can still be called with `uses:` from an enabled workflow: it then runs inside the caller's run, not as a run of its own, as on GitHub. Disable the caller to stop it.44 - A disabled **reusable** workflow (`on: workflow_call`) can still be called with `uses:` from an enabled workflow: it then runs inside the caller's run, not as a run of its own, as on GitHub. Disable the caller to stop it.
45 - The state is checked again in the transaction that stores a run, so a disable takes effect for every run not yet stored, even one being planned at that moment.45 - The state is checked again in the transaction that stores a run, so a disable takes effect for every run not yet stored, even one being planned at that moment.
46 46
47-The **Actions** tab of a repository shows the same. With `failing_checks_block` on (default), a merge waits for every check to pass. Turn it off with `ermite repo edit <repo> --failing-checks-block=false`.47+The **Actions** tab of a repository shows the same. A merge needs passing checks on the head: none failed, queued or running. A head with no checks can merge. `ermite mr merge <repo> <n> --head <id> --ignore-checks` bypasses checks, needs `mr:force-merge`, and cannot be combined with `--auto`.
48 48
49 ## Trust49 ## Trust
50 50
M docs/cli.md +4 -2
@@ -48,7 +48,7 @@ It does not run:
4848
4949 ```sh
5050 ermite repo list | show <name> | create <name> [--description …] [--visibility public]
51-ermite repo edit <name> [--description …] [--visibility …] [--default-branch …] [--merge-modes …] [--failing-checks-block=false] [--allow-auto-merge] [--issues off|owner|public]
51+ermite repo edit <name> [--description …] [--visibility …] [--default-branch …] [--merge-modes …] [--allow-auto-merge] [--issues off|owner|public]
5252 ermite repo delete <name> [--yes]
5353
5454 ermite token create <label> [--preset cli|agent|ci|runner] [--scope …] [--repo …] [--branch-prefix …] [--expires 30d]
@@ -57,7 +57,8 @@ ermite token list | revoke <id>
5757 ermite mr create <repo> --source <branch> --title … | list <repo> | show <repo> <n> [--diff] # show prints the mergeability: up to date, behind, conflict
5858 ermite mr comment <repo> <n> -m … [--path … --line N|A-B --side old|new] [--reply <id>] [--approve|--request-changes]
5959 ermite mr resolve <repo> <n> <comment> | edit-comment <repo> <n> <comment> -m … | delete-comment <repo> <n> <comment>
60-ermite mr merge <repo> <n> --head <id> [--mode fast-forward|squash|merge] [--delete-branch] | merge <repo> <n> --head <id> --auto [--mode …] | merge <repo> <n> --cancel-auto | close <repo> <n>
60+ermite mr ready <repo> <n> # removes WIP:/[WIP], needs mr:write
61+ermite mr merge <repo> <n> --head <id> [--mode fast-forward|squash|merge] [--delete-branch] [--ignore-checks] | merge <repo> <n> --head <id> --auto [--mode …] | merge <repo> <n> --cancel-auto | close <repo> <n>
6162
6263 ermite issue create <repo> --title … [--body … | --body-file …] | list <repo> [--state open|closed|all]
6364 ermite issue show <repo> <n> | comment <repo> <n> -m … | close <repo> <n> | reopen <repo> <n>
@@ -99,6 +100,7 @@ Mirrors push repositories to a remote over SSH, never forcing. See [Push mirror]
99100
100101 - `--json` prints stable field names (listed in each `--help`) on `repo list|show|create|edit`, `issue create|list|show|comment|close|reopen`, `token create|list`, `run list|view|cancel|rerun`, `runner list`, `mirror key|config|status` and `version`.
101102 - `watch --json` prints one object per line. `merge_request_mergeability` events carry `mergeability` (`state`, `behind`, `conflict_paths`…) and print as `repo!n mergeability: behind by 2`.
103+- A merge needs passing checks on the head (no failed, queued or running checks); a head with no checks can merge. `mr merge --ignore-checks` bypasses this, needs `mr:force-merge`, and cannot be combined with `--auto` or `--cancel-auto`. The `cli` preset includes `mr:force-merge`; `agent` does not. Adding `--scope mr:merge` lets an agent merge only with passing checks.
102104 - `mr merge --mode squash|merge` creates a commit signed by the server's own key (generated on first start); the output names its fingerprint, `ermite signing-key show` prints the key. Every mode is allowed by default; `repo edit --merge-modes fast-forward,squash` disables `merge`.
103105 - `mr merge --auto` makes the server merge once CI passed on the head (`repo edit --allow-auto-merge` first); `--cancel-auto` cancels it; both are exclusive with each other and `--auto` with `--delete-branch`. `watch --json` adds `merge_request_auto_merge_enabled`, `merge_request_auto_merge_cancelled` (with `reason`), and `auto` (true) on `merge_request_merged` after an auto-merge. See [Merge requests](merge-requests.md#auto-merge).
104106 - Errors are one line on stderr (`ermite: …`) with exit status 1.
@@ -48,7 +48,7 @@ It does not run:
48 48
49 ```sh49 ```sh
50 ermite repo list | show <name> | create <name> [--description …] [--visibility public]50 ermite repo list | show <name> | create <name> [--description …] [--visibility public]
51-ermite repo edit <name> [--description …] [--visibility …] [--default-branch …] [--merge-modes …] [--failing-checks-block=false] [--allow-auto-merge] [--issues off|owner|public]51+ermite repo edit <name> [--description …] [--visibility …] [--default-branch …] [--merge-modes …] [--allow-auto-merge] [--issues off|owner|public]
52 ermite repo delete <name> [--yes]52 ermite repo delete <name> [--yes]
53 53
54 ermite token create <label> [--preset cli|agent|ci|runner] [--scope …] [--repo …] [--branch-prefix …] [--expires 30d]54 ermite token create <label> [--preset cli|agent|ci|runner] [--scope …] [--repo …] [--branch-prefix …] [--expires 30d]
@@ -57,7 +57,8 @@ ermite token list | revoke <id>
57 ermite mr create <repo> --source <branch> --title … | list <repo> | show <repo> <n> [--diff] # show prints the mergeability: up to date, behind, conflict57 ermite mr create <repo> --source <branch> --title … | list <repo> | show <repo> <n> [--diff] # show prints the mergeability: up to date, behind, conflict
58 ermite mr comment <repo> <n> -m … [--path … --line N|A-B --side old|new] [--reply <id>] [--approve|--request-changes]58 ermite mr comment <repo> <n> -m … [--path … --line N|A-B --side old|new] [--reply <id>] [--approve|--request-changes]
59 ermite mr resolve <repo> <n> <comment> | edit-comment <repo> <n> <comment> -m … | delete-comment <repo> <n> <comment>59 ermite mr resolve <repo> <n> <comment> | edit-comment <repo> <n> <comment> -m … | delete-comment <repo> <n> <comment>
60-ermite mr merge <repo> <n> --head <id> [--mode fast-forward|squash|merge] [--delete-branch] | merge <repo> <n> --head <id> --auto [--mode …] | merge <repo> <n> --cancel-auto | close <repo> <n>60+ermite mr ready <repo> <n> # removes WIP:/[WIP], needs mr:write
61+ermite mr merge <repo> <n> --head <id> [--mode fast-forward|squash|merge] [--delete-branch] [--ignore-checks] | merge <repo> <n> --head <id> --auto [--mode …] | merge <repo> <n> --cancel-auto | close <repo> <n>
61 62
62 ermite issue create <repo> --title … [--body … | --body-file …] | list <repo> [--state open|closed|all]63 ermite issue create <repo> --title … [--body … | --body-file …] | list <repo> [--state open|closed|all]
63 ermite issue show <repo> <n> | comment <repo> <n> -m … | close <repo> <n> | reopen <repo> <n>64 ermite issue show <repo> <n> | comment <repo> <n> -m … | close <repo> <n> | reopen <repo> <n>
@@ -99,6 +100,7 @@ Mirrors push repositories to a remote over SSH, never forcing. See [Push mirror]
99 100
100 - `--json` prints stable field names (listed in each `--help`) on `repo list|show|create|edit`, `issue create|list|show|comment|close|reopen`, `token create|list`, `run list|view|cancel|rerun`, `runner list`, `mirror key|config|status` and `version`.101 - `--json` prints stable field names (listed in each `--help`) on `repo list|show|create|edit`, `issue create|list|show|comment|close|reopen`, `token create|list`, `run list|view|cancel|rerun`, `runner list`, `mirror key|config|status` and `version`.
101 - `watch --json` prints one object per line. `merge_request_mergeability` events carry `mergeability` (`state`, `behind`, `conflict_paths`…) and print as `repo!n mergeability: behind by 2`.102 - `watch --json` prints one object per line. `merge_request_mergeability` events carry `mergeability` (`state`, `behind`, `conflict_paths`…) and print as `repo!n mergeability: behind by 2`.
103+- A merge needs passing checks on the head (no failed, queued or running checks); a head with no checks can merge. `mr merge --ignore-checks` bypasses this, needs `mr:force-merge`, and cannot be combined with `--auto` or `--cancel-auto`. The `cli` preset includes `mr:force-merge`; `agent` does not. Adding `--scope mr:merge` lets an agent merge only with passing checks.
102 - `mr merge --mode squash|merge` creates a commit signed by the server's own key (generated on first start); the output names its fingerprint, `ermite signing-key show` prints the key. Every mode is allowed by default; `repo edit --merge-modes fast-forward,squash` disables `merge`.104 - `mr merge --mode squash|merge` creates a commit signed by the server's own key (generated on first start); the output names its fingerprint, `ermite signing-key show` prints the key. Every mode is allowed by default; `repo edit --merge-modes fast-forward,squash` disables `merge`.
103 - `mr merge --auto` makes the server merge once CI passed on the head (`repo edit --allow-auto-merge` first); `--cancel-auto` cancels it; both are exclusive with each other and `--auto` with `--delete-branch`. `watch --json` adds `merge_request_auto_merge_enabled`, `merge_request_auto_merge_cancelled` (with `reason`), and `auto` (true) on `merge_request_merged` after an auto-merge. See [Merge requests](merge-requests.md#auto-merge).105 - `mr merge --auto` makes the server merge once CI passed on the head (`repo edit --allow-auto-merge` first); `--cancel-auto` cancels it; both are exclusive with each other and `--auto` with `--delete-branch`. `watch --json` adds `merge_request_auto_merge_enabled`, `merge_request_auto_merge_cancelled` (with `reason`), and `auto` (true) on `merge_request_merged` after an auto-merge. See [Merge requests](merge-requests.md#auto-merge).
104 - Errors are one line on stderr (`ermite: …`) with exit status 1.106 - Errors are one line on stderr (`ermite: …`) with exit status 1.
M docs/merge-requests.md +5 -3
@@ -14,6 +14,8 @@ Naming an issue with `#N` links it, and `Fixes #N` closes it on merge: see [issu
1414 merge request web URL on its own line. With `--json`, merge request objects
1515 (create, show, list, close and merge) include `url` alongside the other fields.
1616
17+A draft cannot merge. `ermite mr ready <repo> <n>` removes the `WIP:` or `[WIP]` title prefix; it needs `mr:write` and an open draft.
18+
1719 ## Merge modes
1820
1921 | Mode | Result | Needs |
@@ -25,10 +27,10 @@ merge request web URL on its own line. With `--json`, merge request objects
2527 - The server never rebases. A branch behind its target merges with `squash` or `merge`, or is rebased and pushed again for a fast-forward.
2628 - The server checks the mode again when it merges, whatever the badge showed, and refuses with the reason (the target moved, a conflict with the paths).
2729 - The merge is also refused when the branch moved since the reviewed `--head`.
28-- With `failing_checks_block` on (default), the merge is refused while a check of the head failed or is still running.
30+- A merge needs passing checks on the head: no failed, queued or running checks. A head with no checks can merge. `--ignore-checks` bypasses this, needs `mr:force-merge`, and cannot be combined with `--auto` or `--cancel-auto`. The owner web merge form offers **Ignore checks** when checks fail or are pending.
2931 - The merge answers as soon as the target has moved. `--delete-branch` then deletes the source branch right after, in the background (unless it is the default branch or another open request uses it); if the branch moved meanwhile, it is kept.
3032 - Every mode is allowed by default, including in repositories that had only `fast-forward` and `squash` before `merge` existed: those gain `merge`. Disable a mode per repository with `ermite repo edit <repo> --merge-modes fast-forward,squash` (list the modes to keep).
31-- Scopes: `mr:write` to open and comment, `mr:review` to give a verdict, `mr:merge` to merge.
33+- Scopes: `mr:write` to open and comment, `mr:review` to give a verdict, `mr:merge` to merge, `mr:force-merge` to bypass checks. The `cli` preset includes the bypass scope; `agent` does not. Adding `--scope mr:merge` lets an agent merge only with passing checks.
3234
3335 ## Signed commits
3436
@@ -47,7 +49,7 @@ A merge request can merge by itself once its checks passed.
4749
4850 - The repository setting `allow_auto_merge` is off by default: `ermite repo edit <repo> --allow-auto-merge` (`=false` to turn it off).
4951 - Enable it with `ermite mr merge <repo> <n> --head <commit> --auto --mode squash` (`--head` is required, as for a plain merge; a head that moved is refused with "branch moved"), or with **Merge when ready** in the web merge box. Its separate mode choice offers every repository-allowed mode, even while the head conflicts or is behind. Auto-merge keeps the source branch; the delete-source checkbox applies only to an immediate merge. It needs `mr:merge` and a server with CI. Re-enabling replaces the previous mode and enabler.
50-- The server merges when CI has settled on the head with every run complete and at least one check passed. A failing or unfinished check always blocks, whatever `failing_checks_block` says. The mode must still be allowed.
52+- The server merges when CI has settled on the head with every run complete and at least one check passed. A failing or unfinished check always blocks. The mode must still be allowed.
5153 - The merge is made as the person who enabled it. Their token is checked at every evaluation, even while checks are pending, and again at merge time under the merge lock: it must still be valid and allow `mr:merge` on the repository and the target branch. A token that no longer qualifies cancels the auto-merge.
5254 - A pending auto-merge shows in `mr show` (`auto_merge`), in the web merge box and as `merge_request_auto_merge_enabled` in `ermite watch`. A merge request merged this way has `auto_merged` true.
5355 - A failing, unfinished or conflicting head waits with auto-merge still on. A re-run or a target fix can make it pass; pushing a new source head cancels the recorded intent (the reason names the new head).
@@ -14,6 +14,8 @@ Naming an issue with `#N` links it, and `Fixes #N` closes it on merge: see [issu
14 merge request web URL on its own line. With `--json`, merge request objects14 merge request web URL on its own line. With `--json`, merge request objects
15 (create, show, list, close and merge) include `url` alongside the other fields.15 (create, show, list, close and merge) include `url` alongside the other fields.
16 16
17+A draft cannot merge. `ermite mr ready <repo> <n>` removes the `WIP:` or `[WIP]` title prefix; it needs `mr:write` and an open draft.
18+
17 ## Merge modes19 ## Merge modes
18 20
19 | Mode | Result | Needs |21 | Mode | Result | Needs |
@@ -25,10 +27,10 @@ merge request web URL on its own line. With `--json`, merge request objects
25 - The server never rebases. A branch behind its target merges with `squash` or `merge`, or is rebased and pushed again for a fast-forward.27 - The server never rebases. A branch behind its target merges with `squash` or `merge`, or is rebased and pushed again for a fast-forward.
26 - The server checks the mode again when it merges, whatever the badge showed, and refuses with the reason (the target moved, a conflict with the paths).28 - The server checks the mode again when it merges, whatever the badge showed, and refuses with the reason (the target moved, a conflict with the paths).
27 - The merge is also refused when the branch moved since the reviewed `--head`.29 - The merge is also refused when the branch moved since the reviewed `--head`.
28-- With `failing_checks_block` on (default), the merge is refused while a check of the head failed or is still running.30+- A merge needs passing checks on the head: no failed, queued or running checks. A head with no checks can merge. `--ignore-checks` bypasses this, needs `mr:force-merge`, and cannot be combined with `--auto` or `--cancel-auto`. The owner web merge form offers **Ignore checks** when checks fail or are pending.
29 - The merge answers as soon as the target has moved. `--delete-branch` then deletes the source branch right after, in the background (unless it is the default branch or another open request uses it); if the branch moved meanwhile, it is kept.31 - The merge answers as soon as the target has moved. `--delete-branch` then deletes the source branch right after, in the background (unless it is the default branch or another open request uses it); if the branch moved meanwhile, it is kept.
30 - Every mode is allowed by default, including in repositories that had only `fast-forward` and `squash` before `merge` existed: those gain `merge`. Disable a mode per repository with `ermite repo edit <repo> --merge-modes fast-forward,squash` (list the modes to keep).32 - Every mode is allowed by default, including in repositories that had only `fast-forward` and `squash` before `merge` existed: those gain `merge`. Disable a mode per repository with `ermite repo edit <repo> --merge-modes fast-forward,squash` (list the modes to keep).
31-- Scopes: `mr:write` to open and comment, `mr:review` to give a verdict, `mr:merge` to merge.33+- Scopes: `mr:write` to open and comment, `mr:review` to give a verdict, `mr:merge` to merge, `mr:force-merge` to bypass checks. The `cli` preset includes the bypass scope; `agent` does not. Adding `--scope mr:merge` lets an agent merge only with passing checks.
32 34
33 ## Signed commits35 ## Signed commits
34 36
@@ -47,7 +49,7 @@ A merge request can merge by itself once its checks passed.
47 49
48 - The repository setting `allow_auto_merge` is off by default: `ermite repo edit <repo> --allow-auto-merge` (`=false` to turn it off).50 - The repository setting `allow_auto_merge` is off by default: `ermite repo edit <repo> --allow-auto-merge` (`=false` to turn it off).
49 - Enable it with `ermite mr merge <repo> <n> --head <commit> --auto --mode squash` (`--head` is required, as for a plain merge; a head that moved is refused with "branch moved"), or with **Merge when ready** in the web merge box. Its separate mode choice offers every repository-allowed mode, even while the head conflicts or is behind. Auto-merge keeps the source branch; the delete-source checkbox applies only to an immediate merge. It needs `mr:merge` and a server with CI. Re-enabling replaces the previous mode and enabler.51 - Enable it with `ermite mr merge <repo> <n> --head <commit> --auto --mode squash` (`--head` is required, as for a plain merge; a head that moved is refused with "branch moved"), or with **Merge when ready** in the web merge box. Its separate mode choice offers every repository-allowed mode, even while the head conflicts or is behind. Auto-merge keeps the source branch; the delete-source checkbox applies only to an immediate merge. It needs `mr:merge` and a server with CI. Re-enabling replaces the previous mode and enabler.
50-- The server merges when CI has settled on the head with every run complete and at least one check passed. A failing or unfinished check always blocks, whatever `failing_checks_block` says. The mode must still be allowed.52+- The server merges when CI has settled on the head with every run complete and at least one check passed. A failing or unfinished check always blocks. The mode must still be allowed.
51 - The merge is made as the person who enabled it. Their token is checked at every evaluation, even while checks are pending, and again at merge time under the merge lock: it must still be valid and allow `mr:merge` on the repository and the target branch. A token that no longer qualifies cancels the auto-merge.53 - The merge is made as the person who enabled it. Their token is checked at every evaluation, even while checks are pending, and again at merge time under the merge lock: it must still be valid and allow `mr:merge` on the repository and the target branch. A token that no longer qualifies cancels the auto-merge.
52 - A pending auto-merge shows in `mr show` (`auto_merge`), in the web merge box and as `merge_request_auto_merge_enabled` in `ermite watch`. A merge request merged this way has `auto_merged` true.54 - A pending auto-merge shows in `mr show` (`auto_merge`), in the web merge box and as `merge_request_auto_merge_enabled` in `ermite watch`. A merge request merged this way has `auto_merged` true.
53 - A failing, unfinished or conflicting head waits with auto-merge still on. A re-run or a target fix can make it pass; pushing a new source head cancels the recorded intent (the reason names the new head).55 - A failing, unfinished or conflicting head waits with auto-merge still on. A re-run or a target fix can make it pass; pushing a new source head cancels the recorded intent (the reason names the new head).
M gen/ermite/storage/merge_request.pb.go +9 -5 generated file: collapsed
M go.mod +1 -1
@@ -13,7 +13,7 @@ tool (
1313
1414 require (
1515 bnema.dev/ermite-actions v0.2.0
16- bnema.dev/ermite-api v0.14.0
16+ bnema.dev/ermite-api v0.15.0
1717 bnema.dev/ermite-git v0.1.0
1818 bnema.dev/ermite-kv v0.1.0
1919 connectrpc.com/connect v1.21.0
@@ -13,7 +13,7 @@ tool (
13 13
14 require (14 require (
15 bnema.dev/ermite-actions v0.2.015 bnema.dev/ermite-actions v0.2.0
16- bnema.dev/ermite-api v0.14.016+ bnema.dev/ermite-api v0.15.0
17 bnema.dev/ermite-git v0.1.017 bnema.dev/ermite-git v0.1.0
18 bnema.dev/ermite-kv v0.1.018 bnema.dev/ermite-kv v0.1.0
19 connectrpc.com/connect v1.21.019 connectrpc.com/connect v1.21.0
M go.sum +2 -2
@@ -1,7 +1,7 @@
11 bnema.dev/ermite-actions v0.2.0 h1:238UBhSZcm8RxqqjZvX3RLGKQNyKtxoXKnKIkYt4P/M=
22 bnema.dev/ermite-actions v0.2.0/go.mod h1:qoIB/Iz4xKdLUG4R6wuBDk6cemdugR+UEo5bpSJJYh4=
3-bnema.dev/ermite-api v0.14.0 h1:tN4uirlBla/jxkrcJsLhJz3zuswY3KABRNGw1KLgG5Y=
4-bnema.dev/ermite-api v0.14.0/go.mod h1:L5smjt+IAigyAo3Ny2EnFB9IwvIGOPT1GoByZNgbDa8=
3+bnema.dev/ermite-api v0.15.0 h1:FCTu0iBhx+eXAUnO2U1er3KEzHG/nlfDoM4D/2Lrqv8=
4+bnema.dev/ermite-api v0.15.0/go.mod h1:L5smjt+IAigyAo3Ny2EnFB9IwvIGOPT1GoByZNgbDa8=
55 bnema.dev/ermite-git v0.1.0 h1:8GNhQDj0MxyvrI4kHewZ9k0rT86e9348T3BJ49WHfmI=
66 bnema.dev/ermite-git v0.1.0/go.mod h1:m9d4VYHm28mrY6VxVqWQMRIsadGq80FeLub/nrZX6Cg=
77 bnema.dev/ermite-kv v0.1.0 h1:NbD76jgqMq08AVTe/l0EEqDTHjywaYNoqC5c/WgYRcE=
@@ -1,7 +1,7 @@
1 bnema.dev/ermite-actions v0.2.0 h1:238UBhSZcm8RxqqjZvX3RLGKQNyKtxoXKnKIkYt4P/M=1 bnema.dev/ermite-actions v0.2.0 h1:238UBhSZcm8RxqqjZvX3RLGKQNyKtxoXKnKIkYt4P/M=
2 bnema.dev/ermite-actions v0.2.0/go.mod h1:qoIB/Iz4xKdLUG4R6wuBDk6cemdugR+UEo5bpSJJYh4=2 bnema.dev/ermite-actions v0.2.0/go.mod h1:qoIB/Iz4xKdLUG4R6wuBDk6cemdugR+UEo5bpSJJYh4=
3-bnema.dev/ermite-api v0.14.0 h1:tN4uirlBla/jxkrcJsLhJz3zuswY3KABRNGw1KLgG5Y=3+bnema.dev/ermite-api v0.15.0 h1:FCTu0iBhx+eXAUnO2U1er3KEzHG/nlfDoM4D/2Lrqv8=
4-bnema.dev/ermite-api v0.14.0/go.mod h1:L5smjt+IAigyAo3Ny2EnFB9IwvIGOPT1GoByZNgbDa8=4+bnema.dev/ermite-api v0.15.0/go.mod h1:L5smjt+IAigyAo3Ny2EnFB9IwvIGOPT1GoByZNgbDa8=
5 bnema.dev/ermite-git v0.1.0 h1:8GNhQDj0MxyvrI4kHewZ9k0rT86e9348T3BJ49WHfmI=5 bnema.dev/ermite-git v0.1.0 h1:8GNhQDj0MxyvrI4kHewZ9k0rT86e9348T3BJ49WHfmI=
6 bnema.dev/ermite-git v0.1.0/go.mod h1:m9d4VYHm28mrY6VxVqWQMRIsadGq80FeLub/nrZX6Cg=6 bnema.dev/ermite-git v0.1.0/go.mod h1:m9d4VYHm28mrY6VxVqWQMRIsadGq80FeLub/nrZX6Cg=
7 bnema.dev/ermite-kv v0.1.0 h1:NbD76jgqMq08AVTe/l0EEqDTHjywaYNoqC5c/WgYRcE=7 bnema.dev/ermite-kv v0.1.0 h1:NbD76jgqMq08AVTe/l0EEqDTHjywaYNoqC5c/WgYRcE=
M internal/adapters/client/features.go +4 -0
@@ -8,6 +8,10 @@ import "slices"
88 // ServerService.GetVersion. A server without one still works for everything
99 // else: the CLI warns instead of failing.
1010 const (
11+ // FeatureMarkReady permits marking draft merge requests ready.
12+ FeatureMarkReady = "merge_requests.ready"
13+ // FeatureIgnoreChecks permits merging with checks bypassed.
14+ FeatureIgnoreChecks = "merge_requests.ignore_checks"
1115 // FeatureRepoUpdate is RepoService.UpdateRepo (`ermite repo edit`).
1216 FeatureRepoUpdate = "repo.update"
1317 // FeatureEventsResume is EventService.Watch with resume_after, which
@@ -8,6 +8,10 @@ import "slices"
8 // ServerService.GetVersion. A server without one still works for everything8 // ServerService.GetVersion. A server without one still works for everything
9 // else: the CLI warns instead of failing.9 // else: the CLI warns instead of failing.
10 const (10 const (
11+ // FeatureMarkReady permits marking draft merge requests ready.
12+ FeatureMarkReady = "merge_requests.ready"
13+ // FeatureIgnoreChecks permits merging with checks bypassed.
14+ FeatureIgnoreChecks = "merge_requests.ignore_checks"
11 // FeatureRepoUpdate is RepoService.UpdateRepo (`ermite repo edit`).15 // FeatureRepoUpdate is RepoService.UpdateRepo (`ermite repo edit`).
12 FeatureRepoUpdate = "repo.update"16 FeatureRepoUpdate = "repo.update"
13 // FeatureEventsResume is EventService.Watch with resume_after, which17 // FeatureEventsResume is EventService.Watch with resume_after, which
M internal/adapters/client/scopes.go +3 -0
@@ -28,6 +28,9 @@ var PresetNames = []string{presetCLI, presetAgent, presetCI, presetRunner}
2828 // ScopeName is the name of a scope as users type it and as the docs write
2929 // it: SCOPE_REPO_READ is "repo:read".
3030 func ScopeName(s ermite.Scope) string {
31+ if s == ermite.Scope_SCOPE_MR_FORCE_MERGE {
32+ return "mr:force-merge"
33+ }
3134 name, ok := ermite.Scope_name[int32(s)]
3235 if !ok {
3336 return strconv.Itoa(int(s))
@@ -28,6 +28,9 @@ var PresetNames = []string{presetCLI, presetAgent, presetCI, presetRunner}
28 // ScopeName is the name of a scope as users type it and as the docs write28 // ScopeName is the name of a scope as users type it and as the docs write
29 // it: SCOPE_REPO_READ is "repo:read".29 // it: SCOPE_REPO_READ is "repo:read".
30 func ScopeName(s ermite.Scope) string {30 func ScopeName(s ermite.Scope) string {
31+ if s == ermite.Scope_SCOPE_MR_FORCE_MERGE {
32+ return "mr:force-merge"
33+ }
31 name, ok := ermite.Scope_name[int32(s)]34 name, ok := ermite.Scope_name[int32(s)]
32 if !ok {35 if !ok {
33 return strconv.Itoa(int(s))36 return strconv.Itoa(int(s))
M internal/adapters/client/scopes_test.go +1 -0
@@ -16,6 +16,7 @@ import (
1616
1717 func TestScopeWireNames(t *testing.T) {
1818 want := map[ermite.Scope]string{
19+ ermite.Scope_SCOPE_MR_FORCE_MERGE: "mr:force-merge",
1920 ermite.Scope_SCOPE_MR_REVIEW: "mr:review",
2021 ermite.Scope_SCOPE_REPO_READ: "repo:read",
2122 ermite.Scope_SCOPE_REPO_WRITE: "repo:write",
@@ -16,6 +16,7 @@ import (
16 16
17 func TestScopeWireNames(t *testing.T) {17 func TestScopeWireNames(t *testing.T) {
18 want := map[ermite.Scope]string{18 want := map[ermite.Scope]string{
19+ ermite.Scope_SCOPE_MR_FORCE_MERGE: "mr:force-merge",
19 ermite.Scope_SCOPE_MR_REVIEW: "mr:review",20 ermite.Scope_SCOPE_MR_REVIEW: "mr:review",
20 ermite.Scope_SCOPE_REPO_READ: "repo:read",21 ermite.Scope_SCOPE_REPO_READ: "repo:read",
21 ermite.Scope_SCOPE_REPO_WRITE: "repo:write",22 ermite.Scope_SCOPE_REPO_WRITE: "repo:write",
M internal/adapters/kvstore/issues_test.go +53 -6
@@ -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)
317317 assert.True(t, domain.TokenPrincipal(full10).HasOwnerRights())
318318 assert.Len(t, get("reposmr").Scopes, 10)
319319 assert.NotContains(t, get("reposmr").Scopes, domain.ScopeIssueWrite)
320320 assert.True(t, domain.TokenPrincipal(full).HasOwnerRights())
321- assert.True(t, domain.TokenPrincipal(get("otherfull")).HasOwnerRights(), "the order of the scopes does not matter")
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.True(t, domain.TokenPrincipal(full10).HasOwnerRights())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.True(t, domain.TokenPrincipal(full).HasOwnerRights())320 assert.True(t, domain.TokenPrincipal(full).HasOwnerRights())
321- assert.True(t, domain.TokenPrincipal(get("otherfull")).HasOwnerRights(), "the order of the scopes does not matter")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/mergerequests.go +2 -2
@@ -939,7 +939,7 @@ func decodeSide(s storagepb.DiffSide) domain.DiffSide {
939939 }
940940
941941 func encodeSettings(s domain.RepoSettings) (*storagepb.RepoSettings, error) {
942- pb := &storagepb.RepoSettings{FailingChecksBlock: s.FailingChecksBlock, MergeModesV2: true, AllowAutoMerge: s.AllowAutoMerge}
942+ pb := &storagepb.RepoSettings{MergeModesV2: true, AllowAutoMerge: s.AllowAutoMerge}
943943 for _, m := range s.AllowedMergeModes {
944944 mode, err := encodeMode(m)
945945 if err != nil || mode == storagepb.MergeMode_MERGE_MODE_UNSPECIFIED {
@@ -979,5 +979,5 @@ func decodeSettings(pb *storagepb.RepoSettings) domain.RepoSettings {
979979 if !pb.GetMergeModesV2() && slices.Equal(normalized, []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash}) {
980980 normalized = domain.MergeModes()
981981 }
982- return domain.RepoSettings{AllowedMergeModes: normalized, FailingChecksBlock: pb.GetFailingChecksBlock(), Issues: decodeIssuesMode(pb.GetIssues()), AllowAutoMerge: pb.GetAllowAutoMerge()}
982+ return domain.RepoSettings{AllowedMergeModes: normalized, Issues: decodeIssuesMode(pb.GetIssues()), AllowAutoMerge: pb.GetAllowAutoMerge()}
983983 }
@@ -939,7 +939,7 @@ func decodeSide(s storagepb.DiffSide) domain.DiffSide {
939 }939 }
940 940
941 func encodeSettings(s domain.RepoSettings) (*storagepb.RepoSettings, error) {941 func encodeSettings(s domain.RepoSettings) (*storagepb.RepoSettings, error) {
942- pb := &storagepb.RepoSettings{FailingChecksBlock: s.FailingChecksBlock, MergeModesV2: true, AllowAutoMerge: s.AllowAutoMerge}942+ pb := &storagepb.RepoSettings{MergeModesV2: true, AllowAutoMerge: s.AllowAutoMerge}
943 for _, m := range s.AllowedMergeModes {943 for _, m := range s.AllowedMergeModes {
944 mode, err := encodeMode(m)944 mode, err := encodeMode(m)
945 if err != nil || mode == storagepb.MergeMode_MERGE_MODE_UNSPECIFIED {945 if err != nil || mode == storagepb.MergeMode_MERGE_MODE_UNSPECIFIED {
@@ -979,5 +979,5 @@ func decodeSettings(pb *storagepb.RepoSettings) domain.RepoSettings {
979 if !pb.GetMergeModesV2() && slices.Equal(normalized, []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash}) {979 if !pb.GetMergeModesV2() && slices.Equal(normalized, []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash}) {
980 normalized = domain.MergeModes()980 normalized = domain.MergeModes()
981 }981 }
982- return domain.RepoSettings{AllowedMergeModes: normalized, FailingChecksBlock: pb.GetFailingChecksBlock(), Issues: decodeIssuesMode(pb.GetIssues()), AllowAutoMerge: pb.GetAllowAutoMerge()}982+ return domain.RepoSettings{AllowedMergeModes: normalized, Issues: decodeIssuesMode(pb.GetIssues()), AllowAutoMerge: pb.GetAllowAutoMerge()}
983 }983 }
M internal/adapters/kvstore/mergerequests_test.go +2 -2
@@ -470,7 +470,7 @@ func TestRepoSettings(t *testing.T) {
470470 require.NoError(t, err)
471471 assert.Equal(t, domain.DefaultRepoSettings(), got.Settings)
472472
473- got.Settings = domain.RepoSettings{AllowedMergeModes: []domain.MergeMode{domain.MergeModeSquash}, FailingChecksBlock: false, Issues: domain.IssuesOwner}
473+ got.Settings = domain.RepoSettings{AllowedMergeModes: []domain.MergeMode{domain.MergeModeSquash}, Issues: domain.IssuesOwner}
474474 _, err = s.Mutate(ctx, n, func(r *domain.Repo) (bool, error) { r.Settings = got.Settings; return true, nil })
475475 require.NoError(t, err)
476476 again, err := s.Get(ctx, n)
@@ -634,7 +634,7 @@ func TestStoredOldDefaultModesGainMerge(t *testing.T) {
634634
635635 put := func(db *kv.DB, repo string, modes ...storagepb.MergeMode) {
636636 val, err := proto.Marshal(&storagepb.Repo{Name: repo, Visibility: storagepb.Visibility_VISIBILITY_PUBLIC,
637- Settings: &storagepb.RepoSettings{AllowedMergeModes: modes, FailingChecksBlock: true}})
637+ Settings: &storagepb.RepoSettings{AllowedMergeModes: modes}})
638638 require.NoError(t, err)
639639 var b kv.Batch
640640 b.Put([]byte("repo/"+repo), val)
@@ -470,7 +470,7 @@ func TestRepoSettings(t *testing.T) {
470 require.NoError(t, err)470 require.NoError(t, err)
471 assert.Equal(t, domain.DefaultRepoSettings(), got.Settings)471 assert.Equal(t, domain.DefaultRepoSettings(), got.Settings)
472 472
473- got.Settings = domain.RepoSettings{AllowedMergeModes: []domain.MergeMode{domain.MergeModeSquash}, FailingChecksBlock: false, Issues: domain.IssuesOwner}473+ got.Settings = domain.RepoSettings{AllowedMergeModes: []domain.MergeMode{domain.MergeModeSquash}, Issues: domain.IssuesOwner}
474 _, err = s.Mutate(ctx, n, func(r *domain.Repo) (bool, error) { r.Settings = got.Settings; return true, nil })474 _, err = s.Mutate(ctx, n, func(r *domain.Repo) (bool, error) { r.Settings = got.Settings; return true, nil })
475 require.NoError(t, err)475 require.NoError(t, err)
476 again, err := s.Get(ctx, n)476 again, err := s.Get(ctx, n)
@@ -634,7 +634,7 @@ func TestStoredOldDefaultModesGainMerge(t *testing.T) {
634 634
635 put := func(db *kv.DB, repo string, modes ...storagepb.MergeMode) {635 put := func(db *kv.DB, repo string, modes ...storagepb.MergeMode) {
636 val, err := proto.Marshal(&storagepb.Repo{Name: repo, Visibility: storagepb.Visibility_VISIBILITY_PUBLIC,636 val, err := proto.Marshal(&storagepb.Repo{Name: repo, Visibility: storagepb.Visibility_VISIBILITY_PUBLIC,
637- Settings: &storagepb.RepoSettings{AllowedMergeModes: modes, FailingChecksBlock: true}})637+ Settings: &storagepb.RepoSettings{AllowedMergeModes: modes}})
638 require.NoError(t, err)638 require.NoError(t, err)
639 var b kv.Batch639 var b kv.Batch
640 b.Put([]byte("repo/"+repo), val)640 b.Put([]byte("repo/"+repo), val)
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/rpc/api.go +1 -1
@@ -35,7 +35,7 @@ const RunnerMaxRequestBytes = MaxRequestBytes + 64<<10
3535 // always implements, as GetVersion lists them. FeaturePasskeys comes on top
3636 // when Config.Passkeys is set, and FeatureCISecrets when the settings services
3737 // are, and FeatureCommitSignatures when Config.TrustedKeys is set.
38-var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "issues.links", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.cache", "ci.workflows", "merge_requests.auto_merge"}
38+var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "issues.links", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.cache", "ci.workflows", "merge_requests.auto_merge", "merge_requests.ignore_checks", "merge_requests.ready"}
3939
4040 // FeaturePasskeys is listed by GetVersion when passkeys are available.
4141 // FeatureCIArtifacts (artifact.go) when the artifact services are served.
@@ -35,7 +35,7 @@ const RunnerMaxRequestBytes = MaxRequestBytes + 64<<10
35 // always implements, as GetVersion lists them. FeaturePasskeys comes on top35 // always implements, as GetVersion lists them. FeaturePasskeys comes on top
36 // when Config.Passkeys is set, and FeatureCISecrets when the settings services36 // when Config.Passkeys is set, and FeatureCISecrets when the settings services
37 // are, and FeatureCommitSignatures when Config.TrustedKeys is set.37 // are, and FeatureCommitSignatures when Config.TrustedKeys is set.
38-var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "issues.links", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.cache", "ci.workflows", "merge_requests.auto_merge"}38+var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "issues.links", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.cache", "ci.workflows", "merge_requests.auto_merge", "merge_requests.ignore_checks", "merge_requests.ready"}
39 39
40 // FeaturePasskeys is listed by GetVersion when passkeys are available.40 // FeaturePasskeys is listed by GetVersion when passkeys are available.
41 // FeatureCIArtifacts (artifact.go) when the artifact services are served.41 // FeatureCIArtifacts (artifact.go) when the artifact services are served.
M internal/adapters/rpc/convert.go +2 -1
@@ -72,7 +72,7 @@ func repoToPB(v ports.RepoView) *pb.Repo {
7272 }
7373
7474 func settingsToPB(s domain.RepoSettings) *pb.RepoSettings {
75- out := &pb.RepoSettings{FailingChecksBlock: s.FailingChecksBlock, AllowAutoMerge: s.AllowAutoMerge, Issues: issuesModeToPB(s.Issues)}
75+ out := &pb.RepoSettings{AllowAutoMerge: s.AllowAutoMerge, Issues: issuesModeToPB(s.Issues)}
7676 for _, m := range s.AllowedMergeModes {
7777 out.AllowedMergeModes = append(out.AllowedMergeModes, mergeModeToPB(m))
7878 }
@@ -131,6 +131,7 @@ var scopeToPB = map[domain.Scope]pb.Scope{
131131 domain.ScopeRepoAdmin: pb.Scope_SCOPE_REPO_ADMIN,
132132 domain.ScopeMRWrite: pb.Scope_SCOPE_MR_WRITE,
133133 domain.ScopeMRMerge: pb.Scope_SCOPE_MR_MERGE,
134+ domain.ScopeMRForceMerge: pb.Scope_SCOPE_MR_FORCE_MERGE,
134135 domain.ScopeCIWrite: pb.Scope_SCOPE_CI_WRITE,
135136 domain.ScopeSecretsWrite: pb.Scope_SCOPE_SECRETS_WRITE,
136137 domain.ScopeRunnerRegister: pb.Scope_SCOPE_RUNNER_REGISTER,
@@ -72,7 +72,7 @@ func repoToPB(v ports.RepoView) *pb.Repo {
72 }72 }
73 73
74 func settingsToPB(s domain.RepoSettings) *pb.RepoSettings {74 func settingsToPB(s domain.RepoSettings) *pb.RepoSettings {
75- out := &pb.RepoSettings{FailingChecksBlock: s.FailingChecksBlock, AllowAutoMerge: s.AllowAutoMerge, Issues: issuesModeToPB(s.Issues)}75+ out := &pb.RepoSettings{AllowAutoMerge: s.AllowAutoMerge, Issues: issuesModeToPB(s.Issues)}
76 for _, m := range s.AllowedMergeModes {76 for _, m := range s.AllowedMergeModes {
77 out.AllowedMergeModes = append(out.AllowedMergeModes, mergeModeToPB(m))77 out.AllowedMergeModes = append(out.AllowedMergeModes, mergeModeToPB(m))
78 }78 }
@@ -131,6 +131,7 @@ var scopeToPB = map[domain.Scope]pb.Scope{
131 domain.ScopeRepoAdmin: pb.Scope_SCOPE_REPO_ADMIN,131 domain.ScopeRepoAdmin: pb.Scope_SCOPE_REPO_ADMIN,
132 domain.ScopeMRWrite: pb.Scope_SCOPE_MR_WRITE,132 domain.ScopeMRWrite: pb.Scope_SCOPE_MR_WRITE,
133 domain.ScopeMRMerge: pb.Scope_SCOPE_MR_MERGE,133 domain.ScopeMRMerge: pb.Scope_SCOPE_MR_MERGE,
134+ domain.ScopeMRForceMerge: pb.Scope_SCOPE_MR_FORCE_MERGE,
134 domain.ScopeCIWrite: pb.Scope_SCOPE_CI_WRITE,135 domain.ScopeCIWrite: pb.Scope_SCOPE_CI_WRITE,
135 domain.ScopeSecretsWrite: pb.Scope_SCOPE_SECRETS_WRITE,136 domain.ScopeSecretsWrite: pb.Scope_SCOPE_SECRETS_WRITE,
136 domain.ScopeRunnerRegister: pb.Scope_SCOPE_RUNNER_REGISTER,137 domain.ScopeRunnerRegister: pb.Scope_SCOPE_RUNNER_REGISTER,
M internal/adapters/rpc/convert_test.go +9 -9
@@ -214,12 +214,12 @@ func TestRepoToPBAlwaysHasSettings(t *testing.T) {
214214 // A repository value without settings (never stored with them) reads as the defaults.
215215 r := repoToPB(ports.RepoView{Repo: domain.Repo{Name: mustName(t, "x")}})
216216 assert.Equal(t, []pb.MergeMode{pb.MergeMode_MERGE_MODE_FAST_FORWARD, pb.MergeMode_MERGE_MODE_SQUASH, pb.MergeMode_MERGE_MODE_MERGE_COMMIT}, r.GetSettings().GetAllowedMergeModes())
217- assert.True(t, r.GetSettings().GetFailingChecksBlock())
217+ assert.False(t, r.GetSettings().GetAllowAutoMerge())
218218
219219 custom := domain.RepoSettings{AllowedMergeModes: []domain.MergeMode{domain.MergeModeSquash}}
220220 r = repoToPB(ports.RepoView{Repo: domain.Repo{Name: mustName(t, "x"), Settings: custom}})
221221 assert.Equal(t, []pb.MergeMode{pb.MergeMode_MERGE_MODE_SQUASH}, r.GetSettings().GetAllowedMergeModes())
222- assert.False(t, r.GetSettings().GetFailingChecksBlock())
222+ assert.False(t, r.GetSettings().GetAllowAutoMerge())
223223 }
224224
225225 func mustName(t *testing.T, s string) domain.RepoName {
@@ -235,8 +235,8 @@ func TestUpdateParamsSettingsPaths(t *testing.T) {
235235 return &pb.UpdateRepoRequest{
236236 UpdateMask: &fieldmaskpb.FieldMask{Paths: paths},
237237 Settings: &pb.RepoSettings{
238- AllowedMergeModes: []pb.MergeMode{pb.MergeMode_MERGE_MODE_SQUASH},
239- FailingChecksBlock: true,
238+ AllowedMergeModes: []pb.MergeMode{pb.MergeMode_MERGE_MODE_SQUASH},
239+ AllowAutoMerge: true,
240240 },
241241 }
242242 }
@@ -244,18 +244,18 @@ func TestUpdateParamsSettingsPaths(t *testing.T) {
244244 require.NoError(t, err)
245245 require.NotNil(t, p.AllowedMergeModes)
246246 assert.Equal(t, []domain.MergeMode{domain.MergeModeSquash}, *p.AllowedMergeModes)
247- require.NotNil(t, p.FailingChecksBlock)
248- assert.True(t, *p.FailingChecksBlock)
247+ require.NotNil(t, p.AllowAutoMerge)
248+ assert.True(t, *p.AllowAutoMerge)
249249
250250 p, err = updateParams(msg("settings.allowed_merge_modes"))
251251 require.NoError(t, err)
252252 assert.NotNil(t, p.AllowedMergeModes)
253- assert.Nil(t, p.FailingChecksBlock, "not named: left alone")
253+ assert.Nil(t, p.AllowAutoMerge, "not named: left alone")
254254
255- p, err = updateParams(msg("settings.failing_checks_block"))
255+ p, err = updateParams(msg("settings.allow_auto_merge"))
256256 require.NoError(t, err)
257257 assert.Nil(t, p.AllowedMergeModes)
258- assert.NotNil(t, p.FailingChecksBlock)
258+ assert.NotNil(t, p.AllowAutoMerge)
259259
260260 // Modes named with nothing to take them from are an empty list, which the use case refuses.
261261 p, err = updateParams(&pb.UpdateRepoRequest{UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"settings.allowed_merge_modes"}}})
@@ -214,12 +214,12 @@ func TestRepoToPBAlwaysHasSettings(t *testing.T) {
214 // A repository value without settings (never stored with them) reads as the defaults.214 // A repository value without settings (never stored with them) reads as the defaults.
215 r := repoToPB(ports.RepoView{Repo: domain.Repo{Name: mustName(t, "x")}})215 r := repoToPB(ports.RepoView{Repo: domain.Repo{Name: mustName(t, "x")}})
216 assert.Equal(t, []pb.MergeMode{pb.MergeMode_MERGE_MODE_FAST_FORWARD, pb.MergeMode_MERGE_MODE_SQUASH, pb.MergeMode_MERGE_MODE_MERGE_COMMIT}, r.GetSettings().GetAllowedMergeModes())216 assert.Equal(t, []pb.MergeMode{pb.MergeMode_MERGE_MODE_FAST_FORWARD, pb.MergeMode_MERGE_MODE_SQUASH, pb.MergeMode_MERGE_MODE_MERGE_COMMIT}, r.GetSettings().GetAllowedMergeModes())
217- assert.True(t, r.GetSettings().GetFailingChecksBlock())217+ assert.False(t, r.GetSettings().GetAllowAutoMerge())
218 218
219 custom := domain.RepoSettings{AllowedMergeModes: []domain.MergeMode{domain.MergeModeSquash}}219 custom := domain.RepoSettings{AllowedMergeModes: []domain.MergeMode{domain.MergeModeSquash}}
220 r = repoToPB(ports.RepoView{Repo: domain.Repo{Name: mustName(t, "x"), Settings: custom}})220 r = repoToPB(ports.RepoView{Repo: domain.Repo{Name: mustName(t, "x"), Settings: custom}})
221 assert.Equal(t, []pb.MergeMode{pb.MergeMode_MERGE_MODE_SQUASH}, r.GetSettings().GetAllowedMergeModes())221 assert.Equal(t, []pb.MergeMode{pb.MergeMode_MERGE_MODE_SQUASH}, r.GetSettings().GetAllowedMergeModes())
222- assert.False(t, r.GetSettings().GetFailingChecksBlock())222+ assert.False(t, r.GetSettings().GetAllowAutoMerge())
223 }223 }
224 224
225 func mustName(t *testing.T, s string) domain.RepoName {225 func mustName(t *testing.T, s string) domain.RepoName {
@@ -235,8 +235,8 @@ func TestUpdateParamsSettingsPaths(t *testing.T) {
235 return &pb.UpdateRepoRequest{235 return &pb.UpdateRepoRequest{
236 UpdateMask: &fieldmaskpb.FieldMask{Paths: paths},236 UpdateMask: &fieldmaskpb.FieldMask{Paths: paths},
237 Settings: &pb.RepoSettings{237 Settings: &pb.RepoSettings{
238- AllowedMergeModes: []pb.MergeMode{pb.MergeMode_MERGE_MODE_SQUASH},238+ AllowedMergeModes: []pb.MergeMode{pb.MergeMode_MERGE_MODE_SQUASH},
239- FailingChecksBlock: true,239+ AllowAutoMerge: true,
240 },240 },
241 }241 }
242 }242 }
@@ -244,18 +244,18 @@ func TestUpdateParamsSettingsPaths(t *testing.T) {
244 require.NoError(t, err)244 require.NoError(t, err)
245 require.NotNil(t, p.AllowedMergeModes)245 require.NotNil(t, p.AllowedMergeModes)
246 assert.Equal(t, []domain.MergeMode{domain.MergeModeSquash}, *p.AllowedMergeModes)246 assert.Equal(t, []domain.MergeMode{domain.MergeModeSquash}, *p.AllowedMergeModes)
247- require.NotNil(t, p.FailingChecksBlock)247+ require.NotNil(t, p.AllowAutoMerge)
248- assert.True(t, *p.FailingChecksBlock)248+ assert.True(t, *p.AllowAutoMerge)
249 249
250 p, err = updateParams(msg("settings.allowed_merge_modes"))250 p, err = updateParams(msg("settings.allowed_merge_modes"))
251 require.NoError(t, err)251 require.NoError(t, err)
252 assert.NotNil(t, p.AllowedMergeModes)252 assert.NotNil(t, p.AllowedMergeModes)
253- assert.Nil(t, p.FailingChecksBlock, "not named: left alone")253+ assert.Nil(t, p.AllowAutoMerge, "not named: left alone")
254 254
255- p, err = updateParams(msg("settings.failing_checks_block"))255+ p, err = updateParams(msg("settings.allow_auto_merge"))
256 require.NoError(t, err)256 require.NoError(t, err)
257 assert.Nil(t, p.AllowedMergeModes)257 assert.Nil(t, p.AllowedMergeModes)
258- assert.NotNil(t, p.FailingChecksBlock)258+ assert.NotNil(t, p.AllowAutoMerge)
259 259
260 // Modes named with nothing to take them from are an empty list, which the use case refuses.260 // Modes named with nothing to take them from are an empty list, which the use case refuses.
261 p, err = updateParams(&pb.UpdateRepoRequest{UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"settings.allowed_merge_modes"}}})261 p, err = updateParams(&pb.UpdateRepoRequest{UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"settings.allowed_merge_modes"}}})
M internal/adapters/rpc/mergerequest.go +13 -1
@@ -399,7 +399,7 @@ func (s *mergeRequestService) MergeMergeRequest(ctx context.Context, req *connec
399399 return nil, connectError(ctx, err)
400400 }
401401 res, err := s.mrs.Merge(ctx, caller(ctx), name, req.Msg.GetNumber(), ports.MergeParams{
402- Mode: mode, HeadID: head, DeleteSource: req.Msg.GetDeleteSourceBranch(),
402+ Mode: mode, HeadID: head, DeleteSource: req.Msg.GetDeleteSourceBranch(), IgnoreChecks: req.Msg.GetIgnoreChecks(),
403403 })
404404 if err != nil {
405405 return nil, connectError(ctx, err)
@@ -423,3 +423,15 @@ func (s *mergeRequestService) MarkRead(ctx context.Context, req *connect.Request
423423 }
424424 return connect.NewResponse(&pb.MarkReadResponse{}), nil
425425 }
426+
427+func (s *mergeRequestService) MarkReady(ctx context.Context, req *connect.Request[pb.MarkReadyRequest]) (*connect.Response[pb.MarkReadyResponse], error) {
428+ name, err := domain.ParseRepoName(req.Msg.GetRepo())
429+ if err != nil {
430+ return nil, connectError(ctx, err)
431+ }
432+ mr, err := s.mrs.MarkReady(ctx, caller(ctx), name, req.Msg.GetNumber())
433+ if err != nil {
434+ return nil, connectError(ctx, err)
435+ }
436+ return connect.NewResponse(&pb.MarkReadyResponse{MergeRequest: mergeRequestToPB(mr)}), nil
437+}
@@ -399,7 +399,7 @@ func (s *mergeRequestService) MergeMergeRequest(ctx context.Context, req *connec
399 return nil, connectError(ctx, err)399 return nil, connectError(ctx, err)
400 }400 }
401 res, err := s.mrs.Merge(ctx, caller(ctx), name, req.Msg.GetNumber(), ports.MergeParams{401 res, err := s.mrs.Merge(ctx, caller(ctx), name, req.Msg.GetNumber(), ports.MergeParams{
402- Mode: mode, HeadID: head, DeleteSource: req.Msg.GetDeleteSourceBranch(),402+ Mode: mode, HeadID: head, DeleteSource: req.Msg.GetDeleteSourceBranch(), IgnoreChecks: req.Msg.GetIgnoreChecks(),
403 })403 })
404 if err != nil {404 if err != nil {
405 return nil, connectError(ctx, err)405 return nil, connectError(ctx, err)
@@ -423,3 +423,15 @@ func (s *mergeRequestService) MarkRead(ctx context.Context, req *connect.Request
423 }423 }
424 return connect.NewResponse(&pb.MarkReadResponse{}), nil424 return connect.NewResponse(&pb.MarkReadResponse{}), nil
425 }425 }
426+
427+func (s *mergeRequestService) MarkReady(ctx context.Context, req *connect.Request[pb.MarkReadyRequest]) (*connect.Response[pb.MarkReadyResponse], error) {
428+ name, err := domain.ParseRepoName(req.Msg.GetRepo())
429+ if err != nil {
430+ return nil, connectError(ctx, err)
431+ }
432+ mr, err := s.mrs.MarkReady(ctx, caller(ctx), name, req.Msg.GetNumber())
433+ if err != nil {
434+ return nil, connectError(ctx, err)
435+ }
436+ return connect.NewResponse(&pb.MarkReadyResponse{MergeRequest: mergeRequestToPB(mr)}), nil
437+}
M internal/adapters/rpc/mergerequest_test.go +31 -1
@@ -140,6 +140,7 @@ func TestMergeRequestServiceMerge(t *testing.T) {
140140 wantCall bool
141141 }{
142142 {name: "ok", req: &pb.MergeMergeRequestRequest{Repo: "demo", Number: 1, HeadId: head, DeleteSourceBranch: true}, wantCall: true},
143+ {name: "ignore checks", req: &pb.MergeMergeRequestRequest{Repo: "demo", Number: 1, HeadId: head, IgnoreChecks: true}, wantCall: true},
143144 {name: "head is not a full id", req: &pb.MergeMergeRequestRequest{Repo: "demo", Number: 1, HeadId: "abc"}, wantCode: connect.CodeInvalidArgument},
144145 {name: "invalid repo", req: &pb.MergeMergeRequestRequest{Repo: "../x", Number: 1}, wantCode: connect.CodeInvalidArgument},
145146 {name: "head moved", req: &pb.MergeMergeRequestRequest{Repo: "demo", Number: 1, HeadId: head}, usecase: domain.ErrConflict, wantCode: connect.CodeAborted, wantCall: true},
@@ -152,7 +153,7 @@ func TestMergeRequestServiceMerge(t *testing.T) {
152153 mrs := mocks.NewMergeRequestService(t)
153154 if tt.wantCall {
154155 mrs.EXPECT().Merge(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(1), mock.MatchedBy(func(p ports.MergeParams) bool {
155- return p.DeleteSource == tt.req.GetDeleteSourceBranch() && (tt.req.GetHeadId() == "") == p.HeadID.IsZero()
156+ return p.IgnoreChecks == tt.req.GetIgnoreChecks() && p.DeleteSource == tt.req.GetDeleteSourceBranch() && (tt.req.GetHeadId() == "") == p.HeadID.IsZero()
156157 })).Return(ports.MergeResult{MergeRequest: domain.MergeRequest{Number: 1}, SourceDeleted: true}, tt.usecase).Once()
157158 }
158159 resp, err := (&mergeRequestService{mrs: mrs}).MergeMergeRequest(ownerCtx(), connect.NewRequest(tt.req))
@@ -226,3 +227,32 @@ func TestMergeRequestServiceGet(t *testing.T) {
226227 })
227228 }
228229 }
230+
231+func TestMergeRequestServiceMarkReady(t *testing.T) {
232+ t.Parallel()
233+ for _, tt := range []struct {
234+ name string
235+ err error
236+ code connect.Code
237+ }{
238+ {name: "success"},
239+ {name: "not a draft", err: domain.ErrPreconditionFailed, code: connect.CodeFailedPrecondition},
240+ {name: "closed", err: domain.ErrPreconditionFailed, code: connect.CodeFailedPrecondition},
241+ {name: "without mr:write", err: domain.ErrForbidden, code: connect.CodePermissionDenied},
242+ } {
243+ t.Run(tt.name, func(t *testing.T) {
244+ t.Parallel()
245+ mrs := mocks.NewMergeRequestService(t)
246+ title, err := domain.ParseMRTitle("Ready")
247+ require.NoError(t, err)
248+ mrs.EXPECT().MarkReady(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(1)).Return(domain.MergeRequest{Number: 1, Title: title, State: domain.MRStateOpen}, tt.err).Once()
249+ res, err := (&mergeRequestService{mrs: mrs}).MarkReady(ownerCtx(), connect.NewRequest(&pb.MarkReadyRequest{Repo: "demo", Number: 1}))
250+ if tt.code != 0 {
251+ requireCode(t, tt.code, err)
252+ return
253+ }
254+ require.NoError(t, err)
255+ assert.Equal(t, "Ready", res.Msg.GetMergeRequest().GetTitle())
256+ })
257+ }
258+}
@@ -140,6 +140,7 @@ func TestMergeRequestServiceMerge(t *testing.T) {
140 wantCall bool140 wantCall bool
141 }{141 }{
142 {name: "ok", req: &pb.MergeMergeRequestRequest{Repo: "demo", Number: 1, HeadId: head, DeleteSourceBranch: true}, wantCall: true},142 {name: "ok", req: &pb.MergeMergeRequestRequest{Repo: "demo", Number: 1, HeadId: head, DeleteSourceBranch: true}, wantCall: true},
143+ {name: "ignore checks", req: &pb.MergeMergeRequestRequest{Repo: "demo", Number: 1, HeadId: head, IgnoreChecks: true}, wantCall: true},
143 {name: "head is not a full id", req: &pb.MergeMergeRequestRequest{Repo: "demo", Number: 1, HeadId: "abc"}, wantCode: connect.CodeInvalidArgument},144 {name: "head is not a full id", req: &pb.MergeMergeRequestRequest{Repo: "demo", Number: 1, HeadId: "abc"}, wantCode: connect.CodeInvalidArgument},
144 {name: "invalid repo", req: &pb.MergeMergeRequestRequest{Repo: "../x", Number: 1}, wantCode: connect.CodeInvalidArgument},145 {name: "invalid repo", req: &pb.MergeMergeRequestRequest{Repo: "../x", Number: 1}, wantCode: connect.CodeInvalidArgument},
145 {name: "head moved", req: &pb.MergeMergeRequestRequest{Repo: "demo", Number: 1, HeadId: head}, usecase: domain.ErrConflict, wantCode: connect.CodeAborted, wantCall: true},146 {name: "head moved", req: &pb.MergeMergeRequestRequest{Repo: "demo", Number: 1, HeadId: head}, usecase: domain.ErrConflict, wantCode: connect.CodeAborted, wantCall: true},
@@ -152,7 +153,7 @@ func TestMergeRequestServiceMerge(t *testing.T) {
152 mrs := mocks.NewMergeRequestService(t)153 mrs := mocks.NewMergeRequestService(t)
153 if tt.wantCall {154 if tt.wantCall {
154 mrs.EXPECT().Merge(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(1), mock.MatchedBy(func(p ports.MergeParams) bool {155 mrs.EXPECT().Merge(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(1), mock.MatchedBy(func(p ports.MergeParams) bool {
155- return p.DeleteSource == tt.req.GetDeleteSourceBranch() && (tt.req.GetHeadId() == "") == p.HeadID.IsZero()156+ return p.IgnoreChecks == tt.req.GetIgnoreChecks() && p.DeleteSource == tt.req.GetDeleteSourceBranch() && (tt.req.GetHeadId() == "") == p.HeadID.IsZero()
156 })).Return(ports.MergeResult{MergeRequest: domain.MergeRequest{Number: 1}, SourceDeleted: true}, tt.usecase).Once()157 })).Return(ports.MergeResult{MergeRequest: domain.MergeRequest{Number: 1}, SourceDeleted: true}, tt.usecase).Once()
157 }158 }
158 resp, err := (&mergeRequestService{mrs: mrs}).MergeMergeRequest(ownerCtx(), connect.NewRequest(tt.req))159 resp, err := (&mergeRequestService{mrs: mrs}).MergeMergeRequest(ownerCtx(), connect.NewRequest(tt.req))
@@ -226,3 +227,32 @@ func TestMergeRequestServiceGet(t *testing.T) {
226 })227 })
227 }228 }
228 }229 }
230+
231+func TestMergeRequestServiceMarkReady(t *testing.T) {
232+ t.Parallel()
233+ for _, tt := range []struct {
234+ name string
235+ err error
236+ code connect.Code
237+ }{
238+ {name: "success"},
239+ {name: "not a draft", err: domain.ErrPreconditionFailed, code: connect.CodeFailedPrecondition},
240+ {name: "closed", err: domain.ErrPreconditionFailed, code: connect.CodeFailedPrecondition},
241+ {name: "without mr:write", err: domain.ErrForbidden, code: connect.CodePermissionDenied},
242+ } {
243+ t.Run(tt.name, func(t *testing.T) {
244+ t.Parallel()
245+ mrs := mocks.NewMergeRequestService(t)
246+ title, err := domain.ParseMRTitle("Ready")
247+ require.NoError(t, err)
248+ mrs.EXPECT().MarkReady(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(1)).Return(domain.MergeRequest{Number: 1, Title: title, State: domain.MRStateOpen}, tt.err).Once()
249+ res, err := (&mergeRequestService{mrs: mrs}).MarkReady(ownerCtx(), connect.NewRequest(&pb.MarkReadyRequest{Repo: "demo", Number: 1}))
250+ if tt.code != 0 {
251+ requireCode(t, tt.code, err)
252+ return
253+ }
254+ require.NoError(t, err)
255+ assert.Equal(t, "Ready", res.Msg.GetMergeRequest().GetTitle())
256+ })
257+ }
258+}
M internal/adapters/rpc/repo.go +2 -5
@@ -80,7 +80,7 @@ func (s *repoService) UpdateRepo(ctx context.Context, req *connect.Request[pb.Up
8080
8181 // updateParams maps the field mask of an update. The mask must name at least
8282 // one of "description", "topics", "visibility", "default_branch", "settings",
83-// "settings.allowed_merge_modes", "settings.failing_checks_block" and
83+// "settings.allowed_merge_modes" and
8484 // "settings.allow_auto_merge", and nothing else; the optional issues field stands outside the mask, and alone
8585 // it is a valid update.
8686 func updateParams(msg *pb.UpdateRepoRequest) (ports.UpdateRepoParams, error) {
@@ -113,7 +113,7 @@ func updateParams(msg *pb.UpdateRepoRequest) (ports.UpdateRepoParams, error) {
113113 params.Visibility = &vis
114114 case "default_branch":
115115 params.DefaultBranch = new(msg.GetDefaultBranch())
116- case "settings", "settings.allowed_merge_modes", "settings.failing_checks_block", "settings.allow_auto_merge":
116+ case "settings", "settings.allowed_merge_modes", "settings.allow_auto_merge":
117117 all := path == "settings"
118118 if all || path == "settings.allowed_merge_modes" {
119119 modes, err := mergeModesFromPB(msg.GetSettings().GetAllowedMergeModes())
@@ -122,9 +122,6 @@ func updateParams(msg *pb.UpdateRepoRequest) (ports.UpdateRepoParams, error) {
122122 }
123123 params.AllowedMergeModes = &modes
124124 }
125- if all || path == "settings.failing_checks_block" {
126- params.FailingChecksBlock = new(msg.GetSettings().GetFailingChecksBlock())
127- }
128125 if all || path == "settings.allow_auto_merge" {
129126 params.AllowAutoMerge = new(msg.GetSettings().GetAllowAutoMerge())
130127 }
@@ -80,7 +80,7 @@ func (s *repoService) UpdateRepo(ctx context.Context, req *connect.Request[pb.Up
80 80
81 // updateParams maps the field mask of an update. The mask must name at least81 // updateParams maps the field mask of an update. The mask must name at least
82 // one of "description", "topics", "visibility", "default_branch", "settings",82 // one of "description", "topics", "visibility", "default_branch", "settings",
83-// "settings.allowed_merge_modes", "settings.failing_checks_block" and83+// "settings.allowed_merge_modes" and
84 // "settings.allow_auto_merge", and nothing else; the optional issues field stands outside the mask, and alone84 // "settings.allow_auto_merge", and nothing else; the optional issues field stands outside the mask, and alone
85 // it is a valid update.85 // it is a valid update.
86 func updateParams(msg *pb.UpdateRepoRequest) (ports.UpdateRepoParams, error) {86 func updateParams(msg *pb.UpdateRepoRequest) (ports.UpdateRepoParams, error) {
@@ -113,7 +113,7 @@ func updateParams(msg *pb.UpdateRepoRequest) (ports.UpdateRepoParams, error) {
113 params.Visibility = &vis113 params.Visibility = &vis
114 case "default_branch":114 case "default_branch":
115 params.DefaultBranch = new(msg.GetDefaultBranch())115 params.DefaultBranch = new(msg.GetDefaultBranch())
116- case "settings", "settings.allowed_merge_modes", "settings.failing_checks_block", "settings.allow_auto_merge":116+ case "settings", "settings.allowed_merge_modes", "settings.allow_auto_merge":
117 all := path == "settings"117 all := path == "settings"
118 if all || path == "settings.allowed_merge_modes" {118 if all || path == "settings.allowed_merge_modes" {
119 modes, err := mergeModesFromPB(msg.GetSettings().GetAllowedMergeModes())119 modes, err := mergeModesFromPB(msg.GetSettings().GetAllowedMergeModes())
@@ -122,9 +122,6 @@ func updateParams(msg *pb.UpdateRepoRequest) (ports.UpdateRepoParams, error) {
122 }122 }
123 params.AllowedMergeModes = &modes123 params.AllowedMergeModes = &modes
124 }124 }
125- if all || path == "settings.failing_checks_block" {
126- params.FailingChecksBlock = new(msg.GetSettings().GetFailingChecksBlock())
127- }
128 if all || path == "settings.allow_auto_merge" {125 if all || path == "settings.allow_auto_merge" {
129 params.AllowAutoMerge = new(msg.GetSettings().GetAllowAutoMerge())126 params.AllowAutoMerge = new(msg.GetSettings().GetAllowAutoMerge())
130 }127 }
M internal/adapters/rpc/repo_test.go +1 -7
@@ -284,24 +284,18 @@ func TestRepoToPBTopics(t *testing.T) {
284284 func TestUpdateParamsAllowAutoMerge(t *testing.T) {
285285 t.Parallel()
286286 mask := func(paths ...string) *fieldmaskpb.FieldMask { return &fieldmaskpb.FieldMask{Paths: paths} }
287- settings := &pb.RepoSettings{AllowAutoMerge: true, FailingChecksBlock: true, AllowedMergeModes: []pb.MergeMode{pb.MergeMode_MERGE_MODE_SQUASH}}
287+ settings := &pb.RepoSettings{AllowAutoMerge: true, AllowedMergeModes: []pb.MergeMode{pb.MergeMode_MERGE_MODE_SQUASH}}
288288
289289 params, err := updateParams(&pb.UpdateRepoRequest{UpdateMask: mask("settings.allow_auto_merge"), Settings: settings})
290290 require.NoError(t, err)
291291 require.NotNil(t, params.AllowAutoMerge)
292292 assert.True(t, *params.AllowAutoMerge)
293- assert.Nil(t, params.FailingChecksBlock, "only the masked setting is set")
294293 assert.Nil(t, params.AllowedMergeModes)
295294
296295 params, err = updateParams(&pb.UpdateRepoRequest{UpdateMask: mask("settings"), Settings: settings})
297296 require.NoError(t, err)
298297 require.NotNil(t, params.AllowAutoMerge, "settings covers allow_auto_merge")
299298 assert.True(t, *params.AllowAutoMerge)
300- require.NotNil(t, params.FailingChecksBlock)
301299 require.NotNil(t, params.AllowedMergeModes)
302300
303- params, err = updateParams(&pb.UpdateRepoRequest{UpdateMask: mask("settings.failing_checks_block"), Settings: settings})
304- require.NoError(t, err)
305- assert.Nil(t, params.AllowAutoMerge)
306- require.NotNil(t, params.FailingChecksBlock)
307301 }
@@ -284,24 +284,18 @@ func TestRepoToPBTopics(t *testing.T) {
284 func TestUpdateParamsAllowAutoMerge(t *testing.T) {284 func TestUpdateParamsAllowAutoMerge(t *testing.T) {
285 t.Parallel()285 t.Parallel()
286 mask := func(paths ...string) *fieldmaskpb.FieldMask { return &fieldmaskpb.FieldMask{Paths: paths} }286 mask := func(paths ...string) *fieldmaskpb.FieldMask { return &fieldmaskpb.FieldMask{Paths: paths} }
287- settings := &pb.RepoSettings{AllowAutoMerge: true, FailingChecksBlock: true, AllowedMergeModes: []pb.MergeMode{pb.MergeMode_MERGE_MODE_SQUASH}}287+ settings := &pb.RepoSettings{AllowAutoMerge: true, AllowedMergeModes: []pb.MergeMode{pb.MergeMode_MERGE_MODE_SQUASH}}
288 288
289 params, err := updateParams(&pb.UpdateRepoRequest{UpdateMask: mask("settings.allow_auto_merge"), Settings: settings})289 params, err := updateParams(&pb.UpdateRepoRequest{UpdateMask: mask("settings.allow_auto_merge"), Settings: settings})
290 require.NoError(t, err)290 require.NoError(t, err)
291 require.NotNil(t, params.AllowAutoMerge)291 require.NotNil(t, params.AllowAutoMerge)
292 assert.True(t, *params.AllowAutoMerge)292 assert.True(t, *params.AllowAutoMerge)
293- assert.Nil(t, params.FailingChecksBlock, "only the masked setting is set")
294 assert.Nil(t, params.AllowedMergeModes)293 assert.Nil(t, params.AllowedMergeModes)
295 294
296 params, err = updateParams(&pb.UpdateRepoRequest{UpdateMask: mask("settings"), Settings: settings})295 params, err = updateParams(&pb.UpdateRepoRequest{UpdateMask: mask("settings"), Settings: settings})
297 require.NoError(t, err)296 require.NoError(t, err)
298 require.NotNil(t, params.AllowAutoMerge, "settings covers allow_auto_merge")297 require.NotNil(t, params.AllowAutoMerge, "settings covers allow_auto_merge")
299 assert.True(t, *params.AllowAutoMerge)298 assert.True(t, *params.AllowAutoMerge)
300- require.NotNil(t, params.FailingChecksBlock)
301 require.NotNil(t, params.AllowedMergeModes)299 require.NotNil(t, params.AllowedMergeModes)
302 300
303- params, err = updateParams(&pb.UpdateRepoRequest{UpdateMask: mask("settings.failing_checks_block"), Settings: settings})
304- require.NoError(t, err)
305- assert.Nil(t, params.AllowAutoMerge)
306- require.NotNil(t, params.FailingChecksBlock)
307 }301 }
M internal/adapters/web/checks.go +1 -3
@@ -111,10 +111,8 @@ func checksSection(repo domain.Repo, checks []ports.CheckView) ChecksSection {
111111 switch {
112112 case len(checks) == 0:
113113 sec.Note = "no check on this commit: nothing blocks the merge."
114- case repo.Settings.FailingChecksBlock:
115- sec.Note = "a failing or unfinished check blocks the merge."
116114 default:
117- sec.Note = "checks do not block the merge in this repository."
115+ sec.Note = "a failing or unfinished check blocks the merge."
118116 }
119117 return sec
120118 }
@@ -111,10 +111,8 @@ func checksSection(repo domain.Repo, checks []ports.CheckView) ChecksSection {
111 switch {111 switch {
112 case len(checks) == 0:112 case len(checks) == 0:
113 sec.Note = "no check on this commit: nothing blocks the merge."113 sec.Note = "no check on this commit: nothing blocks the merge."
114- case repo.Settings.FailingChecksBlock:
115- sec.Note = "a failing or unfinished check blocks the merge."
116 default:114 default:
117- sec.Note = "checks do not block the merge in this repository."115+ sec.Note = "a failing or unfinished check blocks the merge."
118 }116 }
119 return sec117 return sec
120 }118 }
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.go +1 -1
@@ -740,7 +740,7 @@ func (s *Server) mrFacts(ctx context.Context, r *http.Request, d *repoData, p *M
740740 p.Modes = append(p.Modes, name)
741741 }
742742 }
743- p.ChecksBlock = d.repo.Settings.FailingChecksBlock
743+ p.ChecksBlock = true
744744 if head != "" {
745745 checks := s.checksFor(ctx, d.repo.Name, head)
746746 p.Checks = checksSection(d.repo.Repo, checks)
@@ -740,7 +740,7 @@ func (s *Server) mrFacts(ctx context.Context, r *http.Request, d *repoData, p *M
740 p.Modes = append(p.Modes, name)740 p.Modes = append(p.Modes, name)
741 }741 }
742 }742 }
743- p.ChecksBlock = d.repo.Settings.FailingChecksBlock743+ p.ChecksBlock = true
744 if head != "" {744 if head != "" {
745 checks := s.checksFor(ctx, d.repo.Name, head)745 checks := s.checksFor(ctx, d.repo.Name, head)
746 p.Checks = checksSection(d.repo.Repo, checks)746 p.Checks = checksSection(d.repo.Repo, checks)
M internal/adapters/web/mr.templ +13 -1
@@ -545,6 +545,7 @@ templ mergeMenu(p MRPage) {
545545 </label>
546546 }
547547 </fieldset>
548+ @MRMergeChecks(p.ChecksSum)
548549 if p.Box.CanDeleteBranch {
549550 <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>
550551 }
@@ -634,7 +635,7 @@ templ moreMenu(p MRPage) {
634635 <dt>checks</dt>
635636 <dd>
636637 if p.ChecksBlock {
637- failing ones block the merge
638+ failing or unfinished checks block the merge
638639 } else {
639640 do not block the merge
640641 }
@@ -766,3 +767,14 @@ templ compareRail(p ComparePage) {
766767 @CloneBlock(p.CLI)
767768 </section>
768769 }
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,6 +545,7 @@ templ mergeMenu(p MRPage) {
545 </label>545 </label>
546 }546 }
547 </fieldset>547 </fieldset>
548+ @MRMergeChecks(p.ChecksSum)
548 if p.Box.CanDeleteBranch {549 if p.Box.CanDeleteBranch {
549 <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>
550 }551 }
@@ -634,7 +635,7 @@ templ moreMenu(p MRPage) {
634 <dt>checks</dt>635 <dt>checks</dt>
635 <dd>636 <dd>
636 if p.ChecksBlock {637 if p.ChecksBlock {
637- failing ones block the merge638+ failing or unfinished checks block the merge
638 } else {639 } else {
639 do not block the merge640 do not block the merge
640 }641 }
@@ -766,3 +767,14 @@ templ compareRail(p ComparePage) {
766 @CloneBlock(p.CLI)767 @CloneBlock(p.CLI)
767 </section>768 </section>
768 }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_post.go +1 -0
@@ -377,6 +377,7 @@ func (s *Server) mrMerge(ctx context.Context, r *http.Request) (string, string,
377377 }
378378 res, err := s.cfg.MergeRequests.Merge(ctx, principal(ctx), name, n, ports.MergeParams{
379379 Mode: mode, HeadID: head, DeleteSource: r.PostFormValue("delete_branch") == "1",
380+ IgnoreChecks: r.PostFormValue("ignore_checks") == "on",
380381 })
381382 switch {
382383 case errors.Is(err, domain.ErrPreconditionFailed), errors.Is(err, domain.ErrConflict):
@@ -377,6 +377,7 @@ func (s *Server) mrMerge(ctx context.Context, r *http.Request) (string, string,
377 }377 }
378 res, err := s.cfg.MergeRequests.Merge(ctx, principal(ctx), name, n, ports.MergeParams{378 res, err := s.cfg.MergeRequests.Merge(ctx, principal(ctx), name, n, ports.MergeParams{
379 Mode: mode, HeadID: head, DeleteSource: r.PostFormValue("delete_branch") == "1",379 Mode: mode, HeadID: head, DeleteSource: r.PostFormValue("delete_branch") == "1",
380+ IgnoreChecks: r.PostFormValue("ignore_checks") == "on",
380 })381 })
381 switch {382 switch {
382 case errors.Is(err, domain.ErrPreconditionFailed), errors.Is(err, domain.ErrConflict):383 case errors.Is(err, domain.ErrPreconditionFailed), errors.Is(err, domain.ErrConflict):
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
M internal/usecase/repos_write.go +4 -7
M internal/usecase/repos_write_test.go +1 -3
M proto/ermite/storage/merge_request.proto +2 -1
M skills/ermite/CLI.md +10 -4
M skills/ermite/SKILL.md +5 -3

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 42

↑↓ moveenter openesc close