Merge eba8ed78a9322e7b454e9d156553ffe57cc7d139 into d5e05051ad53ec339fe415696d1b993cbc0c7948
56 files · +636 -272
M docs/ci.md +1 -1
| @@ -44,7 +44,7 @@ Like `gh workflow disable`: a disabled workflow starts no run for any event (`pu | |||
| 44 | 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 | 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 | 49 | ## Trust | |
| 50 | 50 | ||
| @@ -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 | ## Trust | 49 | ## Trust | ||
| 50 | 50 | ||||
M docs/cli.md +4 -2
| @@ -48,7 +48,7 @@ It does not run: | |||
| 48 | 48 | ||
| 49 | 49 | ```sh | |
| 50 | 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 | 52 | ermite repo delete <name> [--yes] | |
| 53 | 53 | ||
| 54 | 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 | 57 | ermite mr create <repo> --source <branch> --title … | list <repo> | show <repo> <n> [--diff] # show prints the mergeability: up to date, behind, conflict | |
| 58 | 58 | ermite mr comment <repo> <n> -m … [--path … --line N|A-B --side old|new] [--reply <id>] [--approve|--request-changes] | |
| 59 | 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 | 63 | ermite issue create <repo> --title … [--body … | --body-file …] | list <repo> [--state open|closed|all] | |
| 63 | 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 | 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 | 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 | 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 | 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 | 106 | - Errors are one line on stderr (`ermite: …`) with exit status 1. | |
| @@ -48,7 +48,7 @@ It does not run: | |||||
| 48 | 48 | ||||
| 49 | ```sh | 49 | ```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, conflict | 57 | 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 | |||
| 14 | 14 | merge request web URL on its own line. With `--json`, merge request objects | |
| 15 | 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 | 19 | ## Merge modes | |
| 18 | 20 | ||
| 19 | 21 | | Mode | Result | Needs | | |
| @@ -25,10 +27,10 @@ merge request web URL on its own line. With `--json`, merge request objects | |||
| 25 | 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 | 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 | 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 | 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 | 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 | 35 | ## Signed commits | |
| 34 | 36 | ||
| @@ -47,7 +49,7 @@ A merge request can merge by itself once its checks passed. | |||
| 47 | 49 | ||
| 48 | 50 | - The repository setting `allow_auto_merge` is off by default: `ermite repo edit <repo> --allow-auto-merge` (`=false` to turn it off). | |
| 49 | 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 | 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 | 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 | 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). | |
| @@ -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 objects | 14 | 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 modes | 19 | ## 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 commits | 35 | ## 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 +4 -13 generated file: collapsed
M go.mod +2 -0
| @@ -172,3 +172,5 @@ require ( | |||
| 172 | 172 | mvdan.cc/xurls/v2 v2.6.0 // indirect | |
| 173 | 173 | pluginrpc.com/pluginrpc v0.5.0 // indirect | |
| 174 | 174 | ) | |
| 175 | + | ||
| 176 | + | replace bnema.dev/ermite-api => ../../../ermite-api/.worktrees/mr-ready-merge-checks | |
| @@ -172,3 +172,5 @@ require ( | |||||
| 172 | mvdan.cc/xurls/v2 v2.6.0 // indirect | 172 | mvdan.cc/xurls/v2 v2.6.0 // indirect | ||
| 173 | pluginrpc.com/pluginrpc v0.5.0 // indirect | 173 | pluginrpc.com/pluginrpc v0.5.0 // indirect | ||
| 174 | ) | 174 | ) | ||
| 175 | + | ||||
| 176 | + | replace bnema.dev/ermite-api => ../../../ermite-api/.worktrees/mr-ready-merge-checks | |||
M go.sum +0 -2
| @@ -1,7 +1,5 @@ | |||
| 1 | 1 | bnema.dev/ermite-actions v0.2.0 h1:238UBhSZcm8RxqqjZvX3RLGKQNyKtxoXKnKIkYt4P/M= | |
| 2 | 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= | |
| 4 | - | bnema.dev/ermite-api v0.14.0/go.mod h1:L5smjt+IAigyAo3Ny2EnFB9IwvIGOPT1GoByZNgbDa8= | |
| 5 | 3 | bnema.dev/ermite-git v0.1.0 h1:8GNhQDj0MxyvrI4kHewZ9k0rT86e9348T3BJ49WHfmI= | |
| 6 | 4 | bnema.dev/ermite-git v0.1.0/go.mod h1:m9d4VYHm28mrY6VxVqWQMRIsadGq80FeLub/nrZX6Cg= | |
| 7 | 5 | bnema.dev/ermite-kv v0.1.0 h1:NbD76jgqMq08AVTe/l0EEqDTHjywaYNoqC5c/WgYRcE= | |
| @@ -1,7 +1,5 @@ | |||||
| 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= | |||
| 4 | - | bnema.dev/ermite-api v0.14.0/go.mod h1:L5smjt+IAigyAo3Ny2EnFB9IwvIGOPT1GoByZNgbDa8= | |||
| 5 | bnema.dev/ermite-git v0.1.0 h1:8GNhQDj0MxyvrI4kHewZ9k0rT86e9348T3BJ49WHfmI= | 3 | bnema.dev/ermite-git v0.1.0 h1:8GNhQDj0MxyvrI4kHewZ9k0rT86e9348T3BJ49WHfmI= | ||
| 6 | bnema.dev/ermite-git v0.1.0/go.mod h1:m9d4VYHm28mrY6VxVqWQMRIsadGq80FeLub/nrZX6Cg= | 4 | bnema.dev/ermite-git v0.1.0/go.mod h1:m9d4VYHm28mrY6VxVqWQMRIsadGq80FeLub/nrZX6Cg= | ||
| 7 | bnema.dev/ermite-kv v0.1.0 h1:NbD76jgqMq08AVTe/l0EEqDTHjywaYNoqC5c/WgYRcE= | 5 | 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" | |||
| 8 | 8 | // ServerService.GetVersion. A server without one still works for everything | |
| 9 | 9 | // else: the CLI warns instead of failing. | |
| 10 | 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 | 15 | // FeatureRepoUpdate is RepoService.UpdateRepo (`ermite repo edit`). | |
| 12 | 16 | FeatureRepoUpdate = "repo.update" | |
| 13 | 17 | // FeatureEventsResume is EventService.Watch with resume_after, which | |
| @@ -8,6 +8,10 @@ import "slices" | |||||
| 8 | // ServerService.GetVersion. A server without one still works for everything | 8 | // 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, which | 17 | // 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} | |||
| 28 | 28 | // ScopeName is the name of a scope as users type it and as the docs write | |
| 29 | 29 | // it: SCOPE_REPO_READ is "repo:read". | |
| 30 | 30 | func ScopeName(s ermite.Scope) string { | |
| 31 | + | if s == ermite.Scope_SCOPE_MR_FORCE_MERGE { | |
| 32 | + | return "mr:force-merge" | |
| 33 | + | } | |
| 31 | 34 | name, ok := ermite.Scope_name[int32(s)] | |
| 32 | 35 | if !ok { | |
| 33 | 36 | 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 write | 28 | // 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 ( | |||
| 16 | 16 | ||
| 17 | 17 | func TestScopeWireNames(t *testing.T) { | |
| 18 | 18 | want := map[ermite.Scope]string{ | |
| 19 | + | ermite.Scope_SCOPE_MR_FORCE_MERGE: "mr:force-merge", | |
| 19 | 20 | ermite.Scope_SCOPE_MR_REVIEW: "mr:review", | |
| 20 | 21 | ermite.Scope_SCOPE_REPO_READ: "repo:read", | |
| 21 | 22 | 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) { | |||
| 271 | 271 | ||
| 272 | 272 | // TestSchema4UpgradesTheTokensWithAllScopes: the tokens that held every | |
| 273 | 273 | // 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 | 275 | func TestSchema4UpgradesTheTokensWithAllScopes(t *testing.T) { | |
| 276 | 276 | ctx := context.Background() | |
| 277 | 277 | dir := t.TempDir() | |
| @@ -309,17 +309,20 @@ func TestSchema4UpgradesTheTokensWithAllScopes(t *testing.T) { | |||
| 309 | 309 | full := get("full") | |
| 310 | 310 | assert.Contains(t, full.Scopes, domain.ScopeIssueWrite) | |
| 311 | 311 | assert.Contains(t, full.Scopes, domain.ScopeMRReview) | |
| 312 | - | assert.Len(t, full.Scopes, 11) | |
| 312 | + | assert.Len(t, full.Scopes, 12) | |
| 313 | 313 | full10 := get("full10") | |
| 314 | - | assert.Len(t, full10.Scopes, 11) | |
| 314 | + | assert.Len(t, full10.Scopes, 12) | |
| 315 | 315 | assert.Equal(t, 1, countScope(full10.Scopes, domain.ScopeMRReview)) | |
| 316 | 316 | assert.Contains(t, full10.Scopes, domain.ScopeIssueWrite) | |
| 317 | 317 | assert.True(t, domain.TokenPrincipal(full10).HasOwnerRights()) | |
| 318 | 318 | assert.Len(t, get("reposmr").Scopes, 10) | |
| 319 | 319 | assert.NotContains(t, get("reposmr").Scopes, domain.ScopeIssueWrite) | |
| 320 | 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 | 326 | assert.NotContains(t, get("missing").Scopes, domain.ScopeIssueWrite) | |
| 324 | 327 | assert.NotContains(t, get("missing").Scopes, domain.ScopeMRReview) | |
| 325 | 328 | assert.NotContains(t, get("repos").Scopes, domain.ScopeMRReview) | |
| @@ -335,7 +338,7 @@ func TestSchema4UpgradesTheTokensWithAllScopes(t *testing.T) { | |||
| 335 | 338 | assert.Equal(t, "full", full.ID) | |
| 336 | 339 | ||
| 337 | 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 | 343 | // A second open changes nothing. | |
| 341 | 344 | s2, err := kvstore.Open(ctx, dir) | |
| @@ -355,7 +358,7 @@ func TestSchemaNewerThanThisBuildStopsIssuesData(t *testing.T) { | |||
| 355 | 358 | putRaw(t, dir, "meta/schema", mustProto(t, &storagepb.SchemaVersion{Version: kvstore.SchemaVersion + 1})) | |
| 356 | 359 | _, err := kvstore.Open(ctx, dir) | |
| 357 | 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 | 364 | // 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 | 565 | 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 | + | } | |
| @@ -271,7 +271,7 @@ func TestRepoSettingsKeepTheIssuesMode(t *testing.T) { | |||||
| 271 | 271 | ||||
| 272 | // TestSchema4UpgradesTheTokensWithAllScopes: the tokens that held every | 272 | // TestSchema4UpgradesTheTokensWithAllScopes: the tokens that held every | ||
| 273 | // original scope keep their owner rights, gaining mr:review and issue:write | 273 | // 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 delete | 364 | // 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 n | 565 | 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 { | |||
| 939 | 939 | } | |
| 940 | 940 | ||
| 941 | 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 | 943 | for _, m := range s.AllowedMergeModes { | |
| 944 | 944 | mode, err := encodeMode(m) | |
| 945 | 945 | if err != nil || mode == storagepb.MergeMode_MERGE_MODE_UNSPECIFIED { | |
| @@ -979,5 +979,5 @@ func decodeSettings(pb *storagepb.RepoSettings) domain.RepoSettings { | |||
| 979 | 979 | if !pb.GetMergeModesV2() && slices.Equal(normalized, []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash}) { | |
| 980 | 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 | } | |
| @@ -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) { | |||
| 470 | 470 | require.NoError(t, err) | |
| 471 | 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 | 474 | _, err = s.Mutate(ctx, n, func(r *domain.Repo) (bool, error) { r.Settings = got.Settings; return true, nil }) | |
| 475 | 475 | require.NoError(t, err) | |
| 476 | 476 | again, err := s.Get(ctx, n) | |
| @@ -634,7 +634,7 @@ func TestStoredOldDefaultModesGainMerge(t *testing.T) { | |||
| 634 | 634 | ||
| 635 | 635 | put := func(db *kv.DB, repo string, modes ...storagepb.MergeMode) { | |
| 636 | 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 | 638 | require.NoError(t, err) | |
| 639 | 639 | var b kv.Batch | |
| 640 | 640 | 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.Batch | 639 | 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 ( | |||
| 39 | 39 | // (meta/issuekey). The upgrade also adds mr:review and issue:write (each only | |
| 40 | 40 | // if missing) to the stored tokens that held all nine original scopes with no | |
| 41 | 41 | // 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 | |
| 44 | 45 | ||
| 45 | 46 | const schemaKey = "meta/schema" | |
| 46 | 47 | ||
| @@ -109,7 +110,7 @@ func (s *Store) Check(ctx context.Context) error { | |||
| 109 | 110 | func (s *Store) checkSchema(ctx context.Context) error { | |
| 110 | 111 | log := zerowrap.FromCtx(ctx) | |
| 111 | 112 | key := []byte(schemaKey) | |
| 112 | - | for range 2 { | |
| 113 | + | for range 4 { | |
| 113 | 114 | item, ok := s.db.Get(ctx, key) | |
| 114 | 115 | if !ok { | |
| 115 | 116 | var b kv.Batch | |
| @@ -140,6 +141,10 @@ func (s *Store) checkSchema(ctx context.Context) error { | |||
| 140 | 141 | // the new version is recorded so that older builds refuse it. | |
| 141 | 142 | // The tokens are upgraded in the same batch, so that a crash | |
| 142 | 143 | // leaves the old version and the upgrade runs again. | |
| 144 | + | target := uint32(SchemaVersion) | |
| 145 | + | if sv.GetVersion() < 4 { | |
| 146 | + | target = 4 | |
| 147 | + | } | |
| 143 | 148 | var b kv.Batch | |
| 144 | 149 | b.RequireVersion(key, item.Version) | |
| 145 | 150 | if sv.GetVersion() < 4 { | |
| @@ -147,7 +152,12 @@ func (s *Store) checkSchema(ctx context.Context) error { | |||
| 147 | 152 | 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 | 161 | _, err := s.db.Apply(ctx, &b) | |
| 152 | 162 | if errors.Is(err, kv.ErrConflict) { | |
| 153 | 163 | continue // changed meanwhile: read it again | |
| @@ -155,7 +165,10 @@ func (s *Store) checkSchema(ctx context.Context) error { | |||
| 155 | 165 | if err != nil { | |
| 156 | 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 | 172 | return nil | |
| 160 | 173 | } | |
| 161 | 174 | log.Info().Uint32("schema", sv.GetVersion()).Msg("storage schema checked") | |
| @@ -182,11 +195,26 @@ var preIssueScopes = []domain.Scope{ | |||
| 182 | 195 | ||
| 183 | 196 | // upgradeTokens adds to b the schema 4 upgrade of the tokens: a token that | |
| 184 | 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:write | |
| 198 | + | // had the owner rights, which schema 4 needed mr:review and issue:write | |
| 186 | 199 | // for, so it gets each of them if missing (this is no escalation). Every other | |
| 187 | 200 | // token is left as it is. The batch requires each token to be at the version it | |
| 188 | 201 | // was read at. | |
| 189 | 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 | 218 | for key, item := range s.db.Snapshot(ctx).Prefix(ctx, []byte(tokenPrefix)) { | |
| 191 | 219 | pb := &storagepb.Token{} | |
| 192 | 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 | 224 | continue | |
| 197 | 225 | } | |
| 198 | 226 | full := true | |
| 199 | - | for _, sc := range preIssueScopes { | |
| 227 | + | for _, sc := range required { | |
| 200 | 228 | if !slices.Contains(pb.GetScopes(), sc.String()) { | |
| 201 | 229 | full = false | |
| 202 | 230 | break | |
| @@ -206,7 +234,7 @@ func (s *Store) upgradeTokens(ctx context.Context, b *kv.Batch) error { | |||
| 206 | 234 | continue | |
| 207 | 235 | } | |
| 208 | 236 | added := false | |
| 209 | - | for _, sc := range []domain.Scope{domain.ScopeMRReview, domain.ScopeIssueWrite} { | |
| 237 | + | for _, sc := range addedScopes { | |
| 210 | 238 | if !slices.Contains(pb.GetScopes(), sc.String()) { | |
| 211 | 239 | pb.Scopes = append(pb.Scopes, sc.String()) | |
| 212 | 240 | added = true | |
| @@ -39,8 +39,9 @@ import ( | |||||
| 39 | // (meta/issuekey). The upgrade also adds mr:review and issue:write (each only | 39 | // (meta/issuekey). The upgrade also adds mr:review and issue:write (each only | ||
| 40 | // if missing) to the stored tokens that held all nine original scopes with no | 40 | // if missing) to the stored tokens that held all nine original scopes with no | ||
| 41 | // repository list and no branch prefix, so that they keep the owner rights (see | 41 | // 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 = 4 | 43 | + | // 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.Batch | 116 | 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 crash | 142 | // 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.Batch | 148 | 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 err | 152 | 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 again | 163 | 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 nil | 172 | 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 that | 196 | // 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:write | 198 | + | // 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 other | 199 | // for, so it gets each of them if missing (this is no escalation). Every other | ||
| 187 | // token is left as it is. The batch requires each token to be at the version it | 200 | // 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 | continue | 224 | continue | ||
| 197 | } | 225 | } | ||
| 198 | full := true | 226 | 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 = false | 229 | full = false | ||
| 202 | break | 230 | break | ||
| @@ -206,7 +234,7 @@ func (s *Store) upgradeTokens(ctx context.Context, b *kv.Batch) error { | |||||
| 206 | continue | 234 | continue | ||
| 207 | } | 235 | } | ||
| 208 | added := false | 236 | 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 = true | 240 | added = true | ||
M internal/adapters/rpc/api.go +1 -1
| @@ -35,7 +35,7 @@ const RunnerMaxRequestBytes = MaxRequestBytes + 64<<10 | |||
| 35 | 35 | // always implements, as GetVersion lists them. FeaturePasskeys comes on top | |
| 36 | 36 | // when Config.Passkeys is set, and FeatureCISecrets when the settings services | |
| 37 | 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 | 40 | // FeaturePasskeys is listed by GetVersion when passkeys are available. | |
| 41 | 41 | // 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 top | 35 | // always implements, as GetVersion lists them. FeaturePasskeys comes on top | ||
| 36 | // when Config.Passkeys is set, and FeatureCISecrets when the settings services | 36 | // 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 { | |||
| 72 | 72 | } | |
| 73 | 73 | ||
| 74 | 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 | 76 | for _, m := range s.AllowedMergeModes { | |
| 77 | 77 | out.AllowedMergeModes = append(out.AllowedMergeModes, mergeModeToPB(m)) | |
| 78 | 78 | } | |
| @@ -131,6 +131,7 @@ var scopeToPB = map[domain.Scope]pb.Scope{ | |||
| 131 | 131 | domain.ScopeRepoAdmin: pb.Scope_SCOPE_REPO_ADMIN, | |
| 132 | 132 | domain.ScopeMRWrite: pb.Scope_SCOPE_MR_WRITE, | |
| 133 | 133 | domain.ScopeMRMerge: pb.Scope_SCOPE_MR_MERGE, | |
| 134 | + | domain.ScopeMRForceMerge: pb.Scope_SCOPE_MR_FORCE_MERGE, | |
| 134 | 135 | domain.ScopeCIWrite: pb.Scope_SCOPE_CI_WRITE, | |
| 135 | 136 | domain.ScopeSecretsWrite: pb.Scope_SCOPE_SECRETS_WRITE, | |
| 136 | 137 | 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) { | |||
| 214 | 214 | // A repository value without settings (never stored with them) reads as the defaults. | |
| 215 | 215 | r := repoToPB(ports.RepoView{Repo: domain.Repo{Name: mustName(t, "x")}}) | |
| 216 | 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 | 219 | custom := domain.RepoSettings{AllowedMergeModes: []domain.MergeMode{domain.MergeModeSquash}} | |
| 220 | 220 | r = repoToPB(ports.RepoView{Repo: domain.Repo{Name: mustName(t, "x"), Settings: custom}}) | |
| 221 | 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 | 225 | func mustName(t *testing.T, s string) domain.RepoName { | |
| @@ -235,8 +235,8 @@ func TestUpdateParamsSettingsPaths(t *testing.T) { | |||
| 235 | 235 | return &pb.UpdateRepoRequest{ | |
| 236 | 236 | UpdateMask: &fieldmaskpb.FieldMask{Paths: paths}, | |
| 237 | 237 | 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, | |
| 240 | 240 | }, | |
| 241 | 241 | } | |
| 242 | 242 | } | |
| @@ -244,18 +244,18 @@ func TestUpdateParamsSettingsPaths(t *testing.T) { | |||
| 244 | 244 | require.NoError(t, err) | |
| 245 | 245 | require.NotNil(t, p.AllowedMergeModes) | |
| 246 | 246 | 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) | |
| 249 | 249 | ||
| 250 | 250 | p, err = updateParams(msg("settings.allowed_merge_modes")) | |
| 251 | 251 | require.NoError(t, err) | |
| 252 | 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 | 256 | require.NoError(t, err) | |
| 257 | 257 | assert.Nil(t, p.AllowedMergeModes) | |
| 258 | - | assert.NotNil(t, p.FailingChecksBlock) | |
| 258 | + | assert.NotNil(t, p.AllowAutoMerge) | |
| 259 | 259 | ||
| 260 | 260 | // Modes named with nothing to take them from are an empty list, which the use case refuses. | |
| 261 | 261 | 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 | |||
| 399 | 399 | return nil, connectError(ctx, err) | |
| 400 | 400 | } | |
| 401 | 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 | 404 | if err != nil { | |
| 405 | 405 | return nil, connectError(ctx, err) | |
| @@ -423,3 +423,15 @@ func (s *mergeRequestService) MarkRead(ctx context.Context, req *connect.Request | |||
| 423 | 423 | } | |
| 424 | 424 | 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 | + | } | |
| @@ -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{}), nil | 424 | 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 +30 -1
| @@ -140,6 +140,7 @@ func TestMergeRequestServiceMerge(t *testing.T) { | |||
| 140 | 140 | wantCall bool | |
| 141 | 141 | }{ | |
| 142 | 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 | 144 | {name: "head is not a full id", req: &pb.MergeMergeRequestRequest{Repo: "demo", Number: 1, HeadId: "abc"}, wantCode: connect.CodeInvalidArgument}, | |
| 144 | 145 | {name: "invalid repo", req: &pb.MergeMergeRequestRequest{Repo: "../x", Number: 1}, wantCode: connect.CodeInvalidArgument}, | |
| 145 | 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 | 153 | mrs := mocks.NewMergeRequestService(t) | |
| 153 | 154 | if tt.wantCall { | |
| 154 | 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 | 157 | })).Return(ports.MergeResult{MergeRequest: domain.MergeRequest{Number: 1}, SourceDeleted: true}, tt.usecase).Once() | |
| 157 | 158 | } | |
| 158 | 159 | resp, err := (&mergeRequestService{mrs: mrs}).MergeMergeRequest(ownerCtx(), connect.NewRequest(tt.req)) | |
| @@ -226,3 +227,31 @@ 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 | + | mrs := mocks.NewMergeRequestService(t) | |
| 245 | + | title, err := domain.ParseMRTitle("Ready") | |
| 246 | + | require.NoError(t, err) | |
| 247 | + | 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() | |
| 248 | + | res, err := (&mergeRequestService{mrs: mrs}).MarkReady(ownerCtx(), connect.NewRequest(&pb.MarkReadyRequest{Repo: "demo", Number: 1})) | |
| 249 | + | if tt.code != 0 { | |
| 250 | + | requireCode(t, tt.code, err) | |
| 251 | + | return | |
| 252 | + | } | |
| 253 | + | require.NoError(t, err) | |
| 254 | + | assert.Equal(t, "Ready", res.Msg.GetMergeRequest().GetTitle()) | |
| 255 | + | }) | |
| 256 | + | } | |
| 257 | + | } | |
| @@ -140,6 +140,7 @@ func TestMergeRequestServiceMerge(t *testing.T) { | |||||
| 140 | wantCall bool | 140 | 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,31 @@ 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 | + | mrs := mocks.NewMergeRequestService(t) | |||
| 245 | + | title, err := domain.ParseMRTitle("Ready") | |||
| 246 | + | require.NoError(t, err) | |||
| 247 | + | 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() | |||
| 248 | + | res, err := (&mergeRequestService{mrs: mrs}).MarkReady(ownerCtx(), connect.NewRequest(&pb.MarkReadyRequest{Repo: "demo", Number: 1})) | |||
| 249 | + | if tt.code != 0 { | |||
| 250 | + | requireCode(t, tt.code, err) | |||
| 251 | + | return | |||
| 252 | + | } | |||
| 253 | + | require.NoError(t, err) | |||
| 254 | + | assert.Equal(t, "Ready", res.Msg.GetMergeRequest().GetTitle()) | |||
| 255 | + | }) | |||
| 256 | + | } | |||
| 257 | + | } | |||
M internal/adapters/rpc/repo.go +2 -5
| @@ -80,7 +80,7 @@ func (s *repoService) UpdateRepo(ctx context.Context, req *connect.Request[pb.Up | |||
| 80 | 80 | ||
| 81 | 81 | // updateParams maps the field mask of an update. The mask must name at least | |
| 82 | 82 | // one of "description", "topics", "visibility", "default_branch", "settings", | |
| 83 | - | // "settings.allowed_merge_modes", "settings.failing_checks_block" and | |
| 83 | + | // "settings.allowed_merge_modes" and | |
| 84 | 84 | // "settings.allow_auto_merge", and nothing else; the optional issues field stands outside the mask, and alone | |
| 85 | 85 | // it is a valid update. | |
| 86 | 86 | func updateParams(msg *pb.UpdateRepoRequest) (ports.UpdateRepoParams, error) { | |
| @@ -113,7 +113,7 @@ func updateParams(msg *pb.UpdateRepoRequest) (ports.UpdateRepoParams, error) { | |||
| 113 | 113 | params.Visibility = &vis | |
| 114 | 114 | case "default_branch": | |
| 115 | 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 | 117 | all := path == "settings" | |
| 118 | 118 | if all || path == "settings.allowed_merge_modes" { | |
| 119 | 119 | modes, err := mergeModesFromPB(msg.GetSettings().GetAllowedMergeModes()) | |
| @@ -122,9 +122,6 @@ func updateParams(msg *pb.UpdateRepoRequest) (ports.UpdateRepoParams, error) { | |||
| 122 | 122 | } | |
| 123 | 123 | params.AllowedMergeModes = &modes | |
| 124 | 124 | } | |
| 125 | - | if all || path == "settings.failing_checks_block" { | |
| 126 | - | params.FailingChecksBlock = new(msg.GetSettings().GetFailingChecksBlock()) | |
| 127 | - | } | |
| 128 | 125 | if all || path == "settings.allow_auto_merge" { | |
| 129 | 126 | params.AllowAutoMerge = new(msg.GetSettings().GetAllowAutoMerge()) | |
| 130 | 127 | } | |
| @@ -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 least | 81 | // 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" and | 83 | + | // "settings.allowed_merge_modes" and |
| 84 | // "settings.allow_auto_merge", and nothing else; the optional issues field stands outside the mask, and alone | 84 | // "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 = &vis | 113 | 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 = &modes | 123 | 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) { | |||
| 284 | 284 | func TestUpdateParamsAllowAutoMerge(t *testing.T) { | |
| 285 | 285 | t.Parallel() | |
| 286 | 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 | 289 | params, err := updateParams(&pb.UpdateRepoRequest{UpdateMask: mask("settings.allow_auto_merge"), Settings: settings}) | |
| 290 | 290 | require.NoError(t, err) | |
| 291 | 291 | require.NotNil(t, params.AllowAutoMerge) | |
| 292 | 292 | assert.True(t, *params.AllowAutoMerge) | |
| 293 | - | assert.Nil(t, params.FailingChecksBlock, "only the masked setting is set") | |
| 294 | 293 | assert.Nil(t, params.AllowedMergeModes) | |
| 295 | 294 | ||
| 296 | 295 | params, err = updateParams(&pb.UpdateRepoRequest{UpdateMask: mask("settings"), Settings: settings}) | |
| 297 | 296 | require.NoError(t, err) | |
| 298 | 297 | require.NotNil(t, params.AllowAutoMerge, "settings covers allow_auto_merge") | |
| 299 | 298 | assert.True(t, *params.AllowAutoMerge) | |
| 300 | - | require.NotNil(t, params.FailingChecksBlock) | |
| 301 | 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 | } | |
| @@ -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 { | |||
| 111 | 111 | switch { | |
| 112 | 112 | case len(checks) == 0: | |
| 113 | 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 | 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 | 117 | return sec | |
| 120 | 118 | } | |
| @@ -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 sec | 117 | return sec | ||
| 120 | } | 118 | } | ||
M internal/adapters/web/fragments.go +18 -1
| @@ -36,6 +36,8 @@ type liveStream struct { | |||
| 36 | 36 | // head shown (its checks are on that head); a job event does not carry | |
| 37 | 37 | // its commit, so its run is read once. One stream reads it, no lock. | |
| 38 | 38 | 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 | 43 | // maxHeadRuns bounds headRuns: past it the map starts again. | |
| @@ -625,7 +627,22 @@ func (l *liveStream) mrChecks(ctx context.Context) ([]fragment, error) { | |||
| 625 | 627 | return nil, err | |
| 626 | 628 | } | |
| 627 | 629 | 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 | |
| 629 | 646 | } | |
| 630 | 647 | ||
| 631 | 648 | // 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 carry | 36 | // 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]bool | 38 | 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, err | 627 | 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}, 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 | |||
| 629 | } | 646 | } | ||
| 630 | 647 | ||||
| 631 | // recentActions is the "Recent actions" block of the summary rail, read again | 648 | // 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 | |||
| 740 | 740 | p.Modes = append(p.Modes, name) | |
| 741 | 741 | } | |
| 742 | 742 | } | |
| 743 | - | p.ChecksBlock = d.repo.Settings.FailingChecksBlock | |
| 743 | + | p.ChecksBlock = true | |
| 744 | 744 | if head != "" { | |
| 745 | 745 | checks := s.checksFor(ctx, d.repo.Name, head) | |
| 746 | 746 | 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.FailingChecksBlock | 743 | + | 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) { | |||
| 545 | 545 | </label> | |
| 546 | 546 | } | |
| 547 | 547 | </fieldset> | |
| 548 | + | @MRMergeChecks(p.ChecksSum) | |
| 548 | 549 | if p.Box.CanDeleteBranch { | |
| 549 | 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 | 635 | <dt>checks</dt> | |
| 635 | 636 | <dd> | |
| 636 | 637 | if p.ChecksBlock { | |
| 637 | - | failing ones block the merge | |
| 638 | + | failing or unfinished checks block the merge | |
| 638 | 639 | } else { | |
| 639 | 640 | do not block the merge | |
| 640 | 641 | } | |
| @@ -766,3 +767,14 @@ templ compareRail(p ComparePage) { | |||
| 766 | 767 | @CloneBlock(p.CLI) | |
| 767 | 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 | + | } | |
| @@ -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 merge | 638 | + | failing or unfinished checks block the merge |
| 638 | } else { | 639 | } else { | ||
| 639 | do not block the merge | 640 | 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, | |||
| 377 | 377 | } | |
| 378 | 378 | res, err := s.cfg.MergeRequests.Merge(ctx, principal(ctx), name, n, ports.MergeParams{ | |
| 379 | 379 | Mode: mode, HeadID: head, DeleteSource: r.PostFormValue("delete_branch") == "1", | |
| 380 | + | IgnoreChecks: r.PostFormValue("ignore_checks") == "on", | |
| 380 | 381 | }) | |
| 381 | 382 | switch { | |
| 382 | 383 | 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 +34 -1
| @@ -161,7 +161,7 @@ func TestMRPanelsAndMenus(t *testing.T) { | |||
| 161 | 161 | assert.NotContains(t, rail, text) | |
| 162 | 162 | } | |
| 163 | 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 | 165 | assert.Contains(t, more, text) | |
| 166 | 166 | } | |
| 167 | 167 | merge := renderToString(t, mergeMenu(p)) | |
| @@ -297,3 +297,36 @@ func TestAutoMergeLiveEvents(t *testing.T) { | |||
| 297 | 297 | assert.True(t, liveEvent(watchOf(domain.MergeRequestAutoMergeEnabled{}))) | |
| 298 | 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 | + | box := (&Server{}).mergeBox(&repoData{repo: repoWithModes(domain.MergeModes()...)}, 3, "feat", "abc", true, mergeabilityView(domain.Mergeability{State: domain.MergeabilityUpToDate}, "main")) | |
| 306 | + | html := renderToString(t, mergeMenu(MRPage{Box: box, ChecksSum: ChecksSummary{Kind: kind}})) | |
| 307 | + | if kind == "bad" || kind == "running" { | |
| 308 | + | assert.Contains(t, html, `name="ignore_checks"`) | |
| 309 | + | assert.Contains(t, html, "Ignore checks") | |
| 310 | + | } else { | |
| 311 | + | assert.NotContains(t, html, `name="ignore_checks"`) | |
| 312 | + | } | |
| 313 | + | assert.True(t, box.CanMerge()) | |
| 314 | + | }) | |
| 315 | + | } | |
| 316 | + | name, err := domain.ParseRepoName("demo") | |
| 317 | + | require.NoError(t, err) | |
| 318 | + | head, err := domain.ParseObjectID(strings.Repeat("a", domain.ObjectIDLen)) | |
| 319 | + | require.NoError(t, err) | |
| 320 | + | for _, ignore := range []bool{false, true} { | |
| 321 | + | svc := mocks.NewMergeRequestService(t) | |
| 322 | + | svc.EXPECT().Merge(mock.Anything, mock.Anything, name, int64(3), ports.MergeParams{Mode: domain.MergeModeSquash, HeadID: head, IgnoreChecks: ignore}).Return(ports.MergeResult{}, nil).Once() | |
| 323 | + | body := "mode=squash&head_id=" + head.String() | |
| 324 | + | if ignore { | |
| 325 | + | body += "&ignore_checks=on" | |
| 326 | + | } | |
| 327 | + | s := &Server{cfg: Config{MergeRequests: svc}} | |
| 328 | + | to, _, err := s.mrMerge(context.Background(), autoMergeRequest("/demo/merge-requests/3/merge", body)) | |
| 329 | + | require.NoError(t, err) | |
| 330 | + | assert.Equal(t, mrHref("demo", 3), to) | |
| 331 | + | } | |
| 332 | + | } | |
| @@ -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,36 @@ 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 | + | box := (&Server{}).mergeBox(&repoData{repo: repoWithModes(domain.MergeModes()...)}, 3, "feat", "abc", true, mergeabilityView(domain.Mergeability{State: domain.MergeabilityUpToDate}, "main")) | |||
| 306 | + | html := renderToString(t, mergeMenu(MRPage{Box: box, ChecksSum: ChecksSummary{Kind: kind}})) | |||
| 307 | + | if kind == "bad" || kind == "running" { | |||
| 308 | + | assert.Contains(t, html, `name="ignore_checks"`) | |||
| 309 | + | assert.Contains(t, html, "Ignore checks") | |||
| 310 | + | } else { | |||
| 311 | + | assert.NotContains(t, html, `name="ignore_checks"`) | |||
| 312 | + | } | |||
| 313 | + | assert.True(t, box.CanMerge()) | |||
| 314 | + | }) | |||
| 315 | + | } | |||
| 316 | + | name, err := domain.ParseRepoName("demo") | |||
| 317 | + | require.NoError(t, err) | |||
| 318 | + | head, err := domain.ParseObjectID(strings.Repeat("a", domain.ObjectIDLen)) | |||
| 319 | + | require.NoError(t, err) | |||
| 320 | + | for _, ignore := range []bool{false, true} { | |||
| 321 | + | svc := mocks.NewMergeRequestService(t) | |||
| 322 | + | svc.EXPECT().Merge(mock.Anything, mock.Anything, name, int64(3), ports.MergeParams{Mode: domain.MergeModeSquash, HeadID: head, IgnoreChecks: ignore}).Return(ports.MergeResult{}, nil).Once() | |||
| 323 | + | body := "mode=squash&head_id=" + head.String() | |||
| 324 | + | if ignore { | |||
| 325 | + | body += "&ignore_checks=on" | |||
| 326 | + | } | |||
| 327 | + | s := &Server{cfg: Config{MergeRequests: svc}} | |||
| 328 | + | to, _, err := s.mrMerge(context.Background(), autoMergeRequest("/demo/merge-requests/3/merge", body)) | |||
| 329 | + | require.NoError(t, err) | |||
| 330 | + | assert.Equal(t, mrHref("demo", 3), to) | |||
| 331 | + | } | |||
| 332 | + | } | |||
M internal/adapters/web/static/js/ermite.js +7 -0
| @@ -966,6 +966,13 @@ window.ermiteLive = (() => { | |||
| 966 | 966 | continue; | |
| 967 | 967 | } | |
| 968 | 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 | 976 | old.replaceWith(el); | |
| 970 | 977 | const rows = document.getElementById('repo-rows'); | |
| 971 | 978 | if (rows && el.parentElement === rows && rows.dataset.sort === 'activity' && | |
| @@ -966,6 +966,13 @@ window.ermiteLive = (() => { | |||||
| 966 | continue; | 966 | continue; | ||
| 967 | } | 967 | } | ||
| 968 | if (old.hasAttribute('data-cursor')) el.setAttribute('data-cursor', ''); | 968 | if (old.hasAttribute('data-cursor')) el.setAttribute('data-cursor', ''); | ||
| 969 | + | // data-keep-checks: the checkboxes the user ticked stay ticked. | |||
| 970 | + | if (old.hasAttribute('data-keep-checks')) { | |||
| 971 | + | for (const box of old.querySelectorAll('input[type=checkbox][name]:checked')) { | |||
| 972 | + | const same = el.querySelector(`input[type=checkbox][name="${CSS.escape(box.name)}"]`); | |||
| 973 | + | if (same) same.checked = true; | |||
| 974 | + | } | |||
| 975 | + | } | |||
| 969 | old.replaceWith(el); | 976 | old.replaceWith(el); | ||
| 970 | const rows = document.getElementById('repo-rows'); | 977 | const rows = document.getElementById('repo-rows'); | ||
| 971 | if (rows && el.parentElement === rows && rows.dataset.sort === 'activity' && | 978 | if (rows && el.parentElement === rows && rows.dataset.sort === 'activity' && | ||
M internal/adapters/web/summary_actions_test.go +52 -0
| @@ -4,6 +4,7 @@ package web | |||
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "bytes" | |
| 7 | + | "context" | |
| 7 | 8 | "errors" | |
| 8 | 9 | "strings" | |
| 9 | 10 | "testing" | |
| @@ -125,3 +126,54 @@ func TestRunUpdatedOnSummaryWhenCIIsNotReadable(t *testing.T) { | |||
| 125 | 126 | assert.Contains(t, got[0].html, "hidden", "the block empties and hides, no run leaks") | |
| 126 | 127 | assert.NotContains(t, got[0].html, "rr-name") | |
| 127 | 128 | } | |
| 129 | + | ||
| 130 | + | func TestMRChecksLiveMergeControls(t *testing.T) { | |
| 131 | + | t.Parallel() | |
| 132 | + | repo := repoName(t, "proj") | |
| 133 | + | head := strings.Repeat("a", domain.ObjectIDLen) | |
| 134 | + | for _, kind := range []string{"bad", "running", "ok", ""} { | |
| 135 | + | t.Run(kind, func(t *testing.T) { | |
| 136 | + | runs := mocks.NewRunService(t) | |
| 137 | + | var checks []ports.CheckView | |
| 138 | + | if kind != "" { | |
| 139 | + | check := domain.CICheck{Name: "ci", Status: domain.CICompleted, Conclusion: domain.CISuccess} | |
| 140 | + | if kind == "bad" { | |
| 141 | + | check.Conclusion = domain.CIFailure | |
| 142 | + | } | |
| 143 | + | if kind == "running" { | |
| 144 | + | check.Status, check.Conclusion = domain.CIQueued, "" | |
| 145 | + | } | |
| 146 | + | checks = []ports.CheckView{{CICheck: check}} | |
| 147 | + | } | |
| 148 | + | runs.EXPECT().ListChecks(mock.Anything, mock.Anything, repo, head).Return(ports.ChecksResult{Checks: checks}, nil) | |
| 149 | + | srv := newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "proj")), Browse: mocks.NewBrowseService(t), Runs: runs}) | |
| 150 | + | ||
| 151 | + | // A visitor's stream never carries the owner's merge form. | |
| 152 | + | visitor, err := (&liveStream{s: srv, repo: "proj", head: head, view: "mr"}).mrChecks(t.Context()) | |
| 153 | + | require.NoError(t, err) | |
| 154 | + | require.Len(t, visitor, 2) | |
| 155 | + | for _, f := range visitor { | |
| 156 | + | assert.NotContains(t, f.html, `mr-merge-checks`) | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | owner := context.WithValue(t.Context(), signedInKey{}, struct{}{}) | |
| 160 | + | ls := &liveStream{s: srv, repo: "proj", head: head, view: "mr"} | |
| 161 | + | got, err := ls.mrChecks(owner) | |
| 162 | + | require.NoError(t, err) | |
| 163 | + | require.Len(t, got, 3) | |
| 164 | + | assert.Contains(t, got[2].html, `data-keep-checks`, "a swap keeps the ticked box") | |
| 165 | + | assert.Equal(t, "ci-run", got[2].event) | |
| 166 | + | assert.Contains(t, got[2].html, `id="mr-merge-checks"`) | |
| 167 | + | assert.Equal(t, renderToString(t, MRMergeChecks(checksSummary(repo, head, checks))), got[2].html) | |
| 168 | + | if kind == "bad" || kind == "running" { | |
| 169 | + | assert.Contains(t, got[2].html, `name="ignore_checks"`) | |
| 170 | + | } else { | |
| 171 | + | assert.NotContains(t, got[2].html, `name="ignore_checks"`) | |
| 172 | + | } | |
| 173 | + | // An unrelated job event with identical checks must not reset the checkbox. | |
| 174 | + | got, err = ls.mrChecks(owner) | |
| 175 | + | require.NoError(t, err) | |
| 176 | + | require.Len(t, got, 2) | |
| 177 | + | }) | |
| 178 | + | } | |
| 179 | + | } | |
| @@ -4,6 +4,7 @@ package web | |||||
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | "bytes" | 6 | "bytes" | ||
| 7 | + | "context" | |||
| 7 | "errors" | 8 | "errors" | ||
| 8 | "strings" | 9 | "strings" | ||
| 9 | "testing" | 10 | "testing" | ||
| @@ -125,3 +126,54 @@ func TestRunUpdatedOnSummaryWhenCIIsNotReadable(t *testing.T) { | |||||
| 125 | assert.Contains(t, got[0].html, "hidden", "the block empties and hides, no run leaks") | 126 | assert.Contains(t, got[0].html, "hidden", "the block empties and hides, no run leaks") | ||
| 126 | assert.NotContains(t, got[0].html, "rr-name") | 127 | assert.NotContains(t, got[0].html, "rr-name") | ||
| 127 | } | 128 | } | ||
| 129 | + | ||||
| 130 | + | func TestMRChecksLiveMergeControls(t *testing.T) { | |||
| 131 | + | t.Parallel() | |||
| 132 | + | repo := repoName(t, "proj") | |||
| 133 | + | head := strings.Repeat("a", domain.ObjectIDLen) | |||
| 134 | + | for _, kind := range []string{"bad", "running", "ok", ""} { | |||
| 135 | + | t.Run(kind, func(t *testing.T) { | |||
| 136 | + | runs := mocks.NewRunService(t) | |||
| 137 | + | var checks []ports.CheckView | |||
| 138 | + | if kind != "" { | |||
| 139 | + | check := domain.CICheck{Name: "ci", Status: domain.CICompleted, Conclusion: domain.CISuccess} | |||
| 140 | + | if kind == "bad" { | |||
| 141 | + | check.Conclusion = domain.CIFailure | |||
| 142 | + | } | |||
| 143 | + | if kind == "running" { | |||
| 144 | + | check.Status, check.Conclusion = domain.CIQueued, "" | |||
| 145 | + | } | |||
| 146 | + | checks = []ports.CheckView{{CICheck: check}} | |||
| 147 | + | } | |||
| 148 | + | runs.EXPECT().ListChecks(mock.Anything, mock.Anything, repo, head).Return(ports.ChecksResult{Checks: checks}, nil) | |||
| 149 | + | srv := newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "proj")), Browse: mocks.NewBrowseService(t), Runs: runs}) | |||
| 150 | + | ||||
| 151 | + | // A visitor's stream never carries the owner's merge form. | |||
| 152 | + | visitor, err := (&liveStream{s: srv, repo: "proj", head: head, view: "mr"}).mrChecks(t.Context()) | |||
| 153 | + | require.NoError(t, err) | |||
| 154 | + | require.Len(t, visitor, 2) | |||
| 155 | + | for _, f := range visitor { | |||
| 156 | + | assert.NotContains(t, f.html, `mr-merge-checks`) | |||
| 157 | + | } | |||
| 158 | + | ||||
| 159 | + | owner := context.WithValue(t.Context(), signedInKey{}, struct{}{}) | |||
| 160 | + | ls := &liveStream{s: srv, repo: "proj", head: head, view: "mr"} | |||
| 161 | + | got, err := ls.mrChecks(owner) | |||
| 162 | + | require.NoError(t, err) | |||
| 163 | + | require.Len(t, got, 3) | |||
| 164 | + | assert.Contains(t, got[2].html, `data-keep-checks`, "a swap keeps the ticked box") | |||
| 165 | + | assert.Equal(t, "ci-run", got[2].event) | |||
| 166 | + | assert.Contains(t, got[2].html, `id="mr-merge-checks"`) | |||
| 167 | + | assert.Equal(t, renderToString(t, MRMergeChecks(checksSummary(repo, head, checks))), got[2].html) | |||
| 168 | + | if kind == "bad" || kind == "running" { | |||
| 169 | + | assert.Contains(t, got[2].html, `name="ignore_checks"`) | |||
| 170 | + | } else { | |||
| 171 | + | assert.NotContains(t, got[2].html, `name="ignore_checks"`) | |||
| 172 | + | } | |||
| 173 | + | // An unrelated job event with identical checks must not reset the checkbox. | |||
| 174 | + | got, err = ls.mrChecks(owner) | |||
| 175 | + | require.NoError(t, err) | |||
| 176 | + | require.Len(t, got, 2) | |||
| 177 | + | }) | |||
| 178 | + | } | |||
| 179 | + | } | |||
M internal/app/api_test.go +1 -1
| @@ -196,7 +196,7 @@ func TestAC2ReadAccess(t *testing.T) { | |||
| 196 | 196 | for _, who := range allPrincipals { | |
| 197 | 197 | resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{})) | |
| 198 | 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 | 200 | assert.NotEmpty(t, resp.Msg.GetVersion()) | |
| 201 | 201 | } | |
| 202 | 202 | }) | |
| @@ -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 ( | |||
| 13 | 13 | "connectrpc.com/connect" | |
| 14 | 14 | "github.com/stretchr/testify/assert" | |
| 15 | 15 | "github.com/stretchr/testify/require" | |
| 16 | - | "google.golang.org/protobuf/types/known/fieldmaskpb" | |
| 17 | 16 | "google.golang.org/protobuf/types/known/timestamppb" | |
| 18 | 17 | ||
| 19 | 18 | pb "bnema.dev/ermite-api/gen/ermite" | |
| @@ -271,7 +270,7 @@ func TestCIPushRunsAWorkflowOnARunner(t *testing.T) { | |||
| 271 | 270 | } | |
| 272 | 271 | ||
| 273 | 272 | // 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 | 274 | func TestCIMergeIsBlockedByChecks(t *testing.T) { | |
| 276 | 275 | c := newCIHarness(t, nil) | |
| 277 | 276 | runner := c.registerRunner("alpha", "ubuntu-latest") | |
| @@ -302,14 +301,9 @@ func TestCIMergeIsBlockedByChecks(t *testing.T) { | |||
| 302 | 301 | assert.Equal(t, connect.CodeFailedPrecondition, ciErrCode(err)) | |
| 303 | 302 | assert.Contains(t, err.Error(), "failing checks block the merge") | |
| 304 | 303 | ||
| 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})) | |
| 311 | 306 | require.NoError(t, err) | |
| 312 | - | require.NoError(t, merge()) | |
| 313 | 307 | } | |
| 314 | 308 | ||
| 315 | 309 | // 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 they | 272 | // 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 { | |||
| 203 | 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 | 208 | // AC6: the CLI end to end against a real forge. | |
| 209 | 209 | func TestAC6CLIEndToEnd(t *testing.T) { | |
| @@ -350,7 +350,7 @@ func TestAC6CLIEndToEnd(t *testing.T) { | |||
| 350 | 350 | ver := decode[versionOut](t, res.stdout) | |
| 351 | 351 | assert.Equal(t, c.url, ver.Server.URL) | |
| 352 | 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 | 354 | assert.Equal(t, []string{"passkeys"}, ver.Server.MissingFeatures) | |
| 355 | 355 | stdout, stderr, code = c.bin(t, secret, "version") | |
| 356 | 356 | 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) { | |||
| 202 | 202 | }} | |
| 203 | 203 | url := f.serve(t) | |
| 204 | 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 | 206 | res = runCLI(t, envFor(url), "", "repo", "show", "alpha", "--json").ok(t) | |
| 207 | 207 | assert.Contains(t, res.stdout, `"issues": "owner"`) | |
| 208 | 208 | } | |
| @@ -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 { | |||
| 291 | 291 | } | |
| 292 | 292 | mr.AddCommand( | |
| 293 | 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 | 296 | return mr | |
| 297 | 297 | } | |
| @@ -1085,7 +1085,7 @@ func newMRMergeCmd(env *cliEnv) *cobra.Command { | |||
| 1085 | 1085 | var mode string | |
| 1086 | 1086 | var deleteBranch bool | |
| 1087 | 1087 | var head string | |
| 1088 | - | var auto, cancelAuto bool | |
| 1088 | + | var auto, cancelAuto, ignoreChecks bool | |
| 1089 | 1089 | cmd := &cobra.Command{ | |
| 1090 | 1090 | Use: "merge <repo> <number>", | |
| 1091 | 1091 | Short: "Merge a merge request", | |
| @@ -1105,6 +1105,9 @@ func newMRMergeCmd(env *cliEnv) *cobra.Command { | |||
| 1105 | 1105 | "required: give the full id of the head that `ermite mr show <repo> <number>` printed when you\n" + | |
| 1106 | 1106 | "reviewed the change. If the branch moved since, the merge is refused: review again. Needs the\n" + | |
| 1107 | 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 | 1111 | "The merge answers as soon as the target branch has moved. --delete-branch asks the server to delete\n" + | |
| 1109 | 1112 | "the source branch right after, in the background. The merge is refused when the branch is the\n" + | |
| 1110 | 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 | 1126 | cmd.Flags().BoolVar(&deleteBranch, "delete-branch", false, "delete the source branch after the merge") | |
| 1124 | 1127 | cmd.Flags().BoolVar(&auto, "auto", false, "merge by itself once the checks passed (needs `repo edit --allow-auto-merge`)") | |
| 1125 | 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 | 1132 | cmd.MarkFlagsMutuallyExclusive("auto", "cancel-auto") | |
| 1127 | 1133 | cmd.MarkFlagsMutuallyExclusive("cancel-auto", "delete-branch") | |
| 1128 | 1134 | cmd.MarkFlagsMutuallyExclusive("cancel-auto", "head") | |
| @@ -1156,13 +1162,18 @@ func newMRMergeCmd(env *cliEnv) *cobra.Command { | |||
| 1156 | 1162 | return err | |
| 1157 | 1163 | } | |
| 1158 | 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 | 1170 | if pbMode == ermite.MergeMode_MERGE_MODE_MERGE_COMMIT { | |
| 1160 | 1171 | if err := requireFeature(ctx, c, client.FeatureMergeability, "has no merge commit mode (--mode merge)"); err != nil { | |
| 1161 | 1172 | return err | |
| 1162 | 1173 | } | |
| 1163 | 1174 | } | |
| 1164 | 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 | 1178 | if err != nil { | |
| 1168 | 1179 | return c.Describe(err) | |
| @@ -1282,3 +1293,34 @@ func newMRCloseCmd(env *cliEnv) *cobra.Command { | |||
| 1282 | 1293 | } | |
| 1283 | 1294 | 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 | + | } | |
| @@ -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 mr | 296 | return mr | ||
| 297 | } | 297 | } | ||
| @@ -1085,7 +1085,7 @@ func newMRMergeCmd(env *cliEnv) *cobra.Command { | |||||
| 1085 | var mode string | 1085 | var mode string | ||
| 1086 | var deleteBranch bool | 1086 | var deleteBranch bool | ||
| 1087 | var head string | 1087 | var head string | ||
| 1088 | - | var auto, cancelAuto bool | 1088 | + | 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 err | 1162 | 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 err | 1172 | 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 cmd | 1294 | 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) { | |||
| 382 | 382 | c.seed("proj", domain.VisibilityPublic) | |
| 383 | 383 | res := c.cli(t, adminToken, "repo", "show", "proj", "--json").ok(t) | |
| 384 | 384 | repo := decode[struct { | |
| 385 | - | MergeModes []string `json:"merge_modes"` | |
| 386 | - | FailingChecksBlock bool `json:"failing_checks_block"` | |
| 385 | + | MergeModes []string `json:"merge_modes"` | |
| 387 | 386 | }](t, res.stdout) | |
| 388 | 387 | assert.Equal(t, []string{"fast-forward", "squash", "merge"}, repo.MergeModes) | |
| 389 | - | assert.True(t, repo.FailingChecksBlock) | |
| 390 | 388 | ||
| 391 | 389 | res = c.cli(t, adminToken, "repo", "edit", "proj", "--merge-modes", "squash", "--json").ok(t) | |
| 392 | 390 | repo = decode[struct { | |
| 393 | - | MergeModes []string `json:"merge_modes"` | |
| 394 | - | FailingChecksBlock bool `json:"failing_checks_block"` | |
| 391 | + | MergeModes []string `json:"merge_modes"` | |
| 395 | 392 | }](t, res.stdout) | |
| 396 | 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 | 394 | res = c.cli(t, adminToken, "repo", "show", "proj").ok(t) | |
| 406 | 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 | 397 | require.Error(t, c.cli(t, adminToken, "repo", "edit", "proj", "--merge-modes", "rebase").err) | |
| 410 | 398 | require.Error(t, c.cli(t, adminToken, "repo", "edit", "proj", "--merge-modes", "").err) | |
| @@ -497,3 +485,39 @@ func TestMergeRequestNotesCLIEndToEnd(t *testing.T) { | |||
| 497 | 485 | require.Error(t, c.cli(t, adminToken, "mr", "edit-comment", "notes", "1", root.ID, "-m", "late").err) | |
| 498 | 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 | + | } | |
| @@ -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 { | |||
| 34 | 34 | CreatedAt *string `json:"created_at"` | |
| 35 | 35 | PushedAt *string `json:"pushed_at"` | |
| 36 | 36 | // 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"` | |
| 40 | 39 | // Issues is the issue mode ("off", "owner" or "public"). | |
| 41 | 40 | Issues string `json:"issues"` | |
| 42 | 41 | } | |
| @@ -47,17 +46,16 @@ func toJSONRepo(r *ermite.Repo) jsonRepo { | |||
| 47 | 46 | modes = append(modes, modeName(m)) | |
| 48 | 47 | } | |
| 49 | 48 | 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()), | |
| 61 | 59 | } | |
| 62 | 60 | } | |
| 63 | 61 | ||
| @@ -117,7 +115,7 @@ const jsonRepoHelp = ` | |||
| 117 | 115 | JSON fields of a repository (--json): name, description, visibility ("public" or | |
| 118 | 116 | "private"), default_branch, created_at and pushed_at (RFC 3339 UTC; pushed_at is | |
| 119 | 117 | 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 | |
| 121 | 119 | auto-merge can be enabled) and issues (the issue mode: "off", "owner" or "public").` | |
| 122 | 120 | ||
| 123 | 121 | func newRepoCmd(env *cliEnv) *cobra.Command { | |
| @@ -230,7 +228,6 @@ func printRepo(w io.Writer, r *ermite.Repo) error { | |||
| 230 | 228 | {"created", shortTime(r.GetCreatedAt(), "-")}, | |
| 231 | 229 | {"last push", shortTime(r.GetPushedAt(), "never")}, | |
| 232 | 230 | {"merge modes", orDash(strings.Join(modes, ", "))}, | |
| 233 | - | {"failing checks block", strconv.FormatBool(r.GetSettings().GetFailingChecksBlock())}, | |
| 234 | 231 | {"allow auto-merge", strconv.FormatBool(r.GetSettings().GetAllowAutoMerge())}, | |
| 235 | 232 | {"issues", issuesModeName(r.GetSettings().GetIssues())}, | |
| 236 | 233 | }) | |
| @@ -311,7 +308,7 @@ func buildCreateRepo(name, description string, topics []string, visibility strin | |||
| 311 | 308 | func newRepoEditCmd(env *cliEnv) *cobra.Command { | |
| 312 | 309 | var description, visibility, defaultBranch string | |
| 313 | 310 | var topics, mergeModes []string | |
| 314 | - | var failingChecksBlock, allowAutoMerge bool | |
| 311 | + | var allowAutoMerge bool | |
| 315 | 312 | var issues string | |
| 316 | 313 | cmd := &cobra.Command{ | |
| 317 | 314 | Use: "edit <name>", | |
| @@ -321,7 +318,6 @@ func newRepoEditCmd(env *cliEnv) *cobra.Command { | |||
| 321 | 318 | "may be merged with (fast-forward, squash, merge; every mode is allowed by default, so to disable one,\n" + | |
| 322 | 319 | "list the others: --merge-modes fast-forward,squash disables the merge commit; squash and merge\n" + | |
| 323 | 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 | 321 | "--issues sets who may use issues: off (default), owner (the owner and tokens with issue:write) or\n" + | |
| 326 | 322 | "public (visitors of a public repository may also propose issues through a moderated form).\nNeeds a server with the issues feature.\n" + | |
| 327 | 323 | "Needs the repo:admin scope." + jsonRepoHelp, | |
| @@ -333,7 +329,6 @@ func newRepoEditCmd(env *cliEnv) *cobra.Command { | |||
| 333 | 329 | cmd.Flags().StringVar(&visibility, "visibility", "", "new visibility: public or private") | |
| 334 | 330 | cmd.Flags().StringVar(&defaultBranch, "default-branch", "", "short name of the new default branch") | |
| 335 | 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 | 332 | cmd.Flags().BoolVar(&allowAutoMerge, "allow-auto-merge", false, "allow auto-merge on merge requests (--allow-auto-merge=false to forbid it)") | |
| 338 | 333 | cmd.Flags().StringVar(&issues, "issues", "", "issue mode: off, owner or public") | |
| 339 | 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 | 341 | asJSON := addJSONFlag(cmd) | |
| 347 | 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 | 344 | if err != nil { | |
| 350 | 345 | return err | |
| 351 | 346 | } | |
| @@ -369,7 +364,7 @@ func newRepoEditCmd(env *cliEnv) *cobra.Command { | |||
| 369 | 364 | ||
| 370 | 365 | // buildUpdateRepo maps the flags of `repo edit` to the request: only the flags | |
| 371 | 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 | 368 | req := &ermite.UpdateRepoRequest{Repo: name} | |
| 374 | 369 | var paths []string | |
| 375 | 370 | if cmd.Flags().Changed("description") { | |
| @@ -411,13 +406,6 @@ func buildUpdateRepo(cmd *cobra.Command, name, description string, topics []stri | |||
| 411 | 406 | req.Settings = &ermite.RepoSettings{AllowedMergeModes: modes} | |
| 412 | 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 | 409 | if cmd.Flags().Changed("allow-auto-merge") { | |
| 422 | 410 | if req.Settings == nil { | |
| 423 | 411 | req.Settings = &ermite.RepoSettings{} | |
| @@ -434,7 +422,7 @@ func buildUpdateRepo(cmd *cobra.Command, name, description string, topics []stri | |||
| 434 | 422 | req.Issues = &mode | |
| 435 | 423 | } | |
| 436 | 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 | 427 | req.UpdateMask = &fieldmaskpb.FieldMask{Paths: paths} | |
| 440 | 428 | 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" or | 115 | 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 is | 116 | "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/or | 117 | 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 |
| 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 string | 309 | var description, visibility, defaultBranch string | ||
| 313 | var topics, mergeModes []string | 310 | var topics, mergeModes []string | ||
| 314 | - | var failingChecksBlock, allowAutoMerge bool | 311 | + | var allowAutoMerge bool |
| 315 | var issues string | 312 | 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 err | 345 | 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 flags | 365 | // 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 []string | 369 | 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 = &mode | 422 | 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, nil | 428 | return req, nil | ||
M internal/app/cli_repo_test.go +17 -27
| @@ -19,8 +19,7 @@ var ( | |||
| 19 | 19 | Name: "alpha", Description: "The first one", Topics: []string{"cli", "go"}, Visibility: ermite.Visibility_VISIBILITY_PUBLIC, | |
| 20 | 20 | DefaultBranch: "main", CreatedAt: ts("2026-01-02T03:04:05Z"), PushedAt: ts("2026-03-04T05:06:07Z"), | |
| 21 | 21 | 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}, | |
| 24 | 23 | }, | |
| 25 | 24 | } | |
| 26 | 25 | repoBeta = &ermite.Repo{ | |
| @@ -54,8 +53,8 @@ func TestRepoList(t *testing.T) { | |||
| 54 | 53 | { | |
| 55 | 54 | name: "json", repos: both, args: []string{"repo", "list", "--json"}, wantJSON: true, | |
| 56 | 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"}, | |
| 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"} | |
| 59 | 58 | ]`, | |
| 60 | 59 | }, | |
| 61 | 60 | { | |
| @@ -107,22 +106,21 @@ func TestRepoShow(t *testing.T) { | |||
| 107 | 106 | url := f.serve(t) | |
| 108 | 107 | res := runCLI(t, envFor(url), "", "repo", "show", "alpha").ok(t) | |
| 109 | 108 | 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) | |
| 121 | 119 | _, reqs := f.snapshot() | |
| 122 | 120 | assert.Equal(t, "alpha", reqs[0].(*ermite.GetRepoRequest).GetRepo()) | |
| 123 | 121 | ||
| 124 | 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 | 126 | func TestRepoShowNeedsOneArg(t *testing.T) { | |
| @@ -178,7 +176,7 @@ func TestRepoCreateJSON(t *testing.T) { | |||
| 178 | 176 | return &ermite.CreateRepoResponse{Repo: repoBeta}, nil | |
| 179 | 177 | }} | |
| 180 | 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 | 182 | func TestRepoEditMask(t *testing.T) { | |
| @@ -209,16 +207,8 @@ func TestRepoEditMask(t *testing.T) { | |||
| 209 | 207 | {"merge modes", []string{"--merge-modes", "squash,ff"}, []string{"settings.allowed_merge_modes"}, func(t *testing.T, r *ermite.UpdateRepoRequest) { | |
| 210 | 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 | 210 | {"allow auto-merge", []string{"--allow-auto-merge"}, []string{"settings.allow_auto_merge"}, func(t *testing.T, r *ermite.UpdateRepoRequest) { | |
| 220 | 211 | assert.True(t, r.GetSettings().GetAllowAutoMerge()) | |
| 221 | - | assert.False(t, r.GetSettings().GetFailingChecksBlock()) | |
| 222 | 212 | }}, | |
| 223 | 213 | {"forbid auto-merge", []string{"--allow-auto-merge=false"}, []string{"settings.allow_auto_merge"}, func(t *testing.T, r *ermite.UpdateRepoRequest) { | |
| 224 | 214 | assert.False(t, r.GetSettings().GetAllowAutoMerge()) | |
| @@ -460,9 +450,9 @@ func TestRepoEditAllowAutoMerge(t *testing.T) { | |||
| 460 | 450 | assert.Equal(t, []string{"settings.allow_auto_merge"}, got.GetUpdateMask().GetPaths()) | |
| 461 | 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 | 454 | assert.Contains(t, res.stdout, `"allow_auto_merge": true`) | |
| 465 | 455 | _, reqs = f.snapshot() | |
| 466 | 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 | } | |
| @@ -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}, nil | 176 | 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) { | |||
| 95 | 95 | assert.True(t, json.Valid([]byte(sc.Text())), "one JSON value per line: %s", sc.Text()) | |
| 96 | 96 | } | |
| 97 | 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 | 99 | assert.JSONEq(t, `{"id":"3","time":"2026-10-10T12:00:00Z","type":"pushed","repo":"alpha","ref_updates":[ | |
| 100 | 100 | {"ref":"refs/heads/main","old_id":"1111111111111111","new_id":"2222222222222222"}, | |
| 101 | 101 | {"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) { | |||
| 221 | 221 | updated, err := update(r.root, pb.IssuesMode_ISSUES_MODE_OWNER) | |
| 222 | 222 | require.NoError(t, err) | |
| 223 | 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 | 224 | _, err = r.root.issue.CreateIssue(ctx, connect.NewRequest(&pb.CreateIssueRequest{Repo: "priv", Title: "t"})) | |
| 226 | 225 | require.NoError(t, err) | |
| 227 | 226 | ||
| @@ -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) { | |||
| 299 | 299 | got, err := h.api("").repo.GetRepo(ctx, connect.NewRequest(&pb.GetRepoRequest{Repo: "proj"})) | |
| 300 | 300 | require.NoError(t, err) | |
| 301 | 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 | 304 | update := func(c *clients, paths []string, s *pb.RepoSettings) (*pb.Repo, error) { | |
| 305 | 305 | res, err := c.repo.UpdateRepo(ctx, connect.NewRequest(&pb.UpdateRepoRequest{ | |
| @@ -311,23 +311,23 @@ func TestUpdateRepoSettingsOverTheAPI(t *testing.T) { | |||
| 311 | 311 | return res.Msg.GetRepo(), nil | |
| 312 | 312 | } | |
| 313 | 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 | 316 | require.NoError(t, err) | |
| 317 | 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 | 321 | require.NoError(t, err) | |
| 322 | - | assert.False(t, repo.GetSettings().GetFailingChecksBlock()) | |
| 322 | + | assert.False(t, repo.GetSettings().GetAllowAutoMerge()) | |
| 323 | 323 | assert.Equal(t, []pb.MergeMode{pb.MergeMode_MERGE_MODE_SQUASH}, repo.GetSettings().GetAllowedMergeModes(), "not named in the mask: unchanged") | |
| 324 | 324 | ||
| 325 | 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 | 328 | require.NoError(t, err) | |
| 329 | 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 | 332 | for name, s := range map[string]*pb.RepoSettings{ | |
| 333 | 333 | "no modes": {}, | |
| @@ -341,11 +341,11 @@ func TestUpdateRepoSettingsOverTheAPI(t *testing.T) { | |||
| 341 | 341 | assert.Equal(t, "invalid_argument", codeName(err)) | |
| 342 | 342 | ||
| 343 | 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 | 345 | assert.Equal(t, "permission_denied", codeName(err)) | |
| 346 | 346 | got, err = root.repo.GetRepo(ctx, connect.NewRequest(&pb.GetRepoRequest{Repo: "proj"})) | |
| 347 | 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 | 351 | // 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(), nil | 311 | 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 ( | |||
| 42 | 42 | ) | |
| 43 | 43 | ||
| 44 | 44 | // 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 | 46 | func (c CIConclusion) PassesMerge() bool { | |
| 47 | 47 | return c == CISuccess || c == CISkipped || c == CINeutral | |
| 48 | 48 | } | |
| @@ -42,7 +42,7 @@ const ( | |||||
| 42 | ) | 42 | ) | ||
| 43 | 43 | ||||
| 44 | // PassesMerge reports whether a check that ended this way lets a merge go | 44 | // 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 == CINeutral | 47 | return c == CISuccess || c == CISkipped || c == CINeutral | ||
| 48 | } | 48 | } | ||
M internal/domain/mergerequest.go +3 -5
| @@ -46,8 +46,6 @@ type RepoSettings struct { | |||
| 46 | 46 | // AllowedMergeModes is never empty, in canonical order, without | |
| 47 | 47 | // duplicates. | |
| 48 | 48 | AllowedMergeModes []MergeMode | |
| 49 | - | // FailingChecksBlock says that a failing check prevents the merge. | |
| 50 | - | FailingChecksBlock bool | |
| 51 | 49 | // Issues says who may write issues; the empty mode means IssuesOff. | |
| 52 | 50 | Issues IssuesMode | |
| 53 | 51 | // AllowAutoMerge says that merge requests may be set to merge themselves | |
| @@ -55,10 +53,10 @@ type RepoSettings struct { | |||
| 55 | 53 | AllowAutoMerge bool | |
| 56 | 54 | } | |
| 57 | 55 | ||
| 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. | |
| 60 | 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 | 62 | // 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, without | 46 | // AllowedMergeModes is never empty, in canonical order, without | ||
| 47 | // duplicates. | 47 | // duplicates. | ||
| 48 | AllowedMergeModes []MergeMode | 48 | 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 IssuesMode | 50 | Issues IssuesMode | ||
| 53 | // AllowAutoMerge says that merge requests may be set to merge themselves | 51 | // AllowAutoMerge says that merge requests may be set to merge themselves | ||
| @@ -55,10 +53,10 @@ type RepoSettings struct { | |||||
| 55 | AllowAutoMerge bool | 53 | AllowAutoMerge bool | ||
| 56 | } | 54 | } | ||
| 57 | 55 | ||||
| 58 | - | // DefaultRepoSettings are the settings of a new repository: every mode, and | 56 | + | // 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) { | |||
| 25 | 25 | t.Parallel() | |
| 26 | 26 | s := domain.DefaultRepoSettings() | |
| 27 | 27 | assert.Equal(t, []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash, domain.MergeModeMerge}, s.AllowedMergeModes) | |
| 28 | - | assert.True(t, s.FailingChecksBlock) | |
| 29 | 28 | assert.True(t, s.Allows(domain.MergeModeSquash)) | |
| 30 | 29 | assert.False(t, domain.RepoSettings{}.Allows(domain.MergeModeSquash)) | |
| 31 | 30 | 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 ( | |||
| 30 | 30 | ScopeMRWrite Scope = "mr:write" | |
| 31 | 31 | // ScopeMRMerge merges, and deletes the merged source branch on request. | |
| 32 | 32 | ScopeMRMerge Scope = "mr:merge" | |
| 33 | + | // ScopeMRForceMerge bypasses failing or unfinished merge checks. | |
| 34 | + | ScopeMRForceMerge Scope = "mr:force-merge" | |
| 33 | 35 | // ScopeMRReview sets a verdict on a merge request. | |
| 34 | 36 | ScopeMRReview Scope = "mr:review" | |
| 35 | 37 | // ScopeCIWrite re-runs or cancels CI runs. | |
| @@ -45,11 +47,11 @@ const ( | |||
| 45 | 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 | 51 | func AllScopes() []Scope { | |
| 50 | 52 | return []Scope{ | |
| 51 | 53 | ScopeRepoRead, ScopeRepoWrite, ScopeRepoAdmin, | |
| 52 | - | ScopeMRWrite, ScopeMRMerge, ScopeMRReview, | |
| 54 | + | ScopeMRWrite, ScopeMRMerge, ScopeMRForceMerge, ScopeMRReview, | |
| 53 | 55 | ScopeCIWrite, ScopeSecretsWrite, ScopeRunnerRegister, | |
| 54 | 56 | ScopeTokenAdmin, | |
| 55 | 57 | 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 ( | |||
| 16 | 16 | func TestScopes(t *testing.T) { | |
| 17 | 17 | t.Parallel() | |
| 18 | 18 | all := domain.AllScopes() | |
| 19 | - | assert.Len(t, all, 11) | |
| 19 | + | assert.Len(t, all, 12) | |
| 20 | 20 | assert.Contains(t, all, domain.ScopeIssueWrite) | |
| 21 | 21 | got, err := domain.ParseScope("issue:write") | |
| 22 | 22 | 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 { | |||
| 47 | 47 | // AllowedMergeModes replaces the allowed merge modes; it must hold at | |
| 48 | 48 | // least one known mode. | |
| 49 | 49 | AllowedMergeModes *[]domain.MergeMode | |
| 50 | - | // FailingChecksBlock replaces the setting of that name. | |
| 51 | - | FailingChecksBlock *bool | |
| 52 | 50 | // Issues replaces the issues mode. | |
| 53 | 51 | Issues *domain.IssuesMode | |
| 54 | 52 | // AllowAutoMerge replaces the setting of that name. | |
| @@ -381,6 +379,8 @@ type AddCommentParams struct { | |||
| 381 | 379 | ||
| 382 | 380 | // MergeParams describes the merge to perform. | |
| 383 | 381 | type MergeParams struct { | |
| 382 | + | // IgnoreChecks bypasses checks and requires mr:force-merge. | |
| 383 | + | IgnoreChecks bool | |
| 384 | 384 | // Mode defaults to fast-forward. | |
| 385 | 385 | Mode domain.MergeMode | |
| 386 | 386 | // 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 at | 47 | // AllowedMergeModes replaces the allowed merge modes; it must hold at | ||
| 48 | // least one known mode. | 48 | // least one known mode. | ||
| 49 | AllowedMergeModes *[]domain.MergeMode | 49 | 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.IssuesMode | 51 | 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.MergeMode | 385 | Mode domain.MergeMode | ||
| 386 | // HeadID is the commit the caller reviewed; the source branch must still | 386 | // 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) | |||
| 36 | 36 | // repo:admin on it, and no branch prefixes. A token limited to some branches | |
| 37 | 37 | // may not delete the repository (that deletes every branch, those outside its | |
| 38 | 38 | // 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 | |
| 40 | 40 | // token may not write. A caller that may not administer it learns nothing it | |
| 41 | 41 | // could not already read: not found when it cannot see the repository, | |
| 42 | 42 | // 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 branches | 36 | // 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 its | 37 | // 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 the | 39 | + | // 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 it | 40 | // 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) { | |||
| 159 | 159 | require.True(t, found) | |
| 160 | 160 | } | |
| 161 | 161 | ||
| 162 | - | func TestAutoMergeIgnoresFailingChecksBlockOff(t *testing.T) { | |
| 162 | + | func TestAutoMergeRequiresPassingChecks(t *testing.T) { | |
| 163 | 163 | for _, tc := range []struct { | |
| 164 | 164 | name string | |
| 165 | 165 | check domain.CICheck | |
| @@ -170,8 +170,6 @@ func TestAutoMergeIgnoresFailingChecksBlockOff(t *testing.T) { | |||
| 170 | 170 | t.Run(tc.name, func(t *testing.T) { | |
| 171 | 171 | r, mr, checks := autoRig(t) | |
| 172 | 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 | 173 | checks.EXPECT().CommitChecks(mock.Anything, r.repo, r.head().String()).Return([]domain.CICheck{{Status: domain.CICompleted, Conclusion: domain.CISuccess}, tc.check}, nil) | |
| 176 | 174 | a := autoEvaluator(r, checks, nil, nil) | |
| 177 | 175 | defer a.Close(r.ctx) | |
| @@ -388,7 +386,6 @@ func TestMergeGateSettingsUpdateWaits(t *testing.T) { | |||
| 388 | 386 | for _, params := range []ports.UpdateRepoParams{ | |
| 389 | 387 | {AllowAutoMerge: ptr(false)}, | |
| 390 | 388 | {AllowedMergeModes: ptr([]domain.MergeMode{domain.MergeModeSquash})}, | |
| 391 | - | {FailingChecksBlock: ptr(false)}, | |
| 392 | 389 | } { | |
| 393 | 390 | r, _, _ := autoRig(t) | |
| 394 | 391 | 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 string | 164 | name string | ||
| 165 | check domain.CICheck | 165 | 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 { | |||
| 24 | 24 | head domain.ObjectID | |
| 25 | 25 | } | |
| 26 | 26 | ||
| 27 | - | func newMergeUnit(t *testing.T, block bool) *mergeUnit { | |
| 27 | + | func newMergeUnit(t *testing.T) *mergeUnit { | |
| 28 | 28 | t.Helper() | |
| 29 | 29 | u := &mrUnit{ | |
| 30 | 30 | meta: mocks.NewRepoMetaStore(t), | |
| @@ -34,7 +34,6 @@ func newMergeUnit(t *testing.T, block bool) *mergeUnit { | |||
| 34 | 34 | repo: domain.Repo{Name: name(t, "proj"), Visibility: domain.VisibilityPrivate}, | |
| 35 | 35 | } | |
| 36 | 36 | u.repo.Settings = domain.DefaultRepoSettings() | |
| 37 | - | u.repo.Settings.FailingChecksBlock = block | |
| 38 | 37 | clock := mocks.NewClock(t) | |
| 39 | 38 | clock.EXPECT().Now().Return(now).Maybe() | |
| 40 | 39 | sched := mocks.NewMaintenanceScheduler(t) | |
| @@ -63,8 +62,8 @@ func (m *mergeUnit) merge() error { | |||
| 63 | 62 | return err | |
| 64 | 63 | } | |
| 65 | 64 | ||
| 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) { | |
| 68 | 67 | t.Parallel() | |
| 69 | 68 | check := func(status domain.CIStatus, c domain.CIConclusion, name string) domain.CICheck { | |
| 70 | 69 | return domain.CICheck{Name: name, Status: status, Conclusion: c} | |
| @@ -84,7 +83,7 @@ func TestMergeRuleOfFailingChecksBlock(t *testing.T) { | |||
| 84 | 83 | for _, tt := range tests { | |
| 85 | 84 | t.Run(tt.name, func(t *testing.T) { | |
| 86 | 85 | t.Parallel() | |
| 87 | - | m := newMergeUnit(t, true) | |
| 86 | + | m := newMergeUnit(t) | |
| 88 | 87 | m.checks.EXPECT().CommitChecks(mock.Anything, m.repo.Name, m.head.String()).Return(tt.checks, nil) | |
| 89 | 88 | // The merge stops at the rule: the branch mock expects no move. | |
| 90 | 89 | err := m.merge() | |
| @@ -94,7 +93,7 @@ func TestMergeRuleOfFailingChecksBlock(t *testing.T) { | |||
| 94 | 93 | } | |
| 95 | 94 | t.Run("too many checks to tell", func(t *testing.T) { | |
| 96 | 95 | t.Parallel() | |
| 97 | - | m := newMergeUnit(t, true) | |
| 96 | + | m := newMergeUnit(t) | |
| 98 | 97 | m.checks.EXPECT().CommitChecks(mock.Anything, m.repo.Name, m.head.String()).Return(nil, domain.ErrResourceExhausted) | |
| 99 | 98 | err := m.merge() | |
| 100 | 99 | require.ErrorIs(t, err, domain.ErrPreconditionFailed) | |
| @@ -174,3 +173,35 @@ func TestTokenShapes(t *testing.T) { | |||
| 174 | 173 | assert.False(t, domain.IsJobToken("ermite_"+good[10:])) | |
| 175 | 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 | + | } | |
| @@ -24,7 +24,7 @@ type mergeUnit struct { | |||||
| 24 | head domain.ObjectID | 24 | 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 err | 62 | 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 | |||
| 995 | 995 | // allowed by the settings of the repository, the source branch no longer | |
| 996 | 996 | // points to the head ("branch moved, read again") or is gone, the target is | |
| 997 | 997 | // 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. | |
| 1000 | 1001 | // | |
| 1001 | 1002 | // The conditions are always checked with Git, under the lock, never from the | |
| 1002 | 1003 | // 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 | 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 | 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 | 1081 | 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 | 1089 | mode := params.Mode | |
| 1083 | 1090 | if mode == "" { | |
| @@ -1179,7 +1186,7 @@ func (m *MergeRequests) mergeAs(ctx context.Context, p domain.Principal, name do | |||
| 1179 | 1186 | if opts.auto != nil && !settings.AllowAutoMerge { | |
| 1180 | 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 | 1190 | checks, err := m.readMergeChecks(ctx, name, head) | |
| 1184 | 1191 | if err == nil { | |
| 1185 | 1192 | err = checksPolicy(checks) | |
| @@ -1362,7 +1369,7 @@ func checksPolicy(checks []domain.CICheck) error { | |||
| 1362 | 1369 | } | |
| 1363 | 1370 | switch { | |
| 1364 | 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 | 1373 | case len(pending) > 0: | |
| 1367 | 1374 | return fmt.Errorf("%w: checks have not finished: %s", domain.ErrPreconditionFailed, strings.Join(pending, ", ")) | |
| 1368 | 1375 | } | |
| @@ -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 longer | 995 | // 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 is | 996 | // 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 branch | 997 | // 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 | 998 | + | // 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 the | 1002 | // 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 of | 1003 | // 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{}, err | 1081 | 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.Mode | 1089 | 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) { | |||
| 1205 | 1205 | ||
| 1206 | 1206 | modes := []domain.MergeMode{domain.MergeModeSquash, domain.MergeModeSquash} | |
| 1207 | 1207 | 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 | 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 | 1211 | again, err := r.repos.Get(r.ctx, root, r.repo) | |
| 1212 | 1212 | require.NoError(t, err) | |
| 1213 | 1213 | assert.Equal(t, got.Settings, again.Settings) | |
| 1214 | 1214 | ||
| 1215 | 1215 | // Only the named setting changes. | |
| 1216 | 1216 | 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 | 1218 | require.NoError(t, err) | |
| 1219 | 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 | 1222 | for _, bad := range [][]domain.MergeMode{{}, nil, {"rebase"}} { | |
| 1223 | 1223 | bad := bad | |
| @@ -1229,7 +1229,7 @@ func TestUpdateRepoSettings(t *testing.T) { | |||
| 1229 | 1229 | assert.Equal(t, got.Settings, again.Settings, "a refused change stores nothing") | |
| 1230 | 1230 | ||
| 1231 | 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 | 1233 | assert.ErrorIs(t, err, domain.ErrForbidden) | |
| 1234 | 1234 | } | |
| 1235 | 1235 | ||
| @@ -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 := false | 1207 | 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 := true | 1216 | 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 := bad | 1223 | 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 | |||
| 62 | 62 | } | |
| 63 | 63 | before := repo | |
| 64 | 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 | 66 | return domain.Repo{}, fmt.Errorf("%w: nothing to update", domain.ErrInvalid) | |
| 67 | 67 | } | |
| 68 | 68 | if params.AllowedMergeModes != nil { | |
| @@ -116,12 +116,12 @@ func (r *Repos) Update(ctx context.Context, p domain.Principal, name domain.Repo | |||
| 116 | 116 | // branch was checked above, so the remaining causes are a vanished | |
| 117 | 117 | // repository or an I/O failure. | |
| 118 | 118 | 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 | 120 | update := func() error { | |
| 121 | 121 | repo, err = r.updateMeta(ctx, name, params) | |
| 122 | 122 | 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 | 125 | err = r.mergeGate.WithMergeLock(ctx, name, update) | |
| 126 | 126 | } else { | |
| 127 | 127 | err = update() | |
| @@ -154,7 +154,7 @@ func (r *Repos) Update(ctx context.Context, p domain.Principal, name domain.Repo | |||
| 154 | 154 | Bool("topics", params.Topics != nil). | |
| 155 | 155 | Bool("visibility", params.Visibility != nil). | |
| 156 | 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 | 158 | Str("principal", p.Label()).Str("token_id", p.TokenID()). | |
| 159 | 159 | Msg("repository updated") | |
| 160 | 160 | r.bus.Publish(ctx, domain.RepoUpdated{ | |
| @@ -184,9 +184,6 @@ func (r *Repos) updateMeta(ctx context.Context, name domain.RepoName, params por | |||
| 184 | 184 | if params.AllowedMergeModes != nil { | |
| 185 | 185 | repo.Settings.AllowedMergeModes = *params.AllowedMergeModes | |
| 186 | 186 | } | |
| 187 | - | if params.FailingChecksBlock != nil { | |
| 188 | - | repo.Settings.FailingChecksBlock = *params.FailingChecksBlock | |
| 189 | - | } | |
| 190 | 187 | if params.AllowAutoMerge != nil { | |
| 191 | 188 | repo.Settings.AllowAutoMerge = *params.AllowAutoMerge | |
| 192 | 189 | } | |
| @@ -62,7 +62,7 @@ func (r *Repos) Update(ctx context.Context, p domain.Principal, name domain.Repo | |||||
| 62 | } | 62 | } | ||
| 63 | before := repo | 63 | 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 vanished | 116 | // 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 := false | 118 | 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 err | 122 | 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.AllowedMergeModes | 185 | 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.AllowAutoMerge | 188 | 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) { | |||
| 224 | 224 | t.Run("a token limited to branches may not change any setting", func(t *testing.T) { | |
| 225 | 225 | t.Parallel() | |
| 226 | 226 | modes := []domain.MergeMode{domain.MergeModeSquash} | |
| 227 | - | block := true | |
| 228 | 227 | for name, params := range map[string]ports.UpdateRepoParams{ | |
| 229 | 228 | "default branch": {DefaultBranch: ptr("dev")}, | |
| 230 | 229 | "visibility": {Visibility: ptr(domain.VisibilityPublic)}, | |
| 231 | 230 | "description": {Description: ptr("x")}, | |
| 232 | 231 | "merge modes": {AllowedMergeModes: &modes}, | |
| 233 | - | "checks rule": {FailingChecksBlock: &block}, | |
| 234 | 232 | "issues": {Issues: ptr(domain.IssuesOwner)}, | |
| 235 | 233 | } { | |
| 236 | 234 | f := newFixture(t) // no HasBranch, no Update, no SetDefaultBranch, no Publish | |
| @@ -289,7 +287,7 @@ func TestUpdateIssuesMode(t *testing.T) { | |||
| 289 | 287 | f := newFixture(t) | |
| 290 | 288 | f.meta.EXPECT().Get(ctx, n).Return(priv, nil).Once() | |
| 291 | 289 | 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 | |
| 293 | 291 | })) | |
| 294 | 292 | f.bus.EXPECT().Publish(ctx, mock.Anything).Return().Once() | |
| 295 | 293 | 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 Publish | 234 | 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.FailingChecksBlock | 290 | + | 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 { | |||
| 22 | 22 | message RepoSettings { | |
| 23 | 23 | // Never empty once stored. | |
| 24 | 24 | repeated MergeMode allowed_merge_modes = 1; | |
| 25 | - | bool failing_checks_block = 2; | |
| 25 | + | reserved 2; | |
| 26 | + | reserved "failing_checks_block"; | |
| 26 | 27 | // Who may write issues; unspecified reads as off. | |
| 27 | 28 | IssuesMode issues = 3; | |
| 28 | 29 | // 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 | + | reserved 2; |
| 26 | + | reserved "failing_checks_block"; | |||
| 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 explicit | 29 | // 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 | |||
| 11 | 11 | | `repo list [--json]` | none | repositories you can see | | |
| 12 | 12 | | `repo show <name> [--json]` | none | | | |
| 13 | 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 | 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 | 19 | ## Tokens | |
| 20 | 20 | ||
| @@ -28,7 +28,7 @@ Presets add up with `--scope` (repeatable or comma-separated): | |||
| 28 | 28 | ||
| 29 | 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 | 32 | | `agent` | `repo:write`, `mr:write` | | |
| 33 | 33 | | `ci` | `repo:read` | | |
| 34 | 34 | ||
| @@ -42,6 +42,7 @@ Scopes: | |||
| 42 | 42 | | `mr:write` | open, comment on, reply, edit and delete own notes, resolve, close, mark read | | |
| 43 | 43 | | `mr:review` | give a verdict (`--approve`, `--request-changes`) | | |
| 44 | 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 | 46 | | `issue:write` | open, comment on, close and reopen issues | | |
| 46 | 47 | | `token:admin` | create, list and revoke tokens | | |
| 47 | 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 | 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 | 56 | ## Merge requests | |
| 54 | 57 | ||
| 55 | 58 | | Command | Scope | Notes | | |
| @@ -61,10 +64,13 @@ A token can never grant more than the token that creates it. `--repo` (repeatabl | |||
| 61 | 64 | | `mr edit-comment <repo> <n> <comment-id> -m TEXT\|-` | `mr:write` | author only, open merge request; the verdict is unchanged | | |
| 62 | 65 | | `mr delete-comment <repo> <n> <comment-id>` | `mr:write` | author only; the text is lost, the verdict is kept | | |
| 63 | 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 | 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 | 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 | 74 | `ermite mr create` and `ermite mr close` print a confirmation followed by the full | |
| 69 | 75 | merge request web URL on its own line. With `--json`, merge request objects | |
| 70 | 76 | (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 | ## Tokens | 19 | ## 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 requests | 56 | ## 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 full | 74 | `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 objects | 75 | 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, | |||
| 37 | 37 | 2. Read: `ermite mr show <repo> <n> --diff --json`. Keep `head_id`; take line numbers from the hunks. | |
| 38 | 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 | 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 | 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 | 65 | 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 | 70 | ## Follow events | |
| 69 | 71 | ||
| @@ -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 30d | 65 | 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 events | 70 | ## Follow events | ||
| 69 | 71 | ||||