detached 1790409

feat(web,cli): live mergeability in the merge box, merge modes and inline merge errors

commit
179040959e01a697a0632f89990173d53e7c7067
author
brice · · 2026-10-19 14:23 UTC
committer
brice · · 2026-10-19 14:37 UTC
parent
a39330a
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
37 files · +2395 -852
M README.md +1 -1
@@ -122,7 +122,7 @@ ermite mr show myrepo 1 --since-last-read --mark-read
122122 **5. You merge** (or click Merge in the web interface):
123123
124124 ```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
126126 ```
127127
128128 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 ```sh124 ```sh
125-ermite mr merge myrepo 1 --head <head id> --squash --delete-branch125+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]
5454 ermite token create <label> [--preset cli|agent|ci|runner] [--scope …] [--repo …] [--branch-prefix …] [--expires 30d]
5555 ermite token list | revoke <id>
5656
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
5858 ermite mr comment <repo> <n> -m … [--path … --line N|A-B --side old|new] [--reply <id>] [--approve|--request-changes]
5959 ermite mr resolve <repo> <n> <comment> | edit-comment <repo> <n> <comment> -m … | delete-comment <repo> <n> <comment>
60-ermite mr merge <repo> <n> --head <id> [--squash] [--delete-branch] | close <repo> <n>
60+ermite mr merge <repo> <n> --head <id> [--mode fast-forward|squash|merge] [--delete-branch] | close <repo> <n>
6161
6262 ermite issue create <repo> --title … [--body … | --body-file …] | list <repo> [--state open|closed|all]
6363 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>
7878 ermite web login # one-time sign-in link (60 s)
7979 ermite web logout --all # sign every browser out
8080 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)
8282 ermite oidc rotate-key
8383 ermite watch [repo] [--json] # follow live events
8484 ermite version # client and server versions
@@ -90,7 +90,8 @@ Issues need a server with the `issues` feature and `ermite repo edit <repo> --is
9090 ## Output
9191
9292 - `--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`.
9495 - Errors are one line on stderr (`ermite: …`) with exit status 1.
9596 - Shell completion completes repository names from the server.
9697
@@ -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 out79 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 commits81+ermite signing-key show # public key of squash and merge commits (server-signed)
82 ermite oidc rotate-key82 ermite oidc rotate-key
83 ermite watch [repo] [--json] # follow live events83 ermite watch [repo] [--json] # follow live events
84 ermite version # client and server versions84 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 ## Output90 ## 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>
3636
3737 - A key may sign for 1 to 10 emails; up to 100 keys.
3838 - 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.
4040 - 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
3434 |---|---|---|
3535 | `ERMITE_GIT` | `git` | git 2.45 or newer |
3636 | `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 |
3939
4040 ## CI
4141
@@ -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 ## CI40 ## 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).
55 ```sh
66 ermite mr create myrepo --source feature --title "Add feature"
77 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]
99 ```
1010
1111 `ermite mr create` and `ermite mr close` print a confirmation followed by the full
1212 merge request web URL on its own line. With `--json`, merge request objects
1313 (create, show, list, close and merge) include `url` alongside the other fields.
1414
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`.
1726 - 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).
2029 - Scopes: `mr:write` to open and comment, `mr:review` to give a verdict, `mr:merge` to merge.
2130
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) |
2352
2453 ```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)
2656 ```
2757
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+
2866 ## Notes
2967
3068 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 ```sh5 ```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 --diff7 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 full11 `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 objects12 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 ```sh53 ```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 ## Notes66 ## 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 (
3131 // GET /-/events?topic=repo:<name>&view=summary|refs
3232 // GET /-/events?topic=repo:<name>&view=commits&ref=<branch>&head=<sha>
3333 // 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>
3535 // GET /-/events?topic=repo:<name>&view=issues
3636 // GET /-/events?topic=repo:<name>&view=issue&n=<number>
3737 // GET /-/events?topic=repo:<name>&view=actions
@@ -54,6 +54,10 @@ import (
5454 // refs-list data: <tbody id="ref-rows">…</tbody>, and again with
5555 // <dl id="refs-kv">…</dl>, the facts of the rail
5656 // commits-head data: <div id="commits-head">N new commits — reload</div>
57+// mr-mergeability data: <div id="mr-mergeability">…</div>, on the merge request pages:
58+// the badge of the merge box (a slot like mr-notice), when the
59+// mergeability of the request shown settles or a push moves its
60+// source or target
5761 // mr-notice data: <div id="mr-notice">…</div>, on the merge request and issue pages:
5862 // something changed, with the link that reloads the page
5963 // ci-run data: <tr id="run-<id>">…</tr> (the list of runs),
@@ -252,7 +256,7 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) {
252256 return
253257 }
254258
255- 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}
259+ 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}
256260 if topic == ownerTopic {
257261 st.filter = q.Get("repo")
258262 }
@@ -31,7 +31,7 @@ import (
31 // GET /-/events?topic=repo:<name>&view=summary|refs31 // 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=mrs33 // 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=issues35 // 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=actions37 // GET /-/events?topic=repo:<name>&view=actions
@@ -54,6 +54,10 @@ import (
54 // refs-list data: <tbody id="ref-rows">…</tbody>, and again with54 // refs-list data: <tbody id="ref-rows">…</tbody>, and again with
55 // <dl id="refs-kv">…</dl>, the facts of the rail55 // <dl id="refs-kv">…</dl>, the facts of the rail
56 // commits-head data: <div id="commits-head">N new commits — reload</div>56 // commits-head data: <div id="commits-head">N new commits — reload</div>
57+// mr-mergeability data: <div id="mr-mergeability">…</div>, on the merge request pages:
58+// the badge of the merge box (a slot like mr-notice), when the
59+// mergeability of the request shown settles or a push moves its
60+// source or target
57 // mr-notice data: <div id="mr-notice">…</div>, on the merge request and issue pages:61 // mr-notice data: <div id="mr-notice">…</div>, on the merge request and issue pages:
58 // something changed, with the link that reloads the page62 // something changed, with the link that reloads the page
59 // ci-run data: <tr id="run-<id>">…</tr> (the list of runs),63 // ci-run data: <tr id="run-<id>">…</tr> (the list of runs),
@@ -252,7 +256,7 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) {
252 return256 return
253 }257 }
254 258
255- 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}259+ 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}
256 if topic == ownerTopic {260 if topic == ownerTopic {
257 st.filter = q.Get("repo")261 st.filter = q.Get("repo")
258 }262 }
M internal/adapters/web/fragments.go +58 -8
@@ -28,6 +28,7 @@ type liveStream struct {
2828 head string // commits: the commit listed first
2929 n string // mr: the number of the merge request shown; run: the run id
3030 job string // job: the id of the job shown
31+ target string // mr: the target branch of the merge request shown
3132 // sha is the commit of the run of the job shown, read once when the stream
3233 // is set up: the annotations link to files at it, and it never changes.
3334 sha string
@@ -60,7 +61,7 @@ func liveEvent(we ports.WatchEvent) bool {
6061 }
6162 switch we.Event.(type) {
6263 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,
6465 domain.RunUpdated, domain.JobUpdated,
6566 domain.IssueOpened, domain.IssueClosed, domain.IssueReopened, domain.IssueCommented, domain.IssueSubmitted, domain.IssuePendingRemoved:
6667 return true
@@ -170,6 +171,11 @@ func (l *liveStream) repoPage(ctx context.Context, we ports.WatchEvent) ([]fragm
170171 return l.mrNotice(ctx, ev.MergeRequest.Number, "this merge request was closed — reload")
171172 case domain.MergeRequestReady:
172173 return l.mrNotice(ctx, ev.MergeRequest.Number, "this merge request is ready for review — reload")
174+ case domain.MergeRequestMergeability:
175+ if l.view != "mr" || strconv.FormatInt(ev.Number, 10) != l.n {
176+ return nil, nil
177+ }
178+ return l.mrMergeability(ctx)
173179 case domain.CommentEdited:
174180 return l.mrNotice(ctx, ev.Number, "a note was edited — reload")
175181 case domain.CommentDeleted:
@@ -436,21 +442,65 @@ func (l *liveStream) issueNotice(ctx context.Context, number int64, text string)
436442 return nil, nil
437443 }
438444
439-// mrPushed announces a push to the source branch of the merge request shown.
445+// mrPushed announces a push to the source branch of the merge request shown,
446+// and has the badge follow a push to its source or its target.
440447 func (l *liveStream) mrPushed(ctx context.Context, p domain.PushEvent) ([]fragment, error) {
441- if l.ref == "" {
448+ n, err := strconv.ParseInt(l.n, 10, 64)
449+ if err != nil {
442450 return nil, nil
443451 }
452+ var out []fragment
453+ moved := false
444454 for _, u := range p.Updates {
445- if u.Ref == "refs/heads/"+l.ref {
446- n, err := strconv.ParseInt(l.n, 10, 64)
455+ switch {
456+ case l.ref != "" && u.Ref == "refs/heads/"+l.ref:
457+ notice, err := l.notice(ctx, "the branch changed — reload", mrHref(l.repo, n))
447458 if err != nil {
448- return nil, nil
459+ return nil, err
449460 }
450- return l.notice(ctx, "the branch changed — reload", mrHref(l.repo, n))
461+ out = append(out, notice...)
462+ moved = true
463+ case l.target != "" && u.Ref == "refs/heads/"+l.target:
464+ moved = true
465+ }
466+ if moved && len(out) > 0 {
467+ break
451468 }
452469 }
453- return nil, nil
470+ if !moved {
471+ return nil, nil
472+ }
473+ badge, err := l.mrMergeability(ctx)
474+ return append(out, badge...), err
475+}
476+
477+// mrMergeability is the badge of the merge box, read again: the result for the
478+// tips as they are now, "checking" until the server has one (it then sends the
479+// MergeRequestMergeability that turns the badge). Nothing for a request that
480+// is not open.
481+func (l *liveStream) mrMergeability(ctx context.Context) ([]fragment, error) {
482+ if !SignedIn(ctx) || l.s.cfg.MergeRequests == nil {
483+ return nil, nil // the box is the owner's
484+ }
485+ name, err := domain.ParseRepoName(l.repo)
486+ if err != nil {
487+ return nil, nil
488+ }
489+ n, err := strconv.ParseInt(l.n, 10, 64)
490+ if err != nil {
491+ return nil, nil
492+ }
493+ m, err := l.s.cfg.MergeRequests.Mergeability(ctx, principal(ctx), name, n)
494+ switch {
495+ case errors.Is(err, domain.ErrNotFound):
496+ return gap, nil
497+ case err != nil:
498+ return nil, err
499+ case m.State == "":
500+ return nil, nil
501+ }
502+ f, err := renderFragment(ctx, "mr-mergeability", MRMergeabilityFragment(mergeabilityView(m, l.target)))
503+ return []fragment{f}, err
454504 }
455505
456506 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 first28 head string // commits: the commit listed first
29 n string // mr: the number of the merge request shown; run: the run id29 n string // mr: the number of the merge request shown; run: the run id
30 job string // job: the id of the job shown30 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 stream32 // 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 string34 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 true67 return true
@@ -170,6 +171,11 @@ func (l *liveStream) repoPage(ctx context.Context, we ports.WatchEvent) ([]fragm
170 return l.mrNotice(ctx, ev.MergeRequest.Number, "this merge request was closed — reload")171 return l.mrNotice(ctx, ev.MergeRequest.Number, "this merge request was closed — reload")
171 case domain.MergeRequestReady:172 case domain.MergeRequestReady:
172 return l.mrNotice(ctx, ev.MergeRequest.Number, "this merge request is ready for review — reload")173 return l.mrNotice(ctx, ev.MergeRequest.Number, "this merge request is ready for review — reload")
174+ case domain.MergeRequestMergeability:
175+ if l.view != "mr" || strconv.FormatInt(ev.Number, 10) != l.n {
176+ return nil, nil
177+ }
178+ return l.mrMergeability(ctx)
173 case domain.CommentEdited:179 case domain.CommentEdited:
174 return l.mrNotice(ctx, ev.Number, "a note was edited — reload")180 return l.mrNotice(ctx, ev.Number, "a note was edited — reload")
175 case domain.CommentDeleted:181 case domain.CommentDeleted:
@@ -436,21 +442,65 @@ func (l *liveStream) issueNotice(ctx context.Context, number int64, text string)
436 return nil, nil442 return nil, nil
437 }443 }
438 444
439-// mrPushed announces a push to the source branch of the merge request shown.445+// mrPushed announces a push to the source branch of the merge request shown,
446+// and has the badge follow a push to its source or its target.
440 func (l *liveStream) mrPushed(ctx context.Context, p domain.PushEvent) ([]fragment, error) {447 func (l *liveStream) mrPushed(ctx context.Context, p domain.PushEvent) ([]fragment, error) {
441- if l.ref == "" {448+ n, err := strconv.ParseInt(l.n, 10, 64)
449+ if err != nil {
442 return nil, nil450 return nil, nil
443 }451 }
452+ var out []fragment
453+ moved := false
444 for _, u := range p.Updates {454 for _, u := range p.Updates {
445- if u.Ref == "refs/heads/"+l.ref {455+ switch {
446- n, err := strconv.ParseInt(l.n, 10, 64)456+ case l.ref != "" && u.Ref == "refs/heads/"+l.ref:
457+ notice, err := l.notice(ctx, "the branch changed — reload", mrHref(l.repo, n))
447 if err != nil {458 if err != nil {
448- return nil, nil459+ return nil, err
449 }460 }
450- return l.notice(ctx, "the branch changed — reload", mrHref(l.repo, n))461+ out = append(out, notice...)
462+ moved = true
463+ case l.target != "" && u.Ref == "refs/heads/"+l.target:
464+ moved = true
465+ }
466+ if moved && len(out) > 0 {
467+ break
451 }468 }
452 }469 }
453- return nil, nil470+ if !moved {
471+ return nil, nil
472+ }
473+ badge, err := l.mrMergeability(ctx)
474+ return append(out, badge...), err
475+}
476+
477+// mrMergeability is the badge of the merge box, read again: the result for the
478+// tips as they are now, "checking" until the server has one (it then sends the
479+// MergeRequestMergeability that turns the badge). Nothing for a request that
480+// is not open.
481+func (l *liveStream) mrMergeability(ctx context.Context) ([]fragment, error) {
482+ if !SignedIn(ctx) || l.s.cfg.MergeRequests == nil {
483+ return nil, nil // the box is the owner's
484+ }
485+ name, err := domain.ParseRepoName(l.repo)
486+ if err != nil {
487+ return nil, nil
488+ }
489+ n, err := strconv.ParseInt(l.n, 10, 64)
490+ if err != nil {
491+ return nil, nil
492+ }
493+ m, err := l.s.cfg.MergeRequests.Mergeability(ctx, principal(ctx), name, n)
494+ switch {
495+ case errors.Is(err, domain.ErrNotFound):
496+ return gap, nil
497+ case err != nil:
498+ return nil, err
499+ case m.State == "":
500+ return nil, nil
501+ }
502+ f, err := renderFragment(ctx, "mr-mergeability", MRMergeabilityFragment(mergeabilityView(m, l.target)))
503+ return []fragment{f}, err
454 }504 }
455 505
456 func (l *liveStream) notice(ctx context.Context, text, href string) ([]fragment, error) {506 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) {
102102 htmx:sse:close, htmx:sse:error -> ermiteLive.lost();
103103 htmx:sse:error -> ermiteLive.error(event.detail.status);
104104 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);
106106 gap -> ermiteLive.reload()"
107107 ></span>
108108 }
@@ -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 {
517517 if templ_7745c5c3_Err != nil {
518518 return templ_7745c5c3_Err
519519 }
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>")
521521 if templ_7745c5c3_Err != nil {
522522 return templ_7745c5c3_Err
523523 }
@@ -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_Err518 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_Err522 return templ_7745c5c3_Err
523 }523 }
M internal/adapters/web/fragments_test.go +1 -0
@@ -33,6 +33,7 @@ func TestLiveSlotsAreStableStatusRegions(t *testing.T) {
3333 }{
3434 {"empty commits slot", LiveSlot("commits-head"), "commits-head", ""},
3535 {"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"},
3637 {"commits banner", CommitsBanner("2 new commits — reload", "/x/commits/main"), "commits-head", "2 new commits — reload"},
3738 {"mr notice", MRNotice("a comment was added — reload", "/x/merge-requests/1"), "mr-notice", "a comment was added — reload"},
3839 } {
@@ -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 {
196196 // CanDeleteBranch offers to delete the source after the merge: it is not
197197 // the default branch. The server checks again, with the open requests.
198198 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
199208 }
200209
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+}
203231
204232 // SinceNav switches between the whole diff and the diff since the last read.
205233 type SinceNav struct {
@@ -233,6 +261,12 @@ type MRPage struct {
233261 MergedShort string
234262 MergedHref string
235263 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
236270
237271 Since *SinceNav
238272 SinceNote string
@@ -346,10 +380,59 @@ func modeName(m domain.MergeMode) string {
346380 return "fast-forward"
347381 case domain.MergeModeSquash:
348382 return "squash"
383+ case domain.MergeModeMerge:
384+ return "merge commit"
349385 }
350386 return ""
351387 }
352388
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+
353436 // mrRow converts a merge request of a listing.
354437 func (s *Server) mrRow(repo string, m domain.MergeRequest) MRRow {
355438 row := MRRow{
@@ -540,6 +623,9 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
540623 p.MergedHref = repoHref(d.name(), "commit", m.MergedCommitID.String())
541624 p.MergeMode = modeName(m.MergeMode)
542625 }
626+ if signed && m.State == domain.MRStateMerged && r.URL.Query().Get("deleting") == "1" && d.hasBranch(m.Source) {
627+ p.Deleting = visibleText(m.Source)
628+ }
543629 for _, mode := range d.repo.Settings.AllowedMergeModes {
544630 if name := modeName(mode); name != "" {
545631 p.Modes = append(p.Modes, name)
@@ -624,11 +710,15 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
624710 p.Invisible = invisible
625711 p.DiffView = newDiffView(r, lines, withFrom(self+"?view=unified", from), withFrom(self+"?view=split", from))
626712 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)
628715 if m.IsDraft() {
629716 p.Box.ReadyAction = mrHref(d.name(), n, "ready")
630717 }
631718 }
719+ if p.Box == nil {
720+ p.MergeError = mergeErrorOf(ctx) // the request is not open any more
721+ }
632722 if canWrite {
633723 p.GeneralForm = &CommentForm{
634724 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
652742 l.Crumbs = append(l.Crumbs, Crumb{Label: "since", Current: true})
653743 }
654744 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))
656746 l.HasRail = true
657747 l.MR = true
658748 return MergeRequest(l, p), http.StatusOK, nil
@@ -669,37 +759,78 @@ func (d *repoData) hasBranch(name string) bool {
669759 }
670760
671761 // 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 {
673763 b := &MergeBox{
674764 MergeAction: mrHref(d.name(), n, "merge"), CloseAction: mrHref(d.name(), n, "close"),
675765 ReadAction: mrHref(d.name(), n, "read"), Head: head, HeadGone: head == "",
676766 CanMarkRead: head != "" && head != lastRead,
677767 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
678774 }
679- ff, sq := false, false
680775 for _, mode := range d.repo.Settings.AllowedMergeModes {
681776 switch mode {
682777 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)
685780 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")
688788 }
689789 }
790+ if b.Signed && s.cfg.SigningKey != nil {
791+ b.Fingerprint = s.cfg.SigningKey.Info().Key.Fingerprint
792+ }
690793 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}
699795 }
700796 return b
701797 }
702798
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+
703834 // shellWord quotes s for a shell unless it is made of safe characters, which
704835 // a repository name always is.
705836 func shellWord(s string) string {
@@ -196,10 +196,38 @@ type MergeBox struct {
196 // CanDeleteBranch offers to delete the source after the merge: it is not196 // 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 bool198 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 string261 MergedShort string
234 MergedHref string262 MergedHref string
235 MergeMode string263 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 *SinceNav271 Since *SinceNav
238 SinceNote string272 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 = invisible710 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 = true744 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 = true746 l.HasRail = true
657 l.MR = true747 l.MR = true
658 return MergeRequest(l, p), http.StatusOK, nil748 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 = true778+ 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 = true781+ 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 " + head794+ 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 b796 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, which834 // 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 @@
22
33 package web
44
5-import "strings"
5+import (
6+ "strconv"
7+ "strings"
8+)
69
710 // MRList is the list of the merge requests of a repository: the open ones,
811 // then the merged and closed ones.
@@ -138,6 +141,12 @@ templ MergeRequest(l Layout, p MRPage) {
138141 }
139142 </dl>
140143 </header>
144+ if p.Deleting != "" {
145+ <p class="note" role="status">merged. the branch { p.Deleting } is being deleted.</p>
146+ }
147+ if p.MergeError != "" {
148+ <p class="d-err" role="alert">{ p.MergeError }</p>
149+ }
141150 @LiveSlot("mr-notice")
142151 if p.Desc != "" {
143152 <article class="mr-desc prose" aria-label="description" hx-ignore>
@@ -446,8 +455,13 @@ templ mrBar(p MRPage) {
446455 // mergeMenu is the merge of an open request, for the owner: the form (it
447456 // carries the head that was read), or "Ready for review" for a draft. The
448457 // 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.
449463 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 != "" }>
451465 <summary class="btn primary" aria-keyshortcuts="Shift+M">
452466 if p.Box.ReadyAction != "" {
453467 Draft
@@ -469,19 +483,32 @@ templ mergeMenu(p MRPage) {
469483 <dt>into</dt>
470484 <dd>{ p.Target }</dd>
471485 </dl>
486+ if p.Box.Error != "" {
487+ <p class="d-err" role="alert">{ p.Box.Error }</p>
488+ }
472489 if p.Box.ReadyAction != "" {
473490 <p class="d-draft">this is a draft: it cannot be merged until it is ready for review.</p>
474491 <form class="d-btns" method="post" action={ templ.SafeURL(p.Box.ReadyAction) }>
475492 <button type="submit" class="btn primary" data-default title="remove the draft prefix from the title">Ready for review</button>
476493 </form>
477494 } else if !p.Box.HeadGone {
495+ @mrMergeabilitySlot(p.Box.Mergeability)
478496 <form class="d-acts" method="post" action={ templ.SafeURL(p.Box.MergeAction) }>
479497 <input type="hidden" name="head_id" value={ p.Box.Head }/>
480498 <fieldset class="d-modes">
481499 <legend class="sr">merge mode</legend>
482500 for i, m := range p.Box.Modes {
483501 <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+ />
485512 { m.Label }
486513 if i < 9 {
487514 <kbd aria-hidden="true">{ itoa(i + 1) }</kbd>
@@ -489,11 +516,28 @@ templ mergeMenu(p MRPage) {
489516 </label>
490517 }
491518 </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+ }
492528 if p.Box.CanDeleteBranch {
493529 <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>
494530 }
495531 <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>
497541 </div>
498542 </form>
499543 }
@@ -501,6 +545,47 @@ templ mergeMenu(p MRPage) {
501545 </details>
502546 }
503547
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+
504589 // moreMenu is what else the owner does with an open request: mark the head
505590 // read, close it, merge it from a terminal.
506591 templ moreMenu(p MRPage) {
@@ -2,7 +2,10 @@
2 2
3 package web3 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.
@@ -138,6 +141,12 @@ templ MergeRequest(l Layout, p MRPage) {
138 }141 }
139 </dl>142 </dl>
140 </header>143 </header>
144+ if p.Deleting != "" {
145+ <p class="note" role="status">merged. the branch { p.Deleting } is being deleted.</p>
146+ }
147+ if p.MergeError != "" {
148+ <p class="d-err" role="alert">{ p.MergeError }</p>
149+ }
141 @LiveSlot("mr-notice")150 @LiveSlot("mr-notice")
142 if p.Desc != "" {151 if p.Desc != "" {
143 <article class="mr-desc prose" aria-label="description" hx-ignore>152 <article class="mr-desc prose" aria-label="description" hx-ignore>
@@ -446,8 +455,13 @@ templ mrBar(p MRPage) {
446 // mergeMenu is the merge of an open request, for the owner: the form (it455 // 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. The456 // 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 Draft467 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 head589 // 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
6060 return
6161 }
6262 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+ }
6367 if err != nil {
6468 s.actionFailed(ctx, w, r, err, back)
6569 return
@@ -68,6 +72,43 @@ func (s *Server) mrAction(h func(ctx context.Context, r *http.Request) (to strin
6872 }
6973 }
7074
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+
71112 // actionFailed answers a refused action.
72113 func (s *Server) actionFailed(ctx context.Context, w http.ResponseWriter, r *http.Request, err error, back string) {
73114 // 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,
322363 mode = domain.MergeModeFastForward
323364 case "squash":
324365 mode = domain.MergeModeSquash
366+ case "merge":
367+ mode = domain.MergeModeMerge
325368 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}
327371 }
328372 name, err := domain.ParseRepoName(repo)
329373 if err != nil {
@@ -333,11 +377,19 @@ func (s *Server) mrMerge(ctx context.Context, r *http.Request) (string, string,
333377 if err != nil {
334378 return "", back, err
335379 }
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{
337381 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:
339387 return "", back, err
340388 }
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+ }
341393 return back, back, nil
342394 }
343395
@@ -60,6 +60,10 @@ func (s *Server) mrAction(h func(ctx context.Context, r *http.Request) (to strin
60 return60 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 return69 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.MergeModeFastForward363 mode = domain.MergeModeFastForward
323 case "squash":364 case "squash":
324 mode = domain.MergeModeSquash365 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, err378 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, err387 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, nil393 return back, back, nil
342 }394 }
343 395
M internal/adapters/web/mr_templ.go +1187 -740 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
@@ -63,7 +63,22 @@
6363 & .d-btns { display: flex; margin-top: var(--s1); }
6464 & .d-btns .btn { flex: 1; justify-content: center; }
6565 & .d-draft { color: var(--ink-dim); }
66- }
66+ & .d-note { margin: 0; color: var(--ink-dim); }
67+ & .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; }
68+ /* the badge is a live slot, but not a banner */
69+ & #mr-mergeability:not(:empty) { padding: 0; font-weight: normal; background: none; border: 0; display: grid; gap: var(--s1); }
70+ & .mg-paths { margin: 0; padding-left: 2ch; max-height: 8em; overflow: auto; }
71+ & .mg-paths code { overflow-wrap: anywhere; }
72+ & .d-modes label:has(:disabled) { color: var(--ink-mute); cursor: not-allowed; }
73+ & .d-modes label:has(:disabled):hover { background: none; }
74+ & .btn:disabled { opacity: .5; cursor: not-allowed; }
75+ }
76+ .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); }
77+ .badge[data-state="up_to_date"] { border-color: var(--teal); color: var(--teal); }
78+ .badge[data-state="behind"] { border-color: var(--teal-2); }
79+ .badge[data-state="conflict"] { border-color: var(--red); color: var(--red); }
80+ .badge[data-state="checking"] { color: var(--ink-dim); border-style: dashed; }
81+ .note[role="status"] { color: var(--ink-dim); }
6782 .omenu {
6883 & > summary { font-size: 18px; line-height: 1; }
6984 & .menu-pop { width: 280px; }
@@ -63,7 +63,22 @@
63 & .d-btns { display: flex; margin-top: var(--s1); }63 & .d-btns { display: flex; margin-top: var(--s1); }
64 & .d-btns .btn { flex: 1; justify-content: center; }64 & .d-btns .btn { flex: 1; justify-content: center; }
65 & .d-draft { color: var(--ink-dim); }65 & .d-draft { color: var(--ink-dim); }
66- }66+ & .d-note { margin: 0; color: var(--ink-dim); }
67+ & .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; }
68+ /* the badge is a live slot, but not a banner */
69+ & #mr-mergeability:not(:empty) { padding: 0; font-weight: normal; background: none; border: 0; display: grid; gap: var(--s1); }
70+ & .mg-paths { margin: 0; padding-left: 2ch; max-height: 8em; overflow: auto; }
71+ & .mg-paths code { overflow-wrap: anywhere; }
72+ & .d-modes label:has(:disabled) { color: var(--ink-mute); cursor: not-allowed; }
73+ & .d-modes label:has(:disabled):hover { background: none; }
74+ & .btn:disabled { opacity: .5; cursor: not-allowed; }
75+ }
76+ .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); }
77+ .badge[data-state="up_to_date"] { border-color: var(--teal); color: var(--teal); }
78+ .badge[data-state="behind"] { border-color: var(--teal-2); }
79+ .badge[data-state="conflict"] { border-color: var(--red); color: var(--red); }
80+ .badge[data-state="checking"] { color: var(--ink-dim); border-style: dashed; }
81+ .note[role="status"] { color: var(--ink-dim); }
67 .omenu {82 .omenu {
68 & > summary { font-size: 18px; line-height: 1; }83 & > summary { font-size: 18px; line-height: 1; }
69 & .menu-pop { width: 280px; }84 & .menu-pop { width: 280px; }
M internal/adapters/web/static/js/mr.js +1 -1
@@ -219,7 +219,7 @@ window.ermiteMR = (() => {
219219 const box = $('input[name="delete_branch"]', m);
220220 if (box) box.checked = !box.checked;
221221 } else {
222- radios[act.mode].checked = true;
222+ if (!radios[act.mode].disabled) radios[act.mode].checked = true;
223223 }
224224 $('[data-default]', m)?.focus();
225225 return true;
@@ -219,7 +219,7 @@ window.ermiteMR = (() => {
219 const box = $('input[name="delete_branch"]', m);219 const box = $('input[name="delete_branch"]', m);
220 if (box) box.checked = !box.checked;220 if (box) box.checked = !box.checked;
221 } else {221 } else {
222- radios[act.mode].checked = true;222+ if (!radios[act.mode].disabled) radios[act.mode].checked = true;
223 }223 }
224 $('[data-default]', m)?.focus();224 $('[data-default]', m)?.focus();
225 return true;225 return true;
M internal/adapters/web/web.go +4 -0
@@ -76,6 +76,10 @@ type Config struct {
7676 // leads to it. Without it there is neither. Leave it unset rather than
7777 // assigning a typed nil pointer.
7878 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
7983 // Runs serves the CI pages (/<repo>/actions) and the checks shown next to
8084 // commits and merge requests. Without it there are no such pages. Leave it
8185 // 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 than76 // 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.InboxService78 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 to83 // 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 it84 // 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 (
88 "fmt"
99 "io"
1010 "net/url"
11+ "slices"
1112 "strconv"
1213 "strings"
1314
@@ -122,6 +123,71 @@ type jsonMergeRequest struct {
122123 MergeMode string `json:"merge_mode"`
123124 }
124125
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+
125191 // jsonMerged is the output of `mr merge --json`.
126192 type jsonMerged struct {
127193 jsonMergeRequest
@@ -178,9 +244,15 @@ const jsonMRHelp = `
178244 JSON fields of a merge request (--json): repo, url (full web URL), number, title, description, source_branch,
179245 target_branch, state ("open", "merged" or "closed"), author (token label), created_at and
180246 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
182248 unless merged).`
183249
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+
184256 const jsonCommentHelp = `
185257 JSON fields of a comment: id, author, path, line and side ("old" or "new"; empty path, line 0
186258 and empty side for a general comment), commit_id (the head when it was written), text,
@@ -417,6 +489,8 @@ type jsonCommit struct {
417489 // jsonMRDetail is the stable JSON shape of `mr show --json`.
418490 type jsonMRDetail struct {
419491 jsonMergeRequest
492+ // Mergeability is null unless the request is open and the server computes it.
493+ Mergeability *jsonMergeability `json:"mergeability"`
420494 Commits []jsonCommit `json:"commits"`
421495 CommitsTruncated bool `json:"commits_truncated"`
422496 DiffBaseID string `json:"diff_base_id"`
@@ -449,6 +523,7 @@ func lineKindName(k ermite.LineKind) string {
449523 func toJSONDetail(d *ermite.GetMergeRequestResponse, withDiff bool, baseURL string) jsonMRDetail {
450524 out := jsonMRDetail{
451525 jsonMergeRequest: toJSONMergeRequest(d.GetMergeRequest(), baseURL),
526+ Mergeability: toJSONMergeability(d.GetMergeRequest().GetMergeability()),
452527 Commits: []jsonCommit{}, CommitsTruncated: d.GetCommitsTruncated(),
453528 DiffBaseID: d.GetDiffBaseId(),
454529 Stats: jsonStats{
@@ -501,7 +576,9 @@ func newMRShowCmd(env *cliEnv) *cobra.Command {
501576 "--since-last-read shows the diff since the head recorded by --mark-read (or by the web) instead of\n" +
502577 "the whole change; after a force-push it still works when the recorded commit exists, else the\n" +
503578 "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" +
505582 "--json adds to the merge request fields: commits (id, author, summary), commits_truncated,\n" +
506583 "diff_base_id (the commit the diff starts from), stats (files_changed, additions, deletions),\n" +
507584 "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
574651 if m.GetState() == ermite.MergeRequestState_MERGE_REQUEST_STATE_MERGED {
575652 pairs = append(pairs, [2]string{"merged", orDash(m.GetMergedCommitId()) + " (" + modeName(m.GetMergeMode()) + ")"})
576653 }
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+ }
577657 if len(d.GetVerdicts()) > 0 {
578658 var verdicts []string
579659 for _, v := range d.GetVerdicts() {
@@ -962,37 +1042,52 @@ func newMRResolveCmd(env *cliEnv) *cobra.Command {
9621042 }
9631043
9641044 func newMRMergeCmd(env *cliEnv) *cobra.Command {
965- var squash, deleteBranch bool
1045+ var mode string
1046+ var deleteBranch bool
9661047 var head string
9671048 cmd := &cobra.Command{
9681049 Use: "merge <repo> <number>",
9691050 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" +
9751063 "The server merges only if the source branch still points to the head you reviewed, so --head is\n" +
9761064 "required: give the full id of the head that `ermite mr show <repo> <number>` printed when you\n" +
9771065 "reviewed the change. If the branch moved since, the merge is refused: review again. Needs the\n" +
9781066 "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 +
9831071 "\n--json adds source_branch_deleted (true when the deletion was requested: the server deletes the branch right after the merge).",
9841072 Args: cobra.ExactArgs(2),
9851073 ValidArgsFunction: completeMRRepoThenNumber(env),
9861074 }
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)")
9881076 cmd.Flags().StringVar(&head, "head", "", "full id of the head you reviewed, from `ermite mr show` (required)")
9891077 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+ })
9901081 asJSON := addJSONFlag(cmd)
9911082 cmd.RunE = func(cmd *cobra.Command, args []string) error {
9921083 n, err := parseNumber(args[1])
9931084 if err != nil {
9941085 return err
9951086 }
1087+ pbMode, err := parseMode(mode)
1088+ if err != nil {
1089+ return err
1090+ }
9961091 head = strings.TrimSpace(head)
9971092 if head == "" {
9981093 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 {
10031098 return err
10041099 }
10051100 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+ }
10091105 }
10101106 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,
10121108 }))
10131109 if err != nil {
10141110 return c.Describe(err)
@@ -1018,16 +1114,44 @@ func newMRMergeCmd(env *cliEnv) *cobra.Command {
10181114 if *asJSON {
10191115 return writeJSON(cmd.OutOrStdout(), jsonMerged{jsonMergeRequest: toJSONMergeRequest(m, c.URL()), SourceBranchDeleted: deleted})
10201116 }
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(),
10221119 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+ }
10231123 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()))
10251125 }
10261126 return err
10271127 }
10281128 return cmd
10291129 }
10301130
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+
10311155 func newMRCloseCmd(env *cliEnv) *cobra.Command {
10321156 cmd := &cobra.Command{
10331157 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 jsonMergeRequest193 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 and245 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 the246 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 empty247+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 0257 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 jsonMergeRequest491 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 []string658 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 bool1045+ var mode string
1046+ var deleteBranch bool
966 var head string1047 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 err1085 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 err1098 return err
1004 }1099 }
1005 ctx := env.ctx(cmd)1100 ctx := env.ctx(cmd)
1006- mode := ermite.MergeMode_MERGE_MODE_FAST_FORWARD1101+ 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_SQUASH1103+ 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 err1126 return err
1027 }1127 }
1028 return cmd1128 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) {
260260 require.Error(t, res.err)
261261 assert.Contains(t, res.err.Error(), "precondition failed")
262262
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.
264264 c.mustGit(wt, "checkout", "-q", "-b", "feat/two", head3)
265265 c.mustGit(wt, "-c", "user.name=Cy", "-c", "user.email=cy@example.org", "commit", "-q", "--allow-empty", "-m", "empty by Cy")
266266 c.writeFile(wt, "two.txt", []byte("two\n"))
267267 c.mustGit(wt, "push", "-q", url, "feat/two")
268268 c.cli(t, secret, "mr", "create", "proj", "--source", "feat/two", "--title", "Second").ok(t)
269269 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")
271271 requireOneLineError(t, stderr, code, "--head is required", "ermite mr show proj 2")
272272 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)
274274 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)
276276 sq := c.bareGit("proj", "rev-parse", "main")
277277 assert.Equal(t, head3, c.bareGit("proj", "rev-parse", sq+"^"))
278278 assert.Equal(t, "Second (!2)", c.bareGit("proj", "log", "-1", "--format=%s", sq))
@@ -338,7 +338,7 @@ func TestSigningKeyShowVerifiesASquashCommit(t *testing.T) {
338338 c.writeFile(wt, "x.txt", []byte("x\n"))
339339 c.mustGit(wt, "push", "-q", url, "main", "feat")
340340 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"))
342342 require.NoError(t, res.err, res.stderr)
343343 sq := c.bareGit("proj", "rev-parse", "main")
344344
@@ -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 @@
33 package app
44
55 import (
6+ "encoding/json"
7+ "regexp"
68 "strings"
79 "testing"
810
@@ -174,6 +176,53 @@ func TestMRShow(t *testing.T) {
174176 assert.Contains(t, res.stderr, "head you last read is gone")
175177 }
176178
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+
177226 func TestMRShowMarkRead(t *testing.T) {
178227 f := &fakeAPI{
179228 getMR: func(*ermite.GetMergeRequestRequest) (*ermite.GetMergeRequestResponse, error) {
@@ -252,6 +301,12 @@ func TestMRResolveMergeAndClose(t *testing.T) {
252301 closeMR: func(*ermite.CloseMergeRequestRequest) (*ermite.CloseMergeRequestResponse, error) {
253302 return &ermite.CloseMergeRequestResponse{MergeRequest: closed}, nil
254303 },
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+ },
255310 }
256311 url := f.serve(t)
257312
@@ -265,16 +320,37 @@ func TestMRResolveMergeAndClose(t *testing.T) {
265320 assert.False(t, reqs[1].(*ermite.ResolveCommentRequest).GetResolved())
266321
267322 // 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)
270326 assert.Empty(t, res.stderr)
271327 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")
273329 m := reqs[2].(*ermite.MergeMergeRequestRequest)
274330 assert.Equal(t, ermite.MergeMode_MERGE_MODE_SQUASH, m.GetMode())
275331 assert.Equal(t, headA, m.GetHeadId())
276332 assert.EqualValues(t, 1, m.GetNumber())
277333
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+
278354 // The default mode is a fast-forward.
279355 res = runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--head", headA, "--json").ok(t)
280356 assert.Equal(t, "merged", decode[mrOut](t, res.stdout).State)
@@ -299,7 +375,7 @@ func TestMRMergeNeedsTheHead(t *testing.T) {
299375 url := f.serve(t)
300376 for _, args := range [][]string{
301377 {"mr", "merge", "alpha", "1"},
302- {"mr", "merge", "alpha", "1", "--squash"},
378+ {"mr", "merge", "alpha", "1", "--mode", "squash"},
303379 {"mr", "merge", "alpha", "1", "--head", " "},
304380 } {
305381 res := runCLI(t, envFor(url), "", args...)
@@ -3,6 +3,8 @@
3 package app3 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}, nil302 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 {
3333 DefaultBranch string `json:"default_branch"`
3434 CreatedAt *string `json:"created_at"`
3535 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").
3737 MergeModes []string `json:"merge_modes"`
3838 FailingChecksBlock bool `json:"failing_checks_block"`
3939 // Issues is the issue mode ("off", "owner" or "public").
@@ -114,8 +114,8 @@ func parseVisibility(s string) (ermite.Visibility, error) {
114114 const jsonRepoHelp = `
115115 JSON fields of a repository (--json): name, description, visibility ("public" or
116116 "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
119119 mode: "off", "owner" or "public").`
120120
121121 func newRepoCmd(env *cliEnv) *cobra.Command {
@@ -315,7 +315,9 @@ func newRepoEditCmd(env *cliEnv) *cobra.Command {
315315 Short: "Change a repository's description, topics, visibility, default branch, merge settings or issues",
316316 Long: "Change the fields given as flags and leave the others alone; --description \"\" clears the\n" +
317317 "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" +
319321 "--failing-checks-block says whether a failing check prevents a merge (a failed or unfinished CI check of the head; see README, CI).\n" +
320322 "--issues sets who may use issues: off (default), owner (the owner and tokens with issue:write) or\n" +
321323 "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 {
327329 cmd.Flags().StringSliceVar(&topics, "topics", nil, "new topics, comma-separated (empty clears them)")
328330 cmd.Flags().StringVar(&visibility, "visibility", "", "new visibility: public or private")
329331 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)")
331333 cmd.Flags().BoolVar(&failingChecksBlock, "failing-checks-block", true, "a failing check prevents a merge (--failing-checks-block=false to allow it)")
332334 cmd.Flags().StringVar(&issues, "issues", "", "issue mode: off, owner or public")
333335 _ = cmd.RegisterFlagCompletionFunc("issues", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
@@ -335,7 +337,7 @@ func newRepoEditCmd(env *cliEnv) *cobra.Command {
335337 })
336338 _ = cmd.RegisterFlagCompletionFunc("visibility", visibilityCompletion)
337339 _ = 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
339341 })
340342 asJSON := addJSONFlag(cmd)
341343 cmd.RunE = func(cmd *cobra.Command, args []string) error {
@@ -400,7 +402,7 @@ func buildUpdateRepo(cmd *cobra.Command, name, description string, topics []stri
400402 modes = append(modes, mode)
401403 }
402404 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")
404406 }
405407 req.Settings = &ermite.RepoSettings{AllowedMergeModes: modes}
406408 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" or115 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 is116 "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/or117+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 issue118+"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.ShellCompDirectiveNoFileComp340+ 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 {
2929 MergeRequest *jsonMergeRequest `json:"merge_request,omitempty"`
3030 Number int64 `json:"number,omitempty"`
3131 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"`
3434 }
3535
3636 type jsonRefUpdate struct {
@@ -99,7 +99,7 @@ func toJSONEvent(ev *ermite.Event, baseURL string) (jsonEvent, bool) {
9999 out.Type, out.Repo, out.Number, out.Comment = eventCommentResolved, k.CommentResolved.GetRepo(), k.CommentResolved.GetNumber(), &c
100100 case *ermite.Event_MergeRequestMergeability:
101101 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())
103103 case *ermite.Event_Gap:
104104 out.Type = eventGap
105105 default:
@@ -133,13 +133,35 @@ func eventLine(e jsonEvent) string {
133133 case eventCommentAdded, eventCommentEdited, eventCommentDeleted, eventCommentResolved:
134134 detail = fmt.Sprintf("%s!%d by %s", e.Repo, e.Number, e.Comment.Author)
135135 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))
137137 case eventGap:
138138 detail = "events were lost, reload what you display"
139139 }
140140 return clean(ts + " " + e.Type + " " + detail)
141141 }
142142
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+
143165 func refUpdateText(u jsonRefUpdate) string {
144166 short := func(id string) string {
145167 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(), &c99 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 = eventGap104 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) {
200200 assert.Equal(t, "merge_request_closed", decode[eventOut](t, lines[3]).Type)
201201 }
202202
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+
203246 func TestNoteWatchEvents(t *testing.T) {
204247 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"}
205248 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 @@
972972 htmx:sse:close, htmx:sse:error -> ermiteLive.lost();
973973 htmx:sse:error -> ermiteLive.error(event.detail.status);
974974 htmx:sse:before:connection -> ermiteLive.gate(event);
975+<<<<<<< HEAD
975976 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)
976982 gap -> ermiteLive.reload()">
977983 </span>
978984 </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 @@
317317 htmx:sse:close, htmx:sse:error -> ermiteLive.lost();
318318 htmx:sse:error -> ermiteLive.error(event.detail.status);
319319 htmx:sse:before:connection -> ermiteLive.gate(event);
320+<<<<<<< HEAD
320321 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)
321327 gap -> ermiteLive.reload()">
322328 </span>
323329 </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
@@ -191,7 +191,7 @@
191191 htmx:sse:close, htmx:sse:error -> ermiteLive.lost();
192192 htmx:sse:error -> ermiteLive.error(event.detail.status);
193193 htmx:sse:before:connection -> ermiteLive.gate(event);
194- home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data);
194+ home-list, repo-last-push, refs-list, commits-head, mr-notice, mr-mergeability, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data);
195195 gap -> ermiteLive.reload()">
196196 </span>
197197 </div>
@@ -191,7 +191,7 @@
191 htmx:sse:close, htmx:sse:error -> ermiteLive.lost();191 htmx:sse:close, htmx:sse:error -> ermiteLive.lost();
192 htmx:sse:error -> ermiteLive.error(event.detail.status);192 htmx:sse:error -> ermiteLive.error(event.detail.status);
193 htmx:sse:before:connection -> ermiteLive.gate(event);193 htmx:sse:before:connection -> ermiteLive.gate(event);
194- home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data);194+ home-list, repo-last-push, refs-list, commits-head, mr-notice, mr-mergeability, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data);
195 gap -> ermiteLive.reload()">195 gap -> ermiteLive.reload()">
196 </span>196 </span>
197 </div>197 </div>
M internal/app/testdata/web/refs.html +1 -1
@@ -199,7 +199,7 @@
199199 htmx:sse:close, htmx:sse:error -> ermiteLive.lost();
200200 htmx:sse:error -> ermiteLive.error(event.detail.status);
201201 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);
203203 gap -> ermiteLive.reload()">
204204 </span>
205205 </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 @@
225225 htmx:sse:close, htmx:sse:error -> ermiteLive.lost();
226226 htmx:sse:error -> ermiteLive.error(event.detail.status);
227227 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);
229229 gap -> ermiteLive.reload()">
230230 </span>
231231 </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
3535 if f.Passkeys != nil {
3636 passkeys = webPasskeys{f.Passkeys}
3737 }
38+ var signing ports.SigningKeyService
39+ if f.SigningKey != nil {
40+ signing = f.SigningKey
41+ }
3842 // Without artifacts (ERMITE_CI_ARTIFACTS=0) the run page has no section
3943 // for them and no download route.
4044 var artifacts ports.ArtifactService
@@ -50,6 +54,7 @@ func newWeb(cfg config.Config, f *forge, limiter *clientlimit.Limiter) (http.Han
5054 Inbox: f.Inbox,
5155 IssueForm: f.IssueSubmissions,
5256 Moderation: f.IssueModeration,
57+ SigningKey: signing,
5358 Runs: f.CI,
5459 Artifacts: artifacts,
5560 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 section42 // 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.ArtifactService44 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 {
3737 // background. It returns once the server answered, which it does when the
3838 // watch is registered: what happens after is not missed.
3939 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 {
4046 h.t.Helper()
4147 ctx, cancel := context.WithCancel(h.ctx)
4248 h.t.Cleanup(cancel)
@@ -45,6 +51,9 @@ func (h *harness) sse(query, lastID string) *sseStream {
4551 if lastID != "" {
4652 req.Header.Set("Last-Event-ID", lastID)
4753 }
54+ for k, v := range headers {
55+ req.Header.Set(k, v)
56+ }
4857 resp, err := http.DefaultClient.Do(req)
4958 require.NoError(h.t, err)
5059 t := h.t
@@ -37,6 +37,12 @@ type sseStream struct {
37 // background. It returns once the server answered, which it does when the37 // 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.t59 t := h.t
M internal/app/web_mr_test.go +197 -10
@@ -10,6 +10,7 @@ import (
1010 "regexp"
1111 "strings"
1212 "testing"
13+ "time"
1314
1415 "github.com/stretchr/testify/assert"
1516 "github.com/stretchr/testify/require"
@@ -165,12 +166,15 @@ func TestWebMergeRequestPagesRender(t *testing.T) {
165166 assert.Equal(t, head, formHead(t, own.body))
166167 assert.Contains(t, own.body, `action="/proj/merge-requests/1/merge"`)
167168 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")
170172 assert.Contains(t, own.body, "ermite mr merge proj 1 --head "+head)
171173 assert.Contains(t, own.body, `name="delete_branch" value="1"> delete feat/search after the merge <kbd aria-hidden="true">D</kbd>`)
172174 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")
174178 assert.Contains(t, own.body, `action="/proj/merge-requests/1/comments"`)
175179 assert.Contains(t, own.body, `action="/proj/merge-requests/1/close"`)
176180 assert.Contains(t, own.body, `href="/proj/merge-requests/1?on=new:1:search.go#f`, "line numbers link to their form")
@@ -571,8 +575,7 @@ func TestWebMergeRequestEvents(t *testing.T) {
571575 r := newMRRepo(t, domain.VisibilityPublic)
572576 list := r.sse("topic=repo:proj&view=mrs", "")
573577 r.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/search", "--title", "Secret <title>").ok(t)
574- ev := list.next(t, watchWait)
575- assert.Equal(t, "mr-notice", ev.name)
578+ ev := nextNamed(t, list, "mr-notice")
576579 assert.Contains(t, ev.data, `<div id="mr-notice" class="live-slot" role="status" data-slot>`)
577580 assert.Contains(t, ev.data, `href="/proj/merge-requests"`)
578581 assert.NotContains(t, ev.data, "Secret")
@@ -580,19 +583,18 @@ func TestWebMergeRequestEvents(t *testing.T) {
580583 one := r.sse("topic=repo:proj&view=mr&n=1&ref=feat/search", "")
581584 other := r.sse("topic=repo:proj&view=mr&n=2&ref=feat/other", "")
582585 r.cli(t, adminToken, "mr", "comment", "proj", "1", "-m", "private words").ok(t)
583- ev = one.next(t, watchWait)
584- assert.Equal(t, "mr-notice", ev.name)
586+ ev = nextNamed(t, one, "mr-notice")
585587 assert.Contains(t, ev.data, "a comment was added")
586588 assert.Contains(t, ev.data, `href="/proj/merge-requests/1"`)
587589 assert.NotContains(t, ev.data, "private words")
588590
589591 r.writeFile(r.wt, "more.txt", []byte("more\n"))
590592 r.mustGit(r.wt, "push", "-q", r.url, "feat/search")
591- ev = one.next(t, watchWait)
593+ ev = nextNamed(t, one, "mr-notice")
592594 assert.Contains(t, ev.data, "the branch changed")
593595
594596 r.cli(t, adminToken, "mr", "close", "proj", "1").ok(t)
595- assert.Contains(t, one.next(t, watchWait).data, "was closed")
597+ assert.Contains(t, nextNamed(t, one, "mr-notice").data, "was closed")
596598
597599 // A page of another request hears nothing of this one.
598600 for drained := false; !drained; {
@@ -605,7 +607,20 @@ func TestWebMergeRequestEvents(t *testing.T) {
605607 drained = true
606608 }
607609 }
608- assert.Contains(t, r.get1("/proj/merge-requests/1"), `hx-sse:connect="/-/events?topic=repo%3Aproj&amp;view=mr&amp;n=1&amp;ref=feat%2Fsearch&amp;head=`+r.headOf("feat/search")+`"`)
610+ assert.Contains(t, r.get1("/proj/merge-requests/1"), `hx-sse:connect="/-/events?topic=repo%3Aproj&amp;view=mr&amp;n=1&amp;ref=feat%2Fsearch&amp;head=`+r.headOf("feat/search")+`&amp;target=main"`)
611+}
612+
613+// nextNamed returns the next event that carries the fragment event: the
614+// events that change nothing on the page (an id alone) are skipped.
615+func nextNamed(t *testing.T, s *sseStream, name string) sseEvent {
616+ t.Helper()
617+ for range 10 {
618+ if ev := s.next(t, watchWait); ev.name == name {
619+ return ev
620+ }
621+ }
622+ t.Fatalf("no %s event", name)
623+ return sseEvent{}
609624 }
610625
611626 func (r *mrRepo) get1(path string) string {
@@ -724,3 +739,175 @@ func TestWebMergeRequestFileWindows(t *testing.T) {
724739 }
725740
726741 var reWindowSrc = regexp.MustCompile(`data-src="([^"]*from=25[^"]*)"`)
742+
743+// advanceMain pushes a commit to main that the feature branch lacks. file is
744+// the file it adds (give search.go to conflict with the feature branch).
745+func (r *mrRepo) advanceMain(file, content string) {
746+ r.t.Helper()
747+ r.mustGit(r.wt, "checkout", "-q", "main")
748+ r.writeFile(r.wt, file, []byte(content))
749+ r.mustGit(r.wt, "push", "-q", r.url, "main")
750+ r.mustGit(r.wt, "checkout", "-q", "feat/search")
751+}
752+
753+// mrPageWith polls the owner's page of !1 until it contains want.
754+func (r *mrRepo) mrPageWith(token, want string) string {
755+ r.t.Helper()
756+ var body string
757+ require.Eventually(r.t, func() bool {
758+ body = browser{r.harness}.get("/proj/merge-requests/1", as(token)).body
759+ return strings.Contains(body, want)
760+ }, 10*time.Second, 25*time.Millisecond, "the page never showed %q", want)
761+ return body
762+}
763+
764+// The merge box shows the mergeability of the request: ready, then behind
765+// (fast-forward is disabled with its reason, the other modes are not), then
766+// in conflict (no mode is possible and the button says why).
767+func TestWebMergeRequestMergeabilityBadge(t *testing.T) {
768+ r := newMRRepo(t, domain.VisibilityPublic)
769+ token := r.signIn(t)
770+ r.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/search", "--title", "Add search").ok(t)
771+
772+ body := r.mrPageWith(token, "Ready to merge")
773+ assert.Contains(t, body, `<div id="mr-mergeability" class="live-slot" role="status" data-slot>`)
774+ assert.Contains(t, body, `data-state="up_to_date"`)
775+ assert.Contains(t, body, `name="mode" value="fast-forward" checked`)
776+ assert.NotContains(t, body, `value="fast-forward" checked disabled`)
777+ assert.Contains(t, body, `id="mr-signing"`, "squash and merge commits say who signs them")
778+ assert.Contains(t, body, "signed with ermite's own key, generated automatically on first start")
779+ assert.Contains(t, body, "<code>ermite signing-key show</code>")
780+ assert.Contains(t, body, "SHA256:", "the fingerprint of the signing key")
781+ assert.Contains(t, body, `:disabled="`, "the controls follow the badge")
782+ assertNoInline(t, body)
783+
784+ // Main moves on: the branch is behind by one commit and merges cleanly.
785+ r.advanceMain("other.txt", "other\n")
786+ body = r.mrPageWith(token, "Behind main by 1 commit")
787+ assert.Contains(t, body, "merges without conflicts")
788+ assert.Contains(t, body, `data-ff-ok="false"`)
789+ assert.Contains(t, body, `data-merge-ok="true"`)
790+ assert.Regexp(t, `<input type="radio" name="mode" value="fast-forward"[^>]* disabled`, body)
791+ assert.Contains(t, body, `title="behind the target: rebase or use squash/merge"`)
792+ assert.Regexp(t, `name="mode" value="squash" checked`, body, "the first possible mode is checked")
793+ assert.NotRegexp(t, `name="mode" value="squash"[^>]* disabled`, body)
794+
795+ // A conflicting change to main: every mode is disabled and the files are listed.
796+ r.mustGit(r.wt, "checkout", "-q", "main")
797+ r.writeFile(r.wt, "search.go", []byte("package other\n"))
798+ r.mustGit(r.wt, "push", "-q", r.url, "main")
799+ body = r.mrPageWith(token, "Conflicts with main")
800+ assert.Contains(t, body, "rebase needed")
801+ assert.Contains(t, body, `<ul class="mg-paths" aria-label="conflicting files"><li><code>search.go</code></li></ul>`)
802+ assert.Regexp(t, `<button type="submit" class="btn primary" data-default disabled title="conflicts with the target"`, body)
803+ assert.NotContains(t, body, `name="mode" value="squash" checked`)
804+
805+ // The server refuses it too, and says so in the box of the page.
806+ res := browser{r.harness}.form("/proj/merge-requests/1/merge", token, url.Values{"mode": {"squash"}, "head_id": {formHead(t, body)}})
807+ assert.Equal(t, http.StatusConflict, res.status)
808+ assert.Contains(t, res.body, `<p class="d-err" role="alert">`)
809+ assert.Contains(t, res.body, "conflicts with main")
810+}
811+
812+// A merge the server refuses (the target moved after the page was read) shows
813+// the page of the request again, 409, with the reason in its merge box.
814+func TestWebMergeRequestInlineMergeError(t *testing.T) {
815+ r := newMRRepo(t, domain.VisibilityPublic)
816+ b := browser{r.harness}
817+ token := r.signIn(t)
818+ r.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/search", "--title", "Add search").ok(t)
819+ body := r.mrPageWith(token, "Ready to merge")
820+ head := formHead(t, body)
821+
822+ r.advanceMain("other.txt", "other\n") // raced: the form below still says fast-forward
823+ res := b.form("/proj/merge-requests/1/merge", token, url.Values{"mode": {"fast-forward"}, "head_id": {head}})
824+ assert.Equal(t, http.StatusConflict, res.status)
825+ assert.Equal(t, "no-store", res.hdr.Get("Cache-Control"))
826+ assert.Contains(t, res.body, `id="merge-menu"`, "the page of the request, not a state page")
827+ assert.Contains(t, res.body, `<p class="d-err" role="alert">`)
828+ assert.Contains(t, res.body, "the target has moved")
829+ assert.Contains(t, res.body, `<details class="menu mmenu" id="merge-menu" data-state="open" open>`, "the box is open on the error")
830+ assert.Equal(t, head, r.headOf("feat/search"))
831+ assert.NotContains(t, b.get("/proj/merge-requests/1", as(token)).body, "d-err", "a reload shows no error")
832+
833+ // A head that moved is refused the same way.
834+ r.writeFile(r.wt, "more.txt", []byte("more\n"))
835+ r.mustGit(r.wt, "push", "-q", r.url, "feat/search")
836+ res = b.form("/proj/merge-requests/1/merge", token, url.Values{"mode": {"squash"}, "head_id": {head}})
837+ assert.Equal(t, http.StatusConflict, res.status)
838+ assert.Contains(t, res.body, `<p class="d-err" role="alert">`)
839+ assert.Contains(t, res.body, "branch moved")
840+
841+ // Other refusals keep the state page; a mode that does not exist is a 400.
842+ assert.Equal(t, http.StatusBadRequest, b.form("/proj/merge-requests/1/merge", token, url.Values{"mode": {"rebase"}, "head_id": {head}}).status)
843+}
844+
845+// A merge commit through the page; with the branch deletion requested the
846+// merged page says the branch is being deleted, not that it is gone.
847+func TestWebMergeRequestMergeCommitAndDeletingNote(t *testing.T) {
848+ r := newMRRepo(t, domain.VisibilityPublic)
849+ b := browser{r.harness}
850+ token := r.signIn(t)
851+ r.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/search", "--title", "Add search").ok(t)
852+ r.advanceMain("other.txt", "other\n")
853+ body := r.mrPageWith(token, "Behind main by 1 commit")
854+ head := formHead(t, body)
855+ main0 := r.headOf("main")
856+
857+ res := b.form("/proj/merge-requests/1/merge", token, url.Values{"mode": {"merge"}, "head_id": {head}, "delete_branch": {"1"}})
858+ require.Equal(t, http.StatusSeeOther, res.status, res.body)
859+ assert.Equal(t, "/proj/merge-requests/1?deleting=1", res.hdr.Get("Location"))
860+ merged := r.headOf("main")
861+ assert.Equal(t, main0, r.bareGit("proj", "rev-parse", merged+"^1"))
862+ assert.Equal(t, head, r.bareGit("proj", "rev-parse", merged+"^2"))
863+ assert.Contains(t, r.bareGit("proj", "cat-file", "-p", merged), "gpgsig")
864+ page := b.get("/proj/merge-requests/1?deleting=1", as(token))
865+ assert.Contains(t, page.body, "merge commit")
866+ if strings.Contains(page.body, "is being deleted") {
867+ assert.Contains(t, page.body, "feat/search is being deleted")
868+ }
869+ assert.NotContains(t, page.body, "is deleted")
870+ r.forge.MergeRequests.Wait(r.ctx)
871+ assert.Empty(t, r.bareGit("proj", "for-each-ref", "refs/heads/feat/search"))
872+ assert.NotContains(t, b.get("/proj/merge-requests/1?deleting=1", as(token)).body, "is being deleted", "once gone, nothing is being deleted")
873+}
874+
875+// The badge follows the request live: a push to the target turns the fragment
876+// of the page to "checking" or "behind", and the settled result follows.
877+func TestWebMergeRequestMergeabilityLive(t *testing.T) {
878+ r := newMRRepo(t, domain.VisibilityPublic)
879+ token := r.signIn(t)
880+ r.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/search", "--title", "Add search").ok(t)
881+ r.mrPageWith(token, "Ready to merge")
882+
883+ one := r.sseAs("topic=repo:proj&view=mr&n=1&ref=feat/search&target=main", "", as(token))
884+ anon := r.sse("topic=repo:proj&view=mr&n=1&ref=feat/search&target=main", "")
885+ r.advanceMain("other.txt", "other\n")
886+ deadline := time.After(watchWait)
887+ for done := false; !done; {
888+ ev := nextNamed(t, one, "mr-mergeability")
889+ select {
890+ case <-deadline:
891+ t.Fatal("the badge never showed the result")
892+ default:
893+ }
894+ if strings.Contains(ev.data, "Behind main by 1 commit") {
895+ assert.Contains(t, ev.data, `<div id="mr-mergeability" class="live-slot" role="status" data-slot>`)
896+ assert.Contains(t, ev.data, `data-ff-ok="false"`)
897+ assert.Contains(t, ev.data, `data-merge-ok="true"`)
898+ done = true
899+ } else {
900+ assert.Contains(t, ev.data, "Checking…", "before the result, the badge says it checks")
901+ }
902+ }
903+ // The box is the owner's: a visitor's stream carries no badge.
904+ for {
905+ select {
906+ case ev := <-anon.events:
907+ assert.NotEqual(t, "mr-mergeability", ev.name)
908+ continue
909+ default:
910+ }
911+ break
912+ }
913+}
@@ -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")
@@ -571,8 +575,7 @@ func TestWebMergeRequestEvents(t *testing.T) {
571 r := newMRRepo(t, domain.VisibilityPublic)575 r := newMRRepo(t, domain.VisibilityPublic)
572 list := r.sse("topic=repo:proj&view=mrs", "")576 list := r.sse("topic=repo:proj&view=mrs", "")
573 r.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/search", "--title", "Secret <title>").ok(t)577 r.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/search", "--title", "Secret <title>").ok(t)
574- ev := list.next(t, watchWait)578+ ev := nextNamed(t, list, "mr-notice")
575- assert.Equal(t, "mr-notice", ev.name)
576 assert.Contains(t, ev.data, `<div id="mr-notice" class="live-slot" role="status" data-slot>`)579 assert.Contains(t, ev.data, `<div id="mr-notice" class="live-slot" role="status" data-slot>`)
577 assert.Contains(t, ev.data, `href="/proj/merge-requests"`)580 assert.Contains(t, ev.data, `href="/proj/merge-requests"`)
578 assert.NotContains(t, ev.data, "Secret")581 assert.NotContains(t, ev.data, "Secret")
@@ -580,19 +583,18 @@ func TestWebMergeRequestEvents(t *testing.T) {
580 one := r.sse("topic=repo:proj&view=mr&n=1&ref=feat/search", "")583 one := r.sse("topic=repo:proj&view=mr&n=1&ref=feat/search", "")
581 other := r.sse("topic=repo:proj&view=mr&n=2&ref=feat/other", "")584 other := r.sse("topic=repo:proj&view=mr&n=2&ref=feat/other", "")
582 r.cli(t, adminToken, "mr", "comment", "proj", "1", "-m", "private words").ok(t)585 r.cli(t, adminToken, "mr", "comment", "proj", "1", "-m", "private words").ok(t)
583- ev = one.next(t, watchWait)586+ ev = nextNamed(t, one, "mr-notice")
584- assert.Equal(t, "mr-notice", ev.name)
585 assert.Contains(t, ev.data, "a comment was added")587 assert.Contains(t, ev.data, "a comment was added")
586 assert.Contains(t, ev.data, `href="/proj/merge-requests/1"`)588 assert.Contains(t, ev.data, `href="/proj/merge-requests/1"`)
587 assert.NotContains(t, ev.data, "private words")589 assert.NotContains(t, ev.data, "private words")
588 590
589 r.writeFile(r.wt, "more.txt", []byte("more\n"))591 r.writeFile(r.wt, "more.txt", []byte("more\n"))
590 r.mustGit(r.wt, "push", "-q", r.url, "feat/search")592 r.mustGit(r.wt, "push", "-q", r.url, "feat/search")
591- ev = one.next(t, watchWait)593+ ev = nextNamed(t, one, "mr-notice")
592 assert.Contains(t, ev.data, "the branch changed")594 assert.Contains(t, ev.data, "the branch changed")
593 595
594 r.cli(t, adminToken, "mr", "close", "proj", "1").ok(t)596 r.cli(t, adminToken, "mr", "close", "proj", "1").ok(t)
595- assert.Contains(t, one.next(t, watchWait).data, "was closed")597+ assert.Contains(t, nextNamed(t, one, "mr-notice").data, "was closed")
596 598
597 // A page of another request hears nothing of this one.599 // A page of another request hears nothing of this one.
598 for drained := false; !drained; {600 for drained := false; !drained; {
@@ -605,7 +607,20 @@ func TestWebMergeRequestEvents(t *testing.T) {
605 drained = true607 drained = true
606 }608 }
607 }609 }
608- assert.Contains(t, r.get1("/proj/merge-requests/1"), `hx-sse:connect="/-/events?topic=repo%3Aproj&amp;view=mr&amp;n=1&amp;ref=feat%2Fsearch&amp;head=`+r.headOf("feat/search")+`"`)610+ assert.Contains(t, r.get1("/proj/merge-requests/1"), `hx-sse:connect="/-/events?topic=repo%3Aproj&amp;view=mr&amp;n=1&amp;ref=feat%2Fsearch&amp;head=`+r.headOf("feat/search")+`&amp;target=main"`)
611+}
612+
613+// nextNamed returns the next event that carries the fragment event: the
614+// events that change nothing on the page (an id alone) are skipped.
615+func nextNamed(t *testing.T, s *sseStream, name string) sseEvent {
616+ t.Helper()
617+ for range 10 {
618+ if ev := s.next(t, watchWait); ev.name == name {
619+ return ev
620+ }
621+ }
622+ t.Fatalf("no %s event", name)
623+ return sseEvent{}
609 }624 }
610 625
611 func (r *mrRepo) get1(path string) string {626 func (r *mrRepo) get1(path string) string {
@@ -724,3 +739,175 @@ func TestWebMergeRequestFileWindows(t *testing.T) {
724 }739 }
725 740
726 var reWindowSrc = regexp.MustCompile(`data-src="([^"]*from=25[^"]*)"`)741 var reWindowSrc = regexp.MustCompile(`data-src="([^"]*from=25[^"]*)"`)
742+
743+// advanceMain pushes a commit to main that the feature branch lacks. file is
744+// the file it adds (give search.go to conflict with the feature branch).
745+func (r *mrRepo) advanceMain(file, content string) {
746+ r.t.Helper()
747+ r.mustGit(r.wt, "checkout", "-q", "main")
748+ r.writeFile(r.wt, file, []byte(content))
749+ r.mustGit(r.wt, "push", "-q", r.url, "main")
750+ r.mustGit(r.wt, "checkout", "-q", "feat/search")
751+}
752+
753+// mrPageWith polls the owner's page of !1 until it contains want.
754+func (r *mrRepo) mrPageWith(token, want string) string {
755+ r.t.Helper()
756+ var body string
757+ require.Eventually(r.t, func() bool {
758+ body = browser{r.harness}.get("/proj/merge-requests/1", as(token)).body
759+ return strings.Contains(body, want)
760+ }, 10*time.Second, 25*time.Millisecond, "the page never showed %q", want)
761+ return body
762+}
763+
764+// The merge box shows the mergeability of the request: ready, then behind
765+// (fast-forward is disabled with its reason, the other modes are not), then
766+// in conflict (no mode is possible and the button says why).
767+func TestWebMergeRequestMergeabilityBadge(t *testing.T) {
768+ r := newMRRepo(t, domain.VisibilityPublic)
769+ token := r.signIn(t)
770+ r.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/search", "--title", "Add search").ok(t)
771+
772+ body := r.mrPageWith(token, "Ready to merge")
773+ assert.Contains(t, body, `<div id="mr-mergeability" class="live-slot" role="status" data-slot>`)
774+ assert.Contains(t, body, `data-state="up_to_date"`)
775+ assert.Contains(t, body, `name="mode" value="fast-forward" checked`)
776+ assert.NotContains(t, body, `value="fast-forward" checked disabled`)
777+ assert.Contains(t, body, `id="mr-signing"`, "squash and merge commits say who signs them")
778+ assert.Contains(t, body, "signed with ermite's own key, generated automatically on first start")
779+ assert.Contains(t, body, "<code>ermite signing-key show</code>")
780+ assert.Contains(t, body, "SHA256:", "the fingerprint of the signing key")
781+ assert.Contains(t, body, `:disabled="`, "the controls follow the badge")
782+ assertNoInline(t, body)
783+
784+ // Main moves on: the branch is behind by one commit and merges cleanly.
785+ r.advanceMain("other.txt", "other\n")
786+ body = r.mrPageWith(token, "Behind main by 1 commit")
787+ assert.Contains(t, body, "merges without conflicts")
788+ assert.Contains(t, body, `data-ff-ok="false"`)
789+ assert.Contains(t, body, `data-merge-ok="true"`)
790+ assert.Regexp(t, `<input type="radio" name="mode" value="fast-forward"[^>]* disabled`, body)
791+ assert.Contains(t, body, `title="behind the target: rebase or use squash/merge"`)
792+ assert.Regexp(t, `name="mode" value="squash" checked`, body, "the first possible mode is checked")
793+ assert.NotRegexp(t, `name="mode" value="squash"[^>]* disabled`, body)
794+
795+ // A conflicting change to main: every mode is disabled and the files are listed.
796+ r.mustGit(r.wt, "checkout", "-q", "main")
797+ r.writeFile(r.wt, "search.go", []byte("package other\n"))
798+ r.mustGit(r.wt, "push", "-q", r.url, "main")
799+ body = r.mrPageWith(token, "Conflicts with main")
800+ assert.Contains(t, body, "rebase needed")
801+ assert.Contains(t, body, `<ul class="mg-paths" aria-label="conflicting files"><li><code>search.go</code></li></ul>`)
802+ assert.Regexp(t, `<button type="submit" class="btn primary" data-default disabled title="conflicts with the target"`, body)
803+ assert.NotContains(t, body, `name="mode" value="squash" checked`)
804+
805+ // The server refuses it too, and says so in the box of the page.
806+ res := browser{r.harness}.form("/proj/merge-requests/1/merge", token, url.Values{"mode": {"squash"}, "head_id": {formHead(t, body)}})
807+ assert.Equal(t, http.StatusConflict, res.status)
808+ assert.Contains(t, res.body, `<p class="d-err" role="alert">`)
809+ assert.Contains(t, res.body, "conflicts with main")
810+}
811+
812+// A merge the server refuses (the target moved after the page was read) shows
813+// the page of the request again, 409, with the reason in its merge box.
814+func TestWebMergeRequestInlineMergeError(t *testing.T) {
815+ r := newMRRepo(t, domain.VisibilityPublic)
816+ b := browser{r.harness}
817+ token := r.signIn(t)
818+ r.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/search", "--title", "Add search").ok(t)
819+ body := r.mrPageWith(token, "Ready to merge")
820+ head := formHead(t, body)
821+
822+ r.advanceMain("other.txt", "other\n") // raced: the form below still says fast-forward
823+ res := b.form("/proj/merge-requests/1/merge", token, url.Values{"mode": {"fast-forward"}, "head_id": {head}})
824+ assert.Equal(t, http.StatusConflict, res.status)
825+ assert.Equal(t, "no-store", res.hdr.Get("Cache-Control"))
826+ assert.Contains(t, res.body, `id="merge-menu"`, "the page of the request, not a state page")
827+ assert.Contains(t, res.body, `<p class="d-err" role="alert">`)
828+ assert.Contains(t, res.body, "the target has moved")
829+ assert.Contains(t, res.body, `<details class="menu mmenu" id="merge-menu" data-state="open" open>`, "the box is open on the error")
830+ assert.Equal(t, head, r.headOf("feat/search"))
831+ assert.NotContains(t, b.get("/proj/merge-requests/1", as(token)).body, "d-err", "a reload shows no error")
832+
833+ // A head that moved is refused the same way.
834+ r.writeFile(r.wt, "more.txt", []byte("more\n"))
835+ r.mustGit(r.wt, "push", "-q", r.url, "feat/search")
836+ res = b.form("/proj/merge-requests/1/merge", token, url.Values{"mode": {"squash"}, "head_id": {head}})
837+ assert.Equal(t, http.StatusConflict, res.status)
838+ assert.Contains(t, res.body, `<p class="d-err" role="alert">`)
839+ assert.Contains(t, res.body, "branch moved")
840+
841+ // Other refusals keep the state page; a mode that does not exist is a 400.
842+ assert.Equal(t, http.StatusBadRequest, b.form("/proj/merge-requests/1/merge", token, url.Values{"mode": {"rebase"}, "head_id": {head}}).status)
843+}
844+
845+// A merge commit through the page; with the branch deletion requested the
846+// merged page says the branch is being deleted, not that it is gone.
847+func TestWebMergeRequestMergeCommitAndDeletingNote(t *testing.T) {
848+ r := newMRRepo(t, domain.VisibilityPublic)
849+ b := browser{r.harness}
850+ token := r.signIn(t)
851+ r.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/search", "--title", "Add search").ok(t)
852+ r.advanceMain("other.txt", "other\n")
853+ body := r.mrPageWith(token, "Behind main by 1 commit")
854+ head := formHead(t, body)
855+ main0 := r.headOf("main")
856+
857+ res := b.form("/proj/merge-requests/1/merge", token, url.Values{"mode": {"merge"}, "head_id": {head}, "delete_branch": {"1"}})
858+ require.Equal(t, http.StatusSeeOther, res.status, res.body)
859+ assert.Equal(t, "/proj/merge-requests/1?deleting=1", res.hdr.Get("Location"))
860+ merged := r.headOf("main")
861+ assert.Equal(t, main0, r.bareGit("proj", "rev-parse", merged+"^1"))
862+ assert.Equal(t, head, r.bareGit("proj", "rev-parse", merged+"^2"))
863+ assert.Contains(t, r.bareGit("proj", "cat-file", "-p", merged), "gpgsig")
864+ page := b.get("/proj/merge-requests/1?deleting=1", as(token))
865+ assert.Contains(t, page.body, "merge commit")
866+ if strings.Contains(page.body, "is being deleted") {
867+ assert.Contains(t, page.body, "feat/search is being deleted")
868+ }
869+ assert.NotContains(t, page.body, "is deleted")
870+ r.forge.MergeRequests.Wait(r.ctx)
871+ assert.Empty(t, r.bareGit("proj", "for-each-ref", "refs/heads/feat/search"))
872+ assert.NotContains(t, b.get("/proj/merge-requests/1?deleting=1", as(token)).body, "is being deleted", "once gone, nothing is being deleted")
873+}
874+
875+// The badge follows the request live: a push to the target turns the fragment
876+// of the page to "checking" or "behind", and the settled result follows.
877+func TestWebMergeRequestMergeabilityLive(t *testing.T) {
878+ r := newMRRepo(t, domain.VisibilityPublic)
879+ token := r.signIn(t)
880+ r.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/search", "--title", "Add search").ok(t)
881+ r.mrPageWith(token, "Ready to merge")
882+
883+ one := r.sseAs("topic=repo:proj&view=mr&n=1&ref=feat/search&target=main", "", as(token))
884+ anon := r.sse("topic=repo:proj&view=mr&n=1&ref=feat/search&target=main", "")
885+ r.advanceMain("other.txt", "other\n")
886+ deadline := time.After(watchWait)
887+ for done := false; !done; {
888+ ev := nextNamed(t, one, "mr-mergeability")
889+ select {
890+ case <-deadline:
891+ t.Fatal("the badge never showed the result")
892+ default:
893+ }
894+ if strings.Contains(ev.data, "Behind main by 1 commit") {
895+ assert.Contains(t, ev.data, `<div id="mr-mergeability" class="live-slot" role="status" data-slot>`)
896+ assert.Contains(t, ev.data, `data-ff-ok="false"`)
897+ assert.Contains(t, ev.data, `data-merge-ok="true"`)
898+ done = true
899+ } else {
900+ assert.Contains(t, ev.data, "Checking…", "before the result, the badge says it checks")
901+ }
902+ }
903+ // The box is the owner's: a visitor's stream carries no badge.
904+ for {
905+ select {
906+ case ev := <-anon.events:
907+ assert.NotEqual(t, "mr-mergeability", ev.name)
908+ continue
909+ default:
910+ }
911+ break
912+ }
913+}
M internal/ports/mocks/MergeRequestService.go +78 -0
@@ -942,6 +942,84 @@ func (_c *MergeRequestService_Merge_Call) RunAndReturn(run func(ctx context.Cont
942942 return _c
943943 }
944944
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+
9451023 // ResolveComment provides a mock function for the type MergeRequestService
9461024 func (_mock *MergeRequestService) ResolveComment(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, commentID int64, resolved bool) (domain.Comment, error) {
9471025 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 _c942 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 MergeRequestService1023 // 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 {
124124 // CountOpen is the number of open merge requests of a repository the
125125 // principal can read. It reads no Git: the tab of every page shows it.
126126 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)
127131 }
128132
129133 // 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 the124 // 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
@@ -438,6 +438,28 @@ func (m *MergeRequests) CountOpen(ctx context.Context, p domain.Principal, name
438438 return n, nil
439439 }
440440
441+// Mergeability implements ports.MergeRequestService: the mergeability of an
442+// open merge request for the current tips, queued when there is no up-to-date
443+// result. It is the zero value for a request that is not open, or without a
444+// mergeability service.
445+func (m *MergeRequests) Mergeability(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.Mergeability, error) {
446+ _, mr, err := m.get(ctx, p, name, number)
447+ if err != nil {
448+ return domain.Mergeability{}, err
449+ }
450+ if m.mergeab == nil || !mr.IsOpen() {
451+ return domain.Mergeability{}, nil
452+ }
453+ if mr, err = m.withHead(ctx, mr); err != nil {
454+ return domain.Mergeability{}, err
455+ }
456+ tip, err := m.branchOrZero(ctx, name, mr.Target)
457+ if err != nil {
458+ return domain.Mergeability{}, err
459+ }
460+ return m.mergeab.Read(ctx, mr, tip), nil
461+}
462+
441463 func encodeMRToken(last domain.MergeRequest) string {
442464 return base64.RawURLEncoding.EncodeToString([]byte(mrTokenPrefix + strconv.FormatInt(last.Number, 10)))
443465 }
@@ -438,6 +438,28 @@ func (m *MergeRequests) CountOpen(ctx context.Context, p domain.Principal, name
438 return n, nil438 return n, nil
439 }439 }
440 440
441+// Mergeability implements ports.MergeRequestService: the mergeability of an
442+// open merge request for the current tips, queued when there is no up-to-date
443+// result. It is the zero value for a request that is not open, or without a
444+// mergeability service.
445+func (m *MergeRequests) Mergeability(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.Mergeability, error) {
446+ _, mr, err := m.get(ctx, p, name, number)
447+ if err != nil {
448+ return domain.Mergeability{}, err
449+ }
450+ if m.mergeab == nil || !mr.IsOpen() {
451+ return domain.Mergeability{}, nil
452+ }
453+ if mr, err = m.withHead(ctx, mr); err != nil {
454+ return domain.Mergeability{}, err
455+ }
456+ tip, err := m.branchOrZero(ctx, name, mr.Target)
457+ if err != nil {
458+ return domain.Mergeability{}, err
459+ }
460+ return m.mergeab.Read(ctx, mr, tip), nil
461+}
462+
441 func encodeMRToken(last domain.MergeRequest) string {463 func encodeMRToken(last domain.MergeRequest) string {
442 return base64.RawURLEncoding.EncodeToString([]byte(mrTokenPrefix + strconv.FormatInt(last.Number, 10)))464 return base64.RawURLEncoding.EncodeToString([]byte(mrTokenPrefix + strconv.FormatInt(last.Number, 10)))
443 }465 }
M skills/ermite/CLI.md +5 -4
@@ -11,7 +11,7 @@ Errors are one line on standard error (`ermite: ...`) with exit status 1. `--jso
1111 | `repo list [--json]` | none | repositories you can see |
1212 | `repo show <name> [--json]` | none | |
1313 | `repo create <name> [--description TEXT] [--visibility public]` | `repo:write` | private by default |
14-| `repo edit <name> [--description TEXT] [--visibility V] [--default-branch B] [--merge-modes fast-forward,squash] [--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 |
1515 | `repo delete <name> [--yes]` | `repo:admin` | |
1616
1717 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
5656 |---|---|---|
5757 | `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 |
5858 | `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` |
6060 | `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 |
6161 | `mr edit-comment <repo> <n> <comment-id> -m TEXT\|-` | `mr:write` | author only, open merge request; the verdict is unchanged |
6262 | `mr delete-comment <repo> <n> <comment-id>` | `mr:write` | author only; the text is lost, the verdict is kept |
6363 | `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`) |
6565 | `mr close <repo> <n>` | `mr:write` | a closed merge request cannot be reopened |
6666
6767 `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 `
106106 | `repo_created`, `repo_updated`, `repo_deleted` | `repo`, `repository` |
107107 | `pushed` | `repo`, `ref_updates` (`ref`, `old_id`, `new_id`; an empty id means created or deleted) |
108108 | `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 |
109110 | `comment_added`, `comment_edited`, `comment_deleted`, `comment_resolved` | `repo`, `number`, `comment` (a verdict note has `verdict` set) |
110111 | `gap` | none: events were lost, reload the state |
111112
@@ -116,7 +117,7 @@ Workflow JSON: `repo`, `file` (always `.github/workflows/<file>`), `name` (the `
116117 | `serve` | configured by `ERMITE_*` environment variables only (see the project README) |
117118 | `version [--json]` | client version; with a server URL (`ERMITE_URL` or the config file), the server version and features |
118119 | `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` |
120121 | `signing-key trust list [--json]` | keys commit signatures are checked against (the server key first) |
121122 | `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 |
122123 | `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 full67 `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,
3737 2. Read: `ermite mr show <repo> <n> --diff --json`. Keep `head_id`; take line numbers from the hunks.
3838 3. Comment on a line: `ermite mr comment <repo> <n> -m "text" --path <file> --line <n> --side new`; `--line 10-14` covers a range on one side. Without `--path` the comment is general. The line must appear in the diff. Notes are Markdown; `^N` refers to note N.
3939 4. End a review with a verdict (needs `mr:review`): `--approve` or `--request-changes`, alone or with a message. Verdicts are informative and cannot be edited; a new verdict replaces the previous one.
40-5. Merge only when asked: `ermite mr merge <repo> <n> --head <head_id>`, with `--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.
4141
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.
4343
4444 ### Act on a review
4545
@@ -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 review44 ### Act on a review
45 45

Keyboard shortcuts

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

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

Branches 4

Tags 45

↑↓ moveenter openesc close