detached 70c00ad

#7 feat(mr): live mergeability, merge commit mode and inline merge errors

state
merged · 70c00ad · fast-forward
branch
feat/mr-mergeability → main
opened
bnema ·
updated

Merge requests show live mergeability and gain a signed merge-commit mode.

  • Mergeability: a background check per repository (merge-tree) reports checking, up to date, behind N or conflict with paths. It is stored, exposed in the API (feature merge_requests.mergeability), published as a Watch event for every settled result on new branch tips, and shown live in the CLI and the web merge box. Failed checks retry per repository with backoff (1 s to 1 min, 5 attempts).
  • Merge modes: fast-forward (requires up to date), squash (uses the merge-tree tree, so a behind but clean branch can be squashed) and merge commit (two parents, signed by the instance key, see 'ermite signing-key show'). Every mode is allowed by default; existing repos are upgraded once. Restrict with 'ermite repo edit --merge-modes'.
  • Merge answer: a refused merge re-renders the page with an inline error (409), never an error page. Post-merge side effects (source branch deletion, events) run after the response.
  • Requires bnema.dev/ermite-api v0.10.0.

Checks: build, vet, hexcheck -fail-on warn, go test -count=1 ./..., race on usecase and app, JS tests.

CommitSubjectAuthorDateSignatureStatus
70c00adtest: use Store.Mutate for the merge-mode upgrade test and skip id-only events in the inbox stream testbriceGPG Verified
bda2a5efix(mr): wait for the async source deletion before pruning in the squash test; simplify the retry pop conditionbriceGPG Verified
1475ed7build(deps): require ermite-api v0.10.0briceGPG Verified
fb5ff06test(mr): cover stale retry timers, branch disappearance and CAS conflict; drop unused clockbriceGPG Verified
c22da12fix(mr): keep replacement retries from stale timers and log reconcile failuresbriceGPG Verified
1036507refactor(mr): reconcile mergeability once per repositorybriceGPG Verified
0019f14refactor(mr): retry failed mergeability work per repository onlybriceGPG Verified
99b00c3fix(mr): count retry attempts only for the keys that wait in the schedulerbriceGPG Verified
33a90cdfix(mr): arm one retry timer per earliest retry in the mergeability schedulerbriceGPG Verified
41023defix(mr): bound mergeability retries with one scheduler and retry failed publishesbriceGPG Verified
0e2e418feat(cli): report the mergeability featurebriceGPG Verified
bfa2dcdfix(rpc): report checking mergeability on open merge requestsbriceGPG Verified
5ad3fbefix(events): deliver mergeability events on owner topicsbriceGPG Verified
f09b4fbfix(mr): bound conflict paths with exact-sized copiesbriceGPG Verified
f8b1d20fix(mr): retry failed mergeability checks with backoffbriceGPG Verified
f8bb86dfix(mr): alternate pending and dirty work in the mergeability queuebriceGPG Verified
a79212efix(mr): refresh live-event goldens and guard shared issue numbersbriceGPG Verified
5d95127fix(web): enable the merge button after choosing a mode by keyboardbriceGPG Verified
1b45752fix(web): keep the merge box inside its popover and the MR URL after a refused mergebriceGPG Verified
b5b4bf9feat(web,cli): live mergeability in the merge box, merge modes and inline merge errorsbriceGPG Verified
fc9623bfix(mr): safe async source deletion, single-run mergeability queue and domain-side merge-tree boundsbriceGPG Verified
bf27c54feat(mr): per-mode merge checks, signed merge commits and side effects after the responsebriceGPG Verified
b707720refactor(kvstore): decode the mergeability record without mutating itbriceGPG Verified
23c66c0feat(mr): background mergeability checks, their Watch event and API fieldbriceGPG Verified
8f36a2ffeat(mr): mergeability types, merge-tree checks, storage record and merge commit modebriceGPG Verified

Checks 8

89 files · +6371 -1025
8/8
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 gen/ermite/storage/merge_request.pb.go +216 -24 generated file: collapsed
M go.mod +1 -1
@@ -13,7 +13,7 @@ tool (
1313
1414 require (
1515 bnema.dev/ermite-actions v0.2.0
16- bnema.dev/ermite-api v0.9.0
16+ bnema.dev/ermite-api v0.10.0
1717 bnema.dev/ermite-git v0.1.0
1818 bnema.dev/ermite-kv v0.1.0
1919 connectrpc.com/connect v1.21.0
@@ -13,7 +13,7 @@ tool (
13 13
14 require (14 require (
15 bnema.dev/ermite-actions v0.2.015 bnema.dev/ermite-actions v0.2.0
16- bnema.dev/ermite-api v0.9.016+ bnema.dev/ermite-api v0.10.0
17 bnema.dev/ermite-git v0.1.017 bnema.dev/ermite-git v0.1.0
18 bnema.dev/ermite-kv v0.1.018 bnema.dev/ermite-kv v0.1.0
19 connectrpc.com/connect v1.21.019 connectrpc.com/connect v1.21.0
M go.sum +2 -2 generated file: collapsed
M internal/adapters/client/client_test.go +3 -3
@@ -139,15 +139,15 @@ func TestDescribeBoundsLongMessages(t *testing.T) {
139139 func TestMissingFeatures(t *testing.T) {
140140 assert.Empty(t, client.MissingFeatures(client.UsedFeatures()))
141141 assert.Equal(t,
142- []string{client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureIssues, client.FeatureSigningKey, client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, client.FeatureCIWorkflows, client.FeatureCIArtifacts, client.FeatureCommitSignatures},
142+ []string{client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureIssues, client.FeatureMergeability, client.FeatureSigningKey, client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, client.FeatureCIWorkflows, client.FeatureCIArtifacts, client.FeatureCommitSignatures},
143143 client.MissingFeatures([]string{client.FeatureRepoUpdate, client.FeatureSession, client.FeaturePasskeys, "other"}))
144144 assert.Equal(t, []string{client.FeatureSigningKey}, client.MissingFeatures([]string{
145- client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureIssues, client.FeatureSession,
145+ client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureIssues, client.FeatureMergeability, client.FeatureSession,
146146 client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, client.FeatureCIWorkflows,
147147 client.FeatureCIArtifacts, client.FeaturePasskeys, client.FeatureCommitSignatures,
148148 }))
149149 assert.Equal(t, []string{client.FeaturePasskeys}, client.MissingFeatures([]string{
150- client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureIssues, client.FeatureSigningKey, client.FeatureSession,
150+ client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureIssues, client.FeatureMergeability, client.FeatureSigningKey, client.FeatureSession,
151151 client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, client.FeatureCIWorkflows, client.FeatureCIArtifacts, client.FeatureCommitSignatures,
152152 }))
153153 assert.Equal(t, client.UsedFeatures(), client.MissingFeatures(nil))
@@ -139,15 +139,15 @@ func TestDescribeBoundsLongMessages(t *testing.T) {
139 func TestMissingFeatures(t *testing.T) {139 func TestMissingFeatures(t *testing.T) {
140 assert.Empty(t, client.MissingFeatures(client.UsedFeatures()))140 assert.Empty(t, client.MissingFeatures(client.UsedFeatures()))
141 assert.Equal(t,141 assert.Equal(t,
142- []string{client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureIssues, client.FeatureSigningKey, client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, client.FeatureCIWorkflows, client.FeatureCIArtifacts, client.FeatureCommitSignatures},142+ []string{client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureIssues, client.FeatureMergeability, client.FeatureSigningKey, client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, client.FeatureCIWorkflows, client.FeatureCIArtifacts, client.FeatureCommitSignatures},
143 client.MissingFeatures([]string{client.FeatureRepoUpdate, client.FeatureSession, client.FeaturePasskeys, "other"}))143 client.MissingFeatures([]string{client.FeatureRepoUpdate, client.FeatureSession, client.FeaturePasskeys, "other"}))
144 assert.Equal(t, []string{client.FeatureSigningKey}, client.MissingFeatures([]string{144 assert.Equal(t, []string{client.FeatureSigningKey}, client.MissingFeatures([]string{
145- client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureIssues, client.FeatureSession,145+ client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureIssues, client.FeatureMergeability, client.FeatureSession,
146 client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, client.FeatureCIWorkflows,146 client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, client.FeatureCIWorkflows,
147 client.FeatureCIArtifacts, client.FeaturePasskeys, client.FeatureCommitSignatures,147 client.FeatureCIArtifacts, client.FeaturePasskeys, client.FeatureCommitSignatures,
148 }))148 }))
149 assert.Equal(t, []string{client.FeaturePasskeys}, client.MissingFeatures([]string{149 assert.Equal(t, []string{client.FeaturePasskeys}, client.MissingFeatures([]string{
150- client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureIssues, client.FeatureSigningKey, client.FeatureSession,150+ client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureIssues, client.FeatureMergeability, client.FeatureSigningKey, client.FeatureSession,
151 client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, client.FeatureCIWorkflows, client.FeatureCIArtifacts, client.FeatureCommitSignatures,151 client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, client.FeatureCIWorkflows, client.FeatureCIArtifacts, client.FeatureCommitSignatures,
152 }))152 }))
153 assert.Equal(t, client.UsedFeatures(), client.MissingFeatures(nil))153 assert.Equal(t, client.UsedFeatures(), client.MissingFeatures(nil))
M internal/adapters/client/features.go +4 -1
@@ -54,11 +54,14 @@ const (
5454 // FeatureCommitSignatures is TrustedKeyService and Commit.verification
5555 // (`ermite signing-key trust`).
5656 FeatureCommitSignatures = "commit_signatures"
57+ // FeatureMergeability is MergeRequest.mergeability and the
58+ // merge_request_mergeability event.
59+ FeatureMergeability = "merge_requests.mergeability"
5760 )
5861
5962 // UsedFeatures lists the features the CLI uses, in the order they are reported.
6063 func UsedFeatures() []string {
61- return []string{FeatureRepoUpdate, FeatureEventsResume, FeatureMergeRequests, FeatureMergeDeleteBranch, FeatureIssues, FeatureSigningKey, FeatureSession, FeatureTokenBranchPrefixes, FeatureCIRuns, FeatureCIRunners, FeatureCISecrets, FeatureCIDispatch, FeatureCIWorkflows, FeatureCIArtifacts, FeaturePasskeys, FeatureCommitSignatures}
64+ return []string{FeatureRepoUpdate, FeatureEventsResume, FeatureMergeRequests, FeatureMergeDeleteBranch, FeatureIssues, FeatureMergeability, FeatureSigningKey, FeatureSession, FeatureTokenBranchPrefixes, FeatureCIRuns, FeatureCIRunners, FeatureCISecrets, FeatureCIDispatch, FeatureCIWorkflows, FeatureCIArtifacts, FeaturePasskeys, FeatureCommitSignatures}
6265 }
6366
6467 // MissingFeatures returns the features the CLI uses that have is lacking.
@@ -54,11 +54,14 @@ const (
54 // FeatureCommitSignatures is TrustedKeyService and Commit.verification54 // FeatureCommitSignatures is TrustedKeyService and Commit.verification
55 // (`ermite signing-key trust`).55 // (`ermite signing-key trust`).
56 FeatureCommitSignatures = "commit_signatures"56 FeatureCommitSignatures = "commit_signatures"
57+ // FeatureMergeability is MergeRequest.mergeability and the
58+ // merge_request_mergeability event.
59+ FeatureMergeability = "merge_requests.mergeability"
57 )60 )
58 61
59 // UsedFeatures lists the features the CLI uses, in the order they are reported.62 // UsedFeatures lists the features the CLI uses, in the order they are reported.
60 func UsedFeatures() []string {63 func UsedFeatures() []string {
61- return []string{FeatureRepoUpdate, FeatureEventsResume, FeatureMergeRequests, FeatureMergeDeleteBranch, FeatureIssues, FeatureSigningKey, FeatureSession, FeatureTokenBranchPrefixes, FeatureCIRuns, FeatureCIRunners, FeatureCISecrets, FeatureCIDispatch, FeatureCIWorkflows, FeatureCIArtifacts, FeaturePasskeys, FeatureCommitSignatures}64+ return []string{FeatureRepoUpdate, FeatureEventsResume, FeatureMergeRequests, FeatureMergeDeleteBranch, FeatureIssues, FeatureMergeability, FeatureSigningKey, FeatureSession, FeatureTokenBranchPrefixes, FeatureCIRuns, FeatureCIRunners, FeatureCISecrets, FeatureCIDispatch, FeatureCIWorkflows, FeatureCIArtifacts, FeaturePasskeys, FeatureCommitSignatures}
62 }65 }
63 66
64 // MissingFeatures returns the features the CLI uses that have is lacking.67 // MissingFeatures returns the features the CLI uses that have is lacking.
M internal/adapters/gitcli/git.go +12 -2
@@ -131,6 +131,16 @@ func (g *Git) run(ctx context.Context, args ...string) (string, error) {
131131
132132 // runInput is run with the given standard input; nil means none.
133133 func (g *Git) runInput(ctx context.Context, stdin io.Reader, args ...string) (string, error) {
134+ out, err := g.runCapture(ctx, stdin, args...)
135+ if err != nil {
136+ return "", err
137+ }
138+ return out, nil
139+}
140+
141+// runCapture is runInput that also returns the standard output of a failed
142+// run.
143+func (g *Git) runCapture(ctx context.Context, stdin io.Reader, args ...string) (string, error) {
134144 if _, ok := ctx.Deadline(); !ok {
135145 var cancel context.CancelFunc
136146 ctx, cancel = context.WithTimeout(ctx, commandTimeout)
@@ -156,9 +166,9 @@ func (g *Git) runInput(ctx context.Context, stdin io.Reader, args ...string) (st
156166 }
157167 var exit *exec.ExitError
158168 if errors.As(err, &exit) && msg != "" {
159- return "", fmt.Errorf("git %s: %w: %s", subcommand(args), err, msg)
169+ return stdout.String(), fmt.Errorf("git %s: %w: %s", subcommand(args), err, msg)
160170 }
161- return "", fmt.Errorf("git %s: %w", subcommand(args), err)
171+ return stdout.String(), fmt.Errorf("git %s: %w", subcommand(args), err)
162172 }
163173 return stdout.String(), nil
164174 }
@@ -131,6 +131,16 @@ func (g *Git) run(ctx context.Context, args ...string) (string, error) {
131 131
132 // runInput is run with the given standard input; nil means none.132 // runInput is run with the given standard input; nil means none.
133 func (g *Git) runInput(ctx context.Context, stdin io.Reader, args ...string) (string, error) {133 func (g *Git) runInput(ctx context.Context, stdin io.Reader, args ...string) (string, error) {
134+ out, err := g.runCapture(ctx, stdin, args...)
135+ if err != nil {
136+ return "", err
137+ }
138+ return out, nil
139+}
140+
141+// runCapture is runInput that also returns the standard output of a failed
142+// run.
143+func (g *Git) runCapture(ctx context.Context, stdin io.Reader, args ...string) (string, error) {
134 if _, ok := ctx.Deadline(); !ok {144 if _, ok := ctx.Deadline(); !ok {
135 var cancel context.CancelFunc145 var cancel context.CancelFunc
136 ctx, cancel = context.WithTimeout(ctx, commandTimeout)146 ctx, cancel = context.WithTimeout(ctx, commandTimeout)
@@ -156,9 +166,9 @@ func (g *Git) runInput(ctx context.Context, stdin io.Reader, args ...string) (st
156 }166 }
157 var exit *exec.ExitError167 var exit *exec.ExitError
158 if errors.As(err, &exit) && msg != "" {168 if errors.As(err, &exit) && msg != "" {
159- return "", fmt.Errorf("git %s: %w: %s", subcommand(args), err, msg)169+ return stdout.String(), fmt.Errorf("git %s: %w: %s", subcommand(args), err, msg)
160 }170 }
161- return "", fmt.Errorf("git %s: %w", subcommand(args), err)171+ return stdout.String(), fmt.Errorf("git %s: %w", subcommand(args), err)
162 }172 }
163 return stdout.String(), nil173 return stdout.String(), nil
164 }174 }
M internal/adapters/gitcli/merge_test.go +83 -0
@@ -4,6 +4,7 @@ package gitcli_test
44
55 import (
66 "context"
7+ "fmt"
78 "strings"
89 "testing"
910
@@ -231,3 +232,85 @@ func TestDeleteBranch(t *testing.T) {
231232 err = g.DeleteBranch(ctx, name(t, "missing"), "main", m.mainTip)
232233 assert.ErrorIs(t, err, domain.ErrNotFound)
233234 }
235+
236+func TestMergeTree(t *testing.T) {
237+ g, data := newGit(t)
238+ ctx := context.Background()
239+ m := newMergeFixture(t, data)
240+ t.Cleanup(func() { _ = g.Close(ctx) })
241+
242+ // Clean: main is an ancestor of feat.
243+ res, err := g.MergeTree(ctx, m.f.Name, m.mainTip, m.featTip)
244+ require.NoError(t, err)
245+ assert.True(t, res.Clean)
246+ assert.False(t, res.Tree.IsZero())
247+ assert.Empty(t, res.ConflictPaths)
248+
249+ // Conflict: both sides change a.txt and c.txt differently.
250+ m.f.Git("checkout", "--quiet", "-b", "clash", m.mainTip.String())
251+ m.f.Write("a.txt", "one\nTWO\nthree\n")
252+ m.f.Write("c.txt", "other\n")
253+ clash := oidOf(t, m.f.Commit("clash"))
254+ res, err = g.MergeTree(ctx, m.f.Name, clash, m.featTip)
255+ require.NoError(t, err)
256+ assert.False(t, res.Clean)
257+ assert.True(t, res.Tree.IsZero())
258+ assert.ElementsMatch(t, []string{"a.txt", "c.txt"}, res.ConflictPaths)
259+ assert.False(t, res.Truncated)
260+
261+ // No common history: not clean, no paths.
262+ m.f.Git("checkout", "--quiet", "--orphan", "other")
263+ m.f.Git("rm", "-rf", "--quiet", ".")
264+ m.f.Write("o.txt", "other\n")
265+ other := oidOf(t, m.f.Commit("other root"))
266+ res, err = g.MergeTree(ctx, m.f.Name, other, m.featTip)
267+ require.NoError(t, err)
268+ assert.False(t, res.Clean)
269+ assert.Empty(t, res.ConflictPaths)
270+
271+ // More conflicts than the cap.
272+ m.f.Git("checkout", "--quiet", "-b", "wide-a", m.a.String())
273+ for i := 0; i < domain.MaxConflictPaths+5; i++ {
274+ m.f.Write(fmt.Sprintf("f%02d.txt", i), "base\n")
275+ }
276+ base := oidOf(t, m.f.Commit("many"))
277+ m.f.Git("checkout", "--quiet", "-b", "wide-b", base.String())
278+ for i := 0; i < domain.MaxConflictPaths+5; i++ {
279+ m.f.Write(fmt.Sprintf("f%02d.txt", i), "b\n")
280+ }
281+ wb := oidOf(t, m.f.Commit("b side"))
282+ m.f.Git("checkout", "--quiet", "wide-a")
283+ for i := 0; i < domain.MaxConflictPaths+5; i++ {
284+ m.f.Write(fmt.Sprintf("f%02d.txt", i), "a\n")
285+ }
286+ wa := oidOf(t, m.f.Commit("a side"))
287+ res, err = g.MergeTree(ctx, m.f.Name, wa, wb)
288+ require.NoError(t, err)
289+ assert.False(t, res.Clean)
290+ assert.Len(t, res.ConflictPaths, domain.MaxConflictPaths+5, "the adapter returns every path")
291+ assert.False(t, res.Truncated, "bounding is the domain's")
292+}
293+
294+func TestCountBehind(t *testing.T) {
295+ g, data := newGit(t)
296+ ctx := context.Background()
297+ m := newMergeFixture(t, data)
298+ t.Cleanup(func() { _ = g.Close(ctx) })
299+
300+ // main has b that a-based head lacks.
301+ n, err := g.CountBehind(ctx, m.f.Name, m.a, m.mainTip, 10)
302+ require.NoError(t, err)
303+ assert.Equal(t, 1, n)
304+
305+ n, err = g.CountBehind(ctx, m.f.Name, m.featTip, m.mainTip, 10)
306+ require.NoError(t, err)
307+ assert.Equal(t, 0, n)
308+
309+ // feat is 3 commits ahead of a: the raw count stops at the limit.
310+ n, err = g.CountBehind(ctx, m.f.Name, m.a, m.featTip, 2)
311+ require.NoError(t, err)
312+ assert.Equal(t, 2, n)
313+ n, err = g.CountBehind(ctx, m.f.Name, m.a, m.featTip, 4)
314+ require.NoError(t, err)
315+ assert.Equal(t, 3, n)
316+}
@@ -4,6 +4,7 @@ package gitcli_test
4 4
5 import (5 import (
6 "context"6 "context"
7+ "fmt"
7 "strings"8 "strings"
8 "testing"9 "testing"
9 10
@@ -231,3 +232,85 @@ func TestDeleteBranch(t *testing.T) {
231 err = g.DeleteBranch(ctx, name(t, "missing"), "main", m.mainTip)232 err = g.DeleteBranch(ctx, name(t, "missing"), "main", m.mainTip)
232 assert.ErrorIs(t, err, domain.ErrNotFound)233 assert.ErrorIs(t, err, domain.ErrNotFound)
233 }234 }
235+
236+func TestMergeTree(t *testing.T) {
237+ g, data := newGit(t)
238+ ctx := context.Background()
239+ m := newMergeFixture(t, data)
240+ t.Cleanup(func() { _ = g.Close(ctx) })
241+
242+ // Clean: main is an ancestor of feat.
243+ res, err := g.MergeTree(ctx, m.f.Name, m.mainTip, m.featTip)
244+ require.NoError(t, err)
245+ assert.True(t, res.Clean)
246+ assert.False(t, res.Tree.IsZero())
247+ assert.Empty(t, res.ConflictPaths)
248+
249+ // Conflict: both sides change a.txt and c.txt differently.
250+ m.f.Git("checkout", "--quiet", "-b", "clash", m.mainTip.String())
251+ m.f.Write("a.txt", "one\nTWO\nthree\n")
252+ m.f.Write("c.txt", "other\n")
253+ clash := oidOf(t, m.f.Commit("clash"))
254+ res, err = g.MergeTree(ctx, m.f.Name, clash, m.featTip)
255+ require.NoError(t, err)
256+ assert.False(t, res.Clean)
257+ assert.True(t, res.Tree.IsZero())
258+ assert.ElementsMatch(t, []string{"a.txt", "c.txt"}, res.ConflictPaths)
259+ assert.False(t, res.Truncated)
260+
261+ // No common history: not clean, no paths.
262+ m.f.Git("checkout", "--quiet", "--orphan", "other")
263+ m.f.Git("rm", "-rf", "--quiet", ".")
264+ m.f.Write("o.txt", "other\n")
265+ other := oidOf(t, m.f.Commit("other root"))
266+ res, err = g.MergeTree(ctx, m.f.Name, other, m.featTip)
267+ require.NoError(t, err)
268+ assert.False(t, res.Clean)
269+ assert.Empty(t, res.ConflictPaths)
270+
271+ // More conflicts than the cap.
272+ m.f.Git("checkout", "--quiet", "-b", "wide-a", m.a.String())
273+ for i := 0; i < domain.MaxConflictPaths+5; i++ {
274+ m.f.Write(fmt.Sprintf("f%02d.txt", i), "base\n")
275+ }
276+ base := oidOf(t, m.f.Commit("many"))
277+ m.f.Git("checkout", "--quiet", "-b", "wide-b", base.String())
278+ for i := 0; i < domain.MaxConflictPaths+5; i++ {
279+ m.f.Write(fmt.Sprintf("f%02d.txt", i), "b\n")
280+ }
281+ wb := oidOf(t, m.f.Commit("b side"))
282+ m.f.Git("checkout", "--quiet", "wide-a")
283+ for i := 0; i < domain.MaxConflictPaths+5; i++ {
284+ m.f.Write(fmt.Sprintf("f%02d.txt", i), "a\n")
285+ }
286+ wa := oidOf(t, m.f.Commit("a side"))
287+ res, err = g.MergeTree(ctx, m.f.Name, wa, wb)
288+ require.NoError(t, err)
289+ assert.False(t, res.Clean)
290+ assert.Len(t, res.ConflictPaths, domain.MaxConflictPaths+5, "the adapter returns every path")
291+ assert.False(t, res.Truncated, "bounding is the domain's")
292+}
293+
294+func TestCountBehind(t *testing.T) {
295+ g, data := newGit(t)
296+ ctx := context.Background()
297+ m := newMergeFixture(t, data)
298+ t.Cleanup(func() { _ = g.Close(ctx) })
299+
300+ // main has b that a-based head lacks.
301+ n, err := g.CountBehind(ctx, m.f.Name, m.a, m.mainTip, 10)
302+ require.NoError(t, err)
303+ assert.Equal(t, 1, n)
304+
305+ n, err = g.CountBehind(ctx, m.f.Name, m.featTip, m.mainTip, 10)
306+ require.NoError(t, err)
307+ assert.Equal(t, 0, n)
308+
309+ // feat is 3 commits ahead of a: the raw count stops at the limit.
310+ n, err = g.CountBehind(ctx, m.f.Name, m.a, m.featTip, 2)
311+ require.NoError(t, err)
312+ assert.Equal(t, 2, n)
313+ n, err = g.CountBehind(ctx, m.f.Name, m.a, m.featTip, 4)
314+ require.NoError(t, err)
315+ assert.Equal(t, 3, n)
316+}
A internal/adapters/gitcli/mergeability.go +87 -0
@@ -0,0 +1,87 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package gitcli
4+
5+import (
6+ "context"
7+ "errors"
8+ "fmt"
9+ "slices"
10+ "strconv"
11+ "strings"
12+
13+ "bnema.dev/ermite/internal/domain"
14+)
15+
16+// MergeTree runs `git merge-tree --write-tree -z --name-only`, which merges in
17+// the object database and touches no ref or index. It prints the tree of the
18+// result, then on a conflict (exit 1) the NUL-separated conflicted paths.
19+// Commits with no common history do not merge: not clean, without paths.
20+func (g *Git) MergeTree(ctx context.Context, repo domain.RepoName, base, head domain.ObjectID) (domain.MergeTreeResult, error) {
21+ shared, err := g.sharesHistory(ctx, repo, base, head)
22+ if err != nil || !shared {
23+ return domain.MergeTreeResult{}, err
24+ }
25+ out, conflict, err := g.mergeTreeOutput(ctx, repo, base, head)
26+ if err != nil {
27+ return domain.MergeTreeResult{}, err
28+ }
29+ return parseMergeTree(out, conflict)
30+}
31+
32+// mergeTreeOutput runs merge-tree and returns what it printed; conflict is its
33+// exit status 1.
34+func (g *Git) mergeTreeOutput(ctx context.Context, repo domain.RepoName, base, head domain.ObjectID) (out string, conflict bool, err error) {
35+ dir, err := g.repoDir(repo)
36+ if err != nil {
37+ return "", false, err
38+ }
39+ out, err = g.runCapture(ctx, nil, readArgs(dir, "merge-tree", "--write-tree", "-z", "--name-only", "--no-messages", "--end-of-options", base.String(), head.String())...)
40+ conflict = exitCode(err) == 1
41+ if err != nil && !conflict {
42+ return "", false, fmt.Errorf("merge %s into %s in %q: %w", head, base, repo, err)
43+ }
44+ return out, conflict, nil
45+}
46+
47+// sharesHistory reports whether the commits have a merge base.
48+func (g *Git) sharesHistory(ctx context.Context, repo domain.RepoName, a, b domain.ObjectID) (bool, error) {
49+ _, err := g.MergeBase(ctx, repo, a, b)
50+ if errors.Is(err, domain.ErrNotFound) {
51+ return false, nil
52+ }
53+ return err == nil, err
54+}
55+
56+// parseMergeTree reads the output of merge-tree: the tree, then on a conflict
57+// the NUL-separated paths, all of them.
58+func parseMergeTree(out string, conflict bool) (domain.MergeTreeResult, error) {
59+ fields := strings.Split(out, "\x00")
60+ tree, err := parseID(fields[0])
61+ if !conflict {
62+ return domain.MergeTreeResult{Tree: tree, Clean: true}, err
63+ }
64+ paths := fields[1:]
65+ if i := slices.Index(paths, ""); i >= 0 {
66+ paths = paths[:i]
67+ }
68+ return domain.MergeTreeResult{ConflictPaths: paths}, nil
69+}
70+
71+// CountBehind runs `git rev-list --count --max-count=<limit> <target> ^<head>`
72+// and returns the count git printed.
73+func (g *Git) CountBehind(ctx context.Context, repo domain.RepoName, head, target domain.ObjectID, limit int) (int, error) {
74+ dir, err := g.repoDir(repo)
75+ if err != nil {
76+ return 0, err
77+ }
78+ out, err := g.run(ctx, readArgs(dir, "rev-list", "--count", "--max-count="+strconv.Itoa(limit), target.String(), "^"+head.String(), "--")...)
79+ if err != nil {
80+ return 0, fmt.Errorf("count behind %s in %q: %w", target, repo, err)
81+ }
82+ n, err := strconv.Atoi(strings.TrimSpace(out))
83+ if err != nil {
84+ return 0, fmt.Errorf("git printed %q: %w", strings.TrimSpace(out), err)
85+ }
86+ return n, nil
87+}
@@ -0,0 +1,87 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package gitcli
4+
5+import (
6+ "context"
7+ "errors"
8+ "fmt"
9+ "slices"
10+ "strconv"
11+ "strings"
12+
13+ "bnema.dev/ermite/internal/domain"
14+)
15+
16+// MergeTree runs `git merge-tree --write-tree -z --name-only`, which merges in
17+// the object database and touches no ref or index. It prints the tree of the
18+// result, then on a conflict (exit 1) the NUL-separated conflicted paths.
19+// Commits with no common history do not merge: not clean, without paths.
20+func (g *Git) MergeTree(ctx context.Context, repo domain.RepoName, base, head domain.ObjectID) (domain.MergeTreeResult, error) {
21+ shared, err := g.sharesHistory(ctx, repo, base, head)
22+ if err != nil || !shared {
23+ return domain.MergeTreeResult{}, err
24+ }
25+ out, conflict, err := g.mergeTreeOutput(ctx, repo, base, head)
26+ if err != nil {
27+ return domain.MergeTreeResult{}, err
28+ }
29+ return parseMergeTree(out, conflict)
30+}
31+
32+// mergeTreeOutput runs merge-tree and returns what it printed; conflict is its
33+// exit status 1.
34+func (g *Git) mergeTreeOutput(ctx context.Context, repo domain.RepoName, base, head domain.ObjectID) (out string, conflict bool, err error) {
35+ dir, err := g.repoDir(repo)
36+ if err != nil {
37+ return "", false, err
38+ }
39+ out, err = g.runCapture(ctx, nil, readArgs(dir, "merge-tree", "--write-tree", "-z", "--name-only", "--no-messages", "--end-of-options", base.String(), head.String())...)
40+ conflict = exitCode(err) == 1
41+ if err != nil && !conflict {
42+ return "", false, fmt.Errorf("merge %s into %s in %q: %w", head, base, repo, err)
43+ }
44+ return out, conflict, nil
45+}
46+
47+// sharesHistory reports whether the commits have a merge base.
48+func (g *Git) sharesHistory(ctx context.Context, repo domain.RepoName, a, b domain.ObjectID) (bool, error) {
49+ _, err := g.MergeBase(ctx, repo, a, b)
50+ if errors.Is(err, domain.ErrNotFound) {
51+ return false, nil
52+ }
53+ return err == nil, err
54+}
55+
56+// parseMergeTree reads the output of merge-tree: the tree, then on a conflict
57+// the NUL-separated paths, all of them.
58+func parseMergeTree(out string, conflict bool) (domain.MergeTreeResult, error) {
59+ fields := strings.Split(out, "\x00")
60+ tree, err := parseID(fields[0])
61+ if !conflict {
62+ return domain.MergeTreeResult{Tree: tree, Clean: true}, err
63+ }
64+ paths := fields[1:]
65+ if i := slices.Index(paths, ""); i >= 0 {
66+ paths = paths[:i]
67+ }
68+ return domain.MergeTreeResult{ConflictPaths: paths}, nil
69+}
70+
71+// CountBehind runs `git rev-list --count --max-count=<limit> <target> ^<head>`
72+// and returns the count git printed.
73+func (g *Git) CountBehind(ctx context.Context, repo domain.RepoName, head, target domain.ObjectID, limit int) (int, error) {
74+ dir, err := g.repoDir(repo)
75+ if err != nil {
76+ return 0, err
77+ }
78+ out, err := g.run(ctx, readArgs(dir, "rev-list", "--count", "--max-count="+strconv.Itoa(limit), target.String(), "^"+head.String(), "--")...)
79+ if err != nil {
80+ return 0, fmt.Errorf("count behind %s in %q: %w", target, repo, err)
81+ }
82+ n, err := strconv.Atoi(strings.TrimSpace(out))
83+ if err != nil {
84+ return 0, fmt.Errorf("git printed %q: %w", strings.TrimSpace(out), err)
85+ }
86+ return n, nil
87+}
M internal/adapters/kvstore/ci_tip.go +29 -0
@@ -89,3 +89,32 @@ func (c *CI) DeleteRefTip(ctx context.Context, repo domain.RepoName, ref string)
8989 }
9090 return nil
9191 }
92+
93+// DeleteRefTipIf removes the record of a ref only when it still names sha.
94+func (c *CI) DeleteRefTipIf(ctx context.Context, repo domain.RepoName, ref, sha string) error {
95+ for range updateAttemptsTip {
96+ it, ok := c.db.Get(ctx, refTipKey(repo, ref))
97+ if !ok {
98+ return nil
99+ }
100+ var pb storagepb.CIRefTip
101+ if err := proto.Unmarshal(it.Value, &pb); err != nil {
102+ return fmt.Errorf("decode ref tip: %w", err)
103+ }
104+ if pb.GetSha() != sha {
105+ return nil // a newer push owns the record
106+ }
107+ var b kv.Batch
108+ b.RequireVersion(refTipKey(repo, ref), it.Version)
109+ b.Delete(refTipKey(repo, ref))
110+ _, err := c.db.Apply(ctx, &b)
111+ if errors.Is(err, kv.ErrConflict) {
112+ continue
113+ }
114+ if err != nil {
115+ return fmt.Errorf("delete ref tip: %w", err)
116+ }
117+ return nil
118+ }
119+ return fmt.Errorf("delete ref tip: %w", domain.ErrConflict)
120+}
@@ -89,3 +89,32 @@ func (c *CI) DeleteRefTip(ctx context.Context, repo domain.RepoName, ref string)
89 }89 }
90 return nil90 return nil
91 }91 }
92+
93+// DeleteRefTipIf removes the record of a ref only when it still names sha.
94+func (c *CI) DeleteRefTipIf(ctx context.Context, repo domain.RepoName, ref, sha string) error {
95+ for range updateAttemptsTip {
96+ it, ok := c.db.Get(ctx, refTipKey(repo, ref))
97+ if !ok {
98+ return nil
99+ }
100+ var pb storagepb.CIRefTip
101+ if err := proto.Unmarshal(it.Value, &pb); err != nil {
102+ return fmt.Errorf("decode ref tip: %w", err)
103+ }
104+ if pb.GetSha() != sha {
105+ return nil // a newer push owns the record
106+ }
107+ var b kv.Batch
108+ b.RequireVersion(refTipKey(repo, ref), it.Version)
109+ b.Delete(refTipKey(repo, ref))
110+ _, err := c.db.Apply(ctx, &b)
111+ if errors.Is(err, kv.ErrConflict) {
112+ continue
113+ }
114+ if err != nil {
115+ return fmt.Errorf("delete ref tip: %w", err)
116+ }
117+ return nil
118+ }
119+ return fmt.Errorf("delete ref tip: %w", domain.ErrConflict)
120+}
A internal/adapters/kvstore/ci_tip_test.go +34 -0
@@ -0,0 +1,34 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package kvstore_test
4+
5+import (
6+ "context"
7+ "testing"
8+
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+
12+ "bnema.dev/ermite/internal/domain"
13+)
14+
15+func TestDeleteRefTipIfOnlyRemovesTheMatchingRecord(t *testing.T) {
16+ ctx := context.Background()
17+ s := openStore(t, t.TempDir())
18+ n := name(t, "proj")
19+ require.NoError(t, s.Create(ctx, domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, CreatedAt: created}))
20+ ci := s.CI()
21+ ref := "refs/heads/feat"
22+
23+ require.NoError(t, ci.PutRefTip(ctx, domain.CIRefTip{Repo: n, Ref: ref, Sha: "aaa", Actor: "ada"}))
24+ require.NoError(t, ci.DeleteRefTipIf(ctx, n, ref, "bbb"))
25+ got, err := ci.GetRefTip(ctx, n, ref)
26+ require.NoError(t, err)
27+ assert.Equal(t, "aaa", got.Sha, "a record of another object stays")
28+
29+ require.NoError(t, ci.DeleteRefTipIf(ctx, n, ref, "aaa"))
30+ _, err = ci.GetRefTip(ctx, n, ref)
31+ assert.ErrorIs(t, err, domain.ErrNotFound)
32+
33+ require.NoError(t, ci.DeleteRefTipIf(ctx, n, ref, "aaa"), "a missing record is not an error")
34+}
@@ -0,0 +1,34 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package kvstore_test
4+
5+import (
6+ "context"
7+ "testing"
8+
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+
12+ "bnema.dev/ermite/internal/domain"
13+)
14+
15+func TestDeleteRefTipIfOnlyRemovesTheMatchingRecord(t *testing.T) {
16+ ctx := context.Background()
17+ s := openStore(t, t.TempDir())
18+ n := name(t, "proj")
19+ require.NoError(t, s.Create(ctx, domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, CreatedAt: created}))
20+ ci := s.CI()
21+ ref := "refs/heads/feat"
22+
23+ require.NoError(t, ci.PutRefTip(ctx, domain.CIRefTip{Repo: n, Ref: ref, Sha: "aaa", Actor: "ada"}))
24+ require.NoError(t, ci.DeleteRefTipIf(ctx, n, ref, "bbb"))
25+ got, err := ci.GetRefTip(ctx, n, ref)
26+ require.NoError(t, err)
27+ assert.Equal(t, "aaa", got.Sha, "a record of another object stays")
28+
29+ require.NoError(t, ci.DeleteRefTipIf(ctx, n, ref, "aaa"))
30+ _, err = ci.GetRefTip(ctx, n, ref)
31+ assert.ErrorIs(t, err, domain.ErrNotFound)
32+
33+ require.NoError(t, ci.DeleteRefTipIf(ctx, n, ref, "aaa"), "a missing record is not an error")
34+}
M internal/adapters/kvstore/mergerequests.go +150 -5
@@ -17,6 +17,7 @@ import (
1717 kv "bnema.dev/ermite-kv"
1818 "github.com/bnema/zerowrap"
1919 "google.golang.org/protobuf/proto"
20+ "google.golang.org/protobuf/types/known/timestamppb"
2021
2122 storagepb "bnema.dev/ermite/gen/ermite/storage"
2223 "bnema.dev/ermite/internal/domain"
@@ -27,10 +28,11 @@ import (
2728 // ending in "/" never matches another repository; numbers and comment ids are
2829 // zero-padded so that keys sort numerically.
2930 const (
30- mrPrefix = "mr/" // mr/<repo>/<number:010d>
31- commentPrefix = "mrc/" // mrc/<repo>/<number:010d>/<id:010d>
32- readPrefix = "mrread/" // mrread/<repo>/<number:010d>
33- seqPrefix = "mrseq/" // mrseq/<repo>
31+ mrPrefix = "mr/" // mr/<repo>/<number:010d>
32+ commentPrefix = "mrc/" // mrc/<repo>/<number:010d>/<id:010d>
33+ readPrefix = "mrread/" // mrread/<repo>/<number:010d>
34+ seqPrefix = "mrseq/" // mrseq/<repo>
35+ mergePrefix = "mrmerge/" // mrmerge/<repo>/<number:010d>
3436 // The indexes of the open merge requests by source branch and by target
3537 // branch, which a push looks up (the values are not read). They are written in
3638 // the same batch as the merge request, so they are never out of step with it,
@@ -69,6 +71,9 @@ func commentKey(repo domain.RepoName, n, id int64) []byte {
6971 func readKey(repo domain.RepoName, n int64) []byte {
7072 return []byte(readPrefix + repo.String() + "/" + fmt.Sprintf(numberFormat, n))
7173 }
74+func mergeKey(repo domain.RepoName, n int64) []byte {
75+ return []byte(mergePrefix + repo.String() + "/" + fmt.Sprintf(numberFormat, n))
76+}
7277 func seqKey(repo domain.RepoName) []byte { return []byte(seqPrefix + repo.String()) }
7378
7479 // mrOpenRepoPrefix is where the open merge requests of a repository are indexed
@@ -175,6 +180,7 @@ func mergeRequestKeys(ctx context.Context, snap *kv.Snapshot, repo domain.RepoNa
175180 for _, p := range []string{
176181 mrRepoPrefix(repo), commentPrefix + repo.String() + "/", readPrefix + repo.String() + "/",
177182 mrOpenRepoPrefix(repo), mrTargetPrefix + repo.String() + "/",
183+ mergePrefix + repo.String() + "/",
178184 } {
179185 for k := range snap.Prefix(ctx, []byte(p)) {
180186 keys = append(keys, slices.Clone(k))
@@ -469,6 +475,9 @@ func (m *MergeRequests) Update(ctx context.Context, mr domain.MergeRequest) erro
469475 } else {
470476 indexOpen(&b, nil, mr)
471477 }
478+ if !mr.IsOpen() {
479+ b.Delete(mergeKey(mr.Repo, mr.Number))
480+ }
472481 if _, err := m.db.Apply(ctx, &b); err != nil {
473482 if errors.Is(err, kv.ErrConflict) {
474483 return domain.ErrConflict
@@ -579,6 +588,133 @@ func (m *MergeRequests) SetRead(ctx context.Context, repo domain.RepoName, numbe
579588 return fmt.Errorf("record read position of %d in %q: %w", number, repo, domain.ErrConflict)
580589 }
581590
591+// GetMergeability returns the stored mergeability, or domain.ErrNotFound.
592+func (m *MergeRequests) GetMergeability(ctx context.Context, repo domain.RepoName, number int64) (domain.Mergeability, error) {
593+ item, ok := m.db.Get(ctx, mergeKey(repo, number))
594+ if !ok {
595+ return domain.Mergeability{}, domain.ErrNotFound
596+ }
597+ return decodeMergeability(repo, number, item)
598+}
599+
600+// PutMergeability stores the mergeability in a batch that requires the merge
601+// request to be unchanged since it was read here and open, and the record to
602+// be absent (Version 0) or unchanged.
603+//
604+//hexcheck:ignore suspicious-business-logic-in-adapter version preconditions of a derived record: a storage concern, no policy
605+func (m *MergeRequests) PutMergeability(ctx context.Context, mg domain.Mergeability) error {
606+ val, err := encodeMergeability(mg)
607+ if err != nil {
608+ return err
609+ }
610+ for range createTries {
611+ mrItem, ok := m.db.Get(ctx, mrKey(mg.Repo, mg.Number))
612+ if !ok {
613+ return domain.ErrNotFound
614+ }
615+ mr, err := decodeMergeRequest(mg.Repo, mrItem)
616+ if err != nil {
617+ return err
618+ }
619+ if !mr.IsOpen() {
620+ return domain.ErrNotFound
621+ }
622+ key := mergeKey(mg.Repo, mg.Number)
623+ var b kv.Batch
624+ b.RequireVersion(mrKey(mg.Repo, mg.Number), mrItem.Version)
625+ if mg.Version == 0 {
626+ b.RequireAbsent(key)
627+ } else {
628+ b.RequireVersion(key, mg.Version)
629+ }
630+ b.Put(key, val)
631+ _, err = m.db.Apply(ctx, &b)
632+ if errors.Is(err, kv.ErrConflict) {
633+ // Either the record changed (the caller's conflict) or the merge
634+ // request did (retry against it).
635+ if cur, ok := m.db.Get(ctx, mrKey(mg.Repo, mg.Number)); ok && cur.Version != mrItem.Version {
636+ continue
637+ }
638+ return domain.ErrConflict
639+ }
640+ if err != nil {
641+ return fmt.Errorf("store mergeability of %d in %q: %w", mg.Number, mg.Repo, err)
642+ }
643+ return nil
644+ }
645+ return fmt.Errorf("store mergeability of %d in %q: %w", mg.Number, mg.Repo, domain.ErrConflict)
646+}
647+
648+// DeleteMergeability removes the stored mergeability; absent is fine.
649+func (m *MergeRequests) DeleteMergeability(ctx context.Context, repo domain.RepoName, number int64) error {
650+ var b kv.Batch
651+ b.Delete(mergeKey(repo, number))
652+ if _, err := m.db.Apply(ctx, &b); err != nil {
653+ return fmt.Errorf("delete mergeability of %d in %q: %w", number, repo, err)
654+ }
655+ return nil
656+}
657+
658+func encodeMergeability(m domain.Mergeability) ([]byte, error) {
659+ var state storagepb.MergeabilityState
660+ switch m.State {
661+ case domain.MergeabilityUpToDate:
662+ state = storagepb.MergeabilityState_MERGEABILITY_STATE_UP_TO_DATE
663+ case domain.MergeabilityBehind:
664+ state = storagepb.MergeabilityState_MERGEABILITY_STATE_BEHIND
665+ case domain.MergeabilityConflict:
666+ state = storagepb.MergeabilityState_MERGEABILITY_STATE_CONFLICT
667+ default:
668+ return nil, fmt.Errorf("%w: mergeability state %q is not stored", domain.ErrInvalid, m.State)
669+ }
670+ return proto.Marshal(&storagepb.Mergeability{
671+ State: state,
672+ TargetId: m.TargetID.String(),
673+ HeadId: m.HeadID.String(),
674+ Behind: int32(m.Behind), //nolint:gosec // capped at MaxBehindCount
675+ BehindCapped: m.BehindCapped,
676+ ConflictPaths: m.ConflictPaths,
677+ ConflictPathsTruncated: m.ConflictPathsTruncated,
678+ CheckedAt: timestamppb.New(m.CheckedAt),
679+ })
680+}
681+
682+func decodeMergeability(repo domain.RepoName, number int64, item kv.Item) (domain.Mergeability, error) {
683+ var pb storagepb.Mergeability
684+ if err := proto.Unmarshal(item.Value, &pb); err != nil {
685+ return domain.Mergeability{}, fmt.Errorf("decode mergeability of %d in %q: %w", number, repo, err)
686+ }
687+ state, ok := mergeabilityStates[pb.GetState()]
688+ if !ok {
689+ return domain.Mergeability{}, fmt.Errorf("decode mergeability of %d in %q: unknown state %d: %w", number, repo, pb.GetState(), domain.ErrNotFound)
690+ }
691+ target, err := domain.ParseObjectID(pb.GetTargetId())
692+ if err != nil {
693+ return domain.Mergeability{}, fmt.Errorf("decode mergeability of %d in %q: %w", number, repo, err)
694+ }
695+ head, err := domain.ParseObjectID(pb.GetHeadId())
696+ if err != nil {
697+ return domain.Mergeability{}, fmt.Errorf("decode mergeability of %d in %q: %w", number, repo, err)
698+ }
699+ return domain.Mergeability{
700+ Repo: repo, Number: number, State: state,
701+ TargetID: target, HeadID: head,
702+ Behind: int(pb.GetBehind()), BehindCapped: pb.GetBehindCapped(),
703+ ConflictPaths: pb.GetConflictPaths(),
704+ ConflictPathsTruncated: pb.GetConflictPathsTruncated(),
705+ CheckedAt: pb.GetCheckedAt().AsTime(),
706+ Version: item.Version,
707+ }, nil
708+}
709+
710+// mergeabilityStates maps the stored states to the domain ones; CHECKING is
711+// never stored.
712+var mergeabilityStates = map[storagepb.MergeabilityState]domain.MergeabilityState{
713+ storagepb.MergeabilityState_MERGEABILITY_STATE_UP_TO_DATE: domain.MergeabilityUpToDate,
714+ storagepb.MergeabilityState_MERGEABILITY_STATE_BEHIND: domain.MergeabilityBehind,
715+ storagepb.MergeabilityState_MERGEABILITY_STATE_CONFLICT: domain.MergeabilityConflict,
716+}
717+
582718 func encodeMergeRequest(mr domain.MergeRequest) ([]byte, error) {
583719 if mr.Repo.IsZero() {
584720 return nil, fmt.Errorf("%w: empty name", domain.ErrInvalidName)
@@ -735,6 +871,8 @@ func encodeMode(m domain.MergeMode) (storagepb.MergeMode, error) {
735871 return storagepb.MergeMode_MERGE_MODE_FAST_FORWARD, nil
736872 case domain.MergeModeSquash:
737873 return storagepb.MergeMode_MERGE_MODE_SQUASH, nil
874+ case domain.MergeModeMerge:
875+ return storagepb.MergeMode_MERGE_MODE_MERGE, nil
738876 }
739877 return 0, fmt.Errorf("%w: merge mode %q", domain.ErrInvalid, m)
740878 }
@@ -746,6 +884,8 @@ func decodeMode(m storagepb.MergeMode) domain.MergeMode {
746884 return domain.MergeModeFastForward
747885 case storagepb.MergeMode_MERGE_MODE_SQUASH:
748886 return domain.MergeModeSquash
887+ case storagepb.MergeMode_MERGE_MODE_MERGE:
888+ return domain.MergeModeMerge
749889 }
750890 return ""
751891 }
@@ -773,7 +913,7 @@ func decodeSide(s storagepb.DiffSide) domain.DiffSide {
773913 }
774914
775915 func encodeSettings(s domain.RepoSettings) (*storagepb.RepoSettings, error) {
776- pb := &storagepb.RepoSettings{FailingChecksBlock: s.FailingChecksBlock}
916+ pb := &storagepb.RepoSettings{FailingChecksBlock: s.FailingChecksBlock, MergeModesV2: true}
777917 for _, m := range s.AllowedMergeModes {
778918 mode, err := encodeMode(m)
779919 if err != nil || mode == storagepb.MergeMode_MERGE_MODE_UNSPECIFIED {
@@ -810,5 +950,10 @@ func decodeSettings(pb *storagepb.RepoSettings) domain.RepoSettings {
810950 if err != nil {
811951 return domain.DefaultRepoSettings()
812952 }
953+ // Settings stored before merge commits: exactly {fast-forward, squash} was
954+ // the old default, "everything allowed".
955+ if !pb.GetMergeModesV2() && slices.Equal(normalized, []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash}) {
956+ normalized = domain.MergeModes()
957+ }
813958 return domain.RepoSettings{AllowedMergeModes: normalized, FailingChecksBlock: pb.GetFailingChecksBlock(), Issues: decodeIssuesMode(pb.GetIssues())}
814959 }
@@ -17,6 +17,7 @@ import (
17 kv "bnema.dev/ermite-kv"17 kv "bnema.dev/ermite-kv"
18 "github.com/bnema/zerowrap"18 "github.com/bnema/zerowrap"
19 "google.golang.org/protobuf/proto"19 "google.golang.org/protobuf/proto"
20+ "google.golang.org/protobuf/types/known/timestamppb"
20 21
21 storagepb "bnema.dev/ermite/gen/ermite/storage"22 storagepb "bnema.dev/ermite/gen/ermite/storage"
22 "bnema.dev/ermite/internal/domain"23 "bnema.dev/ermite/internal/domain"
@@ -27,10 +28,11 @@ import (
27 // ending in "/" never matches another repository; numbers and comment ids are28 // ending in "/" never matches another repository; numbers and comment ids are
28 // zero-padded so that keys sort numerically.29 // zero-padded so that keys sort numerically.
29 const (30 const (
30- mrPrefix = "mr/" // mr/<repo>/<number:010d>31+ mrPrefix = "mr/" // mr/<repo>/<number:010d>
31- commentPrefix = "mrc/" // mrc/<repo>/<number:010d>/<id:010d>32+ commentPrefix = "mrc/" // mrc/<repo>/<number:010d>/<id:010d>
32- readPrefix = "mrread/" // mrread/<repo>/<number:010d>33+ readPrefix = "mrread/" // mrread/<repo>/<number:010d>
33- seqPrefix = "mrseq/" // mrseq/<repo>34+ seqPrefix = "mrseq/" // mrseq/<repo>
35+ mergePrefix = "mrmerge/" // mrmerge/<repo>/<number:010d>
34 // The indexes of the open merge requests by source branch and by target36 // The indexes of the open merge requests by source branch and by target
35 // branch, which a push looks up (the values are not read). They are written in37 // branch, which a push looks up (the values are not read). They are written in
36 // the same batch as the merge request, so they are never out of step with it,38 // the same batch as the merge request, so they are never out of step with it,
@@ -69,6 +71,9 @@ func commentKey(repo domain.RepoName, n, id int64) []byte {
69 func readKey(repo domain.RepoName, n int64) []byte {71 func readKey(repo domain.RepoName, n int64) []byte {
70 return []byte(readPrefix + repo.String() + "/" + fmt.Sprintf(numberFormat, n))72 return []byte(readPrefix + repo.String() + "/" + fmt.Sprintf(numberFormat, n))
71 }73 }
74+func mergeKey(repo domain.RepoName, n int64) []byte {
75+ return []byte(mergePrefix + repo.String() + "/" + fmt.Sprintf(numberFormat, n))
76+}
72 func seqKey(repo domain.RepoName) []byte { return []byte(seqPrefix + repo.String()) }77 func seqKey(repo domain.RepoName) []byte { return []byte(seqPrefix + repo.String()) }
73 78
74 // mrOpenRepoPrefix is where the open merge requests of a repository are indexed79 // mrOpenRepoPrefix is where the open merge requests of a repository are indexed
@@ -175,6 +180,7 @@ func mergeRequestKeys(ctx context.Context, snap *kv.Snapshot, repo domain.RepoNa
175 for _, p := range []string{180 for _, p := range []string{
176 mrRepoPrefix(repo), commentPrefix + repo.String() + "/", readPrefix + repo.String() + "/",181 mrRepoPrefix(repo), commentPrefix + repo.String() + "/", readPrefix + repo.String() + "/",
177 mrOpenRepoPrefix(repo), mrTargetPrefix + repo.String() + "/",182 mrOpenRepoPrefix(repo), mrTargetPrefix + repo.String() + "/",
183+ mergePrefix + repo.String() + "/",
178 } {184 } {
179 for k := range snap.Prefix(ctx, []byte(p)) {185 for k := range snap.Prefix(ctx, []byte(p)) {
180 keys = append(keys, slices.Clone(k))186 keys = append(keys, slices.Clone(k))
@@ -469,6 +475,9 @@ func (m *MergeRequests) Update(ctx context.Context, mr domain.MergeRequest) erro
469 } else {475 } else {
470 indexOpen(&b, nil, mr)476 indexOpen(&b, nil, mr)
471 }477 }
478+ if !mr.IsOpen() {
479+ b.Delete(mergeKey(mr.Repo, mr.Number))
480+ }
472 if _, err := m.db.Apply(ctx, &b); err != nil {481 if _, err := m.db.Apply(ctx, &b); err != nil {
473 if errors.Is(err, kv.ErrConflict) {482 if errors.Is(err, kv.ErrConflict) {
474 return domain.ErrConflict483 return domain.ErrConflict
@@ -579,6 +588,133 @@ func (m *MergeRequests) SetRead(ctx context.Context, repo domain.RepoName, numbe
579 return fmt.Errorf("record read position of %d in %q: %w", number, repo, domain.ErrConflict)588 return fmt.Errorf("record read position of %d in %q: %w", number, repo, domain.ErrConflict)
580 }589 }
581 590
591+// GetMergeability returns the stored mergeability, or domain.ErrNotFound.
592+func (m *MergeRequests) GetMergeability(ctx context.Context, repo domain.RepoName, number int64) (domain.Mergeability, error) {
593+ item, ok := m.db.Get(ctx, mergeKey(repo, number))
594+ if !ok {
595+ return domain.Mergeability{}, domain.ErrNotFound
596+ }
597+ return decodeMergeability(repo, number, item)
598+}
599+
600+// PutMergeability stores the mergeability in a batch that requires the merge
601+// request to be unchanged since it was read here and open, and the record to
602+// be absent (Version 0) or unchanged.
603+//
604+//hexcheck:ignore suspicious-business-logic-in-adapter version preconditions of a derived record: a storage concern, no policy
605+func (m *MergeRequests) PutMergeability(ctx context.Context, mg domain.Mergeability) error {
606+ val, err := encodeMergeability(mg)
607+ if err != nil {
608+ return err
609+ }
610+ for range createTries {
611+ mrItem, ok := m.db.Get(ctx, mrKey(mg.Repo, mg.Number))
612+ if !ok {
613+ return domain.ErrNotFound
614+ }
615+ mr, err := decodeMergeRequest(mg.Repo, mrItem)
616+ if err != nil {
617+ return err
618+ }
619+ if !mr.IsOpen() {
620+ return domain.ErrNotFound
621+ }
622+ key := mergeKey(mg.Repo, mg.Number)
623+ var b kv.Batch
624+ b.RequireVersion(mrKey(mg.Repo, mg.Number), mrItem.Version)
625+ if mg.Version == 0 {
626+ b.RequireAbsent(key)
627+ } else {
628+ b.RequireVersion(key, mg.Version)
629+ }
630+ b.Put(key, val)
631+ _, err = m.db.Apply(ctx, &b)
632+ if errors.Is(err, kv.ErrConflict) {
633+ // Either the record changed (the caller's conflict) or the merge
634+ // request did (retry against it).
635+ if cur, ok := m.db.Get(ctx, mrKey(mg.Repo, mg.Number)); ok && cur.Version != mrItem.Version {
636+ continue
637+ }
638+ return domain.ErrConflict
639+ }
640+ if err != nil {
641+ return fmt.Errorf("store mergeability of %d in %q: %w", mg.Number, mg.Repo, err)
642+ }
643+ return nil
644+ }
645+ return fmt.Errorf("store mergeability of %d in %q: %w", mg.Number, mg.Repo, domain.ErrConflict)
646+}
647+
648+// DeleteMergeability removes the stored mergeability; absent is fine.
649+func (m *MergeRequests) DeleteMergeability(ctx context.Context, repo domain.RepoName, number int64) error {
650+ var b kv.Batch
651+ b.Delete(mergeKey(repo, number))
652+ if _, err := m.db.Apply(ctx, &b); err != nil {
653+ return fmt.Errorf("delete mergeability of %d in %q: %w", number, repo, err)
654+ }
655+ return nil
656+}
657+
658+func encodeMergeability(m domain.Mergeability) ([]byte, error) {
659+ var state storagepb.MergeabilityState
660+ switch m.State {
661+ case domain.MergeabilityUpToDate:
662+ state = storagepb.MergeabilityState_MERGEABILITY_STATE_UP_TO_DATE
663+ case domain.MergeabilityBehind:
664+ state = storagepb.MergeabilityState_MERGEABILITY_STATE_BEHIND
665+ case domain.MergeabilityConflict:
666+ state = storagepb.MergeabilityState_MERGEABILITY_STATE_CONFLICT
667+ default:
668+ return nil, fmt.Errorf("%w: mergeability state %q is not stored", domain.ErrInvalid, m.State)
669+ }
670+ return proto.Marshal(&storagepb.Mergeability{
671+ State: state,
672+ TargetId: m.TargetID.String(),
673+ HeadId: m.HeadID.String(),
674+ Behind: int32(m.Behind), //nolint:gosec // capped at MaxBehindCount
675+ BehindCapped: m.BehindCapped,
676+ ConflictPaths: m.ConflictPaths,
677+ ConflictPathsTruncated: m.ConflictPathsTruncated,
678+ CheckedAt: timestamppb.New(m.CheckedAt),
679+ })
680+}
681+
682+func decodeMergeability(repo domain.RepoName, number int64, item kv.Item) (domain.Mergeability, error) {
683+ var pb storagepb.Mergeability
684+ if err := proto.Unmarshal(item.Value, &pb); err != nil {
685+ return domain.Mergeability{}, fmt.Errorf("decode mergeability of %d in %q: %w", number, repo, err)
686+ }
687+ state, ok := mergeabilityStates[pb.GetState()]
688+ if !ok {
689+ return domain.Mergeability{}, fmt.Errorf("decode mergeability of %d in %q: unknown state %d: %w", number, repo, pb.GetState(), domain.ErrNotFound)
690+ }
691+ target, err := domain.ParseObjectID(pb.GetTargetId())
692+ if err != nil {
693+ return domain.Mergeability{}, fmt.Errorf("decode mergeability of %d in %q: %w", number, repo, err)
694+ }
695+ head, err := domain.ParseObjectID(pb.GetHeadId())
696+ if err != nil {
697+ return domain.Mergeability{}, fmt.Errorf("decode mergeability of %d in %q: %w", number, repo, err)
698+ }
699+ return domain.Mergeability{
700+ Repo: repo, Number: number, State: state,
701+ TargetID: target, HeadID: head,
702+ Behind: int(pb.GetBehind()), BehindCapped: pb.GetBehindCapped(),
703+ ConflictPaths: pb.GetConflictPaths(),
704+ ConflictPathsTruncated: pb.GetConflictPathsTruncated(),
705+ CheckedAt: pb.GetCheckedAt().AsTime(),
706+ Version: item.Version,
707+ }, nil
708+}
709+
710+// mergeabilityStates maps the stored states to the domain ones; CHECKING is
711+// never stored.
712+var mergeabilityStates = map[storagepb.MergeabilityState]domain.MergeabilityState{
713+ storagepb.MergeabilityState_MERGEABILITY_STATE_UP_TO_DATE: domain.MergeabilityUpToDate,
714+ storagepb.MergeabilityState_MERGEABILITY_STATE_BEHIND: domain.MergeabilityBehind,
715+ storagepb.MergeabilityState_MERGEABILITY_STATE_CONFLICT: domain.MergeabilityConflict,
716+}
717+
582 func encodeMergeRequest(mr domain.MergeRequest) ([]byte, error) {718 func encodeMergeRequest(mr domain.MergeRequest) ([]byte, error) {
583 if mr.Repo.IsZero() {719 if mr.Repo.IsZero() {
584 return nil, fmt.Errorf("%w: empty name", domain.ErrInvalidName)720 return nil, fmt.Errorf("%w: empty name", domain.ErrInvalidName)
@@ -735,6 +871,8 @@ func encodeMode(m domain.MergeMode) (storagepb.MergeMode, error) {
735 return storagepb.MergeMode_MERGE_MODE_FAST_FORWARD, nil871 return storagepb.MergeMode_MERGE_MODE_FAST_FORWARD, nil
736 case domain.MergeModeSquash:872 case domain.MergeModeSquash:
737 return storagepb.MergeMode_MERGE_MODE_SQUASH, nil873 return storagepb.MergeMode_MERGE_MODE_SQUASH, nil
874+ case domain.MergeModeMerge:
875+ return storagepb.MergeMode_MERGE_MODE_MERGE, nil
738 }876 }
739 return 0, fmt.Errorf("%w: merge mode %q", domain.ErrInvalid, m)877 return 0, fmt.Errorf("%w: merge mode %q", domain.ErrInvalid, m)
740 }878 }
@@ -746,6 +884,8 @@ func decodeMode(m storagepb.MergeMode) domain.MergeMode {
746 return domain.MergeModeFastForward884 return domain.MergeModeFastForward
747 case storagepb.MergeMode_MERGE_MODE_SQUASH:885 case storagepb.MergeMode_MERGE_MODE_SQUASH:
748 return domain.MergeModeSquash886 return domain.MergeModeSquash
887+ case storagepb.MergeMode_MERGE_MODE_MERGE:
888+ return domain.MergeModeMerge
749 }889 }
750 return ""890 return ""
751 }891 }
@@ -773,7 +913,7 @@ func decodeSide(s storagepb.DiffSide) domain.DiffSide {
773 }913 }
774 914
775 func encodeSettings(s domain.RepoSettings) (*storagepb.RepoSettings, error) {915 func encodeSettings(s domain.RepoSettings) (*storagepb.RepoSettings, error) {
776- pb := &storagepb.RepoSettings{FailingChecksBlock: s.FailingChecksBlock}916+ pb := &storagepb.RepoSettings{FailingChecksBlock: s.FailingChecksBlock, MergeModesV2: true}
777 for _, m := range s.AllowedMergeModes {917 for _, m := range s.AllowedMergeModes {
778 mode, err := encodeMode(m)918 mode, err := encodeMode(m)
779 if err != nil || mode == storagepb.MergeMode_MERGE_MODE_UNSPECIFIED {919 if err != nil || mode == storagepb.MergeMode_MERGE_MODE_UNSPECIFIED {
@@ -810,5 +950,10 @@ func decodeSettings(pb *storagepb.RepoSettings) domain.RepoSettings {
810 if err != nil {950 if err != nil {
811 return domain.DefaultRepoSettings()951 return domain.DefaultRepoSettings()
812 }952 }
953+ // Settings stored before merge commits: exactly {fast-forward, squash} was
954+ // the old default, "everything allowed".
955+ if !pb.GetMergeModesV2() && slices.Equal(normalized, []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash}) {
956+ normalized = domain.MergeModes()
957+ }
813 return domain.RepoSettings{AllowedMergeModes: normalized, FailingChecksBlock: pb.GetFailingChecksBlock(), Issues: decodeIssuesMode(pb.GetIssues())}958 return domain.RepoSettings{AllowedMergeModes: normalized, FailingChecksBlock: pb.GetFailingChecksBlock(), Issues: decodeIssuesMode(pb.GetIssues())}
814 }959 }
M internal/adapters/kvstore/mergerequests_open_test.go +25 -0
@@ -284,3 +284,28 @@ func TestOpenIndexIsBackfilledOnce(t *testing.T) {
284284 assert.Equal(t, int64(5), five.Number)
285285 assert.Equal(t, []int64{1, 5}, bySource(t, m2, old, "feat/one"))
286286 }
287+
288+// Issues and merge requests share a sequence, but only merge requests belong
289+// to the indexes used by branch-move checks and mergeability rescans.
290+func TestMergeabilityIndexesExcludeIssueNumbers(t *testing.T) {
291+ ctx := context.Background()
292+ s, mrs, repo := repoWithMRs(t)
293+ issue, err := s.Issues().Create(ctx, newIssue(t, repo, "issue"))
294+ require.NoError(t, err)
295+ mr, err := mrs.Create(ctx, newMR(t, repo, "request"))
296+ require.NoError(t, err)
297+ require.Equal(t, issue.Number+1, mr.Number)
298+
299+ source, truncated, err := mrs.OpenBySource(ctx, repo, []string{"feat/x"}, 10)
300+ require.NoError(t, err)
301+ assert.False(t, truncated)
302+ assert.Equal(t, []int64{mr.Number}, source)
303+ target, truncated, err := mrs.OpenByTarget(ctx, repo, []string{"main"}, 10)
304+ require.NoError(t, err)
305+ assert.False(t, truncated)
306+ assert.Equal(t, []int64{mr.Number}, target)
307+ open, err := mrs.ListOpen(ctx, repo)
308+ require.NoError(t, err)
309+ require.Len(t, open, 1)
310+ assert.Equal(t, mr.Number, open[0].Number)
311+}
@@ -284,3 +284,28 @@ func TestOpenIndexIsBackfilledOnce(t *testing.T) {
284 assert.Equal(t, int64(5), five.Number)284 assert.Equal(t, int64(5), five.Number)
285 assert.Equal(t, []int64{1, 5}, bySource(t, m2, old, "feat/one"))285 assert.Equal(t, []int64{1, 5}, bySource(t, m2, old, "feat/one"))
286 }286 }
287+
288+// Issues and merge requests share a sequence, but only merge requests belong
289+// to the indexes used by branch-move checks and mergeability rescans.
290+func TestMergeabilityIndexesExcludeIssueNumbers(t *testing.T) {
291+ ctx := context.Background()
292+ s, mrs, repo := repoWithMRs(t)
293+ issue, err := s.Issues().Create(ctx, newIssue(t, repo, "issue"))
294+ require.NoError(t, err)
295+ mr, err := mrs.Create(ctx, newMR(t, repo, "request"))
296+ require.NoError(t, err)
297+ require.Equal(t, issue.Number+1, mr.Number)
298+
299+ source, truncated, err := mrs.OpenBySource(ctx, repo, []string{"feat/x"}, 10)
300+ require.NoError(t, err)
301+ assert.False(t, truncated)
302+ assert.Equal(t, []int64{mr.Number}, source)
303+ target, truncated, err := mrs.OpenByTarget(ctx, repo, []string{"main"}, 10)
304+ require.NoError(t, err)
305+ assert.False(t, truncated)
306+ assert.Equal(t, []int64{mr.Number}, target)
307+ open, err := mrs.ListOpen(ctx, repo)
308+ require.NoError(t, err)
309+ require.Len(t, open, 1)
310+ assert.Equal(t, mr.Number, open[0].Number)
311+}
M internal/adapters/kvstore/mergerequests_test.go +120 -0
@@ -574,3 +574,123 @@ func TestNoteFieldsRoundTrip(t *testing.T) {
574574 assert.Equal(t, c, got)
575575 }
576576 }
577+
578+// TestStoredOldDefaultModesGainMerge: settings written before merge commits
579+// (no merge_modes_v2) with exactly {fast-forward, squash} read with merge too;
580+// the same set written by this build is an explicit choice and stays.
581+func TestStoredOldDefaultModesGainMerge(t *testing.T) {
582+ ctx := context.Background()
583+ dir := t.TempDir()
584+ s, err := kvstore.Open(ctx, dir)
585+ require.NoError(t, err)
586+ require.NoError(t, s.Close(ctx))
587+
588+ put := func(db *kv.DB, repo string, modes ...storagepb.MergeMode) {
589+ val, err := proto.Marshal(&storagepb.Repo{Name: repo, Visibility: storagepb.Visibility_VISIBILITY_PUBLIC,
590+ Settings: &storagepb.RepoSettings{AllowedMergeModes: modes, FailingChecksBlock: true}})
591+ require.NoError(t, err)
592+ var b kv.Batch
593+ b.Put([]byte("repo/"+repo), val)
594+ _, err = db.Apply(ctx, &b)
595+ require.NoError(t, err)
596+ }
597+ db, err := kv.Open(ctx, dir, kv.Options{})
598+ require.NoError(t, err)
599+ put(db, "old", storagepb.MergeMode_MERGE_MODE_FAST_FORWARD, storagepb.MergeMode_MERGE_MODE_SQUASH)
600+ put(db, "single", storagepb.MergeMode_MERGE_MODE_SQUASH)
601+ require.NoError(t, db.Close(ctx))
602+
603+ s = openStore(t, dir)
604+ old, err := s.Get(ctx, name(t, "old"))
605+ require.NoError(t, err)
606+ assert.Equal(t, domain.MergeModes(), old.Settings.AllowedMergeModes)
607+ single, err := s.Get(ctx, name(t, "single"))
608+ require.NoError(t, err)
609+ assert.Equal(t, []domain.MergeMode{domain.MergeModeSquash}, single.Settings.AllowedMergeModes)
610+
611+ // An explicit {ff, squash} written now is kept.
612+ _, err = s.Mutate(ctx, old.Name, func(r *domain.Repo) (bool, error) {
613+ r.Settings.AllowedMergeModes = []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash}
614+ return true, nil
615+ })
616+ require.NoError(t, err)
617+ again, err := s.Get(ctx, name(t, "old"))
618+ require.NoError(t, err)
619+ assert.Equal(t, []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash}, again.Settings.AllowedMergeModes)
620+}
621+
622+func mergeability(mr domain.MergeRequest) domain.Mergeability {
623+ return domain.Mergeability{
624+ Repo: mr.Repo, Number: mr.Number, State: domain.MergeabilityConflict,
625+ TargetID: oid('a'), HeadID: oid('b'), Behind: 3, BehindCapped: true,
626+ ConflictPaths: []string{"a.txt", "b/c.txt"}, ConflictPathsTruncated: true, CheckedAt: created,
627+ }
628+}
629+
630+func TestPutMergeabilityCAS(t *testing.T) {
631+ ctx := context.Background()
632+ _, mrs, n := repoWithMRs(t)
633+ mr, err := mrs.Create(ctx, newMR(t, n, "t"))
634+ require.NoError(t, err)
635+
636+ _, err = mrs.GetMergeability(ctx, n, mr.Number)
637+ assert.ErrorIs(t, err, domain.ErrNotFound)
638+
639+ m := mergeability(mr)
640+ require.NoError(t, mrs.PutMergeability(ctx, m))
641+ assert.ErrorIs(t, mrs.PutMergeability(ctx, m), domain.ErrConflict, "version 0 needs it absent")
642+
643+ got, err := mrs.GetMergeability(ctx, n, mr.Number)
644+ require.NoError(t, err)
645+ assert.NotZero(t, got.Version)
646+ m.Version = got.Version
647+ assert.Equal(t, m, got)
648+
649+ next := got
650+ next.State = domain.MergeabilityUpToDate
651+ require.NoError(t, mrs.PutMergeability(ctx, next))
652+ assert.ErrorIs(t, mrs.PutMergeability(ctx, next), domain.ErrConflict, "stale version")
653+
654+ require.NoError(t, mrs.DeleteMergeability(ctx, n, mr.Number))
655+ require.NoError(t, mrs.DeleteMergeability(ctx, n, mr.Number), "absent is fine")
656+ _, err = mrs.GetMergeability(ctx, n, mr.Number)
657+ assert.ErrorIs(t, err, domain.ErrNotFound)
658+
659+ missing := mergeability(mr)
660+ missing.Number = 99
661+ assert.ErrorIs(t, mrs.PutMergeability(ctx, missing), domain.ErrNotFound)
662+
663+ checking := mergeability(mr)
664+ checking.State = domain.MergeabilityChecking
665+ assert.ErrorIs(t, mrs.PutMergeability(ctx, checking), domain.ErrInvalid)
666+}
667+
668+func TestMergeabilityRefusedAndDroppedWhenNotOpen(t *testing.T) {
669+ for _, state := range []domain.MRState{domain.MRStateMerged, domain.MRStateClosed} {
670+ ctx := context.Background()
671+ _, mrs, n := repoWithMRs(t)
672+ mr, err := mrs.Create(ctx, newMR(t, n, "t"))
673+ require.NoError(t, err)
674+ require.NoError(t, mrs.PutMergeability(ctx, mergeability(mr)))
675+
676+ cur, err := mrs.Get(ctx, n, mr.Number)
677+ require.NoError(t, err)
678+ cur.State = state
679+ require.NoError(t, mrs.Update(ctx, cur))
680+ _, err = mrs.GetMergeability(ctx, n, mr.Number)
681+ assert.ErrorIs(t, err, domain.ErrNotFound, state)
682+
683+ assert.ErrorIs(t, mrs.PutMergeability(ctx, mergeability(mr)), domain.ErrNotFound, "a late worker never resurrects it: %s", state)
684+ }
685+}
686+
687+func TestDeleteWipesMergeability(t *testing.T) {
688+ ctx := context.Background()
689+ s, mrs, n := repoWithMRs(t)
690+ mr, err := mrs.Create(ctx, newMR(t, n, "t"))
691+ require.NoError(t, err)
692+ require.NoError(t, mrs.PutMergeability(ctx, mergeability(mr)))
693+ require.NoError(t, s.Delete(ctx, n))
694+ _, err = mrs.GetMergeability(ctx, n, mr.Number)
695+ assert.ErrorIs(t, err, domain.ErrNotFound)
696+}
@@ -574,3 +574,123 @@ func TestNoteFieldsRoundTrip(t *testing.T) {
574 assert.Equal(t, c, got)574 assert.Equal(t, c, got)
575 }575 }
576 }576 }
577+
578+// TestStoredOldDefaultModesGainMerge: settings written before merge commits
579+// (no merge_modes_v2) with exactly {fast-forward, squash} read with merge too;
580+// the same set written by this build is an explicit choice and stays.
581+func TestStoredOldDefaultModesGainMerge(t *testing.T) {
582+ ctx := context.Background()
583+ dir := t.TempDir()
584+ s, err := kvstore.Open(ctx, dir)
585+ require.NoError(t, err)
586+ require.NoError(t, s.Close(ctx))
587+
588+ put := func(db *kv.DB, repo string, modes ...storagepb.MergeMode) {
589+ val, err := proto.Marshal(&storagepb.Repo{Name: repo, Visibility: storagepb.Visibility_VISIBILITY_PUBLIC,
590+ Settings: &storagepb.RepoSettings{AllowedMergeModes: modes, FailingChecksBlock: true}})
591+ require.NoError(t, err)
592+ var b kv.Batch
593+ b.Put([]byte("repo/"+repo), val)
594+ _, err = db.Apply(ctx, &b)
595+ require.NoError(t, err)
596+ }
597+ db, err := kv.Open(ctx, dir, kv.Options{})
598+ require.NoError(t, err)
599+ put(db, "old", storagepb.MergeMode_MERGE_MODE_FAST_FORWARD, storagepb.MergeMode_MERGE_MODE_SQUASH)
600+ put(db, "single", storagepb.MergeMode_MERGE_MODE_SQUASH)
601+ require.NoError(t, db.Close(ctx))
602+
603+ s = openStore(t, dir)
604+ old, err := s.Get(ctx, name(t, "old"))
605+ require.NoError(t, err)
606+ assert.Equal(t, domain.MergeModes(), old.Settings.AllowedMergeModes)
607+ single, err := s.Get(ctx, name(t, "single"))
608+ require.NoError(t, err)
609+ assert.Equal(t, []domain.MergeMode{domain.MergeModeSquash}, single.Settings.AllowedMergeModes)
610+
611+ // An explicit {ff, squash} written now is kept.
612+ _, err = s.Mutate(ctx, old.Name, func(r *domain.Repo) (bool, error) {
613+ r.Settings.AllowedMergeModes = []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash}
614+ return true, nil
615+ })
616+ require.NoError(t, err)
617+ again, err := s.Get(ctx, name(t, "old"))
618+ require.NoError(t, err)
619+ assert.Equal(t, []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash}, again.Settings.AllowedMergeModes)
620+}
621+
622+func mergeability(mr domain.MergeRequest) domain.Mergeability {
623+ return domain.Mergeability{
624+ Repo: mr.Repo, Number: mr.Number, State: domain.MergeabilityConflict,
625+ TargetID: oid('a'), HeadID: oid('b'), Behind: 3, BehindCapped: true,
626+ ConflictPaths: []string{"a.txt", "b/c.txt"}, ConflictPathsTruncated: true, CheckedAt: created,
627+ }
628+}
629+
630+func TestPutMergeabilityCAS(t *testing.T) {
631+ ctx := context.Background()
632+ _, mrs, n := repoWithMRs(t)
633+ mr, err := mrs.Create(ctx, newMR(t, n, "t"))
634+ require.NoError(t, err)
635+
636+ _, err = mrs.GetMergeability(ctx, n, mr.Number)
637+ assert.ErrorIs(t, err, domain.ErrNotFound)
638+
639+ m := mergeability(mr)
640+ require.NoError(t, mrs.PutMergeability(ctx, m))
641+ assert.ErrorIs(t, mrs.PutMergeability(ctx, m), domain.ErrConflict, "version 0 needs it absent")
642+
643+ got, err := mrs.GetMergeability(ctx, n, mr.Number)
644+ require.NoError(t, err)
645+ assert.NotZero(t, got.Version)
646+ m.Version = got.Version
647+ assert.Equal(t, m, got)
648+
649+ next := got
650+ next.State = domain.MergeabilityUpToDate
651+ require.NoError(t, mrs.PutMergeability(ctx, next))
652+ assert.ErrorIs(t, mrs.PutMergeability(ctx, next), domain.ErrConflict, "stale version")
653+
654+ require.NoError(t, mrs.DeleteMergeability(ctx, n, mr.Number))
655+ require.NoError(t, mrs.DeleteMergeability(ctx, n, mr.Number), "absent is fine")
656+ _, err = mrs.GetMergeability(ctx, n, mr.Number)
657+ assert.ErrorIs(t, err, domain.ErrNotFound)
658+
659+ missing := mergeability(mr)
660+ missing.Number = 99
661+ assert.ErrorIs(t, mrs.PutMergeability(ctx, missing), domain.ErrNotFound)
662+
663+ checking := mergeability(mr)
664+ checking.State = domain.MergeabilityChecking
665+ assert.ErrorIs(t, mrs.PutMergeability(ctx, checking), domain.ErrInvalid)
666+}
667+
668+func TestMergeabilityRefusedAndDroppedWhenNotOpen(t *testing.T) {
669+ for _, state := range []domain.MRState{domain.MRStateMerged, domain.MRStateClosed} {
670+ ctx := context.Background()
671+ _, mrs, n := repoWithMRs(t)
672+ mr, err := mrs.Create(ctx, newMR(t, n, "t"))
673+ require.NoError(t, err)
674+ require.NoError(t, mrs.PutMergeability(ctx, mergeability(mr)))
675+
676+ cur, err := mrs.Get(ctx, n, mr.Number)
677+ require.NoError(t, err)
678+ cur.State = state
679+ require.NoError(t, mrs.Update(ctx, cur))
680+ _, err = mrs.GetMergeability(ctx, n, mr.Number)
681+ assert.ErrorIs(t, err, domain.ErrNotFound, state)
682+
683+ assert.ErrorIs(t, mrs.PutMergeability(ctx, mergeability(mr)), domain.ErrNotFound, "a late worker never resurrects it: %s", state)
684+ }
685+}
686+
687+func TestDeleteWipesMergeability(t *testing.T) {
688+ ctx := context.Background()
689+ s, mrs, n := repoWithMRs(t)
690+ mr, err := mrs.Create(ctx, newMR(t, n, "t"))
691+ require.NoError(t, err)
692+ require.NoError(t, mrs.PutMergeability(ctx, mergeability(mr)))
693+ require.NoError(t, s.Delete(ctx, n))
694+ _, err = mrs.GetMergeability(ctx, n, mr.Number)
695+ assert.ErrorIs(t, err, domain.ErrNotFound)
696+}
M internal/adapters/rpc/api.go +1 -1
@@ -35,7 +35,7 @@ const RunnerMaxRequestBytes = MaxRequestBytes + 64<<10
3535 // always implements, as GetVersion lists them. FeaturePasskeys comes on top
3636 // when Config.Passkeys is set, and FeatureCISecrets when the settings services
3737 // are, and FeatureCommitSignatures when Config.TrustedKeys is set.
38-var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.cache", "ci.workflows"}
38+var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.cache", "ci.workflows"}
3939
4040 // FeaturePasskeys is listed by GetVersion when passkeys are available.
4141 // FeatureCIArtifacts (artifact.go) when the artifact services are served.
@@ -35,7 +35,7 @@ const RunnerMaxRequestBytes = MaxRequestBytes + 64<<10
35 // always implements, as GetVersion lists them. FeaturePasskeys comes on top35 // always implements, as GetVersion lists them. FeaturePasskeys comes on top
36 // when Config.Passkeys is set, and FeatureCISecrets when the settings services36 // when Config.Passkeys is set, and FeatureCISecrets when the settings services
37 // are, and FeatureCommitSignatures when Config.TrustedKeys is set.37 // are, and FeatureCommitSignatures when Config.TrustedKeys is set.
38-var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.cache", "ci.workflows"}38+var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.cache", "ci.workflows"}
39 39
40 // FeaturePasskeys is listed by GetVersion when passkeys are available.40 // FeaturePasskeys is listed by GetVersion when passkeys are available.
41 // FeatureCIArtifacts (artifact.go) when the artifact services are served.41 // FeatureCIArtifacts (artifact.go) when the artifact services are served.
M internal/adapters/rpc/convert.go +7 -0
@@ -88,6 +88,8 @@ func mergeModeToPB(m domain.MergeMode) pb.MergeMode {
8888 return pb.MergeMode_MERGE_MODE_FAST_FORWARD
8989 case domain.MergeModeSquash:
9090 return pb.MergeMode_MERGE_MODE_SQUASH
91+ case domain.MergeModeMerge:
92+ return pb.MergeMode_MERGE_MODE_MERGE_COMMIT
9193 }
9294 return pb.MergeMode_MERGE_MODE_UNSPECIFIED
9395 }
@@ -102,6 +104,8 @@ func mergeModeFromPB(m pb.MergeMode) (domain.MergeMode, error) {
102104 return domain.MergeModeFastForward, nil
103105 case pb.MergeMode_MERGE_MODE_SQUASH:
104106 return domain.MergeModeSquash, nil
107+ case pb.MergeMode_MERGE_MODE_MERGE_COMMIT:
108+ return domain.MergeModeMerge, nil
105109 }
106110 return "", fmt.Errorf("%w: merge mode %v", domain.ErrInvalid, m)
107111 }
@@ -497,6 +501,9 @@ func watchEventToPB(we ports.WatchEvent) *pb.Event {
497501 case domain.CommentResolved:
498502 out.Time = timestamp(ev.At)
499503 out.Kind = &pb.Event_CommentResolved{CommentResolved: &pb.CommentResolved{Repo: ev.Repo.String(), Number: ev.Number, Comment: commentToPB(ev.Comment)}}
504+ case domain.MergeRequestMergeability:
505+ out.Time = timestamp(ev.At)
506+ out.Kind = &pb.Event_MergeRequestMergeability{MergeRequestMergeability: &pb.MergeRequestMergeability{Repo: ev.Repo.String(), Number: ev.Number, Mergeability: mergeabilityToPB(ev.Mergeability)}}
500507 case domain.RunUpdated:
501508 out.Time = timestamp(ev.At)
502509 out.Kind = runEventToPB(ev)
@@ -88,6 +88,8 @@ func mergeModeToPB(m domain.MergeMode) pb.MergeMode {
88 return pb.MergeMode_MERGE_MODE_FAST_FORWARD88 return pb.MergeMode_MERGE_MODE_FAST_FORWARD
89 case domain.MergeModeSquash:89 case domain.MergeModeSquash:
90 return pb.MergeMode_MERGE_MODE_SQUASH90 return pb.MergeMode_MERGE_MODE_SQUASH
91+ case domain.MergeModeMerge:
92+ return pb.MergeMode_MERGE_MODE_MERGE_COMMIT
91 }93 }
92 return pb.MergeMode_MERGE_MODE_UNSPECIFIED94 return pb.MergeMode_MERGE_MODE_UNSPECIFIED
93 }95 }
@@ -102,6 +104,8 @@ func mergeModeFromPB(m pb.MergeMode) (domain.MergeMode, error) {
102 return domain.MergeModeFastForward, nil104 return domain.MergeModeFastForward, nil
103 case pb.MergeMode_MERGE_MODE_SQUASH:105 case pb.MergeMode_MERGE_MODE_SQUASH:
104 return domain.MergeModeSquash, nil106 return domain.MergeModeSquash, nil
107+ case pb.MergeMode_MERGE_MODE_MERGE_COMMIT:
108+ return domain.MergeModeMerge, nil
105 }109 }
106 return "", fmt.Errorf("%w: merge mode %v", domain.ErrInvalid, m)110 return "", fmt.Errorf("%w: merge mode %v", domain.ErrInvalid, m)
107 }111 }
@@ -497,6 +501,9 @@ func watchEventToPB(we ports.WatchEvent) *pb.Event {
497 case domain.CommentResolved:501 case domain.CommentResolved:
498 out.Time = timestamp(ev.At)502 out.Time = timestamp(ev.At)
499 out.Kind = &pb.Event_CommentResolved{CommentResolved: &pb.CommentResolved{Repo: ev.Repo.String(), Number: ev.Number, Comment: commentToPB(ev.Comment)}}503 out.Kind = &pb.Event_CommentResolved{CommentResolved: &pb.CommentResolved{Repo: ev.Repo.String(), Number: ev.Number, Comment: commentToPB(ev.Comment)}}
504+ case domain.MergeRequestMergeability:
505+ out.Time = timestamp(ev.At)
506+ out.Kind = &pb.Event_MergeRequestMergeability{MergeRequestMergeability: &pb.MergeRequestMergeability{Repo: ev.Repo.String(), Number: ev.Number, Mergeability: mergeabilityToPB(ev.Mergeability)}}
500 case domain.RunUpdated:507 case domain.RunUpdated:
501 out.Time = timestamp(ev.At)508 out.Time = timestamp(ev.At)
502 out.Kind = runEventToPB(ev)509 out.Kind = runEventToPB(ev)
M internal/adapters/rpc/convert_test.go +23 -1
@@ -213,7 +213,7 @@ func TestRepoToPBAlwaysHasSettings(t *testing.T) {
213213 t.Parallel()
214214 // A repository value without settings (never stored with them) reads as the defaults.
215215 r := repoToPB(ports.RepoView{Repo: domain.Repo{Name: mustName(t, "x")}})
216- assert.Equal(t, []pb.MergeMode{pb.MergeMode_MERGE_MODE_FAST_FORWARD, pb.MergeMode_MERGE_MODE_SQUASH}, r.GetSettings().GetAllowedMergeModes())
216+ assert.Equal(t, []pb.MergeMode{pb.MergeMode_MERGE_MODE_FAST_FORWARD, pb.MergeMode_MERGE_MODE_SQUASH, pb.MergeMode_MERGE_MODE_MERGE_COMMIT}, r.GetSettings().GetAllowedMergeModes())
217217 assert.True(t, r.GetSettings().GetFailingChecksBlock())
218218
219219 custom := domain.RepoSettings{AllowedMergeModes: []domain.MergeMode{domain.MergeModeSquash}}
@@ -376,3 +376,25 @@ func TestIssueStateToPBNeverPending(t *testing.T) {
376376 assert.Equal(t, pb.IssueState_ISSUE_STATE_UNSPECIFIED, issueStateToPB(domain.IssueState("pending")))
377377 assert.Equal(t, pb.IssueState_ISSUE_STATE_OPEN, issueStateToPB(domain.IssueStateOpen))
378378 }
379+
380+func TestMergeRequestToPBMergeabilityOnlyWhileOpen(t *testing.T) {
381+ t.Parallel()
382+ repo := mustName(t, "proj")
383+ for state, want := range map[domain.MRState]bool{domain.MRStateOpen: true, domain.MRStateMerged: false, domain.MRStateClosed: false} {
384+ mr := domain.MergeRequest{Repo: repo, Number: 4, State: state}
385+ got := mergeRequestToPB(mr).GetMergeability()
386+ if !want {
387+ assert.Nil(t, got, state)
388+ continue
389+ }
390+ require.NotNil(t, got)
391+ assert.Equal(t, pb.MergeabilityState_MERGEABILITY_STATE_CHECKING, got.GetState())
392+ ev := watchEventToPB(ports.WatchEvent{ID: "e", Event: domain.MergeRequestOpened{MergeRequest: mr}})
393+ assert.Equal(t, pb.MergeabilityState_MERGEABILITY_STATE_CHECKING, ev.GetMergeRequestOpened().GetMergeRequest().GetMergeability().GetState())
394+ }
395+ for _, ev := range []domain.Event{domain.MergeRequestMerged{MergeRequest: domain.MergeRequest{Repo: repo, State: domain.MRStateMerged}}, domain.MergeRequestClosed{MergeRequest: domain.MergeRequest{Repo: repo, State: domain.MRStateClosed}}} {
396+ got := watchEventToPB(ports.WatchEvent{ID: "e", Event: ev})
397+ assert.Nil(t, got.GetMergeRequestMerged().GetMergeRequest().GetMergeability())
398+ assert.Nil(t, got.GetMergeRequestClosed().GetMergeRequest().GetMergeability())
399+ }
400+}
@@ -213,7 +213,7 @@ func TestRepoToPBAlwaysHasSettings(t *testing.T) {
213 t.Parallel()213 t.Parallel()
214 // A repository value without settings (never stored with them) reads as the defaults.214 // A repository value without settings (never stored with them) reads as the defaults.
215 r := repoToPB(ports.RepoView{Repo: domain.Repo{Name: mustName(t, "x")}})215 r := repoToPB(ports.RepoView{Repo: domain.Repo{Name: mustName(t, "x")}})
216- assert.Equal(t, []pb.MergeMode{pb.MergeMode_MERGE_MODE_FAST_FORWARD, pb.MergeMode_MERGE_MODE_SQUASH}, r.GetSettings().GetAllowedMergeModes())216+ assert.Equal(t, []pb.MergeMode{pb.MergeMode_MERGE_MODE_FAST_FORWARD, pb.MergeMode_MERGE_MODE_SQUASH, pb.MergeMode_MERGE_MODE_MERGE_COMMIT}, r.GetSettings().GetAllowedMergeModes())
217 assert.True(t, r.GetSettings().GetFailingChecksBlock())217 assert.True(t, r.GetSettings().GetFailingChecksBlock())
218 218
219 custom := domain.RepoSettings{AllowedMergeModes: []domain.MergeMode{domain.MergeModeSquash}}219 custom := domain.RepoSettings{AllowedMergeModes: []domain.MergeMode{domain.MergeModeSquash}}
@@ -376,3 +376,25 @@ func TestIssueStateToPBNeverPending(t *testing.T) {
376 assert.Equal(t, pb.IssueState_ISSUE_STATE_UNSPECIFIED, issueStateToPB(domain.IssueState("pending")))376 assert.Equal(t, pb.IssueState_ISSUE_STATE_UNSPECIFIED, issueStateToPB(domain.IssueState("pending")))
377 assert.Equal(t, pb.IssueState_ISSUE_STATE_OPEN, issueStateToPB(domain.IssueStateOpen))377 assert.Equal(t, pb.IssueState_ISSUE_STATE_OPEN, issueStateToPB(domain.IssueStateOpen))
378 }378 }
379+
380+func TestMergeRequestToPBMergeabilityOnlyWhileOpen(t *testing.T) {
381+ t.Parallel()
382+ repo := mustName(t, "proj")
383+ for state, want := range map[domain.MRState]bool{domain.MRStateOpen: true, domain.MRStateMerged: false, domain.MRStateClosed: false} {
384+ mr := domain.MergeRequest{Repo: repo, Number: 4, State: state}
385+ got := mergeRequestToPB(mr).GetMergeability()
386+ if !want {
387+ assert.Nil(t, got, state)
388+ continue
389+ }
390+ require.NotNil(t, got)
391+ assert.Equal(t, pb.MergeabilityState_MERGEABILITY_STATE_CHECKING, got.GetState())
392+ ev := watchEventToPB(ports.WatchEvent{ID: "e", Event: domain.MergeRequestOpened{MergeRequest: mr}})
393+ assert.Equal(t, pb.MergeabilityState_MERGEABILITY_STATE_CHECKING, ev.GetMergeRequestOpened().GetMergeRequest().GetMergeability().GetState())
394+ }
395+ for _, ev := range []domain.Event{domain.MergeRequestMerged{MergeRequest: domain.MergeRequest{Repo: repo, State: domain.MRStateMerged}}, domain.MergeRequestClosed{MergeRequest: domain.MergeRequest{Repo: repo, State: domain.MRStateClosed}}} {
396+ got := watchEventToPB(ports.WatchEvent{ID: "e", Event: ev})
397+ assert.Nil(t, got.GetMergeRequestMerged().GetMergeRequest().GetMergeability())
398+ assert.Nil(t, got.GetMergeRequestClosed().GetMergeRequest().GetMergeability())
399+ }
400+}
M internal/adapters/rpc/mergerequest.go +47 -3
@@ -115,8 +115,11 @@ func diffSideFromPB(s pb.DiffSide) (domain.DiffSide, error) {
115115 return "", fmt.Errorf("%w: diff side %v", domain.ErrInvalid, s)
116116 }
117117
118+// mergeRequestToPB maps a request. An open one carries the CHECKING
119+// mergeability: the server has no result in hand here; Get and List override
120+// it with the computed one.
118121 func mergeRequestToPB(mr domain.MergeRequest) *pb.MergeRequest {
119- return &pb.MergeRequest{
122+ out := &pb.MergeRequest{
120123 Repo: mr.Repo.String(),
121124 Number: mr.Number,
122125 Title: mr.Title.String(),
@@ -131,6 +134,47 @@ func mergeRequestToPB(mr domain.MergeRequest) *pb.MergeRequest {
131134 MergedCommitId: mr.MergedCommitID.String(),
132135 MergeMode: mergeModeToPB(mr.MergeMode),
133136 }
137+ if mr.IsOpen() {
138+ out.Mergeability = &pb.Mergeability{State: pb.MergeabilityState_MERGEABILITY_STATE_CHECKING}
139+ }
140+ return out
141+}
142+
143+// mergeRequestWithMergeabilityToPB is mergeRequestToPB with the mergeability of
144+// an open request; m is ignored for a request that is not open.
145+func mergeRequestWithMergeabilityToPB(mr domain.MergeRequest, m domain.Mergeability) *pb.MergeRequest {
146+ out := mergeRequestToPB(mr)
147+ if mr.IsOpen() && m.State != "" {
148+ out.Mergeability = mergeabilityToPB(m)
149+ }
150+ return out
151+}
152+
153+func mergeabilityStateToPB(s domain.MergeabilityState) pb.MergeabilityState {
154+ switch s {
155+ case domain.MergeabilityChecking:
156+ return pb.MergeabilityState_MERGEABILITY_STATE_CHECKING
157+ case domain.MergeabilityUpToDate:
158+ return pb.MergeabilityState_MERGEABILITY_STATE_UP_TO_DATE
159+ case domain.MergeabilityBehind:
160+ return pb.MergeabilityState_MERGEABILITY_STATE_BEHIND
161+ case domain.MergeabilityConflict:
162+ return pb.MergeabilityState_MERGEABILITY_STATE_CONFLICT
163+ }
164+ return pb.MergeabilityState_MERGEABILITY_STATE_UNSPECIFIED
165+}
166+
167+func mergeabilityToPB(m domain.Mergeability) *pb.Mergeability {
168+ return &pb.Mergeability{
169+ State: mergeabilityStateToPB(m.State),
170+ TargetId: m.TargetID.String(),
171+ HeadId: m.HeadID.String(),
172+ Behind: int32(m.Behind), //nolint:gosec // capped at domain.MaxBehindCount
173+ BehindCapped: m.BehindCapped,
174+ ConflictPaths: m.ConflictPaths,
175+ ConflictPathsTruncated: m.ConflictPathsTruncated,
176+ CheckedAt: timestamp(m.CheckedAt),
177+ }
134178 }
135179
136180 func commentToPB(c domain.Comment) *pb.Comment {
@@ -214,7 +258,7 @@ func (s *mergeRequestService) ListMergeRequests(ctx context.Context, req *connec
214258 }
215259 out := &pb.ListMergeRequestsResponse{NextPageToken: page.NextPageToken, MergeRequests: make([]*pb.MergeRequest, 0, len(page.MergeRequests))}
216260 for _, mr := range page.MergeRequests {
217- out.MergeRequests = append(out.MergeRequests, mergeRequestToPB(mr))
261+ out.MergeRequests = append(out.MergeRequests, mergeRequestWithMergeabilityToPB(mr, page.Mergeability[mr.Number]))
218262 }
219263 return connect.NewResponse(out), nil
220264 }
@@ -229,7 +273,7 @@ func (s *mergeRequestService) GetMergeRequest(ctx context.Context, req *connect.
229273 return nil, connectError(ctx, err)
230274 }
231275 out := &pb.GetMergeRequestResponse{
232- MergeRequest: mergeRequestToPB(d.MergeRequest),
276+ MergeRequest: mergeRequestWithMergeabilityToPB(d.MergeRequest, d.Mergeability),
233277 CommitsTruncated: d.CommitsTruncated,
234278 DiffBaseId: d.DiffBaseID.String(),
235279 Stats: diffStatsToPB(d.Stats),
@@ -115,8 +115,11 @@ func diffSideFromPB(s pb.DiffSide) (domain.DiffSide, error) {
115 return "", fmt.Errorf("%w: diff side %v", domain.ErrInvalid, s)115 return "", fmt.Errorf("%w: diff side %v", domain.ErrInvalid, s)
116 }116 }
117 117
118+// mergeRequestToPB maps a request. An open one carries the CHECKING
119+// mergeability: the server has no result in hand here; Get and List override
120+// it with the computed one.
118 func mergeRequestToPB(mr domain.MergeRequest) *pb.MergeRequest {121 func mergeRequestToPB(mr domain.MergeRequest) *pb.MergeRequest {
119- return &pb.MergeRequest{122+ out := &pb.MergeRequest{
120 Repo: mr.Repo.String(),123 Repo: mr.Repo.String(),
121 Number: mr.Number,124 Number: mr.Number,
122 Title: mr.Title.String(),125 Title: mr.Title.String(),
@@ -131,6 +134,47 @@ func mergeRequestToPB(mr domain.MergeRequest) *pb.MergeRequest {
131 MergedCommitId: mr.MergedCommitID.String(),134 MergedCommitId: mr.MergedCommitID.String(),
132 MergeMode: mergeModeToPB(mr.MergeMode),135 MergeMode: mergeModeToPB(mr.MergeMode),
133 }136 }
137+ if mr.IsOpen() {
138+ out.Mergeability = &pb.Mergeability{State: pb.MergeabilityState_MERGEABILITY_STATE_CHECKING}
139+ }
140+ return out
141+}
142+
143+// mergeRequestWithMergeabilityToPB is mergeRequestToPB with the mergeability of
144+// an open request; m is ignored for a request that is not open.
145+func mergeRequestWithMergeabilityToPB(mr domain.MergeRequest, m domain.Mergeability) *pb.MergeRequest {
146+ out := mergeRequestToPB(mr)
147+ if mr.IsOpen() && m.State != "" {
148+ out.Mergeability = mergeabilityToPB(m)
149+ }
150+ return out
151+}
152+
153+func mergeabilityStateToPB(s domain.MergeabilityState) pb.MergeabilityState {
154+ switch s {
155+ case domain.MergeabilityChecking:
156+ return pb.MergeabilityState_MERGEABILITY_STATE_CHECKING
157+ case domain.MergeabilityUpToDate:
158+ return pb.MergeabilityState_MERGEABILITY_STATE_UP_TO_DATE
159+ case domain.MergeabilityBehind:
160+ return pb.MergeabilityState_MERGEABILITY_STATE_BEHIND
161+ case domain.MergeabilityConflict:
162+ return pb.MergeabilityState_MERGEABILITY_STATE_CONFLICT
163+ }
164+ return pb.MergeabilityState_MERGEABILITY_STATE_UNSPECIFIED
165+}
166+
167+func mergeabilityToPB(m domain.Mergeability) *pb.Mergeability {
168+ return &pb.Mergeability{
169+ State: mergeabilityStateToPB(m.State),
170+ TargetId: m.TargetID.String(),
171+ HeadId: m.HeadID.String(),
172+ Behind: int32(m.Behind), //nolint:gosec // capped at domain.MaxBehindCount
173+ BehindCapped: m.BehindCapped,
174+ ConflictPaths: m.ConflictPaths,
175+ ConflictPathsTruncated: m.ConflictPathsTruncated,
176+ CheckedAt: timestamp(m.CheckedAt),
177+ }
134 }178 }
135 179
136 func commentToPB(c domain.Comment) *pb.Comment {180 func commentToPB(c domain.Comment) *pb.Comment {
@@ -214,7 +258,7 @@ func (s *mergeRequestService) ListMergeRequests(ctx context.Context, req *connec
214 }258 }
215 out := &pb.ListMergeRequestsResponse{NextPageToken: page.NextPageToken, MergeRequests: make([]*pb.MergeRequest, 0, len(page.MergeRequests))}259 out := &pb.ListMergeRequestsResponse{NextPageToken: page.NextPageToken, MergeRequests: make([]*pb.MergeRequest, 0, len(page.MergeRequests))}
216 for _, mr := range page.MergeRequests {260 for _, mr := range page.MergeRequests {
217- out.MergeRequests = append(out.MergeRequests, mergeRequestToPB(mr))261+ out.MergeRequests = append(out.MergeRequests, mergeRequestWithMergeabilityToPB(mr, page.Mergeability[mr.Number]))
218 }262 }
219 return connect.NewResponse(out), nil263 return connect.NewResponse(out), nil
220 }264 }
@@ -229,7 +273,7 @@ func (s *mergeRequestService) GetMergeRequest(ctx context.Context, req *connect.
229 return nil, connectError(ctx, err)273 return nil, connectError(ctx, err)
230 }274 }
231 out := &pb.GetMergeRequestResponse{275 out := &pb.GetMergeRequestResponse{
232- MergeRequest: mergeRequestToPB(d.MergeRequest),276+ MergeRequest: mergeRequestWithMergeabilityToPB(d.MergeRequest, d.Mergeability),
233 CommitsTruncated: d.CommitsTruncated,277 CommitsTruncated: d.CommitsTruncated,
234 DiffBaseId: d.DiffBaseID.String(),278 DiffBaseId: d.DiffBaseID.String(),
235 Stats: diffStatsToPB(d.Stats),279 Stats: diffStatsToPB(d.Stats),
M internal/adapters/rpc/mergerequest_test.go +1 -0
@@ -51,6 +51,7 @@ func TestMergeRequestServiceCreate(t *testing.T) {
5151 require.NoError(t, err)
5252 assert.EqualValues(t, 7, resp.Msg.GetMergeRequest().GetNumber())
5353 assert.Equal(t, pb.MergeRequestState_MERGE_REQUEST_STATE_OPEN, resp.Msg.GetMergeRequest().GetState())
54+ assert.Equal(t, pb.MergeabilityState_MERGEABILITY_STATE_CHECKING, resp.Msg.GetMergeRequest().GetMergeability().GetState())
5455 })
5556 }
5657 }
@@ -51,6 +51,7 @@ func TestMergeRequestServiceCreate(t *testing.T) {
51 require.NoError(t, err)51 require.NoError(t, err)
52 assert.EqualValues(t, 7, resp.Msg.GetMergeRequest().GetNumber())52 assert.EqualValues(t, 7, resp.Msg.GetMergeRequest().GetNumber())
53 assert.Equal(t, pb.MergeRequestState_MERGE_REQUEST_STATE_OPEN, resp.Msg.GetMergeRequest().GetState())53 assert.Equal(t, pb.MergeRequestState_MERGE_REQUEST_STATE_OPEN, resp.Msg.GetMergeRequest().GetState())
54+ assert.Equal(t, pb.MergeabilityState_MERGEABILITY_STATE_CHECKING, resp.Msg.GetMergeRequest().GetMergeability().GetState())
54 })55 })
55 }56 }
56 }57 }
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
@@ -56,6 +56,10 @@ import (
5656 // refs-list data: <tbody id="ref-rows">…</tbody>, and again with
5757 // <dl id="refs-kv">…</dl>, the facts of the rail
5858 // commits-head data: <div id="commits-head">N new commits — reload</div>
59+// mr-mergeability data: <div id="mr-mergeability">…</div>, on the merge request pages:
60+// the badge of the merge box (a slot like mr-notice), when the
61+// mergeability of the request shown settles or a push moves its
62+// source or target
5963 // mr-notice data: <div id="mr-notice">…</div>, on the merge request and issue pages:
6064 // something changed, with the link that reloads the page
6165 // ci-run data: <tr id="run-<id>">…</tr> (the list of runs),
@@ -254,7 +258,7 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) {
254258 return
255259 }
256260
257- st := &liveStream{s: s, repo: repo, view: q.Get("view"), ref: q.Get("ref"), head: q.Get("head"), n: q.Get("n"), job: q.Get("job"), sha: sha}
261+ st := &liveStream{s: s, repo: repo, view: q.Get("view"), ref: q.Get("ref"), head: q.Get("head"), n: q.Get("n"), target: q.Get("target"), job: q.Get("job"), sha: sha}
258262 if topic == ownerTopic {
259263 st.filter = q.Get("repo")
260264 }
@@ -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
@@ -56,6 +56,10 @@ import (
56 // refs-list data: <tbody id="ref-rows">…</tbody>, and again with56 // refs-list data: <tbody id="ref-rows">…</tbody>, and again with
57 // <dl id="refs-kv">…</dl>, the facts of the rail57 // <dl id="refs-kv">…</dl>, the facts of the rail
58 // commits-head data: <div id="commits-head">N new commits — reload</div>58 // commits-head data: <div id="commits-head">N new commits — reload</div>
59+// mr-mergeability data: <div id="mr-mergeability">…</div>, on the merge request pages:
60+// the badge of the merge box (a slot like mr-notice), when the
61+// mergeability of the request shown settles or a push moves its
62+// source or target
59 // mr-notice data: <div id="mr-notice">…</div>, on the merge request and issue pages:63 // mr-notice data: <div id="mr-notice">…</div>, on the merge request and issue pages:
60 // something changed, with the link that reloads the page64 // something changed, with the link that reloads the page
61 // ci-run data: <tr id="run-<id>">…</tr> (the list of runs),65 // ci-run data: <tr id="run-<id>">…</tr> (the list of runs),
@@ -254,7 +258,7 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) {
254 return258 return
255 }259 }
256 260
257- st := &liveStream{s: s, repo: repo, view: q.Get("view"), ref: q.Get("ref"), head: q.Get("head"), n: q.Get("n"), job: q.Get("job"), sha: sha}261+ st := &liveStream{s: s, repo: repo, view: q.Get("view"), ref: q.Get("ref"), head: q.Get("head"), n: q.Get("n"), target: q.Get("target"), job: q.Get("job"), sha: sha}
258 if topic == ownerTopic {262 if topic == ownerTopic {
259 st.filter = q.Get("repo")263 st.filter = q.Get("repo")
260 }264 }
M internal/adapters/web/fragments.go +58 -8
@@ -28,6 +28,7 @@ type liveStream struct {
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
@@ -188,6 +189,11 @@ func (l *liveStream) repoPage(ctx context.Context, we ports.WatchEvent) ([]fragm
188189 return l.mrNotice(ctx, ev.MergeRequest.Number, "this merge request was closed — reload")
189190 case domain.MergeRequestReady:
190191 return l.mrNotice(ctx, ev.MergeRequest.Number, "this merge request is ready for review — reload")
192+ case domain.MergeRequestMergeability:
193+ if l.view != "mr" || strconv.FormatInt(ev.Number, 10) != l.n {
194+ return nil, nil
195+ }
196+ return l.mrMergeability(ctx)
191197 case domain.CommentEdited:
192198 return l.mrNotice(ctx, ev.Number, "a note was edited — reload")
193199 case domain.CommentDeleted:
@@ -454,21 +460,65 @@ func (l *liveStream) issueNotice(ctx context.Context, number int64, text string)
454460 return nil, nil
455461 }
456462
457-// mrPushed announces a push to the source branch of the merge request shown.
463+// mrPushed announces a push to the source branch of the merge request shown,
464+// and has the badge follow a push to its source or its target.
458465 func (l *liveStream) mrPushed(ctx context.Context, p domain.PushEvent) ([]fragment, error) {
459- if l.ref == "" {
466+ n, err := strconv.ParseInt(l.n, 10, 64)
467+ if err != nil {
460468 return nil, nil
461469 }
470+ var out []fragment
471+ moved := false
462472 for _, u := range p.Updates {
463- if u.Ref == "refs/heads/"+l.ref {
464- n, err := strconv.ParseInt(l.n, 10, 64)
473+ switch {
474+ case l.ref != "" && u.Ref == "refs/heads/"+l.ref:
475+ notice, err := l.notice(ctx, "the branch changed — reload", mrHref(l.repo, n))
465476 if err != nil {
466- return nil, nil
477+ return nil, err
467478 }
468- return l.notice(ctx, "the branch changed — reload", mrHref(l.repo, n))
479+ out = append(out, notice...)
480+ moved = true
481+ case l.target != "" && u.Ref == "refs/heads/"+l.target:
482+ moved = true
483+ }
484+ if moved && len(out) > 0 {
485+ break
469486 }
470487 }
471- return nil, nil
488+ if !moved {
489+ return nil, nil
490+ }
491+ badge, err := l.mrMergeability(ctx)
492+ return append(out, badge...), err
493+}
494+
495+// mrMergeability is the badge of the merge box, read again: the result for the
496+// tips as they are now, "checking" until the server has one (it then sends the
497+// MergeRequestMergeability that turns the badge). Nothing for a request that
498+// is not open.
499+func (l *liveStream) mrMergeability(ctx context.Context) ([]fragment, error) {
500+ if !SignedIn(ctx) || l.s.cfg.MergeRequests == nil {
501+ return nil, nil // the box is the owner's
502+ }
503+ name, err := domain.ParseRepoName(l.repo)
504+ if err != nil {
505+ return nil, nil
506+ }
507+ n, err := strconv.ParseInt(l.n, 10, 64)
508+ if err != nil {
509+ return nil, nil
510+ }
511+ m, err := l.s.cfg.MergeRequests.Mergeability(ctx, principal(ctx), name, n)
512+ switch {
513+ case errors.Is(err, domain.ErrNotFound):
514+ return gap, nil
515+ case err != nil:
516+ return nil, err
517+ case m.State == "":
518+ return nil, nil
519+ }
520+ f, err := renderFragment(ctx, "mr-mergeability", MRMergeabilityFragment(mergeabilityView(m, l.target)))
521+ return []fragment{f}, err
472522 }
473523
474524 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
@@ -188,6 +189,11 @@ func (l *liveStream) repoPage(ctx context.Context, we ports.WatchEvent) ([]fragm
188 return l.mrNotice(ctx, ev.MergeRequest.Number, "this merge request was closed — reload")189 return l.mrNotice(ctx, ev.MergeRequest.Number, "this merge request was closed — reload")
189 case domain.MergeRequestReady:190 case domain.MergeRequestReady:
190 return l.mrNotice(ctx, ev.MergeRequest.Number, "this merge request is ready for review — reload")191 return l.mrNotice(ctx, ev.MergeRequest.Number, "this merge request is ready for review — reload")
192+ case domain.MergeRequestMergeability:
193+ if l.view != "mr" || strconv.FormatInt(ev.Number, 10) != l.n {
194+ return nil, nil
195+ }
196+ return l.mrMergeability(ctx)
191 case domain.CommentEdited:197 case domain.CommentEdited:
192 return l.mrNotice(ctx, ev.Number, "a note was edited — reload")198 return l.mrNotice(ctx, ev.Number, "a note was edited — reload")
193 case domain.CommentDeleted:199 case domain.CommentDeleted:
@@ -454,21 +460,65 @@ func (l *liveStream) issueNotice(ctx context.Context, number int64, text string)
454 return nil, nil460 return nil, nil
455 }461 }
456 462
457-// mrPushed announces a push to the source branch of the merge request shown.463+// mrPushed announces a push to the source branch of the merge request shown,
464+// and has the badge follow a push to its source or its target.
458 func (l *liveStream) mrPushed(ctx context.Context, p domain.PushEvent) ([]fragment, error) {465 func (l *liveStream) mrPushed(ctx context.Context, p domain.PushEvent) ([]fragment, error) {
459- if l.ref == "" {466+ n, err := strconv.ParseInt(l.n, 10, 64)
467+ if err != nil {
460 return nil, nil468 return nil, nil
461 }469 }
470+ var out []fragment
471+ moved := false
462 for _, u := range p.Updates {472 for _, u := range p.Updates {
463- if u.Ref == "refs/heads/"+l.ref {473+ switch {
464- n, err := strconv.ParseInt(l.n, 10, 64)474+ case l.ref != "" && u.Ref == "refs/heads/"+l.ref:
475+ notice, err := l.notice(ctx, "the branch changed — reload", mrHref(l.repo, n))
465 if err != nil {476 if err != nil {
466- return nil, nil477+ return nil, err
467 }478 }
468- return l.notice(ctx, "the branch changed — reload", mrHref(l.repo, n))479+ out = append(out, notice...)
480+ moved = true
481+ case l.target != "" && u.Ref == "refs/heads/"+l.target:
482+ moved = true
483+ }
484+ if moved && len(out) > 0 {
485+ break
469 }486 }
470 }487 }
471- return nil, nil488+ if !moved {
489+ return nil, nil
490+ }
491+ badge, err := l.mrMergeability(ctx)
492+ return append(out, badge...), err
493+}
494+
495+// mrMergeability is the badge of the merge box, read again: the result for the
496+// tips as they are now, "checking" until the server has one (it then sends the
497+// MergeRequestMergeability that turns the badge). Nothing for a request that
498+// is not open.
499+func (l *liveStream) mrMergeability(ctx context.Context) ([]fragment, error) {
500+ if !SignedIn(ctx) || l.s.cfg.MergeRequests == nil {
501+ return nil, nil // the box is the owner's
502+ }
503+ name, err := domain.ParseRepoName(l.repo)
504+ if err != nil {
505+ return nil, nil
506+ }
507+ n, err := strconv.ParseInt(l.n, 10, 64)
508+ if err != nil {
509+ return nil, nil
510+ }
511+ m, err := l.s.cfg.MergeRequests.Mergeability(ctx, principal(ctx), name, n)
512+ switch {
513+ case errors.Is(err, domain.ErrNotFound):
514+ return gap, nil
515+ case err != nil:
516+ return nil, err
517+ case m.State == "":
518+ return nil, nil
519+ }
520+ f, err := renderFragment(ctx, "mr-mergeability", MRMergeabilityFragment(mergeabilityView(m, l.target)))
521+ return []fragment{f}, err
472 }522 }
473 523
474 func (l *liveStream) notice(ctx context.Context, text, href string) ([]fragment, error) {524 func (l *liveStream) notice(ctx context.Context, text, href string) ([]fragment, error) {
M internal/adapters/web/fragments.templ +1 -1
M internal/adapters/web/fragments_templ.go +1 -1
M internal/adapters/web/fragments_test.go +1 -0
M internal/adapters/web/mr.go +156 -18
M internal/adapters/web/mr.templ +92 -4
M internal/adapters/web/mr_post.go +55 -3
M internal/adapters/web/mr_templ.go +1185 -737
A internal/adapters/web/mr_test.go +130 -0
M internal/adapters/web/static/css/60-mr.css +21 -0
M internal/adapters/web/static/js/mr.js +10 -1
M internal/adapters/web/web.go +4 -0
M internal/app/api_helpers_test.go +14 -1
M internal/app/api_test.go +1 -1
M internal/app/ci_schedule_trust_test.go +6 -2
M internal/app/cli_e2e_test.go +14 -2
M internal/app/cli_mr.go +150 -24
M internal/app/cli_mr_e2e_test.go +6 -6
M internal/app/cli_mr_test.go +83 -7
M internal/app/cli_repo.go +9 -7
M internal/app/cli_version.go +1 -0
M internal/app/cli_version_test.go +5 -5
M internal/app/cli_watch.go +30 -0
M internal/app/cli_watch_test.go +43 -0
M internal/app/forge.go +14 -1
M internal/app/mr_api_test.go +3 -2
M internal/app/testdata/web/commits-root.html +1 -1
M internal/app/testdata/web/commits-small.html +1 -1
M internal/app/testdata/web/home.html +1 -1
M internal/app/testdata/web/refs.html +1 -1
M internal/app/testdata/web/summary.html +1 -1
M internal/app/web.go +5 -0
M internal/app/web_events_test.go +9 -0
M internal/app/web_inbox_test.go +15 -3
M internal/app/web_mr_test.go +202 -10
M internal/domain/event.go +14 -0
M internal/domain/mergerequest.go +155 -16
M internal/domain/mergerequest_test.go +50 -3
A internal/domain/mergetree_bound_test.go +53 -0
M internal/ports/ci.go +3 -0
M internal/ports/dto.go +8 -2
M internal/ports/mocks/CIRefTipStore.go +69 -0
M internal/ports/mocks/MergeGit.go +162 -0
M internal/ports/mocks/MergeRequestService.go +78 -0
M internal/ports/mocks/MergeRequestStore.go +192 -0
A internal/ports/mocks/MergeabilityReader.go +111 -0
A internal/ports/mocks/MergeabilityTrigger.go +146 -0
M internal/ports/ports.go +37 -0
M internal/ports/services.go +4 -0
M internal/usecase/ci_unit_test.go +1 -0
M internal/usecase/events.go +5 -1
M internal/usecase/events_test.go +13 -0
M internal/usecase/export_test.go +46 -0
A internal/usecase/mergeability.go +467 -0
A internal/usecase/mergeability_reconcile_test.go +91 -0
A internal/usecase/mergeability_test.go +673 -0
M internal/usecase/mergerequests.go +246 -70
M internal/usecase/mergerequests_branches_test.go +1 -0
M internal/usecase/mergerequests_delete_test.go +150 -1
M internal/usecase/mergerequests_integration_test.go +11 -6
A internal/usecase/mergerequests_merge_test.go +293 -0
M internal/usecase/pushes.go +23 -11
M proto/ermite/storage/merge_request.proto +28 -0
M skills/ermite/CLI.md +5 -4
M skills/ermite/SKILL.md +2 -2

Comments0 open / 0

no comments yet.

Keyboard shortcuts

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

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

Branches 2

Tags 41

↑↓ moveenter openesc close