detached 2ea01eb

Merge f61e6a68e9c866c042a8c5e4f64cae2b1290a363 into d5e05051ad53ec339fe415696d1b993cbc0c7948

commit
2ea01eb212ec646ef7e24d87c16382677f9d60b8
author
ermite · · 2026-10-21 16:16 UTC
merge of
d5e0505 + f61e6a6 · diff against the first parent
signature
unsigned
56 files · +645 -265
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 generated file: collapsed
M internal/adapters/web/mr_test.go +35 -1
@@ -161,7 +161,7 @@ func TestMRPanelsAndMenus(t *testing.T) {
161161 assert.NotContains(t, rail, text)
162162 }
163163 more := renderToString(t, moreMenu(p))
164- for _, text := range []string{"Merge settings", "squash, merge", "failing ones block", `id="mr-signing"`, "fingerprint", "ermite signing-key show"} {
164+ for _, text := range []string{"Merge settings", "squash, merge", "failing or unfinished checks block", `id="mr-signing"`, "fingerprint", "ermite signing-key show"} {
165165 assert.Contains(t, more, text)
166166 }
167167 merge := renderToString(t, mergeMenu(p))
@@ -297,3 +297,37 @@ func TestAutoMergeLiveEvents(t *testing.T) {
297297 assert.True(t, liveEvent(watchOf(domain.MergeRequestAutoMergeEnabled{})))
298298 assert.True(t, liveEvent(watchOf(domain.MergeRequestAutoMergeCancelled{})))
299299 }
300+
301+func TestMergeIgnoreChecks(t *testing.T) {
302+ t.Parallel()
303+ for _, kind := range []string{"bad", "running", "ok", ""} {
304+ t.Run(kind, func(t *testing.T) {
305+ t.Parallel()
306+ box := (&Server{}).mergeBox(&repoData{repo: repoWithModes(domain.MergeModes()...)}, 3, "feat", "abc", true, mergeabilityView(domain.Mergeability{State: domain.MergeabilityUpToDate}, "main"))
307+ html := renderToString(t, mergeMenu(MRPage{Box: box, ChecksSum: ChecksSummary{Kind: kind}}))
308+ if kind == "bad" || kind == "running" {
309+ assert.Contains(t, html, `name="ignore_checks"`)
310+ assert.Contains(t, html, "Ignore checks")
311+ } else {
312+ assert.NotContains(t, html, `name="ignore_checks"`)
313+ }
314+ assert.True(t, box.CanMerge())
315+ })
316+ }
317+ name, err := domain.ParseRepoName("demo")
318+ require.NoError(t, err)
319+ head, err := domain.ParseObjectID(strings.Repeat("a", domain.ObjectIDLen))
320+ require.NoError(t, err)
321+ for _, ignore := range []bool{false, true} {
322+ svc := mocks.NewMergeRequestService(t)
323+ svc.EXPECT().Merge(mock.Anything, mock.Anything, name, int64(3), ports.MergeParams{Mode: domain.MergeModeSquash, HeadID: head, IgnoreChecks: ignore}).Return(ports.MergeResult{}, nil).Once()
324+ body := "mode=squash&head_id=" + head.String()
325+ if ignore {
326+ body += "&ignore_checks=on"
327+ }
328+ s := &Server{cfg: Config{MergeRequests: svc}}
329+ to, _, err := s.mrMerge(context.Background(), autoMergeRequest("/demo/merge-requests/3/merge", body))
330+ require.NoError(t, err)
331+ assert.Equal(t, mrHref("demo", 3), to)
332+ }
333+}
@@ -161,7 +161,7 @@ func TestMRPanelsAndMenus(t *testing.T) {
161 assert.NotContains(t, rail, text)161 assert.NotContains(t, rail, text)
162 }162 }
163 more := renderToString(t, moreMenu(p))163 more := renderToString(t, moreMenu(p))
164- for _, text := range []string{"Merge settings", "squash, merge", "failing ones block", `id="mr-signing"`, "fingerprint", "ermite signing-key show"} {164+ for _, text := range []string{"Merge settings", "squash, merge", "failing or unfinished checks block", `id="mr-signing"`, "fingerprint", "ermite signing-key show"} {
165 assert.Contains(t, more, text)165 assert.Contains(t, more, text)
166 }166 }
167 merge := renderToString(t, mergeMenu(p))167 merge := renderToString(t, mergeMenu(p))
@@ -297,3 +297,37 @@ func TestAutoMergeLiveEvents(t *testing.T) {
297 assert.True(t, liveEvent(watchOf(domain.MergeRequestAutoMergeEnabled{})))297 assert.True(t, liveEvent(watchOf(domain.MergeRequestAutoMergeEnabled{})))
298 assert.True(t, liveEvent(watchOf(domain.MergeRequestAutoMergeCancelled{})))298 assert.True(t, liveEvent(watchOf(domain.MergeRequestAutoMergeCancelled{})))
299 }299 }
300+
301+func TestMergeIgnoreChecks(t *testing.T) {
302+ t.Parallel()
303+ for _, kind := range []string{"bad", "running", "ok", ""} {
304+ t.Run(kind, func(t *testing.T) {
305+ t.Parallel()
306+ box := (&Server{}).mergeBox(&repoData{repo: repoWithModes(domain.MergeModes()...)}, 3, "feat", "abc", true, mergeabilityView(domain.Mergeability{State: domain.MergeabilityUpToDate}, "main"))
307+ html := renderToString(t, mergeMenu(MRPage{Box: box, ChecksSum: ChecksSummary{Kind: kind}}))
308+ if kind == "bad" || kind == "running" {
309+ assert.Contains(t, html, `name="ignore_checks"`)
310+ assert.Contains(t, html, "Ignore checks")
311+ } else {
312+ assert.NotContains(t, html, `name="ignore_checks"`)
313+ }
314+ assert.True(t, box.CanMerge())
315+ })
316+ }
317+ name, err := domain.ParseRepoName("demo")
318+ require.NoError(t, err)
319+ head, err := domain.ParseObjectID(strings.Repeat("a", domain.ObjectIDLen))
320+ require.NoError(t, err)
321+ for _, ignore := range []bool{false, true} {
322+ svc := mocks.NewMergeRequestService(t)
323+ svc.EXPECT().Merge(mock.Anything, mock.Anything, name, int64(3), ports.MergeParams{Mode: domain.MergeModeSquash, HeadID: head, IgnoreChecks: ignore}).Return(ports.MergeResult{}, nil).Once()
324+ body := "mode=squash&head_id=" + head.String()
325+ if ignore {
326+ body += "&ignore_checks=on"
327+ }
328+ s := &Server{cfg: Config{MergeRequests: svc}}
329+ to, _, err := s.mrMerge(context.Background(), autoMergeRequest("/demo/merge-requests/3/merge", body))
330+ require.NoError(t, err)
331+ assert.Equal(t, mrHref("demo", 3), to)
332+ }
333+}
M internal/adapters/web/static/js/ermite.js +7 -0
@@ -966,6 +966,13 @@ window.ermiteLive = (() => {
966966 continue;
967967 }
968968 if (old.hasAttribute('data-cursor')) el.setAttribute('data-cursor', '');
969+ // data-keep-checks: the checkboxes the user ticked stay ticked.
970+ if (old.hasAttribute('data-keep-checks')) {
971+ for (const box of old.querySelectorAll('input[type=checkbox][name]:checked')) {
972+ const same = el.querySelector(`input[type=checkbox][name="${CSS.escape(box.name)}"]`);
973+ if (same) same.checked = true;
974+ }
975+ }
969976 old.replaceWith(el);
970977 const rows = document.getElementById('repo-rows');
971978 if (rows && el.parentElement === rows && rows.dataset.sort === 'activity' &&
@@ -966,6 +966,13 @@ window.ermiteLive = (() => {
966 continue;966 continue;
967 }967 }
968 if (old.hasAttribute('data-cursor')) el.setAttribute('data-cursor', '');968 if (old.hasAttribute('data-cursor')) el.setAttribute('data-cursor', '');
969+ // data-keep-checks: the checkboxes the user ticked stay ticked.
970+ if (old.hasAttribute('data-keep-checks')) {
971+ for (const box of old.querySelectorAll('input[type=checkbox][name]:checked')) {
972+ const same = el.querySelector(`input[type=checkbox][name="${CSS.escape(box.name)}"]`);
973+ if (same) same.checked = true;
974+ }
975+ }
969 old.replaceWith(el);976 old.replaceWith(el);
970 const rows = document.getElementById('repo-rows');977 const rows = document.getElementById('repo-rows');
971 if (rows && el.parentElement === rows && rows.dataset.sort === 'activity' &&978 if (rows && el.parentElement === rows && rows.dataset.sort === 'activity' &&
M internal/adapters/web/summary_actions_test.go +53 -0
@@ -4,6 +4,7 @@ package web
44
55 import (
66 "bytes"
7+ "context"
78 "errors"
89 "strings"
910 "testing"
@@ -125,3 +126,55 @@ func TestRunUpdatedOnSummaryWhenCIIsNotReadable(t *testing.T) {
125126 assert.Contains(t, got[0].html, "hidden", "the block empties and hides, no run leaks")
126127 assert.NotContains(t, got[0].html, "rr-name")
127128 }
129+
130+func TestMRChecksLiveMergeControls(t *testing.T) {
131+ t.Parallel()
132+ repo := repoName(t, "proj")
133+ head := strings.Repeat("a", domain.ObjectIDLen)
134+ for _, kind := range []string{"bad", "running", "ok", ""} {
135+ t.Run(kind, func(t *testing.T) {
136+ t.Parallel()
137+ runs := mocks.NewRunService(t)
138+ var checks []ports.CheckView
139+ if kind != "" {
140+ check := domain.CICheck{Name: "ci", Status: domain.CICompleted, Conclusion: domain.CISuccess}
141+ if kind == "bad" {
142+ check.Conclusion = domain.CIFailure
143+ }
144+ if kind == "running" {
145+ check.Status, check.Conclusion = domain.CIQueued, ""
146+ }
147+ checks = []ports.CheckView{{CICheck: check}}
148+ }
149+ runs.EXPECT().ListChecks(mock.Anything, mock.Anything, repo, head).Return(ports.ChecksResult{Checks: checks}, nil)
150+ srv := newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "proj")), Browse: mocks.NewBrowseService(t), Runs: runs})
151+
152+ // A visitor's stream never carries the owner's merge form.
153+ visitor, err := (&liveStream{s: srv, repo: "proj", head: head, view: "mr"}).mrChecks(t.Context())
154+ require.NoError(t, err)
155+ require.Len(t, visitor, 2)
156+ for _, f := range visitor {
157+ assert.NotContains(t, f.html, `mr-merge-checks`)
158+ }
159+
160+ owner := context.WithValue(t.Context(), signedInKey{}, struct{}{})
161+ ls := &liveStream{s: srv, repo: "proj", head: head, view: "mr"}
162+ got, err := ls.mrChecks(owner)
163+ require.NoError(t, err)
164+ require.Len(t, got, 3)
165+ assert.Contains(t, got[2].html, `data-keep-checks`, "a swap keeps the ticked box")
166+ assert.Equal(t, "ci-run", got[2].event)
167+ assert.Contains(t, got[2].html, `id="mr-merge-checks"`)
168+ assert.Equal(t, renderToString(t, MRMergeChecks(checksSummary(repo, head, checks))), got[2].html)
169+ if kind == "bad" || kind == "running" {
170+ assert.Contains(t, got[2].html, `name="ignore_checks"`)
171+ } else {
172+ assert.NotContains(t, got[2].html, `name="ignore_checks"`)
173+ }
174+ // An unrelated job event with identical checks must not reset the checkbox.
175+ got, err = ls.mrChecks(owner)
176+ require.NoError(t, err)
177+ require.Len(t, got, 2)
178+ })
179+ }
180+}
@@ -4,6 +4,7 @@ package web
4 4
5 import (5 import (
6 "bytes"6 "bytes"
7+ "context"
7 "errors"8 "errors"
8 "strings"9 "strings"
9 "testing"10 "testing"
@@ -125,3 +126,55 @@ func TestRunUpdatedOnSummaryWhenCIIsNotReadable(t *testing.T) {
125 assert.Contains(t, got[0].html, "hidden", "the block empties and hides, no run leaks")126 assert.Contains(t, got[0].html, "hidden", "the block empties and hides, no run leaks")
126 assert.NotContains(t, got[0].html, "rr-name")127 assert.NotContains(t, got[0].html, "rr-name")
127 }128 }
129+
130+func TestMRChecksLiveMergeControls(t *testing.T) {
131+ t.Parallel()
132+ repo := repoName(t, "proj")
133+ head := strings.Repeat("a", domain.ObjectIDLen)
134+ for _, kind := range []string{"bad", "running", "ok", ""} {
135+ t.Run(kind, func(t *testing.T) {
136+ t.Parallel()
137+ runs := mocks.NewRunService(t)
138+ var checks []ports.CheckView
139+ if kind != "" {
140+ check := domain.CICheck{Name: "ci", Status: domain.CICompleted, Conclusion: domain.CISuccess}
141+ if kind == "bad" {
142+ check.Conclusion = domain.CIFailure
143+ }
144+ if kind == "running" {
145+ check.Status, check.Conclusion = domain.CIQueued, ""
146+ }
147+ checks = []ports.CheckView{{CICheck: check}}
148+ }
149+ runs.EXPECT().ListChecks(mock.Anything, mock.Anything, repo, head).Return(ports.ChecksResult{Checks: checks}, nil)
150+ srv := newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "proj")), Browse: mocks.NewBrowseService(t), Runs: runs})
151+
152+ // A visitor's stream never carries the owner's merge form.
153+ visitor, err := (&liveStream{s: srv, repo: "proj", head: head, view: "mr"}).mrChecks(t.Context())
154+ require.NoError(t, err)
155+ require.Len(t, visitor, 2)
156+ for _, f := range visitor {
157+ assert.NotContains(t, f.html, `mr-merge-checks`)
158+ }
159+
160+ owner := context.WithValue(t.Context(), signedInKey{}, struct{}{})
161+ ls := &liveStream{s: srv, repo: "proj", head: head, view: "mr"}
162+ got, err := ls.mrChecks(owner)
163+ require.NoError(t, err)
164+ require.Len(t, got, 3)
165+ assert.Contains(t, got[2].html, `data-keep-checks`, "a swap keeps the ticked box")
166+ assert.Equal(t, "ci-run", got[2].event)
167+ assert.Contains(t, got[2].html, `id="mr-merge-checks"`)
168+ assert.Equal(t, renderToString(t, MRMergeChecks(checksSummary(repo, head, checks))), got[2].html)
169+ if kind == "bad" || kind == "running" {
170+ assert.Contains(t, got[2].html, `name="ignore_checks"`)
171+ } else {
172+ assert.NotContains(t, got[2].html, `name="ignore_checks"`)
173+ }
174+ // An unrelated job event with identical checks must not reset the checkbox.
175+ got, err = ls.mrChecks(owner)
176+ require.NoError(t, err)
177+ require.Len(t, got, 2)
178+ })
179+ }
180+}
M internal/app/api_test.go +1 -1
@@ -196,7 +196,7 @@ func TestAC2ReadAccess(t *testing.T) {
196196 for _, who := range allPrincipals {
197197 resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{}))
198198 require.NoError(t, err, who)
199- assert.ElementsMatch(t, []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.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures", "mirror", "merge_requests.auto_merge"}, resp.Msg.GetFeatures(), who)
199+ assert.ElementsMatch(t, []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.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures", "mirror", "merge_requests.auto_merge", "merge_requests.ignore_checks", "merge_requests.ready"}, resp.Msg.GetFeatures(), who)
200200 assert.NotEmpty(t, resp.Msg.GetVersion())
201201 }
202202 })
@@ -196,7 +196,7 @@ func TestAC2ReadAccess(t *testing.T) {
196 for _, who := range allPrincipals {196 for _, who := range allPrincipals {
197 resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{}))197 resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{}))
198 require.NoError(t, err, who)198 require.NoError(t, err, who)
199- assert.ElementsMatch(t, []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.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures", "mirror", "merge_requests.auto_merge"}, resp.Msg.GetFeatures(), who)199+ assert.ElementsMatch(t, []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.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures", "mirror", "merge_requests.auto_merge", "merge_requests.ignore_checks", "merge_requests.ready"}, resp.Msg.GetFeatures(), who)
200 assert.NotEmpty(t, resp.Msg.GetVersion())200 assert.NotEmpty(t, resp.Msg.GetVersion())
201 }201 }
202 })202 })
M internal/app/ci_e2e_test.go +3 -9
@@ -13,7 +13,6 @@ import (
1313 "connectrpc.com/connect"
1414 "github.com/stretchr/testify/assert"
1515 "github.com/stretchr/testify/require"
16- "google.golang.org/protobuf/types/known/fieldmaskpb"
1716 "google.golang.org/protobuf/types/known/timestamppb"
1817
1918 pb "bnema.dev/ermite-api/gen/ermite"
@@ -271,7 +270,7 @@ func TestCIPushRunsAWorkflowOnARunner(t *testing.T) {
271270 }
272271
273272 // AC5: merge refused while checks are pending or failing, allowed when they
274-// pass, and when failing_checks_block is off.
273+// pass, or when explicitly bypassed by an authorized caller.
275274 func TestCIMergeIsBlockedByChecks(t *testing.T) {
276275 c := newCIHarness(t, nil)
277276 runner := c.registerRunner("alpha", "ubuntu-latest")
@@ -302,14 +301,9 @@ func TestCIMergeIsBlockedByChecks(t *testing.T) {
302301 assert.Equal(t, connect.CodeFailedPrecondition, ciErrCode(err))
303302 assert.Contains(t, err.Error(), "failing checks block the merge")
304303
305- // Off: the merge goes through.
306- _, err = c.root.repo.UpdateRepo(c.ctx, connect.NewRequest(&pb.UpdateRepoRequest{
307- Repo: "proj",
308- Settings: &pb.RepoSettings{FailingChecksBlock: false},
309- UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"settings.failing_checks_block"}},
310- }))
304+ // An explicit override by root allows the merge despite failure.
305+ _, err = c.root.mr.MergeMergeRequest(c.ctx, connect.NewRequest(&pb.MergeMergeRequestRequest{Repo: "proj", Number: mr.Msg.GetMergeRequest().GetNumber(), HeadId: feat, IgnoreChecks: true}))
311306 require.NoError(t, err)
312- require.NoError(t, merge())
313307 }
314308
315309 // AC2: a job token works for its job only, and not once the job has ended.
@@ -13,7 +13,6 @@ import (
13 "connectrpc.com/connect"13 "connectrpc.com/connect"
14 "github.com/stretchr/testify/assert"14 "github.com/stretchr/testify/assert"
15 "github.com/stretchr/testify/require"15 "github.com/stretchr/testify/require"
16- "google.golang.org/protobuf/types/known/fieldmaskpb"
17 "google.golang.org/protobuf/types/known/timestamppb"16 "google.golang.org/protobuf/types/known/timestamppb"
18 17
19 pb "bnema.dev/ermite-api/gen/ermite"18 pb "bnema.dev/ermite-api/gen/ermite"
@@ -271,7 +270,7 @@ func TestCIPushRunsAWorkflowOnARunner(t *testing.T) {
271 }270 }
272 271
273 // AC5: merge refused while checks are pending or failing, allowed when they272 // AC5: merge refused while checks are pending or failing, allowed when they
274-// pass, and when failing_checks_block is off.273+// pass, or when explicitly bypassed by an authorized caller.
275 func TestCIMergeIsBlockedByChecks(t *testing.T) {274 func TestCIMergeIsBlockedByChecks(t *testing.T) {
276 c := newCIHarness(t, nil)275 c := newCIHarness(t, nil)
277 runner := c.registerRunner("alpha", "ubuntu-latest")276 runner := c.registerRunner("alpha", "ubuntu-latest")
@@ -302,14 +301,9 @@ func TestCIMergeIsBlockedByChecks(t *testing.T) {
302 assert.Equal(t, connect.CodeFailedPrecondition, ciErrCode(err))301 assert.Equal(t, connect.CodeFailedPrecondition, ciErrCode(err))
303 assert.Contains(t, err.Error(), "failing checks block the merge")302 assert.Contains(t, err.Error(), "failing checks block the merge")
304 303
305- // Off: the merge goes through.304+ // An explicit override by root allows the merge despite failure.
306- _, err = c.root.repo.UpdateRepo(c.ctx, connect.NewRequest(&pb.UpdateRepoRequest{305+ _, err = c.root.mr.MergeMergeRequest(c.ctx, connect.NewRequest(&pb.MergeMergeRequestRequest{Repo: "proj", Number: mr.Msg.GetMergeRequest().GetNumber(), HeadId: feat, IgnoreChecks: true}))
307- Repo: "proj",
308- Settings: &pb.RepoSettings{FailingChecksBlock: false},
309- UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"settings.failing_checks_block"}},
310- }))
311 require.NoError(t, err)306 require.NoError(t, err)
312- require.NoError(t, merge())
313 }307 }
314 308
315 // AC2: a job token works for its job only, and not once the job has ended.309 // AC2: a job token works for its job only, and not once the job has ended.
M internal/app/cli_e2e_test.go +2 -2
@@ -203,7 +203,7 @@ type eventOut struct {
203203 MergeRequest *mrOut `json:"merge_request"`
204204 }
205205
206-var repoKeys = []string{"allow_auto_merge", "created_at", "default_branch", "description", "failing_checks_block", "issues", "merge_modes", "name", "pushed_at", "topics", "visibility"}
206+var repoKeys = []string{"allow_auto_merge", "created_at", "default_branch", "description", "issues", "merge_modes", "name", "pushed_at", "topics", "visibility"}
207207
208208 // AC6: the CLI end to end against a real forge.
209209 func TestAC6CLIEndToEnd(t *testing.T) {
@@ -350,7 +350,7 @@ func TestAC6CLIEndToEnd(t *testing.T) {
350350 ver := decode[versionOut](t, res.stdout)
351351 assert.Equal(t, c.url, ver.Server.URL)
352352 assert.NotEmpty(t, ver.Server.Version)
353- assert.ElementsMatch(t, []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.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures", "mirror", "merge_requests.auto_merge"}, ver.Server.Features)
353+ assert.ElementsMatch(t, []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.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures", "mirror", "merge_requests.auto_merge", "merge_requests.ignore_checks", "merge_requests.ready"}, ver.Server.Features)
354354 assert.Equal(t, []string{"passkeys"}, ver.Server.MissingFeatures)
355355 stdout, stderr, code = c.bin(t, secret, "version")
356356 require.Equal(t, 0, code, stderr)
@@ -203,7 +203,7 @@ type eventOut struct {
203 MergeRequest *mrOut `json:"merge_request"`203 MergeRequest *mrOut `json:"merge_request"`
204 }204 }
205 205
206-var repoKeys = []string{"allow_auto_merge", "created_at", "default_branch", "description", "failing_checks_block", "issues", "merge_modes", "name", "pushed_at", "topics", "visibility"}206+var repoKeys = []string{"allow_auto_merge", "created_at", "default_branch", "description", "issues", "merge_modes", "name", "pushed_at", "topics", "visibility"}
207 207
208 // AC6: the CLI end to end against a real forge.208 // AC6: the CLI end to end against a real forge.
209 func TestAC6CLIEndToEnd(t *testing.T) {209 func TestAC6CLIEndToEnd(t *testing.T) {
@@ -350,7 +350,7 @@ func TestAC6CLIEndToEnd(t *testing.T) {
350 ver := decode[versionOut](t, res.stdout)350 ver := decode[versionOut](t, res.stdout)
351 assert.Equal(t, c.url, ver.Server.URL)351 assert.Equal(t, c.url, ver.Server.URL)
352 assert.NotEmpty(t, ver.Server.Version)352 assert.NotEmpty(t, ver.Server.Version)
353- assert.ElementsMatch(t, []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.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures", "mirror", "merge_requests.auto_merge"}, ver.Server.Features)353+ assert.ElementsMatch(t, []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.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures", "mirror", "merge_requests.auto_merge", "merge_requests.ignore_checks", "merge_requests.ready"}, ver.Server.Features)
354 assert.Equal(t, []string{"passkeys"}, ver.Server.MissingFeatures)354 assert.Equal(t, []string{"passkeys"}, ver.Server.MissingFeatures)
355 stdout, stderr, code = c.bin(t, secret, "version")355 stdout, stderr, code = c.bin(t, secret, "version")
356 require.Equal(t, 0, code, stderr)356 require.Equal(t, 0, code, stderr)
M internal/app/cli_issue_test.go +1 -1
@@ -202,7 +202,7 @@ func TestRepoShowIssues(t *testing.T) {
202202 }}
203203 url := f.serve(t)
204204 res := runCLI(t, envFor(url), "", "repo", "show", "alpha").ok(t)
205- assert.Contains(t, res.stdout, "issues owner\n")
205+ assert.Contains(t, res.stdout, "issues owner\n")
206206 res = runCLI(t, envFor(url), "", "repo", "show", "alpha", "--json").ok(t)
207207 assert.Contains(t, res.stdout, `"issues": "owner"`)
208208 }
@@ -202,7 +202,7 @@ func TestRepoShowIssues(t *testing.T) {
202 }}202 }}
203 url := f.serve(t)203 url := f.serve(t)
204 res := runCLI(t, envFor(url), "", "repo", "show", "alpha").ok(t)204 res := runCLI(t, envFor(url), "", "repo", "show", "alpha").ok(t)
205- assert.Contains(t, res.stdout, "issues owner\n")205+ assert.Contains(t, res.stdout, "issues owner\n")
206 res = runCLI(t, envFor(url), "", "repo", "show", "alpha", "--json").ok(t)206 res = runCLI(t, envFor(url), "", "repo", "show", "alpha", "--json").ok(t)
207 assert.Contains(t, res.stdout, `"issues": "owner"`)207 assert.Contains(t, res.stdout, `"issues": "owner"`)
208 }208 }
M internal/app/cli_mr.go +45 -3
@@ -291,7 +291,7 @@ func newMRCmd(env *cliEnv) *cobra.Command {
291291 }
292292 mr.AddCommand(
293293 newMRCreateCmd(env), newMRListCmd(env), newMRShowCmd(env), newMRCommentCmd(env),
294- newMRResolveCmd(env), newMREditCommentCmd(env), newMRDeleteCommentCmd(env), newMRMergeCmd(env), newMRCloseCmd(env),
294+ newMRResolveCmd(env), newMREditCommentCmd(env), newMRDeleteCommentCmd(env), newMRMergeCmd(env), newMRCloseCmd(env), newMRReadyCmd(env),
295295 )
296296 return mr
297297 }
@@ -1085,7 +1085,7 @@ func newMRMergeCmd(env *cliEnv) *cobra.Command {
10851085 var mode string
10861086 var deleteBranch bool
10871087 var head string
1088- var auto, cancelAuto bool
1088+ var auto, cancelAuto, ignoreChecks bool
10891089 cmd := &cobra.Command{
10901090 Use: "merge <repo> <number>",
10911091 Short: "Merge a merge request",
@@ -1105,6 +1105,9 @@ func newMRMergeCmd(env *cliEnv) *cobra.Command {
11051105 "required: give the full id of the head that `ermite mr show <repo> <number>` printed when you\n" +
11061106 "reviewed the change. If the branch moved since, the merge is refused: review again. Needs the\n" +
11071107 "mr:merge scope.\n\n" +
1108+ "A merge needs the checks on the head to pass: none failed, none queued or running. A head\n" +
1109+ "without checks can be merged. --ignore-checks merges anyway and needs the mr:force-merge scope\n" +
1110+ "(the cli preset has it, the agent preset does not).\n\n" +
11081111 "The merge answers as soon as the target branch has moved. --delete-branch asks the server to delete\n" +
11091112 "the source branch right after, in the background. The merge is refused when the branch is the\n" +
11101113 "default branch or used by another open merge request. If the deletion later fails (the branch\n" +
@@ -1123,6 +1126,9 @@ func newMRMergeCmd(env *cliEnv) *cobra.Command {
11231126 cmd.Flags().BoolVar(&deleteBranch, "delete-branch", false, "delete the source branch after the merge")
11241127 cmd.Flags().BoolVar(&auto, "auto", false, "merge by itself once the checks passed (needs `repo edit --allow-auto-merge`)")
11251128 cmd.Flags().BoolVar(&cancelAuto, "cancel-auto", false, "cancel the pending auto-merge")
1129+ cmd.Flags().BoolVar(&ignoreChecks, "ignore-checks", false, "merge despite failing or unfinished checks (needs mr:force-merge)")
1130+ cmd.MarkFlagsMutuallyExclusive("ignore-checks", "auto")
1131+ cmd.MarkFlagsMutuallyExclusive("ignore-checks", "cancel-auto")
11261132 cmd.MarkFlagsMutuallyExclusive("auto", "cancel-auto")
11271133 cmd.MarkFlagsMutuallyExclusive("cancel-auto", "delete-branch")
11281134 cmd.MarkFlagsMutuallyExclusive("cancel-auto", "head")
@@ -1156,13 +1162,18 @@ func newMRMergeCmd(env *cliEnv) *cobra.Command {
11561162 return err
11571163 }
11581164 ctx := env.ctx(cmd)
1165+ if ignoreChecks {
1166+ if err := requireFeature(ctx, c, client.FeatureIgnoreChecks, "cannot bypass checks (--ignore-checks)"); err != nil {
1167+ return err
1168+ }
1169+ }
11591170 if pbMode == ermite.MergeMode_MERGE_MODE_MERGE_COMMIT {
11601171 if err := requireFeature(ctx, c, client.FeatureMergeability, "has no merge commit mode (--mode merge)"); err != nil {
11611172 return err
11621173 }
11631174 }
11641175 res, err := c.MergeRequests().MergeMergeRequest(ctx, connect.NewRequest(&ermite.MergeMergeRequestRequest{
1165- Repo: args[0], Number: n, Mode: pbMode, HeadId: head, DeleteSourceBranch: deleteBranch,
1176+ Repo: args[0], Number: n, Mode: pbMode, HeadId: head, DeleteSourceBranch: deleteBranch, IgnoreChecks: ignoreChecks,
11661177 }))
11671178 if err != nil {
11681179 return c.Describe(err)
@@ -1282,3 +1293,34 @@ func newMRCloseCmd(env *cliEnv) *cobra.Command {
12821293 }
12831294 return cmd
12841295 }
1296+
1297+func newMRReadyCmd(env *cliEnv) *cobra.Command {
1298+ cmd := &cobra.Command{
1299+ Use: "ready <repo> <number>",
1300+ Short: "Mark a draft merge request ready for review",
1301+ Long: "Remove the WIP: or [WIP] prefix from the title so the merge request can be merged.\nFails if it is not open or not a draft. Needs the mr:write scope.\nPrints a confirmation, then the full web URL on its own line." + jsonMRHelp,
1302+ Args: cobra.ExactArgs(2),
1303+ ValidArgsFunction: completeMRRepoThenNumber(env),
1304+ }
1305+ asJSON := addJSONFlag(cmd)
1306+ cmd.RunE = func(cmd *cobra.Command, args []string) error {
1307+ n, err := parseNumber(args[1])
1308+ if err != nil {
1309+ return err
1310+ }
1311+ c, err := env.client(cmd)
1312+ if err != nil {
1313+ return err
1314+ }
1315+ ctx := env.ctx(cmd)
1316+ if err := requireFeature(ctx, c, client.FeatureMarkReady, "cannot mark drafts ready (mr ready)"); err != nil {
1317+ return err
1318+ }
1319+ res, err := c.MergeRequests().MarkReady(ctx, connect.NewRequest(&ermite.MarkReadyRequest{Repo: args[0], Number: n}))
1320+ if err != nil {
1321+ return c.Describe(err)
1322+ }
1323+ return outputMR(cmd, res.Msg.GetMergeRequest(), *asJSON, "marked ready", c.URL())
1324+ }
1325+ return cmd
1326+}
@@ -291,7 +291,7 @@ func newMRCmd(env *cliEnv) *cobra.Command {
291 }291 }
292 mr.AddCommand(292 mr.AddCommand(
293 newMRCreateCmd(env), newMRListCmd(env), newMRShowCmd(env), newMRCommentCmd(env),293 newMRCreateCmd(env), newMRListCmd(env), newMRShowCmd(env), newMRCommentCmd(env),
294- newMRResolveCmd(env), newMREditCommentCmd(env), newMRDeleteCommentCmd(env), newMRMergeCmd(env), newMRCloseCmd(env),294+ newMRResolveCmd(env), newMREditCommentCmd(env), newMRDeleteCommentCmd(env), newMRMergeCmd(env), newMRCloseCmd(env), newMRReadyCmd(env),
295 )295 )
296 return mr296 return mr
297 }297 }
@@ -1085,7 +1085,7 @@ func newMRMergeCmd(env *cliEnv) *cobra.Command {
1085 var mode string1085 var mode string
1086 var deleteBranch bool1086 var deleteBranch bool
1087 var head string1087 var head string
1088- var auto, cancelAuto bool1088+ var auto, cancelAuto, ignoreChecks bool
1089 cmd := &cobra.Command{1089 cmd := &cobra.Command{
1090 Use: "merge <repo> <number>",1090 Use: "merge <repo> <number>",
1091 Short: "Merge a merge request",1091 Short: "Merge a merge request",
@@ -1105,6 +1105,9 @@ func newMRMergeCmd(env *cliEnv) *cobra.Command {
1105 "required: give the full id of the head that `ermite mr show <repo> <number>` printed when you\n" +1105 "required: give the full id of the head that `ermite mr show <repo> <number>` printed when you\n" +
1106 "reviewed the change. If the branch moved since, the merge is refused: review again. Needs the\n" +1106 "reviewed the change. If the branch moved since, the merge is refused: review again. Needs the\n" +
1107 "mr:merge scope.\n\n" +1107 "mr:merge scope.\n\n" +
1108+ "A merge needs the checks on the head to pass: none failed, none queued or running. A head\n" +
1109+ "without checks can be merged. --ignore-checks merges anyway and needs the mr:force-merge scope\n" +
1110+ "(the cli preset has it, the agent preset does not).\n\n" +
1108 "The merge answers as soon as the target branch has moved. --delete-branch asks the server to delete\n" +1111 "The merge answers as soon as the target branch has moved. --delete-branch asks the server to delete\n" +
1109 "the source branch right after, in the background. The merge is refused when the branch is the\n" +1112 "the source branch right after, in the background. The merge is refused when the branch is the\n" +
1110 "default branch or used by another open merge request. If the deletion later fails (the branch\n" +1113 "default branch or used by another open merge request. If the deletion later fails (the branch\n" +
@@ -1123,6 +1126,9 @@ func newMRMergeCmd(env *cliEnv) *cobra.Command {
1123 cmd.Flags().BoolVar(&deleteBranch, "delete-branch", false, "delete the source branch after the merge")1126 cmd.Flags().BoolVar(&deleteBranch, "delete-branch", false, "delete the source branch after the merge")
1124 cmd.Flags().BoolVar(&auto, "auto", false, "merge by itself once the checks passed (needs `repo edit --allow-auto-merge`)")1127 cmd.Flags().BoolVar(&auto, "auto", false, "merge by itself once the checks passed (needs `repo edit --allow-auto-merge`)")
1125 cmd.Flags().BoolVar(&cancelAuto, "cancel-auto", false, "cancel the pending auto-merge")1128 cmd.Flags().BoolVar(&cancelAuto, "cancel-auto", false, "cancel the pending auto-merge")
1129+ cmd.Flags().BoolVar(&ignoreChecks, "ignore-checks", false, "merge despite failing or unfinished checks (needs mr:force-merge)")
1130+ cmd.MarkFlagsMutuallyExclusive("ignore-checks", "auto")
1131+ cmd.MarkFlagsMutuallyExclusive("ignore-checks", "cancel-auto")
1126 cmd.MarkFlagsMutuallyExclusive("auto", "cancel-auto")1132 cmd.MarkFlagsMutuallyExclusive("auto", "cancel-auto")
1127 cmd.MarkFlagsMutuallyExclusive("cancel-auto", "delete-branch")1133 cmd.MarkFlagsMutuallyExclusive("cancel-auto", "delete-branch")
1128 cmd.MarkFlagsMutuallyExclusive("cancel-auto", "head")1134 cmd.MarkFlagsMutuallyExclusive("cancel-auto", "head")
@@ -1156,13 +1162,18 @@ func newMRMergeCmd(env *cliEnv) *cobra.Command {
1156 return err1162 return err
1157 }1163 }
1158 ctx := env.ctx(cmd)1164 ctx := env.ctx(cmd)
1165+ if ignoreChecks {
1166+ if err := requireFeature(ctx, c, client.FeatureIgnoreChecks, "cannot bypass checks (--ignore-checks)"); err != nil {
1167+ return err
1168+ }
1169+ }
1159 if pbMode == ermite.MergeMode_MERGE_MODE_MERGE_COMMIT {1170 if pbMode == ermite.MergeMode_MERGE_MODE_MERGE_COMMIT {
1160 if err := requireFeature(ctx, c, client.FeatureMergeability, "has no merge commit mode (--mode merge)"); err != nil {1171 if err := requireFeature(ctx, c, client.FeatureMergeability, "has no merge commit mode (--mode merge)"); err != nil {
1161 return err1172 return err
1162 }1173 }
1163 }1174 }
1164 res, err := c.MergeRequests().MergeMergeRequest(ctx, connect.NewRequest(&ermite.MergeMergeRequestRequest{1175 res, err := c.MergeRequests().MergeMergeRequest(ctx, connect.NewRequest(&ermite.MergeMergeRequestRequest{
1165- Repo: args[0], Number: n, Mode: pbMode, HeadId: head, DeleteSourceBranch: deleteBranch,1176+ Repo: args[0], Number: n, Mode: pbMode, HeadId: head, DeleteSourceBranch: deleteBranch, IgnoreChecks: ignoreChecks,
1166 }))1177 }))
1167 if err != nil {1178 if err != nil {
1168 return c.Describe(err)1179 return c.Describe(err)
@@ -1282,3 +1293,34 @@ func newMRCloseCmd(env *cliEnv) *cobra.Command {
1282 }1293 }
1283 return cmd1294 return cmd
1284 }1295 }
1296+
1297+func newMRReadyCmd(env *cliEnv) *cobra.Command {
1298+ cmd := &cobra.Command{
1299+ Use: "ready <repo> <number>",
1300+ Short: "Mark a draft merge request ready for review",
1301+ Long: "Remove the WIP: or [WIP] prefix from the title so the merge request can be merged.\nFails if it is not open or not a draft. Needs the mr:write scope.\nPrints a confirmation, then the full web URL on its own line." + jsonMRHelp,
1302+ Args: cobra.ExactArgs(2),
1303+ ValidArgsFunction: completeMRRepoThenNumber(env),
1304+ }
1305+ asJSON := addJSONFlag(cmd)
1306+ cmd.RunE = func(cmd *cobra.Command, args []string) error {
1307+ n, err := parseNumber(args[1])
1308+ if err != nil {
1309+ return err
1310+ }
1311+ c, err := env.client(cmd)
1312+ if err != nil {
1313+ return err
1314+ }
1315+ ctx := env.ctx(cmd)
1316+ if err := requireFeature(ctx, c, client.FeatureMarkReady, "cannot mark drafts ready (mr ready)"); err != nil {
1317+ return err
1318+ }
1319+ res, err := c.MergeRequests().MarkReady(ctx, connect.NewRequest(&ermite.MarkReadyRequest{Repo: args[0], Number: n}))
1320+ if err != nil {
1321+ return c.Describe(err)
1322+ }
1323+ return outputMR(cmd, res.Msg.GetMergeRequest(), *asJSON, "marked ready", c.URL())
1324+ }
1325+ return cmd
1326+}
M internal/app/cli_mr_e2e_test.go +38 -14
@@ -382,29 +382,17 @@ func TestRepoEditMergeSettingsCLI(t *testing.T) {
382382 c.seed("proj", domain.VisibilityPublic)
383383 res := c.cli(t, adminToken, "repo", "show", "proj", "--json").ok(t)
384384 repo := decode[struct {
385- MergeModes []string `json:"merge_modes"`
386- FailingChecksBlock bool `json:"failing_checks_block"`
385+ MergeModes []string `json:"merge_modes"`
387386 }](t, res.stdout)
388387 assert.Equal(t, []string{"fast-forward", "squash", "merge"}, repo.MergeModes)
389- assert.True(t, repo.FailingChecksBlock)
390388
391389 res = c.cli(t, adminToken, "repo", "edit", "proj", "--merge-modes", "squash", "--json").ok(t)
392390 repo = decode[struct {
393- MergeModes []string `json:"merge_modes"`
394- FailingChecksBlock bool `json:"failing_checks_block"`
391+ MergeModes []string `json:"merge_modes"`
395392 }](t, res.stdout)
396393 assert.Equal(t, []string{"squash"}, repo.MergeModes)
397- assert.True(t, repo.FailingChecksBlock, "untouched")
398- res = c.cli(t, adminToken, "repo", "edit", "proj", "--failing-checks-block=false", "--json").ok(t)
399- repo = decode[struct {
400- MergeModes []string `json:"merge_modes"`
401- FailingChecksBlock bool `json:"failing_checks_block"`
402- }](t, res.stdout)
403- assert.Equal(t, []string{"squash"}, repo.MergeModes, "untouched")
404- assert.False(t, repo.FailingChecksBlock)
405394 res = c.cli(t, adminToken, "repo", "show", "proj").ok(t)
406395 assert.Regexp(t, `(?m)^merge modes\s+squash$`, res.stdout)
407- assert.Regexp(t, `(?m)^failing checks block\s+false$`, res.stdout)
408396
409397 require.Error(t, c.cli(t, adminToken, "repo", "edit", "proj", "--merge-modes", "rebase").err)
410398 require.Error(t, c.cli(t, adminToken, "repo", "edit", "proj", "--merge-modes", "").err)
@@ -497,3 +485,39 @@ func TestMergeRequestNotesCLIEndToEnd(t *testing.T) {
497485 require.Error(t, c.cli(t, adminToken, "mr", "edit-comment", "notes", "1", root.ID, "-m", "late").err)
498486 require.Error(t, c.cli(t, adminToken, "mr", "delete-comment", "notes", "1", root.ID).err)
499487 }
488+
489+func TestMRReadyAndIgnoreChecksCLIEndToEnd(t *testing.T) {
490+ c := newCLIHarness(t)
491+ secret := strings.TrimSpace(c.cli(t, adminToken, "token", "create", "daily", "--preset", "cli").ok(t).stdout)
492+ agent := strings.TrimSpace(c.cli(t, adminToken, "token", "create", "agent", "--preset", "agent", "--scope", "mr:merge").ok(t).stdout)
493+ c.cli(t, secret, "repo", "create", "proj", "--visibility", "public").ok(t)
494+ wt := c.workTree("main", "README.md")
495+ url := c.urlAuth(secret, "/proj.git")
496+ c.mustGit(wt, "push", "-q", url, "main")
497+ c.mustGit(wt, "checkout", "-q", "-b", "feat/ready")
498+ c.writeFile(wt, "README.md", []byte("ready\n"))
499+ c.mustGit(wt, "push", "-q", url, "feat/ready")
500+ head := c.bareGit("proj", "rev-parse", "feat/ready")
501+ c.cli(t, agent, "mr", "create", "proj", "--source", "feat/ready", "--title", "WIP: Add feature").ok(t)
502+ ready := c.cli(t, agent, "mr", "ready", "proj", "1", "--json").ok(t)
503+ assert.Equal(t, mrKeys, keys(t, ready.stdout))
504+ assert.Equal(t, "Add feature", decode[mrOut](t, ready.stdout).Title)
505+ res := c.cli(t, agent, "mr", "ready", "proj", "1")
506+ require.Error(t, res.err)
507+ assert.Contains(t, res.err.Error(), "not a draft")
508+ res = c.cli(t, secret, "mr", "merge", "proj", "1", "--head", head, "--ignore-checks", "--auto")
509+ require.Error(t, res.err)
510+ assert.Contains(t, res.err.Error(), "ignore-checks")
511+ res = c.cli(t, agent, "mr", "merge", "proj", "1", "--head", head, "--ignore-checks")
512+ require.Error(t, res.err)
513+ assert.Contains(t, res.err.Error(), "mr:force-merge")
514+ c.cli(t, secret, "mr", "merge", "proj", "1", "--head", head, "--ignore-checks").ok(t)
515+ c.mustGit(wt, "checkout", "-q", "-b", "feat/second")
516+ c.writeFile(wt, "README.md", []byte("second\n"))
517+ c.mustGit(wt, "push", "-q", url, "feat/second")
518+ head = c.bareGit("proj", "rev-parse", "feat/second")
519+ c.cli(t, agent, "mr", "create", "proj", "--source", "feat/second", "--title", "[WIP] Second").ok(t)
520+ res = c.cli(t, agent, "mr", "ready", "proj", "2").ok(t)
521+ assert.Contains(t, res.stdout, "marked ready proj!2 (feat/second into main)\n"+c.url+"/proj/merge-requests/2\n")
522+ c.cli(t, secret, "mr", "merge", "proj", "2", "--head", head).ok(t)
523+}
@@ -382,29 +382,17 @@ func TestRepoEditMergeSettingsCLI(t *testing.T) {
382 c.seed("proj", domain.VisibilityPublic)382 c.seed("proj", domain.VisibilityPublic)
383 res := c.cli(t, adminToken, "repo", "show", "proj", "--json").ok(t)383 res := c.cli(t, adminToken, "repo", "show", "proj", "--json").ok(t)
384 repo := decode[struct {384 repo := decode[struct {
385- MergeModes []string `json:"merge_modes"`385+ MergeModes []string `json:"merge_modes"`
386- FailingChecksBlock bool `json:"failing_checks_block"`
387 }](t, res.stdout)386 }](t, res.stdout)
388 assert.Equal(t, []string{"fast-forward", "squash", "merge"}, repo.MergeModes)387 assert.Equal(t, []string{"fast-forward", "squash", "merge"}, repo.MergeModes)
389- assert.True(t, repo.FailingChecksBlock)
390 388
391 res = c.cli(t, adminToken, "repo", "edit", "proj", "--merge-modes", "squash", "--json").ok(t)389 res = c.cli(t, adminToken, "repo", "edit", "proj", "--merge-modes", "squash", "--json").ok(t)
392 repo = decode[struct {390 repo = decode[struct {
393- MergeModes []string `json:"merge_modes"`391+ MergeModes []string `json:"merge_modes"`
394- FailingChecksBlock bool `json:"failing_checks_block"`
395 }](t, res.stdout)392 }](t, res.stdout)
396 assert.Equal(t, []string{"squash"}, repo.MergeModes)393 assert.Equal(t, []string{"squash"}, repo.MergeModes)
397- assert.True(t, repo.FailingChecksBlock, "untouched")
398- res = c.cli(t, adminToken, "repo", "edit", "proj", "--failing-checks-block=false", "--json").ok(t)
399- repo = decode[struct {
400- MergeModes []string `json:"merge_modes"`
401- FailingChecksBlock bool `json:"failing_checks_block"`
402- }](t, res.stdout)
403- assert.Equal(t, []string{"squash"}, repo.MergeModes, "untouched")
404- assert.False(t, repo.FailingChecksBlock)
405 res = c.cli(t, adminToken, "repo", "show", "proj").ok(t)394 res = c.cli(t, adminToken, "repo", "show", "proj").ok(t)
406 assert.Regexp(t, `(?m)^merge modes\s+squash$`, res.stdout)395 assert.Regexp(t, `(?m)^merge modes\s+squash$`, res.stdout)
407- assert.Regexp(t, `(?m)^failing checks block\s+false$`, res.stdout)
408 396
409 require.Error(t, c.cli(t, adminToken, "repo", "edit", "proj", "--merge-modes", "rebase").err)397 require.Error(t, c.cli(t, adminToken, "repo", "edit", "proj", "--merge-modes", "rebase").err)
410 require.Error(t, c.cli(t, adminToken, "repo", "edit", "proj", "--merge-modes", "").err)398 require.Error(t, c.cli(t, adminToken, "repo", "edit", "proj", "--merge-modes", "").err)
@@ -497,3 +485,39 @@ func TestMergeRequestNotesCLIEndToEnd(t *testing.T) {
497 require.Error(t, c.cli(t, adminToken, "mr", "edit-comment", "notes", "1", root.ID, "-m", "late").err)485 require.Error(t, c.cli(t, adminToken, "mr", "edit-comment", "notes", "1", root.ID, "-m", "late").err)
498 require.Error(t, c.cli(t, adminToken, "mr", "delete-comment", "notes", "1", root.ID).err)486 require.Error(t, c.cli(t, adminToken, "mr", "delete-comment", "notes", "1", root.ID).err)
499 }487 }
488+
489+func TestMRReadyAndIgnoreChecksCLIEndToEnd(t *testing.T) {
490+ c := newCLIHarness(t)
491+ secret := strings.TrimSpace(c.cli(t, adminToken, "token", "create", "daily", "--preset", "cli").ok(t).stdout)
492+ agent := strings.TrimSpace(c.cli(t, adminToken, "token", "create", "agent", "--preset", "agent", "--scope", "mr:merge").ok(t).stdout)
493+ c.cli(t, secret, "repo", "create", "proj", "--visibility", "public").ok(t)
494+ wt := c.workTree("main", "README.md")
495+ url := c.urlAuth(secret, "/proj.git")
496+ c.mustGit(wt, "push", "-q", url, "main")
497+ c.mustGit(wt, "checkout", "-q", "-b", "feat/ready")
498+ c.writeFile(wt, "README.md", []byte("ready\n"))
499+ c.mustGit(wt, "push", "-q", url, "feat/ready")
500+ head := c.bareGit("proj", "rev-parse", "feat/ready")
501+ c.cli(t, agent, "mr", "create", "proj", "--source", "feat/ready", "--title", "WIP: Add feature").ok(t)
502+ ready := c.cli(t, agent, "mr", "ready", "proj", "1", "--json").ok(t)
503+ assert.Equal(t, mrKeys, keys(t, ready.stdout))
504+ assert.Equal(t, "Add feature", decode[mrOut](t, ready.stdout).Title)
505+ res := c.cli(t, agent, "mr", "ready", "proj", "1")
506+ require.Error(t, res.err)
507+ assert.Contains(t, res.err.Error(), "not a draft")
508+ res = c.cli(t, secret, "mr", "merge", "proj", "1", "--head", head, "--ignore-checks", "--auto")
509+ require.Error(t, res.err)
510+ assert.Contains(t, res.err.Error(), "ignore-checks")
511+ res = c.cli(t, agent, "mr", "merge", "proj", "1", "--head", head, "--ignore-checks")
512+ require.Error(t, res.err)
513+ assert.Contains(t, res.err.Error(), "mr:force-merge")
514+ c.cli(t, secret, "mr", "merge", "proj", "1", "--head", head, "--ignore-checks").ok(t)
515+ c.mustGit(wt, "checkout", "-q", "-b", "feat/second")
516+ c.writeFile(wt, "README.md", []byte("second\n"))
517+ c.mustGit(wt, "push", "-q", url, "feat/second")
518+ head = c.bareGit("proj", "rev-parse", "feat/second")
519+ c.cli(t, agent, "mr", "create", "proj", "--source", "feat/second", "--title", "[WIP] Second").ok(t)
520+ res = c.cli(t, agent, "mr", "ready", "proj", "2").ok(t)
521+ assert.Contains(t, res.stdout, "marked ready proj!2 (feat/second into main)\n"+c.url+"/proj/merge-requests/2\n")
522+ c.cli(t, secret, "mr", "merge", "proj", "2", "--head", head).ok(t)
523+}
M internal/app/cli_repo.go +17 -29
@@ -34,9 +34,8 @@ type jsonRepo struct {
3434 CreatedAt *string `json:"created_at"`
3535 PushedAt *string `json:"pushed_at"`
3636 // MergeModes are the allowed merge modes ("fast-forward", "squash", "merge").
37- MergeModes []string `json:"merge_modes"`
38- FailingChecksBlock bool `json:"failing_checks_block"`
39- AllowAutoMerge bool `json:"allow_auto_merge"`
37+ MergeModes []string `json:"merge_modes"`
38+ AllowAutoMerge bool `json:"allow_auto_merge"`
4039 // Issues is the issue mode ("off", "owner" or "public").
4140 Issues string `json:"issues"`
4241 }
@@ -47,17 +46,16 @@ func toJSONRepo(r *ermite.Repo) jsonRepo {
4746 modes = append(modes, modeName(m))
4847 }
4948 return jsonRepo{
50- MergeModes: modes,
51- FailingChecksBlock: r.GetSettings().GetFailingChecksBlock(),
52- AllowAutoMerge: r.GetSettings().GetAllowAutoMerge(),
53- Issues: issuesModeName(r.GetSettings().GetIssues()),
54- Name: r.GetName(),
55- Description: r.GetDescription(),
56- Topics: append([]string{}, r.GetTopics()...),
57- Visibility: visibilityName(r.GetVisibility()),
58- DefaultBranch: r.GetDefaultBranch(),
59- CreatedAt: jsonTime(r.GetCreatedAt()),
60- PushedAt: jsonTime(r.GetPushedAt()),
49+ MergeModes: modes,
50+ AllowAutoMerge: r.GetSettings().GetAllowAutoMerge(),
51+ Issues: issuesModeName(r.GetSettings().GetIssues()),
52+ Name: r.GetName(),
53+ Description: r.GetDescription(),
54+ Topics: append([]string{}, r.GetTopics()...),
55+ Visibility: visibilityName(r.GetVisibility()),
56+ DefaultBranch: r.GetDefaultBranch(),
57+ CreatedAt: jsonTime(r.GetCreatedAt()),
58+ PushedAt: jsonTime(r.GetPushedAt()),
6159 }
6260 }
6361
@@ -117,7 +115,7 @@ const jsonRepoHelp = `
117115 JSON fields of a repository (--json): name, description, visibility ("public" or
118116 "private"), default_branch, created_at and pushed_at (RFC 3339 UTC; pushed_at is
119117 null until the first push), merge_modes (array of the allowed merge modes, "fast-forward", "squash" and/or
120-"merge"), failing_checks_block (whether a failing check prevents a merge), allow_auto_merge (whether
118+"merge"), allow_auto_merge (whether
121119 auto-merge can be enabled) and issues (the issue mode: "off", "owner" or "public").`
122120
123121 func newRepoCmd(env *cliEnv) *cobra.Command {
@@ -230,7 +228,6 @@ func printRepo(w io.Writer, r *ermite.Repo) error {
230228 {"created", shortTime(r.GetCreatedAt(), "-")},
231229 {"last push", shortTime(r.GetPushedAt(), "never")},
232230 {"merge modes", orDash(strings.Join(modes, ", "))},
233- {"failing checks block", strconv.FormatBool(r.GetSettings().GetFailingChecksBlock())},
234231 {"allow auto-merge", strconv.FormatBool(r.GetSettings().GetAllowAutoMerge())},
235232 {"issues", issuesModeName(r.GetSettings().GetIssues())},
236233 })
@@ -311,7 +308,7 @@ func buildCreateRepo(name, description string, topics []string, visibility strin
311308 func newRepoEditCmd(env *cliEnv) *cobra.Command {
312309 var description, visibility, defaultBranch string
313310 var topics, mergeModes []string
314- var failingChecksBlock, allowAutoMerge bool
311+ var allowAutoMerge bool
315312 var issues string
316313 cmd := &cobra.Command{
317314 Use: "edit <name>",
@@ -321,7 +318,6 @@ func newRepoEditCmd(env *cliEnv) *cobra.Command {
321318 "may be merged with (fast-forward, squash, merge; every mode is allowed by default, so to disable one,\n" +
322319 "list the others: --merge-modes fast-forward,squash disables the merge commit; squash and merge\n" +
323320 "commits are signed with the server's key, see `ermite signing-key show`), and\n" +
324- "--failing-checks-block says whether a failing check prevents a merge (a failed or unfinished CI check of the head; see README, CI).\n" +
325321 "--issues sets who may use issues: off (default), owner (the owner and tokens with issue:write) or\n" +
326322 "public (visitors of a public repository may also propose issues through a moderated form).\nNeeds a server with the issues feature.\n" +
327323 "Needs the repo:admin scope." + jsonRepoHelp,
@@ -333,7 +329,6 @@ func newRepoEditCmd(env *cliEnv) *cobra.Command {
333329 cmd.Flags().StringVar(&visibility, "visibility", "", "new visibility: public or private")
334330 cmd.Flags().StringVar(&defaultBranch, "default-branch", "", "short name of the new default branch")
335331 cmd.Flags().StringSliceVar(&mergeModes, "merge-modes", nil, "allowed merge modes, comma-separated: fast-forward, squash, merge (all by default; list the ones to keep)")
336- cmd.Flags().BoolVar(&failingChecksBlock, "failing-checks-block", true, "a failing check prevents a merge (--failing-checks-block=false to allow it)")
337332 cmd.Flags().BoolVar(&allowAutoMerge, "allow-auto-merge", false, "allow auto-merge on merge requests (--allow-auto-merge=false to forbid it)")
338333 cmd.Flags().StringVar(&issues, "issues", "", "issue mode: off, owner or public")
339334 _ = cmd.RegisterFlagCompletionFunc("issues", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
@@ -345,7 +340,7 @@ func newRepoEditCmd(env *cliEnv) *cobra.Command {
345340 })
346341 asJSON := addJSONFlag(cmd)
347342 cmd.RunE = func(cmd *cobra.Command, args []string) error {
348- req, err := buildUpdateRepo(cmd, args[0], description, topics, visibility, defaultBranch, mergeModes, failingChecksBlock, allowAutoMerge, issues)
343+ req, err := buildUpdateRepo(cmd, args[0], description, topics, visibility, defaultBranch, mergeModes, allowAutoMerge, issues)
349344 if err != nil {
350345 return err
351346 }
@@ -369,7 +364,7 @@ func newRepoEditCmd(env *cliEnv) *cobra.Command {
369364
370365 // buildUpdateRepo maps the flags of `repo edit` to the request: only the flags
371366 // the user passed go into the update mask.
372-func buildUpdateRepo(cmd *cobra.Command, name, description string, topics []string, visibility, defaultBranch string, mergeModes []string, failingChecksBlock, allowAutoMerge bool, issues string) (*ermite.UpdateRepoRequest, error) {
367+func buildUpdateRepo(cmd *cobra.Command, name, description string, topics []string, visibility, defaultBranch string, mergeModes []string, allowAutoMerge bool, issues string) (*ermite.UpdateRepoRequest, error) {
373368 req := &ermite.UpdateRepoRequest{Repo: name}
374369 var paths []string
375370 if cmd.Flags().Changed("description") {
@@ -411,13 +406,6 @@ func buildUpdateRepo(cmd *cobra.Command, name, description string, topics []stri
411406 req.Settings = &ermite.RepoSettings{AllowedMergeModes: modes}
412407 paths = append(paths, "settings.allowed_merge_modes")
413408 }
414- if cmd.Flags().Changed("failing-checks-block") {
415- if req.Settings == nil {
416- req.Settings = &ermite.RepoSettings{}
417- }
418- req.Settings.FailingChecksBlock = failingChecksBlock
419- paths = append(paths, "settings.failing_checks_block")
420- }
421409 if cmd.Flags().Changed("allow-auto-merge") {
422410 if req.Settings == nil {
423411 req.Settings = &ermite.RepoSettings{}
@@ -434,7 +422,7 @@ func buildUpdateRepo(cmd *cobra.Command, name, description string, topics []stri
434422 req.Issues = &mode
435423 }
436424 if len(paths) == 0 && req.Issues == nil {
437- return nil, errors.New("nothing to change: give at least one of --description, --topics, --visibility, --default-branch, --merge-modes, --failing-checks-block, --allow-auto-merge, --issues")
425+ return nil, errors.New("nothing to change: give at least one of --description, --topics, --visibility, --default-branch, --merge-modes, --allow-auto-merge, --issues")
438426 }
439427 req.UpdateMask = &fieldmaskpb.FieldMask{Paths: paths}
440428 return req, nil
@@ -34,9 +34,8 @@ type jsonRepo struct {
34 CreatedAt *string `json:"created_at"`34 CreatedAt *string `json:"created_at"`
35 PushedAt *string `json:"pushed_at"`35 PushedAt *string `json:"pushed_at"`
36 // MergeModes are the allowed merge modes ("fast-forward", "squash", "merge").36 // MergeModes are the allowed merge modes ("fast-forward", "squash", "merge").
37- MergeModes []string `json:"merge_modes"`37+ MergeModes []string `json:"merge_modes"`
38- FailingChecksBlock bool `json:"failing_checks_block"`38+ AllowAutoMerge bool `json:"allow_auto_merge"`
39- AllowAutoMerge bool `json:"allow_auto_merge"`
40 // Issues is the issue mode ("off", "owner" or "public").39 // Issues is the issue mode ("off", "owner" or "public").
41 Issues string `json:"issues"`40 Issues string `json:"issues"`
42 }41 }
@@ -47,17 +46,16 @@ func toJSONRepo(r *ermite.Repo) jsonRepo {
47 modes = append(modes, modeName(m))46 modes = append(modes, modeName(m))
48 }47 }
49 return jsonRepo{48 return jsonRepo{
50- MergeModes: modes,49+ MergeModes: modes,
51- FailingChecksBlock: r.GetSettings().GetFailingChecksBlock(),50+ AllowAutoMerge: r.GetSettings().GetAllowAutoMerge(),
52- AllowAutoMerge: r.GetSettings().GetAllowAutoMerge(),51+ Issues: issuesModeName(r.GetSettings().GetIssues()),
53- Issues: issuesModeName(r.GetSettings().GetIssues()),52+ Name: r.GetName(),
54- Name: r.GetName(),53+ Description: r.GetDescription(),
55- Description: r.GetDescription(),54+ Topics: append([]string{}, r.GetTopics()...),
56- Topics: append([]string{}, r.GetTopics()...),55+ Visibility: visibilityName(r.GetVisibility()),
57- Visibility: visibilityName(r.GetVisibility()),56+ DefaultBranch: r.GetDefaultBranch(),
58- DefaultBranch: r.GetDefaultBranch(),57+ CreatedAt: jsonTime(r.GetCreatedAt()),
59- CreatedAt: jsonTime(r.GetCreatedAt()),58+ PushedAt: jsonTime(r.GetPushedAt()),
60- PushedAt: jsonTime(r.GetPushedAt()),
61 }59 }
62 }60 }
63 61
@@ -117,7 +115,7 @@ const jsonRepoHelp = `
117 JSON fields of a repository (--json): name, description, visibility ("public" or115 JSON fields of a repository (--json): name, description, visibility ("public" or
118 "private"), default_branch, created_at and pushed_at (RFC 3339 UTC; pushed_at is116 "private"), default_branch, created_at and pushed_at (RFC 3339 UTC; pushed_at is
119 null until the first push), merge_modes (array of the allowed merge modes, "fast-forward", "squash" and/or117 null until the first push), merge_modes (array of the allowed merge modes, "fast-forward", "squash" and/or
120-"merge"), failing_checks_block (whether a failing check prevents a merge), allow_auto_merge (whether118+"merge"), allow_auto_merge (whether
121 auto-merge can be enabled) and issues (the issue mode: "off", "owner" or "public").`119 auto-merge can be enabled) and issues (the issue mode: "off", "owner" or "public").`
122 120
123 func newRepoCmd(env *cliEnv) *cobra.Command {121 func newRepoCmd(env *cliEnv) *cobra.Command {
@@ -230,7 +228,6 @@ func printRepo(w io.Writer, r *ermite.Repo) error {
230 {"created", shortTime(r.GetCreatedAt(), "-")},228 {"created", shortTime(r.GetCreatedAt(), "-")},
231 {"last push", shortTime(r.GetPushedAt(), "never")},229 {"last push", shortTime(r.GetPushedAt(), "never")},
232 {"merge modes", orDash(strings.Join(modes, ", "))},230 {"merge modes", orDash(strings.Join(modes, ", "))},
233- {"failing checks block", strconv.FormatBool(r.GetSettings().GetFailingChecksBlock())},
234 {"allow auto-merge", strconv.FormatBool(r.GetSettings().GetAllowAutoMerge())},231 {"allow auto-merge", strconv.FormatBool(r.GetSettings().GetAllowAutoMerge())},
235 {"issues", issuesModeName(r.GetSettings().GetIssues())},232 {"issues", issuesModeName(r.GetSettings().GetIssues())},
236 })233 })
@@ -311,7 +308,7 @@ func buildCreateRepo(name, description string, topics []string, visibility strin
311 func newRepoEditCmd(env *cliEnv) *cobra.Command {308 func newRepoEditCmd(env *cliEnv) *cobra.Command {
312 var description, visibility, defaultBranch string309 var description, visibility, defaultBranch string
313 var topics, mergeModes []string310 var topics, mergeModes []string
314- var failingChecksBlock, allowAutoMerge bool311+ var allowAutoMerge bool
315 var issues string312 var issues string
316 cmd := &cobra.Command{313 cmd := &cobra.Command{
317 Use: "edit <name>",314 Use: "edit <name>",
@@ -321,7 +318,6 @@ func newRepoEditCmd(env *cliEnv) *cobra.Command {
321 "may be merged with (fast-forward, squash, merge; every mode is allowed by default, so to disable one,\n" +318 "may be merged with (fast-forward, squash, merge; every mode is allowed by default, so to disable one,\n" +
322 "list the others: --merge-modes fast-forward,squash disables the merge commit; squash and merge\n" +319 "list the others: --merge-modes fast-forward,squash disables the merge commit; squash and merge\n" +
323 "commits are signed with the server's key, see `ermite signing-key show`), and\n" +320 "commits are signed with the server's key, see `ermite signing-key show`), and\n" +
324- "--failing-checks-block says whether a failing check prevents a merge (a failed or unfinished CI check of the head; see README, CI).\n" +
325 "--issues sets who may use issues: off (default), owner (the owner and tokens with issue:write) or\n" +321 "--issues sets who may use issues: off (default), owner (the owner and tokens with issue:write) or\n" +
326 "public (visitors of a public repository may also propose issues through a moderated form).\nNeeds a server with the issues feature.\n" +322 "public (visitors of a public repository may also propose issues through a moderated form).\nNeeds a server with the issues feature.\n" +
327 "Needs the repo:admin scope." + jsonRepoHelp,323 "Needs the repo:admin scope." + jsonRepoHelp,
@@ -333,7 +329,6 @@ func newRepoEditCmd(env *cliEnv) *cobra.Command {
333 cmd.Flags().StringVar(&visibility, "visibility", "", "new visibility: public or private")329 cmd.Flags().StringVar(&visibility, "visibility", "", "new visibility: public or private")
334 cmd.Flags().StringVar(&defaultBranch, "default-branch", "", "short name of the new default branch")330 cmd.Flags().StringVar(&defaultBranch, "default-branch", "", "short name of the new default branch")
335 cmd.Flags().StringSliceVar(&mergeModes, "merge-modes", nil, "allowed merge modes, comma-separated: fast-forward, squash, merge (all by default; list the ones to keep)")331 cmd.Flags().StringSliceVar(&mergeModes, "merge-modes", nil, "allowed merge modes, comma-separated: fast-forward, squash, merge (all by default; list the ones to keep)")
336- cmd.Flags().BoolVar(&failingChecksBlock, "failing-checks-block", true, "a failing check prevents a merge (--failing-checks-block=false to allow it)")
337 cmd.Flags().BoolVar(&allowAutoMerge, "allow-auto-merge", false, "allow auto-merge on merge requests (--allow-auto-merge=false to forbid it)")332 cmd.Flags().BoolVar(&allowAutoMerge, "allow-auto-merge", false, "allow auto-merge on merge requests (--allow-auto-merge=false to forbid it)")
338 cmd.Flags().StringVar(&issues, "issues", "", "issue mode: off, owner or public")333 cmd.Flags().StringVar(&issues, "issues", "", "issue mode: off, owner or public")
339 _ = cmd.RegisterFlagCompletionFunc("issues", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {334 _ = cmd.RegisterFlagCompletionFunc("issues", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
@@ -345,7 +340,7 @@ func newRepoEditCmd(env *cliEnv) *cobra.Command {
345 })340 })
346 asJSON := addJSONFlag(cmd)341 asJSON := addJSONFlag(cmd)
347 cmd.RunE = func(cmd *cobra.Command, args []string) error {342 cmd.RunE = func(cmd *cobra.Command, args []string) error {
348- req, err := buildUpdateRepo(cmd, args[0], description, topics, visibility, defaultBranch, mergeModes, failingChecksBlock, allowAutoMerge, issues)343+ req, err := buildUpdateRepo(cmd, args[0], description, topics, visibility, defaultBranch, mergeModes, allowAutoMerge, issues)
349 if err != nil {344 if err != nil {
350 return err345 return err
351 }346 }
@@ -369,7 +364,7 @@ func newRepoEditCmd(env *cliEnv) *cobra.Command {
369 364
370 // buildUpdateRepo maps the flags of `repo edit` to the request: only the flags365 // buildUpdateRepo maps the flags of `repo edit` to the request: only the flags
371 // the user passed go into the update mask.366 // the user passed go into the update mask.
372-func buildUpdateRepo(cmd *cobra.Command, name, description string, topics []string, visibility, defaultBranch string, mergeModes []string, failingChecksBlock, allowAutoMerge bool, issues string) (*ermite.UpdateRepoRequest, error) {367+func buildUpdateRepo(cmd *cobra.Command, name, description string, topics []string, visibility, defaultBranch string, mergeModes []string, allowAutoMerge bool, issues string) (*ermite.UpdateRepoRequest, error) {
373 req := &ermite.UpdateRepoRequest{Repo: name}368 req := &ermite.UpdateRepoRequest{Repo: name}
374 var paths []string369 var paths []string
375 if cmd.Flags().Changed("description") {370 if cmd.Flags().Changed("description") {
@@ -411,13 +406,6 @@ func buildUpdateRepo(cmd *cobra.Command, name, description string, topics []stri
411 req.Settings = &ermite.RepoSettings{AllowedMergeModes: modes}406 req.Settings = &ermite.RepoSettings{AllowedMergeModes: modes}
412 paths = append(paths, "settings.allowed_merge_modes")407 paths = append(paths, "settings.allowed_merge_modes")
413 }408 }
414- if cmd.Flags().Changed("failing-checks-block") {
415- if req.Settings == nil {
416- req.Settings = &ermite.RepoSettings{}
417- }
418- req.Settings.FailingChecksBlock = failingChecksBlock
419- paths = append(paths, "settings.failing_checks_block")
420- }
421 if cmd.Flags().Changed("allow-auto-merge") {409 if cmd.Flags().Changed("allow-auto-merge") {
422 if req.Settings == nil {410 if req.Settings == nil {
423 req.Settings = &ermite.RepoSettings{}411 req.Settings = &ermite.RepoSettings{}
@@ -434,7 +422,7 @@ func buildUpdateRepo(cmd *cobra.Command, name, description string, topics []stri
434 req.Issues = &mode422 req.Issues = &mode
435 }423 }
436 if len(paths) == 0 && req.Issues == nil {424 if len(paths) == 0 && req.Issues == nil {
437- return nil, errors.New("nothing to change: give at least one of --description, --topics, --visibility, --default-branch, --merge-modes, --failing-checks-block, --allow-auto-merge, --issues")425+ return nil, errors.New("nothing to change: give at least one of --description, --topics, --visibility, --default-branch, --merge-modes, --allow-auto-merge, --issues")
438 }426 }
439 req.UpdateMask = &fieldmaskpb.FieldMask{Paths: paths}427 req.UpdateMask = &fieldmaskpb.FieldMask{Paths: paths}
440 return req, nil428 return req, nil
M internal/app/cli_repo_test.go +17 -27
@@ -19,8 +19,7 @@ var (
1919 Name: "alpha", Description: "The first one", Topics: []string{"cli", "go"}, Visibility: ermite.Visibility_VISIBILITY_PUBLIC,
2020 DefaultBranch: "main", CreatedAt: ts("2026-01-02T03:04:05Z"), PushedAt: ts("2026-03-04T05:06:07Z"),
2121 Settings: &ermite.RepoSettings{
22- AllowedMergeModes: []ermite.MergeMode{ermite.MergeMode_MERGE_MODE_FAST_FORWARD, ermite.MergeMode_MERGE_MODE_SQUASH},
23- FailingChecksBlock: true,
22+ AllowedMergeModes: []ermite.MergeMode{ermite.MergeMode_MERGE_MODE_FAST_FORWARD, ermite.MergeMode_MERGE_MODE_SQUASH},
2423 },
2524 }
2625 repoBeta = &ermite.Repo{
@@ -54,8 +53,8 @@ func TestRepoList(t *testing.T) {
5453 {
5554 name: "json", repos: both, args: []string{"repo", "list", "--json"}, wantJSON: true,
5655 wantStdout: `[
57- {"name":"alpha","description":"The first one","topics":["cli","go"],"visibility":"public","default_branch":"main","created_at":"2026-01-02T03:04:05Z","pushed_at":"2026-03-04T05:06:07Z","merge_modes":["fast-forward","squash"],"failing_checks_block":true,"allow_auto_merge":false,"issues":"off"},
58- {"name":"beta","description":"","topics":[],"visibility":"private","default_branch":"trunk","created_at":"2026-02-02T00:00:00Z","pushed_at":null,"merge_modes":["squash"],"failing_checks_block":false,"allow_auto_merge":false,"issues":"off"}
56+ {"name":"alpha","description":"The first one","topics":["cli","go"],"visibility":"public","default_branch":"main","created_at":"2026-01-02T03:04:05Z","pushed_at":"2026-03-04T05:06:07Z","merge_modes":["fast-forward","squash"],"allow_auto_merge":false,"issues":"off"},
57+ {"name":"beta","description":"","topics":[],"visibility":"private","default_branch":"trunk","created_at":"2026-02-02T00:00:00Z","pushed_at":null,"merge_modes":["squash"],"allow_auto_merge":false,"issues":"off"}
5958 ]`,
6059 },
6160 {
@@ -107,22 +106,21 @@ func TestRepoShow(t *testing.T) {
107106 url := f.serve(t)
108107 res := runCLI(t, envFor(url), "", "repo", "show", "alpha").ok(t)
109108 assert.Equal(t, ""+
110- "name alpha\n"+
111- "visibility public\n"+
112- "default branch main\n"+
113- "description The first one\n"+
114- "topics cli, go\n"+
115- "created 2026-01-02 03:04\n"+
116- "last push 2026-03-04 05:06\n"+
117- "merge modes fast-forward, squash\n"+
118- "failing checks block true\n"+
119- "allow auto-merge false\n"+
120- "issues off\n", res.stdout)
109+ "name alpha\n"+
110+ "visibility public\n"+
111+ "default branch main\n"+
112+ "description The first one\n"+
113+ "topics cli, go\n"+
114+ "created 2026-01-02 03:04\n"+
115+ "last push 2026-03-04 05:06\n"+
116+ "merge modes fast-forward, squash\n"+
117+ "allow auto-merge false\n"+
118+ "issues off\n", res.stdout)
121119 _, reqs := f.snapshot()
122120 assert.Equal(t, "alpha", reqs[0].(*ermite.GetRepoRequest).GetRepo())
123121
124122 res = runCLI(t, envFor(url), "", "repo", "show", "alpha", "--json").ok(t)
125- assert.JSONEq(t, `{"name":"alpha","description":"The first one","topics":["cli","go"],"visibility":"public","default_branch":"main","created_at":"2026-01-02T03:04:05Z","pushed_at":"2026-03-04T05:06:07Z","merge_modes":["fast-forward","squash"],"failing_checks_block":true,"allow_auto_merge":false,"issues":"off"}`, res.stdout)
123+ assert.JSONEq(t, `{"name":"alpha","description":"The first one","topics":["cli","go"],"visibility":"public","default_branch":"main","created_at":"2026-01-02T03:04:05Z","pushed_at":"2026-03-04T05:06:07Z","merge_modes":["fast-forward","squash"],"allow_auto_merge":false,"issues":"off"}`, res.stdout)
126124 }
127125
128126 func TestRepoShowNeedsOneArg(t *testing.T) {
@@ -178,7 +176,7 @@ func TestRepoCreateJSON(t *testing.T) {
178176 return &ermite.CreateRepoResponse{Repo: repoBeta}, nil
179177 }}
180178 res := runCLI(t, envFor(f.serve(t)), "", "repo", "create", "beta", "--json").ok(t)
181- assert.JSONEq(t, `{"name":"beta","description":"","topics":[],"visibility":"private","default_branch":"trunk","created_at":"2026-02-02T00:00:00Z","pushed_at":null,"merge_modes":["squash"],"failing_checks_block":false,"allow_auto_merge":false,"issues":"off"}`, res.stdout)
179+ assert.JSONEq(t, `{"name":"beta","description":"","topics":[],"visibility":"private","default_branch":"trunk","created_at":"2026-02-02T00:00:00Z","pushed_at":null,"merge_modes":["squash"],"allow_auto_merge":false,"issues":"off"}`, res.stdout)
182180 }
183181
184182 func TestRepoEditMask(t *testing.T) {
@@ -209,16 +207,8 @@ func TestRepoEditMask(t *testing.T) {
209207 {"merge modes", []string{"--merge-modes", "squash,ff"}, []string{"settings.allowed_merge_modes"}, func(t *testing.T, r *ermite.UpdateRepoRequest) {
210208 assert.Equal(t, []ermite.MergeMode{ermite.MergeMode_MERGE_MODE_SQUASH, ermite.MergeMode_MERGE_MODE_FAST_FORWARD}, r.GetSettings().GetAllowedMergeModes())
211209 }},
212- {"failing checks do not block", []string{"--failing-checks-block=false"}, []string{"settings.failing_checks_block"}, func(t *testing.T, r *ermite.UpdateRepoRequest) {
213- assert.False(t, r.GetSettings().GetFailingChecksBlock())
214- assert.Empty(t, r.GetSettings().GetAllowedMergeModes())
215- }},
216- {"failing checks block", []string{"--failing-checks-block"}, []string{"settings.failing_checks_block"}, func(t *testing.T, r *ermite.UpdateRepoRequest) {
217- assert.True(t, r.GetSettings().GetFailingChecksBlock())
218- }},
219210 {"allow auto-merge", []string{"--allow-auto-merge"}, []string{"settings.allow_auto_merge"}, func(t *testing.T, r *ermite.UpdateRepoRequest) {
220211 assert.True(t, r.GetSettings().GetAllowAutoMerge())
221- assert.False(t, r.GetSettings().GetFailingChecksBlock())
222212 }},
223213 {"forbid auto-merge", []string{"--allow-auto-merge=false"}, []string{"settings.allow_auto_merge"}, func(t *testing.T, r *ermite.UpdateRepoRequest) {
224214 assert.False(t, r.GetSettings().GetAllowAutoMerge())
@@ -460,9 +450,9 @@ func TestRepoEditAllowAutoMerge(t *testing.T) {
460450 assert.Equal(t, []string{"settings.allow_auto_merge"}, got.GetUpdateMask().GetPaths())
461451 assert.True(t, got.GetSettings().GetAllowAutoMerge())
462452
463- res = runCLI(t, envFor(url), "", "repo", "edit", "alpha", "--allow-auto-merge", "--failing-checks-block=false", "--json").ok(t)
453+ res = runCLI(t, envFor(url), "", "repo", "edit", "alpha", "--allow-auto-merge", "--json").ok(t)
464454 assert.Contains(t, res.stdout, `"allow_auto_merge": true`)
465455 _, reqs = f.snapshot()
466456 got = reqs[1].(*ermite.UpdateRepoRequest)
467- assert.ElementsMatch(t, []string{"settings.allow_auto_merge", "settings.failing_checks_block"}, got.GetUpdateMask().GetPaths())
457+ assert.ElementsMatch(t, []string{"settings.allow_auto_merge"}, got.GetUpdateMask().GetPaths())
468458 }
@@ -19,8 +19,7 @@ var (
19 Name: "alpha", Description: "The first one", Topics: []string{"cli", "go"}, Visibility: ermite.Visibility_VISIBILITY_PUBLIC,19 Name: "alpha", Description: "The first one", Topics: []string{"cli", "go"}, Visibility: ermite.Visibility_VISIBILITY_PUBLIC,
20 DefaultBranch: "main", CreatedAt: ts("2026-01-02T03:04:05Z"), PushedAt: ts("2026-03-04T05:06:07Z"),20 DefaultBranch: "main", CreatedAt: ts("2026-01-02T03:04:05Z"), PushedAt: ts("2026-03-04T05:06:07Z"),
21 Settings: &ermite.RepoSettings{21 Settings: &ermite.RepoSettings{
22- AllowedMergeModes: []ermite.MergeMode{ermite.MergeMode_MERGE_MODE_FAST_FORWARD, ermite.MergeMode_MERGE_MODE_SQUASH},22+ AllowedMergeModes: []ermite.MergeMode{ermite.MergeMode_MERGE_MODE_FAST_FORWARD, ermite.MergeMode_MERGE_MODE_SQUASH},
23- FailingChecksBlock: true,
24 },23 },
25 }24 }
26 repoBeta = &ermite.Repo{25 repoBeta = &ermite.Repo{
@@ -54,8 +53,8 @@ func TestRepoList(t *testing.T) {
54 {53 {
55 name: "json", repos: both, args: []string{"repo", "list", "--json"}, wantJSON: true,54 name: "json", repos: both, args: []string{"repo", "list", "--json"}, wantJSON: true,
56 wantStdout: `[55 wantStdout: `[
57- {"name":"alpha","description":"The first one","topics":["cli","go"],"visibility":"public","default_branch":"main","created_at":"2026-01-02T03:04:05Z","pushed_at":"2026-03-04T05:06:07Z","merge_modes":["fast-forward","squash"],"failing_checks_block":true,"allow_auto_merge":false,"issues":"off"},56+ {"name":"alpha","description":"The first one","topics":["cli","go"],"visibility":"public","default_branch":"main","created_at":"2026-01-02T03:04:05Z","pushed_at":"2026-03-04T05:06:07Z","merge_modes":["fast-forward","squash"],"allow_auto_merge":false,"issues":"off"},
58- {"name":"beta","description":"","topics":[],"visibility":"private","default_branch":"trunk","created_at":"2026-02-02T00:00:00Z","pushed_at":null,"merge_modes":["squash"],"failing_checks_block":false,"allow_auto_merge":false,"issues":"off"}57+ {"name":"beta","description":"","topics":[],"visibility":"private","default_branch":"trunk","created_at":"2026-02-02T00:00:00Z","pushed_at":null,"merge_modes":["squash"],"allow_auto_merge":false,"issues":"off"}
59 ]`,58 ]`,
60 },59 },
61 {60 {
@@ -107,22 +106,21 @@ func TestRepoShow(t *testing.T) {
107 url := f.serve(t)106 url := f.serve(t)
108 res := runCLI(t, envFor(url), "", "repo", "show", "alpha").ok(t)107 res := runCLI(t, envFor(url), "", "repo", "show", "alpha").ok(t)
109 assert.Equal(t, ""+108 assert.Equal(t, ""+
110- "name alpha\n"+109+ "name alpha\n"+
111- "visibility public\n"+110+ "visibility public\n"+
112- "default branch main\n"+111+ "default branch main\n"+
113- "description The first one\n"+112+ "description The first one\n"+
114- "topics cli, go\n"+113+ "topics cli, go\n"+
115- "created 2026-01-02 03:04\n"+114+ "created 2026-01-02 03:04\n"+
116- "last push 2026-03-04 05:06\n"+115+ "last push 2026-03-04 05:06\n"+
117- "merge modes fast-forward, squash\n"+116+ "merge modes fast-forward, squash\n"+
118- "failing checks block true\n"+117+ "allow auto-merge false\n"+
119- "allow auto-merge false\n"+118+ "issues off\n", res.stdout)
120- "issues off\n", res.stdout)
121 _, reqs := f.snapshot()119 _, reqs := f.snapshot()
122 assert.Equal(t, "alpha", reqs[0].(*ermite.GetRepoRequest).GetRepo())120 assert.Equal(t, "alpha", reqs[0].(*ermite.GetRepoRequest).GetRepo())
123 121
124 res = runCLI(t, envFor(url), "", "repo", "show", "alpha", "--json").ok(t)122 res = runCLI(t, envFor(url), "", "repo", "show", "alpha", "--json").ok(t)
125- assert.JSONEq(t, `{"name":"alpha","description":"The first one","topics":["cli","go"],"visibility":"public","default_branch":"main","created_at":"2026-01-02T03:04:05Z","pushed_at":"2026-03-04T05:06:07Z","merge_modes":["fast-forward","squash"],"failing_checks_block":true,"allow_auto_merge":false,"issues":"off"}`, res.stdout)123+ assert.JSONEq(t, `{"name":"alpha","description":"The first one","topics":["cli","go"],"visibility":"public","default_branch":"main","created_at":"2026-01-02T03:04:05Z","pushed_at":"2026-03-04T05:06:07Z","merge_modes":["fast-forward","squash"],"allow_auto_merge":false,"issues":"off"}`, res.stdout)
126 }124 }
127 125
128 func TestRepoShowNeedsOneArg(t *testing.T) {126 func TestRepoShowNeedsOneArg(t *testing.T) {
@@ -178,7 +176,7 @@ func TestRepoCreateJSON(t *testing.T) {
178 return &ermite.CreateRepoResponse{Repo: repoBeta}, nil176 return &ermite.CreateRepoResponse{Repo: repoBeta}, nil
179 }}177 }}
180 res := runCLI(t, envFor(f.serve(t)), "", "repo", "create", "beta", "--json").ok(t)178 res := runCLI(t, envFor(f.serve(t)), "", "repo", "create", "beta", "--json").ok(t)
181- assert.JSONEq(t, `{"name":"beta","description":"","topics":[],"visibility":"private","default_branch":"trunk","created_at":"2026-02-02T00:00:00Z","pushed_at":null,"merge_modes":["squash"],"failing_checks_block":false,"allow_auto_merge":false,"issues":"off"}`, res.stdout)179+ assert.JSONEq(t, `{"name":"beta","description":"","topics":[],"visibility":"private","default_branch":"trunk","created_at":"2026-02-02T00:00:00Z","pushed_at":null,"merge_modes":["squash"],"allow_auto_merge":false,"issues":"off"}`, res.stdout)
182 }180 }
183 181
184 func TestRepoEditMask(t *testing.T) {182 func TestRepoEditMask(t *testing.T) {
@@ -209,16 +207,8 @@ func TestRepoEditMask(t *testing.T) {
209 {"merge modes", []string{"--merge-modes", "squash,ff"}, []string{"settings.allowed_merge_modes"}, func(t *testing.T, r *ermite.UpdateRepoRequest) {207 {"merge modes", []string{"--merge-modes", "squash,ff"}, []string{"settings.allowed_merge_modes"}, func(t *testing.T, r *ermite.UpdateRepoRequest) {
210 assert.Equal(t, []ermite.MergeMode{ermite.MergeMode_MERGE_MODE_SQUASH, ermite.MergeMode_MERGE_MODE_FAST_FORWARD}, r.GetSettings().GetAllowedMergeModes())208 assert.Equal(t, []ermite.MergeMode{ermite.MergeMode_MERGE_MODE_SQUASH, ermite.MergeMode_MERGE_MODE_FAST_FORWARD}, r.GetSettings().GetAllowedMergeModes())
211 }},209 }},
212- {"failing checks do not block", []string{"--failing-checks-block=false"}, []string{"settings.failing_checks_block"}, func(t *testing.T, r *ermite.UpdateRepoRequest) {
213- assert.False(t, r.GetSettings().GetFailingChecksBlock())
214- assert.Empty(t, r.GetSettings().GetAllowedMergeModes())
215- }},
216- {"failing checks block", []string{"--failing-checks-block"}, []string{"settings.failing_checks_block"}, func(t *testing.T, r *ermite.UpdateRepoRequest) {
217- assert.True(t, r.GetSettings().GetFailingChecksBlock())
218- }},
219 {"allow auto-merge", []string{"--allow-auto-merge"}, []string{"settings.allow_auto_merge"}, func(t *testing.T, r *ermite.UpdateRepoRequest) {210 {"allow auto-merge", []string{"--allow-auto-merge"}, []string{"settings.allow_auto_merge"}, func(t *testing.T, r *ermite.UpdateRepoRequest) {
220 assert.True(t, r.GetSettings().GetAllowAutoMerge())211 assert.True(t, r.GetSettings().GetAllowAutoMerge())
221- assert.False(t, r.GetSettings().GetFailingChecksBlock())
222 }},212 }},
223 {"forbid auto-merge", []string{"--allow-auto-merge=false"}, []string{"settings.allow_auto_merge"}, func(t *testing.T, r *ermite.UpdateRepoRequest) {213 {"forbid auto-merge", []string{"--allow-auto-merge=false"}, []string{"settings.allow_auto_merge"}, func(t *testing.T, r *ermite.UpdateRepoRequest) {
224 assert.False(t, r.GetSettings().GetAllowAutoMerge())214 assert.False(t, r.GetSettings().GetAllowAutoMerge())
@@ -460,9 +450,9 @@ func TestRepoEditAllowAutoMerge(t *testing.T) {
460 assert.Equal(t, []string{"settings.allow_auto_merge"}, got.GetUpdateMask().GetPaths())450 assert.Equal(t, []string{"settings.allow_auto_merge"}, got.GetUpdateMask().GetPaths())
461 assert.True(t, got.GetSettings().GetAllowAutoMerge())451 assert.True(t, got.GetSettings().GetAllowAutoMerge())
462 452
463- res = runCLI(t, envFor(url), "", "repo", "edit", "alpha", "--allow-auto-merge", "--failing-checks-block=false", "--json").ok(t)453+ res = runCLI(t, envFor(url), "", "repo", "edit", "alpha", "--allow-auto-merge", "--json").ok(t)
464 assert.Contains(t, res.stdout, `"allow_auto_merge": true`)454 assert.Contains(t, res.stdout, `"allow_auto_merge": true`)
465 _, reqs = f.snapshot()455 _, reqs = f.snapshot()
466 got = reqs[1].(*ermite.UpdateRepoRequest)456 got = reqs[1].(*ermite.UpdateRepoRequest)
467- assert.ElementsMatch(t, []string{"settings.allow_auto_merge", "settings.failing_checks_block"}, got.GetUpdateMask().GetPaths())457+ assert.ElementsMatch(t, []string{"settings.allow_auto_merge"}, got.GetUpdateMask().GetPaths())
468 }458 }
M internal/app/cli_watch_test.go +1 -1
@@ -95,7 +95,7 @@ func TestWatchNDJSON(t *testing.T) {
9595 assert.True(t, json.Valid([]byte(sc.Text())), "one JSON value per line: %s", sc.Text())
9696 }
9797 require.Len(t, lines, 5)
98- assert.JSONEq(t, `{"id":"1","time":"2026-10-10T12:00:00Z","type":"repo_created","repo":"beta","repository":{"name":"beta","description":"","topics":[],"visibility":"private","default_branch":"trunk","created_at":"2026-02-02T00:00:00Z","pushed_at":null,"merge_modes":["squash"],"failing_checks_block":false,"allow_auto_merge":false,"issues":"off"}}`, lines[0])
98+ assert.JSONEq(t, `{"id":"1","time":"2026-10-10T12:00:00Z","type":"repo_created","repo":"beta","repository":{"name":"beta","description":"","topics":[],"visibility":"private","default_branch":"trunk","created_at":"2026-02-02T00:00:00Z","pushed_at":null,"merge_modes":["squash"],"allow_auto_merge":false,"issues":"off"}}`, lines[0])
9999 assert.JSONEq(t, `{"id":"3","time":"2026-10-10T12:00:00Z","type":"pushed","repo":"alpha","ref_updates":[
100100 {"ref":"refs/heads/main","old_id":"1111111111111111","new_id":"2222222222222222"},
101101 {"ref":"refs/heads/new","old_id":"","new_id":"3333333333333333"},
@@ -95,7 +95,7 @@ func TestWatchNDJSON(t *testing.T) {
95 assert.True(t, json.Valid([]byte(sc.Text())), "one JSON value per line: %s", sc.Text())95 assert.True(t, json.Valid([]byte(sc.Text())), "one JSON value per line: %s", sc.Text())
96 }96 }
97 require.Len(t, lines, 5)97 require.Len(t, lines, 5)
98- assert.JSONEq(t, `{"id":"1","time":"2026-10-10T12:00:00Z","type":"repo_created","repo":"beta","repository":{"name":"beta","description":"","topics":[],"visibility":"private","default_branch":"trunk","created_at":"2026-02-02T00:00:00Z","pushed_at":null,"merge_modes":["squash"],"failing_checks_block":false,"allow_auto_merge":false,"issues":"off"}}`, lines[0])98+ assert.JSONEq(t, `{"id":"1","time":"2026-10-10T12:00:00Z","type":"repo_created","repo":"beta","repository":{"name":"beta","description":"","topics":[],"visibility":"private","default_branch":"trunk","created_at":"2026-02-02T00:00:00Z","pushed_at":null,"merge_modes":["squash"],"allow_auto_merge":false,"issues":"off"}}`, lines[0])
99 assert.JSONEq(t, `{"id":"3","time":"2026-10-10T12:00:00Z","type":"pushed","repo":"alpha","ref_updates":[99 assert.JSONEq(t, `{"id":"3","time":"2026-10-10T12:00:00Z","type":"pushed","repo":"alpha","ref_updates":[
100 {"ref":"refs/heads/main","old_id":"1111111111111111","new_id":"2222222222222222"},100 {"ref":"refs/heads/main","old_id":"1111111111111111","new_id":"2222222222222222"},
101 {"ref":"refs/heads/new","old_id":"","new_id":"3333333333333333"},101 {"ref":"refs/heads/new","old_id":"","new_id":"3333333333333333"},
M internal/app/issue_api_test.go +0 -1
@@ -221,7 +221,6 @@ func TestIssuesFeatureAndSettingOverTheAPI(t *testing.T) {
221221 updated, err := update(r.root, pb.IssuesMode_ISSUES_MODE_OWNER)
222222 require.NoError(t, err)
223223 assert.Equal(t, pb.IssuesMode_ISSUES_MODE_OWNER, updated.GetSettings().GetIssues())
224- assert.True(t, updated.GetSettings().GetFailingChecksBlock(), "the other settings are kept")
225224 _, err = r.root.issue.CreateIssue(ctx, connect.NewRequest(&pb.CreateIssueRequest{Repo: "priv", Title: "t"}))
226225 require.NoError(t, err)
227226
@@ -221,7 +221,6 @@ func TestIssuesFeatureAndSettingOverTheAPI(t *testing.T) {
221 updated, err := update(r.root, pb.IssuesMode_ISSUES_MODE_OWNER)221 updated, err := update(r.root, pb.IssuesMode_ISSUES_MODE_OWNER)
222 require.NoError(t, err)222 require.NoError(t, err)
223 assert.Equal(t, pb.IssuesMode_ISSUES_MODE_OWNER, updated.GetSettings().GetIssues())223 assert.Equal(t, pb.IssuesMode_ISSUES_MODE_OWNER, updated.GetSettings().GetIssues())
224- assert.True(t, updated.GetSettings().GetFailingChecksBlock(), "the other settings are kept")
225 _, err = r.root.issue.CreateIssue(ctx, connect.NewRequest(&pb.CreateIssueRequest{Repo: "priv", Title: "t"}))224 _, err = r.root.issue.CreateIssue(ctx, connect.NewRequest(&pb.CreateIssueRequest{Repo: "priv", Title: "t"}))
226 require.NoError(t, err)225 require.NoError(t, err)
227 226
M internal/app/mr_api_test.go +9 -9
@@ -299,7 +299,7 @@ func TestUpdateRepoSettingsOverTheAPI(t *testing.T) {
299299 got, err := h.api("").repo.GetRepo(ctx, connect.NewRequest(&pb.GetRepoRequest{Repo: "proj"}))
300300 require.NoError(t, err)
301301 assert.Equal(t, []pb.MergeMode{pb.MergeMode_MERGE_MODE_FAST_FORWARD, pb.MergeMode_MERGE_MODE_SQUASH, pb.MergeMode_MERGE_MODE_MERGE_COMMIT}, got.Msg.GetRepo().GetSettings().GetAllowedMergeModes())
302- assert.True(t, got.Msg.GetRepo().GetSettings().GetFailingChecksBlock())
302+ assert.False(t, got.Msg.GetRepo().GetSettings().GetAllowAutoMerge())
303303
304304 update := func(c *clients, paths []string, s *pb.RepoSettings) (*pb.Repo, error) {
305305 res, err := c.repo.UpdateRepo(ctx, connect.NewRequest(&pb.UpdateRepoRequest{
@@ -311,23 +311,23 @@ func TestUpdateRepoSettingsOverTheAPI(t *testing.T) {
311311 return res.Msg.GetRepo(), nil
312312 }
313313 repo, err := update(root, []string{"settings.allowed_merge_modes"}, &pb.RepoSettings{
314- AllowedMergeModes: []pb.MergeMode{pb.MergeMode_MERGE_MODE_SQUASH}, FailingChecksBlock: false,
314+ AllowedMergeModes: []pb.MergeMode{pb.MergeMode_MERGE_MODE_SQUASH}, AllowAutoMerge: false,
315315 })
316316 require.NoError(t, err)
317317 assert.Equal(t, []pb.MergeMode{pb.MergeMode_MERGE_MODE_SQUASH}, repo.GetSettings().GetAllowedMergeModes())
318- assert.True(t, repo.GetSettings().GetFailingChecksBlock(), "not named in the mask: unchanged")
318+ assert.False(t, repo.GetSettings().GetAllowAutoMerge(), "not named in the mask: unchanged")
319319
320- repo, err = update(root, []string{"settings.failing_checks_block"}, &pb.RepoSettings{FailingChecksBlock: false})
320+ repo, err = update(root, []string{"settings.allow_auto_merge"}, &pb.RepoSettings{AllowAutoMerge: false})
321321 require.NoError(t, err)
322- assert.False(t, repo.GetSettings().GetFailingChecksBlock())
322+ assert.False(t, repo.GetSettings().GetAllowAutoMerge())
323323 assert.Equal(t, []pb.MergeMode{pb.MergeMode_MERGE_MODE_SQUASH}, repo.GetSettings().GetAllowedMergeModes(), "not named in the mask: unchanged")
324324
325325 repo, err = update(root, []string{"settings"}, &pb.RepoSettings{
326- AllowedMergeModes: []pb.MergeMode{pb.MergeMode_MERGE_MODE_SQUASH, pb.MergeMode_MERGE_MODE_FAST_FORWARD, pb.MergeMode_MERGE_MODE_SQUASH}, FailingChecksBlock: true,
326+ AllowedMergeModes: []pb.MergeMode{pb.MergeMode_MERGE_MODE_SQUASH, pb.MergeMode_MERGE_MODE_FAST_FORWARD, pb.MergeMode_MERGE_MODE_SQUASH}, AllowAutoMerge: true,
327327 })
328328 require.NoError(t, err)
329329 assert.Equal(t, []pb.MergeMode{pb.MergeMode_MERGE_MODE_FAST_FORWARD, pb.MergeMode_MERGE_MODE_SQUASH}, repo.GetSettings().GetAllowedMergeModes(), "canonical order, no duplicates")
330- assert.True(t, repo.GetSettings().GetFailingChecksBlock())
330+ assert.True(t, repo.GetSettings().GetAllowAutoMerge())
331331
332332 for name, s := range map[string]*pb.RepoSettings{
333333 "no modes": {},
@@ -341,11 +341,11 @@ func TestUpdateRepoSettingsOverTheAPI(t *testing.T) {
341341 assert.Equal(t, "invalid_argument", codeName(err))
342342
343343 // repo:admin is needed.
344- _, err = update(h.api(h.token(scopes(domain.ScopeRepoRead, domain.ScopeMRMerge))), []string{"settings.failing_checks_block"}, &pb.RepoSettings{})
344+ _, err = update(h.api(h.token(scopes(domain.ScopeRepoRead, domain.ScopeMRMerge))), []string{"settings.allow_auto_merge"}, &pb.RepoSettings{})
345345 assert.Equal(t, "permission_denied", codeName(err))
346346 got, err = root.repo.GetRepo(ctx, connect.NewRequest(&pb.GetRepoRequest{Repo: "proj"}))
347347 require.NoError(t, err)
348- assert.True(t, got.Msg.GetRepo().GetSettings().GetFailingChecksBlock())
348+ assert.True(t, got.Msg.GetRepo().GetSettings().GetAllowAutoMerge())
349349 }
350350
351351 // AC4 over the API: a mode the settings do not allow is refused.
@@ -299,7 +299,7 @@ func TestUpdateRepoSettingsOverTheAPI(t *testing.T) {
299 got, err := h.api("").repo.GetRepo(ctx, connect.NewRequest(&pb.GetRepoRequest{Repo: "proj"}))299 got, err := h.api("").repo.GetRepo(ctx, connect.NewRequest(&pb.GetRepoRequest{Repo: "proj"}))
300 require.NoError(t, err)300 require.NoError(t, err)
301 assert.Equal(t, []pb.MergeMode{pb.MergeMode_MERGE_MODE_FAST_FORWARD, pb.MergeMode_MERGE_MODE_SQUASH, pb.MergeMode_MERGE_MODE_MERGE_COMMIT}, got.Msg.GetRepo().GetSettings().GetAllowedMergeModes())301 assert.Equal(t, []pb.MergeMode{pb.MergeMode_MERGE_MODE_FAST_FORWARD, pb.MergeMode_MERGE_MODE_SQUASH, pb.MergeMode_MERGE_MODE_MERGE_COMMIT}, got.Msg.GetRepo().GetSettings().GetAllowedMergeModes())
302- assert.True(t, got.Msg.GetRepo().GetSettings().GetFailingChecksBlock())302+ assert.False(t, got.Msg.GetRepo().GetSettings().GetAllowAutoMerge())
303 303
304 update := func(c *clients, paths []string, s *pb.RepoSettings) (*pb.Repo, error) {304 update := func(c *clients, paths []string, s *pb.RepoSettings) (*pb.Repo, error) {
305 res, err := c.repo.UpdateRepo(ctx, connect.NewRequest(&pb.UpdateRepoRequest{305 res, err := c.repo.UpdateRepo(ctx, connect.NewRequest(&pb.UpdateRepoRequest{
@@ -311,23 +311,23 @@ func TestUpdateRepoSettingsOverTheAPI(t *testing.T) {
311 return res.Msg.GetRepo(), nil311 return res.Msg.GetRepo(), nil
312 }312 }
313 repo, err := update(root, []string{"settings.allowed_merge_modes"}, &pb.RepoSettings{313 repo, err := update(root, []string{"settings.allowed_merge_modes"}, &pb.RepoSettings{
314- AllowedMergeModes: []pb.MergeMode{pb.MergeMode_MERGE_MODE_SQUASH}, FailingChecksBlock: false,314+ AllowedMergeModes: []pb.MergeMode{pb.MergeMode_MERGE_MODE_SQUASH}, AllowAutoMerge: false,
315 })315 })
316 require.NoError(t, err)316 require.NoError(t, err)
317 assert.Equal(t, []pb.MergeMode{pb.MergeMode_MERGE_MODE_SQUASH}, repo.GetSettings().GetAllowedMergeModes())317 assert.Equal(t, []pb.MergeMode{pb.MergeMode_MERGE_MODE_SQUASH}, repo.GetSettings().GetAllowedMergeModes())
318- assert.True(t, repo.GetSettings().GetFailingChecksBlock(), "not named in the mask: unchanged")318+ assert.False(t, repo.GetSettings().GetAllowAutoMerge(), "not named in the mask: unchanged")
319 319
320- repo, err = update(root, []string{"settings.failing_checks_block"}, &pb.RepoSettings{FailingChecksBlock: false})320+ repo, err = update(root, []string{"settings.allow_auto_merge"}, &pb.RepoSettings{AllowAutoMerge: false})
321 require.NoError(t, err)321 require.NoError(t, err)
322- assert.False(t, repo.GetSettings().GetFailingChecksBlock())322+ assert.False(t, repo.GetSettings().GetAllowAutoMerge())
323 assert.Equal(t, []pb.MergeMode{pb.MergeMode_MERGE_MODE_SQUASH}, repo.GetSettings().GetAllowedMergeModes(), "not named in the mask: unchanged")323 assert.Equal(t, []pb.MergeMode{pb.MergeMode_MERGE_MODE_SQUASH}, repo.GetSettings().GetAllowedMergeModes(), "not named in the mask: unchanged")
324 324
325 repo, err = update(root, []string{"settings"}, &pb.RepoSettings{325 repo, err = update(root, []string{"settings"}, &pb.RepoSettings{
326- AllowedMergeModes: []pb.MergeMode{pb.MergeMode_MERGE_MODE_SQUASH, pb.MergeMode_MERGE_MODE_FAST_FORWARD, pb.MergeMode_MERGE_MODE_SQUASH}, FailingChecksBlock: true,326+ AllowedMergeModes: []pb.MergeMode{pb.MergeMode_MERGE_MODE_SQUASH, pb.MergeMode_MERGE_MODE_FAST_FORWARD, pb.MergeMode_MERGE_MODE_SQUASH}, AllowAutoMerge: true,
327 })327 })
328 require.NoError(t, err)328 require.NoError(t, err)
329 assert.Equal(t, []pb.MergeMode{pb.MergeMode_MERGE_MODE_FAST_FORWARD, pb.MergeMode_MERGE_MODE_SQUASH}, repo.GetSettings().GetAllowedMergeModes(), "canonical order, no duplicates")329 assert.Equal(t, []pb.MergeMode{pb.MergeMode_MERGE_MODE_FAST_FORWARD, pb.MergeMode_MERGE_MODE_SQUASH}, repo.GetSettings().GetAllowedMergeModes(), "canonical order, no duplicates")
330- assert.True(t, repo.GetSettings().GetFailingChecksBlock())330+ assert.True(t, repo.GetSettings().GetAllowAutoMerge())
331 331
332 for name, s := range map[string]*pb.RepoSettings{332 for name, s := range map[string]*pb.RepoSettings{
333 "no modes": {},333 "no modes": {},
@@ -341,11 +341,11 @@ func TestUpdateRepoSettingsOverTheAPI(t *testing.T) {
341 assert.Equal(t, "invalid_argument", codeName(err))341 assert.Equal(t, "invalid_argument", codeName(err))
342 342
343 // repo:admin is needed.343 // repo:admin is needed.
344- _, err = update(h.api(h.token(scopes(domain.ScopeRepoRead, domain.ScopeMRMerge))), []string{"settings.failing_checks_block"}, &pb.RepoSettings{})344+ _, err = update(h.api(h.token(scopes(domain.ScopeRepoRead, domain.ScopeMRMerge))), []string{"settings.allow_auto_merge"}, &pb.RepoSettings{})
345 assert.Equal(t, "permission_denied", codeName(err))345 assert.Equal(t, "permission_denied", codeName(err))
346 got, err = root.repo.GetRepo(ctx, connect.NewRequest(&pb.GetRepoRequest{Repo: "proj"}))346 got, err = root.repo.GetRepo(ctx, connect.NewRequest(&pb.GetRepoRequest{Repo: "proj"}))
347 require.NoError(t, err)347 require.NoError(t, err)
348- assert.True(t, got.Msg.GetRepo().GetSettings().GetFailingChecksBlock())348+ assert.True(t, got.Msg.GetRepo().GetSettings().GetAllowAutoMerge())
349 }349 }
350 350
351 // AC4 over the API: a mode the settings do not allow is refused.351 // AC4 over the API: a mode the settings do not allow is refused.
M internal/domain/ci.go +1 -1
@@ -42,7 +42,7 @@ const (
4242 )
4343
4444 // PassesMerge reports whether a check that ended this way lets a merge go
45-// through under FailingChecksBlock: success, skipped and neutral do.
45+// through under the merge checks policy: success, skipped and neutral do.
4646 func (c CIConclusion) PassesMerge() bool {
4747 return c == CISuccess || c == CISkipped || c == CINeutral
4848 }
@@ -42,7 +42,7 @@ const (
42 )42 )
43 43
44 // PassesMerge reports whether a check that ended this way lets a merge go44 // PassesMerge reports whether a check that ended this way lets a merge go
45-// through under FailingChecksBlock: success, skipped and neutral do.45+// through under the merge checks policy: success, skipped and neutral do.
46 func (c CIConclusion) PassesMerge() bool {46 func (c CIConclusion) PassesMerge() bool {
47 return c == CISuccess || c == CISkipped || c == CINeutral47 return c == CISuccess || c == CISkipped || c == CINeutral
48 }48 }
M internal/domain/mergerequest.go +3 -5
@@ -46,8 +46,6 @@ type RepoSettings struct {
4646 // AllowedMergeModes is never empty, in canonical order, without
4747 // duplicates.
4848 AllowedMergeModes []MergeMode
49- // FailingChecksBlock says that a failing check prevents the merge.
50- FailingChecksBlock bool
5149 // Issues says who may write issues; the empty mode means IssuesOff.
5250 Issues IssuesMode
5351 // AllowAutoMerge says that merge requests may be set to merge themselves
@@ -55,10 +53,10 @@ type RepoSettings struct {
5553 AllowAutoMerge bool
5654 }
5755
58-// DefaultRepoSettings are the settings of a new repository: every mode, and
59-// failing checks block the merge.
56+// DefaultRepoSettings are the settings of a new repository: every merge mode,
57+// with issues and auto-merge disabled.
6058 func DefaultRepoSettings() RepoSettings {
61- return RepoSettings{AllowedMergeModes: MergeModes(), FailingChecksBlock: true, Issues: IssuesOff}
59+ return RepoSettings{AllowedMergeModes: MergeModes(), Issues: IssuesOff}
6260 }
6361
6462 // IsZero reports whether s holds no mode, which only the zero value does.
@@ -46,8 +46,6 @@ type RepoSettings struct {
46 // AllowedMergeModes is never empty, in canonical order, without46 // AllowedMergeModes is never empty, in canonical order, without
47 // duplicates.47 // duplicates.
48 AllowedMergeModes []MergeMode48 AllowedMergeModes []MergeMode
49- // FailingChecksBlock says that a failing check prevents the merge.
50- FailingChecksBlock bool
51 // Issues says who may write issues; the empty mode means IssuesOff.49 // Issues says who may write issues; the empty mode means IssuesOff.
52 Issues IssuesMode50 Issues IssuesMode
53 // AllowAutoMerge says that merge requests may be set to merge themselves51 // AllowAutoMerge says that merge requests may be set to merge themselves
@@ -55,10 +53,10 @@ type RepoSettings struct {
55 AllowAutoMerge bool53 AllowAutoMerge bool
56 }54 }
57 55
58-// DefaultRepoSettings are the settings of a new repository: every mode, and56+// DefaultRepoSettings are the settings of a new repository: every merge mode,
59-// failing checks block the merge.57+// with issues and auto-merge disabled.
60 func DefaultRepoSettings() RepoSettings {58 func DefaultRepoSettings() RepoSettings {
61- return RepoSettings{AllowedMergeModes: MergeModes(), FailingChecksBlock: true, Issues: IssuesOff}59+ return RepoSettings{AllowedMergeModes: MergeModes(), Issues: IssuesOff}
62 }60 }
63 61
64 // IsZero reports whether s holds no mode, which only the zero value does.62 // IsZero reports whether s holds no mode, which only the zero value does.
M internal/domain/mergerequest_test.go +0 -1
@@ -25,7 +25,6 @@ func TestDefaultRepoSettings(t *testing.T) {
2525 t.Parallel()
2626 s := domain.DefaultRepoSettings()
2727 assert.Equal(t, []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash, domain.MergeModeMerge}, s.AllowedMergeModes)
28- assert.True(t, s.FailingChecksBlock)
2928 assert.True(t, s.Allows(domain.MergeModeSquash))
3029 assert.False(t, domain.RepoSettings{}.Allows(domain.MergeModeSquash))
3130 assert.True(t, domain.RepoSettings{}.IsZero())
@@ -25,7 +25,6 @@ func TestDefaultRepoSettings(t *testing.T) {
25 t.Parallel()25 t.Parallel()
26 s := domain.DefaultRepoSettings()26 s := domain.DefaultRepoSettings()
27 assert.Equal(t, []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash, domain.MergeModeMerge}, s.AllowedMergeModes)27 assert.Equal(t, []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash, domain.MergeModeMerge}, s.AllowedMergeModes)
28- assert.True(t, s.FailingChecksBlock)
29 assert.True(t, s.Allows(domain.MergeModeSquash))28 assert.True(t, s.Allows(domain.MergeModeSquash))
30 assert.False(t, domain.RepoSettings{}.Allows(domain.MergeModeSquash))29 assert.False(t, domain.RepoSettings{}.Allows(domain.MergeModeSquash))
31 assert.True(t, domain.RepoSettings{}.IsZero())30 assert.True(t, domain.RepoSettings{}.IsZero())
M internal/domain/token.go +4 -2
@@ -30,6 +30,8 @@ const (
3030 ScopeMRWrite Scope = "mr:write"
3131 // ScopeMRMerge merges, and deletes the merged source branch on request.
3232 ScopeMRMerge Scope = "mr:merge"
33+ // ScopeMRForceMerge bypasses failing or unfinished merge checks.
34+ ScopeMRForceMerge Scope = "mr:force-merge"
3335 // ScopeMRReview sets a verdict on a merge request.
3436 ScopeMRReview Scope = "mr:review"
3537 // ScopeCIWrite re-runs or cancels CI runs.
@@ -45,11 +47,11 @@ const (
4547 ScopeIssueWrite Scope = "issue:write"
4648 )
4749
48-// AllScopes returns the eleven scopes in a stable order.
50+// AllScopes returns the twelve scopes in a stable order.
4951 func AllScopes() []Scope {
5052 return []Scope{
5153 ScopeRepoRead, ScopeRepoWrite, ScopeRepoAdmin,
52- ScopeMRWrite, ScopeMRMerge, ScopeMRReview,
54+ ScopeMRWrite, ScopeMRMerge, ScopeMRForceMerge, ScopeMRReview,
5355 ScopeCIWrite, ScopeSecretsWrite, ScopeRunnerRegister,
5456 ScopeTokenAdmin,
5557 ScopeIssueWrite,
@@ -30,6 +30,8 @@ const (
30 ScopeMRWrite Scope = "mr:write"30 ScopeMRWrite Scope = "mr:write"
31 // ScopeMRMerge merges, and deletes the merged source branch on request.31 // ScopeMRMerge merges, and deletes the merged source branch on request.
32 ScopeMRMerge Scope = "mr:merge"32 ScopeMRMerge Scope = "mr:merge"
33+ // ScopeMRForceMerge bypasses failing or unfinished merge checks.
34+ ScopeMRForceMerge Scope = "mr:force-merge"
33 // ScopeMRReview sets a verdict on a merge request.35 // ScopeMRReview sets a verdict on a merge request.
34 ScopeMRReview Scope = "mr:review"36 ScopeMRReview Scope = "mr:review"
35 // ScopeCIWrite re-runs or cancels CI runs.37 // ScopeCIWrite re-runs or cancels CI runs.
@@ -45,11 +47,11 @@ const (
45 ScopeIssueWrite Scope = "issue:write"47 ScopeIssueWrite Scope = "issue:write"
46 )48 )
47 49
48-// AllScopes returns the eleven scopes in a stable order.50+// AllScopes returns the twelve scopes in a stable order.
49 func AllScopes() []Scope {51 func AllScopes() []Scope {
50 return []Scope{52 return []Scope{
51 ScopeRepoRead, ScopeRepoWrite, ScopeRepoAdmin,53 ScopeRepoRead, ScopeRepoWrite, ScopeRepoAdmin,
52- ScopeMRWrite, ScopeMRMerge, ScopeMRReview,54+ ScopeMRWrite, ScopeMRMerge, ScopeMRForceMerge, ScopeMRReview,
53 ScopeCIWrite, ScopeSecretsWrite, ScopeRunnerRegister,55 ScopeCIWrite, ScopeSecretsWrite, ScopeRunnerRegister,
54 ScopeTokenAdmin,56 ScopeTokenAdmin,
55 ScopeIssueWrite,57 ScopeIssueWrite,
M internal/domain/token_test.go +1 -1
@@ -16,7 +16,7 @@ import (
1616 func TestScopes(t *testing.T) {
1717 t.Parallel()
1818 all := domain.AllScopes()
19- assert.Len(t, all, 11)
19+ assert.Len(t, all, 12)
2020 assert.Contains(t, all, domain.ScopeIssueWrite)
2121 got, err := domain.ParseScope("issue:write")
2222 require.NoError(t, err)
@@ -16,7 +16,7 @@ import (
16 func TestScopes(t *testing.T) {16 func TestScopes(t *testing.T) {
17 t.Parallel()17 t.Parallel()
18 all := domain.AllScopes()18 all := domain.AllScopes()
19- assert.Len(t, all, 11)19+ assert.Len(t, all, 12)
20 assert.Contains(t, all, domain.ScopeIssueWrite)20 assert.Contains(t, all, domain.ScopeIssueWrite)
21 got, err := domain.ParseScope("issue:write")21 got, err := domain.ParseScope("issue:write")
22 require.NoError(t, err)22 require.NoError(t, err)
M internal/ports/dto.go +2 -2
@@ -47,8 +47,6 @@ type UpdateRepoParams struct {
4747 // AllowedMergeModes replaces the allowed merge modes; it must hold at
4848 // least one known mode.
4949 AllowedMergeModes *[]domain.MergeMode
50- // FailingChecksBlock replaces the setting of that name.
51- FailingChecksBlock *bool
5250 // Issues replaces the issues mode.
5351 Issues *domain.IssuesMode
5452 // AllowAutoMerge replaces the setting of that name.
@@ -381,6 +379,8 @@ type AddCommentParams struct {
381379
382380 // MergeParams describes the merge to perform.
383381 type MergeParams struct {
382+ // IgnoreChecks bypasses checks and requires mr:force-merge.
383+ IgnoreChecks bool
384384 // Mode defaults to fast-forward.
385385 Mode domain.MergeMode
386386 // HeadID is the commit the caller reviewed; the source branch must still
@@ -47,8 +47,6 @@ type UpdateRepoParams struct {
47 // AllowedMergeModes replaces the allowed merge modes; it must hold at47 // AllowedMergeModes replaces the allowed merge modes; it must hold at
48 // least one known mode.48 // least one known mode.
49 AllowedMergeModes *[]domain.MergeMode49 AllowedMergeModes *[]domain.MergeMode
50- // FailingChecksBlock replaces the setting of that name.
51- FailingChecksBlock *bool
52 // Issues replaces the issues mode.50 // Issues replaces the issues mode.
53 Issues *domain.IssuesMode51 Issues *domain.IssuesMode
54 // AllowAutoMerge replaces the setting of that name.52 // AllowAutoMerge replaces the setting of that name.
@@ -381,6 +379,8 @@ type AddCommentParams struct {
381 379
382 // MergeParams describes the merge to perform.380 // MergeParams describes the merge to perform.
383 type MergeParams struct {381 type MergeParams struct {
382+ // IgnoreChecks bypasses checks and requires mr:force-merge.
383+ IgnoreChecks bool
384 // Mode defaults to fast-forward.384 // Mode defaults to fast-forward.
385 Mode domain.MergeMode385 Mode domain.MergeMode
386 // HeadID is the commit the caller reviewed; the source branch must still386 // HeadID is the commit the caller reviewed; the source branch must still
M internal/usecase/access.go +1 -1
@@ -36,7 +36,7 @@ func canReadAs(p domain.Principal, name domain.RepoName, vis domain.Visibility)
3636 // repo:admin on it, and no branch prefixes. A token limited to some branches
3737 // may not delete the repository (that deletes every branch, those outside its
3838 // prefixes included), nor change its default branch, visibility, description,
39-// merge modes or failing-checks rule: these decide what happens to branches the
39+// merge modes or auto-merge settings: these decide what happens to branches the
4040 // token may not write. A caller that may not administer it learns nothing it
4141 // could not already read: not found when it cannot see the repository,
4242 // unauthenticated when it is anonymous, forbidden otherwise.
@@ -36,7 +36,7 @@ func canReadAs(p domain.Principal, name domain.RepoName, vis domain.Visibility)
36 // repo:admin on it, and no branch prefixes. A token limited to some branches36 // repo:admin on it, and no branch prefixes. A token limited to some branches
37 // may not delete the repository (that deletes every branch, those outside its37 // may not delete the repository (that deletes every branch, those outside its
38 // prefixes included), nor change its default branch, visibility, description,38 // prefixes included), nor change its default branch, visibility, description,
39-// merge modes or failing-checks rule: these decide what happens to branches the39+// merge modes or auto-merge settings: these decide what happens to branches the
40 // token may not write. A caller that may not administer it learns nothing it40 // token may not write. A caller that may not administer it learns nothing it
41 // could not already read: not found when it cannot see the repository,41 // could not already read: not found when it cannot see the repository,
42 // unauthenticated when it is anonymous, forbidden otherwise.42 // unauthenticated when it is anonymous, forbidden otherwise.
M internal/usecase/automerge_test.go +1 -4
@@ -159,7 +159,7 @@ func TestAutoMergeMergesAsEnablerWhenAChecksPassed(t *testing.T) {
159159 require.True(t, found)
160160 }
161161
162-func TestAutoMergeIgnoresFailingChecksBlockOff(t *testing.T) {
162+func TestAutoMergeRequiresPassingChecks(t *testing.T) {
163163 for _, tc := range []struct {
164164 name string
165165 check domain.CICheck
@@ -170,8 +170,6 @@ func TestAutoMergeIgnoresFailingChecksBlockOff(t *testing.T) {
170170 t.Run(tc.name, func(t *testing.T) {
171171 r, mr, checks := autoRig(t)
172172 enableAuto(t, r, mr)
173- _, err := r.repos.Update(r.ctx, root, r.repo, ports.UpdateRepoParams{FailingChecksBlock: ptr(false)})
174- require.NoError(t, err)
175173 checks.EXPECT().CommitChecks(mock.Anything, r.repo, r.head().String()).Return([]domain.CICheck{{Status: domain.CICompleted, Conclusion: domain.CISuccess}, tc.check}, nil)
176174 a := autoEvaluator(r, checks, nil, nil)
177175 defer a.Close(r.ctx)
@@ -388,7 +386,6 @@ func TestMergeGateSettingsUpdateWaits(t *testing.T) {
388386 for _, params := range []ports.UpdateRepoParams{
389387 {AllowAutoMerge: ptr(false)},
390388 {AllowedMergeModes: ptr([]domain.MergeMode{domain.MergeModeSquash})},
391- {FailingChecksBlock: ptr(false)},
392389 } {
393390 r, _, _ := autoRig(t)
394391 r.repos.WithMergeGate(r.mrs)
@@ -159,7 +159,7 @@ func TestAutoMergeMergesAsEnablerWhenAChecksPassed(t *testing.T) {
159 require.True(t, found)159 require.True(t, found)
160 }160 }
161 161
162-func TestAutoMergeIgnoresFailingChecksBlockOff(t *testing.T) {162+func TestAutoMergeRequiresPassingChecks(t *testing.T) {
163 for _, tc := range []struct {163 for _, tc := range []struct {
164 name string164 name string
165 check domain.CICheck165 check domain.CICheck
@@ -170,8 +170,6 @@ func TestAutoMergeIgnoresFailingChecksBlockOff(t *testing.T) {
170 t.Run(tc.name, func(t *testing.T) {170 t.Run(tc.name, func(t *testing.T) {
171 r, mr, checks := autoRig(t)171 r, mr, checks := autoRig(t)
172 enableAuto(t, r, mr)172 enableAuto(t, r, mr)
173- _, err := r.repos.Update(r.ctx, root, r.repo, ports.UpdateRepoParams{FailingChecksBlock: ptr(false)})
174- require.NoError(t, err)
175 checks.EXPECT().CommitChecks(mock.Anything, r.repo, r.head().String()).Return([]domain.CICheck{{Status: domain.CICompleted, Conclusion: domain.CISuccess}, tc.check}, nil)173 checks.EXPECT().CommitChecks(mock.Anything, r.repo, r.head().String()).Return([]domain.CICheck{{Status: domain.CICompleted, Conclusion: domain.CISuccess}, tc.check}, nil)
176 a := autoEvaluator(r, checks, nil, nil)174 a := autoEvaluator(r, checks, nil, nil)
177 defer a.Close(r.ctx)175 defer a.Close(r.ctx)
@@ -388,7 +386,6 @@ func TestMergeGateSettingsUpdateWaits(t *testing.T) {
388 for _, params := range []ports.UpdateRepoParams{386 for _, params := range []ports.UpdateRepoParams{
389 {AllowAutoMerge: ptr(false)},387 {AllowAutoMerge: ptr(false)},
390 {AllowedMergeModes: ptr([]domain.MergeMode{domain.MergeModeSquash})},388 {AllowedMergeModes: ptr([]domain.MergeMode{domain.MergeModeSquash})},
391- {FailingChecksBlock: ptr(false)},
392 } {389 } {
393 r, _, _ := autoRig(t)390 r, _, _ := autoRig(t)
394 r.repos.WithMergeGate(r.mrs)391 r.repos.WithMergeGate(r.mrs)
M internal/usecase/ci_unit_test.go +37 -6
@@ -24,7 +24,7 @@ type mergeUnit struct {
2424 head domain.ObjectID
2525 }
2626
27-func newMergeUnit(t *testing.T, block bool) *mergeUnit {
27+func newMergeUnit(t *testing.T) *mergeUnit {
2828 t.Helper()
2929 u := &mrUnit{
3030 meta: mocks.NewRepoMetaStore(t),
@@ -34,7 +34,6 @@ func newMergeUnit(t *testing.T, block bool) *mergeUnit {
3434 repo: domain.Repo{Name: name(t, "proj"), Visibility: domain.VisibilityPrivate},
3535 }
3636 u.repo.Settings = domain.DefaultRepoSettings()
37- u.repo.Settings.FailingChecksBlock = block
3837 clock := mocks.NewClock(t)
3938 clock.EXPECT().Now().Return(now).Maybe()
4039 sched := mocks.NewMaintenanceScheduler(t)
@@ -63,8 +62,8 @@ func (m *mergeUnit) merge() error {
6362 return err
6463 }
6564
66-// AC5: the merge rule under FailingChecksBlock.
67-func TestMergeRuleOfFailingChecksBlock(t *testing.T) {
65+// AC5: the merge rule under ChecksPolicy.
66+func TestMergeRuleOfChecksPolicy(t *testing.T) {
6867 t.Parallel()
6968 check := func(status domain.CIStatus, c domain.CIConclusion, name string) domain.CICheck {
7069 return domain.CICheck{Name: name, Status: status, Conclusion: c}
@@ -84,7 +83,7 @@ func TestMergeRuleOfFailingChecksBlock(t *testing.T) {
8483 for _, tt := range tests {
8584 t.Run(tt.name, func(t *testing.T) {
8685 t.Parallel()
87- m := newMergeUnit(t, true)
86+ m := newMergeUnit(t)
8887 m.checks.EXPECT().CommitChecks(mock.Anything, m.repo.Name, m.head.String()).Return(tt.checks, nil)
8988 // The merge stops at the rule: the branch mock expects no move.
9089 err := m.merge()
@@ -94,7 +93,7 @@ func TestMergeRuleOfFailingChecksBlock(t *testing.T) {
9493 }
9594 t.Run("too many checks to tell", func(t *testing.T) {
9695 t.Parallel()
97- m := newMergeUnit(t, true)
96+ m := newMergeUnit(t)
9897 m.checks.EXPECT().CommitChecks(mock.Anything, m.repo.Name, m.head.String()).Return(nil, domain.ErrResourceExhausted)
9998 err := m.merge()
10099 require.ErrorIs(t, err, domain.ErrPreconditionFailed)
@@ -174,3 +173,35 @@ func TestTokenShapes(t *testing.T) {
174173 assert.False(t, domain.IsJobToken("ermite_"+good[10:]))
175174 assert.False(t, domain.IsJobToken(good+"="))
176175 }
176+
177+func TestMergeChecksAndOverride(t *testing.T) {
178+ for _, tc := range []struct {
179+ name string
180+ principal domain.Principal
181+ checks []domain.CICheck
182+ ignore bool
183+ want error
184+ }{
185+ {name: "no checks allowed", principal: writer(domain.ScopeRepoRead, domain.ScopeMRMerge)},
186+ {name: "failure refused", principal: root, checks: []domain.CICheck{{Name: "build", Status: domain.CICompleted, Conclusion: domain.CIFailure}}, want: domain.ErrPreconditionFailed},
187+ {name: "pending refused", principal: root, checks: []domain.CICheck{{Name: "build", Status: domain.CIQueued}}, want: domain.ErrPreconditionFailed},
188+ {name: "force scope bypasses failure", principal: writer(domain.ScopeRepoRead, domain.ScopeMRMerge, domain.ScopeMRForceMerge), checks: []domain.CICheck{{Status: domain.CICompleted, Conclusion: domain.CIFailure}}, ignore: true},
189+ {name: "missing force scope denied", principal: writer(domain.ScopeRepoRead, domain.ScopeMRMerge), ignore: true, want: domain.ErrForbidden},
190+ {name: "force scope alone insufficient", principal: writer(domain.ScopeRepoRead, domain.ScopeMRForceMerge), ignore: true, want: domain.ErrForbidden},
191+ {name: "root override", principal: domain.Root(), ignore: true},
192+ {name: "owner override", principal: domain.Owner(), ignore: true},
193+ } {
194+ t.Run(tc.name, func(t *testing.T) {
195+ r, mr, checks := autoRig(t)
196+ if !tc.ignore {
197+ checks.EXPECT().CommitChecks(mock.Anything, r.repo, r.head().String()).Return(tc.checks, nil).Once()
198+ }
199+ _, err := r.mrs.Merge(r.ctx, tc.principal, r.repo, mr.Number, ports.MergeParams{HeadID: r.head(), IgnoreChecks: tc.ignore})
200+ if tc.want != nil {
201+ require.ErrorIs(t, err, tc.want)
202+ } else {
203+ require.NoError(t, err)
204+ }
205+ })
206+ }
207+}
@@ -24,7 +24,7 @@ type mergeUnit struct {
24 head domain.ObjectID24 head domain.ObjectID
25 }25 }
26 26
27-func newMergeUnit(t *testing.T, block bool) *mergeUnit {27+func newMergeUnit(t *testing.T) *mergeUnit {
28 t.Helper()28 t.Helper()
29 u := &mrUnit{29 u := &mrUnit{
30 meta: mocks.NewRepoMetaStore(t),30 meta: mocks.NewRepoMetaStore(t),
@@ -34,7 +34,6 @@ func newMergeUnit(t *testing.T, block bool) *mergeUnit {
34 repo: domain.Repo{Name: name(t, "proj"), Visibility: domain.VisibilityPrivate},34 repo: domain.Repo{Name: name(t, "proj"), Visibility: domain.VisibilityPrivate},
35 }35 }
36 u.repo.Settings = domain.DefaultRepoSettings()36 u.repo.Settings = domain.DefaultRepoSettings()
37- u.repo.Settings.FailingChecksBlock = block
38 clock := mocks.NewClock(t)37 clock := mocks.NewClock(t)
39 clock.EXPECT().Now().Return(now).Maybe()38 clock.EXPECT().Now().Return(now).Maybe()
40 sched := mocks.NewMaintenanceScheduler(t)39 sched := mocks.NewMaintenanceScheduler(t)
@@ -63,8 +62,8 @@ func (m *mergeUnit) merge() error {
63 return err62 return err
64 }63 }
65 64
66-// AC5: the merge rule under FailingChecksBlock.65+// AC5: the merge rule under ChecksPolicy.
67-func TestMergeRuleOfFailingChecksBlock(t *testing.T) {66+func TestMergeRuleOfChecksPolicy(t *testing.T) {
68 t.Parallel()67 t.Parallel()
69 check := func(status domain.CIStatus, c domain.CIConclusion, name string) domain.CICheck {68 check := func(status domain.CIStatus, c domain.CIConclusion, name string) domain.CICheck {
70 return domain.CICheck{Name: name, Status: status, Conclusion: c}69 return domain.CICheck{Name: name, Status: status, Conclusion: c}
@@ -84,7 +83,7 @@ func TestMergeRuleOfFailingChecksBlock(t *testing.T) {
84 for _, tt := range tests {83 for _, tt := range tests {
85 t.Run(tt.name, func(t *testing.T) {84 t.Run(tt.name, func(t *testing.T) {
86 t.Parallel()85 t.Parallel()
87- m := newMergeUnit(t, true)86+ m := newMergeUnit(t)
88 m.checks.EXPECT().CommitChecks(mock.Anything, m.repo.Name, m.head.String()).Return(tt.checks, nil)87 m.checks.EXPECT().CommitChecks(mock.Anything, m.repo.Name, m.head.String()).Return(tt.checks, nil)
89 // The merge stops at the rule: the branch mock expects no move.88 // The merge stops at the rule: the branch mock expects no move.
90 err := m.merge()89 err := m.merge()
@@ -94,7 +93,7 @@ func TestMergeRuleOfFailingChecksBlock(t *testing.T) {
94 }93 }
95 t.Run("too many checks to tell", func(t *testing.T) {94 t.Run("too many checks to tell", func(t *testing.T) {
96 t.Parallel()95 t.Parallel()
97- m := newMergeUnit(t, true)96+ m := newMergeUnit(t)
98 m.checks.EXPECT().CommitChecks(mock.Anything, m.repo.Name, m.head.String()).Return(nil, domain.ErrResourceExhausted)97 m.checks.EXPECT().CommitChecks(mock.Anything, m.repo.Name, m.head.String()).Return(nil, domain.ErrResourceExhausted)
99 err := m.merge()98 err := m.merge()
100 require.ErrorIs(t, err, domain.ErrPreconditionFailed)99 require.ErrorIs(t, err, domain.ErrPreconditionFailed)
@@ -174,3 +173,35 @@ func TestTokenShapes(t *testing.T) {
174 assert.False(t, domain.IsJobToken("ermite_"+good[10:]))173 assert.False(t, domain.IsJobToken("ermite_"+good[10:]))
175 assert.False(t, domain.IsJobToken(good+"="))174 assert.False(t, domain.IsJobToken(good+"="))
176 }175 }
176+
177+func TestMergeChecksAndOverride(t *testing.T) {
178+ for _, tc := range []struct {
179+ name string
180+ principal domain.Principal
181+ checks []domain.CICheck
182+ ignore bool
183+ want error
184+ }{
185+ {name: "no checks allowed", principal: writer(domain.ScopeRepoRead, domain.ScopeMRMerge)},
186+ {name: "failure refused", principal: root, checks: []domain.CICheck{{Name: "build", Status: domain.CICompleted, Conclusion: domain.CIFailure}}, want: domain.ErrPreconditionFailed},
187+ {name: "pending refused", principal: root, checks: []domain.CICheck{{Name: "build", Status: domain.CIQueued}}, want: domain.ErrPreconditionFailed},
188+ {name: "force scope bypasses failure", principal: writer(domain.ScopeRepoRead, domain.ScopeMRMerge, domain.ScopeMRForceMerge), checks: []domain.CICheck{{Status: domain.CICompleted, Conclusion: domain.CIFailure}}, ignore: true},
189+ {name: "missing force scope denied", principal: writer(domain.ScopeRepoRead, domain.ScopeMRMerge), ignore: true, want: domain.ErrForbidden},
190+ {name: "force scope alone insufficient", principal: writer(domain.ScopeRepoRead, domain.ScopeMRForceMerge), ignore: true, want: domain.ErrForbidden},
191+ {name: "root override", principal: domain.Root(), ignore: true},
192+ {name: "owner override", principal: domain.Owner(), ignore: true},
193+ } {
194+ t.Run(tc.name, func(t *testing.T) {
195+ r, mr, checks := autoRig(t)
196+ if !tc.ignore {
197+ checks.EXPECT().CommitChecks(mock.Anything, r.repo, r.head().String()).Return(tc.checks, nil).Once()
198+ }
199+ _, err := r.mrs.Merge(r.ctx, tc.principal, r.repo, mr.Number, ports.MergeParams{HeadID: r.head(), IgnoreChecks: tc.ignore})
200+ if tc.want != nil {
201+ require.ErrorIs(t, err, tc.want)
202+ } else {
203+ require.NoError(t, err)
204+ }
205+ })
206+ }
207+}
M internal/usecase/mergerequests.go +12 -5
@@ -995,8 +995,9 @@ func (m *MergeRequests) MarkRead(ctx context.Context, p domain.Principal, name d
995995 // allowed by the settings of the repository, the source branch no longer
996996 // points to the head ("branch moved, read again") or is gone, the target is
997997 // gone, the mode's own condition does not hold (see below), or the branch
998-// brings nothing. Checks come with CI (CI-1): there are none yet, so the
999-// setting that lets failing checks block the merge has nothing to act on.
998+// brings nothing. Checks on the head must pass: none may be failed, queued
999+// or running; a head with no checks may be merged. params.IgnoreChecks bypasses
1000+// this requirement and needs mr:force-merge.
10001001 //
10011002 // The conditions are always checked with Git, under the lock, never from the
10021003 // stored mergeability. A fast-forward needs the target to be an ancestor of
@@ -1075,9 +1076,15 @@ var errAutoMergeWait = errors.New("auto-merge is waiting for checks")
10751076
10761077 func (m *MergeRequests) mergeAs(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params ports.MergeParams, opts mergeOpts) (domain.MergeRequest, error) {
10771078 if opts.auto == nil {
1078- if _, err := m.writable(ctx, p, name, domain.ScopeMRMerge); err != nil {
1079+ repo, err := m.writable(ctx, p, name, domain.ScopeMRMerge)
1080+ if err != nil {
10791081 return domain.MergeRequest{}, err
10801082 }
1083+ if params.IgnoreChecks {
1084+ if err := scopeAccess(p, repo, domain.ScopeMRForceMerge); err != nil {
1085+ return domain.MergeRequest{}, err
1086+ }
1087+ }
10811088 }
10821089 mode := params.Mode
10831090 if mode == "" {
@@ -1179,7 +1186,7 @@ func (m *MergeRequests) mergeAs(ctx context.Context, p domain.Principal, name do
11791186 if opts.auto != nil && !settings.AllowAutoMerge {
11801187 return domain.MergeRequest{}, fmt.Errorf("%w: auto-merge is off in %s", domain.ErrPreconditionFailed, name)
11811188 }
1182- if settings.FailingChecksBlock || opts.auto != nil {
1189+ if !params.IgnoreChecks || opts.auto != nil {
11831190 checks, err := m.readMergeChecks(ctx, name, head)
11841191 if err == nil {
11851192 err = checksPolicy(checks)
@@ -1362,7 +1369,7 @@ func checksPolicy(checks []domain.CICheck) error {
13621369 }
13631370 switch {
13641371 case len(failing) > 0:
1365- return fmt.Errorf("%w: failing checks block the merge: %s (turn off failing_checks_block to merge anyway)", domain.ErrPreconditionFailed, strings.Join(failing, ", "))
1372+ return fmt.Errorf("%w: failing checks block the merge: %s (merge with --ignore-checks, needs the mr:force-merge scope)", domain.ErrPreconditionFailed, strings.Join(failing, ", "))
13661373 case len(pending) > 0:
13671374 return fmt.Errorf("%w: checks have not finished: %s", domain.ErrPreconditionFailed, strings.Join(pending, ", "))
13681375 }
@@ -995,8 +995,9 @@ func (m *MergeRequests) MarkRead(ctx context.Context, p domain.Principal, name d
995 // allowed by the settings of the repository, the source branch no longer995 // allowed by the settings of the repository, the source branch no longer
996 // points to the head ("branch moved, read again") or is gone, the target is996 // points to the head ("branch moved, read again") or is gone, the target is
997 // gone, the mode's own condition does not hold (see below), or the branch997 // gone, the mode's own condition does not hold (see below), or the branch
998-// brings nothing. Checks come with CI (CI-1): there are none yet, so the998+// brings nothing. Checks on the head must pass: none may be failed, queued
999-// setting that lets failing checks block the merge has nothing to act on.999+// or running; a head with no checks may be merged. params.IgnoreChecks bypasses
1000+// this requirement and needs mr:force-merge.
1000 //1001 //
1001 // The conditions are always checked with Git, under the lock, never from the1002 // The conditions are always checked with Git, under the lock, never from the
1002 // stored mergeability. A fast-forward needs the target to be an ancestor of1003 // stored mergeability. A fast-forward needs the target to be an ancestor of
@@ -1075,9 +1076,15 @@ var errAutoMergeWait = errors.New("auto-merge is waiting for checks")
1075 1076
1076 func (m *MergeRequests) mergeAs(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params ports.MergeParams, opts mergeOpts) (domain.MergeRequest, error) {1077 func (m *MergeRequests) mergeAs(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params ports.MergeParams, opts mergeOpts) (domain.MergeRequest, error) {
1077 if opts.auto == nil {1078 if opts.auto == nil {
1078- if _, err := m.writable(ctx, p, name, domain.ScopeMRMerge); err != nil {1079+ repo, err := m.writable(ctx, p, name, domain.ScopeMRMerge)
1080+ if err != nil {
1079 return domain.MergeRequest{}, err1081 return domain.MergeRequest{}, err
1080 }1082 }
1083+ if params.IgnoreChecks {
1084+ if err := scopeAccess(p, repo, domain.ScopeMRForceMerge); err != nil {
1085+ return domain.MergeRequest{}, err
1086+ }
1087+ }
1081 }1088 }
1082 mode := params.Mode1089 mode := params.Mode
1083 if mode == "" {1090 if mode == "" {
@@ -1179,7 +1186,7 @@ func (m *MergeRequests) mergeAs(ctx context.Context, p domain.Principal, name do
1179 if opts.auto != nil && !settings.AllowAutoMerge {1186 if opts.auto != nil && !settings.AllowAutoMerge {
1180 return domain.MergeRequest{}, fmt.Errorf("%w: auto-merge is off in %s", domain.ErrPreconditionFailed, name)1187 return domain.MergeRequest{}, fmt.Errorf("%w: auto-merge is off in %s", domain.ErrPreconditionFailed, name)
1181 }1188 }
1182- if settings.FailingChecksBlock || opts.auto != nil {1189+ if !params.IgnoreChecks || opts.auto != nil {
1183 checks, err := m.readMergeChecks(ctx, name, head)1190 checks, err := m.readMergeChecks(ctx, name, head)
1184 if err == nil {1191 if err == nil {
1185 err = checksPolicy(checks)1192 err = checksPolicy(checks)
@@ -1362,7 +1369,7 @@ func checksPolicy(checks []domain.CICheck) error {
1362 }1369 }
1363 switch {1370 switch {
1364 case len(failing) > 0:1371 case len(failing) > 0:
1365- return fmt.Errorf("%w: failing checks block the merge: %s (turn off failing_checks_block to merge anyway)", domain.ErrPreconditionFailed, strings.Join(failing, ", "))1372+ return fmt.Errorf("%w: failing checks block the merge: %s (merge with --ignore-checks, needs the mr:force-merge scope)", domain.ErrPreconditionFailed, strings.Join(failing, ", "))
1366 case len(pending) > 0:1373 case len(pending) > 0:
1367 return fmt.Errorf("%w: checks have not finished: %s", domain.ErrPreconditionFailed, strings.Join(pending, ", "))1374 return fmt.Errorf("%w: checks have not finished: %s", domain.ErrPreconditionFailed, strings.Join(pending, ", "))
1368 }1375 }
M internal/usecase/mergerequests_integration_test.go +5 -5
@@ -1205,19 +1205,19 @@ func TestUpdateRepoSettings(t *testing.T) {
12051205
12061206 modes := []domain.MergeMode{domain.MergeModeSquash, domain.MergeModeSquash}
12071207 off := false
1208- got, err := r.repos.Update(r.ctx, root, r.repo, ports.UpdateRepoParams{AllowedMergeModes: &modes, FailingChecksBlock: &off})
1208+ got, err := r.repos.Update(r.ctx, root, r.repo, ports.UpdateRepoParams{AllowedMergeModes: &modes, AllowAutoMerge: &off})
12091209 require.NoError(t, err)
1210- assert.Equal(t, domain.RepoSettings{AllowedMergeModes: []domain.MergeMode{domain.MergeModeSquash}, FailingChecksBlock: false, Issues: domain.IssuesOff}, got.Settings)
1210+ assert.Equal(t, domain.RepoSettings{AllowedMergeModes: []domain.MergeMode{domain.MergeModeSquash}, AllowAutoMerge: false, Issues: domain.IssuesOff}, got.Settings)
12111211 again, err := r.repos.Get(r.ctx, root, r.repo)
12121212 require.NoError(t, err)
12131213 assert.Equal(t, got.Settings, again.Settings)
12141214
12151215 // Only the named setting changes.
12161216 on := true
1217- got, err = r.repos.Update(r.ctx, root, r.repo, ports.UpdateRepoParams{FailingChecksBlock: &on})
1217+ got, err = r.repos.Update(r.ctx, root, r.repo, ports.UpdateRepoParams{AllowAutoMerge: &on})
12181218 require.NoError(t, err)
12191219 assert.Equal(t, []domain.MergeMode{domain.MergeModeSquash}, got.Settings.AllowedMergeModes)
1220- assert.True(t, got.Settings.FailingChecksBlock)
1220+ assert.True(t, got.Settings.AllowAutoMerge)
12211221
12221222 for _, bad := range [][]domain.MergeMode{{}, nil, {"rebase"}} {
12231223 bad := bad
@@ -1229,7 +1229,7 @@ func TestUpdateRepoSettings(t *testing.T) {
12291229 assert.Equal(t, got.Settings, again.Settings, "a refused change stores nothing")
12301230
12311231 // repo:admin is needed, like for any other field.
1232- _, err = r.repos.Update(r.ctx, writer(domain.ScopeRepoRead, domain.ScopeMRMerge), r.repo, ports.UpdateRepoParams{FailingChecksBlock: &off})
1232+ _, err = r.repos.Update(r.ctx, writer(domain.ScopeRepoRead, domain.ScopeMRMerge), r.repo, ports.UpdateRepoParams{AllowAutoMerge: &off})
12331233 assert.ErrorIs(t, err, domain.ErrForbidden)
12341234 }
12351235
@@ -1205,19 +1205,19 @@ func TestUpdateRepoSettings(t *testing.T) {
1205 1205
1206 modes := []domain.MergeMode{domain.MergeModeSquash, domain.MergeModeSquash}1206 modes := []domain.MergeMode{domain.MergeModeSquash, domain.MergeModeSquash}
1207 off := false1207 off := false
1208- got, err := r.repos.Update(r.ctx, root, r.repo, ports.UpdateRepoParams{AllowedMergeModes: &modes, FailingChecksBlock: &off})1208+ got, err := r.repos.Update(r.ctx, root, r.repo, ports.UpdateRepoParams{AllowedMergeModes: &modes, AllowAutoMerge: &off})
1209 require.NoError(t, err)1209 require.NoError(t, err)
1210- assert.Equal(t, domain.RepoSettings{AllowedMergeModes: []domain.MergeMode{domain.MergeModeSquash}, FailingChecksBlock: false, Issues: domain.IssuesOff}, got.Settings)1210+ assert.Equal(t, domain.RepoSettings{AllowedMergeModes: []domain.MergeMode{domain.MergeModeSquash}, AllowAutoMerge: false, Issues: domain.IssuesOff}, got.Settings)
1211 again, err := r.repos.Get(r.ctx, root, r.repo)1211 again, err := r.repos.Get(r.ctx, root, r.repo)
1212 require.NoError(t, err)1212 require.NoError(t, err)
1213 assert.Equal(t, got.Settings, again.Settings)1213 assert.Equal(t, got.Settings, again.Settings)
1214 1214
1215 // Only the named setting changes.1215 // Only the named setting changes.
1216 on := true1216 on := true
1217- got, err = r.repos.Update(r.ctx, root, r.repo, ports.UpdateRepoParams{FailingChecksBlock: &on})1217+ got, err = r.repos.Update(r.ctx, root, r.repo, ports.UpdateRepoParams{AllowAutoMerge: &on})
1218 require.NoError(t, err)1218 require.NoError(t, err)
1219 assert.Equal(t, []domain.MergeMode{domain.MergeModeSquash}, got.Settings.AllowedMergeModes)1219 assert.Equal(t, []domain.MergeMode{domain.MergeModeSquash}, got.Settings.AllowedMergeModes)
1220- assert.True(t, got.Settings.FailingChecksBlock)1220+ assert.True(t, got.Settings.AllowAutoMerge)
1221 1221
1222 for _, bad := range [][]domain.MergeMode{{}, nil, {"rebase"}} {1222 for _, bad := range [][]domain.MergeMode{{}, nil, {"rebase"}} {
1223 bad := bad1223 bad := bad
@@ -1229,7 +1229,7 @@ func TestUpdateRepoSettings(t *testing.T) {
1229 assert.Equal(t, got.Settings, again.Settings, "a refused change stores nothing")1229 assert.Equal(t, got.Settings, again.Settings, "a refused change stores nothing")
1230 1230
1231 // repo:admin is needed, like for any other field.1231 // repo:admin is needed, like for any other field.
1232- _, err = r.repos.Update(r.ctx, writer(domain.ScopeRepoRead, domain.ScopeMRMerge), r.repo, ports.UpdateRepoParams{FailingChecksBlock: &off})1232+ _, err = r.repos.Update(r.ctx, writer(domain.ScopeRepoRead, domain.ScopeMRMerge), r.repo, ports.UpdateRepoParams{AllowAutoMerge: &off})
1233 assert.ErrorIs(t, err, domain.ErrForbidden)1233 assert.ErrorIs(t, err, domain.ErrForbidden)
1234 }1234 }
1235 1235
M internal/usecase/repos_write.go +4 -7
@@ -62,7 +62,7 @@ func (r *Repos) Update(ctx context.Context, p domain.Principal, name domain.Repo
6262 }
6363 before := repo
6464 if params.Description == nil && params.Topics == nil && params.Visibility == nil && params.DefaultBranch == nil &&
65- params.AllowedMergeModes == nil && params.FailingChecksBlock == nil && params.AllowAutoMerge == nil && params.Issues == nil {
65+ params.AllowedMergeModes == nil && params.AllowAutoMerge == nil && params.Issues == nil {
6666 return domain.Repo{}, fmt.Errorf("%w: nothing to update", domain.ErrInvalid)
6767 }
6868 if params.AllowedMergeModes != nil {
@@ -116,12 +116,12 @@ func (r *Repos) Update(ctx context.Context, p domain.Principal, name domain.Repo
116116 // branch was checked above, so the remaining causes are a vanished
117117 // repository or an I/O failure.
118118 metaDone := false
119- if params.Description != nil || params.Topics != nil || params.Visibility != nil || params.AllowedMergeModes != nil || params.FailingChecksBlock != nil || params.AllowAutoMerge != nil || params.Issues != nil {
119+ if params.Description != nil || params.Topics != nil || params.Visibility != nil || params.AllowedMergeModes != nil || params.AllowAutoMerge != nil || params.Issues != nil {
120120 update := func() error {
121121 repo, err = r.updateMeta(ctx, name, params)
122122 return err
123123 }
124- if r.mergeGate != nil && (params.AllowedMergeModes != nil || params.FailingChecksBlock != nil || params.AllowAutoMerge != nil) {
124+ if r.mergeGate != nil && (params.AllowedMergeModes != nil || params.AllowAutoMerge != nil) {
125125 err = r.mergeGate.WithMergeLock(ctx, name, update)
126126 } else {
127127 err = update()
@@ -154,7 +154,7 @@ func (r *Repos) Update(ctx context.Context, p domain.Principal, name domain.Repo
154154 Bool("topics", params.Topics != nil).
155155 Bool("visibility", params.Visibility != nil).
156156 Bool("default_branch", params.DefaultBranch != nil).
157- Bool("settings", params.AllowedMergeModes != nil || params.FailingChecksBlock != nil || params.AllowAutoMerge != nil || params.Issues != nil).
157+ Bool("settings", params.AllowedMergeModes != nil || params.AllowAutoMerge != nil || params.Issues != nil).
158158 Str("principal", p.Label()).Str("token_id", p.TokenID()).
159159 Msg("repository updated")
160160 r.bus.Publish(ctx, domain.RepoUpdated{
@@ -184,9 +184,6 @@ func (r *Repos) updateMeta(ctx context.Context, name domain.RepoName, params por
184184 if params.AllowedMergeModes != nil {
185185 repo.Settings.AllowedMergeModes = *params.AllowedMergeModes
186186 }
187- if params.FailingChecksBlock != nil {
188- repo.Settings.FailingChecksBlock = *params.FailingChecksBlock
189- }
190187 if params.AllowAutoMerge != nil {
191188 repo.Settings.AllowAutoMerge = *params.AllowAutoMerge
192189 }
@@ -62,7 +62,7 @@ func (r *Repos) Update(ctx context.Context, p domain.Principal, name domain.Repo
62 }62 }
63 before := repo63 before := repo
64 if params.Description == nil && params.Topics == nil && params.Visibility == nil && params.DefaultBranch == nil &&64 if params.Description == nil && params.Topics == nil && params.Visibility == nil && params.DefaultBranch == nil &&
65- params.AllowedMergeModes == nil && params.FailingChecksBlock == nil && params.AllowAutoMerge == nil && params.Issues == nil {65+ params.AllowedMergeModes == nil && params.AllowAutoMerge == nil && params.Issues == nil {
66 return domain.Repo{}, fmt.Errorf("%w: nothing to update", domain.ErrInvalid)66 return domain.Repo{}, fmt.Errorf("%w: nothing to update", domain.ErrInvalid)
67 }67 }
68 if params.AllowedMergeModes != nil {68 if params.AllowedMergeModes != nil {
@@ -116,12 +116,12 @@ func (r *Repos) Update(ctx context.Context, p domain.Principal, name domain.Repo
116 // branch was checked above, so the remaining causes are a vanished116 // branch was checked above, so the remaining causes are a vanished
117 // repository or an I/O failure.117 // repository or an I/O failure.
118 metaDone := false118 metaDone := false
119- if params.Description != nil || params.Topics != nil || params.Visibility != nil || params.AllowedMergeModes != nil || params.FailingChecksBlock != nil || params.AllowAutoMerge != nil || params.Issues != nil {119+ if params.Description != nil || params.Topics != nil || params.Visibility != nil || params.AllowedMergeModes != nil || params.AllowAutoMerge != nil || params.Issues != nil {
120 update := func() error {120 update := func() error {
121 repo, err = r.updateMeta(ctx, name, params)121 repo, err = r.updateMeta(ctx, name, params)
122 return err122 return err
123 }123 }
124- if r.mergeGate != nil && (params.AllowedMergeModes != nil || params.FailingChecksBlock != nil || params.AllowAutoMerge != nil) {124+ if r.mergeGate != nil && (params.AllowedMergeModes != nil || params.AllowAutoMerge != nil) {
125 err = r.mergeGate.WithMergeLock(ctx, name, update)125 err = r.mergeGate.WithMergeLock(ctx, name, update)
126 } else {126 } else {
127 err = update()127 err = update()
@@ -154,7 +154,7 @@ func (r *Repos) Update(ctx context.Context, p domain.Principal, name domain.Repo
154 Bool("topics", params.Topics != nil).154 Bool("topics", params.Topics != nil).
155 Bool("visibility", params.Visibility != nil).155 Bool("visibility", params.Visibility != nil).
156 Bool("default_branch", params.DefaultBranch != nil).156 Bool("default_branch", params.DefaultBranch != nil).
157- Bool("settings", params.AllowedMergeModes != nil || params.FailingChecksBlock != nil || params.AllowAutoMerge != nil || params.Issues != nil).157+ Bool("settings", params.AllowedMergeModes != nil || params.AllowAutoMerge != nil || params.Issues != nil).
158 Str("principal", p.Label()).Str("token_id", p.TokenID()).158 Str("principal", p.Label()).Str("token_id", p.TokenID()).
159 Msg("repository updated")159 Msg("repository updated")
160 r.bus.Publish(ctx, domain.RepoUpdated{160 r.bus.Publish(ctx, domain.RepoUpdated{
@@ -184,9 +184,6 @@ func (r *Repos) updateMeta(ctx context.Context, name domain.RepoName, params por
184 if params.AllowedMergeModes != nil {184 if params.AllowedMergeModes != nil {
185 repo.Settings.AllowedMergeModes = *params.AllowedMergeModes185 repo.Settings.AllowedMergeModes = *params.AllowedMergeModes
186 }186 }
187- if params.FailingChecksBlock != nil {
188- repo.Settings.FailingChecksBlock = *params.FailingChecksBlock
189- }
190 if params.AllowAutoMerge != nil {187 if params.AllowAutoMerge != nil {
191 repo.Settings.AllowAutoMerge = *params.AllowAutoMerge188 repo.Settings.AllowAutoMerge = *params.AllowAutoMerge
192 }189 }
M internal/usecase/repos_write_test.go +1 -3
@@ -224,13 +224,11 @@ func TestUpdate(t *testing.T) {
224224 t.Run("a token limited to branches may not change any setting", func(t *testing.T) {
225225 t.Parallel()
226226 modes := []domain.MergeMode{domain.MergeModeSquash}
227- block := true
228227 for name, params := range map[string]ports.UpdateRepoParams{
229228 "default branch": {DefaultBranch: ptr("dev")},
230229 "visibility": {Visibility: ptr(domain.VisibilityPublic)},
231230 "description": {Description: ptr("x")},
232231 "merge modes": {AllowedMergeModes: &modes},
233- "checks rule": {FailingChecksBlock: &block},
234232 "issues": {Issues: ptr(domain.IssuesOwner)},
235233 } {
236234 f := newFixture(t) // no HasBranch, no Update, no SetDefaultBranch, no Publish
@@ -289,7 +287,7 @@ func TestUpdateIssuesMode(t *testing.T) {
289287 f := newFixture(t)
290288 f.meta.EXPECT().Get(ctx, n).Return(priv, nil).Once()
291289 expectRepoMutation(t, f.meta, priv, mock.MatchedBy(func(r domain.Repo) bool {
292- return r.Settings.Issues == domain.IssuesOwner && r.Settings.FailingChecksBlock
290+ return r.Settings.Issues == domain.IssuesOwner
293291 }))
294292 f.bus.EXPECT().Publish(ctx, mock.Anything).Return().Once()
295293 repo, err := f.repos.Update(ctx, domain.Root(), n, ports.UpdateRepoParams{Issues: ptr(domain.IssuesOwner)})
@@ -224,13 +224,11 @@ func TestUpdate(t *testing.T) {
224 t.Run("a token limited to branches may not change any setting", func(t *testing.T) {224 t.Run("a token limited to branches may not change any setting", func(t *testing.T) {
225 t.Parallel()225 t.Parallel()
226 modes := []domain.MergeMode{domain.MergeModeSquash}226 modes := []domain.MergeMode{domain.MergeModeSquash}
227- block := true
228 for name, params := range map[string]ports.UpdateRepoParams{227 for name, params := range map[string]ports.UpdateRepoParams{
229 "default branch": {DefaultBranch: ptr("dev")},228 "default branch": {DefaultBranch: ptr("dev")},
230 "visibility": {Visibility: ptr(domain.VisibilityPublic)},229 "visibility": {Visibility: ptr(domain.VisibilityPublic)},
231 "description": {Description: ptr("x")},230 "description": {Description: ptr("x")},
232 "merge modes": {AllowedMergeModes: &modes},231 "merge modes": {AllowedMergeModes: &modes},
233- "checks rule": {FailingChecksBlock: &block},
234 "issues": {Issues: ptr(domain.IssuesOwner)},232 "issues": {Issues: ptr(domain.IssuesOwner)},
235 } {233 } {
236 f := newFixture(t) // no HasBranch, no Update, no SetDefaultBranch, no Publish234 f := newFixture(t) // no HasBranch, no Update, no SetDefaultBranch, no Publish
@@ -289,7 +287,7 @@ func TestUpdateIssuesMode(t *testing.T) {
289 f := newFixture(t)287 f := newFixture(t)
290 f.meta.EXPECT().Get(ctx, n).Return(priv, nil).Once()288 f.meta.EXPECT().Get(ctx, n).Return(priv, nil).Once()
291 expectRepoMutation(t, f.meta, priv, mock.MatchedBy(func(r domain.Repo) bool {289 expectRepoMutation(t, f.meta, priv, mock.MatchedBy(func(r domain.Repo) bool {
292- return r.Settings.Issues == domain.IssuesOwner && r.Settings.FailingChecksBlock290+ return r.Settings.Issues == domain.IssuesOwner
293 }))291 }))
294 f.bus.EXPECT().Publish(ctx, mock.Anything).Return().Once()292 f.bus.EXPECT().Publish(ctx, mock.Anything).Return().Once()
295 repo, err := f.repos.Update(ctx, domain.Root(), n, ports.UpdateRepoParams{Issues: ptr(domain.IssuesOwner)})293 repo, err := f.repos.Update(ctx, domain.Root(), n, ports.UpdateRepoParams{Issues: ptr(domain.IssuesOwner)})
M proto/ermite/storage/merge_request.proto +2 -1
@@ -22,7 +22,8 @@ enum MergeMode {
2222 message RepoSettings {
2323 // Never empty once stored.
2424 repeated MergeMode allowed_merge_modes = 1;
25- bool failing_checks_block = 2;
25+ // Unused: never read, never written.
26+ bool failing_checks_block = 2 [deprecated = true];
2627 // Who may write issues; unspecified reads as off.
2728 IssuesMode issues = 3;
2829 // Set on every write since merge commits exist: the modes are the explicit
@@ -22,7 +22,8 @@ enum MergeMode {
22 message RepoSettings {22 message RepoSettings {
23 // Never empty once stored.23 // Never empty once stored.
24 repeated MergeMode allowed_merge_modes = 1;24 repeated MergeMode allowed_merge_modes = 1;
25- bool failing_checks_block = 2;25+ // Unused: never read, never written.
26+ bool failing_checks_block = 2 [deprecated = true];
26 // Who may write issues; unspecified reads as off.27 // Who may write issues; unspecified reads as off.
27 IssuesMode issues = 3;28 IssuesMode issues = 3;
28 // Set on every write since merge commits exist: the modes are the explicit29 // Set on every write since merge commits exist: the modes are the explicit
M skills/ermite/CLI.md +10 -4
@@ -11,10 +11,10 @@ Errors are one line on standard error (`ermite: ...`) with exit status 1. `--jso
1111 | `repo list [--json]` | none | repositories you can see |
1212 | `repo show <name> [--json]` | none | |
1313 | `repo create <name> [--description TEXT] [--visibility public]` | `repo:write` | private by default |
14-| `repo edit <name> [--description TEXT] [--visibility V] [--default-branch B] [--merge-modes fast-forward,squash,merge] [--failing-checks-block=false] [--allow-auto-merge]` | `repo:admin` | only the given flags change; every merge mode is allowed by default, list the ones to keep; `--description ""` clears it; the branch must exist |
14+| `repo edit <name> [--description TEXT] [--visibility V] [--default-branch B] [--merge-modes fast-forward,squash,merge] [--allow-auto-merge]` | `repo:admin` | only the given flags change; every merge mode is allowed by default, list the ones to keep; `--description ""` clears it; the branch must exist |
1515 | `repo delete <name> [--yes]` | `repo:admin` | |
1616
17-Repository JSON: `name`, `description`, `visibility`, `default_branch`, `created_at`, `pushed_at` (null before the first push), `merge_modes`, `failing_checks_block`, `allow_auto_merge`.
17+Repository JSON: `name`, `description`, `visibility`, `default_branch`, `created_at`, `pushed_at` (null before the first push), `merge_modes`, `allow_auto_merge`.
1818
1919 ## Tokens
2020
@@ -28,7 +28,7 @@ Presets add up with `--scope` (repeatable or comma-separated):
2828
2929 | Preset | Scopes |
3030 |---|---|
31-| `cli` | every scope (including `mr:review`), for the owner's daily use; a token created before `mr:review` existed lacks it and loses owner rights: recreate it with `--preset cli` using the root token or from a signed-in owner web session (an old token lacking `mr:review` cannot grant it), then replace the stored CLI secret and revoke the old token |
31+| `cli` | every scope, including `mr:review` and `mr:force-merge`, for the owner's daily use |
3232 | `agent` | `repo:write`, `mr:write` |
3333 | `ci` | `repo:read` |
3434
@@ -42,6 +42,7 @@ Scopes:
4242 | `mr:write` | open, comment on, reply, edit and delete own notes, resolve, close, mark read |
4343 | `mr:review` | give a verdict (`--approve`, `--request-changes`) |
4444 | `mr:merge` | merge, and delete the merged source branch with `--delete-branch` |
45+| `mr:force-merge` | bypass failing or unfinished checks with `--ignore-checks` |
4546 | `issue:write` | open, comment on, close and reopen issues |
4647 | `token:admin` | create, list and revoke tokens |
4748 | `ci:write`, `secrets:write`, `runner:register` | reserved for CI |
@@ -50,6 +51,8 @@ A token can never grant more than the token that creates it. `--repo` (repeatabl
5051
5152 `--branch-prefix` (repeatable, up to 8, each ending with `/`, for example `agent/claude/`) limits the branches the token may write; the default is all. A limited token pushes, force-pushes and deletes only `refs/heads/<prefix>…`: any other ref (`main`, tags, notes) refuses the whole push, naming the ref. It cannot create or delete a repository or change its settings (`repo edit`: default branch, visibility, description, merge modes), merges only into branches under its prefixes (and deletes only a source branch under them), never has owner rights, and creates only tokens within its own prefixes. A token for an agent: `ermite token create claude --preset agent --scope repo:read --repo <repo> --branch-prefix agent/claude/`. If you hold such a token, work on a branch under your prefix and open a merge request; do not try `main` or tags.
5253
54+The `agent` preset lacks `mr:force-merge`; adding `--scope mr:merge` lets an agent merge only with passing checks.
55+
5356 ## Merge requests
5457
5558 | Command | Scope | Notes |
@@ -61,10 +64,13 @@ A token can never grant more than the token that creates it. `--repo` (repeatabl
6164 | `mr edit-comment <repo> <n> <comment-id> -m TEXT\|-` | `mr:write` | author only, open merge request; the verdict is unchanged |
6265 | `mr delete-comment <repo> <n> <comment-id>` | `mr:write` | author only; the text is lost, the verdict is kept |
6366 | `mr resolve <repo> <n> <comment-id> [--undo]` | `mr:write` | on the thread root, covers its replies; doing it twice is not an error |
64-| `mr merge <repo> <n> --head <full-id> [--mode fast-forward\|squash\|merge] [--delete-branch]` | `mr:merge` | refused if the branch moved, if `fast-forward` and the target is not an ancestor of the head (behind: use `squash` or `merge`), or if `squash`/`merge` conflict; squash and merge commits are signed by the server key (`signing-key show`); answers once the target moved; `--delete-branch` deletes the source after the merge (refused for the default branch or a branch another open request uses; JSON adds `source_branch_deleted`) |
67+| `mr merge <repo> <n> --head <full-id> [--mode fast-forward\|squash\|merge] [--delete-branch] [--ignore-checks]` | `mr:merge` | refused if the branch moved, if `fast-forward` and the target is not an ancestor of the head (behind: use `squash` or `merge`), or if `squash`/`merge` conflict; squash and merge commits are signed by the server key (`signing-key show`); answers once the target moved; `--delete-branch` deletes the source after the merge (refused for the default branch or a branch another open request uses; JSON adds `source_branch_deleted`) |
6568 | `mr merge <repo> <n> --head <full-id> --auto [--mode M]` / `mr merge <repo> <n> --cancel-auto` | `mr:merge` | the server merges as you once CI settled on the head with a passed check and no failing one; needs `repo edit --allow-auto-merge`; failing checks or conflicts wait with auto-merge on; a new head cancels it, as do a deleted source, a disallowed mode, the setting turned off or your token losing merge rights; a manual merge supersedes it (`merge_request_merged`, `auto=false`, no cancelled event); a manual close clears it (`merge_request_closed` then cancelled with reason `closed`); JSON `auto_merge` (null unless pending), `auto_merged` |
69+| `mr ready <repo> <n>` | `mr:write` | removes the `WIP:` or `[WIP]` title prefix; needs an open draft |
6670 | `mr close <repo> <n>` | `mr:write` | a closed merge request cannot be reopened |
6771
72+A merge needs passing checks on the head: none failed, queued or running. A head with no checks can merge. `mr merge --ignore-checks` bypasses checks, needs `mr:force-merge`, and cannot be combined with `--auto` or `--cancel-auto`.
73+
6874 `ermite mr create` and `ermite mr close` print a confirmation followed by the full
6975 merge request web URL on its own line. With `--json`, merge request objects
7076 (create, show, list, close and merge) include `url` alongside the other fields.
@@ -11,10 +11,10 @@ Errors are one line on standard error (`ermite: ...`) with exit status 1. `--jso
11 | `repo list [--json]` | none | repositories you can see |11 | `repo list [--json]` | none | repositories you can see |
12 | `repo show <name> [--json]` | none | |12 | `repo show <name> [--json]` | none | |
13 | `repo create <name> [--description TEXT] [--visibility public]` | `repo:write` | private by default |13 | `repo create <name> [--description TEXT] [--visibility public]` | `repo:write` | private by default |
14-| `repo edit <name> [--description TEXT] [--visibility V] [--default-branch B] [--merge-modes fast-forward,squash,merge] [--failing-checks-block=false] [--allow-auto-merge]` | `repo:admin` | only the given flags change; every merge mode is allowed by default, list the ones to keep; `--description ""` clears it; the branch must exist |14+| `repo edit <name> [--description TEXT] [--visibility V] [--default-branch B] [--merge-modes fast-forward,squash,merge] [--allow-auto-merge]` | `repo:admin` | only the given flags change; every merge mode is allowed by default, list the ones to keep; `--description ""` clears it; the branch must exist |
15 | `repo delete <name> [--yes]` | `repo:admin` | |15 | `repo delete <name> [--yes]` | `repo:admin` | |
16 16
17-Repository JSON: `name`, `description`, `visibility`, `default_branch`, `created_at`, `pushed_at` (null before the first push), `merge_modes`, `failing_checks_block`, `allow_auto_merge`.17+Repository JSON: `name`, `description`, `visibility`, `default_branch`, `created_at`, `pushed_at` (null before the first push), `merge_modes`, `allow_auto_merge`.
18 18
19 ## Tokens19 ## Tokens
20 20
@@ -28,7 +28,7 @@ Presets add up with `--scope` (repeatable or comma-separated):
28 28
29 | Preset | Scopes |29 | Preset | Scopes |
30 |---|---|30 |---|---|
31-| `cli` | every scope (including `mr:review`), for the owner's daily use; a token created before `mr:review` existed lacks it and loses owner rights: recreate it with `--preset cli` using the root token or from a signed-in owner web session (an old token lacking `mr:review` cannot grant it), then replace the stored CLI secret and revoke the old token |31+| `cli` | every scope, including `mr:review` and `mr:force-merge`, for the owner's daily use |
32 | `agent` | `repo:write`, `mr:write` |32 | `agent` | `repo:write`, `mr:write` |
33 | `ci` | `repo:read` |33 | `ci` | `repo:read` |
34 34
@@ -42,6 +42,7 @@ Scopes:
42 | `mr:write` | open, comment on, reply, edit and delete own notes, resolve, close, mark read |42 | `mr:write` | open, comment on, reply, edit and delete own notes, resolve, close, mark read |
43 | `mr:review` | give a verdict (`--approve`, `--request-changes`) |43 | `mr:review` | give a verdict (`--approve`, `--request-changes`) |
44 | `mr:merge` | merge, and delete the merged source branch with `--delete-branch` |44 | `mr:merge` | merge, and delete the merged source branch with `--delete-branch` |
45+| `mr:force-merge` | bypass failing or unfinished checks with `--ignore-checks` |
45 | `issue:write` | open, comment on, close and reopen issues |46 | `issue:write` | open, comment on, close and reopen issues |
46 | `token:admin` | create, list and revoke tokens |47 | `token:admin` | create, list and revoke tokens |
47 | `ci:write`, `secrets:write`, `runner:register` | reserved for CI |48 | `ci:write`, `secrets:write`, `runner:register` | reserved for CI |
@@ -50,6 +51,8 @@ A token can never grant more than the token that creates it. `--repo` (repeatabl
50 51
51 `--branch-prefix` (repeatable, up to 8, each ending with `/`, for example `agent/claude/`) limits the branches the token may write; the default is all. A limited token pushes, force-pushes and deletes only `refs/heads/<prefix>…`: any other ref (`main`, tags, notes) refuses the whole push, naming the ref. It cannot create or delete a repository or change its settings (`repo edit`: default branch, visibility, description, merge modes), merges only into branches under its prefixes (and deletes only a source branch under them), never has owner rights, and creates only tokens within its own prefixes. A token for an agent: `ermite token create claude --preset agent --scope repo:read --repo <repo> --branch-prefix agent/claude/`. If you hold such a token, work on a branch under your prefix and open a merge request; do not try `main` or tags.52 `--branch-prefix` (repeatable, up to 8, each ending with `/`, for example `agent/claude/`) limits the branches the token may write; the default is all. A limited token pushes, force-pushes and deletes only `refs/heads/<prefix>…`: any other ref (`main`, tags, notes) refuses the whole push, naming the ref. It cannot create or delete a repository or change its settings (`repo edit`: default branch, visibility, description, merge modes), merges only into branches under its prefixes (and deletes only a source branch under them), never has owner rights, and creates only tokens within its own prefixes. A token for an agent: `ermite token create claude --preset agent --scope repo:read --repo <repo> --branch-prefix agent/claude/`. If you hold such a token, work on a branch under your prefix and open a merge request; do not try `main` or tags.
52 53
54+The `agent` preset lacks `mr:force-merge`; adding `--scope mr:merge` lets an agent merge only with passing checks.
55+
53 ## Merge requests56 ## Merge requests
54 57
55 | Command | Scope | Notes |58 | Command | Scope | Notes |
@@ -61,10 +64,13 @@ A token can never grant more than the token that creates it. `--repo` (repeatabl
61 | `mr edit-comment <repo> <n> <comment-id> -m TEXT\|-` | `mr:write` | author only, open merge request; the verdict is unchanged |64 | `mr edit-comment <repo> <n> <comment-id> -m TEXT\|-` | `mr:write` | author only, open merge request; the verdict is unchanged |
62 | `mr delete-comment <repo> <n> <comment-id>` | `mr:write` | author only; the text is lost, the verdict is kept |65 | `mr delete-comment <repo> <n> <comment-id>` | `mr:write` | author only; the text is lost, the verdict is kept |
63 | `mr resolve <repo> <n> <comment-id> [--undo]` | `mr:write` | on the thread root, covers its replies; doing it twice is not an error |66 | `mr resolve <repo> <n> <comment-id> [--undo]` | `mr:write` | on the thread root, covers its replies; doing it twice is not an error |
64-| `mr merge <repo> <n> --head <full-id> [--mode fast-forward\|squash\|merge] [--delete-branch]` | `mr:merge` | refused if the branch moved, if `fast-forward` and the target is not an ancestor of the head (behind: use `squash` or `merge`), or if `squash`/`merge` conflict; squash and merge commits are signed by the server key (`signing-key show`); answers once the target moved; `--delete-branch` deletes the source after the merge (refused for the default branch or a branch another open request uses; JSON adds `source_branch_deleted`) |67+| `mr merge <repo> <n> --head <full-id> [--mode fast-forward\|squash\|merge] [--delete-branch] [--ignore-checks]` | `mr:merge` | refused if the branch moved, if `fast-forward` and the target is not an ancestor of the head (behind: use `squash` or `merge`), or if `squash`/`merge` conflict; squash and merge commits are signed by the server key (`signing-key show`); answers once the target moved; `--delete-branch` deletes the source after the merge (refused for the default branch or a branch another open request uses; JSON adds `source_branch_deleted`) |
65 | `mr merge <repo> <n> --head <full-id> --auto [--mode M]` / `mr merge <repo> <n> --cancel-auto` | `mr:merge` | the server merges as you once CI settled on the head with a passed check and no failing one; needs `repo edit --allow-auto-merge`; failing checks or conflicts wait with auto-merge on; a new head cancels it, as do a deleted source, a disallowed mode, the setting turned off or your token losing merge rights; a manual merge supersedes it (`merge_request_merged`, `auto=false`, no cancelled event); a manual close clears it (`merge_request_closed` then cancelled with reason `closed`); JSON `auto_merge` (null unless pending), `auto_merged` |68 | `mr merge <repo> <n> --head <full-id> --auto [--mode M]` / `mr merge <repo> <n> --cancel-auto` | `mr:merge` | the server merges as you once CI settled on the head with a passed check and no failing one; needs `repo edit --allow-auto-merge`; failing checks or conflicts wait with auto-merge on; a new head cancels it, as do a deleted source, a disallowed mode, the setting turned off or your token losing merge rights; a manual merge supersedes it (`merge_request_merged`, `auto=false`, no cancelled event); a manual close clears it (`merge_request_closed` then cancelled with reason `closed`); JSON `auto_merge` (null unless pending), `auto_merged` |
69+| `mr ready <repo> <n>` | `mr:write` | removes the `WIP:` or `[WIP]` title prefix; needs an open draft |
66 | `mr close <repo> <n>` | `mr:write` | a closed merge request cannot be reopened |70 | `mr close <repo> <n>` | `mr:write` | a closed merge request cannot be reopened |
67 71
72+A merge needs passing checks on the head: none failed, queued or running. A head with no checks can merge. `mr merge --ignore-checks` bypasses checks, needs `mr:force-merge`, and cannot be combined with `--auto` or `--cancel-auto`.
73+
68 `ermite mr create` and `ermite mr close` print a confirmation followed by the full74 `ermite mr create` and `ermite mr close` print a confirmation followed by the full
69 merge request web URL on its own line. With `--json`, merge request objects75 merge request web URL on its own line. With `--json`, merge request objects
70 (create, show, list, close and merge) include `url` alongside the other fields.76 (create, show, list, close and merge) include `url` alongside the other fields.
M skills/ermite/SKILL.md +5 -3
@@ -37,9 +37,11 @@ git push https://git.example.com/<repo>.git main # password = token,
3737 2. Read: `ermite mr show <repo> <n> --diff --json`. Keep `head_id`; take line numbers from the hunks.
3838 3. Comment on a line: `ermite mr comment <repo> <n> -m "text" --path <file> --line <n> --side new`; `--line 10-14` covers a range on one side. Without `--path` the comment is general. The line must appear in the diff. Notes are Markdown; `^N` refers to note N.
3939 4. End a review with a verdict (needs `mr:review`): `--approve` or `--request-changes`, alone or with a message. Verdicts are informative and cannot be edited; a new verdict replaces the previous one.
40-5. Merge only when asked: `ermite mr merge <repo> <n> --head <head_id>`, with `--mode squash` for one commit or `--mode merge` for a merge commit (both signed by the server's key, see `ermite signing-key show`) and `--delete-branch` to delete the source branch afterwards.
40+5. Merge only when the human asks explicitly. If the request is a draft, run `ermite mr ready <repo> <n>` first (removes the `WIP:` or `[WIP]` prefix; needs `mr:write`). Merge: `ermite mr merge <repo> <n> --head <head_id>`, with `--mode squash` for one commit or `--mode merge` for a merge commit (both signed by the server's key, see `ermite signing-key show`) and `--delete-branch` to delete the source branch afterwards.
4141
42-To merge once CI passed instead of now, add `--auto` (`ermite mr merge <repo> <n> --head <head_id> --auto --mode squash`; needs `repo edit --allow-auto-merge`); `--cancel-auto` cancels it.
42+To merge once CI passed automatically, add `--auto` (`ermite mr merge <repo> <n> --head <head_id> --auto --mode squash`; needs `repo edit --allow-auto-merge`); `--cancel-auto` cancels it.
43+
44+A merge needs passing checks on the head (none failed, queued or running); a head with no checks can merge. Never use `--ignore-checks` unless the human asks for it. It needs `mr:force-merge` and cannot be combined with `--auto` or `--cancel-auto`.
4345
4446 The merge is refused when the branch moved since `head_id` (review again) or, for `fast-forward`, when the target is not an ancestor of the head (rebase and push again, or use `squash`/`merge`), or when `squash`/`merge` conflict. `ermite mr show` prints the `mergeability` line (up to date, behind by N, conflict with paths). The repository may allow only some merge modes.
4547
@@ -63,7 +65,7 @@ Open one: `ermite mr create <repo> --source <branch> --title "..."`. After new p
6365 ermite token create my-agent --preset agent --repo <repo> --expires 30d
6466 ```
6567
66-The secret is printed once. Presets: `agent` (push, open and comment on merge requests; no verdicts), `ci` (read), `cli` (everything). Add `--scope mr:review` to let an agent give verdicts, `--scope mr:merge` to let it merge. A token can never grant more than the token that creates it. Full scope list: [CLI.md](CLI.md).
68+The secret is printed once. Presets: `agent` (push, open and comment on merge requests; no verdicts), `ci` (read), `cli` (every scope, including `mr:force-merge`). Add `--scope mr:review` to let an agent give verdicts, `--scope mr:merge` to let it merge only with passing checks. The `agent` preset does not include `mr:force-merge`. A token can never grant more than the token that creates it. Full scope list: [CLI.md](CLI.md).
6769
6870 ## Follow events
6971
@@ -37,9 +37,11 @@ git push https://git.example.com/<repo>.git main # password = token,
37 2. Read: `ermite mr show <repo> <n> --diff --json`. Keep `head_id`; take line numbers from the hunks.37 2. Read: `ermite mr show <repo> <n> --diff --json`. Keep `head_id`; take line numbers from the hunks.
38 3. Comment on a line: `ermite mr comment <repo> <n> -m "text" --path <file> --line <n> --side new`; `--line 10-14` covers a range on one side. Without `--path` the comment is general. The line must appear in the diff. Notes are Markdown; `^N` refers to note N.38 3. Comment on a line: `ermite mr comment <repo> <n> -m "text" --path <file> --line <n> --side new`; `--line 10-14` covers a range on one side. Without `--path` the comment is general. The line must appear in the diff. Notes are Markdown; `^N` refers to note N.
39 4. End a review with a verdict (needs `mr:review`): `--approve` or `--request-changes`, alone or with a message. Verdicts are informative and cannot be edited; a new verdict replaces the previous one.39 4. End a review with a verdict (needs `mr:review`): `--approve` or `--request-changes`, alone or with a message. Verdicts are informative and cannot be edited; a new verdict replaces the previous one.
40-5. Merge only when asked: `ermite mr merge <repo> <n> --head <head_id>`, with `--mode squash` for one commit or `--mode merge` for a merge commit (both signed by the server's key, see `ermite signing-key show`) and `--delete-branch` to delete the source branch afterwards.40+5. Merge only when the human asks explicitly. If the request is a draft, run `ermite mr ready <repo> <n>` first (removes the `WIP:` or `[WIP]` prefix; needs `mr:write`). Merge: `ermite mr merge <repo> <n> --head <head_id>`, with `--mode squash` for one commit or `--mode merge` for a merge commit (both signed by the server's key, see `ermite signing-key show`) and `--delete-branch` to delete the source branch afterwards.
41 41
42-To merge once CI passed instead of now, add `--auto` (`ermite mr merge <repo> <n> --head <head_id> --auto --mode squash`; needs `repo edit --allow-auto-merge`); `--cancel-auto` cancels it.42+To merge once CI passed automatically, add `--auto` (`ermite mr merge <repo> <n> --head <head_id> --auto --mode squash`; needs `repo edit --allow-auto-merge`); `--cancel-auto` cancels it.
43+
44+A merge needs passing checks on the head (none failed, queued or running); a head with no checks can merge. Never use `--ignore-checks` unless the human asks for it. It needs `mr:force-merge` and cannot be combined with `--auto` or `--cancel-auto`.
43 45
44 The merge is refused when the branch moved since `head_id` (review again) or, for `fast-forward`, when the target is not an ancestor of the head (rebase and push again, or use `squash`/`merge`), or when `squash`/`merge` conflict. `ermite mr show` prints the `mergeability` line (up to date, behind by N, conflict with paths). The repository may allow only some merge modes.46 The merge is refused when the branch moved since `head_id` (review again) or, for `fast-forward`, when the target is not an ancestor of the head (rebase and push again, or use `squash`/`merge`), or when `squash`/`merge` conflict. `ermite mr show` prints the `mergeability` line (up to date, behind by N, conflict with paths). The repository may allow only some merge modes.
45 47
@@ -63,7 +65,7 @@ Open one: `ermite mr create <repo> --source <branch> --title "..."`. After new p
63 ermite token create my-agent --preset agent --repo <repo> --expires 30d65 ermite token create my-agent --preset agent --repo <repo> --expires 30d
64 ```66 ```
65 67
66-The secret is printed once. Presets: `agent` (push, open and comment on merge requests; no verdicts), `ci` (read), `cli` (everything). Add `--scope mr:review` to let an agent give verdicts, `--scope mr:merge` to let it merge. A token can never grant more than the token that creates it. Full scope list: [CLI.md](CLI.md).68+The secret is printed once. Presets: `agent` (push, open and comment on merge requests; no verdicts), `ci` (read), `cli` (every scope, including `mr:force-merge`). Add `--scope mr:review` to let an agent give verdicts, `--scope mr:merge` to let it merge only with passing checks. The `agent` preset does not include `mr:force-merge`. A token can never grant more than the token that creates it. Full scope list: [CLI.md](CLI.md).
67 69
68 ## Follow events70 ## Follow events
69 71

Keyboard shortcuts

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

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

Branches 2

Tags 42

↑↓ moveenter openesc close