feat(web,cli): live mergeability in the merge box, merge modes and inline merge errors
- commit
- b5b4bf99dbfc084d3ae3141928c279c9e43dd162
- author
- brice · · 2026-10-19 14:23 UTC
- committer
- brice · · 2026-10-19 18:30 UTC
- parent
- fc9623b
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
37 files · +2392 -849
M README.md +1 -1
| @@ -122,7 +122,7 @@ ermite mr show myrepo 1 --since-last-read --mark-read | |||
| 122 | 122 | **5. You merge** (or click Merge in the web interface): | |
| 123 | 123 | ||
| 124 | 124 | ```sh | |
| 125 | - | ermite mr merge myrepo 1 --head <head id> --squash --delete-branch | |
| 125 | + | ermite mr merge myrepo 1 --head <head id> --mode squash --delete-branch | |
| 126 | 126 | ``` | |
| 127 | 127 | ||
| 128 | 128 | Give the agent the [`skills/ermite`](skills/ermite/SKILL.md) skill so it knows the CLI. Rules of agent tokens: [docs/tokens.md](docs/tokens.md). | |
| @@ -122,7 +122,7 @@ ermite mr show myrepo 1 --since-last-read --mark-read | |||||
| 122 | **5. You merge** (or click Merge in the web interface): | 122 | **5. You merge** (or click Merge in the web interface): | ||
| 123 | 123 | ||||
| 124 | ```sh | 124 | ```sh | ||
| 125 | - | ermite mr merge myrepo 1 --head <head id> --squash --delete-branch | 125 | + | ermite mr merge myrepo 1 --head <head id> --mode squash --delete-branch |
| 126 | ``` | 126 | ``` | ||
| 127 | 127 | ||||
| 128 | Give the agent the [`skills/ermite`](skills/ermite/SKILL.md) skill so it knows the CLI. Rules of agent tokens: [docs/tokens.md](docs/tokens.md). | 128 | Give the agent the [`skills/ermite`](skills/ermite/SKILL.md) skill so it knows the CLI. Rules of agent tokens: [docs/tokens.md](docs/tokens.md). | ||
M docs/cli.md +5 -4
| @@ -54,10 +54,10 @@ ermite repo delete <name> [--yes] | |||
| 54 | 54 | ermite token create <label> [--preset cli|agent|ci|runner] [--scope …] [--repo …] [--branch-prefix …] [--expires 30d] | |
| 55 | 55 | ermite token list | revoke <id> | |
| 56 | 56 | ||
| 57 | - | ermite mr create <repo> --source <branch> --title … | list <repo> | show <repo> <n> [--diff] | |
| 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> [--squash] [--delete-branch] | close <repo> <n> | |
| 60 | + | ermite mr merge <repo> <n> --head <id> [--mode fast-forward|squash|merge] [--delete-branch] | close <repo> <n> | |
| 61 | 61 | ||
| 62 | 62 | ermite issue create <repo> --title … [--body … | --body-file …] | list <repo> [--state open|closed|all] | |
| 63 | 63 | ermite issue show <repo> <n> | comment <repo> <n> -m … | close <repo> <n> | reopen <repo> <n> | |
| @@ -78,7 +78,7 @@ ermite environment create|list|delete … --repo <repo> | |||
| 78 | 78 | ermite web login # one-time sign-in link (60 s) | |
| 79 | 79 | ermite web logout --all # sign every browser out | |
| 80 | 80 | ermite web passkey list | remove <id> | |
| 81 | - | ermite signing-key show # public key of squash commits | |
| 81 | + | ermite signing-key show # public key of squash and merge commits (server-signed) | |
| 82 | 82 | ermite oidc rotate-key | |
| 83 | 83 | ermite watch [repo] [--json] # follow live events | |
| 84 | 84 | ermite version # client and server versions | |
| @@ -90,7 +90,8 @@ Issues need a server with the `issues` feature and `ermite repo edit <repo> --is | |||
| 90 | 90 | ## Output | |
| 91 | 91 | ||
| 92 | 92 | - `--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` and `version`. | |
| 93 | - | - `watch --json` prints one object per line. | |
| 93 | + | - `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`. | |
| 94 | + | - `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`. | |
| 94 | 95 | - Errors are one line on stderr (`ermite: …`) with exit status 1. | |
| 95 | 96 | - Shell completion completes repository names from the server. | |
| 96 | 97 | ||
| @@ -54,10 +54,10 @@ ermite repo delete <name> [--yes] | |||||
| 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] | ||
| 55 | ermite token list | revoke <id> | 55 | ermite token list | revoke <id> | ||
| 56 | 56 | ||||
| 57 | - | ermite mr create <repo> --source <branch> --title … | list <repo> | show <repo> <n> [--diff] | 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> [--squash] [--delete-branch] | close <repo> <n> | 60 | + | ermite mr merge <repo> <n> --head <id> [--mode fast-forward|squash|merge] [--delete-branch] | close <repo> <n> |
| 61 | 61 | ||||
| 62 | ermite issue create <repo> --title … [--body … | --body-file …] | list <repo> [--state open|closed|all] | 62 | 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> | 63 | ermite issue show <repo> <n> | comment <repo> <n> -m … | close <repo> <n> | reopen <repo> <n> | ||
| @@ -78,7 +78,7 @@ ermite environment create|list|delete … --repo <repo> | |||||
| 78 | ermite web login # one-time sign-in link (60 s) | 78 | ermite web login # one-time sign-in link (60 s) | ||
| 79 | ermite web logout --all # sign every browser out | 79 | ermite web logout --all # sign every browser out | ||
| 80 | ermite web passkey list | remove <id> | 80 | ermite web passkey list | remove <id> | ||
| 81 | - | ermite signing-key show # public key of squash commits | 81 | + | ermite signing-key show # public key of squash and merge commits (server-signed) |
| 82 | ermite oidc rotate-key | 82 | ermite oidc rotate-key | ||
| 83 | ermite watch [repo] [--json] # follow live events | 83 | ermite watch [repo] [--json] # follow live events | ||
| 84 | ermite version # client and server versions | 84 | ermite version # client and server versions | ||
| @@ -90,7 +90,8 @@ Issues need a server with the `issues` feature and `ermite repo edit <repo> --is | |||||
| 90 | ## Output | 90 | ## Output | ||
| 91 | 91 | ||||
| 92 | - `--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` and `version`. | 92 | - `--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` and `version`. | ||
| 93 | - | - `watch --json` prints one object per line. | 93 | + | - `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`. |
| 94 | + | - `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`. | |||
| 94 | - Errors are one line on stderr (`ermite: …`) with exit status 1. | 95 | - Errors are one line on stderr (`ermite: …`) with exit status 1. | ||
| 95 | - Shell completion completes repository names from the server. | 96 | - Shell completion completes repository names from the server. | ||
| 96 | 97 | ||||
M docs/commit-signatures.md +1 -1
| @@ -36,5 +36,5 @@ ermite signing-key trust remove <id> | |||
| 36 | 36 | ||
| 37 | 37 | - A key may sign for 1 to 10 emails; up to 100 keys. | |
| 38 | 38 | - The id is the fingerprint: `SHA256:…` for SSH, the primary key fingerprint for OpenPGP. | |
| 39 | - | - The server's own key (`ermite signing-key show`), which signs squash merges, is always trusted for the committer email and cannot be removed. | |
| 39 | + | - The server's own key (`ermite signing-key show`), which signs squash and merge commits (generated automatically on first start), is always trusted for the committer email and cannot be removed. | |
| 40 | 40 | - Private keys are refused. | |
| @@ -36,5 +36,5 @@ ermite signing-key trust remove <id> | |||||
| 36 | 36 | ||||
| 37 | - A key may sign for 1 to 10 emails; up to 100 keys. | 37 | - A key may sign for 1 to 10 emails; up to 100 keys. | ||
| 38 | - The id is the fingerprint: `SHA256:…` for SSH, the primary key fingerprint for OpenPGP. | 38 | - The id is the fingerprint: `SHA256:…` for SSH, the primary key fingerprint for OpenPGP. | ||
| 39 | - | - The server's own key (`ermite signing-key show`), which signs squash merges, is always trusted for the committer email and cannot be removed. | 39 | + | - The server's own key (`ermite signing-key show`), which signs squash and merge commits (generated automatically on first start), is always trusted for the committer email and cannot be removed. |
| 40 | - Private keys are refused. | 40 | - Private keys are refused. | ||
M docs/configuration.md +2 -2
| @@ -34,8 +34,8 @@ ermite serve | |||
| 34 | 34 | |---|---|---| | |
| 35 | 35 | | `ERMITE_GIT` | `git` | git 2.45 or newer | | |
| 36 | 36 | | `ERMITE_GIT_READER` | `ermite-git` | `ermite-git` reads with the [ermite-git](https://github.com/bnema/ermite-git) library where it is faster, and falls back to `git`. `cli` uses `git` for every read: set it and restart if a read looks wrong | | |
| 37 | - | | `ERMITE_COMMITTER_NAME` | `ermite` | committer of squash merges | | |
| 38 | - | | `ERMITE_COMMITTER_EMAIL` | `ermite@<public host>` | committer email of squash merges | | |
| 37 | + | | `ERMITE_COMMITTER_NAME` | `ermite` | committer of squash and merge commits | | |
| 38 | + | | `ERMITE_COMMITTER_EMAIL` | `ermite@<public host>` | committer email of squash and merge commits | | |
| 39 | 39 | ||
| 40 | 40 | ## CI | |
| 41 | 41 | ||
| @@ -34,8 +34,8 @@ ermite serve | |||||
| 34 | |---|---|---| | 34 | |---|---|---| | ||
| 35 | | `ERMITE_GIT` | `git` | git 2.45 or newer | | 35 | | `ERMITE_GIT` | `git` | git 2.45 or newer | | ||
| 36 | | `ERMITE_GIT_READER` | `ermite-git` | `ermite-git` reads with the [ermite-git](https://github.com/bnema/ermite-git) library where it is faster, and falls back to `git`. `cli` uses `git` for every read: set it and restart if a read looks wrong | | 36 | | `ERMITE_GIT_READER` | `ermite-git` | `ermite-git` reads with the [ermite-git](https://github.com/bnema/ermite-git) library where it is faster, and falls back to `git`. `cli` uses `git` for every read: set it and restart if a read looks wrong | | ||
| 37 | - | | `ERMITE_COMMITTER_NAME` | `ermite` | committer of squash merges | | 37 | + | | `ERMITE_COMMITTER_NAME` | `ermite` | committer of squash and merge commits | |
| 38 | - | | `ERMITE_COMMITTER_EMAIL` | `ermite@<public host>` | committer email of squash merges | | 38 | + | | `ERMITE_COMMITTER_EMAIL` | `ermite@<public host>` | committer email of squash and merge commits | |
| 39 | 39 | ||||
| 40 | ## CI | 40 | ## CI | ||
| 41 | 41 | ||||
M docs/merge-requests.md +45 -7
| @@ -5,26 +5,64 @@ A merge request proposes a branch for a target (the default branch unless set). | |||
| 5 | 5 | ```sh | |
| 6 | 6 | ermite mr create myrepo --source feature --title "Add feature" | |
| 7 | 7 | ermite mr show myrepo 1 --diff | |
| 8 | - | ermite mr merge myrepo 1 --head <commit> [--squash] [--delete-branch] | |
| 8 | + | ermite mr merge myrepo 1 --head <commit> [--mode fast-forward|squash|merge] [--delete-branch] | |
| 9 | 9 | ``` | |
| 10 | 10 | ||
| 11 | 11 | `ermite mr create` and `ermite mr close` print a confirmation followed by the full | |
| 12 | 12 | merge request web URL on its own line. With `--json`, merge request objects | |
| 13 | 13 | (create, show, list, close and merge) include `url` alongside the other fields. | |
| 14 | 14 | ||
| 15 | - | - **Fast-forward** moves the target. **Squash** writes one commit signed by the server's SSH key. | |
| 16 | - | - The merge is refused when the branch moved since the reviewed `--head`, or when the target is not an ancestor of it. | |
| 15 | + | ## Merge modes | |
| 16 | + | ||
| 17 | + | | Mode | Result | Needs | | |
| 18 | + | |---|---|---| | |
| 19 | + | | `fast-forward` (default) | the target moves to the head, no commit | the target is an ancestor of the head | | |
| 20 | + | | `squash` | one new commit with the merged tree, the target tip as only parent | the branch merges without conflicts | | |
| 21 | + | | `merge` | a merge commit with two parents, the target tip and the head | the branch merges without conflicts | | |
| 22 | + | ||
| 23 | + | - The server never rebases. A branch behind its target merges with `squash` or `merge`, or is rebased and pushed again for a fast-forward. | |
| 24 | + | - 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). | |
| 25 | + | - The merge is also refused when the branch moved since the reviewed `--head`. | |
| 17 | 26 | - With `failing_checks_block` on (default), the merge is refused while a check of the head failed or is still running. | |
| 18 | - | - `--delete-branch` deletes the source branch, unless it is the default branch or another open request uses it. | |
| 19 | - | - `ermite repo edit --merge-modes` limits the modes per repository. | |
| 27 | + | - 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. | |
| 28 | + | - 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). | |
| 20 | 29 | - Scopes: `mr:write` to open and comment, `mr:review` to give a verdict, `mr:merge` to merge. | |
| 21 | 30 | ||
| 22 | - | Verify squash commits with the `allowed_signers` line from `ermite signing-key show`: | |
| 31 | + | ## Signed commits | |
| 32 | + | ||
| 33 | + | Squash and merge commits are signed by ermite's own key. The key is generated automatically the first time the server starts, belongs to the server, and its committer identity is `ERMITE_COMMITTER_NAME` / `ERMITE_COMMITTER_EMAIL`. `ermite signing-key show` prints its fingerprint and the `allowed_signers` line; use it to verify locally, or register the public key on GitHub or Forgejo. | |
| 34 | + | ||
| 35 | + | ```sh | |
| 36 | + | ermite signing-key show --allowed-signers > allowed_signers | |
| 37 | + | git -c gpg.format=ssh -c gpg.ssh.allowedSignersFile=allowed_signers verify-commit <commit> | |
| 38 | + | ``` | |
| 39 | + | ||
| 40 | + | The merge box of the web page and the output of `mr merge --mode squash|merge` repeat this with the fingerprint. | |
| 41 | + | ||
| 42 | + | ## Mergeability | |
| 43 | + | ||
| 44 | + | The server checks in the background whether each open request merges with its target, and again whenever the source or the target moves. `mr show` prints it; the web merge box shows it as a live badge; `ermite watch` reports each change. | |
| 45 | + | ||
| 46 | + | | State | Meaning | Modes possible | | |
| 47 | + | |---|---|---| | |
| 48 | + | | `checking` | no result yet for the current branches | none (wait) | | |
| 49 | + | | `up_to_date` | the target is an ancestor of the head | all | | |
| 50 | + | | `behind` | the head lacks `behind` commits of the target (`behind_capped`: at least 1000) but merges without conflicts | `squash`, `merge` | | |
| 51 | + | | `conflict` | the merge conflicts; up to 20 `conflict_paths` are listed (`conflict_paths_truncated` when there are more); no paths means no common history | none (rebase) | | |
| 23 | 52 | ||
| 24 | 53 | ```sh | |
| 25 | - | git verify-commit <commit> | |
| 54 | + | ermite mr show myrepo 1 | |
| 55 | + | # mergeability behind by 2, merges without conflicts (squash or merge commit; fast-forward needs a rebase) | |
| 26 | 56 | ``` | |
| 27 | 57 | ||
| 58 | + | In `mr show --json`, `mergeability` holds `state`, `behind`, `behind_capped`, `conflict_paths`, `conflict_paths_truncated`, `target_id` and `head_id`; it is `null` unless the request is open. `ermite watch` prints `merge_request_mergeability` events when the state changes (not on every push): | |
| 59 | + | ||
| 60 | + | ```text | |
| 61 | + | 2026-10-19T14:00:00Z merge_request_mergeability myrepo!1 mergeability: behind by 2 | |
| 62 | + | ``` | |
| 63 | + | ||
| 64 | + | In the web merge box a mode that is not possible is disabled with its reason, and the Merge button too when no mode is possible. A merge the server refuses (target moved, conflict, branch moved) shows the page again with the reason in the box. | |
| 65 | + | ||
| 28 | 66 | ## Notes | |
| 29 | 67 | ||
| 30 | 68 | A note is the only review object. It is published as soon as it is written: there is no draft and no submit step. | |
| @@ -5,26 +5,64 @@ A merge request proposes a branch for a target (the default branch unless set). | |||||
| 5 | ```sh | 5 | ```sh | ||
| 6 | ermite mr create myrepo --source feature --title "Add feature" | 6 | ermite mr create myrepo --source feature --title "Add feature" | ||
| 7 | ermite mr show myrepo 1 --diff | 7 | ermite mr show myrepo 1 --diff | ||
| 8 | - | ermite mr merge myrepo 1 --head <commit> [--squash] [--delete-branch] | 8 | + | ermite mr merge myrepo 1 --head <commit> [--mode fast-forward|squash|merge] [--delete-branch] |
| 9 | ``` | 9 | ``` | ||
| 10 | 10 | ||||
| 11 | `ermite mr create` and `ermite mr close` print a confirmation followed by the full | 11 | `ermite mr create` and `ermite mr close` print a confirmation followed by the full | ||
| 12 | merge request web URL on its own line. With `--json`, merge request objects | 12 | merge request web URL on its own line. With `--json`, merge request objects | ||
| 13 | (create, show, list, close and merge) include `url` alongside the other fields. | 13 | (create, show, list, close and merge) include `url` alongside the other fields. | ||
| 14 | 14 | ||||
| 15 | - | - **Fast-forward** moves the target. **Squash** writes one commit signed by the server's SSH key. | 15 | + | ## Merge modes |
| 16 | - | - The merge is refused when the branch moved since the reviewed `--head`, or when the target is not an ancestor of it. | 16 | + | |
| 17 | + | | Mode | Result | Needs | | |||
| 18 | + | |---|---|---| | |||
| 19 | + | | `fast-forward` (default) | the target moves to the head, no commit | the target is an ancestor of the head | | |||
| 20 | + | | `squash` | one new commit with the merged tree, the target tip as only parent | the branch merges without conflicts | | |||
| 21 | + | | `merge` | a merge commit with two parents, the target tip and the head | the branch merges without conflicts | | |||
| 22 | + | ||||
| 23 | + | - The server never rebases. A branch behind its target merges with `squash` or `merge`, or is rebased and pushed again for a fast-forward. | |||
| 24 | + | - 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). | |||
| 25 | + | - The merge is also refused when the branch moved since the reviewed `--head`. | |||
| 17 | - With `failing_checks_block` on (default), the merge is refused while a check of the head failed or is still running. | 26 | - With `failing_checks_block` on (default), the merge is refused while a check of the head failed or is still running. | ||
| 18 | - | - `--delete-branch` deletes the source branch, unless it is the default branch or another open request uses it. | 27 | + | - 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. |
| 19 | - | - `ermite repo edit --merge-modes` limits the modes per repository. | 28 | + | - 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). |
| 20 | - Scopes: `mr:write` to open and comment, `mr:review` to give a verdict, `mr:merge` to merge. | 29 | - Scopes: `mr:write` to open and comment, `mr:review` to give a verdict, `mr:merge` to merge. | ||
| 21 | 30 | ||||
| 22 | - | Verify squash commits with the `allowed_signers` line from `ermite signing-key show`: | 31 | + | ## Signed commits |
| 32 | + | ||||
| 33 | + | Squash and merge commits are signed by ermite's own key. The key is generated automatically the first time the server starts, belongs to the server, and its committer identity is `ERMITE_COMMITTER_NAME` / `ERMITE_COMMITTER_EMAIL`. `ermite signing-key show` prints its fingerprint and the `allowed_signers` line; use it to verify locally, or register the public key on GitHub or Forgejo. | |||
| 34 | + | ||||
| 35 | + | ```sh | |||
| 36 | + | ermite signing-key show --allowed-signers > allowed_signers | |||
| 37 | + | git -c gpg.format=ssh -c gpg.ssh.allowedSignersFile=allowed_signers verify-commit <commit> | |||
| 38 | + | ``` | |||
| 39 | + | ||||
| 40 | + | The merge box of the web page and the output of `mr merge --mode squash|merge` repeat this with the fingerprint. | |||
| 41 | + | ||||
| 42 | + | ## Mergeability | |||
| 43 | + | ||||
| 44 | + | The server checks in the background whether each open request merges with its target, and again whenever the source or the target moves. `mr show` prints it; the web merge box shows it as a live badge; `ermite watch` reports each change. | |||
| 45 | + | ||||
| 46 | + | | State | Meaning | Modes possible | | |||
| 47 | + | |---|---|---| | |||
| 48 | + | | `checking` | no result yet for the current branches | none (wait) | | |||
| 49 | + | | `up_to_date` | the target is an ancestor of the head | all | | |||
| 50 | + | | `behind` | the head lacks `behind` commits of the target (`behind_capped`: at least 1000) but merges without conflicts | `squash`, `merge` | | |||
| 51 | + | | `conflict` | the merge conflicts; up to 20 `conflict_paths` are listed (`conflict_paths_truncated` when there are more); no paths means no common history | none (rebase) | | |||
| 23 | 52 | ||||
| 24 | ```sh | 53 | ```sh | ||
| 25 | - | git verify-commit <commit> | 54 | + | ermite mr show myrepo 1 |
| 55 | + | # mergeability behind by 2, merges without conflicts (squash or merge commit; fast-forward needs a rebase) | |||
| 26 | ``` | 56 | ``` | ||
| 27 | 57 | ||||
| 58 | + | In `mr show --json`, `mergeability` holds `state`, `behind`, `behind_capped`, `conflict_paths`, `conflict_paths_truncated`, `target_id` and `head_id`; it is `null` unless the request is open. `ermite watch` prints `merge_request_mergeability` events when the state changes (not on every push): | |||
| 59 | + | ||||
| 60 | + | ```text | |||
| 61 | + | 2026-10-19T14:00:00Z merge_request_mergeability myrepo!1 mergeability: behind by 2 | |||
| 62 | + | ``` | |||
| 63 | + | ||||
| 64 | + | In the web merge box a mode that is not possible is disabled with its reason, and the Merge button too when no mode is possible. A merge the server refuses (target moved, conflict, branch moved) shows the page again with the reason in the box. | |||
| 65 | + | ||||
| 28 | ## Notes | 66 | ## Notes | ||
| 29 | 67 | ||||
| 30 | A note is the only review object. It is published as soon as it is written: there is no draft and no submit step. | 68 | A note is the only review object. It is published as soon as it is written: there is no draft and no submit step. | ||
M internal/adapters/web/events.go +6 -2
| @@ -31,7 +31,7 @@ import ( | |||
| 31 | 31 | // GET /-/events?topic=repo:<name>&view=summary|refs | |
| 32 | 32 | // GET /-/events?topic=repo:<name>&view=commits&ref=<branch>&head=<sha> | |
| 33 | 33 | // GET /-/events?topic=repo:<name>&view=mrs | |
| 34 | - | // GET /-/events?topic=repo:<name>&view=mr&n=<number>&ref=<source branch> | |
| 34 | + | // GET /-/events?topic=repo:<name>&view=mr&n=<number>&ref=<source branch>&target=<target branch> | |
| 35 | 35 | // GET /-/events?topic=repo:<name>&view=issues | |
| 36 | 36 | // GET /-/events?topic=repo:<name>&view=issue&n=<number> | |
| 37 | 37 | // GET /-/events?topic=repo:<name>&view=actions | |
| @@ -56,6 +56,10 @@ import ( | |||
| 56 | 56 | // refs-list data: <tbody id="ref-rows">…</tbody>, and again with | |
| 57 | 57 | // <dl id="refs-kv">…</dl>, the facts of the rail | |
| 58 | 58 | // commits-head data: <div id="commits-head">N new commits — reload</div> | |
| 59 | + | // mr-mergeability data: <div id="mr-mergeability">…</div>, on the merge request pages: | |
| 60 | + | // the badge of the merge box (a slot like mr-notice), when the | |
| 61 | + | // mergeability of the request shown settles or a push moves its | |
| 62 | + | // source or target | |
| 59 | 63 | // mr-notice data: <div id="mr-notice">…</div>, on the merge request and issue pages: | |
| 60 | 64 | // something changed, with the link that reloads the page | |
| 61 | 65 | // ci-run data: <tr id="run-<id>">…</tr> (the list of runs), | |
| @@ -254,7 +258,7 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) { | |||
| 254 | 258 | return | |
| 255 | 259 | } | |
| 256 | 260 | ||
| 257 | - | st := &liveStream{s: s, repo: repo, view: q.Get("view"), ref: q.Get("ref"), head: q.Get("head"), n: q.Get("n"), job: q.Get("job"), sha: sha} | |
| 261 | + | st := &liveStream{s: s, repo: repo, view: q.Get("view"), ref: q.Get("ref"), head: q.Get("head"), n: q.Get("n"), target: q.Get("target"), job: q.Get("job"), sha: sha} | |
| 258 | 262 | if topic == ownerTopic { | |
| 259 | 263 | st.filter = q.Get("repo") | |
| 260 | 264 | } | |
| @@ -31,7 +31,7 @@ import ( | |||||
| 31 | // GET /-/events?topic=repo:<name>&view=summary|refs | 31 | // GET /-/events?topic=repo:<name>&view=summary|refs | ||
| 32 | // GET /-/events?topic=repo:<name>&view=commits&ref=<branch>&head=<sha> | 32 | // GET /-/events?topic=repo:<name>&view=commits&ref=<branch>&head=<sha> | ||
| 33 | // GET /-/events?topic=repo:<name>&view=mrs | 33 | // GET /-/events?topic=repo:<name>&view=mrs | ||
| 34 | - | // GET /-/events?topic=repo:<name>&view=mr&n=<number>&ref=<source branch> | 34 | + | // GET /-/events?topic=repo:<name>&view=mr&n=<number>&ref=<source branch>&target=<target branch> |
| 35 | // GET /-/events?topic=repo:<name>&view=issues | 35 | // GET /-/events?topic=repo:<name>&view=issues | ||
| 36 | // GET /-/events?topic=repo:<name>&view=issue&n=<number> | 36 | // GET /-/events?topic=repo:<name>&view=issue&n=<number> | ||
| 37 | // GET /-/events?topic=repo:<name>&view=actions | 37 | // GET /-/events?topic=repo:<name>&view=actions | ||
| @@ -56,6 +56,10 @@ import ( | |||||
| 56 | // refs-list data: <tbody id="ref-rows">…</tbody>, and again with | 56 | // refs-list data: <tbody id="ref-rows">…</tbody>, and again with | ||
| 57 | // <dl id="refs-kv">…</dl>, the facts of the rail | 57 | // <dl id="refs-kv">…</dl>, the facts of the rail | ||
| 58 | // commits-head data: <div id="commits-head">N new commits — reload</div> | 58 | // commits-head data: <div id="commits-head">N new commits — reload</div> | ||
| 59 | + | // mr-mergeability data: <div id="mr-mergeability">…</div>, on the merge request pages: | |||
| 60 | + | // the badge of the merge box (a slot like mr-notice), when the | |||
| 61 | + | // mergeability of the request shown settles or a push moves its | |||
| 62 | + | // source or target | |||
| 59 | // mr-notice data: <div id="mr-notice">…</div>, on the merge request and issue pages: | 63 | // mr-notice data: <div id="mr-notice">…</div>, on the merge request and issue pages: | ||
| 60 | // something changed, with the link that reloads the page | 64 | // something changed, with the link that reloads the page | ||
| 61 | // ci-run data: <tr id="run-<id>">…</tr> (the list of runs), | 65 | // ci-run data: <tr id="run-<id>">…</tr> (the list of runs), | ||
| @@ -254,7 +258,7 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) { | |||||
| 254 | return | 258 | return | ||
| 255 | } | 259 | } | ||
| 256 | 260 | ||||
| 257 | - | st := &liveStream{s: s, repo: repo, view: q.Get("view"), ref: q.Get("ref"), head: q.Get("head"), n: q.Get("n"), job: q.Get("job"), sha: sha} | 261 | + | st := &liveStream{s: s, repo: repo, view: q.Get("view"), ref: q.Get("ref"), head: q.Get("head"), n: q.Get("n"), target: q.Get("target"), job: q.Get("job"), sha: sha} |
| 258 | if topic == ownerTopic { | 262 | if topic == ownerTopic { | ||
| 259 | st.filter = q.Get("repo") | 263 | st.filter = q.Get("repo") | ||
| 260 | } | 264 | } | ||
M internal/adapters/web/fragments.go +58 -8
| @@ -28,6 +28,7 @@ type liveStream struct { | |||
| 28 | 28 | head string // commits: the commit listed first | |
| 29 | 29 | n string // mr: the number of the merge request shown; run: the run id | |
| 30 | 30 | job string // job: the id of the job shown | |
| 31 | + | target string // mr: the target branch of the merge request shown | |
| 31 | 32 | // sha is the commit of the run of the job shown, read once when the stream | |
| 32 | 33 | // is set up: the annotations link to files at it, and it never changes. | |
| 33 | 34 | sha string | |
| @@ -60,7 +61,7 @@ func liveEvent(we ports.WatchEvent) bool { | |||
| 60 | 61 | } | |
| 61 | 62 | switch we.Event.(type) { | |
| 62 | 63 | case domain.RepoCreated, domain.RepoUpdated, domain.RepoDeleted, domain.PushEvent, | |
| 63 | - | domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.MergeRequestReady, domain.CommentAdded, domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved, | |
| 64 | + | domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.MergeRequestReady, domain.MergeRequestMergeability, domain.CommentAdded, domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved, | |
| 64 | 65 | domain.RunUpdated, domain.JobUpdated, | |
| 65 | 66 | domain.IssueOpened, domain.IssueClosed, domain.IssueReopened, domain.IssueCommented, domain.IssueSubmitted, domain.IssuePendingRemoved: | |
| 66 | 67 | return true | |
| @@ -188,6 +189,11 @@ func (l *liveStream) repoPage(ctx context.Context, we ports.WatchEvent) ([]fragm | |||
| 188 | 189 | return l.mrNotice(ctx, ev.MergeRequest.Number, "this merge request was closed — reload") | |
| 189 | 190 | case domain.MergeRequestReady: | |
| 190 | 191 | return l.mrNotice(ctx, ev.MergeRequest.Number, "this merge request is ready for review — reload") | |
| 192 | + | case domain.MergeRequestMergeability: | |
| 193 | + | if l.view != "mr" || strconv.FormatInt(ev.Number, 10) != l.n { | |
| 194 | + | return nil, nil | |
| 195 | + | } | |
| 196 | + | return l.mrMergeability(ctx) | |
| 191 | 197 | case domain.CommentEdited: | |
| 192 | 198 | return l.mrNotice(ctx, ev.Number, "a note was edited — reload") | |
| 193 | 199 | case domain.CommentDeleted: | |
| @@ -454,21 +460,65 @@ func (l *liveStream) issueNotice(ctx context.Context, number int64, text string) | |||
| 454 | 460 | return nil, nil | |
| 455 | 461 | } | |
| 456 | 462 | ||
| 457 | - | // mrPushed announces a push to the source branch of the merge request shown. | |
| 463 | + | // mrPushed announces a push to the source branch of the merge request shown, | |
| 464 | + | // and has the badge follow a push to its source or its target. | |
| 458 | 465 | func (l *liveStream) mrPushed(ctx context.Context, p domain.PushEvent) ([]fragment, error) { | |
| 459 | - | if l.ref == "" { | |
| 466 | + | n, err := strconv.ParseInt(l.n, 10, 64) | |
| 467 | + | if err != nil { | |
| 460 | 468 | return nil, nil | |
| 461 | 469 | } | |
| 470 | + | var out []fragment | |
| 471 | + | moved := false | |
| 462 | 472 | for _, u := range p.Updates { | |
| 463 | - | if u.Ref == "refs/heads/"+l.ref { | |
| 464 | - | n, err := strconv.ParseInt(l.n, 10, 64) | |
| 473 | + | switch { | |
| 474 | + | case l.ref != "" && u.Ref == "refs/heads/"+l.ref: | |
| 475 | + | notice, err := l.notice(ctx, "the branch changed — reload", mrHref(l.repo, n)) | |
| 465 | 476 | if err != nil { | |
| 466 | - | return nil, nil | |
| 477 | + | return nil, err | |
| 467 | 478 | } | |
| 468 | - | return l.notice(ctx, "the branch changed — reload", mrHref(l.repo, n)) | |
| 479 | + | out = append(out, notice...) | |
| 480 | + | moved = true | |
| 481 | + | case l.target != "" && u.Ref == "refs/heads/"+l.target: | |
| 482 | + | moved = true | |
| 483 | + | } | |
| 484 | + | if moved && len(out) > 0 { | |
| 485 | + | break | |
| 469 | 486 | } | |
| 470 | 487 | } | |
| 471 | - | return nil, nil | |
| 488 | + | if !moved { | |
| 489 | + | return nil, nil | |
| 490 | + | } | |
| 491 | + | badge, err := l.mrMergeability(ctx) | |
| 492 | + | return append(out, badge...), err | |
| 493 | + | } | |
| 494 | + | ||
| 495 | + | // mrMergeability is the badge of the merge box, read again: the result for the | |
| 496 | + | // tips as they are now, "checking" until the server has one (it then sends the | |
| 497 | + | // MergeRequestMergeability that turns the badge). Nothing for a request that | |
| 498 | + | // is not open. | |
| 499 | + | func (l *liveStream) mrMergeability(ctx context.Context) ([]fragment, error) { | |
| 500 | + | if !SignedIn(ctx) || l.s.cfg.MergeRequests == nil { | |
| 501 | + | return nil, nil // the box is the owner's | |
| 502 | + | } | |
| 503 | + | name, err := domain.ParseRepoName(l.repo) | |
| 504 | + | if err != nil { | |
| 505 | + | return nil, nil | |
| 506 | + | } | |
| 507 | + | n, err := strconv.ParseInt(l.n, 10, 64) | |
| 508 | + | if err != nil { | |
| 509 | + | return nil, nil | |
| 510 | + | } | |
| 511 | + | m, err := l.s.cfg.MergeRequests.Mergeability(ctx, principal(ctx), name, n) | |
| 512 | + | switch { | |
| 513 | + | case errors.Is(err, domain.ErrNotFound): | |
| 514 | + | return gap, nil | |
| 515 | + | case err != nil: | |
| 516 | + | return nil, err | |
| 517 | + | case m.State == "": | |
| 518 | + | return nil, nil | |
| 519 | + | } | |
| 520 | + | f, err := renderFragment(ctx, "mr-mergeability", MRMergeabilityFragment(mergeabilityView(m, l.target))) | |
| 521 | + | return []fragment{f}, err | |
| 472 | 522 | } | |
| 473 | 523 | ||
| 474 | 524 | func (l *liveStream) notice(ctx context.Context, text, href string) ([]fragment, error) { | |
| @@ -28,6 +28,7 @@ type liveStream struct { | |||||
| 28 | head string // commits: the commit listed first | 28 | head string // commits: the commit listed first | ||
| 29 | n string // mr: the number of the merge request shown; run: the run id | 29 | n string // mr: the number of the merge request shown; run: the run id | ||
| 30 | job string // job: the id of the job shown | 30 | job string // job: the id of the job shown | ||
| 31 | + | target string // mr: the target branch of the merge request shown | |||
| 31 | // sha is the commit of the run of the job shown, read once when the stream | 32 | // sha is the commit of the run of the job shown, read once when the stream | ||
| 32 | // is set up: the annotations link to files at it, and it never changes. | 33 | // is set up: the annotations link to files at it, and it never changes. | ||
| 33 | sha string | 34 | sha string | ||
| @@ -60,7 +61,7 @@ func liveEvent(we ports.WatchEvent) bool { | |||||
| 60 | } | 61 | } | ||
| 61 | switch we.Event.(type) { | 62 | switch we.Event.(type) { | ||
| 62 | case domain.RepoCreated, domain.RepoUpdated, domain.RepoDeleted, domain.PushEvent, | 63 | case domain.RepoCreated, domain.RepoUpdated, domain.RepoDeleted, domain.PushEvent, | ||
| 63 | - | domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.MergeRequestReady, domain.CommentAdded, domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved, | 64 | + | domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.MergeRequestReady, domain.MergeRequestMergeability, domain.CommentAdded, domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved, |
| 64 | domain.RunUpdated, domain.JobUpdated, | 65 | domain.RunUpdated, domain.JobUpdated, | ||
| 65 | domain.IssueOpened, domain.IssueClosed, domain.IssueReopened, domain.IssueCommented, domain.IssueSubmitted, domain.IssuePendingRemoved: | 66 | domain.IssueOpened, domain.IssueClosed, domain.IssueReopened, domain.IssueCommented, domain.IssueSubmitted, domain.IssuePendingRemoved: | ||
| 66 | return true | 67 | return true | ||
| @@ -188,6 +189,11 @@ func (l *liveStream) repoPage(ctx context.Context, we ports.WatchEvent) ([]fragm | |||||
| 188 | return l.mrNotice(ctx, ev.MergeRequest.Number, "this merge request was closed — reload") | 189 | return l.mrNotice(ctx, ev.MergeRequest.Number, "this merge request was closed — reload") | ||
| 189 | case domain.MergeRequestReady: | 190 | case domain.MergeRequestReady: | ||
| 190 | return l.mrNotice(ctx, ev.MergeRequest.Number, "this merge request is ready for review — reload") | 191 | return l.mrNotice(ctx, ev.MergeRequest.Number, "this merge request is ready for review — reload") | ||
| 192 | + | case domain.MergeRequestMergeability: | |||
| 193 | + | if l.view != "mr" || strconv.FormatInt(ev.Number, 10) != l.n { | |||
| 194 | + | return nil, nil | |||
| 195 | + | } | |||
| 196 | + | return l.mrMergeability(ctx) | |||
| 191 | case domain.CommentEdited: | 197 | case domain.CommentEdited: | ||
| 192 | return l.mrNotice(ctx, ev.Number, "a note was edited — reload") | 198 | return l.mrNotice(ctx, ev.Number, "a note was edited — reload") | ||
| 193 | case domain.CommentDeleted: | 199 | case domain.CommentDeleted: | ||
| @@ -454,21 +460,65 @@ func (l *liveStream) issueNotice(ctx context.Context, number int64, text string) | |||||
| 454 | return nil, nil | 460 | return nil, nil | ||
| 455 | } | 461 | } | ||
| 456 | 462 | ||||
| 457 | - | // mrPushed announces a push to the source branch of the merge request shown. | 463 | + | // mrPushed announces a push to the source branch of the merge request shown, |
| 464 | + | // and has the badge follow a push to its source or its target. | |||
| 458 | func (l *liveStream) mrPushed(ctx context.Context, p domain.PushEvent) ([]fragment, error) { | 465 | func (l *liveStream) mrPushed(ctx context.Context, p domain.PushEvent) ([]fragment, error) { | ||
| 459 | - | if l.ref == "" { | 466 | + | n, err := strconv.ParseInt(l.n, 10, 64) |
| 467 | + | if err != nil { | |||
| 460 | return nil, nil | 468 | return nil, nil | ||
| 461 | } | 469 | } | ||
| 470 | + | var out []fragment | |||
| 471 | + | moved := false | |||
| 462 | for _, u := range p.Updates { | 472 | for _, u := range p.Updates { | ||
| 463 | - | if u.Ref == "refs/heads/"+l.ref { | 473 | + | switch { |
| 464 | - | n, err := strconv.ParseInt(l.n, 10, 64) | 474 | + | case l.ref != "" && u.Ref == "refs/heads/"+l.ref: |
| 475 | + | notice, err := l.notice(ctx, "the branch changed — reload", mrHref(l.repo, n)) | |||
| 465 | if err != nil { | 476 | if err != nil { | ||
| 466 | - | return nil, nil | 477 | + | return nil, err |
| 467 | } | 478 | } | ||
| 468 | - | return l.notice(ctx, "the branch changed — reload", mrHref(l.repo, n)) | 479 | + | out = append(out, notice...) |
| 480 | + | moved = true | |||
| 481 | + | case l.target != "" && u.Ref == "refs/heads/"+l.target: | |||
| 482 | + | moved = true | |||
| 483 | + | } | |||
| 484 | + | if moved && len(out) > 0 { | |||
| 485 | + | break | |||
| 469 | } | 486 | } | ||
| 470 | } | 487 | } | ||
| 471 | - | return nil, nil | 488 | + | if !moved { |
| 489 | + | return nil, nil | |||
| 490 | + | } | |||
| 491 | + | badge, err := l.mrMergeability(ctx) | |||
| 492 | + | return append(out, badge...), err | |||
| 493 | + | } | |||
| 494 | + | ||||
| 495 | + | // mrMergeability is the badge of the merge box, read again: the result for the | |||
| 496 | + | // tips as they are now, "checking" until the server has one (it then sends the | |||
| 497 | + | // MergeRequestMergeability that turns the badge). Nothing for a request that | |||
| 498 | + | // is not open. | |||
| 499 | + | func (l *liveStream) mrMergeability(ctx context.Context) ([]fragment, error) { | |||
| 500 | + | if !SignedIn(ctx) || l.s.cfg.MergeRequests == nil { | |||
| 501 | + | return nil, nil // the box is the owner's | |||
| 502 | + | } | |||
| 503 | + | name, err := domain.ParseRepoName(l.repo) | |||
| 504 | + | if err != nil { | |||
| 505 | + | return nil, nil | |||
| 506 | + | } | |||
| 507 | + | n, err := strconv.ParseInt(l.n, 10, 64) | |||
| 508 | + | if err != nil { | |||
| 509 | + | return nil, nil | |||
| 510 | + | } | |||
| 511 | + | m, err := l.s.cfg.MergeRequests.Mergeability(ctx, principal(ctx), name, n) | |||
| 512 | + | switch { | |||
| 513 | + | case errors.Is(err, domain.ErrNotFound): | |||
| 514 | + | return gap, nil | |||
| 515 | + | case err != nil: | |||
| 516 | + | return nil, err | |||
| 517 | + | case m.State == "": | |||
| 518 | + | return nil, nil | |||
| 519 | + | } | |||
| 520 | + | f, err := renderFragment(ctx, "mr-mergeability", MRMergeabilityFragment(mergeabilityView(m, l.target))) | |||
| 521 | + | return []fragment{f}, err | |||
| 472 | } | 522 | } | ||
| 473 | 523 | ||||
| 474 | func (l *liveStream) notice(ctx context.Context, text, href string) ([]fragment, error) { | 524 | func (l *liveStream) notice(ctx context.Context, text, href string) ([]fragment, error) { | ||
M internal/adapters/web/fragments.templ +1 -1
| @@ -102,7 +102,7 @@ templ Live(l Layout) { | |||
| 102 | 102 | htmx:sse:close, htmx:sse:error -> ermiteLive.lost(); | |
| 103 | 103 | htmx:sse:error -> ermiteLive.error(event.detail.status); | |
| 104 | 104 | htmx:sse:before:connection -> ermiteLive.gate(event); | |
| 105 | - | home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data); | |
| 105 | + | home-list, repo-last-push, refs-list, commits-head, mr-notice, mr-mergeability, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data); | |
| 106 | 106 | gap -> ermiteLive.reload()" | |
| 107 | 107 | ></span> | |
| 108 | 108 | } | |
| @@ -102,7 +102,7 @@ templ Live(l Layout) { | |||||
| 102 | htmx:sse:close, htmx:sse:error -> ermiteLive.lost(); | 102 | htmx:sse:close, htmx:sse:error -> ermiteLive.lost(); | ||
| 103 | htmx:sse:error -> ermiteLive.error(event.detail.status); | 103 | htmx:sse:error -> ermiteLive.error(event.detail.status); | ||
| 104 | htmx:sse:before:connection -> ermiteLive.gate(event); | 104 | htmx:sse:before:connection -> ermiteLive.gate(event); | ||
| 105 | - | home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data); | 105 | + | home-list, repo-last-push, refs-list, commits-head, mr-notice, mr-mergeability, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data); |
| 106 | gap -> ermiteLive.reload()" | 106 | gap -> ermiteLive.reload()" | ||
| 107 | ></span> | 107 | ></span> | ||
| 108 | } | 108 | } | ||
M internal/adapters/web/fragments_templ.go +1 -1
| @@ -517,7 +517,7 @@ func Live(l Layout) templ.Component { | |||
| 517 | 517 | if templ_7745c5c3_Err != nil { | |
| 518 | 518 | return templ_7745c5c3_Err | |
| 519 | 519 | } | |
| 520 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "\" hx-swap=\"none\" :text=\"data.state === 'offline' ? 'live updates paused' : ''\" hx-on=\"htmx:sse:after:connection -> ermiteLive.back();\n htmx:sse:close, htmx:sse:error -> ermiteLive.lost();\n htmx:sse:error -> ermiteLive.error(event.detail.status);\n htmx:sse:before:connection -> ermiteLive.gate(event);\n home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data);\n gap -> ermiteLive.reload()\"></span>") | |
| 520 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "\" hx-swap=\"none\" :text=\"data.state === 'offline' ? 'live updates paused' : ''\" hx-on=\"htmx:sse:after:connection -> ermiteLive.back();\n htmx:sse:close, htmx:sse:error -> ermiteLive.lost();\n htmx:sse:error -> ermiteLive.error(event.detail.status);\n htmx:sse:before:connection -> ermiteLive.gate(event);\n home-list, repo-last-push, refs-list, commits-head, mr-notice, mr-mergeability, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data);\n gap -> ermiteLive.reload()\"></span>") | |
| 521 | 521 | if templ_7745c5c3_Err != nil { | |
| 522 | 522 | return templ_7745c5c3_Err | |
| 523 | 523 | } | |
| @@ -517,7 +517,7 @@ func Live(l Layout) templ.Component { | |||||
| 517 | if templ_7745c5c3_Err != nil { | 517 | if templ_7745c5c3_Err != nil { | ||
| 518 | return templ_7745c5c3_Err | 518 | return templ_7745c5c3_Err | ||
| 519 | } | 519 | } | ||
| 520 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "\" hx-swap=\"none\" :text=\"data.state === 'offline' ? 'live updates paused' : ''\" hx-on=\"htmx:sse:after:connection -> ermiteLive.back();\n htmx:sse:close, htmx:sse:error -> ermiteLive.lost();\n htmx:sse:error -> ermiteLive.error(event.detail.status);\n htmx:sse:before:connection -> ermiteLive.gate(event);\n home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data);\n gap -> ermiteLive.reload()\"></span>") | 520 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "\" hx-swap=\"none\" :text=\"data.state === 'offline' ? 'live updates paused' : ''\" hx-on=\"htmx:sse:after:connection -> ermiteLive.back();\n htmx:sse:close, htmx:sse:error -> ermiteLive.lost();\n htmx:sse:error -> ermiteLive.error(event.detail.status);\n htmx:sse:before:connection -> ermiteLive.gate(event);\n home-list, repo-last-push, refs-list, commits-head, mr-notice, mr-mergeability, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data);\n gap -> ermiteLive.reload()\"></span>") |
| 521 | if templ_7745c5c3_Err != nil { | 521 | if templ_7745c5c3_Err != nil { | ||
| 522 | return templ_7745c5c3_Err | 522 | return templ_7745c5c3_Err | ||
| 523 | } | 523 | } | ||
M internal/adapters/web/fragments_test.go +1 -0
| @@ -33,6 +33,7 @@ func TestLiveSlotsAreStableStatusRegions(t *testing.T) { | |||
| 33 | 33 | }{ | |
| 34 | 34 | {"empty commits slot", LiveSlot("commits-head"), "commits-head", ""}, | |
| 35 | 35 | {"empty mr slot", LiveSlot("mr-notice"), "mr-notice", ""}, | |
| 36 | + | {"mergeability slot, as sent by the stream", MRMergeabilityFragment(MergeabilityView{State: "up_to_date", Label: "Ready to merge", FFOk: true, MergeOk: true}), "mr-mergeability", "Ready to merge"}, | |
| 36 | 37 | {"commits banner", CommitsBanner("2 new commits — reload", "/x/commits/main"), "commits-head", "2 new commits — reload"}, | |
| 37 | 38 | {"mr notice", MRNotice("a comment was added — reload", "/x/merge-requests/1"), "mr-notice", "a comment was added — reload"}, | |
| 38 | 39 | } { | |
| @@ -33,6 +33,7 @@ func TestLiveSlotsAreStableStatusRegions(t *testing.T) { | |||||
| 33 | }{ | 33 | }{ | ||
| 34 | {"empty commits slot", LiveSlot("commits-head"), "commits-head", ""}, | 34 | {"empty commits slot", LiveSlot("commits-head"), "commits-head", ""}, | ||
| 35 | {"empty mr slot", LiveSlot("mr-notice"), "mr-notice", ""}, | 35 | {"empty mr slot", LiveSlot("mr-notice"), "mr-notice", ""}, | ||
| 36 | + | {"mergeability slot, as sent by the stream", MRMergeabilityFragment(MergeabilityView{State: "up_to_date", Label: "Ready to merge", FFOk: true, MergeOk: true}), "mr-mergeability", "Ready to merge"}, | |||
| 36 | {"commits banner", CommitsBanner("2 new commits — reload", "/x/commits/main"), "commits-head", "2 new commits — reload"}, | 37 | {"commits banner", CommitsBanner("2 new commits — reload", "/x/commits/main"), "commits-head", "2 new commits — reload"}, | ||
| 37 | {"mr notice", MRNotice("a comment was added — reload", "/x/merge-requests/1"), "mr-notice", "a comment was added — reload"}, | 38 | {"mr notice", MRNotice("a comment was added — reload", "/x/merge-requests/1"), "mr-notice", "a comment was added — reload"}, | ||
| 38 | } { | 39 | } { | ||
M internal/adapters/web/mr.go +149 -18
| @@ -196,10 +196,38 @@ type MergeBox struct { | |||
| 196 | 196 | // CanDeleteBranch offers to delete the source after the merge: it is not | |
| 197 | 197 | // the default branch. The server checks again, with the open requests. | |
| 198 | 198 | CanDeleteBranch bool | |
| 199 | + | // Mergeability is the live badge of the box and what the modes depend on. | |
| 200 | + | Mergeability MergeabilityView | |
| 201 | + | // Signed is true when a mode that creates a commit (squash, merge commit) | |
| 202 | + | // is offered; Fingerprint is then the one of the key that signs it, empty | |
| 203 | + | // when the server does not tell. | |
| 204 | + | Signed bool | |
| 205 | + | Fingerprint string | |
| 206 | + | // Error is why the last merge was refused, shown in the box. | |
| 207 | + | Error string | |
| 199 | 208 | } | |
| 200 | 209 | ||
| 201 | - | // ModeOption is a merge mode the repository allows. | |
| 202 | - | type ModeOption struct{ Value, Label string } | |
| 210 | + | // ModeOption is a merge mode the repository allows. Disabled says that the | |
| 211 | + | // mode is not possible for the current state of the request, Reason why. | |
| 212 | + | type ModeOption struct { | |
| 213 | + | Value, Label string | |
| 214 | + | Disabled bool | |
| 215 | + | Reason string | |
| 216 | + | } | |
| 217 | + | ||
| 218 | + | // MergeabilityView is the mergeability of an open request, ready to show: the | |
| 219 | + | // badge, and what the merge modes depend on (the flags are what the controls | |
| 220 | + | // of the box read, live). | |
| 221 | + | type MergeabilityView struct { | |
| 222 | + | State, Label, Detail string | |
| 223 | + | Paths []string | |
| 224 | + | // PathsNote says that more paths conflict than are listed. | |
| 225 | + | PathsNote string | |
| 226 | + | // FFOk and MergeOk say whether fast-forward, squash and merge commit are | |
| 227 | + | // possible; the reasons say why not. | |
| 228 | + | FFOk, MergeOk bool | |
| 229 | + | FFReason, MergeReason string | |
| 230 | + | } | |
| 203 | 231 | ||
| 204 | 232 | // SinceNav switches between the whole diff and the diff since the last read. | |
| 205 | 233 | type SinceNav struct { | |
| @@ -233,6 +261,12 @@ type MRPage struct { | |||
| 233 | 261 | MergedShort string | |
| 234 | 262 | MergedHref string | |
| 235 | 263 | MergeMode string | |
| 264 | + | // Deleting is the source branch that a merge just asked to delete: the | |
| 265 | + | // deletion follows the answer. | |
| 266 | + | Deleting string | |
| 267 | + | // MergeError is why a merge was refused when the page has no merge box | |
| 268 | + | // to show it in (the request is not open any more). | |
| 269 | + | MergeError string | |
| 236 | 270 | ||
| 237 | 271 | Since *SinceNav | |
| 238 | 272 | SinceNote string | |
| @@ -346,10 +380,59 @@ func modeName(m domain.MergeMode) string { | |||
| 346 | 380 | return "fast-forward" | |
| 347 | 381 | case domain.MergeModeSquash: | |
| 348 | 382 | return "squash" | |
| 383 | + | case domain.MergeModeMerge: | |
| 384 | + | return "merge commit" | |
| 349 | 385 | } | |
| 350 | 386 | return "" | |
| 351 | 387 | } | |
| 352 | 388 | ||
| 389 | + | // mergeabilityView converts the mergeability of a request into the badge and | |
| 390 | + | // the flags of the controls of the merge box. A request without result is | |
| 391 | + | // being checked. | |
| 392 | + | func mergeabilityView(m domain.Mergeability, target string) MergeabilityView { | |
| 393 | + | v := MergeabilityView{State: string(domain.MergeabilityChecking), Label: "Checking…"} | |
| 394 | + | v.FFOk, v.FFReason = m.Allows(domain.MergeModeFastForward) | |
| 395 | + | v.MergeOk, v.MergeReason = m.Allows(domain.MergeModeSquash) | |
| 396 | + | target = visibleText(target) | |
| 397 | + | switch m.State { | |
| 398 | + | case domain.MergeabilityUpToDate: | |
| 399 | + | v.State, v.Label = string(m.State), "Ready to merge" | |
| 400 | + | case domain.MergeabilityBehind: | |
| 401 | + | v.State, v.Label, v.Detail = string(m.State), "Behind "+target+" by "+behindText(m), "merges without conflicts" | |
| 402 | + | case domain.MergeabilityConflict: | |
| 403 | + | v.State, v.Label, v.Detail = string(m.State), "Conflicts with "+target, "rebase needed" | |
| 404 | + | v.Paths, v.PathsNote = conflictList(m) | |
| 405 | + | } | |
| 406 | + | return v | |
| 407 | + | } | |
| 408 | + | ||
| 409 | + | // behindText is "2 commits", "1 commit" or "1000+ commits". | |
| 410 | + | func behindText(m domain.Mergeability) string { | |
| 411 | + | if m.Behind == 1 && !m.BehindCapped { | |
| 412 | + | return "1 commit" | |
| 413 | + | } | |
| 414 | + | n := itoa(m.Behind) | |
| 415 | + | if m.BehindCapped { | |
| 416 | + | n += "+" | |
| 417 | + | } | |
| 418 | + | return n + " commits" | |
| 419 | + | } | |
| 420 | + | ||
| 421 | + | // conflictList is the conflicting paths to show, and the note that says what | |
| 422 | + | // the list lacks. | |
| 423 | + | func conflictList(m domain.Mergeability) (paths []string, note string) { | |
| 424 | + | for _, p := range m.ConflictPaths { | |
| 425 | + | paths = append(paths, visibleText(p)) | |
| 426 | + | } | |
| 427 | + | switch { | |
| 428 | + | case len(paths) == 0: | |
| 429 | + | note = "no common history." | |
| 430 | + | case m.ConflictPathsTruncated: | |
| 431 | + | note = "more files conflict than are listed." | |
| 432 | + | } | |
| 433 | + | return paths, note | |
| 434 | + | } | |
| 435 | + | ||
| 353 | 436 | // mrRow converts a merge request of a listing. | |
| 354 | 437 | func (s *Server) mrRow(repo string, m domain.MergeRequest) MRRow { | |
| 355 | 438 | row := MRRow{ | |
| @@ -540,6 +623,9 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ | |||
| 540 | 623 | p.MergedHref = repoHref(d.name(), "commit", m.MergedCommitID.String()) | |
| 541 | 624 | p.MergeMode = modeName(m.MergeMode) | |
| 542 | 625 | } | |
| 626 | + | if signed && m.State == domain.MRStateMerged && r.URL.Query().Get("deleting") == "1" && d.hasBranch(m.Source) { | |
| 627 | + | p.Deleting = visibleText(m.Source) | |
| 628 | + | } | |
| 543 | 629 | for _, mode := range d.repo.Settings.AllowedMergeModes { | |
| 544 | 630 | if name := modeName(mode); name != "" { | |
| 545 | 631 | p.Modes = append(p.Modes, name) | |
| @@ -624,11 +710,15 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ | |||
| 624 | 710 | p.Invisible = invisible | |
| 625 | 711 | p.DiffView = newDiffView(r, lines, withFrom(self+"?view=unified", from), withFrom(self+"?view=split", from)) | |
| 626 | 712 | if signed && open { | |
| 627 | - | p.Box = s.mergeBox(d, n, m.Source, head, lastRead) | |
| 713 | + | p.Box = s.mergeBox(d, n, m.Source, head, lastRead, mergeabilityView(res.Mergeability, m.Target)) | |
| 714 | + | p.Box.Error = mergeErrorOf(ctx) | |
| 628 | 715 | if m.IsDraft() { | |
| 629 | 716 | p.Box.ReadyAction = mrHref(d.name(), n, "ready") | |
| 630 | 717 | } | |
| 631 | 718 | } | |
| 719 | + | if p.Box == nil { | |
| 720 | + | p.MergeError = mergeErrorOf(ctx) // the request is not open any more | |
| 721 | + | } | |
| 632 | 722 | if canWrite { | |
| 633 | 723 | p.GeneralForm = &CommentForm{ | |
| 634 | 724 | ID: "cf-general", Action: mrHref(d.name(), n, "comments"), Title: "comment on the merge request", | |
| @@ -652,7 +742,7 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ | |||
| 652 | 742 | l.Crumbs = append(l.Crumbs, Crumb{Label: "since", Current: true}) | |
| 653 | 743 | } | |
| 654 | 744 | p.Live = true | |
| 655 | - | l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=mr&n="+strconv.FormatInt(n, 10)+"&ref="+url.QueryEscape(m.Source)+"&head="+url.QueryEscape(head)) | |
| 745 | + | l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=mr&n="+strconv.FormatInt(n, 10)+"&ref="+url.QueryEscape(m.Source)+"&head="+url.QueryEscape(head)+"&target="+url.QueryEscape(m.Target)) | |
| 656 | 746 | l.HasRail = true | |
| 657 | 747 | l.MR = true | |
| 658 | 748 | return MergeRequest(l, p), http.StatusOK, nil | |
| @@ -669,37 +759,78 @@ func (d *repoData) hasBranch(name string) bool { | |||
| 669 | 759 | } | |
| 670 | 760 | ||
| 671 | 761 | // mergeBox is what the owner can do with an open request. | |
| 672 | - | func (s *Server) mergeBox(d *repoData, n int64, source, head, lastRead string) *MergeBox { | |
| 762 | + | func (s *Server) mergeBox(d *repoData, n int64, source, head, lastRead string, mg MergeabilityView) *MergeBox { | |
| 673 | 763 | b := &MergeBox{ | |
| 674 | 764 | MergeAction: mrHref(d.name(), n, "merge"), CloseAction: mrHref(d.name(), n, "close"), | |
| 675 | 765 | ReadAction: mrHref(d.name(), n, "read"), Head: head, HeadGone: head == "", | |
| 676 | 766 | CanMarkRead: head != "" && head != lastRead, | |
| 677 | 767 | CanDeleteBranch: source != d.repo.DefaultBranch, | |
| 768 | + | Mergeability: mg, | |
| 769 | + | } | |
| 770 | + | var lines []string | |
| 771 | + | cmd := "" | |
| 772 | + | if head != "" { | |
| 773 | + | cmd = "ermite mr merge " + shellWord(d.name()) + " " + strconv.FormatInt(n, 10) + " --head " + head | |
| 678 | 774 | } | |
| 679 | - | ff, sq := false, false | |
| 680 | 775 | for _, mode := range d.repo.Settings.AllowedMergeModes { | |
| 681 | 776 | switch mode { | |
| 682 | 777 | case domain.MergeModeFastForward: | |
| 683 | - | ff = true | |
| 684 | - | b.Modes = append(b.Modes, ModeOption{Value: "fast-forward", Label: "fast-forward"}) | |
| 778 | + | b.Modes = append(b.Modes, ModeOption{Value: "fast-forward", Label: "fast-forward", Disabled: !mg.FFOk, Reason: reasonIf(!mg.FFOk, mg.FFReason)}) | |
| 779 | + | lines = append(lines, cmd) | |
| 685 | 780 | case domain.MergeModeSquash: | |
| 686 | - | sq = true | |
| 687 | - | b.Modes = append(b.Modes, ModeOption{Value: "squash", Label: "squash (one signed commit)"}) | |
| 781 | + | b.Signed = true | |
| 782 | + | b.Modes = append(b.Modes, ModeOption{Value: "squash", Label: "squash (one signed commit)", Disabled: !mg.MergeOk, Reason: reasonIf(!mg.MergeOk, mg.MergeReason)}) | |
| 783 | + | lines = append(lines, cmd+" --mode squash") | |
| 784 | + | case domain.MergeModeMerge: | |
| 785 | + | b.Signed = true | |
| 786 | + | b.Modes = append(b.Modes, ModeOption{Value: "merge", Label: "merge commit (signed)", Disabled: !mg.MergeOk, Reason: reasonIf(!mg.MergeOk, mg.MergeReason)}) | |
| 787 | + | lines = append(lines, cmd+" --mode merge") | |
| 688 | 788 | } | |
| 689 | 789 | } | |
| 790 | + | if b.Signed && s.cfg.SigningKey != nil { | |
| 791 | + | b.Fingerprint = s.cfg.SigningKey.Info().Key.Fingerprint | |
| 792 | + | } | |
| 690 | 793 | if head != "" { | |
| 691 | - | cmd := "ermite mr merge " + shellWord(d.name()) + " " + strconv.FormatInt(n, 10) + " --head " + head | |
| 692 | - | b.CLI = Clone{ID: "cli-merge"} | |
| 693 | - | if ff { | |
| 694 | - | b.CLI.Lines = append(b.CLI.Lines, cmd) | |
| 695 | - | } | |
| 696 | - | if sq { | |
| 697 | - | b.CLI.Lines = append(b.CLI.Lines, cmd+" --squash") | |
| 698 | - | } | |
| 794 | + | b.CLI = Clone{ID: "cli-merge", Lines: lines} | |
| 699 | 795 | } | |
| 700 | 796 | return b | |
| 701 | 797 | } | |
| 702 | 798 | ||
| 799 | + | func reasonIf(cond bool, reason string) string { | |
| 800 | + | if cond { | |
| 801 | + | return reason | |
| 802 | + | } | |
| 803 | + | return "" | |
| 804 | + | } | |
| 805 | + | ||
| 806 | + | // firstModeOK is the index of the first mode that is possible, -1 for none. | |
| 807 | + | func (b *MergeBox) firstModeOK() int { | |
| 808 | + | for i, m := range b.Modes { | |
| 809 | + | if !m.Disabled { | |
| 810 | + | return i | |
| 811 | + | } | |
| 812 | + | } | |
| 813 | + | return -1 | |
| 814 | + | } | |
| 815 | + | ||
| 816 | + | // Checked is the mode the form has checked: the first that is possible, else | |
| 817 | + | // the first (it is disabled, and a live update may enable it). | |
| 818 | + | func (b *MergeBox) Checked() int { return max(b.firstModeOK(), 0) } | |
| 819 | + | ||
| 820 | + | // CanMerge tells whether some mode is possible: the button is enabled. | |
| 821 | + | func (b *MergeBox) CanMerge() bool { return b.firstModeOK() >= 0 } | |
| 822 | + | ||
| 823 | + | // MergeTitle is why the button is disabled, empty when it is not. | |
| 824 | + | func (b *MergeBox) MergeTitle() string { | |
| 825 | + | if b.CanMerge() { | |
| 826 | + | return "" | |
| 827 | + | } | |
| 828 | + | if len(b.Modes) == 0 { | |
| 829 | + | return "the repository allows no merge mode" | |
| 830 | + | } | |
| 831 | + | return b.Modes[0].Reason | |
| 832 | + | } | |
| 833 | + | ||
| 703 | 834 | // shellWord quotes s for a shell unless it is made of safe characters, which | |
| 704 | 835 | // a repository name always is. | |
| 705 | 836 | func shellWord(s string) string { | |
| @@ -196,10 +196,38 @@ type MergeBox struct { | |||||
| 196 | // CanDeleteBranch offers to delete the source after the merge: it is not | 196 | // CanDeleteBranch offers to delete the source after the merge: it is not | ||
| 197 | // the default branch. The server checks again, with the open requests. | 197 | // the default branch. The server checks again, with the open requests. | ||
| 198 | CanDeleteBranch bool | 198 | CanDeleteBranch bool | ||
| 199 | + | // Mergeability is the live badge of the box and what the modes depend on. | |||
| 200 | + | Mergeability MergeabilityView | |||
| 201 | + | // Signed is true when a mode that creates a commit (squash, merge commit) | |||
| 202 | + | // is offered; Fingerprint is then the one of the key that signs it, empty | |||
| 203 | + | // when the server does not tell. | |||
| 204 | + | Signed bool | |||
| 205 | + | Fingerprint string | |||
| 206 | + | // Error is why the last merge was refused, shown in the box. | |||
| 207 | + | Error string | |||
| 199 | } | 208 | } | ||
| 200 | 209 | ||||
| 201 | - | // ModeOption is a merge mode the repository allows. | 210 | + | // ModeOption is a merge mode the repository allows. Disabled says that the |
| 202 | - | type ModeOption struct{ Value, Label string } | 211 | + | // mode is not possible for the current state of the request, Reason why. |
| 212 | + | type ModeOption struct { | |||
| 213 | + | Value, Label string | |||
| 214 | + | Disabled bool | |||
| 215 | + | Reason string | |||
| 216 | + | } | |||
| 217 | + | ||||
| 218 | + | // MergeabilityView is the mergeability of an open request, ready to show: the | |||
| 219 | + | // badge, and what the merge modes depend on (the flags are what the controls | |||
| 220 | + | // of the box read, live). | |||
| 221 | + | type MergeabilityView struct { | |||
| 222 | + | State, Label, Detail string | |||
| 223 | + | Paths []string | |||
| 224 | + | // PathsNote says that more paths conflict than are listed. | |||
| 225 | + | PathsNote string | |||
| 226 | + | // FFOk and MergeOk say whether fast-forward, squash and merge commit are | |||
| 227 | + | // possible; the reasons say why not. | |||
| 228 | + | FFOk, MergeOk bool | |||
| 229 | + | FFReason, MergeReason string | |||
| 230 | + | } | |||
| 203 | 231 | ||||
| 204 | // SinceNav switches between the whole diff and the diff since the last read. | 232 | // SinceNav switches between the whole diff and the diff since the last read. | ||
| 205 | type SinceNav struct { | 233 | type SinceNav struct { | ||
| @@ -233,6 +261,12 @@ type MRPage struct { | |||||
| 233 | MergedShort string | 261 | MergedShort string | ||
| 234 | MergedHref string | 262 | MergedHref string | ||
| 235 | MergeMode string | 263 | MergeMode string | ||
| 264 | + | // Deleting is the source branch that a merge just asked to delete: the | |||
| 265 | + | // deletion follows the answer. | |||
| 266 | + | Deleting string | |||
| 267 | + | // MergeError is why a merge was refused when the page has no merge box | |||
| 268 | + | // to show it in (the request is not open any more). | |||
| 269 | + | MergeError string | |||
| 236 | 270 | ||||
| 237 | Since *SinceNav | 271 | Since *SinceNav | ||
| 238 | SinceNote string | 272 | SinceNote string | ||
| @@ -346,10 +380,59 @@ func modeName(m domain.MergeMode) string { | |||||
| 346 | return "fast-forward" | 380 | return "fast-forward" | ||
| 347 | case domain.MergeModeSquash: | 381 | case domain.MergeModeSquash: | ||
| 348 | return "squash" | 382 | return "squash" | ||
| 383 | + | case domain.MergeModeMerge: | |||
| 384 | + | return "merge commit" | |||
| 349 | } | 385 | } | ||
| 350 | return "" | 386 | return "" | ||
| 351 | } | 387 | } | ||
| 352 | 388 | ||||
| 389 | + | // mergeabilityView converts the mergeability of a request into the badge and | |||
| 390 | + | // the flags of the controls of the merge box. A request without result is | |||
| 391 | + | // being checked. | |||
| 392 | + | func mergeabilityView(m domain.Mergeability, target string) MergeabilityView { | |||
| 393 | + | v := MergeabilityView{State: string(domain.MergeabilityChecking), Label: "Checking…"} | |||
| 394 | + | v.FFOk, v.FFReason = m.Allows(domain.MergeModeFastForward) | |||
| 395 | + | v.MergeOk, v.MergeReason = m.Allows(domain.MergeModeSquash) | |||
| 396 | + | target = visibleText(target) | |||
| 397 | + | switch m.State { | |||
| 398 | + | case domain.MergeabilityUpToDate: | |||
| 399 | + | v.State, v.Label = string(m.State), "Ready to merge" | |||
| 400 | + | case domain.MergeabilityBehind: | |||
| 401 | + | v.State, v.Label, v.Detail = string(m.State), "Behind "+target+" by "+behindText(m), "merges without conflicts" | |||
| 402 | + | case domain.MergeabilityConflict: | |||
| 403 | + | v.State, v.Label, v.Detail = string(m.State), "Conflicts with "+target, "rebase needed" | |||
| 404 | + | v.Paths, v.PathsNote = conflictList(m) | |||
| 405 | + | } | |||
| 406 | + | return v | |||
| 407 | + | } | |||
| 408 | + | ||||
| 409 | + | // behindText is "2 commits", "1 commit" or "1000+ commits". | |||
| 410 | + | func behindText(m domain.Mergeability) string { | |||
| 411 | + | if m.Behind == 1 && !m.BehindCapped { | |||
| 412 | + | return "1 commit" | |||
| 413 | + | } | |||
| 414 | + | n := itoa(m.Behind) | |||
| 415 | + | if m.BehindCapped { | |||
| 416 | + | n += "+" | |||
| 417 | + | } | |||
| 418 | + | return n + " commits" | |||
| 419 | + | } | |||
| 420 | + | ||||
| 421 | + | // conflictList is the conflicting paths to show, and the note that says what | |||
| 422 | + | // the list lacks. | |||
| 423 | + | func conflictList(m domain.Mergeability) (paths []string, note string) { | |||
| 424 | + | for _, p := range m.ConflictPaths { | |||
| 425 | + | paths = append(paths, visibleText(p)) | |||
| 426 | + | } | |||
| 427 | + | switch { | |||
| 428 | + | case len(paths) == 0: | |||
| 429 | + | note = "no common history." | |||
| 430 | + | case m.ConflictPathsTruncated: | |||
| 431 | + | note = "more files conflict than are listed." | |||
| 432 | + | } | |||
| 433 | + | return paths, note | |||
| 434 | + | } | |||
| 435 | + | ||||
| 353 | // mrRow converts a merge request of a listing. | 436 | // mrRow converts a merge request of a listing. | ||
| 354 | func (s *Server) mrRow(repo string, m domain.MergeRequest) MRRow { | 437 | func (s *Server) mrRow(repo string, m domain.MergeRequest) MRRow { | ||
| 355 | row := MRRow{ | 438 | row := MRRow{ | ||
| @@ -540,6 +623,9 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ | |||||
| 540 | p.MergedHref = repoHref(d.name(), "commit", m.MergedCommitID.String()) | 623 | p.MergedHref = repoHref(d.name(), "commit", m.MergedCommitID.String()) | ||
| 541 | p.MergeMode = modeName(m.MergeMode) | 624 | p.MergeMode = modeName(m.MergeMode) | ||
| 542 | } | 625 | } | ||
| 626 | + | if signed && m.State == domain.MRStateMerged && r.URL.Query().Get("deleting") == "1" && d.hasBranch(m.Source) { | |||
| 627 | + | p.Deleting = visibleText(m.Source) | |||
| 628 | + | } | |||
| 543 | for _, mode := range d.repo.Settings.AllowedMergeModes { | 629 | for _, mode := range d.repo.Settings.AllowedMergeModes { | ||
| 544 | if name := modeName(mode); name != "" { | 630 | if name := modeName(mode); name != "" { | ||
| 545 | p.Modes = append(p.Modes, name) | 631 | p.Modes = append(p.Modes, name) | ||
| @@ -624,11 +710,15 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ | |||||
| 624 | p.Invisible = invisible | 710 | p.Invisible = invisible | ||
| 625 | p.DiffView = newDiffView(r, lines, withFrom(self+"?view=unified", from), withFrom(self+"?view=split", from)) | 711 | p.DiffView = newDiffView(r, lines, withFrom(self+"?view=unified", from), withFrom(self+"?view=split", from)) | ||
| 626 | if signed && open { | 712 | if signed && open { | ||
| 627 | - | p.Box = s.mergeBox(d, n, m.Source, head, lastRead) | 713 | + | p.Box = s.mergeBox(d, n, m.Source, head, lastRead, mergeabilityView(res.Mergeability, m.Target)) |
| 714 | + | p.Box.Error = mergeErrorOf(ctx) | |||
| 628 | if m.IsDraft() { | 715 | if m.IsDraft() { | ||
| 629 | p.Box.ReadyAction = mrHref(d.name(), n, "ready") | 716 | p.Box.ReadyAction = mrHref(d.name(), n, "ready") | ||
| 630 | } | 717 | } | ||
| 631 | } | 718 | } | ||
| 719 | + | if p.Box == nil { | |||
| 720 | + | p.MergeError = mergeErrorOf(ctx) // the request is not open any more | |||
| 721 | + | } | |||
| 632 | if canWrite { | 722 | if canWrite { | ||
| 633 | p.GeneralForm = &CommentForm{ | 723 | p.GeneralForm = &CommentForm{ | ||
| 634 | ID: "cf-general", Action: mrHref(d.name(), n, "comments"), Title: "comment on the merge request", | 724 | ID: "cf-general", Action: mrHref(d.name(), n, "comments"), Title: "comment on the merge request", | ||
| @@ -652,7 +742,7 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ | |||||
| 652 | l.Crumbs = append(l.Crumbs, Crumb{Label: "since", Current: true}) | 742 | l.Crumbs = append(l.Crumbs, Crumb{Label: "since", Current: true}) | ||
| 653 | } | 743 | } | ||
| 654 | p.Live = true | 744 | p.Live = true | ||
| 655 | - | l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=mr&n="+strconv.FormatInt(n, 10)+"&ref="+url.QueryEscape(m.Source)+"&head="+url.QueryEscape(head)) | 745 | + | l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=mr&n="+strconv.FormatInt(n, 10)+"&ref="+url.QueryEscape(m.Source)+"&head="+url.QueryEscape(head)+"&target="+url.QueryEscape(m.Target)) |
| 656 | l.HasRail = true | 746 | l.HasRail = true | ||
| 657 | l.MR = true | 747 | l.MR = true | ||
| 658 | return MergeRequest(l, p), http.StatusOK, nil | 748 | return MergeRequest(l, p), http.StatusOK, nil | ||
| @@ -669,37 +759,78 @@ func (d *repoData) hasBranch(name string) bool { | |||||
| 669 | } | 759 | } | ||
| 670 | 760 | ||||
| 671 | // mergeBox is what the owner can do with an open request. | 761 | // mergeBox is what the owner can do with an open request. | ||
| 672 | - | func (s *Server) mergeBox(d *repoData, n int64, source, head, lastRead string) *MergeBox { | 762 | + | func (s *Server) mergeBox(d *repoData, n int64, source, head, lastRead string, mg MergeabilityView) *MergeBox { |
| 673 | b := &MergeBox{ | 763 | b := &MergeBox{ | ||
| 674 | MergeAction: mrHref(d.name(), n, "merge"), CloseAction: mrHref(d.name(), n, "close"), | 764 | MergeAction: mrHref(d.name(), n, "merge"), CloseAction: mrHref(d.name(), n, "close"), | ||
| 675 | ReadAction: mrHref(d.name(), n, "read"), Head: head, HeadGone: head == "", | 765 | ReadAction: mrHref(d.name(), n, "read"), Head: head, HeadGone: head == "", | ||
| 676 | CanMarkRead: head != "" && head != lastRead, | 766 | CanMarkRead: head != "" && head != lastRead, | ||
| 677 | CanDeleteBranch: source != d.repo.DefaultBranch, | 767 | CanDeleteBranch: source != d.repo.DefaultBranch, | ||
| 768 | + | Mergeability: mg, | |||
| 769 | + | } | |||
| 770 | + | var lines []string | |||
| 771 | + | cmd := "" | |||
| 772 | + | if head != "" { | |||
| 773 | + | cmd = "ermite mr merge " + shellWord(d.name()) + " " + strconv.FormatInt(n, 10) + " --head " + head | |||
| 678 | } | 774 | } | ||
| 679 | - | ff, sq := false, false | |||
| 680 | for _, mode := range d.repo.Settings.AllowedMergeModes { | 775 | for _, mode := range d.repo.Settings.AllowedMergeModes { | ||
| 681 | switch mode { | 776 | switch mode { | ||
| 682 | case domain.MergeModeFastForward: | 777 | case domain.MergeModeFastForward: | ||
| 683 | - | ff = true | 778 | + | b.Modes = append(b.Modes, ModeOption{Value: "fast-forward", Label: "fast-forward", Disabled: !mg.FFOk, Reason: reasonIf(!mg.FFOk, mg.FFReason)}) |
| 684 | - | b.Modes = append(b.Modes, ModeOption{Value: "fast-forward", Label: "fast-forward"}) | 779 | + | lines = append(lines, cmd) |
| 685 | case domain.MergeModeSquash: | 780 | case domain.MergeModeSquash: | ||
| 686 | - | sq = true | 781 | + | b.Signed = true |
| 687 | - | b.Modes = append(b.Modes, ModeOption{Value: "squash", Label: "squash (one signed commit)"}) | 782 | + | b.Modes = append(b.Modes, ModeOption{Value: "squash", Label: "squash (one signed commit)", Disabled: !mg.MergeOk, Reason: reasonIf(!mg.MergeOk, mg.MergeReason)}) |
| 783 | + | lines = append(lines, cmd+" --mode squash") | |||
| 784 | + | case domain.MergeModeMerge: | |||
| 785 | + | b.Signed = true | |||
| 786 | + | b.Modes = append(b.Modes, ModeOption{Value: "merge", Label: "merge commit (signed)", Disabled: !mg.MergeOk, Reason: reasonIf(!mg.MergeOk, mg.MergeReason)}) | |||
| 787 | + | lines = append(lines, cmd+" --mode merge") | |||
| 688 | } | 788 | } | ||
| 689 | } | 789 | } | ||
| 790 | + | if b.Signed && s.cfg.SigningKey != nil { | |||
| 791 | + | b.Fingerprint = s.cfg.SigningKey.Info().Key.Fingerprint | |||
| 792 | + | } | |||
| 690 | if head != "" { | 793 | if head != "" { | ||
| 691 | - | cmd := "ermite mr merge " + shellWord(d.name()) + " " + strconv.FormatInt(n, 10) + " --head " + head | 794 | + | b.CLI = Clone{ID: "cli-merge", Lines: lines} |
| 692 | - | b.CLI = Clone{ID: "cli-merge"} | |||
| 693 | - | if ff { | |||
| 694 | - | b.CLI.Lines = append(b.CLI.Lines, cmd) | |||
| 695 | - | } | |||
| 696 | - | if sq { | |||
| 697 | - | b.CLI.Lines = append(b.CLI.Lines, cmd+" --squash") | |||
| 698 | - | } | |||
| 699 | } | 795 | } | ||
| 700 | return b | 796 | return b | ||
| 701 | } | 797 | } | ||
| 702 | 798 | ||||
| 799 | + | func reasonIf(cond bool, reason string) string { | |||
| 800 | + | if cond { | |||
| 801 | + | return reason | |||
| 802 | + | } | |||
| 803 | + | return "" | |||
| 804 | + | } | |||
| 805 | + | ||||
| 806 | + | // firstModeOK is the index of the first mode that is possible, -1 for none. | |||
| 807 | + | func (b *MergeBox) firstModeOK() int { | |||
| 808 | + | for i, m := range b.Modes { | |||
| 809 | + | if !m.Disabled { | |||
| 810 | + | return i | |||
| 811 | + | } | |||
| 812 | + | } | |||
| 813 | + | return -1 | |||
| 814 | + | } | |||
| 815 | + | ||||
| 816 | + | // Checked is the mode the form has checked: the first that is possible, else | |||
| 817 | + | // the first (it is disabled, and a live update may enable it). | |||
| 818 | + | func (b *MergeBox) Checked() int { return max(b.firstModeOK(), 0) } | |||
| 819 | + | ||||
| 820 | + | // CanMerge tells whether some mode is possible: the button is enabled. | |||
| 821 | + | func (b *MergeBox) CanMerge() bool { return b.firstModeOK() >= 0 } | |||
| 822 | + | ||||
| 823 | + | // MergeTitle is why the button is disabled, empty when it is not. | |||
| 824 | + | func (b *MergeBox) MergeTitle() string { | |||
| 825 | + | if b.CanMerge() { | |||
| 826 | + | return "" | |||
| 827 | + | } | |||
| 828 | + | if len(b.Modes) == 0 { | |||
| 829 | + | return "the repository allows no merge mode" | |||
| 830 | + | } | |||
| 831 | + | return b.Modes[0].Reason | |||
| 832 | + | } | |||
| 833 | + | ||||
| 703 | // shellWord quotes s for a shell unless it is made of safe characters, which | 834 | // shellWord quotes s for a shell unless it is made of safe characters, which | ||
| 704 | // a repository name always is. | 835 | // a repository name always is. | ||
| 705 | func shellWord(s string) string { | 836 | func shellWord(s string) string { | ||
M internal/adapters/web/mr.templ +89 -4
| @@ -2,7 +2,10 @@ | |||
| 2 | 2 | ||
| 3 | 3 | package web | |
| 4 | 4 | ||
| 5 | - | import "strings" | |
| 5 | + | import ( | |
| 6 | + | "strconv" | |
| 7 | + | "strings" | |
| 8 | + | ) | |
| 6 | 9 | ||
| 7 | 10 | // MRList is the list of the merge requests of a repository: the open ones, | |
| 8 | 11 | // then the merged and closed ones. | |
| @@ -145,6 +148,12 @@ templ MergeRequest(l Layout, p MRPage) { | |||
| 145 | 148 | </article> | |
| 146 | 149 | } | |
| 147 | 150 | </section> | |
| 151 | + | if p.Deleting != "" { | |
| 152 | + | <p class="note" role="status">merged. the branch { p.Deleting } is being deleted.</p> | |
| 153 | + | } | |
| 154 | + | if p.MergeError != "" { | |
| 155 | + | <p class="d-err" role="alert">{ p.MergeError }</p> | |
| 156 | + | } | |
| 148 | 157 | @LiveSlot("mr-notice") | |
| 149 | 158 | if p.Since != nil { | |
| 150 | 159 | <nav class="since" aria-label="Diff range"> | |
| @@ -446,8 +455,13 @@ templ mrBar(p MRPage) { | |||
| 446 | 455 | // mergeMenu is the merge of an open request, for the owner: the form (it | |
| 447 | 456 | // carries the head that was read), or "Ready for review" for a draft. The | |
| 448 | 457 | // mode and the branch deletion have keys (1-3, D) while it is open. | |
| 458 | + | // | |
| 459 | + | // The badge is a live slot (mr-mergeability); the modes and the button read | |
| 460 | + | // its data-* attributes (hx-live bindings), so that they follow the slot when | |
| 461 | + | // the stream replaces its content. The server renders the same state in the | |
| 462 | + | // attributes, for a page without scripts. | |
| 449 | 463 | templ mergeMenu(p MRPage) { | |
| 450 | - | <details class="menu mmenu" id="merge-menu" data-state="open"> | |
| 464 | + | <details class="menu mmenu" id="merge-menu" data-state="open" open?={ p.Box.Error != "" }> | |
| 451 | 465 | <summary class="btn primary" aria-keyshortcuts="Shift+M"> | |
| 452 | 466 | if p.Box.ReadyAction != "" { | |
| 453 | 467 | Draft | |
| @@ -469,19 +483,32 @@ templ mergeMenu(p MRPage) { | |||
| 469 | 483 | <dt>into</dt> | |
| 470 | 484 | <dd>{ p.Target }</dd> | |
| 471 | 485 | </dl> | |
| 486 | + | if p.Box.Error != "" { | |
| 487 | + | <p class="d-err" role="alert">{ p.Box.Error }</p> | |
| 488 | + | } | |
| 472 | 489 | if p.Box.ReadyAction != "" { | |
| 473 | 490 | <p class="d-draft">this is a draft: it cannot be merged until it is ready for review.</p> | |
| 474 | 491 | <form class="d-btns" method="post" action={ templ.SafeURL(p.Box.ReadyAction) }> | |
| 475 | 492 | <button type="submit" class="btn primary" data-default title="remove the draft prefix from the title">Ready for review</button> | |
| 476 | 493 | </form> | |
| 477 | 494 | } else if !p.Box.HeadGone { | |
| 495 | + | @mrMergeabilitySlot(p.Box.Mergeability) | |
| 478 | 496 | <form class="d-acts" method="post" action={ templ.SafeURL(p.Box.MergeAction) }> | |
| 479 | 497 | <input type="hidden" name="head_id" value={ p.Box.Head }/> | |
| 480 | 498 | <fieldset class="d-modes"> | |
| 481 | 499 | <legend class="sr">merge mode</legend> | |
| 482 | 500 | for i, m := range p.Box.Modes { | |
| 483 | 501 | <label> | |
| 484 | - | <input type="radio" name="mode" value={ m.Value } checked?={ i == 0 }/> | |
| 502 | + | <input | |
| 503 | + | type="radio" | |
| 504 | + | name="mode" | |
| 505 | + | value={ m.Value } | |
| 506 | + | checked?={ i == p.Box.Checked() } | |
| 507 | + | disabled?={ m.Disabled } | |
| 508 | + | title={ m.Reason } | |
| 509 | + | :disabled="this.value === 'fast-forward' ? q('[data-mergeability]').data.ffOk !== true : q('[data-mergeability]').data.mergeOk !== true" | |
| 510 | + | :title="this.value === 'fast-forward' ? q('[data-mergeability]').data.ffReason : q('[data-mergeability]').data.mergeReason" | |
| 511 | + | /> | |
| 485 | 512 | { m.Label } | |
| 486 | 513 | if i < 9 { | |
| 487 | 514 | <kbd aria-hidden="true">{ itoa(i + 1) }</kbd> | |
| @@ -489,11 +516,28 @@ templ mergeMenu(p MRPage) { | |||
| 489 | 516 | </label> | |
| 490 | 517 | } | |
| 491 | 518 | </fieldset> | |
| 519 | + | if p.Box.Signed { | |
| 520 | + | <p class="d-note" id="mr-signing"> | |
| 521 | + | squash and merge commits are signed with ermite's own key, generated automatically on first start. | |
| 522 | + | if p.Box.Fingerprint != "" { | |
| 523 | + | { " " }key <code>{ p.Box.Fingerprint }</code>. | |
| 524 | + | } | |
| 525 | + | { " " }<code>ermite signing-key show</code> prints the public key to verify them. | |
| 526 | + | </p> | |
| 527 | + | } | |
| 492 | 528 | if p.Box.CanDeleteBranch { | |
| 493 | 529 | <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> | |
| 494 | 530 | } | |
| 495 | 531 | <div class="d-btns"> | |
| 496 | - | <button type="submit" class="btn primary" data-default>Merge into { p.Target }</button> | |
| 532 | + | <button | |
| 533 | + | type="submit" | |
| 534 | + | class="btn primary" | |
| 535 | + | data-default | |
| 536 | + | disabled?={ !p.Box.CanMerge() } | |
| 537 | + | title={ p.Box.MergeTitle() } | |
| 538 | + | :disabled="!this.form.querySelector('input[name=mode]:checked:not(:disabled)')" | |
| 539 | + | :title="(this.form.querySelector('input[name=mode]:checked') || {}).title || ''" | |
| 540 | + | >Merge into { p.Target }</button> | |
| 497 | 541 | </div> | |
| 498 | 542 | </form> | |
| 499 | 543 | } | |
| @@ -501,6 +545,47 @@ templ mergeMenu(p MRPage) { | |||
| 501 | 545 | </details> | |
| 502 | 546 | } | |
| 503 | 547 | ||
| 548 | + | // MRMergeability is the content of the mergeability slot: the badge, and the | |
| 549 | + | // state the controls of the merge box read. | |
| 550 | + | templ MRMergeability(v MergeabilityView) { | |
| 551 | + | <span | |
| 552 | + | data-mergeability | |
| 553 | + | data-state={ v.State } | |
| 554 | + | data-ff-ok={ strconv.FormatBool(v.FFOk) } | |
| 555 | + | data-merge-ok={ strconv.FormatBool(v.MergeOk) } | |
| 556 | + | data-ff-reason={ v.FFReason } | |
| 557 | + | data-merge-reason={ v.MergeReason } | |
| 558 | + | > | |
| 559 | + | <span class="badge" data-state={ v.State }>{ v.Label }</span> | |
| 560 | + | if v.Detail != "" { | |
| 561 | + | { " " }<span class="dim">{ v.Detail }</span> | |
| 562 | + | } | |
| 563 | + | </span> | |
| 564 | + | if len(v.Paths) > 0 { | |
| 565 | + | <ul class="mg-paths" aria-label="conflicting files"> | |
| 566 | + | for _, path := range v.Paths { | |
| 567 | + | <li><code>{ path }</code></li> | |
| 568 | + | } | |
| 569 | + | </ul> | |
| 570 | + | } | |
| 571 | + | if v.PathsNote != "" { | |
| 572 | + | <p class="dim">{ v.PathsNote }</p> | |
| 573 | + | } | |
| 574 | + | } | |
| 575 | + | ||
| 576 | + | // mrMergeabilitySlot is the status region of the badge, with its content: the | |
| 577 | + | // stream replaces the children, never the element (see LiveSlot). | |
| 578 | + | templ mrMergeabilitySlot(v MergeabilityView) { | |
| 579 | + | <div id="mr-mergeability" class="live-slot" role="status" data-slot> | |
| 580 | + | @MRMergeability(v) | |
| 581 | + | </div> | |
| 582 | + | } | |
| 583 | + | ||
| 584 | + | // MRMergeabilityFragment is the slot as the stream sends it. | |
| 585 | + | templ MRMergeabilityFragment(v MergeabilityView) { | |
| 586 | + | @mrMergeabilitySlot(v) | |
| 587 | + | } | |
| 588 | + | ||
| 504 | 589 | // moreMenu is what else the owner does with an open request: mark the head | |
| 505 | 590 | // read, close it, merge it from a terminal. | |
| 506 | 591 | templ moreMenu(p MRPage) { | |
| @@ -2,7 +2,10 @@ | |||||
| 2 | 2 | ||||
| 3 | package web | 3 | package web | ||
| 4 | 4 | ||||
| 5 | - | import "strings" | 5 | + | import ( |
| 6 | + | "strconv" | |||
| 7 | + | "strings" | |||
| 8 | + | ) | |||
| 6 | 9 | ||||
| 7 | // MRList is the list of the merge requests of a repository: the open ones, | 10 | // MRList is the list of the merge requests of a repository: the open ones, | ||
| 8 | // then the merged and closed ones. | 11 | // then the merged and closed ones. | ||
| @@ -145,6 +148,12 @@ templ MergeRequest(l Layout, p MRPage) { | |||||
| 145 | </article> | 148 | </article> | ||
| 146 | } | 149 | } | ||
| 147 | </section> | 150 | </section> | ||
| 151 | + | if p.Deleting != "" { | |||
| 152 | + | <p class="note" role="status">merged. the branch { p.Deleting } is being deleted.</p> | |||
| 153 | + | } | |||
| 154 | + | if p.MergeError != "" { | |||
| 155 | + | <p class="d-err" role="alert">{ p.MergeError }</p> | |||
| 156 | + | } | |||
| 148 | @LiveSlot("mr-notice") | 157 | @LiveSlot("mr-notice") | ||
| 149 | if p.Since != nil { | 158 | if p.Since != nil { | ||
| 150 | <nav class="since" aria-label="Diff range"> | 159 | <nav class="since" aria-label="Diff range"> | ||
| @@ -446,8 +455,13 @@ templ mrBar(p MRPage) { | |||||
| 446 | // mergeMenu is the merge of an open request, for the owner: the form (it | 455 | // mergeMenu is the merge of an open request, for the owner: the form (it | ||
| 447 | // carries the head that was read), or "Ready for review" for a draft. The | 456 | // carries the head that was read), or "Ready for review" for a draft. The | ||
| 448 | // mode and the branch deletion have keys (1-3, D) while it is open. | 457 | // mode and the branch deletion have keys (1-3, D) while it is open. | ||
| 458 | + | // | |||
| 459 | + | // The badge is a live slot (mr-mergeability); the modes and the button read | |||
| 460 | + | // its data-* attributes (hx-live bindings), so that they follow the slot when | |||
| 461 | + | // the stream replaces its content. The server renders the same state in the | |||
| 462 | + | // attributes, for a page without scripts. | |||
| 449 | templ mergeMenu(p MRPage) { | 463 | templ mergeMenu(p MRPage) { | ||
| 450 | - | <details class="menu mmenu" id="merge-menu" data-state="open"> | 464 | + | <details class="menu mmenu" id="merge-menu" data-state="open" open?={ p.Box.Error != "" }> |
| 451 | <summary class="btn primary" aria-keyshortcuts="Shift+M"> | 465 | <summary class="btn primary" aria-keyshortcuts="Shift+M"> | ||
| 452 | if p.Box.ReadyAction != "" { | 466 | if p.Box.ReadyAction != "" { | ||
| 453 | Draft | 467 | Draft | ||
| @@ -469,19 +483,32 @@ templ mergeMenu(p MRPage) { | |||||
| 469 | <dt>into</dt> | 483 | <dt>into</dt> | ||
| 470 | <dd>{ p.Target }</dd> | 484 | <dd>{ p.Target }</dd> | ||
| 471 | </dl> | 485 | </dl> | ||
| 486 | + | if p.Box.Error != "" { | |||
| 487 | + | <p class="d-err" role="alert">{ p.Box.Error }</p> | |||
| 488 | + | } | |||
| 472 | if p.Box.ReadyAction != "" { | 489 | if p.Box.ReadyAction != "" { | ||
| 473 | <p class="d-draft">this is a draft: it cannot be merged until it is ready for review.</p> | 490 | <p class="d-draft">this is a draft: it cannot be merged until it is ready for review.</p> | ||
| 474 | <form class="d-btns" method="post" action={ templ.SafeURL(p.Box.ReadyAction) }> | 491 | <form class="d-btns" method="post" action={ templ.SafeURL(p.Box.ReadyAction) }> | ||
| 475 | <button type="submit" class="btn primary" data-default title="remove the draft prefix from the title">Ready for review</button> | 492 | <button type="submit" class="btn primary" data-default title="remove the draft prefix from the title">Ready for review</button> | ||
| 476 | </form> | 493 | </form> | ||
| 477 | } else if !p.Box.HeadGone { | 494 | } else if !p.Box.HeadGone { | ||
| 495 | + | @mrMergeabilitySlot(p.Box.Mergeability) | |||
| 478 | <form class="d-acts" method="post" action={ templ.SafeURL(p.Box.MergeAction) }> | 496 | <form class="d-acts" method="post" action={ templ.SafeURL(p.Box.MergeAction) }> | ||
| 479 | <input type="hidden" name="head_id" value={ p.Box.Head }/> | 497 | <input type="hidden" name="head_id" value={ p.Box.Head }/> | ||
| 480 | <fieldset class="d-modes"> | 498 | <fieldset class="d-modes"> | ||
| 481 | <legend class="sr">merge mode</legend> | 499 | <legend class="sr">merge mode</legend> | ||
| 482 | for i, m := range p.Box.Modes { | 500 | for i, m := range p.Box.Modes { | ||
| 483 | <label> | 501 | <label> | ||
| 484 | - | <input type="radio" name="mode" value={ m.Value } checked?={ i == 0 }/> | 502 | + | <input |
| 503 | + | type="radio" | |||
| 504 | + | name="mode" | |||
| 505 | + | value={ m.Value } | |||
| 506 | + | checked?={ i == p.Box.Checked() } | |||
| 507 | + | disabled?={ m.Disabled } | |||
| 508 | + | title={ m.Reason } | |||
| 509 | + | :disabled="this.value === 'fast-forward' ? q('[data-mergeability]').data.ffOk !== true : q('[data-mergeability]').data.mergeOk !== true" | |||
| 510 | + | :title="this.value === 'fast-forward' ? q('[data-mergeability]').data.ffReason : q('[data-mergeability]').data.mergeReason" | |||
| 511 | + | /> | |||
| 485 | { m.Label } | 512 | { m.Label } | ||
| 486 | if i < 9 { | 513 | if i < 9 { | ||
| 487 | <kbd aria-hidden="true">{ itoa(i + 1) }</kbd> | 514 | <kbd aria-hidden="true">{ itoa(i + 1) }</kbd> | ||
| @@ -489,11 +516,28 @@ templ mergeMenu(p MRPage) { | |||||
| 489 | </label> | 516 | </label> | ||
| 490 | } | 517 | } | ||
| 491 | </fieldset> | 518 | </fieldset> | ||
| 519 | + | if p.Box.Signed { | |||
| 520 | + | <p class="d-note" id="mr-signing"> | |||
| 521 | + | squash and merge commits are signed with ermite's own key, generated automatically on first start. | |||
| 522 | + | if p.Box.Fingerprint != "" { | |||
| 523 | + | { " " }key <code>{ p.Box.Fingerprint }</code>. | |||
| 524 | + | } | |||
| 525 | + | { " " }<code>ermite signing-key show</code> prints the public key to verify them. | |||
| 526 | + | </p> | |||
| 527 | + | } | |||
| 492 | if p.Box.CanDeleteBranch { | 528 | if p.Box.CanDeleteBranch { | ||
| 493 | <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> | 529 | <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> | ||
| 494 | } | 530 | } | ||
| 495 | <div class="d-btns"> | 531 | <div class="d-btns"> | ||
| 496 | - | <button type="submit" class="btn primary" data-default>Merge into { p.Target }</button> | 532 | + | <button |
| 533 | + | type="submit" | |||
| 534 | + | class="btn primary" | |||
| 535 | + | data-default | |||
| 536 | + | disabled?={ !p.Box.CanMerge() } | |||
| 537 | + | title={ p.Box.MergeTitle() } | |||
| 538 | + | :disabled="!this.form.querySelector('input[name=mode]:checked:not(:disabled)')" | |||
| 539 | + | :title="(this.form.querySelector('input[name=mode]:checked') || {}).title || ''" | |||
| 540 | + | >Merge into { p.Target }</button> | |||
| 497 | </div> | 541 | </div> | ||
| 498 | </form> | 542 | </form> | ||
| 499 | } | 543 | } | ||
| @@ -501,6 +545,47 @@ templ mergeMenu(p MRPage) { | |||||
| 501 | </details> | 545 | </details> | ||
| 502 | } | 546 | } | ||
| 503 | 547 | ||||
| 548 | + | // MRMergeability is the content of the mergeability slot: the badge, and the | |||
| 549 | + | // state the controls of the merge box read. | |||
| 550 | + | templ MRMergeability(v MergeabilityView) { | |||
| 551 | + | <span | |||
| 552 | + | data-mergeability | |||
| 553 | + | data-state={ v.State } | |||
| 554 | + | data-ff-ok={ strconv.FormatBool(v.FFOk) } | |||
| 555 | + | data-merge-ok={ strconv.FormatBool(v.MergeOk) } | |||
| 556 | + | data-ff-reason={ v.FFReason } | |||
| 557 | + | data-merge-reason={ v.MergeReason } | |||
| 558 | + | > | |||
| 559 | + | <span class="badge" data-state={ v.State }>{ v.Label }</span> | |||
| 560 | + | if v.Detail != "" { | |||
| 561 | + | { " " }<span class="dim">{ v.Detail }</span> | |||
| 562 | + | } | |||
| 563 | + | </span> | |||
| 564 | + | if len(v.Paths) > 0 { | |||
| 565 | + | <ul class="mg-paths" aria-label="conflicting files"> | |||
| 566 | + | for _, path := range v.Paths { | |||
| 567 | + | <li><code>{ path }</code></li> | |||
| 568 | + | } | |||
| 569 | + | </ul> | |||
| 570 | + | } | |||
| 571 | + | if v.PathsNote != "" { | |||
| 572 | + | <p class="dim">{ v.PathsNote }</p> | |||
| 573 | + | } | |||
| 574 | + | } | |||
| 575 | + | ||||
| 576 | + | // mrMergeabilitySlot is the status region of the badge, with its content: the | |||
| 577 | + | // stream replaces the children, never the element (see LiveSlot). | |||
| 578 | + | templ mrMergeabilitySlot(v MergeabilityView) { | |||
| 579 | + | <div id="mr-mergeability" class="live-slot" role="status" data-slot> | |||
| 580 | + | @MRMergeability(v) | |||
| 581 | + | </div> | |||
| 582 | + | } | |||
| 583 | + | ||||
| 584 | + | // MRMergeabilityFragment is the slot as the stream sends it. | |||
| 585 | + | templ MRMergeabilityFragment(v MergeabilityView) { | |||
| 586 | + | @mrMergeabilitySlot(v) | |||
| 587 | + | } | |||
| 588 | + | ||||
| 504 | // moreMenu is what else the owner does with an open request: mark the head | 589 | // moreMenu is what else the owner does with an open request: mark the head | ||
| 505 | // read, close it, merge it from a terminal. | 590 | // read, close it, merge it from a terminal. | ||
| 506 | templ moreMenu(p MRPage) { | 591 | templ moreMenu(p MRPage) { | ||
M internal/adapters/web/mr_post.go +55 -3
| @@ -60,6 +60,10 @@ func (s *Server) mrAction(h func(ctx context.Context, r *http.Request) (to strin | |||
| 60 | 60 | return | |
| 61 | 61 | } | |
| 62 | 62 | to, back, err := h(ctx, r) | |
| 63 | + | var re *rerender | |
| 64 | + | if errors.As(err, &re) && s.rerenderMR(ctx, w, r, re) { | |
| 65 | + | return | |
| 66 | + | } | |
| 63 | 67 | if err != nil { | |
| 64 | 68 | s.actionFailed(ctx, w, r, err, back) | |
| 65 | 69 | return | |
| @@ -68,6 +72,43 @@ func (s *Server) mrAction(h func(ctx context.Context, r *http.Request) (to strin | |||
| 68 | 72 | } | |
| 69 | 73 | } | |
| 70 | 74 | ||
| 75 | + | // rerender is the error of a merge the use case refused for a reason the | |
| 76 | + | // owner reads on the page of the request: the page is shown again, with the | |
| 77 | + | // status, and the reason in its merge box. Any other error keeps actionFailed. | |
| 78 | + | type rerender struct { | |
| 79 | + | status int | |
| 80 | + | msg string | |
| 81 | + | err error | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | func (e *rerender) Error() string { return e.err.Error() } | |
| 85 | + | func (e *rerender) Unwrap() error { return e.err } | |
| 86 | + | ||
| 87 | + | type mergeErrorKey struct{} | |
| 88 | + | ||
| 89 | + | // withMergeError carries the reason of a refused merge to the page. | |
| 90 | + | func withMergeError(ctx context.Context, msg string) context.Context { | |
| 91 | + | return context.WithValue(ctx, mergeErrorKey{}, msg) | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | func mergeErrorOf(ctx context.Context) string { | |
| 95 | + | msg, _ := ctx.Value(mergeErrorKey{}).(string) | |
| 96 | + | return msg | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | // rerenderMR writes the page of the request with the reason of the refusal. | |
| 100 | + | // It reports false when the page cannot be built; the caller then answers as | |
| 101 | + | // for any refused action. | |
| 102 | + | func (s *Server) rerenderMR(ctx context.Context, w http.ResponseWriter, r *http.Request, re *rerender) bool { | |
| 103 | + | c, _, err := s.mrPage(withMergeError(ctx, re.msg), r, false) | |
| 104 | + | if err != nil { | |
| 105 | + | return false | |
| 106 | + | } | |
| 107 | + | w.Header().Set("Cache-Control", "no-store") | |
| 108 | + | s.write(ctx, w, r, re.status, c) | |
| 109 | + | return true | |
| 110 | + | } | |
| 111 | + | ||
| 71 | 112 | // actionFailed answers a refused action. | |
| 72 | 113 | func (s *Server) actionFailed(ctx context.Context, w http.ResponseWriter, r *http.Request, err error, back string) { | |
| 73 | 114 | // The words of the use case, for the rules the owner can fix. | |
| @@ -322,8 +363,11 @@ func (s *Server) mrMerge(ctx context.Context, r *http.Request) (string, string, | |||
| 322 | 363 | mode = domain.MergeModeFastForward | |
| 323 | 364 | case "squash": | |
| 324 | 365 | mode = domain.MergeModeSquash | |
| 366 | + | case "merge": | |
| 367 | + | mode = domain.MergeModeMerge | |
| 325 | 368 | default: | |
| 326 | - | return "", back, formError("mode must be fast-forward or squash") | |
| 369 | + | err := formError("mode must be fast-forward, squash or merge") | |
| 370 | + | return "", back, &rerender{status: http.StatusBadRequest, msg: err.Error(), err: err} | |
| 327 | 371 | } | |
| 328 | 372 | name, err := domain.ParseRepoName(repo) | |
| 329 | 373 | if err != nil { | |
| @@ -333,11 +377,19 @@ func (s *Server) mrMerge(ctx context.Context, r *http.Request) (string, string, | |||
| 333 | 377 | if err != nil { | |
| 334 | 378 | return "", back, err | |
| 335 | 379 | } | |
| 336 | - | if _, err := s.cfg.MergeRequests.Merge(ctx, principal(ctx), name, n, ports.MergeParams{ | |
| 380 | + | res, err := s.cfg.MergeRequests.Merge(ctx, principal(ctx), name, n, ports.MergeParams{ | |
| 337 | 381 | Mode: mode, HeadID: head, DeleteSource: r.PostFormValue("delete_branch") == "1", | |
| 338 | - | }); err != nil { | |
| 382 | + | }) | |
| 383 | + | switch { | |
| 384 | + | case errors.Is(err, domain.ErrPreconditionFailed), errors.Is(err, domain.ErrConflict): | |
| 385 | + | return "", back, &rerender{status: http.StatusConflict, msg: rule(err.Error(), "the merge request is not in a state that allows this."), err: err} | |
| 386 | + | case err != nil: | |
| 339 | 387 | return "", back, err | |
| 340 | 388 | } | |
| 389 | + | if res.SourceDeleted { | |
| 390 | + | // The deletion follows the answer: the page says it is being done. | |
| 391 | + | return back + "?deleting=1", back, nil | |
| 392 | + | } | |
| 341 | 393 | return back, back, nil | |
| 342 | 394 | } | |
| 343 | 395 | ||
| @@ -60,6 +60,10 @@ func (s *Server) mrAction(h func(ctx context.Context, r *http.Request) (to strin | |||||
| 60 | return | 60 | return | ||
| 61 | } | 61 | } | ||
| 62 | to, back, err := h(ctx, r) | 62 | to, back, err := h(ctx, r) | ||
| 63 | + | var re *rerender | |||
| 64 | + | if errors.As(err, &re) && s.rerenderMR(ctx, w, r, re) { | |||
| 65 | + | return | |||
| 66 | + | } | |||
| 63 | if err != nil { | 67 | if err != nil { | ||
| 64 | s.actionFailed(ctx, w, r, err, back) | 68 | s.actionFailed(ctx, w, r, err, back) | ||
| 65 | return | 69 | return | ||
| @@ -68,6 +72,43 @@ func (s *Server) mrAction(h func(ctx context.Context, r *http.Request) (to strin | |||||
| 68 | } | 72 | } | ||
| 69 | } | 73 | } | ||
| 70 | 74 | ||||
| 75 | + | // rerender is the error of a merge the use case refused for a reason the | |||
| 76 | + | // owner reads on the page of the request: the page is shown again, with the | |||
| 77 | + | // status, and the reason in its merge box. Any other error keeps actionFailed. | |||
| 78 | + | type rerender struct { | |||
| 79 | + | status int | |||
| 80 | + | msg string | |||
| 81 | + | err error | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | func (e *rerender) Error() string { return e.err.Error() } | |||
| 85 | + | func (e *rerender) Unwrap() error { return e.err } | |||
| 86 | + | ||||
| 87 | + | type mergeErrorKey struct{} | |||
| 88 | + | ||||
| 89 | + | // withMergeError carries the reason of a refused merge to the page. | |||
| 90 | + | func withMergeError(ctx context.Context, msg string) context.Context { | |||
| 91 | + | return context.WithValue(ctx, mergeErrorKey{}, msg) | |||
| 92 | + | } | |||
| 93 | + | ||||
| 94 | + | func mergeErrorOf(ctx context.Context) string { | |||
| 95 | + | msg, _ := ctx.Value(mergeErrorKey{}).(string) | |||
| 96 | + | return msg | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | // rerenderMR writes the page of the request with the reason of the refusal. | |||
| 100 | + | // It reports false when the page cannot be built; the caller then answers as | |||
| 101 | + | // for any refused action. | |||
| 102 | + | func (s *Server) rerenderMR(ctx context.Context, w http.ResponseWriter, r *http.Request, re *rerender) bool { | |||
| 103 | + | c, _, err := s.mrPage(withMergeError(ctx, re.msg), r, false) | |||
| 104 | + | if err != nil { | |||
| 105 | + | return false | |||
| 106 | + | } | |||
| 107 | + | w.Header().Set("Cache-Control", "no-store") | |||
| 108 | + | s.write(ctx, w, r, re.status, c) | |||
| 109 | + | return true | |||
| 110 | + | } | |||
| 111 | + | ||||
| 71 | // actionFailed answers a refused action. | 112 | // actionFailed answers a refused action. | ||
| 72 | func (s *Server) actionFailed(ctx context.Context, w http.ResponseWriter, r *http.Request, err error, back string) { | 113 | func (s *Server) actionFailed(ctx context.Context, w http.ResponseWriter, r *http.Request, err error, back string) { | ||
| 73 | // The words of the use case, for the rules the owner can fix. | 114 | // The words of the use case, for the rules the owner can fix. | ||
| @@ -322,8 +363,11 @@ func (s *Server) mrMerge(ctx context.Context, r *http.Request) (string, string, | |||||
| 322 | mode = domain.MergeModeFastForward | 363 | mode = domain.MergeModeFastForward | ||
| 323 | case "squash": | 364 | case "squash": | ||
| 324 | mode = domain.MergeModeSquash | 365 | mode = domain.MergeModeSquash | ||
| 366 | + | case "merge": | |||
| 367 | + | mode = domain.MergeModeMerge | |||
| 325 | default: | 368 | default: | ||
| 326 | - | return "", back, formError("mode must be fast-forward or squash") | 369 | + | err := formError("mode must be fast-forward, squash or merge") |
| 370 | + | return "", back, &rerender{status: http.StatusBadRequest, msg: err.Error(), err: err} | |||
| 327 | } | 371 | } | ||
| 328 | name, err := domain.ParseRepoName(repo) | 372 | name, err := domain.ParseRepoName(repo) | ||
| 329 | if err != nil { | 373 | if err != nil { | ||
| @@ -333,11 +377,19 @@ func (s *Server) mrMerge(ctx context.Context, r *http.Request) (string, string, | |||||
| 333 | if err != nil { | 377 | if err != nil { | ||
| 334 | return "", back, err | 378 | return "", back, err | ||
| 335 | } | 379 | } | ||
| 336 | - | if _, err := s.cfg.MergeRequests.Merge(ctx, principal(ctx), name, n, ports.MergeParams{ | 380 | + | res, err := s.cfg.MergeRequests.Merge(ctx, principal(ctx), name, n, ports.MergeParams{ |
| 337 | Mode: mode, HeadID: head, DeleteSource: r.PostFormValue("delete_branch") == "1", | 381 | Mode: mode, HeadID: head, DeleteSource: r.PostFormValue("delete_branch") == "1", | ||
| 338 | - | }); err != nil { | 382 | + | }) |
| 383 | + | switch { | |||
| 384 | + | case errors.Is(err, domain.ErrPreconditionFailed), errors.Is(err, domain.ErrConflict): | |||
| 385 | + | return "", back, &rerender{status: http.StatusConflict, msg: rule(err.Error(), "the merge request is not in a state that allows this."), err: err} | |||
| 386 | + | case err != nil: | |||
| 339 | return "", back, err | 387 | return "", back, err | ||
| 340 | } | 388 | } | ||
| 389 | + | if res.SourceDeleted { | |||
| 390 | + | // The deletion follows the answer: the page says it is being done. | |||
| 391 | + | return back + "?deleting=1", back, nil | |||
| 392 | + | } | |||
| 341 | return back, back, nil | 393 | return back, back, nil | ||
| 342 | } | 394 | } | ||
| 343 | 395 | ||||
M internal/adapters/web/mr_templ.go +1184 -737 generated file: collapsed
A internal/adapters/web/mr_test.go +130 -0
| @@ -0,0 +1,130 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package web | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "testing" | |
| 8 | + | ||
| 9 | + | "github.com/stretchr/testify/assert" | |
| 10 | + | "github.com/stretchr/testify/mock" | |
| 11 | + | "github.com/stretchr/testify/require" | |
| 12 | + | ||
| 13 | + | "bnema.dev/ermite/internal/domain" | |
| 14 | + | "bnema.dev/ermite/internal/ports" | |
| 15 | + | "bnema.dev/ermite/internal/ports/mocks" | |
| 16 | + | ) | |
| 17 | + | ||
| 18 | + | func repoWithModes(modes ...domain.MergeMode) ports.RepoView { | |
| 19 | + | var v ports.RepoView | |
| 20 | + | v.Settings.AllowedMergeModes = modes | |
| 21 | + | return v | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | func watchOf(ev domain.Event) ports.WatchEvent { return ports.WatchEvent{Event: ev} } | |
| 25 | + | ||
| 26 | + | func TestMergeabilityView(t *testing.T) { | |
| 27 | + | t.Parallel() | |
| 28 | + | for _, tt := range []struct { | |
| 29 | + | name string | |
| 30 | + | m domain.Mergeability | |
| 31 | + | label, detail string | |
| 32 | + | ffOK, mergeOK bool | |
| 33 | + | note string | |
| 34 | + | paths []string | |
| 35 | + | }{ | |
| 36 | + | {"up to date", domain.Mergeability{State: domain.MergeabilityUpToDate}, "Ready to merge", "", true, true, "", nil}, | |
| 37 | + | {"behind", domain.Mergeability{State: domain.MergeabilityBehind, Behind: 2}, "Behind main by 2 commits", "merges without conflicts", false, true, "", nil}, | |
| 38 | + | {"behind by one", domain.Mergeability{State: domain.MergeabilityBehind, Behind: 1}, "Behind main by 1 commit", "merges without conflicts", false, true, "", nil}, | |
| 39 | + | {"behind, capped", domain.Mergeability{State: domain.MergeabilityBehind, Behind: 1000, BehindCapped: true}, "Behind main by 1000+ commits", "merges without conflicts", false, true, "", nil}, | |
| 40 | + | {"conflict", domain.Mergeability{State: domain.MergeabilityConflict, ConflictPaths: []string{"a.go"}}, "Conflicts with main", "rebase needed", false, false, "", []string{"a.go"}}, | |
| 41 | + | {"conflict, truncated", domain.Mergeability{State: domain.MergeabilityConflict, ConflictPaths: []string{"a.go"}, ConflictPathsTruncated: true}, "Conflicts with main", "rebase needed", false, false, "more files conflict than are listed.", []string{"a.go"}}, | |
| 42 | + | {"no common history", domain.Mergeability{State: domain.MergeabilityConflict}, "Conflicts with main", "rebase needed", false, false, "no common history.", nil}, | |
| 43 | + | {"checking", domain.Mergeability{State: domain.MergeabilityChecking}, "Checking…", "", false, false, "", nil}, | |
| 44 | + | {"none", domain.Mergeability{}, "Checking…", "", false, false, "", nil}, | |
| 45 | + | } { | |
| 46 | + | t.Run(tt.name, func(t *testing.T) { | |
| 47 | + | t.Parallel() | |
| 48 | + | v := mergeabilityView(tt.m, "main") | |
| 49 | + | assert.Equal(t, tt.label, v.Label) | |
| 50 | + | assert.Equal(t, tt.detail, v.Detail) | |
| 51 | + | assert.Equal(t, tt.ffOK, v.FFOk) | |
| 52 | + | assert.Equal(t, tt.mergeOK, v.MergeOk) | |
| 53 | + | assert.Equal(t, tt.note, v.PathsNote) | |
| 54 | + | assert.Equal(t, tt.paths, v.Paths) | |
| 55 | + | assert.Equal(t, !tt.ffOK, v.FFReason != "", "a mode that is not possible says why") | |
| 56 | + | assert.Equal(t, !tt.mergeOK, v.MergeReason != "") | |
| 57 | + | }) | |
| 58 | + | } | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | func TestMergeBoxModes(t *testing.T) { | |
| 62 | + | t.Parallel() | |
| 63 | + | d := &repoData{repo: repoWithModes(domain.MergeModes()...)} | |
| 64 | + | s := &Server{} | |
| 65 | + | ||
| 66 | + | behind := s.mergeBox(d, 1, "feat", "abc", "", mergeabilityView(domain.Mergeability{State: domain.MergeabilityBehind, Behind: 1}, "main")) | |
| 67 | + | require.Len(t, behind.Modes, 3) | |
| 68 | + | assert.True(t, behind.Modes[0].Disabled, "fast-forward is not possible when behind") | |
| 69 | + | assert.NotEmpty(t, behind.Modes[0].Reason) | |
| 70 | + | assert.False(t, behind.Modes[1].Disabled) | |
| 71 | + | assert.False(t, behind.Modes[2].Disabled) | |
| 72 | + | assert.Equal(t, 1, behind.Checked(), "the first possible mode is checked") | |
| 73 | + | assert.True(t, behind.CanMerge()) | |
| 74 | + | assert.True(t, behind.Signed) | |
| 75 | + | ||
| 76 | + | conflict := s.mergeBox(d, 1, "feat", "abc", "", mergeabilityView(domain.Mergeability{State: domain.MergeabilityConflict}, "main")) | |
| 77 | + | assert.False(t, conflict.CanMerge()) | |
| 78 | + | assert.NotEmpty(t, conflict.MergeTitle(), "the button says why it is disabled") | |
| 79 | + | assert.Equal(t, 0, conflict.Checked()) | |
| 80 | + | assert.Contains(t, conflict.CLI.Lines[2], "--mode merge") | |
| 81 | + | assert.NotContains(t, conflict.CLI.Lines[1], "--squash") | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | func TestMergeBoxOnlyFastForwardIsNotSigned(t *testing.T) { | |
| 85 | + | t.Parallel() | |
| 86 | + | d := &repoData{repo: repoWithModes(domain.MergeModeFastForward)} | |
| 87 | + | b := (&Server{}).mergeBox(d, 1, "feat", "abc", "", mergeabilityView(domain.Mergeability{State: domain.MergeabilityUpToDate}, "main")) | |
| 88 | + | assert.False(t, b.Signed, "no commit is created, nothing to explain") | |
| 89 | + | assert.Len(t, b.Modes, 1) | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | func TestMergeBoxNamesTheSigningKey(t *testing.T) { | |
| 93 | + | t.Parallel() | |
| 94 | + | key := mocks.NewSigningKeyService(t) | |
| 95 | + | key.EXPECT().Info().Return(domain.SigningKeyInfo{Key: domain.SigningPublicKey{Fingerprint: "SHA256:abc"}}) | |
| 96 | + | d := &repoData{repo: repoWithModes(domain.MergeModes()...)} | |
| 97 | + | b := (&Server{cfg: Config{SigningKey: key}}).mergeBox(d, 1, "feat", "abc", "", mergeabilityView(domain.Mergeability{}, "main")) | |
| 98 | + | assert.Equal(t, "SHA256:abc", b.Fingerprint) | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | func TestMergeErrorTravelsInTheContext(t *testing.T) { | |
| 102 | + | t.Parallel() | |
| 103 | + | assert.Empty(t, mergeErrorOf(context.Background())) | |
| 104 | + | assert.Equal(t, "boom", mergeErrorOf(withMergeError(context.Background(), "boom"))) | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | func TestMergeabilityLiveEvents(t *testing.T) { | |
| 108 | + | t.Parallel() | |
| 109 | + | assert.True(t, liveEvent(watchOf(domain.MergeRequestMergeability{Number: 1}))) | |
| 110 | + | svc := mocks.NewMergeRequestService(t) | |
| 111 | + | svc.EXPECT().Mergeability(mock.Anything, mock.Anything, mock.Anything, int64(1)). | |
| 112 | + | Return(domain.Mergeability{State: domain.MergeabilityBehind, Behind: 3}, nil) | |
| 113 | + | l := liveStream{s: &Server{cfg: Config{MergeRequests: svc}}, repo: "demo", view: "mr", n: "1", target: "main"} | |
| 114 | + | ctx := context.WithValue(context.Background(), signedInKey{}, struct{}{}) | |
| 115 | + | ||
| 116 | + | fs, err := l.repoPage(ctx, watchOf(domain.MergeRequestMergeability{Number: 1})) | |
| 117 | + | require.NoError(t, err) | |
| 118 | + | require.Len(t, fs, 1) | |
| 119 | + | assert.Equal(t, "mr-mergeability", fs[0].event) | |
| 120 | + | assert.Contains(t, fs[0].html, "Behind main by 3 commits") | |
| 121 | + | ||
| 122 | + | // Another request, or a page that is not one: nothing. | |
| 123 | + | fs, err = l.repoPage(ctx, watchOf(domain.MergeRequestMergeability{Number: 2})) | |
| 124 | + | require.NoError(t, err) | |
| 125 | + | assert.Empty(t, fs) | |
| 126 | + | // The box is the owner's. | |
| 127 | + | fs, err = l.repoPage(context.Background(), watchOf(domain.MergeRequestMergeability{Number: 1})) | |
| 128 | + | require.NoError(t, err) | |
| 129 | + | assert.Empty(t, fs) | |
| 130 | + | } | |
| @@ -0,0 +1,130 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package web | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "testing" | |||
| 8 | + | ||||
| 9 | + | "github.com/stretchr/testify/assert" | |||
| 10 | + | "github.com/stretchr/testify/mock" | |||
| 11 | + | "github.com/stretchr/testify/require" | |||
| 12 | + | ||||
| 13 | + | "bnema.dev/ermite/internal/domain" | |||
| 14 | + | "bnema.dev/ermite/internal/ports" | |||
| 15 | + | "bnema.dev/ermite/internal/ports/mocks" | |||
| 16 | + | ) | |||
| 17 | + | ||||
| 18 | + | func repoWithModes(modes ...domain.MergeMode) ports.RepoView { | |||
| 19 | + | var v ports.RepoView | |||
| 20 | + | v.Settings.AllowedMergeModes = modes | |||
| 21 | + | return v | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | func watchOf(ev domain.Event) ports.WatchEvent { return ports.WatchEvent{Event: ev} } | |||
| 25 | + | ||||
| 26 | + | func TestMergeabilityView(t *testing.T) { | |||
| 27 | + | t.Parallel() | |||
| 28 | + | for _, tt := range []struct { | |||
| 29 | + | name string | |||
| 30 | + | m domain.Mergeability | |||
| 31 | + | label, detail string | |||
| 32 | + | ffOK, mergeOK bool | |||
| 33 | + | note string | |||
| 34 | + | paths []string | |||
| 35 | + | }{ | |||
| 36 | + | {"up to date", domain.Mergeability{State: domain.MergeabilityUpToDate}, "Ready to merge", "", true, true, "", nil}, | |||
| 37 | + | {"behind", domain.Mergeability{State: domain.MergeabilityBehind, Behind: 2}, "Behind main by 2 commits", "merges without conflicts", false, true, "", nil}, | |||
| 38 | + | {"behind by one", domain.Mergeability{State: domain.MergeabilityBehind, Behind: 1}, "Behind main by 1 commit", "merges without conflicts", false, true, "", nil}, | |||
| 39 | + | {"behind, capped", domain.Mergeability{State: domain.MergeabilityBehind, Behind: 1000, BehindCapped: true}, "Behind main by 1000+ commits", "merges without conflicts", false, true, "", nil}, | |||
| 40 | + | {"conflict", domain.Mergeability{State: domain.MergeabilityConflict, ConflictPaths: []string{"a.go"}}, "Conflicts with main", "rebase needed", false, false, "", []string{"a.go"}}, | |||
| 41 | + | {"conflict, truncated", domain.Mergeability{State: domain.MergeabilityConflict, ConflictPaths: []string{"a.go"}, ConflictPathsTruncated: true}, "Conflicts with main", "rebase needed", false, false, "more files conflict than are listed.", []string{"a.go"}}, | |||
| 42 | + | {"no common history", domain.Mergeability{State: domain.MergeabilityConflict}, "Conflicts with main", "rebase needed", false, false, "no common history.", nil}, | |||
| 43 | + | {"checking", domain.Mergeability{State: domain.MergeabilityChecking}, "Checking…", "", false, false, "", nil}, | |||
| 44 | + | {"none", domain.Mergeability{}, "Checking…", "", false, false, "", nil}, | |||
| 45 | + | } { | |||
| 46 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 47 | + | t.Parallel() | |||
| 48 | + | v := mergeabilityView(tt.m, "main") | |||
| 49 | + | assert.Equal(t, tt.label, v.Label) | |||
| 50 | + | assert.Equal(t, tt.detail, v.Detail) | |||
| 51 | + | assert.Equal(t, tt.ffOK, v.FFOk) | |||
| 52 | + | assert.Equal(t, tt.mergeOK, v.MergeOk) | |||
| 53 | + | assert.Equal(t, tt.note, v.PathsNote) | |||
| 54 | + | assert.Equal(t, tt.paths, v.Paths) | |||
| 55 | + | assert.Equal(t, !tt.ffOK, v.FFReason != "", "a mode that is not possible says why") | |||
| 56 | + | assert.Equal(t, !tt.mergeOK, v.MergeReason != "") | |||
| 57 | + | }) | |||
| 58 | + | } | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | func TestMergeBoxModes(t *testing.T) { | |||
| 62 | + | t.Parallel() | |||
| 63 | + | d := &repoData{repo: repoWithModes(domain.MergeModes()...)} | |||
| 64 | + | s := &Server{} | |||
| 65 | + | ||||
| 66 | + | behind := s.mergeBox(d, 1, "feat", "abc", "", mergeabilityView(domain.Mergeability{State: domain.MergeabilityBehind, Behind: 1}, "main")) | |||
| 67 | + | require.Len(t, behind.Modes, 3) | |||
| 68 | + | assert.True(t, behind.Modes[0].Disabled, "fast-forward is not possible when behind") | |||
| 69 | + | assert.NotEmpty(t, behind.Modes[0].Reason) | |||
| 70 | + | assert.False(t, behind.Modes[1].Disabled) | |||
| 71 | + | assert.False(t, behind.Modes[2].Disabled) | |||
| 72 | + | assert.Equal(t, 1, behind.Checked(), "the first possible mode is checked") | |||
| 73 | + | assert.True(t, behind.CanMerge()) | |||
| 74 | + | assert.True(t, behind.Signed) | |||
| 75 | + | ||||
| 76 | + | conflict := s.mergeBox(d, 1, "feat", "abc", "", mergeabilityView(domain.Mergeability{State: domain.MergeabilityConflict}, "main")) | |||
| 77 | + | assert.False(t, conflict.CanMerge()) | |||
| 78 | + | assert.NotEmpty(t, conflict.MergeTitle(), "the button says why it is disabled") | |||
| 79 | + | assert.Equal(t, 0, conflict.Checked()) | |||
| 80 | + | assert.Contains(t, conflict.CLI.Lines[2], "--mode merge") | |||
| 81 | + | assert.NotContains(t, conflict.CLI.Lines[1], "--squash") | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | func TestMergeBoxOnlyFastForwardIsNotSigned(t *testing.T) { | |||
| 85 | + | t.Parallel() | |||
| 86 | + | d := &repoData{repo: repoWithModes(domain.MergeModeFastForward)} | |||
| 87 | + | b := (&Server{}).mergeBox(d, 1, "feat", "abc", "", mergeabilityView(domain.Mergeability{State: domain.MergeabilityUpToDate}, "main")) | |||
| 88 | + | assert.False(t, b.Signed, "no commit is created, nothing to explain") | |||
| 89 | + | assert.Len(t, b.Modes, 1) | |||
| 90 | + | } | |||
| 91 | + | ||||
| 92 | + | func TestMergeBoxNamesTheSigningKey(t *testing.T) { | |||
| 93 | + | t.Parallel() | |||
| 94 | + | key := mocks.NewSigningKeyService(t) | |||
| 95 | + | key.EXPECT().Info().Return(domain.SigningKeyInfo{Key: domain.SigningPublicKey{Fingerprint: "SHA256:abc"}}) | |||
| 96 | + | d := &repoData{repo: repoWithModes(domain.MergeModes()...)} | |||
| 97 | + | b := (&Server{cfg: Config{SigningKey: key}}).mergeBox(d, 1, "feat", "abc", "", mergeabilityView(domain.Mergeability{}, "main")) | |||
| 98 | + | assert.Equal(t, "SHA256:abc", b.Fingerprint) | |||
| 99 | + | } | |||
| 100 | + | ||||
| 101 | + | func TestMergeErrorTravelsInTheContext(t *testing.T) { | |||
| 102 | + | t.Parallel() | |||
| 103 | + | assert.Empty(t, mergeErrorOf(context.Background())) | |||
| 104 | + | assert.Equal(t, "boom", mergeErrorOf(withMergeError(context.Background(), "boom"))) | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | func TestMergeabilityLiveEvents(t *testing.T) { | |||
| 108 | + | t.Parallel() | |||
| 109 | + | assert.True(t, liveEvent(watchOf(domain.MergeRequestMergeability{Number: 1}))) | |||
| 110 | + | svc := mocks.NewMergeRequestService(t) | |||
| 111 | + | svc.EXPECT().Mergeability(mock.Anything, mock.Anything, mock.Anything, int64(1)). | |||
| 112 | + | Return(domain.Mergeability{State: domain.MergeabilityBehind, Behind: 3}, nil) | |||
| 113 | + | l := liveStream{s: &Server{cfg: Config{MergeRequests: svc}}, repo: "demo", view: "mr", n: "1", target: "main"} | |||
| 114 | + | ctx := context.WithValue(context.Background(), signedInKey{}, struct{}{}) | |||
| 115 | + | ||||
| 116 | + | fs, err := l.repoPage(ctx, watchOf(domain.MergeRequestMergeability{Number: 1})) | |||
| 117 | + | require.NoError(t, err) | |||
| 118 | + | require.Len(t, fs, 1) | |||
| 119 | + | assert.Equal(t, "mr-mergeability", fs[0].event) | |||
| 120 | + | assert.Contains(t, fs[0].html, "Behind main by 3 commits") | |||
| 121 | + | ||||
| 122 | + | // Another request, or a page that is not one: nothing. | |||
| 123 | + | fs, err = l.repoPage(ctx, watchOf(domain.MergeRequestMergeability{Number: 2})) | |||
| 124 | + | require.NoError(t, err) | |||
| 125 | + | assert.Empty(t, fs) | |||
| 126 | + | // The box is the owner's. | |||
| 127 | + | fs, err = l.repoPage(context.Background(), watchOf(domain.MergeRequestMergeability{Number: 1})) | |||
| 128 | + | require.NoError(t, err) | |||
| 129 | + | assert.Empty(t, fs) | |||
| 130 | + | } | |||
M internal/adapters/web/static/css/60-mr.css +16 -1
| @@ -78,7 +78,22 @@ | |||
| 78 | 78 | & .d-btns { display: flex; margin-top: var(--s1); } | |
| 79 | 79 | & .d-btns .btn { flex: 1; justify-content: center; } | |
| 80 | 80 | & .d-draft { color: var(--ink-dim); } | |
| 81 | - | } | |
| 81 | + | & .d-note { margin: 0; color: var(--ink-dim); } | |
| 82 | + | & .d-err { margin: 0; padding: var(--s2) var(--s3); color: var(--ink-hi); border: 1px solid var(--red); border-left-width: 3px; border-radius: var(--r-s); overflow-wrap: anywhere; } | |
| 83 | + | /* the badge is a live slot, but not a banner */ | |
| 84 | + | & #mr-mergeability:not(:empty) { padding: 0; font-weight: normal; background: none; border: 0; display: grid; gap: var(--s1); } | |
| 85 | + | & .mg-paths { margin: 0; padding-left: 2ch; max-height: 8em; overflow: auto; } | |
| 86 | + | & .mg-paths code { overflow-wrap: anywhere; } | |
| 87 | + | & .d-modes label:has(:disabled) { color: var(--ink-mute); cursor: not-allowed; } | |
| 88 | + | & .d-modes label:has(:disabled):hover { background: none; } | |
| 89 | + | & .btn:disabled { opacity: .5; cursor: not-allowed; } | |
| 90 | + | } | |
| 91 | + | .badge[data-state] { display: inline-block; padding: 0 var(--s2); border: 1px solid var(--border-strong); border-radius: var(--r-s); font-weight: 600; color: var(--ink-hi); } | |
| 92 | + | .badge[data-state="up_to_date"] { border-color: var(--teal); color: var(--teal); } | |
| 93 | + | .badge[data-state="behind"] { border-color: var(--teal-2); } | |
| 94 | + | .badge[data-state="conflict"] { border-color: var(--red); color: var(--red); } | |
| 95 | + | .badge[data-state="checking"] { color: var(--ink-dim); border-style: dashed; } | |
| 96 | + | .note[role="status"] { color: var(--ink-dim); } | |
| 82 | 97 | .omenu { | |
| 83 | 98 | & > summary { font-size: 18px; line-height: 1; } | |
| 84 | 99 | & .menu-pop { width: 280px; } | |
| @@ -78,7 +78,22 @@ | |||||
| 78 | & .d-btns { display: flex; margin-top: var(--s1); } | 78 | & .d-btns { display: flex; margin-top: var(--s1); } | ||
| 79 | & .d-btns .btn { flex: 1; justify-content: center; } | 79 | & .d-btns .btn { flex: 1; justify-content: center; } | ||
| 80 | & .d-draft { color: var(--ink-dim); } | 80 | & .d-draft { color: var(--ink-dim); } | ||
| 81 | - | } | 81 | + | & .d-note { margin: 0; color: var(--ink-dim); } |
| 82 | + | & .d-err { margin: 0; padding: var(--s2) var(--s3); color: var(--ink-hi); border: 1px solid var(--red); border-left-width: 3px; border-radius: var(--r-s); overflow-wrap: anywhere; } | |||
| 83 | + | /* the badge is a live slot, but not a banner */ | |||
| 84 | + | & #mr-mergeability:not(:empty) { padding: 0; font-weight: normal; background: none; border: 0; display: grid; gap: var(--s1); } | |||
| 85 | + | & .mg-paths { margin: 0; padding-left: 2ch; max-height: 8em; overflow: auto; } | |||
| 86 | + | & .mg-paths code { overflow-wrap: anywhere; } | |||
| 87 | + | & .d-modes label:has(:disabled) { color: var(--ink-mute); cursor: not-allowed; } | |||
| 88 | + | & .d-modes label:has(:disabled):hover { background: none; } | |||
| 89 | + | & .btn:disabled { opacity: .5; cursor: not-allowed; } | |||
| 90 | + | } | |||
| 91 | + | .badge[data-state] { display: inline-block; padding: 0 var(--s2); border: 1px solid var(--border-strong); border-radius: var(--r-s); font-weight: 600; color: var(--ink-hi); } | |||
| 92 | + | .badge[data-state="up_to_date"] { border-color: var(--teal); color: var(--teal); } | |||
| 93 | + | .badge[data-state="behind"] { border-color: var(--teal-2); } | |||
| 94 | + | .badge[data-state="conflict"] { border-color: var(--red); color: var(--red); } | |||
| 95 | + | .badge[data-state="checking"] { color: var(--ink-dim); border-style: dashed; } | |||
| 96 | + | .note[role="status"] { color: var(--ink-dim); } | |||
| 82 | .omenu { | 97 | .omenu { | ||
| 83 | & > summary { font-size: 18px; line-height: 1; } | 98 | & > summary { font-size: 18px; line-height: 1; } | ||
| 84 | & .menu-pop { width: 280px; } | 99 | & .menu-pop { width: 280px; } | ||
M internal/adapters/web/static/js/mr.js +1 -1
| @@ -213,7 +213,7 @@ window.ermiteMR = (() => { | |||
| 213 | 213 | const box = $('input[name="delete_branch"]', m); | |
| 214 | 214 | if (box) box.checked = !box.checked; | |
| 215 | 215 | } else { | |
| 216 | - | radios[act.mode].checked = true; | |
| 216 | + | if (!radios[act.mode].disabled) radios[act.mode].checked = true; | |
| 217 | 217 | } | |
| 218 | 218 | $('[data-default]', m)?.focus(); | |
| 219 | 219 | return true; | |
| @@ -213,7 +213,7 @@ window.ermiteMR = (() => { | |||||
| 213 | const box = $('input[name="delete_branch"]', m); | 213 | const box = $('input[name="delete_branch"]', m); | ||
| 214 | if (box) box.checked = !box.checked; | 214 | if (box) box.checked = !box.checked; | ||
| 215 | } else { | 215 | } else { | ||
| 216 | - | radios[act.mode].checked = true; | 216 | + | if (!radios[act.mode].disabled) radios[act.mode].checked = true; |
| 217 | } | 217 | } | ||
| 218 | $('[data-default]', m)?.focus(); | 218 | $('[data-default]', m)?.focus(); | ||
| 219 | return true; | 219 | return true; | ||
M internal/adapters/web/web.go +4 -0
| @@ -76,6 +76,10 @@ type Config struct { | |||
| 76 | 76 | // leads to it. Without it there is neither. Leave it unset rather than | |
| 77 | 77 | // assigning a typed nil pointer. | |
| 78 | 78 | Inbox ports.InboxService | |
| 79 | + | // SigningKey tells the key that signs the squash and merge commits, shown | |
| 80 | + | // in the merge box. Without it the box does not name the key. Leave it | |
| 81 | + | // unset rather than assigning a typed nil pointer. | |
| 82 | + | SigningKey ports.SigningKeyService | |
| 79 | 83 | // Runs serves the CI pages (/<repo>/actions) and the checks shown next to | |
| 80 | 84 | // commits and merge requests. Without it there are no such pages. Leave it | |
| 81 | 85 | // unset rather than assigning a typed nil pointer. | |
| @@ -76,6 +76,10 @@ type Config struct { | |||||
| 76 | // leads to it. Without it there is neither. Leave it unset rather than | 76 | // leads to it. Without it there is neither. Leave it unset rather than | ||
| 77 | // assigning a typed nil pointer. | 77 | // assigning a typed nil pointer. | ||
| 78 | Inbox ports.InboxService | 78 | Inbox ports.InboxService | ||
| 79 | + | // SigningKey tells the key that signs the squash and merge commits, shown | |||
| 80 | + | // in the merge box. Without it the box does not name the key. Leave it | |||
| 81 | + | // unset rather than assigning a typed nil pointer. | |||
| 82 | + | SigningKey ports.SigningKeyService | |||
| 79 | // Runs serves the CI pages (/<repo>/actions) and the checks shown next to | 83 | // Runs serves the CI pages (/<repo>/actions) and the checks shown next to | ||
| 80 | // commits and merge requests. Without it there are no such pages. Leave it | 84 | // commits and merge requests. Without it there are no such pages. Leave it | ||
| 81 | // unset rather than assigning a typed nil pointer. | 85 | // unset rather than assigning a typed nil pointer. | ||
M internal/app/cli_mr.go +143 -19
| @@ -8,6 +8,7 @@ import ( | |||
| 8 | 8 | "fmt" | |
| 9 | 9 | "io" | |
| 10 | 10 | "net/url" | |
| 11 | + | "slices" | |
| 11 | 12 | "strconv" | |
| 12 | 13 | "strings" | |
| 13 | 14 | ||
| @@ -122,6 +123,71 @@ type jsonMergeRequest struct { | |||
| 122 | 123 | MergeMode string `json:"merge_mode"` | |
| 123 | 124 | } | |
| 124 | 125 | ||
| 126 | + | // jsonMergeability is the stable JSON shape of the mergeability of an open | |
| 127 | + | // merge request. | |
| 128 | + | type jsonMergeability struct { | |
| 129 | + | // State is "checking", "up_to_date", "behind" or "conflict". | |
| 130 | + | State string `json:"state"` | |
| 131 | + | // Behind counts the commits of the target the head lacks (behind only); | |
| 132 | + | // BehindCapped says it is a lower bound. | |
| 133 | + | Behind int32 `json:"behind"` | |
| 134 | + | BehindCapped bool `json:"behind_capped"` | |
| 135 | + | ConflictPaths []string `json:"conflict_paths"` | |
| 136 | + | // ConflictPathsTruncated says that more paths conflict than listed. | |
| 137 | + | ConflictPathsTruncated bool `json:"conflict_paths_truncated"` | |
| 138 | + | TargetID string `json:"target_id"` | |
| 139 | + | HeadID string `json:"head_id"` | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | func mergeabilityName(s ermite.MergeabilityState) string { | |
| 143 | + | switch s { | |
| 144 | + | case ermite.MergeabilityState_MERGEABILITY_STATE_CHECKING: | |
| 145 | + | return "checking" | |
| 146 | + | case ermite.MergeabilityState_MERGEABILITY_STATE_UP_TO_DATE: | |
| 147 | + | return "up_to_date" | |
| 148 | + | case ermite.MergeabilityState_MERGEABILITY_STATE_BEHIND: | |
| 149 | + | return "behind" | |
| 150 | + | case ermite.MergeabilityState_MERGEABILITY_STATE_CONFLICT: | |
| 151 | + | return "conflict" | |
| 152 | + | } | |
| 153 | + | return "" | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | // toJSONMergeability maps a mergeability; nil when the server sent none. | |
| 157 | + | func toJSONMergeability(m *ermite.Mergeability) *jsonMergeability { | |
| 158 | + | if m == nil { | |
| 159 | + | return nil | |
| 160 | + | } | |
| 161 | + | return &jsonMergeability{ | |
| 162 | + | State: mergeabilityName(m.GetState()), Behind: m.GetBehind(), BehindCapped: m.GetBehindCapped(), | |
| 163 | + | ConflictPaths: append([]string{}, m.GetConflictPaths()...), ConflictPathsTruncated: m.GetConflictPathsTruncated(), | |
| 164 | + | TargetID: m.GetTargetId(), HeadID: m.GetHeadId(), | |
| 165 | + | } | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | // mergeabilityText is the human form of a mergeability: "up to date", | |
| 169 | + | // "behind by 2", "conflict (a, b, ...)", "checking". | |
| 170 | + | func mergeabilityText(m *jsonMergeability) string { | |
| 171 | + | switch m.State { | |
| 172 | + | case "up_to_date": | |
| 173 | + | return "up to date with the target, can be fast-forwarded" | |
| 174 | + | case "behind": | |
| 175 | + | return fmt.Sprintf("behind by %d%s, merges without conflicts (squash or merge commit; fast-forward needs a rebase)", m.Behind, plus(m.BehindCapped)) | |
| 176 | + | case "conflict": | |
| 177 | + | if len(m.ConflictPaths) == 0 { | |
| 178 | + | return "conflict (no common history with the target), rebase needed" | |
| 179 | + | } | |
| 180 | + | more := "" | |
| 181 | + | if m.ConflictPathsTruncated { | |
| 182 | + | more = ", ..." | |
| 183 | + | } | |
| 184 | + | return "conflict (" + strings.Join(m.ConflictPaths, ", ") + more + "), rebase needed" | |
| 185 | + | case "checking": | |
| 186 | + | return "checking" | |
| 187 | + | } | |
| 188 | + | return m.State | |
| 189 | + | } | |
| 190 | + | ||
| 125 | 191 | // jsonMerged is the output of `mr merge --json`. | |
| 126 | 192 | type jsonMerged struct { | |
| 127 | 193 | jsonMergeRequest | |
| @@ -178,9 +244,15 @@ const jsonMRHelp = ` | |||
| 178 | 244 | JSON fields of a merge request (--json): repo, url (full web URL), number, title, description, source_branch, | |
| 179 | 245 | target_branch, state ("open", "merged" or "closed"), author (token label), created_at and | |
| 180 | 246 | updated_at (RFC 3339 UTC), head_id (the commit the source branch points to; empty when the | |
| 181 | - | branch is gone), merged_commit_id and merge_mode ("fast-forward" or "squash"; both empty | |
| 247 | + | branch is gone), merged_commit_id and merge_mode ("fast-forward", "squash" or "merge"; both empty | |
| 182 | 248 | unless merged).` | |
| 183 | 249 | ||
| 250 | + | const jsonMergeabilityHelp = ` | |
| 251 | + | mr show --json adds mergeability, null unless the request is open: state ("checking", "up_to_date", | |
| 252 | + | "behind" or "conflict"), behind and behind_capped (commits of the target the head lacks; behind | |
| 253 | + | only), conflict_paths and conflict_paths_truncated (conflict only), target_id and head_id (the | |
| 254 | + | commits the result is for).` | |
| 255 | + | ||
| 184 | 256 | const jsonCommentHelp = ` | |
| 185 | 257 | JSON fields of a comment: id, author, path, line and side ("old" or "new"; empty path, line 0 | |
| 186 | 258 | and empty side for a general comment), commit_id (the head when it was written), text, | |
| @@ -417,6 +489,8 @@ type jsonCommit struct { | |||
| 417 | 489 | // jsonMRDetail is the stable JSON shape of `mr show --json`. | |
| 418 | 490 | type jsonMRDetail struct { | |
| 419 | 491 | jsonMergeRequest | |
| 492 | + | // Mergeability is null unless the request is open and the server computes it. | |
| 493 | + | Mergeability *jsonMergeability `json:"mergeability"` | |
| 420 | 494 | Commits []jsonCommit `json:"commits"` | |
| 421 | 495 | CommitsTruncated bool `json:"commits_truncated"` | |
| 422 | 496 | DiffBaseID string `json:"diff_base_id"` | |
| @@ -449,6 +523,7 @@ func lineKindName(k ermite.LineKind) string { | |||
| 449 | 523 | func toJSONDetail(d *ermite.GetMergeRequestResponse, withDiff bool, baseURL string) jsonMRDetail { | |
| 450 | 524 | out := jsonMRDetail{ | |
| 451 | 525 | jsonMergeRequest: toJSONMergeRequest(d.GetMergeRequest(), baseURL), | |
| 526 | + | Mergeability: toJSONMergeability(d.GetMergeRequest().GetMergeability()), | |
| 452 | 527 | Commits: []jsonCommit{}, CommitsTruncated: d.GetCommitsTruncated(), | |
| 453 | 528 | DiffBaseID: d.GetDiffBaseId(), | |
| 454 | 529 | Stats: jsonStats{ | |
| @@ -501,7 +576,9 @@ func newMRShowCmd(env *cliEnv) *cobra.Command { | |||
| 501 | 576 | "--since-last-read shows the diff since the head recorded by --mark-read (or by the web) instead of\n" + | |
| 502 | 577 | "the whole change; after a force-push it still works when the recorded commit exists, else the\n" + | |
| 503 | 578 | "whole change is shown and the output says so. --mark-read records the head shown as read; it needs\n" + | |
| 504 | - | "the mr:write scope." + jsonMRHelp + jsonCommentHelp + "\n" + | |
| 579 | + | "the mr:write scope. The mergeability line says whether the branch merges with its target now: up to\n" + | |
| 580 | + | "date (can be fast-forwarded), behind by N commits (merges without conflicts, squash or merge commit),\n" + | |
| 581 | + | "or in conflict with the paths that conflict (rebase needed)." + jsonMRHelp + jsonMergeabilityHelp + jsonCommentHelp + "\n" + | |
| 505 | 582 | "--json adds to the merge request fields: commits (id, author, summary), commits_truncated,\n" + | |
| 506 | 583 | "diff_base_id (the commit the diff starts from), stats (files_changed, additions, deletions),\n" + | |
| 507 | 584 | "diff_truncated, files (old_path, new_path, status, additions, deletions, binary, truncated and,\n" + | |
| @@ -574,6 +651,9 @@ func printMRDetail(w io.Writer, d *ermite.GetMergeRequestResponse, withDiff, sin | |||
| 574 | 651 | if m.GetState() == ermite.MergeRequestState_MERGE_REQUEST_STATE_MERGED { | |
| 575 | 652 | pairs = append(pairs, [2]string{"merged", orDash(m.GetMergedCommitId()) + " (" + modeName(m.GetMergeMode()) + ")"}) | |
| 576 | 653 | } | |
| 654 | + | if mg := toJSONMergeability(m.GetMergeability()); mg != nil && m.GetState() == ermite.MergeRequestState_MERGE_REQUEST_STATE_OPEN { | |
| 655 | + | pairs = append(pairs, [2]string{"mergeability", mergeabilityText(mg)}) | |
| 656 | + | } | |
| 577 | 657 | if len(d.GetVerdicts()) > 0 { | |
| 578 | 658 | var verdicts []string | |
| 579 | 659 | for _, v := range d.GetVerdicts() { | |
| @@ -962,37 +1042,52 @@ func newMRResolveCmd(env *cliEnv) *cobra.Command { | |||
| 962 | 1042 | } | |
| 963 | 1043 | ||
| 964 | 1044 | func newMRMergeCmd(env *cliEnv) *cobra.Command { | |
| 965 | - | var squash, deleteBranch bool | |
| 1045 | + | var mode string | |
| 1046 | + | var deleteBranch bool | |
| 966 | 1047 | var head string | |
| 967 | 1048 | cmd := &cobra.Command{ | |
| 968 | 1049 | Use: "merge <repo> <number>", | |
| 969 | 1050 | Short: "Merge a merge request", | |
| 970 | - | Long: "Merge an open merge request. The default is a fast-forward: the target branch moves to the head\n" + | |
| 971 | - | "and no commit is created. --squash makes one new commit with the tree of the head and the target\n" + | |
| 972 | - | "as only parent, signed by the server's key (see `ermite signing-key show`). The target must be an\n" + | |
| 973 | - | "ancestor of the head, else rebase the branch and push it again; the repository settings may allow\n" + | |
| 974 | - | "only one of the modes.\n\n" + | |
| 1051 | + | Long: "Merge an open merge request with --mode (default fast-forward; the repository settings may allow\n" + | |
| 1052 | + | "only some modes, see `ermite repo edit --merge-modes`):\n" + | |
| 1053 | + | " fast-forward the target branch moves to the head and no commit is created. The target must be\n" + | |
| 1054 | + | " an ancestor of the head, else rebase the branch and push it again, or use another mode.\n" + | |
| 1055 | + | " squash one new commit with the tree of the merge of the head and the target, the target tip\n" + | |
| 1056 | + | " as only parent. The branch must merge without conflicts.\n" + | |
| 1057 | + | " merge a merge commit with two parents, the target tip and the head. The branch must merge\n" + | |
| 1058 | + | " without conflicts.\n" + | |
| 1059 | + | "A squash and a merge commit are signed with the server's own key, generated automatically when the\n" + | |
| 1060 | + | "server first starts; `ermite signing-key show` prints it. The server never rebases. `ermite mr show`\n" + | |
| 1061 | + | "prints the mergeability (up to date, behind, conflict); the server checks again when merging and\n" + | |
| 1062 | + | "refuses a merge that is not possible, saying why.\n\n" + | |
| 975 | 1063 | "The server merges only if the source branch still points to the head you reviewed, so --head is\n" + | |
| 976 | 1064 | "required: give the full id of the head that `ermite mr show <repo> <number>` printed when you\n" + | |
| 977 | 1065 | "reviewed the change. If the branch moved since, the merge is refused: review again. Needs the\n" + | |
| 978 | 1066 | "mr:merge scope.\n\n" + | |
| 979 | - | "--delete-branch asks the server to delete the source branch right after the merge, in the\n" + | |
| 980 | - | "background. The merge is refused when the branch is the default branch or used by another open\n" + | |
| 981 | - | "merge request. If the deletion later fails (the branch moved, say), the merge stands and the\n" + | |
| 982 | - | "branch is kept." + jsonMRHelp + | |
| 1067 | + | "The merge answers as soon as the target branch has moved. --delete-branch asks the server to delete\n" + | |
| 1068 | + | "the source branch right after, in the background. The merge is refused when the branch is the\n" + | |
| 1069 | + | "default branch or used by another open merge request. If the deletion later fails (the branch\n" + | |
| 1070 | + | "moved, say), the merge stands and the branch is kept." + jsonMRHelp + | |
| 983 | 1071 | "\n--json adds source_branch_deleted (true when the deletion was requested: the server deletes the branch right after the merge).", | |
| 984 | 1072 | Args: cobra.ExactArgs(2), | |
| 985 | 1073 | ValidArgsFunction: completeMRRepoThenNumber(env), | |
| 986 | 1074 | } | |
| 987 | - | cmd.Flags().BoolVar(&squash, "squash", false, "squash the branch into one signed commit instead of a fast-forward") | |
| 1075 | + | cmd.Flags().StringVar(&mode, "mode", modeFastForward, "merge mode: fast-forward, squash or merge (a merge commit)") | |
| 988 | 1076 | cmd.Flags().StringVar(&head, "head", "", "full id of the head you reviewed, from `ermite mr show` (required)") | |
| 989 | 1077 | cmd.Flags().BoolVar(&deleteBranch, "delete-branch", false, "delete the source branch after the merge") | |
| 1078 | + | _ = cmd.RegisterFlagCompletionFunc("mode", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { | |
| 1079 | + | return []string{modeFastForward, modeSquash, modeMerge}, cobra.ShellCompDirectiveNoFileComp | |
| 1080 | + | }) | |
| 990 | 1081 | asJSON := addJSONFlag(cmd) | |
| 991 | 1082 | cmd.RunE = func(cmd *cobra.Command, args []string) error { | |
| 992 | 1083 | n, err := parseNumber(args[1]) | |
| 993 | 1084 | if err != nil { | |
| 994 | 1085 | return err | |
| 995 | 1086 | } | |
| 1087 | + | pbMode, err := parseMode(mode) | |
| 1088 | + | if err != nil { | |
| 1089 | + | return err | |
| 1090 | + | } | |
| 996 | 1091 | head = strings.TrimSpace(head) | |
| 997 | 1092 | if head == "" { | |
| 998 | 1093 | return fmt.Errorf("--head is required: run `ermite mr show %s %d` and give the head id it prints, "+ | |
| @@ -1003,12 +1098,13 @@ func newMRMergeCmd(env *cliEnv) *cobra.Command { | |||
| 1003 | 1098 | return err | |
| 1004 | 1099 | } | |
| 1005 | 1100 | ctx := env.ctx(cmd) | |
| 1006 | - | mode := ermite.MergeMode_MERGE_MODE_FAST_FORWARD | |
| 1007 | - | if squash { | |
| 1008 | - | mode = ermite.MergeMode_MERGE_MODE_SQUASH | |
| 1101 | + | if pbMode == ermite.MergeMode_MERGE_MODE_MERGE_COMMIT { | |
| 1102 | + | if err := requireFeature(ctx, c, client.FeatureMergeability, "has no merge commit mode (--mode merge)"); err != nil { | |
| 1103 | + | return err | |
| 1104 | + | } | |
| 1009 | 1105 | } | |
| 1010 | 1106 | res, err := c.MergeRequests().MergeMergeRequest(ctx, connect.NewRequest(&ermite.MergeMergeRequestRequest{ | |
| 1011 | - | Repo: args[0], Number: n, Mode: mode, HeadId: head, DeleteSourceBranch: deleteBranch, | |
| 1107 | + | Repo: args[0], Number: n, Mode: pbMode, HeadId: head, DeleteSourceBranch: deleteBranch, | |
| 1012 | 1108 | })) | |
| 1013 | 1109 | if err != nil { | |
| 1014 | 1110 | return c.Describe(err) | |
| @@ -1018,16 +1114,44 @@ func newMRMergeCmd(env *cliEnv) *cobra.Command { | |||
| 1018 | 1114 | if *asJSON { | |
| 1019 | 1115 | return writeJSON(cmd.OutOrStdout(), jsonMerged{jsonMergeRequest: toJSONMergeRequest(m, c.URL()), SourceBranchDeleted: deleted}) | |
| 1020 | 1116 | } | |
| 1021 | - | _, err = fmt.Fprintf(cmd.OutOrStdout(), "merged %s!%d into %s (%s) at %s\n", clean(m.GetRepo()), m.GetNumber(), | |
| 1117 | + | out := cmd.OutOrStdout() | |
| 1118 | + | _, err = fmt.Fprintf(out, "merged %s!%d into %s (%s) at %s\n", clean(m.GetRepo()), m.GetNumber(), | |
| 1022 | 1119 | clean(m.GetTargetBranch()), modeName(m.GetMergeMode()), shortID(m.GetMergedCommitId())) | |
| 1120 | + | if err == nil && pbMode != ermite.MergeMode_MERGE_MODE_FAST_FORWARD { | |
| 1121 | + | _, err = fmt.Fprintln(out, signedNote(ctx, c)) | |
| 1122 | + | } | |
| 1023 | 1123 | if err == nil && deleted { | |
| 1024 | - | _, err = fmt.Fprintf(cmd.OutOrStdout(), "deleting branch %s (done by the server after the merge)\n", clean(m.GetSourceBranch())) | |
| 1124 | + | _, err = fmt.Fprintf(out, "deleting branch %s (done by the server after the merge)\n", clean(m.GetSourceBranch())) | |
| 1025 | 1125 | } | |
| 1026 | 1126 | return err | |
| 1027 | 1127 | } | |
| 1028 | 1128 | return cmd | |
| 1029 | 1129 | } | |
| 1030 | 1130 | ||
| 1131 | + | // signedNote says that a squash or merge commit is signed by the server's | |
| 1132 | + | // key, with its fingerprint when the server tells it. | |
| 1133 | + | func signedNote(ctx context.Context, c *client.Client) string { | |
| 1134 | + | const how = "; `ermite signing-key show` prints the key to verify it" | |
| 1135 | + | res, err := c.Server().GetSigningKey(ctx, connect.NewRequest(&ermite.GetSigningKeyRequest{})) | |
| 1136 | + | if err != nil || res.Msg.GetFingerprint() == "" { | |
| 1137 | + | return "the commit is signed by the server's key" + how | |
| 1138 | + | } | |
| 1139 | + | return "the commit is signed by the server's key " + clean(res.Msg.GetFingerprint()) + how | |
| 1140 | + | } | |
| 1141 | + | ||
| 1142 | + | // requireFeature refuses to go on with a server that does not list the | |
| 1143 | + | // feature. A server whose features cannot be read is refused too. | |
| 1144 | + | func requireFeature(ctx context.Context, c *client.Client, feature, what string) error { | |
| 1145 | + | res, err := c.Server().GetVersion(ctx, connect.NewRequest(&ermite.GetVersionRequest{})) | |
| 1146 | + | if err != nil { | |
| 1147 | + | return c.Describe(err) | |
| 1148 | + | } | |
| 1149 | + | if !slices.Contains(res.Msg.GetFeatures(), feature) { | |
| 1150 | + | return fmt.Errorf("the server does not support %s, so it %s (upgrade the server, see `ermite version`)", feature, what) | |
| 1151 | + | } | |
| 1152 | + | return nil | |
| 1153 | + | } | |
| 1154 | + | ||
| 1031 | 1155 | func newMRCloseCmd(env *cliEnv) *cobra.Command { | |
| 1032 | 1156 | cmd := &cobra.Command{ | |
| 1033 | 1157 | Use: "close <repo> <number>", | |
| @@ -8,6 +8,7 @@ import ( | |||||
| 8 | "fmt" | 8 | "fmt" | ||
| 9 | "io" | 9 | "io" | ||
| 10 | "net/url" | 10 | "net/url" | ||
| 11 | + | "slices" | |||
| 11 | "strconv" | 12 | "strconv" | ||
| 12 | "strings" | 13 | "strings" | ||
| 13 | 14 | ||||
| @@ -122,6 +123,71 @@ type jsonMergeRequest struct { | |||||
| 122 | MergeMode string `json:"merge_mode"` | 123 | MergeMode string `json:"merge_mode"` | ||
| 123 | } | 124 | } | ||
| 124 | 125 | ||||
| 126 | + | // jsonMergeability is the stable JSON shape of the mergeability of an open | |||
| 127 | + | // merge request. | |||
| 128 | + | type jsonMergeability struct { | |||
| 129 | + | // State is "checking", "up_to_date", "behind" or "conflict". | |||
| 130 | + | State string `json:"state"` | |||
| 131 | + | // Behind counts the commits of the target the head lacks (behind only); | |||
| 132 | + | // BehindCapped says it is a lower bound. | |||
| 133 | + | Behind int32 `json:"behind"` | |||
| 134 | + | BehindCapped bool `json:"behind_capped"` | |||
| 135 | + | ConflictPaths []string `json:"conflict_paths"` | |||
| 136 | + | // ConflictPathsTruncated says that more paths conflict than listed. | |||
| 137 | + | ConflictPathsTruncated bool `json:"conflict_paths_truncated"` | |||
| 138 | + | TargetID string `json:"target_id"` | |||
| 139 | + | HeadID string `json:"head_id"` | |||
| 140 | + | } | |||
| 141 | + | ||||
| 142 | + | func mergeabilityName(s ermite.MergeabilityState) string { | |||
| 143 | + | switch s { | |||
| 144 | + | case ermite.MergeabilityState_MERGEABILITY_STATE_CHECKING: | |||
| 145 | + | return "checking" | |||
| 146 | + | case ermite.MergeabilityState_MERGEABILITY_STATE_UP_TO_DATE: | |||
| 147 | + | return "up_to_date" | |||
| 148 | + | case ermite.MergeabilityState_MERGEABILITY_STATE_BEHIND: | |||
| 149 | + | return "behind" | |||
| 150 | + | case ermite.MergeabilityState_MERGEABILITY_STATE_CONFLICT: | |||
| 151 | + | return "conflict" | |||
| 152 | + | } | |||
| 153 | + | return "" | |||
| 154 | + | } | |||
| 155 | + | ||||
| 156 | + | // toJSONMergeability maps a mergeability; nil when the server sent none. | |||
| 157 | + | func toJSONMergeability(m *ermite.Mergeability) *jsonMergeability { | |||
| 158 | + | if m == nil { | |||
| 159 | + | return nil | |||
| 160 | + | } | |||
| 161 | + | return &jsonMergeability{ | |||
| 162 | + | State: mergeabilityName(m.GetState()), Behind: m.GetBehind(), BehindCapped: m.GetBehindCapped(), | |||
| 163 | + | ConflictPaths: append([]string{}, m.GetConflictPaths()...), ConflictPathsTruncated: m.GetConflictPathsTruncated(), | |||
| 164 | + | TargetID: m.GetTargetId(), HeadID: m.GetHeadId(), | |||
| 165 | + | } | |||
| 166 | + | } | |||
| 167 | + | ||||
| 168 | + | // mergeabilityText is the human form of a mergeability: "up to date", | |||
| 169 | + | // "behind by 2", "conflict (a, b, ...)", "checking". | |||
| 170 | + | func mergeabilityText(m *jsonMergeability) string { | |||
| 171 | + | switch m.State { | |||
| 172 | + | case "up_to_date": | |||
| 173 | + | return "up to date with the target, can be fast-forwarded" | |||
| 174 | + | case "behind": | |||
| 175 | + | return fmt.Sprintf("behind by %d%s, merges without conflicts (squash or merge commit; fast-forward needs a rebase)", m.Behind, plus(m.BehindCapped)) | |||
| 176 | + | case "conflict": | |||
| 177 | + | if len(m.ConflictPaths) == 0 { | |||
| 178 | + | return "conflict (no common history with the target), rebase needed" | |||
| 179 | + | } | |||
| 180 | + | more := "" | |||
| 181 | + | if m.ConflictPathsTruncated { | |||
| 182 | + | more = ", ..." | |||
| 183 | + | } | |||
| 184 | + | return "conflict (" + strings.Join(m.ConflictPaths, ", ") + more + "), rebase needed" | |||
| 185 | + | case "checking": | |||
| 186 | + | return "checking" | |||
| 187 | + | } | |||
| 188 | + | return m.State | |||
| 189 | + | } | |||
| 190 | + | ||||
| 125 | // jsonMerged is the output of `mr merge --json`. | 191 | // jsonMerged is the output of `mr merge --json`. | ||
| 126 | type jsonMerged struct { | 192 | type jsonMerged struct { | ||
| 127 | jsonMergeRequest | 193 | jsonMergeRequest | ||
| @@ -178,9 +244,15 @@ const jsonMRHelp = ` | |||||
| 178 | JSON fields of a merge request (--json): repo, url (full web URL), number, title, description, source_branch, | 244 | JSON fields of a merge request (--json): repo, url (full web URL), number, title, description, source_branch, | ||
| 179 | target_branch, state ("open", "merged" or "closed"), author (token label), created_at and | 245 | target_branch, state ("open", "merged" or "closed"), author (token label), created_at and | ||
| 180 | updated_at (RFC 3339 UTC), head_id (the commit the source branch points to; empty when the | 246 | updated_at (RFC 3339 UTC), head_id (the commit the source branch points to; empty when the | ||
| 181 | - | branch is gone), merged_commit_id and merge_mode ("fast-forward" or "squash"; both empty | 247 | + | branch is gone), merged_commit_id and merge_mode ("fast-forward", "squash" or "merge"; both empty |
| 182 | unless merged).` | 248 | unless merged).` | ||
| 183 | 249 | ||||
| 250 | + | const jsonMergeabilityHelp = ` | |||
| 251 | + | mr show --json adds mergeability, null unless the request is open: state ("checking", "up_to_date", | |||
| 252 | + | "behind" or "conflict"), behind and behind_capped (commits of the target the head lacks; behind | |||
| 253 | + | only), conflict_paths and conflict_paths_truncated (conflict only), target_id and head_id (the | |||
| 254 | + | commits the result is for).` | |||
| 255 | + | ||||
| 184 | const jsonCommentHelp = ` | 256 | const jsonCommentHelp = ` | ||
| 185 | JSON fields of a comment: id, author, path, line and side ("old" or "new"; empty path, line 0 | 257 | JSON fields of a comment: id, author, path, line and side ("old" or "new"; empty path, line 0 | ||
| 186 | and empty side for a general comment), commit_id (the head when it was written), text, | 258 | and empty side for a general comment), commit_id (the head when it was written), text, | ||
| @@ -417,6 +489,8 @@ type jsonCommit struct { | |||||
| 417 | // jsonMRDetail is the stable JSON shape of `mr show --json`. | 489 | // jsonMRDetail is the stable JSON shape of `mr show --json`. | ||
| 418 | type jsonMRDetail struct { | 490 | type jsonMRDetail struct { | ||
| 419 | jsonMergeRequest | 491 | jsonMergeRequest | ||
| 492 | + | // Mergeability is null unless the request is open and the server computes it. | |||
| 493 | + | Mergeability *jsonMergeability `json:"mergeability"` | |||
| 420 | Commits []jsonCommit `json:"commits"` | 494 | Commits []jsonCommit `json:"commits"` | ||
| 421 | CommitsTruncated bool `json:"commits_truncated"` | 495 | CommitsTruncated bool `json:"commits_truncated"` | ||
| 422 | DiffBaseID string `json:"diff_base_id"` | 496 | DiffBaseID string `json:"diff_base_id"` | ||
| @@ -449,6 +523,7 @@ func lineKindName(k ermite.LineKind) string { | |||||
| 449 | func toJSONDetail(d *ermite.GetMergeRequestResponse, withDiff bool, baseURL string) jsonMRDetail { | 523 | func toJSONDetail(d *ermite.GetMergeRequestResponse, withDiff bool, baseURL string) jsonMRDetail { | ||
| 450 | out := jsonMRDetail{ | 524 | out := jsonMRDetail{ | ||
| 451 | jsonMergeRequest: toJSONMergeRequest(d.GetMergeRequest(), baseURL), | 525 | jsonMergeRequest: toJSONMergeRequest(d.GetMergeRequest(), baseURL), | ||
| 526 | + | Mergeability: toJSONMergeability(d.GetMergeRequest().GetMergeability()), | |||
| 452 | Commits: []jsonCommit{}, CommitsTruncated: d.GetCommitsTruncated(), | 527 | Commits: []jsonCommit{}, CommitsTruncated: d.GetCommitsTruncated(), | ||
| 453 | DiffBaseID: d.GetDiffBaseId(), | 528 | DiffBaseID: d.GetDiffBaseId(), | ||
| 454 | Stats: jsonStats{ | 529 | Stats: jsonStats{ | ||
| @@ -501,7 +576,9 @@ func newMRShowCmd(env *cliEnv) *cobra.Command { | |||||
| 501 | "--since-last-read shows the diff since the head recorded by --mark-read (or by the web) instead of\n" + | 576 | "--since-last-read shows the diff since the head recorded by --mark-read (or by the web) instead of\n" + | ||
| 502 | "the whole change; after a force-push it still works when the recorded commit exists, else the\n" + | 577 | "the whole change; after a force-push it still works when the recorded commit exists, else the\n" + | ||
| 503 | "whole change is shown and the output says so. --mark-read records the head shown as read; it needs\n" + | 578 | "whole change is shown and the output says so. --mark-read records the head shown as read; it needs\n" + | ||
| 504 | - | "the mr:write scope." + jsonMRHelp + jsonCommentHelp + "\n" + | 579 | + | "the mr:write scope. The mergeability line says whether the branch merges with its target now: up to\n" + |
| 580 | + | "date (can be fast-forwarded), behind by N commits (merges without conflicts, squash or merge commit),\n" + | |||
| 581 | + | "or in conflict with the paths that conflict (rebase needed)." + jsonMRHelp + jsonMergeabilityHelp + jsonCommentHelp + "\n" + | |||
| 505 | "--json adds to the merge request fields: commits (id, author, summary), commits_truncated,\n" + | 582 | "--json adds to the merge request fields: commits (id, author, summary), commits_truncated,\n" + | ||
| 506 | "diff_base_id (the commit the diff starts from), stats (files_changed, additions, deletions),\n" + | 583 | "diff_base_id (the commit the diff starts from), stats (files_changed, additions, deletions),\n" + | ||
| 507 | "diff_truncated, files (old_path, new_path, status, additions, deletions, binary, truncated and,\n" + | 584 | "diff_truncated, files (old_path, new_path, status, additions, deletions, binary, truncated and,\n" + | ||
| @@ -574,6 +651,9 @@ func printMRDetail(w io.Writer, d *ermite.GetMergeRequestResponse, withDiff, sin | |||||
| 574 | if m.GetState() == ermite.MergeRequestState_MERGE_REQUEST_STATE_MERGED { | 651 | if m.GetState() == ermite.MergeRequestState_MERGE_REQUEST_STATE_MERGED { | ||
| 575 | pairs = append(pairs, [2]string{"merged", orDash(m.GetMergedCommitId()) + " (" + modeName(m.GetMergeMode()) + ")"}) | 652 | pairs = append(pairs, [2]string{"merged", orDash(m.GetMergedCommitId()) + " (" + modeName(m.GetMergeMode()) + ")"}) | ||
| 576 | } | 653 | } | ||
| 654 | + | if mg := toJSONMergeability(m.GetMergeability()); mg != nil && m.GetState() == ermite.MergeRequestState_MERGE_REQUEST_STATE_OPEN { | |||
| 655 | + | pairs = append(pairs, [2]string{"mergeability", mergeabilityText(mg)}) | |||
| 656 | + | } | |||
| 577 | if len(d.GetVerdicts()) > 0 { | 657 | if len(d.GetVerdicts()) > 0 { | ||
| 578 | var verdicts []string | 658 | var verdicts []string | ||
| 579 | for _, v := range d.GetVerdicts() { | 659 | for _, v := range d.GetVerdicts() { | ||
| @@ -962,37 +1042,52 @@ func newMRResolveCmd(env *cliEnv) *cobra.Command { | |||||
| 962 | } | 1042 | } | ||
| 963 | 1043 | ||||
| 964 | func newMRMergeCmd(env *cliEnv) *cobra.Command { | 1044 | func newMRMergeCmd(env *cliEnv) *cobra.Command { | ||
| 965 | - | var squash, deleteBranch bool | 1045 | + | var mode string |
| 1046 | + | var deleteBranch bool | |||
| 966 | var head string | 1047 | var head string | ||
| 967 | cmd := &cobra.Command{ | 1048 | cmd := &cobra.Command{ | ||
| 968 | Use: "merge <repo> <number>", | 1049 | Use: "merge <repo> <number>", | ||
| 969 | Short: "Merge a merge request", | 1050 | Short: "Merge a merge request", | ||
| 970 | - | Long: "Merge an open merge request. The default is a fast-forward: the target branch moves to the head\n" + | 1051 | + | Long: "Merge an open merge request with --mode (default fast-forward; the repository settings may allow\n" + |
| 971 | - | "and no commit is created. --squash makes one new commit with the tree of the head and the target\n" + | 1052 | + | "only some modes, see `ermite repo edit --merge-modes`):\n" + |
| 972 | - | "as only parent, signed by the server's key (see `ermite signing-key show`). The target must be an\n" + | 1053 | + | " fast-forward the target branch moves to the head and no commit is created. The target must be\n" + |
| 973 | - | "ancestor of the head, else rebase the branch and push it again; the repository settings may allow\n" + | 1054 | + | " an ancestor of the head, else rebase the branch and push it again, or use another mode.\n" + |
| 974 | - | "only one of the modes.\n\n" + | 1055 | + | " squash one new commit with the tree of the merge of the head and the target, the target tip\n" + |
| 1056 | + | " as only parent. The branch must merge without conflicts.\n" + | |||
| 1057 | + | " merge a merge commit with two parents, the target tip and the head. The branch must merge\n" + | |||
| 1058 | + | " without conflicts.\n" + | |||
| 1059 | + | "A squash and a merge commit are signed with the server's own key, generated automatically when the\n" + | |||
| 1060 | + | "server first starts; `ermite signing-key show` prints it. The server never rebases. `ermite mr show`\n" + | |||
| 1061 | + | "prints the mergeability (up to date, behind, conflict); the server checks again when merging and\n" + | |||
| 1062 | + | "refuses a merge that is not possible, saying why.\n\n" + | |||
| 975 | "The server merges only if the source branch still points to the head you reviewed, so --head is\n" + | 1063 | "The server merges only if the source branch still points to the head you reviewed, so --head is\n" + | ||
| 976 | "required: give the full id of the head that `ermite mr show <repo> <number>` printed when you\n" + | 1064 | "required: give the full id of the head that `ermite mr show <repo> <number>` printed when you\n" + | ||
| 977 | "reviewed the change. If the branch moved since, the merge is refused: review again. Needs the\n" + | 1065 | "reviewed the change. If the branch moved since, the merge is refused: review again. Needs the\n" + | ||
| 978 | "mr:merge scope.\n\n" + | 1066 | "mr:merge scope.\n\n" + | ||
| 979 | - | "--delete-branch asks the server to delete the source branch right after the merge, in the\n" + | 1067 | + | "The merge answers as soon as the target branch has moved. --delete-branch asks the server to delete\n" + |
| 980 | - | "background. The merge is refused when the branch is the default branch or used by another open\n" + | 1068 | + | "the source branch right after, in the background. The merge is refused when the branch is the\n" + |
| 981 | - | "merge request. If the deletion later fails (the branch moved, say), the merge stands and the\n" + | 1069 | + | "default branch or used by another open merge request. If the deletion later fails (the branch\n" + |
| 982 | - | "branch is kept." + jsonMRHelp + | 1070 | + | "moved, say), the merge stands and the branch is kept." + jsonMRHelp + |
| 983 | "\n--json adds source_branch_deleted (true when the deletion was requested: the server deletes the branch right after the merge).", | 1071 | "\n--json adds source_branch_deleted (true when the deletion was requested: the server deletes the branch right after the merge).", | ||
| 984 | Args: cobra.ExactArgs(2), | 1072 | Args: cobra.ExactArgs(2), | ||
| 985 | ValidArgsFunction: completeMRRepoThenNumber(env), | 1073 | ValidArgsFunction: completeMRRepoThenNumber(env), | ||
| 986 | } | 1074 | } | ||
| 987 | - | cmd.Flags().BoolVar(&squash, "squash", false, "squash the branch into one signed commit instead of a fast-forward") | 1075 | + | cmd.Flags().StringVar(&mode, "mode", modeFastForward, "merge mode: fast-forward, squash or merge (a merge commit)") |
| 988 | cmd.Flags().StringVar(&head, "head", "", "full id of the head you reviewed, from `ermite mr show` (required)") | 1076 | cmd.Flags().StringVar(&head, "head", "", "full id of the head you reviewed, from `ermite mr show` (required)") | ||
| 989 | cmd.Flags().BoolVar(&deleteBranch, "delete-branch", false, "delete the source branch after the merge") | 1077 | cmd.Flags().BoolVar(&deleteBranch, "delete-branch", false, "delete the source branch after the merge") | ||
| 1078 | + | _ = cmd.RegisterFlagCompletionFunc("mode", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { | |||
| 1079 | + | return []string{modeFastForward, modeSquash, modeMerge}, cobra.ShellCompDirectiveNoFileComp | |||
| 1080 | + | }) | |||
| 990 | asJSON := addJSONFlag(cmd) | 1081 | asJSON := addJSONFlag(cmd) | ||
| 991 | cmd.RunE = func(cmd *cobra.Command, args []string) error { | 1082 | cmd.RunE = func(cmd *cobra.Command, args []string) error { | ||
| 992 | n, err := parseNumber(args[1]) | 1083 | n, err := parseNumber(args[1]) | ||
| 993 | if err != nil { | 1084 | if err != nil { | ||
| 994 | return err | 1085 | return err | ||
| 995 | } | 1086 | } | ||
| 1087 | + | pbMode, err := parseMode(mode) | |||
| 1088 | + | if err != nil { | |||
| 1089 | + | return err | |||
| 1090 | + | } | |||
| 996 | head = strings.TrimSpace(head) | 1091 | head = strings.TrimSpace(head) | ||
| 997 | if head == "" { | 1092 | if head == "" { | ||
| 998 | return fmt.Errorf("--head is required: run `ermite mr show %s %d` and give the head id it prints, "+ | 1093 | return fmt.Errorf("--head is required: run `ermite mr show %s %d` and give the head id it prints, "+ | ||
| @@ -1003,12 +1098,13 @@ func newMRMergeCmd(env *cliEnv) *cobra.Command { | |||||
| 1003 | return err | 1098 | return err | ||
| 1004 | } | 1099 | } | ||
| 1005 | ctx := env.ctx(cmd) | 1100 | ctx := env.ctx(cmd) | ||
| 1006 | - | mode := ermite.MergeMode_MERGE_MODE_FAST_FORWARD | 1101 | + | if pbMode == ermite.MergeMode_MERGE_MODE_MERGE_COMMIT { |
| 1007 | - | if squash { | 1102 | + | if err := requireFeature(ctx, c, client.FeatureMergeability, "has no merge commit mode (--mode merge)"); err != nil { |
| 1008 | - | mode = ermite.MergeMode_MERGE_MODE_SQUASH | 1103 | + | return err |
| 1104 | + | } | |||
| 1009 | } | 1105 | } | ||
| 1010 | res, err := c.MergeRequests().MergeMergeRequest(ctx, connect.NewRequest(&ermite.MergeMergeRequestRequest{ | 1106 | res, err := c.MergeRequests().MergeMergeRequest(ctx, connect.NewRequest(&ermite.MergeMergeRequestRequest{ | ||
| 1011 | - | Repo: args[0], Number: n, Mode: mode, HeadId: head, DeleteSourceBranch: deleteBranch, | 1107 | + | Repo: args[0], Number: n, Mode: pbMode, HeadId: head, DeleteSourceBranch: deleteBranch, |
| 1012 | })) | 1108 | })) | ||
| 1013 | if err != nil { | 1109 | if err != nil { | ||
| 1014 | return c.Describe(err) | 1110 | return c.Describe(err) | ||
| @@ -1018,16 +1114,44 @@ func newMRMergeCmd(env *cliEnv) *cobra.Command { | |||||
| 1018 | if *asJSON { | 1114 | if *asJSON { | ||
| 1019 | return writeJSON(cmd.OutOrStdout(), jsonMerged{jsonMergeRequest: toJSONMergeRequest(m, c.URL()), SourceBranchDeleted: deleted}) | 1115 | return writeJSON(cmd.OutOrStdout(), jsonMerged{jsonMergeRequest: toJSONMergeRequest(m, c.URL()), SourceBranchDeleted: deleted}) | ||
| 1020 | } | 1116 | } | ||
| 1021 | - | _, err = fmt.Fprintf(cmd.OutOrStdout(), "merged %s!%d into %s (%s) at %s\n", clean(m.GetRepo()), m.GetNumber(), | 1117 | + | out := cmd.OutOrStdout() |
| 1118 | + | _, err = fmt.Fprintf(out, "merged %s!%d into %s (%s) at %s\n", clean(m.GetRepo()), m.GetNumber(), | |||
| 1022 | clean(m.GetTargetBranch()), modeName(m.GetMergeMode()), shortID(m.GetMergedCommitId())) | 1119 | clean(m.GetTargetBranch()), modeName(m.GetMergeMode()), shortID(m.GetMergedCommitId())) | ||
| 1120 | + | if err == nil && pbMode != ermite.MergeMode_MERGE_MODE_FAST_FORWARD { | |||
| 1121 | + | _, err = fmt.Fprintln(out, signedNote(ctx, c)) | |||
| 1122 | + | } | |||
| 1023 | if err == nil && deleted { | 1123 | if err == nil && deleted { | ||
| 1024 | - | _, err = fmt.Fprintf(cmd.OutOrStdout(), "deleting branch %s (done by the server after the merge)\n", clean(m.GetSourceBranch())) | 1124 | + | _, err = fmt.Fprintf(out, "deleting branch %s (done by the server after the merge)\n", clean(m.GetSourceBranch())) |
| 1025 | } | 1125 | } | ||
| 1026 | return err | 1126 | return err | ||
| 1027 | } | 1127 | } | ||
| 1028 | return cmd | 1128 | return cmd | ||
| 1029 | } | 1129 | } | ||
| 1030 | 1130 | ||||
| 1131 | + | // signedNote says that a squash or merge commit is signed by the server's | |||
| 1132 | + | // key, with its fingerprint when the server tells it. | |||
| 1133 | + | func signedNote(ctx context.Context, c *client.Client) string { | |||
| 1134 | + | const how = "; `ermite signing-key show` prints the key to verify it" | |||
| 1135 | + | res, err := c.Server().GetSigningKey(ctx, connect.NewRequest(&ermite.GetSigningKeyRequest{})) | |||
| 1136 | + | if err != nil || res.Msg.GetFingerprint() == "" { | |||
| 1137 | + | return "the commit is signed by the server's key" + how | |||
| 1138 | + | } | |||
| 1139 | + | return "the commit is signed by the server's key " + clean(res.Msg.GetFingerprint()) + how | |||
| 1140 | + | } | |||
| 1141 | + | ||||
| 1142 | + | // requireFeature refuses to go on with a server that does not list the | |||
| 1143 | + | // feature. A server whose features cannot be read is refused too. | |||
| 1144 | + | func requireFeature(ctx context.Context, c *client.Client, feature, what string) error { | |||
| 1145 | + | res, err := c.Server().GetVersion(ctx, connect.NewRequest(&ermite.GetVersionRequest{})) | |||
| 1146 | + | if err != nil { | |||
| 1147 | + | return c.Describe(err) | |||
| 1148 | + | } | |||
| 1149 | + | if !slices.Contains(res.Msg.GetFeatures(), feature) { | |||
| 1150 | + | return fmt.Errorf("the server does not support %s, so it %s (upgrade the server, see `ermite version`)", feature, what) | |||
| 1151 | + | } | |||
| 1152 | + | return nil | |||
| 1153 | + | } | |||
| 1154 | + | ||||
| 1031 | func newMRCloseCmd(env *cliEnv) *cobra.Command { | 1155 | func newMRCloseCmd(env *cliEnv) *cobra.Command { | ||
| 1032 | cmd := &cobra.Command{ | 1156 | cmd := &cobra.Command{ | ||
| 1033 | Use: "close <repo> <number>", | 1157 | Use: "close <repo> <number>", | ||
M internal/app/cli_mr_e2e_test.go +5 -5
| @@ -260,19 +260,19 @@ func TestMergeRequestsCLIEndToEnd(t *testing.T) { | |||
| 260 | 260 | require.Error(t, res.err) | |
| 261 | 261 | assert.Contains(t, res.err.Error(), "precondition failed") | |
| 262 | 262 | ||
| 263 | - | // Squash, with --squash. Without --head the merge is refused before it reaches the server. | |
| 263 | + | // Squash, with --mode squash. Without --head the merge is refused before it reaches the server. | |
| 264 | 264 | c.mustGit(wt, "checkout", "-q", "-b", "feat/two", head3) | |
| 265 | 265 | c.mustGit(wt, "-c", "user.name=Cy", "-c", "user.email=cy@example.org", "commit", "-q", "--allow-empty", "-m", "empty by Cy") | |
| 266 | 266 | c.writeFile(wt, "two.txt", []byte("two\n")) | |
| 267 | 267 | c.mustGit(wt, "push", "-q", url, "feat/two") | |
| 268 | 268 | c.cli(t, secret, "mr", "create", "proj", "--source", "feat/two", "--title", "Second").ok(t) | |
| 269 | 269 | w.nextOfType(t, "merge_request_opened") | |
| 270 | - | _, stderr, code = c.bin(t, secret, "mr", "merge", "proj", "2", "--squash") | |
| 270 | + | _, stderr, code = c.bin(t, secret, "mr", "merge", "proj", "2", "--mode", "squash") | |
| 271 | 271 | requireOneLineError(t, stderr, code, "--head is required", "ermite mr show proj 2") | |
| 272 | 272 | head4 := c.bareGit("proj", "rev-parse", "feat/two") | |
| 273 | - | res = c.cli(t, secret, "mr", "merge", "proj", "2", "--squash", "--head", head4) | |
| 273 | + | res = c.cli(t, secret, "mr", "merge", "proj", "2", "--mode", "squash", "--head", head4) | |
| 274 | 274 | require.NoError(t, res.err, res.stderr) | |
| 275 | - | assert.Regexp(t, `^merged proj!2 into main \(squash\) at [0-9a-f]{10}\n$`, res.stdout) | |
| 275 | + | assert.Regexp(t, `^merged proj!2 into main \(squash\) at [0-9a-f]{10}\nthe commit is signed by the server's key SHA256:\S+; .ermite signing-key show. prints the key to verify it\n$`, res.stdout) | |
| 276 | 276 | sq := c.bareGit("proj", "rev-parse", "main") | |
| 277 | 277 | assert.Equal(t, head3, c.bareGit("proj", "rev-parse", sq+"^")) | |
| 278 | 278 | assert.Equal(t, "Second (!2)", c.bareGit("proj", "log", "-1", "--format=%s", sq)) | |
| @@ -338,7 +338,7 @@ func TestSigningKeyShowVerifiesASquashCommit(t *testing.T) { | |||
| 338 | 338 | c.writeFile(wt, "x.txt", []byte("x\n")) | |
| 339 | 339 | c.mustGit(wt, "push", "-q", url, "main", "feat") | |
| 340 | 340 | c.cli(t, adminToken, "mr", "create", "proj", "--source", "feat", "--title", "Add x").ok(t) | |
| 341 | - | res := c.cli(t, adminToken, "mr", "merge", "proj", "1", "--squash", "--head", c.bareGit("proj", "rev-parse", "feat")) | |
| 341 | + | res := c.cli(t, adminToken, "mr", "merge", "proj", "1", "--mode", "squash", "--head", c.bareGit("proj", "rev-parse", "feat")) | |
| 342 | 342 | require.NoError(t, res.err, res.stderr) | |
| 343 | 343 | sq := c.bareGit("proj", "rev-parse", "main") | |
| 344 | 344 | ||
| @@ -260,19 +260,19 @@ func TestMergeRequestsCLIEndToEnd(t *testing.T) { | |||||
| 260 | require.Error(t, res.err) | 260 | require.Error(t, res.err) | ||
| 261 | assert.Contains(t, res.err.Error(), "precondition failed") | 261 | assert.Contains(t, res.err.Error(), "precondition failed") | ||
| 262 | 262 | ||||
| 263 | - | // Squash, with --squash. Without --head the merge is refused before it reaches the server. | 263 | + | // Squash, with --mode squash. Without --head the merge is refused before it reaches the server. |
| 264 | c.mustGit(wt, "checkout", "-q", "-b", "feat/two", head3) | 264 | c.mustGit(wt, "checkout", "-q", "-b", "feat/two", head3) | ||
| 265 | c.mustGit(wt, "-c", "user.name=Cy", "-c", "user.email=cy@example.org", "commit", "-q", "--allow-empty", "-m", "empty by Cy") | 265 | c.mustGit(wt, "-c", "user.name=Cy", "-c", "user.email=cy@example.org", "commit", "-q", "--allow-empty", "-m", "empty by Cy") | ||
| 266 | c.writeFile(wt, "two.txt", []byte("two\n")) | 266 | c.writeFile(wt, "two.txt", []byte("two\n")) | ||
| 267 | c.mustGit(wt, "push", "-q", url, "feat/two") | 267 | c.mustGit(wt, "push", "-q", url, "feat/two") | ||
| 268 | c.cli(t, secret, "mr", "create", "proj", "--source", "feat/two", "--title", "Second").ok(t) | 268 | c.cli(t, secret, "mr", "create", "proj", "--source", "feat/two", "--title", "Second").ok(t) | ||
| 269 | w.nextOfType(t, "merge_request_opened") | 269 | w.nextOfType(t, "merge_request_opened") | ||
| 270 | - | _, stderr, code = c.bin(t, secret, "mr", "merge", "proj", "2", "--squash") | 270 | + | _, stderr, code = c.bin(t, secret, "mr", "merge", "proj", "2", "--mode", "squash") |
| 271 | requireOneLineError(t, stderr, code, "--head is required", "ermite mr show proj 2") | 271 | requireOneLineError(t, stderr, code, "--head is required", "ermite mr show proj 2") | ||
| 272 | head4 := c.bareGit("proj", "rev-parse", "feat/two") | 272 | head4 := c.bareGit("proj", "rev-parse", "feat/two") | ||
| 273 | - | res = c.cli(t, secret, "mr", "merge", "proj", "2", "--squash", "--head", head4) | 273 | + | res = c.cli(t, secret, "mr", "merge", "proj", "2", "--mode", "squash", "--head", head4) |
| 274 | require.NoError(t, res.err, res.stderr) | 274 | require.NoError(t, res.err, res.stderr) | ||
| 275 | - | assert.Regexp(t, `^merged proj!2 into main \(squash\) at [0-9a-f]{10}\n$`, res.stdout) | 275 | + | assert.Regexp(t, `^merged proj!2 into main \(squash\) at [0-9a-f]{10}\nthe commit is signed by the server's key SHA256:\S+; .ermite signing-key show. prints the key to verify it\n$`, res.stdout) |
| 276 | sq := c.bareGit("proj", "rev-parse", "main") | 276 | sq := c.bareGit("proj", "rev-parse", "main") | ||
| 277 | assert.Equal(t, head3, c.bareGit("proj", "rev-parse", sq+"^")) | 277 | assert.Equal(t, head3, c.bareGit("proj", "rev-parse", sq+"^")) | ||
| 278 | assert.Equal(t, "Second (!2)", c.bareGit("proj", "log", "-1", "--format=%s", sq)) | 278 | assert.Equal(t, "Second (!2)", c.bareGit("proj", "log", "-1", "--format=%s", sq)) | ||
| @@ -338,7 +338,7 @@ func TestSigningKeyShowVerifiesASquashCommit(t *testing.T) { | |||||
| 338 | c.writeFile(wt, "x.txt", []byte("x\n")) | 338 | c.writeFile(wt, "x.txt", []byte("x\n")) | ||
| 339 | c.mustGit(wt, "push", "-q", url, "main", "feat") | 339 | c.mustGit(wt, "push", "-q", url, "main", "feat") | ||
| 340 | c.cli(t, adminToken, "mr", "create", "proj", "--source", "feat", "--title", "Add x").ok(t) | 340 | c.cli(t, adminToken, "mr", "create", "proj", "--source", "feat", "--title", "Add x").ok(t) | ||
| 341 | - | res := c.cli(t, adminToken, "mr", "merge", "proj", "1", "--squash", "--head", c.bareGit("proj", "rev-parse", "feat")) | 341 | + | res := c.cli(t, adminToken, "mr", "merge", "proj", "1", "--mode", "squash", "--head", c.bareGit("proj", "rev-parse", "feat")) |
| 342 | require.NoError(t, res.err, res.stderr) | 342 | require.NoError(t, res.err, res.stderr) | ||
| 343 | sq := c.bareGit("proj", "rev-parse", "main") | 343 | sq := c.bareGit("proj", "rev-parse", "main") | ||
| 344 | 344 | ||||
M internal/app/cli_mr_test.go +80 -4
| @@ -3,6 +3,8 @@ | |||
| 3 | 3 | package app | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | + | "encoding/json" | |
| 7 | + | "regexp" | |
| 6 | 8 | "strings" | |
| 7 | 9 | "testing" | |
| 8 | 10 | ||
| @@ -174,6 +176,53 @@ func TestMRShow(t *testing.T) { | |||
| 174 | 176 | assert.Contains(t, res.stderr, "head you last read is gone") | |
| 175 | 177 | } | |
| 176 | 178 | ||
| 179 | + | // The mergeability of an open request is a line of `mr show` and a field of | |
| 180 | + | // its JSON. | |
| 181 | + | func TestMRShowMergeability(t *testing.T) { | |
| 182 | + | withState := func(mg *ermite.Mergeability) *ermite.GetMergeRequestResponse { | |
| 183 | + | m := proto.Clone(mrOne).(*ermite.MergeRequest) | |
| 184 | + | m.Mergeability = mg | |
| 185 | + | return &ermite.GetMergeRequestResponse{MergeRequest: m} | |
| 186 | + | } | |
| 187 | + | var cur *ermite.GetMergeRequestResponse | |
| 188 | + | f := &fakeAPI{getMR: func(*ermite.GetMergeRequestRequest) (*ermite.GetMergeRequestResponse, error) { return cur, nil }} | |
| 189 | + | url := f.serve(t) | |
| 190 | + | for _, tt := range []struct { | |
| 191 | + | mg *ermite.Mergeability | |
| 192 | + | line string | |
| 193 | + | }{ | |
| 194 | + | {&ermite.Mergeability{State: ermite.MergeabilityState_MERGEABILITY_STATE_UP_TO_DATE}, "up to date"}, | |
| 195 | + | {&ermite.Mergeability{State: ermite.MergeabilityState_MERGEABILITY_STATE_BEHIND, Behind: 1000, BehindCapped: true}, "behind by 1000+"}, | |
| 196 | + | {&ermite.Mergeability{State: ermite.MergeabilityState_MERGEABILITY_STATE_CONFLICT, ConflictPaths: []string{"a.txt", "b/c.go"}, ConflictPathsTruncated: true}, "conflict (a.txt, b/c.go, ...)"}, | |
| 197 | + | {&ermite.Mergeability{State: ermite.MergeabilityState_MERGEABILITY_STATE_CHECKING}, "checking"}, | |
| 198 | + | } { | |
| 199 | + | cur = withState(tt.mg) | |
| 200 | + | res := runCLI(t, envFor(url), "", "mr", "show", "alpha", "1").ok(t) | |
| 201 | + | assert.Regexp(t, `(?m)^mergeability\s+.*`+regexp.QuoteMeta(tt.line), res.stdout) | |
| 202 | + | } | |
| 203 | + | cur = withState(&ermite.Mergeability{State: ermite.MergeabilityState_MERGEABILITY_STATE_BEHIND, Behind: 2, TargetId: headA}) | |
| 204 | + | res := runCLI(t, envFor(url), "", "mr", "show", "alpha", "1", "--json").ok(t) | |
| 205 | + | var got struct { | |
| 206 | + | Mergeability *struct { | |
| 207 | + | State string `json:"state"` | |
| 208 | + | Behind int `json:"behind"` | |
| 209 | + | TargetID string `json:"target_id"` | |
| 210 | + | } `json:"mergeability"` | |
| 211 | + | } | |
| 212 | + | require.NoError(t, json.Unmarshal([]byte(res.stdout), &got)) | |
| 213 | + | require.NotNil(t, got.Mergeability) | |
| 214 | + | assert.Equal(t, "behind", got.Mergeability.State) | |
| 215 | + | assert.Equal(t, 2, got.Mergeability.Behind) | |
| 216 | + | assert.Equal(t, headA, got.Mergeability.TargetID) | |
| 217 | + | ||
| 218 | + | // No mergeability (a merged request, or an older server): no line, null. | |
| 219 | + | cur = withState(nil) | |
| 220 | + | res = runCLI(t, envFor(url), "", "mr", "show", "alpha", "1").ok(t) | |
| 221 | + | assert.NotContains(t, res.stdout, "mergeability") | |
| 222 | + | res = runCLI(t, envFor(url), "", "mr", "show", "alpha", "1", "--json").ok(t) | |
| 223 | + | assert.Regexp(t, `"mergeability":\s*null`, res.stdout) | |
| 224 | + | } | |
| 225 | + | ||
| 177 | 226 | func TestMRShowMarkRead(t *testing.T) { | |
| 178 | 227 | f := &fakeAPI{ | |
| 179 | 228 | getMR: func(*ermite.GetMergeRequestRequest) (*ermite.GetMergeRequestResponse, error) { | |
| @@ -252,6 +301,12 @@ func TestMRResolveMergeAndClose(t *testing.T) { | |||
| 252 | 301 | closeMR: func(*ermite.CloseMergeRequestRequest) (*ermite.CloseMergeRequestResponse, error) { | |
| 253 | 302 | return &ermite.CloseMergeRequestResponse{MergeRequest: closed}, nil | |
| 254 | 303 | }, | |
| 304 | + | getSigningKey: func(*ermite.GetSigningKeyRequest) (*ermite.GetSigningKeyResponse, error) { | |
| 305 | + | return &ermite.GetSigningKeyResponse{Fingerprint: "SHA256:abc"}, nil | |
| 306 | + | }, | |
| 307 | + | getVersion: func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) { | |
| 308 | + | return &ermite.GetVersionResponse{Version: "test", Features: []string{"merge_requests.mergeability"}}, nil | |
| 309 | + | }, | |
| 255 | 310 | } | |
| 256 | 311 | url := f.serve(t) | |
| 257 | 312 | ||
| @@ -265,16 +320,37 @@ func TestMRResolveMergeAndClose(t *testing.T) { | |||
| 265 | 320 | assert.False(t, reqs[1].(*ermite.ResolveCommentRequest).GetResolved()) | |
| 266 | 321 | ||
| 267 | 322 | // Merge with an explicit head and mode. | |
| 268 | - | res = runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--squash", "--head", headA).ok(t) | |
| 269 | - | assert.Equal(t, "merged alpha!1 into main (squash) at dddddddddd\n", res.stdout) | |
| 323 | + | res = runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--mode", "squash", "--head", headA).ok(t) | |
| 324 | + | assert.Equal(t, "merged alpha!1 into main (squash) at dddddddddd\n"+ | |
| 325 | + | "the commit is signed by the server's key SHA256:abc; `ermite signing-key show` prints the key to verify it\n", res.stdout) | |
| 270 | 326 | assert.Empty(t, res.stderr) | |
| 271 | 327 | calls, reqs := f.snapshot() | |
| 272 | - | assert.Equal(t, []string{"ResolveComment", "ResolveComment", "MergeMergeRequest"}, calls, "an explicit head needs no read first") | |
| 328 | + | assert.Equal(t, []string{"ResolveComment", "ResolveComment", "MergeMergeRequest", "GetSigningKey"}, calls, "an explicit head needs no read first; the signing key is for the note") | |
| 273 | 329 | m := reqs[2].(*ermite.MergeMergeRequestRequest) | |
| 274 | 330 | assert.Equal(t, ermite.MergeMode_MERGE_MODE_SQUASH, m.GetMode()) | |
| 275 | 331 | assert.Equal(t, headA, m.GetHeadId()) | |
| 276 | 332 | assert.EqualValues(t, 1, m.GetNumber()) | |
| 277 | 333 | ||
| 334 | + | // A merge commit, and a mode that does not exist. | |
| 335 | + | merged.MergeMode = ermite.MergeMode_MERGE_MODE_MERGE_COMMIT | |
| 336 | + | res = runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--mode", "merge", "--head", headA).ok(t) | |
| 337 | + | assert.Contains(t, res.stdout, "merged alpha!1 into main (merge) at dddddddddd\n") | |
| 338 | + | assert.Contains(t, res.stdout, "signed by the server's key SHA256:abc") | |
| 339 | + | _, reqs = f.snapshot() | |
| 340 | + | var lastMerge *ermite.MergeMergeRequestRequest | |
| 341 | + | for _, r := range reqs { | |
| 342 | + | if mr, ok := r.(*ermite.MergeMergeRequestRequest); ok { | |
| 343 | + | lastMerge = mr | |
| 344 | + | } | |
| 345 | + | } | |
| 346 | + | require.NotNil(t, lastMerge) | |
| 347 | + | assert.Equal(t, ermite.MergeMode_MERGE_MODE_MERGE_COMMIT, lastMerge.GetMode()) | |
| 348 | + | bad := runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--mode", "rebase", "--head", headA) | |
| 349 | + | require.Error(t, bad.err) | |
| 350 | + | assert.Contains(t, bad.err.Error(), "invalid merge mode") | |
| 351 | + | assert.Error(t, runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--squash", "--head", headA).err, "--squash is gone") | |
| 352 | + | merged.MergeMode = ermite.MergeMode_MERGE_MODE_SQUASH | |
| 353 | + | ||
| 278 | 354 | // The default mode is a fast-forward. | |
| 279 | 355 | res = runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--head", headA, "--json").ok(t) | |
| 280 | 356 | assert.Equal(t, "merged", decode[mrOut](t, res.stdout).State) | |
| @@ -299,7 +375,7 @@ func TestMRMergeNeedsTheHead(t *testing.T) { | |||
| 299 | 375 | url := f.serve(t) | |
| 300 | 376 | for _, args := range [][]string{ | |
| 301 | 377 | {"mr", "merge", "alpha", "1"}, | |
| 302 | - | {"mr", "merge", "alpha", "1", "--squash"}, | |
| 378 | + | {"mr", "merge", "alpha", "1", "--mode", "squash"}, | |
| 303 | 379 | {"mr", "merge", "alpha", "1", "--head", " "}, | |
| 304 | 380 | } { | |
| 305 | 381 | res := runCLI(t, envFor(url), "", args...) | |
| @@ -3,6 +3,8 @@ | |||||
| 3 | package app | 3 | package app | ||
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | + | "encoding/json" | |||
| 7 | + | "regexp" | |||
| 6 | "strings" | 8 | "strings" | ||
| 7 | "testing" | 9 | "testing" | ||
| 8 | 10 | ||||
| @@ -174,6 +176,53 @@ func TestMRShow(t *testing.T) { | |||||
| 174 | assert.Contains(t, res.stderr, "head you last read is gone") | 176 | assert.Contains(t, res.stderr, "head you last read is gone") | ||
| 175 | } | 177 | } | ||
| 176 | 178 | ||||
| 179 | + | // The mergeability of an open request is a line of `mr show` and a field of | |||
| 180 | + | // its JSON. | |||
| 181 | + | func TestMRShowMergeability(t *testing.T) { | |||
| 182 | + | withState := func(mg *ermite.Mergeability) *ermite.GetMergeRequestResponse { | |||
| 183 | + | m := proto.Clone(mrOne).(*ermite.MergeRequest) | |||
| 184 | + | m.Mergeability = mg | |||
| 185 | + | return &ermite.GetMergeRequestResponse{MergeRequest: m} | |||
| 186 | + | } | |||
| 187 | + | var cur *ermite.GetMergeRequestResponse | |||
| 188 | + | f := &fakeAPI{getMR: func(*ermite.GetMergeRequestRequest) (*ermite.GetMergeRequestResponse, error) { return cur, nil }} | |||
| 189 | + | url := f.serve(t) | |||
| 190 | + | for _, tt := range []struct { | |||
| 191 | + | mg *ermite.Mergeability | |||
| 192 | + | line string | |||
| 193 | + | }{ | |||
| 194 | + | {&ermite.Mergeability{State: ermite.MergeabilityState_MERGEABILITY_STATE_UP_TO_DATE}, "up to date"}, | |||
| 195 | + | {&ermite.Mergeability{State: ermite.MergeabilityState_MERGEABILITY_STATE_BEHIND, Behind: 1000, BehindCapped: true}, "behind by 1000+"}, | |||
| 196 | + | {&ermite.Mergeability{State: ermite.MergeabilityState_MERGEABILITY_STATE_CONFLICT, ConflictPaths: []string{"a.txt", "b/c.go"}, ConflictPathsTruncated: true}, "conflict (a.txt, b/c.go, ...)"}, | |||
| 197 | + | {&ermite.Mergeability{State: ermite.MergeabilityState_MERGEABILITY_STATE_CHECKING}, "checking"}, | |||
| 198 | + | } { | |||
| 199 | + | cur = withState(tt.mg) | |||
| 200 | + | res := runCLI(t, envFor(url), "", "mr", "show", "alpha", "1").ok(t) | |||
| 201 | + | assert.Regexp(t, `(?m)^mergeability\s+.*`+regexp.QuoteMeta(tt.line), res.stdout) | |||
| 202 | + | } | |||
| 203 | + | cur = withState(&ermite.Mergeability{State: ermite.MergeabilityState_MERGEABILITY_STATE_BEHIND, Behind: 2, TargetId: headA}) | |||
| 204 | + | res := runCLI(t, envFor(url), "", "mr", "show", "alpha", "1", "--json").ok(t) | |||
| 205 | + | var got struct { | |||
| 206 | + | Mergeability *struct { | |||
| 207 | + | State string `json:"state"` | |||
| 208 | + | Behind int `json:"behind"` | |||
| 209 | + | TargetID string `json:"target_id"` | |||
| 210 | + | } `json:"mergeability"` | |||
| 211 | + | } | |||
| 212 | + | require.NoError(t, json.Unmarshal([]byte(res.stdout), &got)) | |||
| 213 | + | require.NotNil(t, got.Mergeability) | |||
| 214 | + | assert.Equal(t, "behind", got.Mergeability.State) | |||
| 215 | + | assert.Equal(t, 2, got.Mergeability.Behind) | |||
| 216 | + | assert.Equal(t, headA, got.Mergeability.TargetID) | |||
| 217 | + | ||||
| 218 | + | // No mergeability (a merged request, or an older server): no line, null. | |||
| 219 | + | cur = withState(nil) | |||
| 220 | + | res = runCLI(t, envFor(url), "", "mr", "show", "alpha", "1").ok(t) | |||
| 221 | + | assert.NotContains(t, res.stdout, "mergeability") | |||
| 222 | + | res = runCLI(t, envFor(url), "", "mr", "show", "alpha", "1", "--json").ok(t) | |||
| 223 | + | assert.Regexp(t, `"mergeability":\s*null`, res.stdout) | |||
| 224 | + | } | |||
| 225 | + | ||||
| 177 | func TestMRShowMarkRead(t *testing.T) { | 226 | func TestMRShowMarkRead(t *testing.T) { | ||
| 178 | f := &fakeAPI{ | 227 | f := &fakeAPI{ | ||
| 179 | getMR: func(*ermite.GetMergeRequestRequest) (*ermite.GetMergeRequestResponse, error) { | 228 | getMR: func(*ermite.GetMergeRequestRequest) (*ermite.GetMergeRequestResponse, error) { | ||
| @@ -252,6 +301,12 @@ func TestMRResolveMergeAndClose(t *testing.T) { | |||||
| 252 | closeMR: func(*ermite.CloseMergeRequestRequest) (*ermite.CloseMergeRequestResponse, error) { | 301 | closeMR: func(*ermite.CloseMergeRequestRequest) (*ermite.CloseMergeRequestResponse, error) { | ||
| 253 | return &ermite.CloseMergeRequestResponse{MergeRequest: closed}, nil | 302 | return &ermite.CloseMergeRequestResponse{MergeRequest: closed}, nil | ||
| 254 | }, | 303 | }, | ||
| 304 | + | getSigningKey: func(*ermite.GetSigningKeyRequest) (*ermite.GetSigningKeyResponse, error) { | |||
| 305 | + | return &ermite.GetSigningKeyResponse{Fingerprint: "SHA256:abc"}, nil | |||
| 306 | + | }, | |||
| 307 | + | getVersion: func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) { | |||
| 308 | + | return &ermite.GetVersionResponse{Version: "test", Features: []string{"merge_requests.mergeability"}}, nil | |||
| 309 | + | }, | |||
| 255 | } | 310 | } | ||
| 256 | url := f.serve(t) | 311 | url := f.serve(t) | ||
| 257 | 312 | ||||
| @@ -265,16 +320,37 @@ func TestMRResolveMergeAndClose(t *testing.T) { | |||||
| 265 | assert.False(t, reqs[1].(*ermite.ResolveCommentRequest).GetResolved()) | 320 | assert.False(t, reqs[1].(*ermite.ResolveCommentRequest).GetResolved()) | ||
| 266 | 321 | ||||
| 267 | // Merge with an explicit head and mode. | 322 | // Merge with an explicit head and mode. | ||
| 268 | - | res = runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--squash", "--head", headA).ok(t) | 323 | + | res = runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--mode", "squash", "--head", headA).ok(t) |
| 269 | - | assert.Equal(t, "merged alpha!1 into main (squash) at dddddddddd\n", res.stdout) | 324 | + | assert.Equal(t, "merged alpha!1 into main (squash) at dddddddddd\n"+ |
| 325 | + | "the commit is signed by the server's key SHA256:abc; `ermite signing-key show` prints the key to verify it\n", res.stdout) | |||
| 270 | assert.Empty(t, res.stderr) | 326 | assert.Empty(t, res.stderr) | ||
| 271 | calls, reqs := f.snapshot() | 327 | calls, reqs := f.snapshot() | ||
| 272 | - | assert.Equal(t, []string{"ResolveComment", "ResolveComment", "MergeMergeRequest"}, calls, "an explicit head needs no read first") | 328 | + | assert.Equal(t, []string{"ResolveComment", "ResolveComment", "MergeMergeRequest", "GetSigningKey"}, calls, "an explicit head needs no read first; the signing key is for the note") |
| 273 | m := reqs[2].(*ermite.MergeMergeRequestRequest) | 329 | m := reqs[2].(*ermite.MergeMergeRequestRequest) | ||
| 274 | assert.Equal(t, ermite.MergeMode_MERGE_MODE_SQUASH, m.GetMode()) | 330 | assert.Equal(t, ermite.MergeMode_MERGE_MODE_SQUASH, m.GetMode()) | ||
| 275 | assert.Equal(t, headA, m.GetHeadId()) | 331 | assert.Equal(t, headA, m.GetHeadId()) | ||
| 276 | assert.EqualValues(t, 1, m.GetNumber()) | 332 | assert.EqualValues(t, 1, m.GetNumber()) | ||
| 277 | 333 | ||||
| 334 | + | // A merge commit, and a mode that does not exist. | |||
| 335 | + | merged.MergeMode = ermite.MergeMode_MERGE_MODE_MERGE_COMMIT | |||
| 336 | + | res = runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--mode", "merge", "--head", headA).ok(t) | |||
| 337 | + | assert.Contains(t, res.stdout, "merged alpha!1 into main (merge) at dddddddddd\n") | |||
| 338 | + | assert.Contains(t, res.stdout, "signed by the server's key SHA256:abc") | |||
| 339 | + | _, reqs = f.snapshot() | |||
| 340 | + | var lastMerge *ermite.MergeMergeRequestRequest | |||
| 341 | + | for _, r := range reqs { | |||
| 342 | + | if mr, ok := r.(*ermite.MergeMergeRequestRequest); ok { | |||
| 343 | + | lastMerge = mr | |||
| 344 | + | } | |||
| 345 | + | } | |||
| 346 | + | require.NotNil(t, lastMerge) | |||
| 347 | + | assert.Equal(t, ermite.MergeMode_MERGE_MODE_MERGE_COMMIT, lastMerge.GetMode()) | |||
| 348 | + | bad := runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--mode", "rebase", "--head", headA) | |||
| 349 | + | require.Error(t, bad.err) | |||
| 350 | + | assert.Contains(t, bad.err.Error(), "invalid merge mode") | |||
| 351 | + | assert.Error(t, runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--squash", "--head", headA).err, "--squash is gone") | |||
| 352 | + | merged.MergeMode = ermite.MergeMode_MERGE_MODE_SQUASH | |||
| 353 | + | ||||
| 278 | // The default mode is a fast-forward. | 354 | // The default mode is a fast-forward. | ||
| 279 | res = runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--head", headA, "--json").ok(t) | 355 | res = runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--head", headA, "--json").ok(t) | ||
| 280 | assert.Equal(t, "merged", decode[mrOut](t, res.stdout).State) | 356 | assert.Equal(t, "merged", decode[mrOut](t, res.stdout).State) | ||
| @@ -299,7 +375,7 @@ func TestMRMergeNeedsTheHead(t *testing.T) { | |||||
| 299 | url := f.serve(t) | 375 | url := f.serve(t) | ||
| 300 | for _, args := range [][]string{ | 376 | for _, args := range [][]string{ | ||
| 301 | {"mr", "merge", "alpha", "1"}, | 377 | {"mr", "merge", "alpha", "1"}, | ||
| 302 | - | {"mr", "merge", "alpha", "1", "--squash"}, | 378 | + | {"mr", "merge", "alpha", "1", "--mode", "squash"}, |
| 303 | {"mr", "merge", "alpha", "1", "--head", " "}, | 379 | {"mr", "merge", "alpha", "1", "--head", " "}, | ||
| 304 | } { | 380 | } { | ||
| 305 | res := runCLI(t, envFor(url), "", args...) | 381 | res := runCLI(t, envFor(url), "", args...) | ||
M internal/app/cli_repo.go +9 -7
| @@ -33,7 +33,7 @@ type jsonRepo struct { | |||
| 33 | 33 | DefaultBranch string `json:"default_branch"` | |
| 34 | 34 | CreatedAt *string `json:"created_at"` | |
| 35 | 35 | PushedAt *string `json:"pushed_at"` | |
| 36 | - | // MergeModes are the allowed merge modes ("fast-forward", "squash"). | |
| 36 | + | // MergeModes are the allowed merge modes ("fast-forward", "squash", "merge"). | |
| 37 | 37 | MergeModes []string `json:"merge_modes"` | |
| 38 | 38 | FailingChecksBlock bool `json:"failing_checks_block"` | |
| 39 | 39 | // Issues is the issue mode ("off", "owner" or "public"). | |
| @@ -114,8 +114,8 @@ func parseVisibility(s string) (ermite.Visibility, error) { | |||
| 114 | 114 | const jsonRepoHelp = ` | |
| 115 | 115 | JSON fields of a repository (--json): name, description, visibility ("public" or | |
| 116 | 116 | "private"), default_branch, created_at and pushed_at (RFC 3339 UTC; pushed_at is | |
| 117 | - | null until the first push), merge_modes (array of the allowed merge modes, "fast-forward" and/or | |
| 118 | - | "squash"), failing_checks_block (whether a failing check prevents a merge) and issues (the issue | |
| 117 | + | null until the first push), merge_modes (array of the allowed merge modes, "fast-forward", "squash" and/or | |
| 118 | + | "merge"), failing_checks_block (whether a failing check prevents a merge) and issues (the issue | |
| 119 | 119 | mode: "off", "owner" or "public").` | |
| 120 | 120 | ||
| 121 | 121 | func newRepoCmd(env *cliEnv) *cobra.Command { | |
| @@ -315,7 +315,9 @@ func newRepoEditCmd(env *cliEnv) *cobra.Command { | |||
| 315 | 315 | Short: "Change a repository's description, topics, visibility, default branch, merge settings or issues", | |
| 316 | 316 | Long: "Change the fields given as flags and leave the others alone; --description \"\" clears the\n" + | |
| 317 | 317 | "description and --topics \"\" clears the topics. The default branch must already exist. --merge-modes lists the modes a merge request\n" + | |
| 318 | - | "may be merged with (fast-forward, squash; default for a new repository: both), and\n" + | |
| 318 | + | "may be merged with (fast-forward, squash, merge; every mode is allowed by default, so to disable one,\n" + | |
| 319 | + | "list the others: --merge-modes fast-forward,squash disables the merge commit; squash and merge\n" + | |
| 320 | + | "commits are signed with the server's key, see `ermite signing-key show`), and\n" + | |
| 319 | 321 | "--failing-checks-block says whether a failing check prevents a merge (a failed or unfinished CI check of the head; see README, CI).\n" + | |
| 320 | 322 | "--issues sets who may use issues: off (default), owner (the owner and tokens with issue:write) or\n" + | |
| 321 | 323 | "public (visitors of a public repository may also propose issues through a moderated form).\nNeeds a server with the issues feature.\n" + | |
| @@ -327,7 +329,7 @@ func newRepoEditCmd(env *cliEnv) *cobra.Command { | |||
| 327 | 329 | cmd.Flags().StringSliceVar(&topics, "topics", nil, "new topics, comma-separated (empty clears them)") | |
| 328 | 330 | cmd.Flags().StringVar(&visibility, "visibility", "", "new visibility: public or private") | |
| 329 | 331 | cmd.Flags().StringVar(&defaultBranch, "default-branch", "", "short name of the new default branch") | |
| 330 | - | cmd.Flags().StringSliceVar(&mergeModes, "merge-modes", nil, "allowed merge modes, comma-separated: fast-forward, squash") | |
| 332 | + | 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 | 333 | cmd.Flags().BoolVar(&failingChecksBlock, "failing-checks-block", true, "a failing check prevents a merge (--failing-checks-block=false to allow it)") | |
| 332 | 334 | cmd.Flags().StringVar(&issues, "issues", "", "issue mode: off, owner or public") | |
| 333 | 335 | _ = cmd.RegisterFlagCompletionFunc("issues", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { | |
| @@ -335,7 +337,7 @@ func newRepoEditCmd(env *cliEnv) *cobra.Command { | |||
| 335 | 337 | }) | |
| 336 | 338 | _ = cmd.RegisterFlagCompletionFunc("visibility", visibilityCompletion) | |
| 337 | 339 | _ = cmd.RegisterFlagCompletionFunc("merge-modes", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { | |
| 338 | - | return []string{modeFastForward, modeSquash}, cobra.ShellCompDirectiveNoFileComp | |
| 340 | + | return []string{modeFastForward, modeSquash, modeMerge}, cobra.ShellCompDirectiveNoFileComp | |
| 339 | 341 | }) | |
| 340 | 342 | asJSON := addJSONFlag(cmd) | |
| 341 | 343 | cmd.RunE = func(cmd *cobra.Command, args []string) error { | |
| @@ -400,7 +402,7 @@ func buildUpdateRepo(cmd *cobra.Command, name, description string, topics []stri | |||
| 400 | 402 | modes = append(modes, mode) | |
| 401 | 403 | } | |
| 402 | 404 | if len(modes) == 0 { | |
| 403 | - | return nil, errors.New("--merge-modes must name at least one mode: fast-forward, squash") | |
| 405 | + | return nil, errors.New("--merge-modes must name at least one mode: fast-forward, squash, merge") | |
| 404 | 406 | } | |
| 405 | 407 | req.Settings = &ermite.RepoSettings{AllowedMergeModes: modes} | |
| 406 | 408 | paths = append(paths, "settings.allowed_merge_modes") | |
| @@ -33,7 +33,7 @@ type jsonRepo struct { | |||||
| 33 | DefaultBranch string `json:"default_branch"` | 33 | DefaultBranch string `json:"default_branch"` | ||
| 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"). | 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 | FailingChecksBlock bool `json:"failing_checks_block"` | ||
| 39 | // Issues is the issue mode ("off", "owner" or "public"). | 39 | // Issues is the issue mode ("off", "owner" or "public"). | ||
| @@ -114,8 +114,8 @@ func parseVisibility(s string) (ermite.Visibility, error) { | |||||
| 114 | const jsonRepoHelp = ` | 114 | const jsonRepoHelp = ` | ||
| 115 | JSON fields of a repository (--json): name, description, visibility ("public" or | 115 | JSON fields of a repository (--json): name, description, visibility ("public" or | ||
| 116 | "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 | ||
| 117 | - | null until the first push), merge_modes (array of the allowed merge modes, "fast-forward" and/or | 117 | + | null until the first push), merge_modes (array of the allowed merge modes, "fast-forward", "squash" and/or |
| 118 | - | "squash"), failing_checks_block (whether a failing check prevents a merge) and issues (the issue | 118 | + | "merge"), failing_checks_block (whether a failing check prevents a merge) and issues (the issue |
| 119 | mode: "off", "owner" or "public").` | 119 | mode: "off", "owner" or "public").` | ||
| 120 | 120 | ||||
| 121 | func newRepoCmd(env *cliEnv) *cobra.Command { | 121 | func newRepoCmd(env *cliEnv) *cobra.Command { | ||
| @@ -315,7 +315,9 @@ func newRepoEditCmd(env *cliEnv) *cobra.Command { | |||||
| 315 | Short: "Change a repository's description, topics, visibility, default branch, merge settings or issues", | 315 | Short: "Change a repository's description, topics, visibility, default branch, merge settings or issues", | ||
| 316 | Long: "Change the fields given as flags and leave the others alone; --description \"\" clears the\n" + | 316 | Long: "Change the fields given as flags and leave the others alone; --description \"\" clears the\n" + | ||
| 317 | "description and --topics \"\" clears the topics. The default branch must already exist. --merge-modes lists the modes a merge request\n" + | 317 | "description and --topics \"\" clears the topics. The default branch must already exist. --merge-modes lists the modes a merge request\n" + | ||
| 318 | - | "may be merged with (fast-forward, squash; default for a new repository: both), and\n" + | 318 | + | "may be merged with (fast-forward, squash, merge; every mode is allowed by default, so to disable one,\n" + |
| 319 | + | "list the others: --merge-modes fast-forward,squash disables the merge commit; squash and merge\n" + | |||
| 320 | + | "commits are signed with the server's key, see `ermite signing-key show`), and\n" + | |||
| 319 | "--failing-checks-block says whether a failing check prevents a merge (a failed or unfinished CI check of the head; see README, CI).\n" + | 321 | "--failing-checks-block says whether a failing check prevents a merge (a failed or unfinished CI check of the head; see README, CI).\n" + | ||
| 320 | "--issues sets who may use issues: off (default), owner (the owner and tokens with issue:write) or\n" + | 322 | "--issues sets who may use issues: off (default), owner (the owner and tokens with issue:write) or\n" + | ||
| 321 | "public (visitors of a public repository may also propose issues through a moderated form).\nNeeds a server with the issues feature.\n" + | 323 | "public (visitors of a public repository may also propose issues through a moderated form).\nNeeds a server with the issues feature.\n" + | ||
| @@ -327,7 +329,7 @@ func newRepoEditCmd(env *cliEnv) *cobra.Command { | |||||
| 327 | cmd.Flags().StringSliceVar(&topics, "topics", nil, "new topics, comma-separated (empty clears them)") | 329 | cmd.Flags().StringSliceVar(&topics, "topics", nil, "new topics, comma-separated (empty clears them)") | ||
| 328 | cmd.Flags().StringVar(&visibility, "visibility", "", "new visibility: public or private") | 330 | cmd.Flags().StringVar(&visibility, "visibility", "", "new visibility: public or private") | ||
| 329 | cmd.Flags().StringVar(&defaultBranch, "default-branch", "", "short name of the new default branch") | 331 | cmd.Flags().StringVar(&defaultBranch, "default-branch", "", "short name of the new default branch") | ||
| 330 | - | cmd.Flags().StringSliceVar(&mergeModes, "merge-modes", nil, "allowed merge modes, comma-separated: fast-forward, squash") | 332 | + | 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().BoolVar(&failingChecksBlock, "failing-checks-block", true, "a failing check prevents a merge (--failing-checks-block=false to allow it)") | 333 | cmd.Flags().BoolVar(&failingChecksBlock, "failing-checks-block", true, "a failing check prevents a merge (--failing-checks-block=false to allow it)") | ||
| 332 | cmd.Flags().StringVar(&issues, "issues", "", "issue mode: off, owner or public") | 334 | cmd.Flags().StringVar(&issues, "issues", "", "issue mode: off, owner or public") | ||
| 333 | _ = cmd.RegisterFlagCompletionFunc("issues", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { | 335 | _ = cmd.RegisterFlagCompletionFunc("issues", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { | ||
| @@ -335,7 +337,7 @@ func newRepoEditCmd(env *cliEnv) *cobra.Command { | |||||
| 335 | }) | 337 | }) | ||
| 336 | _ = cmd.RegisterFlagCompletionFunc("visibility", visibilityCompletion) | 338 | _ = cmd.RegisterFlagCompletionFunc("visibility", visibilityCompletion) | ||
| 337 | _ = cmd.RegisterFlagCompletionFunc("merge-modes", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { | 339 | _ = cmd.RegisterFlagCompletionFunc("merge-modes", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { | ||
| 338 | - | return []string{modeFastForward, modeSquash}, cobra.ShellCompDirectiveNoFileComp | 340 | + | return []string{modeFastForward, modeSquash, modeMerge}, cobra.ShellCompDirectiveNoFileComp |
| 339 | }) | 341 | }) | ||
| 340 | asJSON := addJSONFlag(cmd) | 342 | asJSON := addJSONFlag(cmd) | ||
| 341 | cmd.RunE = func(cmd *cobra.Command, args []string) error { | 343 | cmd.RunE = func(cmd *cobra.Command, args []string) error { | ||
| @@ -400,7 +402,7 @@ func buildUpdateRepo(cmd *cobra.Command, name, description string, topics []stri | |||||
| 400 | modes = append(modes, mode) | 402 | modes = append(modes, mode) | ||
| 401 | } | 403 | } | ||
| 402 | if len(modes) == 0 { | 404 | if len(modes) == 0 { | ||
| 403 | - | return nil, errors.New("--merge-modes must name at least one mode: fast-forward, squash") | 405 | + | return nil, errors.New("--merge-modes must name at least one mode: fast-forward, squash, merge") |
| 404 | } | 406 | } | ||
| 405 | req.Settings = &ermite.RepoSettings{AllowedMergeModes: modes} | 407 | req.Settings = &ermite.RepoSettings{AllowedMergeModes: modes} | ||
| 406 | paths = append(paths, "settings.allowed_merge_modes") | 408 | paths = append(paths, "settings.allowed_merge_modes") | ||
M internal/app/cli_watch.go +26 -4
| @@ -29,8 +29,8 @@ type jsonEvent struct { | |||
| 29 | 29 | MergeRequest *jsonMergeRequest `json:"merge_request,omitempty"` | |
| 30 | 30 | Number int64 `json:"number,omitempty"` | |
| 31 | 31 | Comment *jsonComment `json:"comment,omitempty"` | |
| 32 | - | // Mergeability is the state of a merge_request_mergeability event. | |
| 33 | - | Mergeability string `json:"mergeability,omitempty"` | |
| 32 | + | // Mergeability is set for a merge_request_mergeability event. | |
| 33 | + | Mergeability *jsonMergeability `json:"mergeability,omitempty"` | |
| 34 | 34 | } | |
| 35 | 35 | ||
| 36 | 36 | type jsonRefUpdate struct { | |
| @@ -99,7 +99,7 @@ func toJSONEvent(ev *ermite.Event, baseURL string) (jsonEvent, bool) { | |||
| 99 | 99 | out.Type, out.Repo, out.Number, out.Comment = eventCommentResolved, k.CommentResolved.GetRepo(), k.CommentResolved.GetNumber(), &c | |
| 100 | 100 | case *ermite.Event_MergeRequestMergeability: | |
| 101 | 101 | out.Type, out.Repo, out.Number = eventMRMergeability, k.MergeRequestMergeability.GetRepo(), k.MergeRequestMergeability.GetNumber() | |
| 102 | - | out.Mergeability = k.MergeRequestMergeability.GetMergeability().GetState().String() | |
| 102 | + | out.Mergeability = toJSONMergeability(k.MergeRequestMergeability.GetMergeability()) | |
| 103 | 103 | case *ermite.Event_Gap: | |
| 104 | 104 | out.Type = eventGap | |
| 105 | 105 | default: | |
| @@ -133,13 +133,35 @@ func eventLine(e jsonEvent) string { | |||
| 133 | 133 | case eventCommentAdded, eventCommentEdited, eventCommentDeleted, eventCommentResolved: | |
| 134 | 134 | detail = fmt.Sprintf("%s!%d by %s", e.Repo, e.Number, e.Comment.Author) | |
| 135 | 135 | case eventMRMergeability: | |
| 136 | - | detail = fmt.Sprintf("%s!%d %s", e.Repo, e.Number, e.Mergeability) | |
| 136 | + | detail = fmt.Sprintf("%s!%d mergeability: %s", e.Repo, e.Number, mergeabilityEventText(e.Mergeability)) | |
| 137 | 137 | case eventGap: | |
| 138 | 138 | detail = "events were lost, reload what you display" | |
| 139 | 139 | } | |
| 140 | 140 | return clean(ts + " " + e.Type + " " + detail) | |
| 141 | 141 | } | |
| 142 | 142 | ||
| 143 | + | // mergeabilityEventText is the short form of a mergeability for a watch line: | |
| 144 | + | // "up_to_date", "behind by 2", "conflict (a, b)", "checking". | |
| 145 | + | func mergeabilityEventText(m *jsonMergeability) string { | |
| 146 | + | if m == nil { | |
| 147 | + | return "unknown" | |
| 148 | + | } | |
| 149 | + | switch m.State { | |
| 150 | + | case "behind": | |
| 151 | + | return fmt.Sprintf("behind by %d%s", m.Behind, plus(m.BehindCapped)) | |
| 152 | + | case "conflict": | |
| 153 | + | if len(m.ConflictPaths) == 0 { | |
| 154 | + | return "conflict (no common history)" | |
| 155 | + | } | |
| 156 | + | more := "" | |
| 157 | + | if m.ConflictPathsTruncated { | |
| 158 | + | more = ", ..." | |
| 159 | + | } | |
| 160 | + | return "conflict (" + strings.Join(m.ConflictPaths, ", ") + more + ")" | |
| 161 | + | } | |
| 162 | + | return m.State | |
| 163 | + | } | |
| 164 | + | ||
| 143 | 165 | func refUpdateText(u jsonRefUpdate) string { | |
| 144 | 166 | short := func(id string) string { | |
| 145 | 167 | if len(id) > 8 { | |
| @@ -29,8 +29,8 @@ type jsonEvent struct { | |||||
| 29 | MergeRequest *jsonMergeRequest `json:"merge_request,omitempty"` | 29 | MergeRequest *jsonMergeRequest `json:"merge_request,omitempty"` | ||
| 30 | Number int64 `json:"number,omitempty"` | 30 | Number int64 `json:"number,omitempty"` | ||
| 31 | Comment *jsonComment `json:"comment,omitempty"` | 31 | Comment *jsonComment `json:"comment,omitempty"` | ||
| 32 | - | // Mergeability is the state of a merge_request_mergeability event. | 32 | + | // Mergeability is set for a merge_request_mergeability event. |
| 33 | - | Mergeability string `json:"mergeability,omitempty"` | 33 | + | Mergeability *jsonMergeability `json:"mergeability,omitempty"` |
| 34 | } | 34 | } | ||
| 35 | 35 | ||||
| 36 | type jsonRefUpdate struct { | 36 | type jsonRefUpdate struct { | ||
| @@ -99,7 +99,7 @@ func toJSONEvent(ev *ermite.Event, baseURL string) (jsonEvent, bool) { | |||||
| 99 | out.Type, out.Repo, out.Number, out.Comment = eventCommentResolved, k.CommentResolved.GetRepo(), k.CommentResolved.GetNumber(), &c | 99 | out.Type, out.Repo, out.Number, out.Comment = eventCommentResolved, k.CommentResolved.GetRepo(), k.CommentResolved.GetNumber(), &c | ||
| 100 | case *ermite.Event_MergeRequestMergeability: | 100 | case *ermite.Event_MergeRequestMergeability: | ||
| 101 | out.Type, out.Repo, out.Number = eventMRMergeability, k.MergeRequestMergeability.GetRepo(), k.MergeRequestMergeability.GetNumber() | 101 | out.Type, out.Repo, out.Number = eventMRMergeability, k.MergeRequestMergeability.GetRepo(), k.MergeRequestMergeability.GetNumber() | ||
| 102 | - | out.Mergeability = k.MergeRequestMergeability.GetMergeability().GetState().String() | 102 | + | out.Mergeability = toJSONMergeability(k.MergeRequestMergeability.GetMergeability()) |
| 103 | case *ermite.Event_Gap: | 103 | case *ermite.Event_Gap: | ||
| 104 | out.Type = eventGap | 104 | out.Type = eventGap | ||
| 105 | default: | 105 | default: | ||
| @@ -133,13 +133,35 @@ func eventLine(e jsonEvent) string { | |||||
| 133 | case eventCommentAdded, eventCommentEdited, eventCommentDeleted, eventCommentResolved: | 133 | case eventCommentAdded, eventCommentEdited, eventCommentDeleted, eventCommentResolved: | ||
| 134 | detail = fmt.Sprintf("%s!%d by %s", e.Repo, e.Number, e.Comment.Author) | 134 | detail = fmt.Sprintf("%s!%d by %s", e.Repo, e.Number, e.Comment.Author) | ||
| 135 | case eventMRMergeability: | 135 | case eventMRMergeability: | ||
| 136 | - | detail = fmt.Sprintf("%s!%d %s", e.Repo, e.Number, e.Mergeability) | 136 | + | detail = fmt.Sprintf("%s!%d mergeability: %s", e.Repo, e.Number, mergeabilityEventText(e.Mergeability)) |
| 137 | case eventGap: | 137 | case eventGap: | ||
| 138 | detail = "events were lost, reload what you display" | 138 | detail = "events were lost, reload what you display" | ||
| 139 | } | 139 | } | ||
| 140 | return clean(ts + " " + e.Type + " " + detail) | 140 | return clean(ts + " " + e.Type + " " + detail) | ||
| 141 | } | 141 | } | ||
| 142 | 142 | ||||
| 143 | + | // mergeabilityEventText is the short form of a mergeability for a watch line: | |||
| 144 | + | // "up_to_date", "behind by 2", "conflict (a, b)", "checking". | |||
| 145 | + | func mergeabilityEventText(m *jsonMergeability) string { | |||
| 146 | + | if m == nil { | |||
| 147 | + | return "unknown" | |||
| 148 | + | } | |||
| 149 | + | switch m.State { | |||
| 150 | + | case "behind": | |||
| 151 | + | return fmt.Sprintf("behind by %d%s", m.Behind, plus(m.BehindCapped)) | |||
| 152 | + | case "conflict": | |||
| 153 | + | if len(m.ConflictPaths) == 0 { | |||
| 154 | + | return "conflict (no common history)" | |||
| 155 | + | } | |||
| 156 | + | more := "" | |||
| 157 | + | if m.ConflictPathsTruncated { | |||
| 158 | + | more = ", ..." | |||
| 159 | + | } | |||
| 160 | + | return "conflict (" + strings.Join(m.ConflictPaths, ", ") + more + ")" | |||
| 161 | + | } | |||
| 162 | + | return m.State | |||
| 163 | + | } | |||
| 164 | + | ||||
| 143 | func refUpdateText(u jsonRefUpdate) string { | 165 | func refUpdateText(u jsonRefUpdate) string { | ||
| 144 | short := func(id string) string { | 166 | short := func(id string) string { | ||
| 145 | if len(id) > 8 { | 167 | if len(id) > 8 { | ||
M internal/app/cli_watch_test.go +43 -0
| @@ -200,6 +200,49 @@ func TestWatchMergeRequestEvents(t *testing.T) { | |||
| 200 | 200 | assert.Equal(t, "merge_request_closed", decode[eventOut](t, lines[3]).Type) | |
| 201 | 201 | } | |
| 202 | 202 | ||
| 203 | + | // A mergeability event shows the state, with how far behind or the paths. | |
| 204 | + | func TestWatchMergeabilityEvents(t *testing.T) { | |
| 205 | + | mg := func(m *ermite.Mergeability) func(*ermite.Event) { | |
| 206 | + | return func(e *ermite.Event) { | |
| 207 | + | e.Kind = &ermite.Event_MergeRequestMergeability{MergeRequestMergeability: &ermite.MergeRequestMergeability{Repo: "alpha", Number: 1, Mergeability: m}} | |
| 208 | + | } | |
| 209 | + | } | |
| 210 | + | events := []*ermite.WatchResponse{ | |
| 211 | + | evt("1", mg(&ermite.Mergeability{State: ermite.MergeabilityState_MERGEABILITY_STATE_UP_TO_DATE})), | |
| 212 | + | evt("2", mg(&ermite.Mergeability{State: ermite.MergeabilityState_MERGEABILITY_STATE_BEHIND, Behind: 2})), | |
| 213 | + | evt("3", mg(&ermite.Mergeability{State: ermite.MergeabilityState_MERGEABILITY_STATE_CONFLICT, ConflictPaths: []string{"a.txt", "b.txt"}})), | |
| 214 | + | } | |
| 215 | + | f := &fakeAPI{watch: finishing(events)} | |
| 216 | + | url := f.serve(t) | |
| 217 | + | ||
| 218 | + | res := runCLI(t, envFor(url), "", "watch") | |
| 219 | + | require.Error(t, res.err) | |
| 220 | + | assert.Equal(t, ""+ | |
| 221 | + | "2026-10-10T12:00:00Z merge_request_mergeability alpha!1 mergeability: up_to_date\n"+ | |
| 222 | + | "2026-10-10T12:00:00Z merge_request_mergeability alpha!1 mergeability: behind by 2\n"+ | |
| 223 | + | "2026-10-10T12:00:00Z merge_request_mergeability alpha!1 mergeability: conflict (a.txt, b.txt)\n", res.stdout) | |
| 224 | + | ||
| 225 | + | res = runCLI(t, envFor(url), "", "watch", "--json") | |
| 226 | + | lines := strings.Split(strings.TrimSpace(res.stdout), "\n") | |
| 227 | + | require.Len(t, lines, 3) | |
| 228 | + | var ev struct { | |
| 229 | + | Type string `json:"type"` | |
| 230 | + | Number int64 `json:"number"` | |
| 231 | + | Mergeability struct { | |
| 232 | + | State string `json:"state"` | |
| 233 | + | Behind int `json:"behind"` | |
| 234 | + | ConflictPaths []string `json:"conflict_paths"` | |
| 235 | + | } `json:"mergeability"` | |
| 236 | + | } | |
| 237 | + | require.NoError(t, json.Unmarshal([]byte(lines[1]), &ev)) | |
| 238 | + | assert.Equal(t, "merge_request_mergeability", ev.Type) | |
| 239 | + | assert.EqualValues(t, 1, ev.Number) | |
| 240 | + | assert.Equal(t, "behind", ev.Mergeability.State) | |
| 241 | + | assert.Equal(t, 2, ev.Mergeability.Behind) | |
| 242 | + | require.NoError(t, json.Unmarshal([]byte(lines[2]), &ev)) | |
| 243 | + | assert.Equal(t, []string{"a.txt", "b.txt"}, ev.Mergeability.ConflictPaths) | |
| 244 | + | } | |
| 245 | + | ||
| 203 | 246 | func TestNoteWatchEvents(t *testing.T) { | |
| 204 | 247 | note := &ermite.Comment{Id: "4", ReplyTo: "2", StartLine: 10, Line: 24, Verdict: ermite.Verdict_VERDICT_APPROVE, EditedAt: ts("2026-10-10T12:00:00Z"), Deleted: true, Author: "owner"} | |
| 205 | 248 | events := []*ermite.Event{ | |
| @@ -200,6 +200,49 @@ func TestWatchMergeRequestEvents(t *testing.T) { | |||||
| 200 | assert.Equal(t, "merge_request_closed", decode[eventOut](t, lines[3]).Type) | 200 | assert.Equal(t, "merge_request_closed", decode[eventOut](t, lines[3]).Type) | ||
| 201 | } | 201 | } | ||
| 202 | 202 | ||||
| 203 | + | // A mergeability event shows the state, with how far behind or the paths. | |||
| 204 | + | func TestWatchMergeabilityEvents(t *testing.T) { | |||
| 205 | + | mg := func(m *ermite.Mergeability) func(*ermite.Event) { | |||
| 206 | + | return func(e *ermite.Event) { | |||
| 207 | + | e.Kind = &ermite.Event_MergeRequestMergeability{MergeRequestMergeability: &ermite.MergeRequestMergeability{Repo: "alpha", Number: 1, Mergeability: m}} | |||
| 208 | + | } | |||
| 209 | + | } | |||
| 210 | + | events := []*ermite.WatchResponse{ | |||
| 211 | + | evt("1", mg(&ermite.Mergeability{State: ermite.MergeabilityState_MERGEABILITY_STATE_UP_TO_DATE})), | |||
| 212 | + | evt("2", mg(&ermite.Mergeability{State: ermite.MergeabilityState_MERGEABILITY_STATE_BEHIND, Behind: 2})), | |||
| 213 | + | evt("3", mg(&ermite.Mergeability{State: ermite.MergeabilityState_MERGEABILITY_STATE_CONFLICT, ConflictPaths: []string{"a.txt", "b.txt"}})), | |||
| 214 | + | } | |||
| 215 | + | f := &fakeAPI{watch: finishing(events)} | |||
| 216 | + | url := f.serve(t) | |||
| 217 | + | ||||
| 218 | + | res := runCLI(t, envFor(url), "", "watch") | |||
| 219 | + | require.Error(t, res.err) | |||
| 220 | + | assert.Equal(t, ""+ | |||
| 221 | + | "2026-10-10T12:00:00Z merge_request_mergeability alpha!1 mergeability: up_to_date\n"+ | |||
| 222 | + | "2026-10-10T12:00:00Z merge_request_mergeability alpha!1 mergeability: behind by 2\n"+ | |||
| 223 | + | "2026-10-10T12:00:00Z merge_request_mergeability alpha!1 mergeability: conflict (a.txt, b.txt)\n", res.stdout) | |||
| 224 | + | ||||
| 225 | + | res = runCLI(t, envFor(url), "", "watch", "--json") | |||
| 226 | + | lines := strings.Split(strings.TrimSpace(res.stdout), "\n") | |||
| 227 | + | require.Len(t, lines, 3) | |||
| 228 | + | var ev struct { | |||
| 229 | + | Type string `json:"type"` | |||
| 230 | + | Number int64 `json:"number"` | |||
| 231 | + | Mergeability struct { | |||
| 232 | + | State string `json:"state"` | |||
| 233 | + | Behind int `json:"behind"` | |||
| 234 | + | ConflictPaths []string `json:"conflict_paths"` | |||
| 235 | + | } `json:"mergeability"` | |||
| 236 | + | } | |||
| 237 | + | require.NoError(t, json.Unmarshal([]byte(lines[1]), &ev)) | |||
| 238 | + | assert.Equal(t, "merge_request_mergeability", ev.Type) | |||
| 239 | + | assert.EqualValues(t, 1, ev.Number) | |||
| 240 | + | assert.Equal(t, "behind", ev.Mergeability.State) | |||
| 241 | + | assert.Equal(t, 2, ev.Mergeability.Behind) | |||
| 242 | + | require.NoError(t, json.Unmarshal([]byte(lines[2]), &ev)) | |||
| 243 | + | assert.Equal(t, []string{"a.txt", "b.txt"}, ev.Mergeability.ConflictPaths) | |||
| 244 | + | } | |||
| 245 | + | ||||
| 203 | func TestNoteWatchEvents(t *testing.T) { | 246 | func TestNoteWatchEvents(t *testing.T) { | ||
| 204 | note := &ermite.Comment{Id: "4", ReplyTo: "2", StartLine: 10, Line: 24, Verdict: ermite.Verdict_VERDICT_APPROVE, EditedAt: ts("2026-10-10T12:00:00Z"), Deleted: true, Author: "owner"} | 247 | note := &ermite.Comment{Id: "4", ReplyTo: "2", StartLine: 10, Line: 24, Verdict: ermite.Verdict_VERDICT_APPROVE, EditedAt: ts("2026-10-10T12:00:00Z"), Deleted: true, Author: "owner"} | ||
| 205 | events := []*ermite.Event{ | 248 | events := []*ermite.Event{ | ||
M internal/app/testdata/web/commits-root.html +6 -0
| @@ -972,7 +972,13 @@ | |||
| 972 | 972 | htmx:sse:close, htmx:sse:error -> ermiteLive.lost(); | |
| 973 | 973 | htmx:sse:error -> ermiteLive.error(event.detail.status); | |
| 974 | 974 | htmx:sse:before:connection -> ermiteLive.gate(event); | |
| 975 | + | <<<<<<< HEAD | |
| 975 | 976 | home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data); | |
| 977 | + | ||||||| parent of 7eacfea (feat(web,cli): live mergeability in the merge box, merge modes and inline merge errors) | |
| 978 | + | home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job -> ermiteLive.swap(event.detail.data); | |
| 979 | + | ======= | |
| 980 | + | home-list, repo-last-push, refs-list, commits-head, mr-notice, mr-mergeability, ci-run, ci-job -> ermiteLive.swap(event.detail.data); | |
| 981 | + | >>>>>>> 7eacfea (feat(web,cli): live mergeability in the merge box, merge modes and inline merge errors) | |
| 976 | 982 | gap -> ermiteLive.reload()"> | |
| 977 | 983 | </span> | |
| 978 | 984 | </div> | |
| @@ -972,7 +972,13 @@ | |||||
| 972 | htmx:sse:close, htmx:sse:error -> ermiteLive.lost(); | 972 | htmx:sse:close, htmx:sse:error -> ermiteLive.lost(); | ||
| 973 | htmx:sse:error -> ermiteLive.error(event.detail.status); | 973 | htmx:sse:error -> ermiteLive.error(event.detail.status); | ||
| 974 | htmx:sse:before:connection -> ermiteLive.gate(event); | 974 | htmx:sse:before:connection -> ermiteLive.gate(event); | ||
| 975 | + | <<<<<<< HEAD | |||
| 975 | home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data); | 976 | home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data); | ||
| 977 | + | ||||||| parent of 7eacfea (feat(web,cli): live mergeability in the merge box, merge modes and inline merge errors) | |||
| 978 | + | home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job -> ermiteLive.swap(event.detail.data); | |||
| 979 | + | ======= | |||
| 980 | + | home-list, repo-last-push, refs-list, commits-head, mr-notice, mr-mergeability, ci-run, ci-job -> ermiteLive.swap(event.detail.data); | |||
| 981 | + | >>>>>>> 7eacfea (feat(web,cli): live mergeability in the merge box, merge modes and inline merge errors) | |||
| 976 | gap -> ermiteLive.reload()"> | 982 | gap -> ermiteLive.reload()"> | ||
| 977 | </span> | 983 | </span> | ||
| 978 | </div> | 984 | </div> | ||
M internal/app/testdata/web/commits-small.html +6 -0
| @@ -317,7 +317,13 @@ | |||
| 317 | 317 | htmx:sse:close, htmx:sse:error -> ermiteLive.lost(); | |
| 318 | 318 | htmx:sse:error -> ermiteLive.error(event.detail.status); | |
| 319 | 319 | htmx:sse:before:connection -> ermiteLive.gate(event); | |
| 320 | + | <<<<<<< HEAD | |
| 320 | 321 | home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data); | |
| 322 | + | ||||||| parent of 7eacfea (feat(web,cli): live mergeability in the merge box, merge modes and inline merge errors) | |
| 323 | + | home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job -> ermiteLive.swap(event.detail.data); | |
| 324 | + | ======= | |
| 325 | + | home-list, repo-last-push, refs-list, commits-head, mr-notice, mr-mergeability, ci-run, ci-job -> ermiteLive.swap(event.detail.data); | |
| 326 | + | >>>>>>> 7eacfea (feat(web,cli): live mergeability in the merge box, merge modes and inline merge errors) | |
| 321 | 327 | gap -> ermiteLive.reload()"> | |
| 322 | 328 | </span> | |
| 323 | 329 | </div> | |
| @@ -317,7 +317,13 @@ | |||||
| 317 | htmx:sse:close, htmx:sse:error -> ermiteLive.lost(); | 317 | htmx:sse:close, htmx:sse:error -> ermiteLive.lost(); | ||
| 318 | htmx:sse:error -> ermiteLive.error(event.detail.status); | 318 | htmx:sse:error -> ermiteLive.error(event.detail.status); | ||
| 319 | htmx:sse:before:connection -> ermiteLive.gate(event); | 319 | htmx:sse:before:connection -> ermiteLive.gate(event); | ||
| 320 | + | <<<<<<< HEAD | |||
| 320 | home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data); | 321 | home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data); | ||
| 322 | + | ||||||| parent of 7eacfea (feat(web,cli): live mergeability in the merge box, merge modes and inline merge errors) | |||
| 323 | + | home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job -> ermiteLive.swap(event.detail.data); | |||
| 324 | + | ======= | |||
| 325 | + | home-list, repo-last-push, refs-list, commits-head, mr-notice, mr-mergeability, ci-run, ci-job -> ermiteLive.swap(event.detail.data); | |||
| 326 | + | >>>>>>> 7eacfea (feat(web,cli): live mergeability in the merge box, merge modes and inline merge errors) | |||
| 321 | gap -> ermiteLive.reload()"> | 327 | gap -> ermiteLive.reload()"> | ||
| 322 | </span> | 328 | </span> | ||
| 323 | </div> | 329 | </div> | ||
M internal/app/testdata/web/home.html +1 -1
| @@ -193,7 +193,7 @@ | |||
| 193 | 193 | htmx:sse:close, htmx:sse:error -> ermiteLive.lost(); | |
| 194 | 194 | htmx:sse:error -> ermiteLive.error(event.detail.status); | |
| 195 | 195 | htmx:sse:before:connection -> ermiteLive.gate(event); | |
| 196 | - | home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data); | |
| 196 | + | home-list, repo-last-push, refs-list, commits-head, mr-notice, mr-mergeability, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data); | |
| 197 | 197 | gap -> ermiteLive.reload()"> | |
| 198 | 198 | </span> | |
| 199 | 199 | </div> | |
| @@ -193,7 +193,7 @@ | |||||
| 193 | htmx:sse:close, htmx:sse:error -> ermiteLive.lost(); | 193 | htmx:sse:close, htmx:sse:error -> ermiteLive.lost(); | ||
| 194 | htmx:sse:error -> ermiteLive.error(event.detail.status); | 194 | htmx:sse:error -> ermiteLive.error(event.detail.status); | ||
| 195 | htmx:sse:before:connection -> ermiteLive.gate(event); | 195 | htmx:sse:before:connection -> ermiteLive.gate(event); | ||
| 196 | - | home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data); | 196 | + | home-list, repo-last-push, refs-list, commits-head, mr-notice, mr-mergeability, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data); |
| 197 | gap -> ermiteLive.reload()"> | 197 | gap -> ermiteLive.reload()"> | ||
| 198 | </span> | 198 | </span> | ||
| 199 | </div> | 199 | </div> | ||
M internal/app/testdata/web/refs.html +1 -1
| @@ -199,7 +199,7 @@ | |||
| 199 | 199 | htmx:sse:close, htmx:sse:error -> ermiteLive.lost(); | |
| 200 | 200 | htmx:sse:error -> ermiteLive.error(event.detail.status); | |
| 201 | 201 | htmx:sse:before:connection -> ermiteLive.gate(event); | |
| 202 | - | home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data); | |
| 202 | + | home-list, repo-last-push, refs-list, commits-head, mr-notice, mr-mergeability, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data); | |
| 203 | 203 | gap -> ermiteLive.reload()"> | |
| 204 | 204 | </span> | |
| 205 | 205 | </div> | |
| @@ -199,7 +199,7 @@ | |||||
| 199 | htmx:sse:close, htmx:sse:error -> ermiteLive.lost(); | 199 | htmx:sse:close, htmx:sse:error -> ermiteLive.lost(); | ||
| 200 | htmx:sse:error -> ermiteLive.error(event.detail.status); | 200 | htmx:sse:error -> ermiteLive.error(event.detail.status); | ||
| 201 | htmx:sse:before:connection -> ermiteLive.gate(event); | 201 | htmx:sse:before:connection -> ermiteLive.gate(event); | ||
| 202 | - | home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data); | 202 | + | home-list, repo-last-push, refs-list, commits-head, mr-notice, mr-mergeability, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data); |
| 203 | gap -> ermiteLive.reload()"> | 203 | gap -> ermiteLive.reload()"> | ||
| 204 | </span> | 204 | </span> | ||
| 205 | </div> | 205 | </div> | ||
M internal/app/testdata/web/summary.html +1 -1
| @@ -225,7 +225,7 @@ | |||
| 225 | 225 | htmx:sse:close, htmx:sse:error -> ermiteLive.lost(); | |
| 226 | 226 | htmx:sse:error -> ermiteLive.error(event.detail.status); | |
| 227 | 227 | htmx:sse:before:connection -> ermiteLive.gate(event); | |
| 228 | - | home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data); | |
| 228 | + | home-list, repo-last-push, refs-list, commits-head, mr-notice, mr-mergeability, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data); | |
| 229 | 229 | gap -> ermiteLive.reload()"> | |
| 230 | 230 | </span> | |
| 231 | 231 | </div> | |
| @@ -225,7 +225,7 @@ | |||||
| 225 | htmx:sse:close, htmx:sse:error -> ermiteLive.lost(); | 225 | htmx:sse:close, htmx:sse:error -> ermiteLive.lost(); | ||
| 226 | htmx:sse:error -> ermiteLive.error(event.detail.status); | 226 | htmx:sse:error -> ermiteLive.error(event.detail.status); | ||
| 227 | htmx:sse:before:connection -> ermiteLive.gate(event); | 227 | htmx:sse:before:connection -> ermiteLive.gate(event); | ||
| 228 | - | home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data); | 228 | + | home-list, repo-last-push, refs-list, commits-head, mr-notice, mr-mergeability, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data); |
| 229 | gap -> ermiteLive.reload()"> | 229 | gap -> ermiteLive.reload()"> | ||
| 230 | </span> | 230 | </span> | ||
| 231 | </div> | 231 | </div> | ||
M internal/app/web.go +5 -0
| @@ -35,6 +35,10 @@ func newWeb(cfg config.Config, f *forge, limiter *clientlimit.Limiter) (http.Han | |||
| 35 | 35 | if f.Passkeys != nil { | |
| 36 | 36 | passkeys = webPasskeys{f.Passkeys} | |
| 37 | 37 | } | |
| 38 | + | var signing ports.SigningKeyService | |
| 39 | + | if f.SigningKey != nil { | |
| 40 | + | signing = f.SigningKey | |
| 41 | + | } | |
| 38 | 42 | // Without artifacts (ERMITE_CI_ARTIFACTS=0) the run page has no section | |
| 39 | 43 | // for them and no download route. | |
| 40 | 44 | var artifacts ports.ArtifactService | |
| @@ -50,6 +54,7 @@ func newWeb(cfg config.Config, f *forge, limiter *clientlimit.Limiter) (http.Han | |||
| 50 | 54 | Inbox: f.Inbox, | |
| 51 | 55 | IssueForm: f.IssueSubmissions, | |
| 52 | 56 | Moderation: f.IssueModeration, | |
| 57 | + | SigningKey: signing, | |
| 53 | 58 | Runs: f.CI, | |
| 54 | 59 | Artifacts: artifacts, | |
| 55 | 60 | Events: f.EventLog, | |
| @@ -35,6 +35,10 @@ func newWeb(cfg config.Config, f *forge, limiter *clientlimit.Limiter) (http.Han | |||||
| 35 | if f.Passkeys != nil { | 35 | if f.Passkeys != nil { | ||
| 36 | passkeys = webPasskeys{f.Passkeys} | 36 | passkeys = webPasskeys{f.Passkeys} | ||
| 37 | } | 37 | } | ||
| 38 | + | var signing ports.SigningKeyService | |||
| 39 | + | if f.SigningKey != nil { | |||
| 40 | + | signing = f.SigningKey | |||
| 41 | + | } | |||
| 38 | // Without artifacts (ERMITE_CI_ARTIFACTS=0) the run page has no section | 42 | // Without artifacts (ERMITE_CI_ARTIFACTS=0) the run page has no section | ||
| 39 | // for them and no download route. | 43 | // for them and no download route. | ||
| 40 | var artifacts ports.ArtifactService | 44 | var artifacts ports.ArtifactService | ||
| @@ -50,6 +54,7 @@ func newWeb(cfg config.Config, f *forge, limiter *clientlimit.Limiter) (http.Han | |||||
| 50 | Inbox: f.Inbox, | 54 | Inbox: f.Inbox, | ||
| 51 | IssueForm: f.IssueSubmissions, | 55 | IssueForm: f.IssueSubmissions, | ||
| 52 | Moderation: f.IssueModeration, | 56 | Moderation: f.IssueModeration, | ||
| 57 | + | SigningKey: signing, | |||
| 53 | Runs: f.CI, | 58 | Runs: f.CI, | ||
| 54 | Artifacts: artifacts, | 59 | Artifacts: artifacts, | ||
| 55 | Events: f.EventLog, | 60 | Events: f.EventLog, | ||
M internal/app/web_events_test.go +9 -0
| @@ -37,6 +37,12 @@ type sseStream struct { | |||
| 37 | 37 | // background. It returns once the server answered, which it does when the | |
| 38 | 38 | // watch is registered: what happens after is not missed. | |
| 39 | 39 | func (h *harness) sse(query, lastID string) *sseStream { | |
| 40 | + | h.t.Helper() | |
| 41 | + | return h.sseAs(query, lastID, nil) | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | // sseAs opens the stream with the headers given (the owner's cookie). | |
| 45 | + | func (h *harness) sseAs(query, lastID string, headers map[string]string) *sseStream { | |
| 40 | 46 | h.t.Helper() | |
| 41 | 47 | ctx, cancel := context.WithCancel(h.ctx) | |
| 42 | 48 | h.t.Cleanup(cancel) | |
| @@ -45,6 +51,9 @@ func (h *harness) sse(query, lastID string) *sseStream { | |||
| 45 | 51 | if lastID != "" { | |
| 46 | 52 | req.Header.Set("Last-Event-ID", lastID) | |
| 47 | 53 | } | |
| 54 | + | for k, v := range headers { | |
| 55 | + | req.Header.Set(k, v) | |
| 56 | + | } | |
| 48 | 57 | resp, err := http.DefaultClient.Do(req) | |
| 49 | 58 | require.NoError(h.t, err) | |
| 50 | 59 | t := h.t | |
| @@ -37,6 +37,12 @@ type sseStream struct { | |||||
| 37 | // background. It returns once the server answered, which it does when the | 37 | // background. It returns once the server answered, which it does when the | ||
| 38 | // watch is registered: what happens after is not missed. | 38 | // watch is registered: what happens after is not missed. | ||
| 39 | func (h *harness) sse(query, lastID string) *sseStream { | 39 | func (h *harness) sse(query, lastID string) *sseStream { | ||
| 40 | + | h.t.Helper() | |||
| 41 | + | return h.sseAs(query, lastID, nil) | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | // sseAs opens the stream with the headers given (the owner's cookie). | |||
| 45 | + | func (h *harness) sseAs(query, lastID string, headers map[string]string) *sseStream { | |||
| 40 | h.t.Helper() | 46 | h.t.Helper() | ||
| 41 | ctx, cancel := context.WithCancel(h.ctx) | 47 | ctx, cancel := context.WithCancel(h.ctx) | ||
| 42 | h.t.Cleanup(cancel) | 48 | h.t.Cleanup(cancel) | ||
| @@ -45,6 +51,9 @@ func (h *harness) sse(query, lastID string) *sseStream { | |||||
| 45 | if lastID != "" { | 51 | if lastID != "" { | ||
| 46 | req.Header.Set("Last-Event-ID", lastID) | 52 | req.Header.Set("Last-Event-ID", lastID) | ||
| 47 | } | 53 | } | ||
| 54 | + | for k, v := range headers { | |||
| 55 | + | req.Header.Set(k, v) | |||
| 56 | + | } | |||
| 48 | resp, err := http.DefaultClient.Do(req) | 57 | resp, err := http.DefaultClient.Do(req) | ||
| 49 | require.NoError(h.t, err) | 58 | require.NoError(h.t, err) | ||
| 50 | t := h.t | 59 | t := h.t | ||
M internal/app/web_mr_test.go +197 -10
| @@ -10,6 +10,7 @@ import ( | |||
| 10 | 10 | "regexp" | |
| 11 | 11 | "strings" | |
| 12 | 12 | "testing" | |
| 13 | + | "time" | |
| 13 | 14 | ||
| 14 | 15 | "github.com/stretchr/testify/assert" | |
| 15 | 16 | "github.com/stretchr/testify/require" | |
| @@ -165,12 +166,15 @@ func TestWebMergeRequestPagesRender(t *testing.T) { | |||
| 165 | 166 | assert.Equal(t, head, formHead(t, own.body)) | |
| 166 | 167 | assert.Contains(t, own.body, `action="/proj/merge-requests/1/merge"`) | |
| 167 | 168 | assert.Contains(t, own.body, `id="merge-menu"`, "the merge is a menu of the bar above the diff") | |
| 168 | - | assert.Contains(t, own.body, `<input type="radio" name="mode" value="fast-forward" checked>`, "the first mode is the default") | |
| 169 | - | assert.Contains(t, own.body, `<input type="radio" name="mode" value="squash">`) | |
| 169 | + | assert.Contains(t, own.body, `name="mode" value="fast-forward" checked`, "the first possible mode is the default") | |
| 170 | + | assert.Contains(t, own.body, `name="mode" value="squash"`) | |
| 171 | + | assert.Contains(t, own.body, `name="mode" value="merge"`, "every mode is allowed by default") | |
| 170 | 172 | assert.Contains(t, own.body, "ermite mr merge proj 1 --head "+head) | |
| 171 | 173 | assert.Contains(t, own.body, `name="delete_branch" value="1"> delete feat/search after the merge <kbd aria-hidden="true">D</kbd>`) | |
| 172 | 174 | assert.NotContains(t, own.body, `name="delete_branch" value="1" checked>`, "unchecked by default") | |
| 173 | - | assert.Contains(t, own.body, "ermite mr merge proj 1 --head "+head+" --squash") | |
| 175 | + | assert.Contains(t, own.body, "ermite mr merge proj 1 --head "+head+" --mode squash") | |
| 176 | + | assert.Contains(t, own.body, "ermite mr merge proj 1 --head "+head+" --mode merge") | |
| 177 | + | assert.NotContains(t, own.body, "--squash") | |
| 174 | 178 | assert.Contains(t, own.body, `action="/proj/merge-requests/1/comments"`) | |
| 175 | 179 | assert.Contains(t, own.body, `action="/proj/merge-requests/1/close"`) | |
| 176 | 180 | assert.Contains(t, own.body, `href="/proj/merge-requests/1?on=new:1:search.go#f`, "line numbers link to their form") | |
| @@ -573,8 +577,7 @@ func TestWebMergeRequestEvents(t *testing.T) { | |||
| 573 | 577 | r := newMRRepo(t, domain.VisibilityPublic) | |
| 574 | 578 | list := r.sse("topic=repo:proj&view=mrs", "") | |
| 575 | 579 | r.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/search", "--title", "Secret <title>").ok(t) | |
| 576 | - | ev := list.next(t, watchWait) | |
| 577 | - | assert.Equal(t, "mr-notice", ev.name) | |
| 580 | + | ev := nextNamed(t, list, "mr-notice") | |
| 578 | 581 | assert.Contains(t, ev.data, `<div id="mr-notice" class="live-slot" role="status" data-slot>`) | |
| 579 | 582 | assert.Contains(t, ev.data, `href="/proj/merge-requests"`) | |
| 580 | 583 | assert.NotContains(t, ev.data, "Secret") | |
| @@ -582,19 +585,18 @@ func TestWebMergeRequestEvents(t *testing.T) { | |||
| 582 | 585 | one := r.sse("topic=repo:proj&view=mr&n=1&ref=feat/search", "") | |
| 583 | 586 | other := r.sse("topic=repo:proj&view=mr&n=2&ref=feat/other", "") | |
| 584 | 587 | r.cli(t, adminToken, "mr", "comment", "proj", "1", "-m", "private words").ok(t) | |
| 585 | - | ev = one.next(t, watchWait) | |
| 586 | - | assert.Equal(t, "mr-notice", ev.name) | |
| 588 | + | ev = nextNamed(t, one, "mr-notice") | |
| 587 | 589 | assert.Contains(t, ev.data, "a comment was added") | |
| 588 | 590 | assert.Contains(t, ev.data, `href="/proj/merge-requests/1"`) | |
| 589 | 591 | assert.NotContains(t, ev.data, "private words") | |
| 590 | 592 | ||
| 591 | 593 | r.writeFile(r.wt, "more.txt", []byte("more\n")) | |
| 592 | 594 | r.mustGit(r.wt, "push", "-q", r.url, "feat/search") | |
| 593 | - | ev = one.next(t, watchWait) | |
| 595 | + | ev = nextNamed(t, one, "mr-notice") | |
| 594 | 596 | assert.Contains(t, ev.data, "the branch changed") | |
| 595 | 597 | ||
| 596 | 598 | r.cli(t, adminToken, "mr", "close", "proj", "1").ok(t) | |
| 597 | - | assert.Contains(t, one.next(t, watchWait).data, "was closed") | |
| 599 | + | assert.Contains(t, nextNamed(t, one, "mr-notice").data, "was closed") | |
| 598 | 600 | ||
| 599 | 601 | // A page of another request hears nothing of this one. | |
| 600 | 602 | for drained := false; !drained; { | |
| @@ -607,7 +609,20 @@ func TestWebMergeRequestEvents(t *testing.T) { | |||
| 607 | 609 | drained = true | |
| 608 | 610 | } | |
| 609 | 611 | } | |
| 610 | - | assert.Contains(t, r.get1("/proj/merge-requests/1"), `hx-sse:connect="/-/events?topic=repo%3Aproj&view=mr&n=1&ref=feat%2Fsearch&head=`+r.headOf("feat/search")+`"`) | |
| 612 | + | assert.Contains(t, r.get1("/proj/merge-requests/1"), `hx-sse:connect="/-/events?topic=repo%3Aproj&view=mr&n=1&ref=feat%2Fsearch&head=`+r.headOf("feat/search")+`&target=main"`) | |
| 613 | + | } | |
| 614 | + | ||
| 615 | + | // nextNamed returns the next event that carries the fragment event: the | |
| 616 | + | // events that change nothing on the page (an id alone) are skipped. | |
| 617 | + | func nextNamed(t *testing.T, s *sseStream, name string) sseEvent { | |
| 618 | + | t.Helper() | |
| 619 | + | for range 10 { | |
| 620 | + | if ev := s.next(t, watchWait); ev.name == name { | |
| 621 | + | return ev | |
| 622 | + | } | |
| 623 | + | } | |
| 624 | + | t.Fatalf("no %s event", name) | |
| 625 | + | return sseEvent{} | |
| 611 | 626 | } | |
| 612 | 627 | ||
| 613 | 628 | func (r *mrRepo) get1(path string) string { | |
| @@ -726,3 +741,175 @@ func TestWebMergeRequestFileWindows(t *testing.T) { | |||
| 726 | 741 | } | |
| 727 | 742 | ||
| 728 | 743 | var reWindowSrc = regexp.MustCompile(`data-src="([^"]*from=25[^"]*)"`) | |
| 744 | + | ||
| 745 | + | // advanceMain pushes a commit to main that the feature branch lacks. file is | |
| 746 | + | // the file it adds (give search.go to conflict with the feature branch). | |
| 747 | + | func (r *mrRepo) advanceMain(file, content string) { | |
| 748 | + | r.t.Helper() | |
| 749 | + | r.mustGit(r.wt, "checkout", "-q", "main") | |
| 750 | + | r.writeFile(r.wt, file, []byte(content)) | |
| 751 | + | r.mustGit(r.wt, "push", "-q", r.url, "main") | |
| 752 | + | r.mustGit(r.wt, "checkout", "-q", "feat/search") | |
| 753 | + | } | |
| 754 | + | ||
| 755 | + | // mrPageWith polls the owner's page of !1 until it contains want. | |
| 756 | + | func (r *mrRepo) mrPageWith(token, want string) string { | |
| 757 | + | r.t.Helper() | |
| 758 | + | var body string | |
| 759 | + | require.Eventually(r.t, func() bool { | |
| 760 | + | body = browser{r.harness}.get("/proj/merge-requests/1", as(token)).body | |
| 761 | + | return strings.Contains(body, want) | |
| 762 | + | }, 10*time.Second, 25*time.Millisecond, "the page never showed %q", want) | |
| 763 | + | return body | |
| 764 | + | } | |
| 765 | + | ||
| 766 | + | // The merge box shows the mergeability of the request: ready, then behind | |
| 767 | + | // (fast-forward is disabled with its reason, the other modes are not), then | |
| 768 | + | // in conflict (no mode is possible and the button says why). | |
| 769 | + | func TestWebMergeRequestMergeabilityBadge(t *testing.T) { | |
| 770 | + | r := newMRRepo(t, domain.VisibilityPublic) | |
| 771 | + | token := r.signIn(t) | |
| 772 | + | r.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/search", "--title", "Add search").ok(t) | |
| 773 | + | ||
| 774 | + | body := r.mrPageWith(token, "Ready to merge") | |
| 775 | + | assert.Contains(t, body, `<div id="mr-mergeability" class="live-slot" role="status" data-slot>`) | |
| 776 | + | assert.Contains(t, body, `data-state="up_to_date"`) | |
| 777 | + | assert.Contains(t, body, `name="mode" value="fast-forward" checked`) | |
| 778 | + | assert.NotContains(t, body, `value="fast-forward" checked disabled`) | |
| 779 | + | assert.Contains(t, body, `id="mr-signing"`, "squash and merge commits say who signs them") | |
| 780 | + | assert.Contains(t, body, "signed with ermite's own key, generated automatically on first start") | |
| 781 | + | assert.Contains(t, body, "<code>ermite signing-key show</code>") | |
| 782 | + | assert.Contains(t, body, "SHA256:", "the fingerprint of the signing key") | |
| 783 | + | assert.Contains(t, body, `:disabled="`, "the controls follow the badge") | |
| 784 | + | assertNoInline(t, body) | |
| 785 | + | ||
| 786 | + | // Main moves on: the branch is behind by one commit and merges cleanly. | |
| 787 | + | r.advanceMain("other.txt", "other\n") | |
| 788 | + | body = r.mrPageWith(token, "Behind main by 1 commit") | |
| 789 | + | assert.Contains(t, body, "merges without conflicts") | |
| 790 | + | assert.Contains(t, body, `data-ff-ok="false"`) | |
| 791 | + | assert.Contains(t, body, `data-merge-ok="true"`) | |
| 792 | + | assert.Regexp(t, `<input type="radio" name="mode" value="fast-forward"[^>]* disabled`, body) | |
| 793 | + | assert.Contains(t, body, `title="behind the target: rebase or use squash/merge"`) | |
| 794 | + | assert.Regexp(t, `name="mode" value="squash" checked`, body, "the first possible mode is checked") | |
| 795 | + | assert.NotRegexp(t, `name="mode" value="squash"[^>]* disabled`, body) | |
| 796 | + | ||
| 797 | + | // A conflicting change to main: every mode is disabled and the files are listed. | |
| 798 | + | r.mustGit(r.wt, "checkout", "-q", "main") | |
| 799 | + | r.writeFile(r.wt, "search.go", []byte("package other\n")) | |
| 800 | + | r.mustGit(r.wt, "push", "-q", r.url, "main") | |
| 801 | + | body = r.mrPageWith(token, "Conflicts with main") | |
| 802 | + | assert.Contains(t, body, "rebase needed") | |
| 803 | + | assert.Contains(t, body, `<ul class="mg-paths" aria-label="conflicting files"><li><code>search.go</code></li></ul>`) | |
| 804 | + | assert.Regexp(t, `<button type="submit" class="btn primary" data-default disabled title="conflicts with the target"`, body) | |
| 805 | + | assert.NotContains(t, body, `name="mode" value="squash" checked`) | |
| 806 | + | ||
| 807 | + | // The server refuses it too, and says so in the box of the page. | |
| 808 | + | res := browser{r.harness}.form("/proj/merge-requests/1/merge", token, url.Values{"mode": {"squash"}, "head_id": {formHead(t, body)}}) | |
| 809 | + | assert.Equal(t, http.StatusConflict, res.status) | |
| 810 | + | assert.Contains(t, res.body, `<p class="d-err" role="alert">`) | |
| 811 | + | assert.Contains(t, res.body, "conflicts with main") | |
| 812 | + | } | |
| 813 | + | ||
| 814 | + | // A merge the server refuses (the target moved after the page was read) shows | |
| 815 | + | // the page of the request again, 409, with the reason in its merge box. | |
| 816 | + | func TestWebMergeRequestInlineMergeError(t *testing.T) { | |
| 817 | + | r := newMRRepo(t, domain.VisibilityPublic) | |
| 818 | + | b := browser{r.harness} | |
| 819 | + | token := r.signIn(t) | |
| 820 | + | r.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/search", "--title", "Add search").ok(t) | |
| 821 | + | body := r.mrPageWith(token, "Ready to merge") | |
| 822 | + | head := formHead(t, body) | |
| 823 | + | ||
| 824 | + | r.advanceMain("other.txt", "other\n") // raced: the form below still says fast-forward | |
| 825 | + | res := b.form("/proj/merge-requests/1/merge", token, url.Values{"mode": {"fast-forward"}, "head_id": {head}}) | |
| 826 | + | assert.Equal(t, http.StatusConflict, res.status) | |
| 827 | + | assert.Equal(t, "no-store", res.hdr.Get("Cache-Control")) | |
| 828 | + | assert.Contains(t, res.body, `id="merge-menu"`, "the page of the request, not a state page") | |
| 829 | + | assert.Contains(t, res.body, `<p class="d-err" role="alert">`) | |
| 830 | + | assert.Contains(t, res.body, "the target has moved") | |
| 831 | + | assert.Contains(t, res.body, `<details class="menu mmenu" id="merge-menu" data-state="open" open>`, "the box is open on the error") | |
| 832 | + | assert.Equal(t, head, r.headOf("feat/search")) | |
| 833 | + | assert.NotContains(t, b.get("/proj/merge-requests/1", as(token)).body, "d-err", "a reload shows no error") | |
| 834 | + | ||
| 835 | + | // A head that moved is refused the same way. | |
| 836 | + | r.writeFile(r.wt, "more.txt", []byte("more\n")) | |
| 837 | + | r.mustGit(r.wt, "push", "-q", r.url, "feat/search") | |
| 838 | + | res = b.form("/proj/merge-requests/1/merge", token, url.Values{"mode": {"squash"}, "head_id": {head}}) | |
| 839 | + | assert.Equal(t, http.StatusConflict, res.status) | |
| 840 | + | assert.Contains(t, res.body, `<p class="d-err" role="alert">`) | |
| 841 | + | assert.Contains(t, res.body, "branch moved") | |
| 842 | + | ||
| 843 | + | // Other refusals keep the state page; a mode that does not exist is a 400. | |
| 844 | + | assert.Equal(t, http.StatusBadRequest, b.form("/proj/merge-requests/1/merge", token, url.Values{"mode": {"rebase"}, "head_id": {head}}).status) | |
| 845 | + | } | |
| 846 | + | ||
| 847 | + | // A merge commit through the page; with the branch deletion requested the | |
| 848 | + | // merged page says the branch is being deleted, not that it is gone. | |
| 849 | + | func TestWebMergeRequestMergeCommitAndDeletingNote(t *testing.T) { | |
| 850 | + | r := newMRRepo(t, domain.VisibilityPublic) | |
| 851 | + | b := browser{r.harness} | |
| 852 | + | token := r.signIn(t) | |
| 853 | + | r.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/search", "--title", "Add search").ok(t) | |
| 854 | + | r.advanceMain("other.txt", "other\n") | |
| 855 | + | body := r.mrPageWith(token, "Behind main by 1 commit") | |
| 856 | + | head := formHead(t, body) | |
| 857 | + | main0 := r.headOf("main") | |
| 858 | + | ||
| 859 | + | res := b.form("/proj/merge-requests/1/merge", token, url.Values{"mode": {"merge"}, "head_id": {head}, "delete_branch": {"1"}}) | |
| 860 | + | require.Equal(t, http.StatusSeeOther, res.status, res.body) | |
| 861 | + | assert.Equal(t, "/proj/merge-requests/1?deleting=1", res.hdr.Get("Location")) | |
| 862 | + | merged := r.headOf("main") | |
| 863 | + | assert.Equal(t, main0, r.bareGit("proj", "rev-parse", merged+"^1")) | |
| 864 | + | assert.Equal(t, head, r.bareGit("proj", "rev-parse", merged+"^2")) | |
| 865 | + | assert.Contains(t, r.bareGit("proj", "cat-file", "-p", merged), "gpgsig") | |
| 866 | + | page := b.get("/proj/merge-requests/1?deleting=1", as(token)) | |
| 867 | + | assert.Contains(t, page.body, "merge commit") | |
| 868 | + | if strings.Contains(page.body, "is being deleted") { | |
| 869 | + | assert.Contains(t, page.body, "feat/search is being deleted") | |
| 870 | + | } | |
| 871 | + | assert.NotContains(t, page.body, "is deleted") | |
| 872 | + | r.forge.MergeRequests.Wait(r.ctx) | |
| 873 | + | assert.Empty(t, r.bareGit("proj", "for-each-ref", "refs/heads/feat/search")) | |
| 874 | + | assert.NotContains(t, b.get("/proj/merge-requests/1?deleting=1", as(token)).body, "is being deleted", "once gone, nothing is being deleted") | |
| 875 | + | } | |
| 876 | + | ||
| 877 | + | // The badge follows the request live: a push to the target turns the fragment | |
| 878 | + | // of the page to "checking" or "behind", and the settled result follows. | |
| 879 | + | func TestWebMergeRequestMergeabilityLive(t *testing.T) { | |
| 880 | + | r := newMRRepo(t, domain.VisibilityPublic) | |
| 881 | + | token := r.signIn(t) | |
| 882 | + | r.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/search", "--title", "Add search").ok(t) | |
| 883 | + | r.mrPageWith(token, "Ready to merge") | |
| 884 | + | ||
| 885 | + | one := r.sseAs("topic=repo:proj&view=mr&n=1&ref=feat/search&target=main", "", as(token)) | |
| 886 | + | anon := r.sse("topic=repo:proj&view=mr&n=1&ref=feat/search&target=main", "") | |
| 887 | + | r.advanceMain("other.txt", "other\n") | |
| 888 | + | deadline := time.After(watchWait) | |
| 889 | + | for done := false; !done; { | |
| 890 | + | ev := nextNamed(t, one, "mr-mergeability") | |
| 891 | + | select { | |
| 892 | + | case <-deadline: | |
| 893 | + | t.Fatal("the badge never showed the result") | |
| 894 | + | default: | |
| 895 | + | } | |
| 896 | + | if strings.Contains(ev.data, "Behind main by 1 commit") { | |
| 897 | + | assert.Contains(t, ev.data, `<div id="mr-mergeability" class="live-slot" role="status" data-slot>`) | |
| 898 | + | assert.Contains(t, ev.data, `data-ff-ok="false"`) | |
| 899 | + | assert.Contains(t, ev.data, `data-merge-ok="true"`) | |
| 900 | + | done = true | |
| 901 | + | } else { | |
| 902 | + | assert.Contains(t, ev.data, "Checking…", "before the result, the badge says it checks") | |
| 903 | + | } | |
| 904 | + | } | |
| 905 | + | // The box is the owner's: a visitor's stream carries no badge. | |
| 906 | + | for { | |
| 907 | + | select { | |
| 908 | + | case ev := <-anon.events: | |
| 909 | + | assert.NotEqual(t, "mr-mergeability", ev.name) | |
| 910 | + | continue | |
| 911 | + | default: | |
| 912 | + | } | |
| 913 | + | break | |
| 914 | + | } | |
| 915 | + | } | |
| @@ -10,6 +10,7 @@ import ( | |||||
| 10 | "regexp" | 10 | "regexp" | ||
| 11 | "strings" | 11 | "strings" | ||
| 12 | "testing" | 12 | "testing" | ||
| 13 | + | "time" | |||
| 13 | 14 | ||||
| 14 | "github.com/stretchr/testify/assert" | 15 | "github.com/stretchr/testify/assert" | ||
| 15 | "github.com/stretchr/testify/require" | 16 | "github.com/stretchr/testify/require" | ||
| @@ -165,12 +166,15 @@ func TestWebMergeRequestPagesRender(t *testing.T) { | |||||
| 165 | assert.Equal(t, head, formHead(t, own.body)) | 166 | assert.Equal(t, head, formHead(t, own.body)) | ||
| 166 | assert.Contains(t, own.body, `action="/proj/merge-requests/1/merge"`) | 167 | assert.Contains(t, own.body, `action="/proj/merge-requests/1/merge"`) | ||
| 167 | assert.Contains(t, own.body, `id="merge-menu"`, "the merge is a menu of the bar above the diff") | 168 | assert.Contains(t, own.body, `id="merge-menu"`, "the merge is a menu of the bar above the diff") | ||
| 168 | - | assert.Contains(t, own.body, `<input type="radio" name="mode" value="fast-forward" checked>`, "the first mode is the default") | 169 | + | assert.Contains(t, own.body, `name="mode" value="fast-forward" checked`, "the first possible mode is the default") |
| 169 | - | assert.Contains(t, own.body, `<input type="radio" name="mode" value="squash">`) | 170 | + | assert.Contains(t, own.body, `name="mode" value="squash"`) |
| 171 | + | assert.Contains(t, own.body, `name="mode" value="merge"`, "every mode is allowed by default") | |||
| 170 | assert.Contains(t, own.body, "ermite mr merge proj 1 --head "+head) | 172 | assert.Contains(t, own.body, "ermite mr merge proj 1 --head "+head) | ||
| 171 | assert.Contains(t, own.body, `name="delete_branch" value="1"> delete feat/search after the merge <kbd aria-hidden="true">D</kbd>`) | 173 | assert.Contains(t, own.body, `name="delete_branch" value="1"> delete feat/search after the merge <kbd aria-hidden="true">D</kbd>`) | ||
| 172 | assert.NotContains(t, own.body, `name="delete_branch" value="1" checked>`, "unchecked by default") | 174 | assert.NotContains(t, own.body, `name="delete_branch" value="1" checked>`, "unchecked by default") | ||
| 173 | - | assert.Contains(t, own.body, "ermite mr merge proj 1 --head "+head+" --squash") | 175 | + | assert.Contains(t, own.body, "ermite mr merge proj 1 --head "+head+" --mode squash") |
| 176 | + | assert.Contains(t, own.body, "ermite mr merge proj 1 --head "+head+" --mode merge") | |||
| 177 | + | assert.NotContains(t, own.body, "--squash") | |||
| 174 | assert.Contains(t, own.body, `action="/proj/merge-requests/1/comments"`) | 178 | assert.Contains(t, own.body, `action="/proj/merge-requests/1/comments"`) | ||
| 175 | assert.Contains(t, own.body, `action="/proj/merge-requests/1/close"`) | 179 | assert.Contains(t, own.body, `action="/proj/merge-requests/1/close"`) | ||
| 176 | assert.Contains(t, own.body, `href="/proj/merge-requests/1?on=new:1:search.go#f`, "line numbers link to their form") | 180 | assert.Contains(t, own.body, `href="/proj/merge-requests/1?on=new:1:search.go#f`, "line numbers link to their form") | ||
| @@ -573,8 +577,7 @@ func TestWebMergeRequestEvents(t *testing.T) { | |||||
| 573 | r := newMRRepo(t, domain.VisibilityPublic) | 577 | r := newMRRepo(t, domain.VisibilityPublic) | ||
| 574 | list := r.sse("topic=repo:proj&view=mrs", "") | 578 | list := r.sse("topic=repo:proj&view=mrs", "") | ||
| 575 | r.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/search", "--title", "Secret <title>").ok(t) | 579 | r.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/search", "--title", "Secret <title>").ok(t) | ||
| 576 | - | ev := list.next(t, watchWait) | 580 | + | ev := nextNamed(t, list, "mr-notice") |
| 577 | - | assert.Equal(t, "mr-notice", ev.name) | |||
| 578 | assert.Contains(t, ev.data, `<div id="mr-notice" class="live-slot" role="status" data-slot>`) | 581 | assert.Contains(t, ev.data, `<div id="mr-notice" class="live-slot" role="status" data-slot>`) | ||
| 579 | assert.Contains(t, ev.data, `href="/proj/merge-requests"`) | 582 | assert.Contains(t, ev.data, `href="/proj/merge-requests"`) | ||
| 580 | assert.NotContains(t, ev.data, "Secret") | 583 | assert.NotContains(t, ev.data, "Secret") | ||
| @@ -582,19 +585,18 @@ func TestWebMergeRequestEvents(t *testing.T) { | |||||
| 582 | one := r.sse("topic=repo:proj&view=mr&n=1&ref=feat/search", "") | 585 | one := r.sse("topic=repo:proj&view=mr&n=1&ref=feat/search", "") | ||
| 583 | other := r.sse("topic=repo:proj&view=mr&n=2&ref=feat/other", "") | 586 | other := r.sse("topic=repo:proj&view=mr&n=2&ref=feat/other", "") | ||
| 584 | r.cli(t, adminToken, "mr", "comment", "proj", "1", "-m", "private words").ok(t) | 587 | r.cli(t, adminToken, "mr", "comment", "proj", "1", "-m", "private words").ok(t) | ||
| 585 | - | ev = one.next(t, watchWait) | 588 | + | ev = nextNamed(t, one, "mr-notice") |
| 586 | - | assert.Equal(t, "mr-notice", ev.name) | |||
| 587 | assert.Contains(t, ev.data, "a comment was added") | 589 | assert.Contains(t, ev.data, "a comment was added") | ||
| 588 | assert.Contains(t, ev.data, `href="/proj/merge-requests/1"`) | 590 | assert.Contains(t, ev.data, `href="/proj/merge-requests/1"`) | ||
| 589 | assert.NotContains(t, ev.data, "private words") | 591 | assert.NotContains(t, ev.data, "private words") | ||
| 590 | 592 | ||||
| 591 | r.writeFile(r.wt, "more.txt", []byte("more\n")) | 593 | r.writeFile(r.wt, "more.txt", []byte("more\n")) | ||
| 592 | r.mustGit(r.wt, "push", "-q", r.url, "feat/search") | 594 | r.mustGit(r.wt, "push", "-q", r.url, "feat/search") | ||
| 593 | - | ev = one.next(t, watchWait) | 595 | + | ev = nextNamed(t, one, "mr-notice") |
| 594 | assert.Contains(t, ev.data, "the branch changed") | 596 | assert.Contains(t, ev.data, "the branch changed") | ||
| 595 | 597 | ||||
| 596 | r.cli(t, adminToken, "mr", "close", "proj", "1").ok(t) | 598 | r.cli(t, adminToken, "mr", "close", "proj", "1").ok(t) | ||
| 597 | - | assert.Contains(t, one.next(t, watchWait).data, "was closed") | 599 | + | assert.Contains(t, nextNamed(t, one, "mr-notice").data, "was closed") |
| 598 | 600 | ||||
| 599 | // A page of another request hears nothing of this one. | 601 | // A page of another request hears nothing of this one. | ||
| 600 | for drained := false; !drained; { | 602 | for drained := false; !drained; { | ||
| @@ -607,7 +609,20 @@ func TestWebMergeRequestEvents(t *testing.T) { | |||||
| 607 | drained = true | 609 | drained = true | ||
| 608 | } | 610 | } | ||
| 609 | } | 611 | } | ||
| 610 | - | assert.Contains(t, r.get1("/proj/merge-requests/1"), `hx-sse:connect="/-/events?topic=repo%3Aproj&view=mr&n=1&ref=feat%2Fsearch&head=`+r.headOf("feat/search")+`"`) | 612 | + | assert.Contains(t, r.get1("/proj/merge-requests/1"), `hx-sse:connect="/-/events?topic=repo%3Aproj&view=mr&n=1&ref=feat%2Fsearch&head=`+r.headOf("feat/search")+`&target=main"`) |
| 613 | + | } | |||
| 614 | + | ||||
| 615 | + | // nextNamed returns the next event that carries the fragment event: the | |||
| 616 | + | // events that change nothing on the page (an id alone) are skipped. | |||
| 617 | + | func nextNamed(t *testing.T, s *sseStream, name string) sseEvent { | |||
| 618 | + | t.Helper() | |||
| 619 | + | for range 10 { | |||
| 620 | + | if ev := s.next(t, watchWait); ev.name == name { | |||
| 621 | + | return ev | |||
| 622 | + | } | |||
| 623 | + | } | |||
| 624 | + | t.Fatalf("no %s event", name) | |||
| 625 | + | return sseEvent{} | |||
| 611 | } | 626 | } | ||
| 612 | 627 | ||||
| 613 | func (r *mrRepo) get1(path string) string { | 628 | func (r *mrRepo) get1(path string) string { | ||
| @@ -726,3 +741,175 @@ func TestWebMergeRequestFileWindows(t *testing.T) { | |||||
| 726 | } | 741 | } | ||
| 727 | 742 | ||||
| 728 | var reWindowSrc = regexp.MustCompile(`data-src="([^"]*from=25[^"]*)"`) | 743 | var reWindowSrc = regexp.MustCompile(`data-src="([^"]*from=25[^"]*)"`) | ||
| 744 | + | ||||
| 745 | + | // advanceMain pushes a commit to main that the feature branch lacks. file is | |||
| 746 | + | // the file it adds (give search.go to conflict with the feature branch). | |||
| 747 | + | func (r *mrRepo) advanceMain(file, content string) { | |||
| 748 | + | r.t.Helper() | |||
| 749 | + | r.mustGit(r.wt, "checkout", "-q", "main") | |||
| 750 | + | r.writeFile(r.wt, file, []byte(content)) | |||
| 751 | + | r.mustGit(r.wt, "push", "-q", r.url, "main") | |||
| 752 | + | r.mustGit(r.wt, "checkout", "-q", "feat/search") | |||
| 753 | + | } | |||
| 754 | + | ||||
| 755 | + | // mrPageWith polls the owner's page of !1 until it contains want. | |||
| 756 | + | func (r *mrRepo) mrPageWith(token, want string) string { | |||
| 757 | + | r.t.Helper() | |||
| 758 | + | var body string | |||
| 759 | + | require.Eventually(r.t, func() bool { | |||
| 760 | + | body = browser{r.harness}.get("/proj/merge-requests/1", as(token)).body | |||
| 761 | + | return strings.Contains(body, want) | |||
| 762 | + | }, 10*time.Second, 25*time.Millisecond, "the page never showed %q", want) | |||
| 763 | + | return body | |||
| 764 | + | } | |||
| 765 | + | ||||
| 766 | + | // The merge box shows the mergeability of the request: ready, then behind | |||
| 767 | + | // (fast-forward is disabled with its reason, the other modes are not), then | |||
| 768 | + | // in conflict (no mode is possible and the button says why). | |||
| 769 | + | func TestWebMergeRequestMergeabilityBadge(t *testing.T) { | |||
| 770 | + | r := newMRRepo(t, domain.VisibilityPublic) | |||
| 771 | + | token := r.signIn(t) | |||
| 772 | + | r.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/search", "--title", "Add search").ok(t) | |||
| 773 | + | ||||
| 774 | + | body := r.mrPageWith(token, "Ready to merge") | |||
| 775 | + | assert.Contains(t, body, `<div id="mr-mergeability" class="live-slot" role="status" data-slot>`) | |||
| 776 | + | assert.Contains(t, body, `data-state="up_to_date"`) | |||
| 777 | + | assert.Contains(t, body, `name="mode" value="fast-forward" checked`) | |||
| 778 | + | assert.NotContains(t, body, `value="fast-forward" checked disabled`) | |||
| 779 | + | assert.Contains(t, body, `id="mr-signing"`, "squash and merge commits say who signs them") | |||
| 780 | + | assert.Contains(t, body, "signed with ermite's own key, generated automatically on first start") | |||
| 781 | + | assert.Contains(t, body, "<code>ermite signing-key show</code>") | |||
| 782 | + | assert.Contains(t, body, "SHA256:", "the fingerprint of the signing key") | |||
| 783 | + | assert.Contains(t, body, `:disabled="`, "the controls follow the badge") | |||
| 784 | + | assertNoInline(t, body) | |||
| 785 | + | ||||
| 786 | + | // Main moves on: the branch is behind by one commit and merges cleanly. | |||
| 787 | + | r.advanceMain("other.txt", "other\n") | |||
| 788 | + | body = r.mrPageWith(token, "Behind main by 1 commit") | |||
| 789 | + | assert.Contains(t, body, "merges without conflicts") | |||
| 790 | + | assert.Contains(t, body, `data-ff-ok="false"`) | |||
| 791 | + | assert.Contains(t, body, `data-merge-ok="true"`) | |||
| 792 | + | assert.Regexp(t, `<input type="radio" name="mode" value="fast-forward"[^>]* disabled`, body) | |||
| 793 | + | assert.Contains(t, body, `title="behind the target: rebase or use squash/merge"`) | |||
| 794 | + | assert.Regexp(t, `name="mode" value="squash" checked`, body, "the first possible mode is checked") | |||
| 795 | + | assert.NotRegexp(t, `name="mode" value="squash"[^>]* disabled`, body) | |||
| 796 | + | ||||
| 797 | + | // A conflicting change to main: every mode is disabled and the files are listed. | |||
| 798 | + | r.mustGit(r.wt, "checkout", "-q", "main") | |||
| 799 | + | r.writeFile(r.wt, "search.go", []byte("package other\n")) | |||
| 800 | + | r.mustGit(r.wt, "push", "-q", r.url, "main") | |||
| 801 | + | body = r.mrPageWith(token, "Conflicts with main") | |||
| 802 | + | assert.Contains(t, body, "rebase needed") | |||
| 803 | + | assert.Contains(t, body, `<ul class="mg-paths" aria-label="conflicting files"><li><code>search.go</code></li></ul>`) | |||
| 804 | + | assert.Regexp(t, `<button type="submit" class="btn primary" data-default disabled title="conflicts with the target"`, body) | |||
| 805 | + | assert.NotContains(t, body, `name="mode" value="squash" checked`) | |||
| 806 | + | ||||
| 807 | + | // The server refuses it too, and says so in the box of the page. | |||
| 808 | + | res := browser{r.harness}.form("/proj/merge-requests/1/merge", token, url.Values{"mode": {"squash"}, "head_id": {formHead(t, body)}}) | |||
| 809 | + | assert.Equal(t, http.StatusConflict, res.status) | |||
| 810 | + | assert.Contains(t, res.body, `<p class="d-err" role="alert">`) | |||
| 811 | + | assert.Contains(t, res.body, "conflicts with main") | |||
| 812 | + | } | |||
| 813 | + | ||||
| 814 | + | // A merge the server refuses (the target moved after the page was read) shows | |||
| 815 | + | // the page of the request again, 409, with the reason in its merge box. | |||
| 816 | + | func TestWebMergeRequestInlineMergeError(t *testing.T) { | |||
| 817 | + | r := newMRRepo(t, domain.VisibilityPublic) | |||
| 818 | + | b := browser{r.harness} | |||
| 819 | + | token := r.signIn(t) | |||
| 820 | + | r.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/search", "--title", "Add search").ok(t) | |||
| 821 | + | body := r.mrPageWith(token, "Ready to merge") | |||
| 822 | + | head := formHead(t, body) | |||
| 823 | + | ||||
| 824 | + | r.advanceMain("other.txt", "other\n") // raced: the form below still says fast-forward | |||
| 825 | + | res := b.form("/proj/merge-requests/1/merge", token, url.Values{"mode": {"fast-forward"}, "head_id": {head}}) | |||
| 826 | + | assert.Equal(t, http.StatusConflict, res.status) | |||
| 827 | + | assert.Equal(t, "no-store", res.hdr.Get("Cache-Control")) | |||
| 828 | + | assert.Contains(t, res.body, `id="merge-menu"`, "the page of the request, not a state page") | |||
| 829 | + | assert.Contains(t, res.body, `<p class="d-err" role="alert">`) | |||
| 830 | + | assert.Contains(t, res.body, "the target has moved") | |||
| 831 | + | assert.Contains(t, res.body, `<details class="menu mmenu" id="merge-menu" data-state="open" open>`, "the box is open on the error") | |||
| 832 | + | assert.Equal(t, head, r.headOf("feat/search")) | |||
| 833 | + | assert.NotContains(t, b.get("/proj/merge-requests/1", as(token)).body, "d-err", "a reload shows no error") | |||
| 834 | + | ||||
| 835 | + | // A head that moved is refused the same way. | |||
| 836 | + | r.writeFile(r.wt, "more.txt", []byte("more\n")) | |||
| 837 | + | r.mustGit(r.wt, "push", "-q", r.url, "feat/search") | |||
| 838 | + | res = b.form("/proj/merge-requests/1/merge", token, url.Values{"mode": {"squash"}, "head_id": {head}}) | |||
| 839 | + | assert.Equal(t, http.StatusConflict, res.status) | |||
| 840 | + | assert.Contains(t, res.body, `<p class="d-err" role="alert">`) | |||
| 841 | + | assert.Contains(t, res.body, "branch moved") | |||
| 842 | + | ||||
| 843 | + | // Other refusals keep the state page; a mode that does not exist is a 400. | |||
| 844 | + | assert.Equal(t, http.StatusBadRequest, b.form("/proj/merge-requests/1/merge", token, url.Values{"mode": {"rebase"}, "head_id": {head}}).status) | |||
| 845 | + | } | |||
| 846 | + | ||||
| 847 | + | // A merge commit through the page; with the branch deletion requested the | |||
| 848 | + | // merged page says the branch is being deleted, not that it is gone. | |||
| 849 | + | func TestWebMergeRequestMergeCommitAndDeletingNote(t *testing.T) { | |||
| 850 | + | r := newMRRepo(t, domain.VisibilityPublic) | |||
| 851 | + | b := browser{r.harness} | |||
| 852 | + | token := r.signIn(t) | |||
| 853 | + | r.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/search", "--title", "Add search").ok(t) | |||
| 854 | + | r.advanceMain("other.txt", "other\n") | |||
| 855 | + | body := r.mrPageWith(token, "Behind main by 1 commit") | |||
| 856 | + | head := formHead(t, body) | |||
| 857 | + | main0 := r.headOf("main") | |||
| 858 | + | ||||
| 859 | + | res := b.form("/proj/merge-requests/1/merge", token, url.Values{"mode": {"merge"}, "head_id": {head}, "delete_branch": {"1"}}) | |||
| 860 | + | require.Equal(t, http.StatusSeeOther, res.status, res.body) | |||
| 861 | + | assert.Equal(t, "/proj/merge-requests/1?deleting=1", res.hdr.Get("Location")) | |||
| 862 | + | merged := r.headOf("main") | |||
| 863 | + | assert.Equal(t, main0, r.bareGit("proj", "rev-parse", merged+"^1")) | |||
| 864 | + | assert.Equal(t, head, r.bareGit("proj", "rev-parse", merged+"^2")) | |||
| 865 | + | assert.Contains(t, r.bareGit("proj", "cat-file", "-p", merged), "gpgsig") | |||
| 866 | + | page := b.get("/proj/merge-requests/1?deleting=1", as(token)) | |||
| 867 | + | assert.Contains(t, page.body, "merge commit") | |||
| 868 | + | if strings.Contains(page.body, "is being deleted") { | |||
| 869 | + | assert.Contains(t, page.body, "feat/search is being deleted") | |||
| 870 | + | } | |||
| 871 | + | assert.NotContains(t, page.body, "is deleted") | |||
| 872 | + | r.forge.MergeRequests.Wait(r.ctx) | |||
| 873 | + | assert.Empty(t, r.bareGit("proj", "for-each-ref", "refs/heads/feat/search")) | |||
| 874 | + | assert.NotContains(t, b.get("/proj/merge-requests/1?deleting=1", as(token)).body, "is being deleted", "once gone, nothing is being deleted") | |||
| 875 | + | } | |||
| 876 | + | ||||
| 877 | + | // The badge follows the request live: a push to the target turns the fragment | |||
| 878 | + | // of the page to "checking" or "behind", and the settled result follows. | |||
| 879 | + | func TestWebMergeRequestMergeabilityLive(t *testing.T) { | |||
| 880 | + | r := newMRRepo(t, domain.VisibilityPublic) | |||
| 881 | + | token := r.signIn(t) | |||
| 882 | + | r.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/search", "--title", "Add search").ok(t) | |||
| 883 | + | r.mrPageWith(token, "Ready to merge") | |||
| 884 | + | ||||
| 885 | + | one := r.sseAs("topic=repo:proj&view=mr&n=1&ref=feat/search&target=main", "", as(token)) | |||
| 886 | + | anon := r.sse("topic=repo:proj&view=mr&n=1&ref=feat/search&target=main", "") | |||
| 887 | + | r.advanceMain("other.txt", "other\n") | |||
| 888 | + | deadline := time.After(watchWait) | |||
| 889 | + | for done := false; !done; { | |||
| 890 | + | ev := nextNamed(t, one, "mr-mergeability") | |||
| 891 | + | select { | |||
| 892 | + | case <-deadline: | |||
| 893 | + | t.Fatal("the badge never showed the result") | |||
| 894 | + | default: | |||
| 895 | + | } | |||
| 896 | + | if strings.Contains(ev.data, "Behind main by 1 commit") { | |||
| 897 | + | assert.Contains(t, ev.data, `<div id="mr-mergeability" class="live-slot" role="status" data-slot>`) | |||
| 898 | + | assert.Contains(t, ev.data, `data-ff-ok="false"`) | |||
| 899 | + | assert.Contains(t, ev.data, `data-merge-ok="true"`) | |||
| 900 | + | done = true | |||
| 901 | + | } else { | |||
| 902 | + | assert.Contains(t, ev.data, "Checking…", "before the result, the badge says it checks") | |||
| 903 | + | } | |||
| 904 | + | } | |||
| 905 | + | // The box is the owner's: a visitor's stream carries no badge. | |||
| 906 | + | for { | |||
| 907 | + | select { | |||
| 908 | + | case ev := <-anon.events: | |||
| 909 | + | assert.NotEqual(t, "mr-mergeability", ev.name) | |||
| 910 | + | continue | |||
| 911 | + | default: | |||
| 912 | + | } | |||
| 913 | + | break | |||
| 914 | + | } | |||
| 915 | + | } | |||
M internal/ports/mocks/MergeRequestService.go +78 -0
| @@ -942,6 +942,84 @@ func (_c *MergeRequestService_Merge_Call) RunAndReturn(run func(ctx context.Cont | |||
| 942 | 942 | return _c | |
| 943 | 943 | } | |
| 944 | 944 | ||
| 945 | + | // Mergeability provides a mock function for the type MergeRequestService | |
| 946 | + | func (_mock *MergeRequestService) Mergeability(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.Mergeability, error) { | |
| 947 | + | ret := _mock.Called(ctx, p, name, number) | |
| 948 | + | ||
| 949 | + | if len(ret) == 0 { | |
| 950 | + | panic("no return value specified for Mergeability") | |
| 951 | + | } | |
| 952 | + | ||
| 953 | + | var r0 domain.Mergeability | |
| 954 | + | var r1 error | |
| 955 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64) (domain.Mergeability, error)); ok { | |
| 956 | + | return returnFunc(ctx, p, name, number) | |
| 957 | + | } | |
| 958 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64) domain.Mergeability); ok { | |
| 959 | + | r0 = returnFunc(ctx, p, name, number) | |
| 960 | + | } else { | |
| 961 | + | r0 = ret.Get(0).(domain.Mergeability) | |
| 962 | + | } | |
| 963 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, int64) error); ok { | |
| 964 | + | r1 = returnFunc(ctx, p, name, number) | |
| 965 | + | } else { | |
| 966 | + | r1 = ret.Error(1) | |
| 967 | + | } | |
| 968 | + | return r0, r1 | |
| 969 | + | } | |
| 970 | + | ||
| 971 | + | // MergeRequestService_Mergeability_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Mergeability' | |
| 972 | + | type MergeRequestService_Mergeability_Call struct { | |
| 973 | + | *mock.Call | |
| 974 | + | } | |
| 975 | + | ||
| 976 | + | // Mergeability is a helper method to define mock.On call | |
| 977 | + | // - ctx context.Context | |
| 978 | + | // - p domain.Principal | |
| 979 | + | // - name domain.RepoName | |
| 980 | + | // - number int64 | |
| 981 | + | func (_e *MergeRequestService_Expecter) Mergeability(ctx any, p any, name any, number any) *MergeRequestService_Mergeability_Call { | |
| 982 | + | return &MergeRequestService_Mergeability_Call{Call: _e.mock.On("Mergeability", ctx, p, name, number)} | |
| 983 | + | } | |
| 984 | + | ||
| 985 | + | func (_c *MergeRequestService_Mergeability_Call) Run(run func(ctx context.Context, p domain.Principal, name domain.RepoName, number int64)) *MergeRequestService_Mergeability_Call { | |
| 986 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 987 | + | var arg0 context.Context | |
| 988 | + | if args[0] != nil { | |
| 989 | + | arg0 = args[0].(context.Context) | |
| 990 | + | } | |
| 991 | + | var arg1 domain.Principal | |
| 992 | + | if args[1] != nil { | |
| 993 | + | arg1 = args[1].(domain.Principal) | |
| 994 | + | } | |
| 995 | + | var arg2 domain.RepoName | |
| 996 | + | if args[2] != nil { | |
| 997 | + | arg2 = args[2].(domain.RepoName) | |
| 998 | + | } | |
| 999 | + | var arg3 int64 | |
| 1000 | + | if args[3] != nil { | |
| 1001 | + | arg3 = args[3].(int64) | |
| 1002 | + | } | |
| 1003 | + | run( | |
| 1004 | + | arg0, | |
| 1005 | + | arg1, | |
| 1006 | + | arg2, | |
| 1007 | + | arg3, | |
| 1008 | + | ) | |
| 1009 | + | }) | |
| 1010 | + | return _c | |
| 1011 | + | } | |
| 1012 | + | ||
| 1013 | + | func (_c *MergeRequestService_Mergeability_Call) Return(mergeability domain.Mergeability, err error) *MergeRequestService_Mergeability_Call { | |
| 1014 | + | _c.Call.Return(mergeability, err) | |
| 1015 | + | return _c | |
| 1016 | + | } | |
| 1017 | + | ||
| 1018 | + | func (_c *MergeRequestService_Mergeability_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.Mergeability, error)) *MergeRequestService_Mergeability_Call { | |
| 1019 | + | _c.Call.Return(run) | |
| 1020 | + | return _c | |
| 1021 | + | } | |
| 1022 | + | ||
| 945 | 1023 | // ResolveComment provides a mock function for the type MergeRequestService | |
| 946 | 1024 | func (_mock *MergeRequestService) ResolveComment(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, commentID int64, resolved bool) (domain.Comment, error) { | |
| 947 | 1025 | ret := _mock.Called(ctx, p, name, number, commentID, resolved) | |
| @@ -942,6 +942,84 @@ func (_c *MergeRequestService_Merge_Call) RunAndReturn(run func(ctx context.Cont | |||||
| 942 | return _c | 942 | return _c | ||
| 943 | } | 943 | } | ||
| 944 | 944 | ||||
| 945 | + | // Mergeability provides a mock function for the type MergeRequestService | |||
| 946 | + | func (_mock *MergeRequestService) Mergeability(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.Mergeability, error) { | |||
| 947 | + | ret := _mock.Called(ctx, p, name, number) | |||
| 948 | + | ||||
| 949 | + | if len(ret) == 0 { | |||
| 950 | + | panic("no return value specified for Mergeability") | |||
| 951 | + | } | |||
| 952 | + | ||||
| 953 | + | var r0 domain.Mergeability | |||
| 954 | + | var r1 error | |||
| 955 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64) (domain.Mergeability, error)); ok { | |||
| 956 | + | return returnFunc(ctx, p, name, number) | |||
| 957 | + | } | |||
| 958 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64) domain.Mergeability); ok { | |||
| 959 | + | r0 = returnFunc(ctx, p, name, number) | |||
| 960 | + | } else { | |||
| 961 | + | r0 = ret.Get(0).(domain.Mergeability) | |||
| 962 | + | } | |||
| 963 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, int64) error); ok { | |||
| 964 | + | r1 = returnFunc(ctx, p, name, number) | |||
| 965 | + | } else { | |||
| 966 | + | r1 = ret.Error(1) | |||
| 967 | + | } | |||
| 968 | + | return r0, r1 | |||
| 969 | + | } | |||
| 970 | + | ||||
| 971 | + | // MergeRequestService_Mergeability_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Mergeability' | |||
| 972 | + | type MergeRequestService_Mergeability_Call struct { | |||
| 973 | + | *mock.Call | |||
| 974 | + | } | |||
| 975 | + | ||||
| 976 | + | // Mergeability is a helper method to define mock.On call | |||
| 977 | + | // - ctx context.Context | |||
| 978 | + | // - p domain.Principal | |||
| 979 | + | // - name domain.RepoName | |||
| 980 | + | // - number int64 | |||
| 981 | + | func (_e *MergeRequestService_Expecter) Mergeability(ctx any, p any, name any, number any) *MergeRequestService_Mergeability_Call { | |||
| 982 | + | return &MergeRequestService_Mergeability_Call{Call: _e.mock.On("Mergeability", ctx, p, name, number)} | |||
| 983 | + | } | |||
| 984 | + | ||||
| 985 | + | func (_c *MergeRequestService_Mergeability_Call) Run(run func(ctx context.Context, p domain.Principal, name domain.RepoName, number int64)) *MergeRequestService_Mergeability_Call { | |||
| 986 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 987 | + | var arg0 context.Context | |||
| 988 | + | if args[0] != nil { | |||
| 989 | + | arg0 = args[0].(context.Context) | |||
| 990 | + | } | |||
| 991 | + | var arg1 domain.Principal | |||
| 992 | + | if args[1] != nil { | |||
| 993 | + | arg1 = args[1].(domain.Principal) | |||
| 994 | + | } | |||
| 995 | + | var arg2 domain.RepoName | |||
| 996 | + | if args[2] != nil { | |||
| 997 | + | arg2 = args[2].(domain.RepoName) | |||
| 998 | + | } | |||
| 999 | + | var arg3 int64 | |||
| 1000 | + | if args[3] != nil { | |||
| 1001 | + | arg3 = args[3].(int64) | |||
| 1002 | + | } | |||
| 1003 | + | run( | |||
| 1004 | + | arg0, | |||
| 1005 | + | arg1, | |||
| 1006 | + | arg2, | |||
| 1007 | + | arg3, | |||
| 1008 | + | ) | |||
| 1009 | + | }) | |||
| 1010 | + | return _c | |||
| 1011 | + | } | |||
| 1012 | + | ||||
| 1013 | + | func (_c *MergeRequestService_Mergeability_Call) Return(mergeability domain.Mergeability, err error) *MergeRequestService_Mergeability_Call { | |||
| 1014 | + | _c.Call.Return(mergeability, err) | |||
| 1015 | + | return _c | |||
| 1016 | + | } | |||
| 1017 | + | ||||
| 1018 | + | func (_c *MergeRequestService_Mergeability_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.Mergeability, error)) *MergeRequestService_Mergeability_Call { | |||
| 1019 | + | _c.Call.Return(run) | |||
| 1020 | + | return _c | |||
| 1021 | + | } | |||
| 1022 | + | ||||
| 945 | // ResolveComment provides a mock function for the type MergeRequestService | 1023 | // ResolveComment provides a mock function for the type MergeRequestService | ||
| 946 | func (_mock *MergeRequestService) ResolveComment(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, commentID int64, resolved bool) (domain.Comment, error) { | 1024 | func (_mock *MergeRequestService) ResolveComment(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, commentID int64, resolved bool) (domain.Comment, error) { | ||
| 947 | ret := _mock.Called(ctx, p, name, number, commentID, resolved) | 1025 | ret := _mock.Called(ctx, p, name, number, commentID, resolved) | ||
M internal/ports/services.go +4 -0
| @@ -124,6 +124,10 @@ type MergeRequestService interface { | |||
| 124 | 124 | // CountOpen is the number of open merge requests of a repository the | |
| 125 | 125 | // principal can read. It reads no Git: the tab of every page shows it. | |
| 126 | 126 | CountOpen(ctx context.Context, p domain.Principal, name domain.RepoName) (int, error) | |
| 127 | + | // Mergeability is the mergeability of an open merge request, "checking" | |
| 128 | + | // until the server has a result for the current tips; zero for a request | |
| 129 | + | // that is not open. It reads no diff: the live pages call it on each change. | |
| 130 | + | Mergeability(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.Mergeability, error) | |
| 127 | 131 | } | |
| 128 | 132 | ||
| 129 | 133 | // IssueService is the issue use cases. *usecase.Issues implements it. | |
| @@ -124,6 +124,10 @@ type MergeRequestService interface { | |||||
| 124 | // CountOpen is the number of open merge requests of a repository the | 124 | // CountOpen is the number of open merge requests of a repository the | ||
| 125 | // principal can read. It reads no Git: the tab of every page shows it. | 125 | // principal can read. It reads no Git: the tab of every page shows it. | ||
| 126 | CountOpen(ctx context.Context, p domain.Principal, name domain.RepoName) (int, error) | 126 | CountOpen(ctx context.Context, p domain.Principal, name domain.RepoName) (int, error) | ||
| 127 | + | // Mergeability is the mergeability of an open merge request, "checking" | |||
| 128 | + | // until the server has a result for the current tips; zero for a request | |||
| 129 | + | // that is not open. It reads no diff: the live pages call it on each change. | |||
| 130 | + | Mergeability(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.Mergeability, error) | |||
| 127 | } | 131 | } | ||
| 128 | 132 | ||||
| 129 | // IssueService is the issue use cases. *usecase.Issues implements it. | 133 | // IssueService is the issue use cases. *usecase.Issues implements it. | ||
M internal/usecase/mergerequests.go +22 -0
| @@ -440,6 +440,28 @@ func (m *MergeRequests) CountOpen(ctx context.Context, p domain.Principal, name | |||
| 440 | 440 | return n, nil | |
| 441 | 441 | } | |
| 442 | 442 | ||
| 443 | + | // Mergeability implements ports.MergeRequestService: the mergeability of an | |
| 444 | + | // open merge request for the current tips, queued when there is no up-to-date | |
| 445 | + | // result. It is the zero value for a request that is not open, or without a | |
| 446 | + | // mergeability service. | |
| 447 | + | func (m *MergeRequests) Mergeability(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.Mergeability, error) { | |
| 448 | + | _, mr, err := m.get(ctx, p, name, number) | |
| 449 | + | if err != nil { | |
| 450 | + | return domain.Mergeability{}, err | |
| 451 | + | } | |
| 452 | + | if m.mergeab == nil || !mr.IsOpen() { | |
| 453 | + | return domain.Mergeability{}, nil | |
| 454 | + | } | |
| 455 | + | if mr, err = m.withHead(ctx, mr); err != nil { | |
| 456 | + | return domain.Mergeability{}, err | |
| 457 | + | } | |
| 458 | + | tip, err := m.branchOrZero(ctx, name, mr.Target) | |
| 459 | + | if err != nil { | |
| 460 | + | return domain.Mergeability{}, err | |
| 461 | + | } | |
| 462 | + | return m.mergeab.Read(ctx, mr, tip), nil | |
| 463 | + | } | |
| 464 | + | ||
| 443 | 465 | func encodeMRToken(last domain.MergeRequest) string { | |
| 444 | 466 | return base64.RawURLEncoding.EncodeToString([]byte(mrTokenPrefix + strconv.FormatInt(last.Number, 10))) | |
| 445 | 467 | } | |
| @@ -440,6 +440,28 @@ func (m *MergeRequests) CountOpen(ctx context.Context, p domain.Principal, name | |||||
| 440 | return n, nil | 440 | return n, nil | ||
| 441 | } | 441 | } | ||
| 442 | 442 | ||||
| 443 | + | // Mergeability implements ports.MergeRequestService: the mergeability of an | |||
| 444 | + | // open merge request for the current tips, queued when there is no up-to-date | |||
| 445 | + | // result. It is the zero value for a request that is not open, or without a | |||
| 446 | + | // mergeability service. | |||
| 447 | + | func (m *MergeRequests) Mergeability(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.Mergeability, error) { | |||
| 448 | + | _, mr, err := m.get(ctx, p, name, number) | |||
| 449 | + | if err != nil { | |||
| 450 | + | return domain.Mergeability{}, err | |||
| 451 | + | } | |||
| 452 | + | if m.mergeab == nil || !mr.IsOpen() { | |||
| 453 | + | return domain.Mergeability{}, nil | |||
| 454 | + | } | |||
| 455 | + | if mr, err = m.withHead(ctx, mr); err != nil { | |||
| 456 | + | return domain.Mergeability{}, err | |||
| 457 | + | } | |||
| 458 | + | tip, err := m.branchOrZero(ctx, name, mr.Target) | |||
| 459 | + | if err != nil { | |||
| 460 | + | return domain.Mergeability{}, err | |||
| 461 | + | } | |||
| 462 | + | return m.mergeab.Read(ctx, mr, tip), nil | |||
| 463 | + | } | |||
| 464 | + | ||||
| 443 | func encodeMRToken(last domain.MergeRequest) string { | 465 | func encodeMRToken(last domain.MergeRequest) string { | ||
| 444 | return base64.RawURLEncoding.EncodeToString([]byte(mrTokenPrefix + strconv.FormatInt(last.Number, 10))) | 466 | return base64.RawURLEncoding.EncodeToString([]byte(mrTokenPrefix + strconv.FormatInt(last.Number, 10))) | ||
| 445 | } | 467 | } | ||
M skills/ermite/CLI.md +5 -4
| @@ -11,7 +11,7 @@ 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] [--failing-checks-block=false]` | `repo:admin` | only the given flags change; `--description ""` clears it; the branch must exist | | |
| 14 | + | | `repo edit <name> [--description TEXT] [--visibility V] [--default-branch B] [--merge-modes fast-forward,squash,merge] [--failing-checks-block=false]` | `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 | 17 | Repository JSON: `name`, `description`, `visibility`, `default_branch`, `created_at`, `pushed_at` (null before the first push), `merge_modes`, `failing_checks_block`. | |
| @@ -56,12 +56,12 @@ A token can never grant more than the token that creates it. `--repo` (repeatabl | |||
| 56 | 56 | |---|---|---| | |
| 57 | 57 | | `mr create <repo> --source B --title T [--target B] [--description TEXT\|-]` | `mr:write` | target defaults to the default branch; title of 120 characters max; `-` reads standard input | | |
| 58 | 58 | | `mr list <repo> [--state open\|merged\|closed\|all] [--json]` | none | default `open` | | |
| 59 | - | | `mr show <repo> <n> [--diff] [--since-last-read] [--mark-read] [--json]` | `mr:write` for `--mark-read` | commits, changed files and comments; `--diff` adds hunks with line numbers | | |
| 59 | + | | `mr show <repo> <n> [--diff] [--since-last-read] [--mark-read] [--json]` | `mr:write` for `--mark-read` | commits, changed files and comments; `--diff` adds hunks with line numbers; the `mergeability` line (JSON `mergeability`, null unless open) is `up_to_date`, `behind` (N commits, merges cleanly), `conflict` (paths) or `checking` | | |
| 60 | 60 | | `mr comment <repo> <n> -m TEXT\|- [--path P --line N\|A-B --side old\|new] [--reply ID] [--approve\|--request-changes]` | `mr:write`; verdicts `mr:review` | Markdown note (8000 characters max); the lines must be in the current diff on one side; a deleted file uses its old path; `--reply` takes a root note (`12`, `#12` or `^12`), inherits its anchor and cannot carry a verdict; a verdict note may have no message | | |
| 61 | 61 | | `mr edit-comment <repo> <n> <comment-id> -m TEXT\|-` | `mr:write` | author only, open merge request; the verdict is unchanged | | |
| 62 | 62 | | `mr delete-comment <repo> <n> <comment-id>` | `mr:write` | author only; the text is lost, the verdict is kept | | |
| 63 | 63 | | `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> [--squash] [--delete-branch]` | `mr:merge` | refused if the branch moved or the target is not an ancestor of the head; `--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`) | | |
| 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`) | | |
| 65 | 65 | | `mr close <repo> <n>` | `mr:write` | a closed merge request cannot be reopened | | |
| 66 | 66 | ||
| 67 | 67 | `ermite mr create` and `ermite mr close` print a confirmation followed by the full | |
| @@ -106,6 +106,7 @@ Workflow JSON: `repo`, `file` (always `.github/workflows/<file>`), `name` (the ` | |||
| 106 | 106 | | `repo_created`, `repo_updated`, `repo_deleted` | `repo`, `repository` | | |
| 107 | 107 | | `pushed` | `repo`, `ref_updates` (`ref`, `old_id`, `new_id`; an empty id means created or deleted) | | |
| 108 | 108 | | `merge_request_opened`, `merge_request_merged`, `merge_request_closed` | `repo`, `merge_request` | | |
| 109 | + | | `merge_request_mergeability` | `repo`, `number`, `mergeability` (`state`: `checking`, `up_to_date`, `behind`, `conflict`; `behind`, `behind_capped`, `conflict_paths`, `conflict_paths_truncated`); sent when the state changes | | |
| 109 | 110 | | `comment_added`, `comment_edited`, `comment_deleted`, `comment_resolved` | `repo`, `number`, `comment` (a verdict note has `verdict` set) | | |
| 110 | 111 | | `gap` | none: events were lost, reload the state | | |
| 111 | 112 | ||
| @@ -116,7 +117,7 @@ Workflow JSON: `repo`, `file` (always `.github/workflows/<file>`), `name` (the ` | |||
| 116 | 117 | | `serve` | configured by `ERMITE_*` environment variables only (see the project README) | | |
| 117 | 118 | | `version [--json]` | client version; with a server URL (`ERMITE_URL` or the config file), the server version and features | | |
| 118 | 119 | | `config show [--json]` / `config set <url\|token.cmd> <value>` / `config unset <key>` | the CLI config file; `token.cmd` is a command that prints the token (any secret manager), never the token itself; `show` never runs it | | |
| 119 | - | | `signing-key show` | public key and `allowed_signers` line to check squash commits with `git verify-commit` | | |
| 120 | + | | `signing-key show` | public key and `allowed_signers` line to check squash and merge commits with `git verify-commit` | | |
| 120 | 121 | | `signing-key trust list [--json]` | keys commit signatures are checked against (the server key first) | | |
| 121 | 122 | | `signing-key trust add --format ssh\|gpg --title <t> --email <e>... <file\|->` / `trust remove <id>` | owner only; a commit shows verified when its committer email is listed for the key | | |
| 122 | 123 | | `web login [--json]` | one-time sign-in link (about 60 s); needs the admin token or a token with every scope, no repository list and no branch prefix; the link is a secret | | |
| @@ -11,7 +11,7 @@ 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] [--failing-checks-block=false]` | `repo:admin` | only the given flags change; `--description ""` clears it; the branch must exist | | 14 | + | | `repo edit <name> [--description TEXT] [--visibility V] [--default-branch B] [--merge-modes fast-forward,squash,merge] [--failing-checks-block=false]` | `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`. | 17 | Repository JSON: `name`, `description`, `visibility`, `default_branch`, `created_at`, `pushed_at` (null before the first push), `merge_modes`, `failing_checks_block`. | ||
| @@ -56,12 +56,12 @@ A token can never grant more than the token that creates it. `--repo` (repeatabl | |||||
| 56 | |---|---|---| | 56 | |---|---|---| | ||
| 57 | | `mr create <repo> --source B --title T [--target B] [--description TEXT\|-]` | `mr:write` | target defaults to the default branch; title of 120 characters max; `-` reads standard input | | 57 | | `mr create <repo> --source B --title T [--target B] [--description TEXT\|-]` | `mr:write` | target defaults to the default branch; title of 120 characters max; `-` reads standard input | | ||
| 58 | | `mr list <repo> [--state open\|merged\|closed\|all] [--json]` | none | default `open` | | 58 | | `mr list <repo> [--state open\|merged\|closed\|all] [--json]` | none | default `open` | | ||
| 59 | - | | `mr show <repo> <n> [--diff] [--since-last-read] [--mark-read] [--json]` | `mr:write` for `--mark-read` | commits, changed files and comments; `--diff` adds hunks with line numbers | | 59 | + | | `mr show <repo> <n> [--diff] [--since-last-read] [--mark-read] [--json]` | `mr:write` for `--mark-read` | commits, changed files and comments; `--diff` adds hunks with line numbers; the `mergeability` line (JSON `mergeability`, null unless open) is `up_to_date`, `behind` (N commits, merges cleanly), `conflict` (paths) or `checking` | |
| 60 | | `mr comment <repo> <n> -m TEXT\|- [--path P --line N\|A-B --side old\|new] [--reply ID] [--approve\|--request-changes]` | `mr:write`; verdicts `mr:review` | Markdown note (8000 characters max); the lines must be in the current diff on one side; a deleted file uses its old path; `--reply` takes a root note (`12`, `#12` or `^12`), inherits its anchor and cannot carry a verdict; a verdict note may have no message | | 60 | | `mr comment <repo> <n> -m TEXT\|- [--path P --line N\|A-B --side old\|new] [--reply ID] [--approve\|--request-changes]` | `mr:write`; verdicts `mr:review` | Markdown note (8000 characters max); the lines must be in the current diff on one side; a deleted file uses its old path; `--reply` takes a root note (`12`, `#12` or `^12`), inherits its anchor and cannot carry a verdict; a verdict note may have no message | | ||
| 61 | | `mr edit-comment <repo> <n> <comment-id> -m TEXT\|-` | `mr:write` | author only, open merge request; the verdict is unchanged | | 61 | | `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 | | 62 | | `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 | | 63 | | `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> [--squash] [--delete-branch]` | `mr:merge` | refused if the branch moved or the target is not an ancestor of the head; `--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`) | | 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`) | |
| 65 | | `mr close <repo> <n>` | `mr:write` | a closed merge request cannot be reopened | | 65 | | `mr close <repo> <n>` | `mr:write` | a closed merge request cannot be reopened | | ||
| 66 | 66 | ||||
| 67 | `ermite mr create` and `ermite mr close` print a confirmation followed by the full | 67 | `ermite mr create` and `ermite mr close` print a confirmation followed by the full | ||
| @@ -106,6 +106,7 @@ Workflow JSON: `repo`, `file` (always `.github/workflows/<file>`), `name` (the ` | |||||
| 106 | | `repo_created`, `repo_updated`, `repo_deleted` | `repo`, `repository` | | 106 | | `repo_created`, `repo_updated`, `repo_deleted` | `repo`, `repository` | | ||
| 107 | | `pushed` | `repo`, `ref_updates` (`ref`, `old_id`, `new_id`; an empty id means created or deleted) | | 107 | | `pushed` | `repo`, `ref_updates` (`ref`, `old_id`, `new_id`; an empty id means created or deleted) | | ||
| 108 | | `merge_request_opened`, `merge_request_merged`, `merge_request_closed` | `repo`, `merge_request` | | 108 | | `merge_request_opened`, `merge_request_merged`, `merge_request_closed` | `repo`, `merge_request` | | ||
| 109 | + | | `merge_request_mergeability` | `repo`, `number`, `mergeability` (`state`: `checking`, `up_to_date`, `behind`, `conflict`; `behind`, `behind_capped`, `conflict_paths`, `conflict_paths_truncated`); sent when the state changes | | |||
| 109 | | `comment_added`, `comment_edited`, `comment_deleted`, `comment_resolved` | `repo`, `number`, `comment` (a verdict note has `verdict` set) | | 110 | | `comment_added`, `comment_edited`, `comment_deleted`, `comment_resolved` | `repo`, `number`, `comment` (a verdict note has `verdict` set) | | ||
| 110 | | `gap` | none: events were lost, reload the state | | 111 | | `gap` | none: events were lost, reload the state | | ||
| 111 | 112 | ||||
| @@ -116,7 +117,7 @@ Workflow JSON: `repo`, `file` (always `.github/workflows/<file>`), `name` (the ` | |||||
| 116 | | `serve` | configured by `ERMITE_*` environment variables only (see the project README) | | 117 | | `serve` | configured by `ERMITE_*` environment variables only (see the project README) | | ||
| 117 | | `version [--json]` | client version; with a server URL (`ERMITE_URL` or the config file), the server version and features | | 118 | | `version [--json]` | client version; with a server URL (`ERMITE_URL` or the config file), the server version and features | | ||
| 118 | | `config show [--json]` / `config set <url\|token.cmd> <value>` / `config unset <key>` | the CLI config file; `token.cmd` is a command that prints the token (any secret manager), never the token itself; `show` never runs it | | 119 | | `config show [--json]` / `config set <url\|token.cmd> <value>` / `config unset <key>` | the CLI config file; `token.cmd` is a command that prints the token (any secret manager), never the token itself; `show` never runs it | | ||
| 119 | - | | `signing-key show` | public key and `allowed_signers` line to check squash commits with `git verify-commit` | | 120 | + | | `signing-key show` | public key and `allowed_signers` line to check squash and merge commits with `git verify-commit` | |
| 120 | | `signing-key trust list [--json]` | keys commit signatures are checked against (the server key first) | | 121 | | `signing-key trust list [--json]` | keys commit signatures are checked against (the server key first) | | ||
| 121 | | `signing-key trust add --format ssh\|gpg --title <t> --email <e>... <file\|->` / `trust remove <id>` | owner only; a commit shows verified when its committer email is listed for the key | | 122 | | `signing-key trust add --format ssh\|gpg --title <t> --email <e>... <file\|->` / `trust remove <id>` | owner only; a commit shows verified when its committer email is listed for the key | | ||
| 122 | | `web login [--json]` | one-time sign-in link (about 60 s); needs the admin token or a token with every scope, no repository list and no branch prefix; the link is a secret | | 123 | | `web login [--json]` | one-time sign-in link (about 60 s); needs the admin token or a token with every scope, no repository list and no branch prefix; the link is a secret | | ||
M skills/ermite/SKILL.md +2 -2
| @@ -37,9 +37,9 @@ 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 `--squash` for one commit signed by the server and `--delete-branch` to delete the source branch afterwards. | |
| 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. | |
| 41 | 41 | ||
| 42 | - | The merge is refused when the branch moved since `head_id` (review again) or when the target is not an ancestor of the head (rebase and push again). The repository may allow only one merge mode. | |
| 42 | + | 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. | |
| 43 | 43 | ||
| 44 | 44 | ### Act on a review | |
| 45 | 45 | ||
| @@ -37,9 +37,9 @@ 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 `--squash` for one commit signed by the server and `--delete-branch` to delete the source branch afterwards. | 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. |
| 41 | 41 | ||||
| 42 | - | The merge is refused when the branch moved since `head_id` (review again) or when the target is not an ancestor of the head (rebase and push again). The repository may allow only one merge mode. | 42 | + | 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. |
| 43 | 43 | ||||
| 44 | ### Act on a review | 44 | ### Act on a review | ||
| 45 | 45 | ||||