detached aa7340c

Merge 843ebed5ec107f59b860cc5e66bbcaf8e64a8425 into d8e7d5b03a01f5f16f8d49f12a7e132355b40f06

commit
aa7340c5e160a7e6553043c53ccaea045dbe7f20
author
ermite · · 2026-10-19 18:07 UTC
merge of
d8e7d5b + 843ebed · diff against the first parent
signature
unsigned
88 files · +6356 -1026
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).
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
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.
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
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.
M gen/ermite/storage/merge_request.pb.go +216 -24
@@ -33,6 +33,7 @@ const (
3333 MergeMode_MERGE_MODE_UNSPECIFIED MergeMode = 0
3434 MergeMode_MERGE_MODE_FAST_FORWARD MergeMode = 1
3535 MergeMode_MERGE_MODE_SQUASH MergeMode = 2
36+ MergeMode_MERGE_MODE_MERGE MergeMode = 3
3637 )
3738
3839 // Enum value maps for MergeMode.
@@ -41,11 +42,13 @@ var (
4142 0: "MERGE_MODE_UNSPECIFIED",
4243 1: "MERGE_MODE_FAST_FORWARD",
4344 2: "MERGE_MODE_SQUASH",
45+ 3: "MERGE_MODE_MERGE",
4446 }
4547 MergeMode_value = map[string]int32{
4648 "MERGE_MODE_UNSPECIFIED": 0,
4749 "MERGE_MODE_FAST_FORWARD": 1,
4850 "MERGE_MODE_SQUASH": 2,
51+ "MERGE_MODE_MERGE": 3,
4952 }
5053 )
5154
@@ -283,6 +286,60 @@ func (Verdict) EnumDescriptor() ([]byte, []int) {
283286 return file_ermite_storage_merge_request_proto_rawDescGZIP(), []int{4}
284287 }
285288
289+// MergeabilityState is the outcome of a mergeability check. "Checking" is
290+// never stored.
291+type MergeabilityState int32
292+
293+const (
294+ MergeabilityState_MERGEABILITY_STATE_UNSPECIFIED MergeabilityState = 0
295+ MergeabilityState_MERGEABILITY_STATE_UP_TO_DATE MergeabilityState = 1
296+ MergeabilityState_MERGEABILITY_STATE_BEHIND MergeabilityState = 2
297+ MergeabilityState_MERGEABILITY_STATE_CONFLICT MergeabilityState = 3
298+)
299+
300+// Enum value maps for MergeabilityState.
301+var (
302+ MergeabilityState_name = map[int32]string{
303+ 0: "MERGEABILITY_STATE_UNSPECIFIED",
304+ 1: "MERGEABILITY_STATE_UP_TO_DATE",
305+ 2: "MERGEABILITY_STATE_BEHIND",
306+ 3: "MERGEABILITY_STATE_CONFLICT",
307+ }
308+ MergeabilityState_value = map[string]int32{
309+ "MERGEABILITY_STATE_UNSPECIFIED": 0,
310+ "MERGEABILITY_STATE_UP_TO_DATE": 1,
311+ "MERGEABILITY_STATE_BEHIND": 2,
312+ "MERGEABILITY_STATE_CONFLICT": 3,
313+ }
314+)
315+
316+func (x MergeabilityState) Enum() *MergeabilityState {
317+ p := new(MergeabilityState)
318+ *p = x
319+ return p
320+}
321+
322+func (x MergeabilityState) String() string {
323+ return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
324+}
325+
326+func (MergeabilityState) Descriptor() protoreflect.EnumDescriptor {
327+ return file_ermite_storage_merge_request_proto_enumTypes[5].Descriptor()
328+}
329+
330+func (MergeabilityState) Type() protoreflect.EnumType {
331+ return &file_ermite_storage_merge_request_proto_enumTypes[5]
332+}
333+
334+func (x MergeabilityState) Number() protoreflect.EnumNumber {
335+ return protoreflect.EnumNumber(x)
336+}
337+
338+// Deprecated: Use MergeabilityState.Descriptor instead.
339+func (MergeabilityState) EnumDescriptor() ([]byte, []int) {
340+ return file_ermite_storage_merge_request_proto_rawDescGZIP(), []int{5}
341+}
342+
286343 // RepoSettings are the merge settings of a repository, stored inside Repo.
287344 type RepoSettings struct {
288345 state protoimpl.MessageState `protogen:"open.v1"`
@@ -290,7 +347,12 @@ type RepoSettings struct {
290347 AllowedMergeModes []MergeMode `protobuf:"varint,1,rep,packed,name=allowed_merge_modes,json=allowedMergeModes,proto3,enum=ermite.storage.MergeMode" json:"allowed_merge_modes,omitempty"`
291348 FailingChecksBlock bool `protobuf:"varint,2,opt,name=failing_checks_block,json=failingChecksBlock,proto3" json:"failing_checks_block,omitempty"`
292349 // Who may write issues; unspecified reads as off.
293- Issues IssuesMode `protobuf:"varint,3,opt,name=issues,proto3,enum=ermite.storage.IssuesMode" json:"issues,omitempty"`
350+ Issues IssuesMode `protobuf:"varint,3,opt,name=issues,proto3,enum=ermite.storage.IssuesMode" json:"issues,omitempty"`
351+ // Set on every write since merge commits exist: the modes are the explicit
352+ // choice of the owner. When false (settings stored before), exactly
353+ // {fast-forward, squash} was the old default and gains MERGE_MODE_MERGE on
354+ // read.
355+ MergeModesV2 bool `protobuf:"varint,4,opt,name=merge_modes_v2,json=mergeModesV2,proto3" json:"merge_modes_v2,omitempty"`
294356 unknownFields protoimpl.UnknownFields
295357 sizeCache protoimpl.SizeCache
296358 }
@@ -346,6 +408,13 @@ func (x *RepoSettings) GetIssues() IssuesMode {
346408 return IssuesMode_ISSUES_MODE_UNSPECIFIED
347409 }
348410
411+func (x *RepoSettings) GetMergeModesV2() bool {
412+ if x != nil {
413+ return x.MergeModesV2
414+ }
415+ return false
416+}
417+
349418 // MergeRequest is stored under the key "mr/<repo>/<number:010d>".
350419 type MergeRequest struct {
351420 state protoimpl.MessageState `protogen:"open.v1"`
@@ -696,15 +765,118 @@ func (x *Sequence) GetLast() int64 {
696765 return 0
697766 }
698767
768+// Mergeability is stored under the key "mrmerge/<repo>/<number:010d>": a
769+// derived, discardable record of an open merge request.
770+type Mergeability struct {
771+ state protoimpl.MessageState `protogen:"open.v1"`
772+ State MergeabilityState `protobuf:"varint,1,opt,name=state,proto3,enum=ermite.storage.MergeabilityState" json:"state,omitempty"`
773+ TargetId string `protobuf:"bytes,2,opt,name=target_id,json=targetId,proto3" json:"target_id,omitempty"`
774+ HeadId string `protobuf:"bytes,3,opt,name=head_id,json=headId,proto3" json:"head_id,omitempty"`
775+ Behind int32 `protobuf:"varint,4,opt,name=behind,proto3" json:"behind,omitempty"`
776+ BehindCapped bool `protobuf:"varint,5,opt,name=behind_capped,json=behindCapped,proto3" json:"behind_capped,omitempty"`
777+ ConflictPaths []string `protobuf:"bytes,6,rep,name=conflict_paths,json=conflictPaths,proto3" json:"conflict_paths,omitempty"`
778+ ConflictPathsTruncated bool `protobuf:"varint,7,opt,name=conflict_paths_truncated,json=conflictPathsTruncated,proto3" json:"conflict_paths_truncated,omitempty"`
779+ CheckedAt *timestamppb.Timestamp `protobuf:"bytes,8,opt,name=checked_at,json=checkedAt,proto3" json:"checked_at,omitempty"`
780+ unknownFields protoimpl.UnknownFields
781+ sizeCache protoimpl.SizeCache
782+}
783+
784+func (x *Mergeability) Reset() {
785+ *x = Mergeability{}
786+ mi := &file_ermite_storage_merge_request_proto_msgTypes[4]
787+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
788+ ms.StoreMessageInfo(mi)
789+}
790+
791+func (x *Mergeability) String() string {
792+ return protoimpl.X.MessageStringOf(x)
793+}
794+
795+func (*Mergeability) ProtoMessage() {}
796+
797+func (x *Mergeability) ProtoReflect() protoreflect.Message {
798+ mi := &file_ermite_storage_merge_request_proto_msgTypes[4]
799+ if x != nil {
800+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
801+ if ms.LoadMessageInfo() == nil {
802+ ms.StoreMessageInfo(mi)
803+ }
804+ return ms
805+ }
806+ return mi.MessageOf(x)
807+}
808+
809+// Deprecated: Use Mergeability.ProtoReflect.Descriptor instead.
810+func (*Mergeability) Descriptor() ([]byte, []int) {
811+ return file_ermite_storage_merge_request_proto_rawDescGZIP(), []int{4}
812+}
813+
814+func (x *Mergeability) GetState() MergeabilityState {
815+ if x != nil {
816+ return x.State
817+ }
818+ return MergeabilityState_MERGEABILITY_STATE_UNSPECIFIED
819+}
820+
821+func (x *Mergeability) GetTargetId() string {
822+ if x != nil {
823+ return x.TargetId
824+ }
825+ return ""
826+}
827+
828+func (x *Mergeability) GetHeadId() string {
829+ if x != nil {
830+ return x.HeadId
831+ }
832+ return ""
833+}
834+
835+func (x *Mergeability) GetBehind() int32 {
836+ if x != nil {
837+ return x.Behind
838+ }
839+ return 0
840+}
841+
842+func (x *Mergeability) GetBehindCapped() bool {
843+ if x != nil {
844+ return x.BehindCapped
845+ }
846+ return false
847+}
848+
849+func (x *Mergeability) GetConflictPaths() []string {
850+ if x != nil {
851+ return x.ConflictPaths
852+ }
853+ return nil
854+}
855+
856+func (x *Mergeability) GetConflictPathsTruncated() bool {
857+ if x != nil {
858+ return x.ConflictPathsTruncated
859+ }
860+ return false
861+}
862+
863+func (x *Mergeability) GetCheckedAt() *timestamppb.Timestamp {
864+ if x != nil {
865+ return x.CheckedAt
866+ }
867+ return nil
868+}
869+
699870 var File_ermite_storage_merge_request_proto protoreflect.FileDescriptor
700871
701872 const file_ermite_storage_merge_request_proto_rawDesc = "" +
702873 "\n" +
703- "\"ermite/storage/merge_request.proto\x12\x0eermite.storage\x1a\x1fgoogle/protobuf/timestamp.proto\"\xbf\x01\n" +
874+ "\"ermite/storage/merge_request.proto\x12\x0eermite.storage\x1a\x1fgoogle/protobuf/timestamp.proto\"\xe5\x01\n" +
704875 "\fRepoSettings\x12I\n" +
705876 "\x13allowed_merge_modes\x18\x01 \x03(\x0e2\x19.ermite.storage.MergeModeR\x11allowedMergeModes\x120\n" +
706877 "\x14failing_checks_block\x18\x02 \x01(\bR\x12failingChecksBlock\x122\n" +
707- "\x06issues\x18\x03 \x01(\x0e2\x1a.ermite.storage.IssuesModeR\x06issues\"\xa6\x04\n" +
878+ "\x06issues\x18\x03 \x01(\x0e2\x1a.ermite.storage.IssuesModeR\x06issues\x12$\n" +
879+ "\x0emerge_modes_v2\x18\x04 \x01(\bR\fmergeModesV2\"\xa6\x04\n" +
708880 "\fMergeRequest\x12\x16\n" +
709881 "\x06number\x18\x01 \x01(\x03R\x06number\x12\x14\n" +
710882 "\x05title\x18\x02 \x01(\tR\x05title\x12 \n" +
@@ -744,11 +916,22 @@ const file_ermite_storage_merge_request_proto_rawDesc = "" +
744916 "\tedited_at\x18\r \x01(\v2\x1a.google.protobuf.TimestampR\beditedAt\x12\x18\n" +
745917 "\adeleted\x18\x0e \x01(\bR\adeleted\"\x1e\n" +
746918 "\bSequence\x12\x12\n" +
747- "\x04last\x18\x01 \x01(\x03R\x04last*[\n" +
919+ "\x04last\x18\x01 \x01(\x03R\x04last\"\xd6\x02\n" +
920+ "\fMergeability\x127\n" +
921+ "\x05state\x18\x01 \x01(\x0e2!.ermite.storage.MergeabilityStateR\x05state\x12\x1b\n" +
922+ "\ttarget_id\x18\x02 \x01(\tR\btargetId\x12\x17\n" +
923+ "\ahead_id\x18\x03 \x01(\tR\x06headId\x12\x16\n" +
924+ "\x06behind\x18\x04 \x01(\x05R\x06behind\x12#\n" +
925+ "\rbehind_capped\x18\x05 \x01(\bR\fbehindCapped\x12%\n" +
926+ "\x0econflict_paths\x18\x06 \x03(\tR\rconflictPaths\x128\n" +
927+ "\x18conflict_paths_truncated\x18\a \x01(\bR\x16conflictPathsTruncated\x129\n" +
928+ "\n" +
929+ "checked_at\x18\b \x01(\v2\x1a.google.protobuf.TimestampR\tcheckedAt*q\n" +
748930 "\tMergeMode\x12\x1a\n" +
749931 "\x16MERGE_MODE_UNSPECIFIED\x10\x00\x12\x1b\n" +
750932 "\x17MERGE_MODE_FAST_FORWARD\x10\x01\x12\x15\n" +
751- "\x11MERGE_MODE_SQUASH\x10\x02*m\n" +
933+ "\x11MERGE_MODE_SQUASH\x10\x02\x12\x14\n" +
934+ "\x10MERGE_MODE_MERGE\x10\x03*m\n" +
752935 "\n" +
753936 "IssuesMode\x12\x1b\n" +
754937 "\x17ISSUES_MODE_UNSPECIFIED\x10\x00\x12\x13\n" +
@@ -767,7 +950,12 @@ const file_ermite_storage_merge_request_proto_rawDesc = "" +
767950 "\aVerdict\x12\x17\n" +
768951 "\x13VERDICT_UNSPECIFIED\x10\x00\x12\x13\n" +
769952 "\x0fVERDICT_APPROVE\x10\x01\x12\x1d\n" +
770- "\x19VERDICT_CHANGES_REQUESTED\x10\x02B/Z-bnema.dev/ermite/gen/ermite/storage;storagepbb\x06proto3"
953+ "\x19VERDICT_CHANGES_REQUESTED\x10\x02*\x9a\x01\n" +
954+ "\x11MergeabilityState\x12\"\n" +
955+ "\x1eMERGEABILITY_STATE_UNSPECIFIED\x10\x00\x12!\n" +
956+ "\x1dMERGEABILITY_STATE_UP_TO_DATE\x10\x01\x12\x1d\n" +
957+ "\x19MERGEABILITY_STATE_BEHIND\x10\x02\x12\x1f\n" +
958+ "\x1bMERGEABILITY_STATE_CONFLICT\x10\x03B/Z-bnema.dev/ermite/gen/ermite/storage;storagepbb\x06proto3"
771959
772960 var (
773961 file_ermite_storage_merge_request_proto_rawDescOnce sync.Once
@@ -781,36 +969,40 @@ func file_ermite_storage_merge_request_proto_rawDescGZIP() []byte {
781969 return file_ermite_storage_merge_request_proto_rawDescData
782970 }
783971
784-var file_ermite_storage_merge_request_proto_enumTypes = make([]protoimpl.EnumInfo, 5)
785-var file_ermite_storage_merge_request_proto_msgTypes = make([]protoimpl.MessageInfo, 4)
972+var file_ermite_storage_merge_request_proto_enumTypes = make([]protoimpl.EnumInfo, 6)
973+var file_ermite_storage_merge_request_proto_msgTypes = make([]protoimpl.MessageInfo, 5)
786974 var file_ermite_storage_merge_request_proto_goTypes = []any{
787975 (MergeMode)(0), // 0: ermite.storage.MergeMode
788976 (IssuesMode)(0), // 1: ermite.storage.IssuesMode
789977 (MergeRequestState)(0), // 2: ermite.storage.MergeRequestState
790978 (DiffSide)(0), // 3: ermite.storage.DiffSide
791979 (Verdict)(0), // 4: ermite.storage.Verdict
792- (*RepoSettings)(nil), // 5: ermite.storage.RepoSettings
793- (*MergeRequest)(nil), // 6: ermite.storage.MergeRequest
794- (*Comment)(nil), // 7: ermite.storage.Comment
795- (*Sequence)(nil), // 8: ermite.storage.Sequence
796- (*timestamppb.Timestamp)(nil), // 9: google.protobuf.Timestamp
980+ (MergeabilityState)(0), // 5: ermite.storage.MergeabilityState
981+ (*RepoSettings)(nil), // 6: ermite.storage.RepoSettings
982+ (*MergeRequest)(nil), // 7: ermite.storage.MergeRequest
983+ (*Comment)(nil), // 8: ermite.storage.Comment
984+ (*Sequence)(nil), // 9: ermite.storage.Sequence
985+ (*Mergeability)(nil), // 10: ermite.storage.Mergeability
986+ (*timestamppb.Timestamp)(nil), // 11: google.protobuf.Timestamp
797987 }
798988 var file_ermite_storage_merge_request_proto_depIdxs = []int32{
799989 0, // 0: ermite.storage.RepoSettings.allowed_merge_modes:type_name -> ermite.storage.MergeMode
800990 1, // 1: ermite.storage.RepoSettings.issues:type_name -> ermite.storage.IssuesMode
801991 2, // 2: ermite.storage.MergeRequest.state:type_name -> ermite.storage.MergeRequestState
802- 9, // 3: ermite.storage.MergeRequest.created_at:type_name -> google.protobuf.Timestamp
803- 9, // 4: ermite.storage.MergeRequest.updated_at:type_name -> google.protobuf.Timestamp
992+ 11, // 3: ermite.storage.MergeRequest.created_at:type_name -> google.protobuf.Timestamp
993+ 11, // 4: ermite.storage.MergeRequest.updated_at:type_name -> google.protobuf.Timestamp
804994 0, // 5: ermite.storage.MergeRequest.merge_mode:type_name -> ermite.storage.MergeMode
805995 3, // 6: ermite.storage.Comment.side:type_name -> ermite.storage.DiffSide
806- 9, // 7: ermite.storage.Comment.created_at:type_name -> google.protobuf.Timestamp
996+ 11, // 7: ermite.storage.Comment.created_at:type_name -> google.protobuf.Timestamp
807997 4, // 8: ermite.storage.Comment.verdict:type_name -> ermite.storage.Verdict
808- 9, // 9: ermite.storage.Comment.edited_at:type_name -> google.protobuf.Timestamp
809- 10, // [10:10] is the sub-list for method output_type
810- 10, // [10:10] is the sub-list for method input_type
811- 10, // [10:10] is the sub-list for extension type_name
812- 10, // [10:10] is the sub-list for extension extendee
813- 0, // [0:10] is the sub-list for field type_name
998+ 11, // 9: ermite.storage.Comment.edited_at:type_name -> google.protobuf.Timestamp
999+ 5, // 10: ermite.storage.Mergeability.state:type_name -> ermite.storage.MergeabilityState
1000+ 11, // 11: ermite.storage.Mergeability.checked_at:type_name -> google.protobuf.Timestamp
1001+ 12, // [12:12] is the sub-list for method output_type
1002+ 12, // [12:12] is the sub-list for method input_type
1003+ 12, // [12:12] is the sub-list for extension type_name
1004+ 12, // [12:12] is the sub-list for extension extendee
1005+ 0, // [0:12] is the sub-list for field type_name
8141006 }
8151007
8161008 func init() { file_ermite_storage_merge_request_proto_init() }
@@ -823,8 +1015,8 @@ func file_ermite_storage_merge_request_proto_init() {
8231015 File: protoimpl.DescBuilder{
8241016 GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
8251017 RawDescriptor: unsafe.Slice(unsafe.StringData(file_ermite_storage_merge_request_proto_rawDesc), len(file_ermite_storage_merge_request_proto_rawDesc)),
826- NumEnums: 5,
827- NumMessages: 4,
1018+ NumEnums: 6,
1019+ NumMessages: 5,
8281020 NumExtensions: 0,
8291021 NumServices: 0,
8301022 },
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
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))
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.
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 }
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+}
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+}
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+}
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+}
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 }
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+}
M internal/adapters/kvstore/mergerequests_test.go +117 -0
@@ -572,3 +572,120 @@ func TestNoteFieldsRoundTrip(t *testing.T) {
572572 assert.Equal(t, c, got)
573573 }
574574 }
575+
576+// TestStoredOldDefaultModesGainMerge: settings written before merge commits
577+// (no merge_modes_v2) with exactly {fast-forward, squash} read with merge too;
578+// the same set written by this build is an explicit choice and stays.
579+func TestStoredOldDefaultModesGainMerge(t *testing.T) {
580+ ctx := context.Background()
581+ dir := t.TempDir()
582+ s, err := kvstore.Open(ctx, dir)
583+ require.NoError(t, err)
584+ require.NoError(t, s.Close(ctx))
585+
586+ put := func(db *kv.DB, repo string, modes ...storagepb.MergeMode) {
587+ val, err := proto.Marshal(&storagepb.Repo{Name: repo, Visibility: storagepb.Visibility_VISIBILITY_PUBLIC,
588+ Settings: &storagepb.RepoSettings{AllowedMergeModes: modes, FailingChecksBlock: true}})
589+ require.NoError(t, err)
590+ var b kv.Batch
591+ b.Put([]byte("repo/"+repo), val)
592+ _, err = db.Apply(ctx, &b)
593+ require.NoError(t, err)
594+ }
595+ db, err := kv.Open(ctx, dir, kv.Options{})
596+ require.NoError(t, err)
597+ put(db, "old", storagepb.MergeMode_MERGE_MODE_FAST_FORWARD, storagepb.MergeMode_MERGE_MODE_SQUASH)
598+ put(db, "single", storagepb.MergeMode_MERGE_MODE_SQUASH)
599+ require.NoError(t, db.Close(ctx))
600+
601+ s = openStore(t, dir)
602+ old, err := s.Get(ctx, name(t, "old"))
603+ require.NoError(t, err)
604+ assert.Equal(t, domain.MergeModes(), old.Settings.AllowedMergeModes)
605+ single, err := s.Get(ctx, name(t, "single"))
606+ require.NoError(t, err)
607+ assert.Equal(t, []domain.MergeMode{domain.MergeModeSquash}, single.Settings.AllowedMergeModes)
608+
609+ // An explicit {ff, squash} written now is kept.
610+ old.Settings.AllowedMergeModes = []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash}
611+ require.NoError(t, s.Update(ctx, old))
612+ again, err := s.Get(ctx, name(t, "old"))
613+ require.NoError(t, err)
614+ assert.Equal(t, []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash}, again.Settings.AllowedMergeModes)
615+}
616+
617+func mergeability(mr domain.MergeRequest) domain.Mergeability {
618+ return domain.Mergeability{
619+ Repo: mr.Repo, Number: mr.Number, State: domain.MergeabilityConflict,
620+ TargetID: oid('a'), HeadID: oid('b'), Behind: 3, BehindCapped: true,
621+ ConflictPaths: []string{"a.txt", "b/c.txt"}, ConflictPathsTruncated: true, CheckedAt: created,
622+ }
623+}
624+
625+func TestPutMergeabilityCAS(t *testing.T) {
626+ ctx := context.Background()
627+ _, mrs, n := repoWithMRs(t)
628+ mr, err := mrs.Create(ctx, newMR(t, n, "t"))
629+ require.NoError(t, err)
630+
631+ _, err = mrs.GetMergeability(ctx, n, mr.Number)
632+ assert.ErrorIs(t, err, domain.ErrNotFound)
633+
634+ m := mergeability(mr)
635+ require.NoError(t, mrs.PutMergeability(ctx, m))
636+ assert.ErrorIs(t, mrs.PutMergeability(ctx, m), domain.ErrConflict, "version 0 needs it absent")
637+
638+ got, err := mrs.GetMergeability(ctx, n, mr.Number)
639+ require.NoError(t, err)
640+ assert.NotZero(t, got.Version)
641+ m.Version = got.Version
642+ assert.Equal(t, m, got)
643+
644+ next := got
645+ next.State = domain.MergeabilityUpToDate
646+ require.NoError(t, mrs.PutMergeability(ctx, next))
647+ assert.ErrorIs(t, mrs.PutMergeability(ctx, next), domain.ErrConflict, "stale version")
648+
649+ require.NoError(t, mrs.DeleteMergeability(ctx, n, mr.Number))
650+ require.NoError(t, mrs.DeleteMergeability(ctx, n, mr.Number), "absent is fine")
651+ _, err = mrs.GetMergeability(ctx, n, mr.Number)
652+ assert.ErrorIs(t, err, domain.ErrNotFound)
653+
654+ missing := mergeability(mr)
655+ missing.Number = 99
656+ assert.ErrorIs(t, mrs.PutMergeability(ctx, missing), domain.ErrNotFound)
657+
658+ checking := mergeability(mr)
659+ checking.State = domain.MergeabilityChecking
660+ assert.ErrorIs(t, mrs.PutMergeability(ctx, checking), domain.ErrInvalid)
661+}
662+
663+func TestMergeabilityRefusedAndDroppedWhenNotOpen(t *testing.T) {
664+ for _, state := range []domain.MRState{domain.MRStateMerged, domain.MRStateClosed} {
665+ ctx := context.Background()
666+ _, mrs, n := repoWithMRs(t)
667+ mr, err := mrs.Create(ctx, newMR(t, n, "t"))
668+ require.NoError(t, err)
669+ require.NoError(t, mrs.PutMergeability(ctx, mergeability(mr)))
670+
671+ cur, err := mrs.Get(ctx, n, mr.Number)
672+ require.NoError(t, err)
673+ cur.State = state
674+ require.NoError(t, mrs.Update(ctx, cur))
675+ _, err = mrs.GetMergeability(ctx, n, mr.Number)
676+ assert.ErrorIs(t, err, domain.ErrNotFound, state)
677+
678+ assert.ErrorIs(t, mrs.PutMergeability(ctx, mergeability(mr)), domain.ErrNotFound, "a late worker never resurrects it: %s", state)
679+ }
680+}
681+
682+func TestDeleteWipesMergeability(t *testing.T) {
683+ ctx := context.Background()
684+ s, mrs, n := repoWithMRs(t)
685+ mr, err := mrs.Create(ctx, newMR(t, n, "t"))
686+ require.NoError(t, err)
687+ require.NoError(t, mrs.PutMergeability(ctx, mergeability(mr)))
688+ require.NoError(t, s.Delete(ctx, n))
689+ _, err = mrs.GetMergeability(ctx, n, mr.Number)
690+ assert.ErrorIs(t, err, domain.ErrNotFound)
691+}
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.
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)
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+}
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),
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 }
M internal/adapters/web/events.go +6 -2
@@ -31,7 +31,7 @@ import (
3131 // GET /-/events?topic=repo:<name>&view=summary|refs
3232 // GET /-/events?topic=repo:<name>&view=commits&ref=<branch>&head=<sha>
3333 // GET /-/events?topic=repo:<name>&view=mrs
34-// GET /-/events?topic=repo:<name>&view=mr&n=<number>&ref=<source branch>
34+// GET /-/events?topic=repo:<name>&view=mr&n=<number>&ref=<source branch>&target=<target branch>
3535 // GET /-/events?topic=repo:<name>&view=issues
3636 // GET /-/events?topic=repo:<name>&view=issue&n=<number>
3737 // GET /-/events?topic=repo:<name>&view=actions
@@ -54,6 +54,10 @@ import (
5454 // refs-list data: <tbody id="ref-rows">…</tbody>, and again with
5555 // <dl id="refs-kv">…</dl>, the facts of the rail
5656 // commits-head data: <div id="commits-head">N new commits — reload</div>
57+// mr-mergeability data: <div id="mr-mergeability">…</div>, on the merge request pages:
58+// the badge of the merge box (a slot like mr-notice), when the
59+// mergeability of the request shown settles or a push moves its
60+// source or target
5761 // mr-notice data: <div id="mr-notice">…</div>, on the merge request and issue pages:
5862 // something changed, with the link that reloads the page
5963 // ci-run data: <tr id="run-<id>">…</tr> (the list of runs),
@@ -252,7 +256,7 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) {
252256 return
253257 }
254258
255- st := &liveStream{s: s, repo: repo, view: q.Get("view"), ref: q.Get("ref"), head: q.Get("head"), n: q.Get("n"), job: q.Get("job"), sha: sha}
259+ st := &liveStream{s: s, repo: repo, view: q.Get("view"), ref: q.Get("ref"), head: q.Get("head"), n: q.Get("n"), target: q.Get("target"), job: q.Get("job"), sha: sha}
256260 if topic == ownerTopic {
257261 st.filter = q.Get("repo")
258262 }
M internal/adapters/web/fragments.go +58 -8
@@ -28,6 +28,7 @@ type liveStream struct {
2828 head string // commits: the commit listed first
2929 n string // mr: the number of the merge request shown; run: the run id
3030 job string // job: the id of the job shown
31+ target string // mr: the target branch of the merge request shown
3132 // sha is the commit of the run of the job shown, read once when the stream
3233 // is set up: the annotations link to files at it, and it never changes.
3334 sha string
@@ -60,7 +61,7 @@ func liveEvent(we ports.WatchEvent) bool {
6061 }
6162 switch we.Event.(type) {
6263 case domain.RepoCreated, domain.RepoUpdated, domain.RepoDeleted, domain.PushEvent,
63- domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.MergeRequestReady, domain.CommentAdded, domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved,
64+ domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.MergeRequestReady, domain.MergeRequestMergeability, domain.CommentAdded, domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved,
6465 domain.RunUpdated, domain.JobUpdated,
6566 domain.IssueOpened, domain.IssueClosed, domain.IssueReopened, domain.IssueCommented, domain.IssueSubmitted, domain.IssuePendingRemoved:
6667 return true
@@ -170,6 +171,11 @@ func (l *liveStream) repoPage(ctx context.Context, we ports.WatchEvent) ([]fragm
170171 return l.mrNotice(ctx, ev.MergeRequest.Number, "this merge request was closed — reload")
171172 case domain.MergeRequestReady:
172173 return l.mrNotice(ctx, ev.MergeRequest.Number, "this merge request is ready for review — reload")
174+ case domain.MergeRequestMergeability:
175+ if l.view != "mr" || strconv.FormatInt(ev.Number, 10) != l.n {
176+ return nil, nil
177+ }
178+ return l.mrMergeability(ctx)
173179 case domain.CommentEdited:
174180 return l.mrNotice(ctx, ev.Number, "a note was edited — reload")
175181 case domain.CommentDeleted:
@@ -436,21 +442,65 @@ func (l *liveStream) issueNotice(ctx context.Context, number int64, text string)
436442 return nil, nil
437443 }
438444
439-// mrPushed announces a push to the source branch of the merge request shown.
445+// mrPushed announces a push to the source branch of the merge request shown,
446+// and has the badge follow a push to its source or its target.
440447 func (l *liveStream) mrPushed(ctx context.Context, p domain.PushEvent) ([]fragment, error) {
441- if l.ref == "" {
448+ n, err := strconv.ParseInt(l.n, 10, 64)
449+ if err != nil {
442450 return nil, nil
443451 }
452+ var out []fragment
453+ moved := false
444454 for _, u := range p.Updates {
445- if u.Ref == "refs/heads/"+l.ref {
446- n, err := strconv.ParseInt(l.n, 10, 64)
455+ switch {
456+ case l.ref != "" && u.Ref == "refs/heads/"+l.ref:
457+ notice, err := l.notice(ctx, "the branch changed — reload", mrHref(l.repo, n))
447458 if err != nil {
448- return nil, nil
459+ return nil, err
449460 }
450- return l.notice(ctx, "the branch changed — reload", mrHref(l.repo, n))
461+ out = append(out, notice...)
462+ moved = true
463+ case l.target != "" && u.Ref == "refs/heads/"+l.target:
464+ moved = true
465+ }
466+ if moved && len(out) > 0 {
467+ break
451468 }
452469 }
453- return nil, nil
470+ if !moved {
471+ return nil, nil
472+ }
473+ badge, err := l.mrMergeability(ctx)
474+ return append(out, badge...), err
475+}
476+
477+// mrMergeability is the badge of the merge box, read again: the result for the
478+// tips as they are now, "checking" until the server has one (it then sends the
479+// MergeRequestMergeability that turns the badge). Nothing for a request that
480+// is not open.
481+func (l *liveStream) mrMergeability(ctx context.Context) ([]fragment, error) {
482+ if !SignedIn(ctx) || l.s.cfg.MergeRequests == nil {
483+ return nil, nil // the box is the owner's
484+ }
485+ name, err := domain.ParseRepoName(l.repo)
486+ if err != nil {
487+ return nil, nil
488+ }
489+ n, err := strconv.ParseInt(l.n, 10, 64)
490+ if err != nil {
491+ return nil, nil
492+ }
493+ m, err := l.s.cfg.MergeRequests.Mergeability(ctx, principal(ctx), name, n)
494+ switch {
495+ case errors.Is(err, domain.ErrNotFound):
496+ return gap, nil
497+ case err != nil:
498+ return nil, err
499+ case m.State == "":
500+ return nil, nil
501+ }
502+ f, err := renderFragment(ctx, "mr-mergeability", MRMergeabilityFragment(mergeabilityView(m, l.target)))
503+ return []fragment{f}, err
454504 }
455505
456506 func (l *liveStream) notice(ctx context.Context, text, href string) ([]fragment, error) {
M internal/adapters/web/fragments.templ +1 -1
@@ -102,7 +102,7 @@ templ Live(l Layout) {
102102 htmx:sse:close, htmx:sse:error -> ermiteLive.lost();
103103 htmx:sse:error -> ermiteLive.error(event.detail.status);
104104 htmx:sse:before:connection -> ermiteLive.gate(event);
105- home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data);
105+ home-list, repo-last-push, refs-list, commits-head, mr-notice, mr-mergeability, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data);
106106 gap -> ermiteLive.reload()"
107107 ></span>
108108 }
M internal/adapters/web/fragments_templ.go +1 -1 generated file: collapsed
M internal/adapters/web/fragments_test.go +1 -0
@@ -33,6 +33,7 @@ func TestLiveSlotsAreStableStatusRegions(t *testing.T) {
3333 }{
3434 {"empty commits slot", LiveSlot("commits-head"), "commits-head", ""},
3535 {"empty mr slot", LiveSlot("mr-notice"), "mr-notice", ""},
36+ {"mergeability slot, as sent by the stream", MRMergeabilityFragment(MergeabilityView{State: "up_to_date", Label: "Ready to merge", FFOk: true, MergeOk: true}), "mr-mergeability", "Ready to merge"},
3637 {"commits banner", CommitsBanner("2 new commits — reload", "/x/commits/main"), "commits-head", "2 new commits — reload"},
3738 {"mr notice", MRNotice("a comment was added — reload", "/x/merge-requests/1"), "mr-notice", "a comment was added — reload"},
3839 } {
M internal/adapters/web/mr.go +156 -18
@@ -196,10 +196,38 @@ type MergeBox struct {
196196 // CanDeleteBranch offers to delete the source after the merge: it is not
197197 // the default branch. The server checks again, with the open requests.
198198 CanDeleteBranch bool
199+ // Mergeability is the live badge of the box and what the modes depend on.
200+ Mergeability MergeabilityView
201+ // Signed is true when a mode that creates a commit (squash, merge commit)
202+ // is offered; Fingerprint is then the one of the key that signs it, empty
203+ // when the server does not tell.
204+ Signed bool
205+ Fingerprint string
206+ // Error is why the last merge was refused, shown in the box.
207+ Error string
199208 }
200209
201-// ModeOption is a merge mode the repository allows.
202-type ModeOption struct{ Value, Label string }
210+// ModeOption is a merge mode the repository allows. Disabled says that the
211+// mode is not possible for the current state of the request, Reason why.
212+type ModeOption struct {
213+ Value, Label string
214+ Disabled bool
215+ Reason string
216+}
217+
218+// MergeabilityView is the mergeability of an open request, ready to show: the
219+// badge, and what the merge modes depend on (the flags are what the controls
220+// of the box read, live).
221+type MergeabilityView struct {
222+ State, Label, Detail string
223+ Paths []string
224+ // PathsNote says that more paths conflict than are listed.
225+ PathsNote string
226+ // FFOk and MergeOk say whether fast-forward, squash and merge commit are
227+ // possible; the reasons say why not.
228+ FFOk, MergeOk bool
229+ FFReason, MergeReason string
230+}
203231
204232 // SinceNav switches between the whole diff and the diff since the last read.
205233 type SinceNav struct {
@@ -233,6 +261,16 @@ type MRPage struct {
233261 MergedShort string
234262 MergedHref string
235263 MergeMode string
264+ // Deleting is the source branch that a merge just asked to delete: the
265+ // deletion follows the answer.
266+ Deleting string
267+ // MergeError is why a merge was refused when the page has no merge box
268+ // to show it in (the request is not open any more).
269+ MergeError string
270+ // Canonical is the address of the request, set when the page answers a
271+ // refused action from the address of that action: the script puts it in
272+ // the address bar, so that a reload does not post again.
273+ Canonical string
236274
237275 Since *SinceNav
238276 SinceNote string
@@ -346,10 +384,59 @@ func modeName(m domain.MergeMode) string {
346384 return "fast-forward"
347385 case domain.MergeModeSquash:
348386 return "squash"
387+ case domain.MergeModeMerge:
388+ return "merge commit"
349389 }
350390 return ""
351391 }
352392
393+// mergeabilityView converts the mergeability of a request into the badge and
394+// the flags of the controls of the merge box. A request without result is
395+// being checked.
396+func mergeabilityView(m domain.Mergeability, target string) MergeabilityView {
397+ v := MergeabilityView{State: string(domain.MergeabilityChecking), Label: "Checking…"}
398+ v.FFOk, v.FFReason = m.Allows(domain.MergeModeFastForward)
399+ v.MergeOk, v.MergeReason = m.Allows(domain.MergeModeSquash)
400+ target = visibleText(target)
401+ switch m.State {
402+ case domain.MergeabilityUpToDate:
403+ v.State, v.Label = string(m.State), "Ready to merge"
404+ case domain.MergeabilityBehind:
405+ v.State, v.Label, v.Detail = string(m.State), "Behind "+target+" by "+behindText(m), "merges without conflicts"
406+ case domain.MergeabilityConflict:
407+ v.State, v.Label, v.Detail = string(m.State), "Conflicts with "+target, "rebase needed"
408+ v.Paths, v.PathsNote = conflictList(m)
409+ }
410+ return v
411+}
412+
413+// behindText is "2 commits", "1 commit" or "1000+ commits".
414+func behindText(m domain.Mergeability) string {
415+ if m.Behind == 1 && !m.BehindCapped {
416+ return "1 commit"
417+ }
418+ n := itoa(m.Behind)
419+ if m.BehindCapped {
420+ n += "+"
421+ }
422+ return n + " commits"
423+}
424+
425+// conflictList is the conflicting paths to show, and the note that says what
426+// the list lacks.
427+func conflictList(m domain.Mergeability) (paths []string, note string) {
428+ for _, p := range m.ConflictPaths {
429+ paths = append(paths, visibleText(p))
430+ }
431+ switch {
432+ case len(paths) == 0:
433+ note = "no common history."
434+ case m.ConflictPathsTruncated:
435+ note = "more files conflict than are listed."
436+ }
437+ return paths, note
438+}
439+
353440 // mrRow converts a merge request of a listing.
354441 func (s *Server) mrRow(repo string, m domain.MergeRequest) MRRow {
355442 row := MRRow{
@@ -540,6 +627,9 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
540627 p.MergedHref = repoHref(d.name(), "commit", m.MergedCommitID.String())
541628 p.MergeMode = modeName(m.MergeMode)
542629 }
630+ if signed && m.State == domain.MRStateMerged && r.URL.Query().Get("deleting") == "1" && d.hasBranch(m.Source) {
631+ p.Deleting = visibleText(m.Source)
632+ }
543633 for _, mode := range d.repo.Settings.AllowedMergeModes {
544634 if name := modeName(mode); name != "" {
545635 p.Modes = append(p.Modes, name)
@@ -624,11 +714,18 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
624714 p.Invisible = invisible
625715 p.DiffView = newDiffView(r, lines, withFrom(self+"?view=unified", from), withFrom(self+"?view=split", from))
626716 if signed && open {
627- p.Box = s.mergeBox(d, n, m.Source, head, lastRead)
717+ p.Box = s.mergeBox(d, n, m.Source, head, lastRead, mergeabilityView(res.Mergeability, m.Target))
718+ p.Box.Error = mergeErrorOf(ctx)
628719 if m.IsDraft() {
629720 p.Box.ReadyAction = mrHref(d.name(), n, "ready")
630721 }
631722 }
723+ if mergeErrorOf(ctx) != "" {
724+ p.Canonical = self
725+ }
726+ if p.Box == nil {
727+ p.MergeError = mergeErrorOf(ctx) // the request is not open any more
728+ }
632729 if canWrite {
633730 p.GeneralForm = &CommentForm{
634731 ID: "cf-general", Action: mrHref(d.name(), n, "comments"), Title: "comment on the merge request",
@@ -652,7 +749,7 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
652749 l.Crumbs = append(l.Crumbs, Crumb{Label: "since", Current: true})
653750 }
654751 p.Live = true
655- l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=mr&n="+strconv.FormatInt(n, 10)+"&ref="+url.QueryEscape(m.Source)+"&head="+url.QueryEscape(head))
752+ l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=mr&n="+strconv.FormatInt(n, 10)+"&ref="+url.QueryEscape(m.Source)+"&head="+url.QueryEscape(head)+"&target="+url.QueryEscape(m.Target))
656753 l.HasRail = true
657754 l.MR = true
658755 return MergeRequest(l, p), http.StatusOK, nil
@@ -669,37 +766,78 @@ func (d *repoData) hasBranch(name string) bool {
669766 }
670767
671768 // mergeBox is what the owner can do with an open request.
672-func (s *Server) mergeBox(d *repoData, n int64, source, head, lastRead string) *MergeBox {
769+func (s *Server) mergeBox(d *repoData, n int64, source, head, lastRead string, mg MergeabilityView) *MergeBox {
673770 b := &MergeBox{
674771 MergeAction: mrHref(d.name(), n, "merge"), CloseAction: mrHref(d.name(), n, "close"),
675772 ReadAction: mrHref(d.name(), n, "read"), Head: head, HeadGone: head == "",
676773 CanMarkRead: head != "" && head != lastRead,
677774 CanDeleteBranch: source != d.repo.DefaultBranch,
775+ Mergeability: mg,
776+ }
777+ var lines []string
778+ cmd := ""
779+ if head != "" {
780+ cmd = "ermite mr merge " + shellWord(d.name()) + " " + strconv.FormatInt(n, 10) + " --head " + head
678781 }
679- ff, sq := false, false
680782 for _, mode := range d.repo.Settings.AllowedMergeModes {
681783 switch mode {
682784 case domain.MergeModeFastForward:
683- ff = true
684- b.Modes = append(b.Modes, ModeOption{Value: "fast-forward", Label: "fast-forward"})
785+ b.Modes = append(b.Modes, ModeOption{Value: "fast-forward", Label: "fast-forward", Disabled: !mg.FFOk, Reason: reasonIf(!mg.FFOk, mg.FFReason)})
786+ lines = append(lines, cmd)
685787 case domain.MergeModeSquash:
686- sq = true
687- b.Modes = append(b.Modes, ModeOption{Value: "squash", Label: "squash (one signed commit)"})
788+ b.Signed = true
789+ b.Modes = append(b.Modes, ModeOption{Value: "squash", Label: "squash (one signed commit)", Disabled: !mg.MergeOk, Reason: reasonIf(!mg.MergeOk, mg.MergeReason)})
790+ lines = append(lines, cmd+" --mode squash")
791+ case domain.MergeModeMerge:
792+ b.Signed = true
793+ b.Modes = append(b.Modes, ModeOption{Value: "merge", Label: "merge commit (signed)", Disabled: !mg.MergeOk, Reason: reasonIf(!mg.MergeOk, mg.MergeReason)})
794+ lines = append(lines, cmd+" --mode merge")
688795 }
689796 }
797+ if b.Signed && s.cfg.SigningKey != nil {
798+ b.Fingerprint = s.cfg.SigningKey.Info().Key.Fingerprint
799+ }
690800 if head != "" {
691- cmd := "ermite mr merge " + shellWord(d.name()) + " " + strconv.FormatInt(n, 10) + " --head " + head
692- b.CLI = Clone{ID: "cli-merge"}
693- if ff {
694- b.CLI.Lines = append(b.CLI.Lines, cmd)
695- }
696- if sq {
697- b.CLI.Lines = append(b.CLI.Lines, cmd+" --squash")
698- }
801+ b.CLI = Clone{ID: "cli-merge", Lines: lines}
699802 }
700803 return b
701804 }
702805
806+func reasonIf(cond bool, reason string) string {
807+ if cond {
808+ return reason
809+ }
810+ return ""
811+}
812+
813+// firstModeOK is the index of the first mode that is possible, -1 for none.
814+func (b *MergeBox) firstModeOK() int {
815+ for i, m := range b.Modes {
816+ if !m.Disabled {
817+ return i
818+ }
819+ }
820+ return -1
821+}
822+
823+// Checked is the mode the form has checked: the first that is possible, else
824+// the first (it is disabled, and a live update may enable it).
825+func (b *MergeBox) Checked() int { return max(b.firstModeOK(), 0) }
826+
827+// CanMerge tells whether some mode is possible: the button is enabled.
828+func (b *MergeBox) CanMerge() bool { return b.firstModeOK() >= 0 }
829+
830+// MergeTitle is why the button is disabled, empty when it is not.
831+func (b *MergeBox) MergeTitle() string {
832+ if b.CanMerge() {
833+ return ""
834+ }
835+ if len(b.Modes) == 0 {
836+ return "the repository allows no merge mode"
837+ }
838+ return b.Modes[0].Reason
839+}
840+
703841 // shellWord quotes s for a shell unless it is made of safe characters, which
704842 // a repository name always is.
705843 func shellWord(s string) string {
M internal/adapters/web/mr.templ +92 -4
@@ -2,7 +2,10 @@
22
33 package web
44
5-import "strings"
5+import (
6+ "strconv"
7+ "strings"
8+)
69
710 // MRList is the list of the merge requests of a repository: the open ones,
811 // then the merged and closed ones.
@@ -138,6 +141,12 @@ templ MergeRequest(l Layout, p MRPage) {
138141 }
139142 </dl>
140143 </header>
144+ if p.Deleting != "" {
145+ <p class="note" role="status">merged. the branch { p.Deleting } is being deleted.</p>
146+ }
147+ if p.MergeError != "" {
148+ <p class="d-err" role="alert">{ p.MergeError }</p>
149+ }
141150 @LiveSlot("mr-notice")
142151 if p.Desc != "" {
143152 <article class="mr-desc prose" aria-label="description" hx-ignore>
@@ -176,6 +185,9 @@ templ MergeRequest(l Layout, p MRPage) {
176185 <div
177186 id="diffs"
178187 data-mr
188+ if p.Canonical != "" {
189+ data-canonical={ p.Canonical }
190+ }
179191 if p.Both {
180192 data-diff={ p.View }
181193 } else {
@@ -446,8 +458,13 @@ templ mrBar(p MRPage) {
446458 // mergeMenu is the merge of an open request, for the owner: the form (it
447459 // carries the head that was read), or "Ready for review" for a draft. The
448460 // mode and the branch deletion have keys (1-3, D) while it is open.
461+//
462+// The badge is a live slot (mr-mergeability); the modes and the button read
463+// its data-* attributes (hx-live bindings), so that they follow the slot when
464+// the stream replaces its content. The server renders the same state in the
465+// attributes, for a page without scripts.
449466 templ mergeMenu(p MRPage) {
450- <details class="menu mmenu" id="merge-menu" data-state="open">
467+ <details class="menu mmenu" id="merge-menu" data-state="open" open?={ p.Box.Error != "" }>
451468 <summary class="btn primary" aria-keyshortcuts="Shift+M">
452469 if p.Box.ReadyAction != "" {
453470 Draft
@@ -469,19 +486,32 @@ templ mergeMenu(p MRPage) {
469486 <dt>into</dt>
470487 <dd>{ p.Target }</dd>
471488 </dl>
489+ if p.Box.Error != "" {
490+ <p class="d-err" role="alert">{ p.Box.Error }</p>
491+ }
472492 if p.Box.ReadyAction != "" {
473493 <p class="d-draft">this is a draft: it cannot be merged until it is ready for review.</p>
474494 <form class="d-btns" method="post" action={ templ.SafeURL(p.Box.ReadyAction) }>
475495 <button type="submit" class="btn primary" data-default title="remove the draft prefix from the title">Ready for review</button>
476496 </form>
477497 } else if !p.Box.HeadGone {
498+ @mrMergeabilitySlot(p.Box.Mergeability)
478499 <form class="d-acts" method="post" action={ templ.SafeURL(p.Box.MergeAction) }>
479500 <input type="hidden" name="head_id" value={ p.Box.Head }/>
480501 <fieldset class="d-modes">
481502 <legend class="sr">merge mode</legend>
482503 for i, m := range p.Box.Modes {
483504 <label>
484- <input type="radio" name="mode" value={ m.Value } checked?={ i == 0 }/>
505+ <input
506+ type="radio"
507+ name="mode"
508+ value={ m.Value }
509+ checked?={ i == p.Box.Checked() }
510+ disabled?={ m.Disabled }
511+ title={ m.Reason }
512+ :disabled="this.value === 'fast-forward' ? q('[data-mergeability]').data.ffOk !== true : q('[data-mergeability]').data.mergeOk !== true"
513+ :title="this.value === 'fast-forward' ? q('[data-mergeability]').data.ffReason : q('[data-mergeability]').data.mergeReason"
514+ />
485515 { m.Label }
486516 if i < 9 {
487517 <kbd aria-hidden="true">{ itoa(i + 1) }</kbd>
@@ -489,11 +519,28 @@ templ mergeMenu(p MRPage) {
489519 </label>
490520 }
491521 </fieldset>
522+ if p.Box.Signed {
523+ <p class="d-note" id="mr-signing">
524+ squash and merge commits are signed with ermite's own key (generated on first start).
525+ if p.Box.Fingerprint != "" {
526+ fingerprint <code>{ p.Box.Fingerprint }</code>
527+ }
528+ <code>ermite signing-key show</code> prints it.
529+ </p>
530+ }
492531 if p.Box.CanDeleteBranch {
493532 <label class="d-check"><input type="checkbox" name="delete_branch" value="1"/> delete { p.Source } after the merge <kbd aria-hidden="true">D</kbd></label>
494533 }
495534 <div class="d-btns">
496- <button type="submit" class="btn primary" data-default>Merge into { p.Target }</button>
535+ <button
536+ type="submit"
537+ class="btn primary"
538+ data-default
539+ disabled?={ !p.Box.CanMerge() }
540+ title={ p.Box.MergeTitle() }
541+ :disabled="!this.form.querySelector('input[name=mode]:checked:not(:disabled)')"
542+ :title="(this.form.querySelector('input[name=mode]:checked') || {}).title || ''"
543+ >Merge into { p.Target }</button>
497544 </div>
498545 </form>
499546 }
@@ -501,6 +548,47 @@ templ mergeMenu(p MRPage) {
501548 </details>
502549 }
503550
551+// MRMergeability is the content of the mergeability slot: the badge, and the
552+// state the controls of the merge box read.
553+templ MRMergeability(v MergeabilityView) {
554+ <span
555+ data-mergeability
556+ data-state={ v.State }
557+ data-ff-ok={ strconv.FormatBool(v.FFOk) }
558+ data-merge-ok={ strconv.FormatBool(v.MergeOk) }
559+ data-ff-reason={ v.FFReason }
560+ data-merge-reason={ v.MergeReason }
561+ >
562+ <span class="badge" data-state={ v.State }>{ v.Label }</span>
563+ if v.Detail != "" {
564+ { " " }<span class="dim">{ v.Detail }</span>
565+ }
566+ </span>
567+ if len(v.Paths) > 0 {
568+ <ul class="mg-paths" aria-label="conflicting files">
569+ for _, path := range v.Paths {
570+ <li><code>{ path }</code></li>
571+ }
572+ </ul>
573+ }
574+ if v.PathsNote != "" {
575+ <p class="dim">{ v.PathsNote }</p>
576+ }
577+}
578+
579+// mrMergeabilitySlot is the status region of the badge, with its content: the
580+// stream replaces the children, never the element (see LiveSlot).
581+templ mrMergeabilitySlot(v MergeabilityView) {
582+ <div id="mr-mergeability" class="live-slot" role="status" data-slot>
583+ @MRMergeability(v)
584+ </div>
585+}
586+
587+// MRMergeabilityFragment is the slot as the stream sends it.
588+templ MRMergeabilityFragment(v MergeabilityView) {
589+ @mrMergeabilitySlot(v)
590+}
591+
504592 // moreMenu is what else the owner does with an open request: mark the head
505593 // read, close it, merge it from a terminal.
506594 templ moreMenu(p MRPage) {
M internal/adapters/web/mr_post.go +55 -3
@@ -60,6 +60,10 @@ func (s *Server) mrAction(h func(ctx context.Context, r *http.Request) (to strin
6060 return
6161 }
6262 to, back, err := h(ctx, r)
63+ var re *rerender
64+ if errors.As(err, &re) && s.rerenderMR(ctx, w, r, re) {
65+ return
66+ }
6367 if err != nil {
6468 s.actionFailed(ctx, w, r, err, back)
6569 return
@@ -68,6 +72,43 @@ func (s *Server) mrAction(h func(ctx context.Context, r *http.Request) (to strin
6872 }
6973 }
7074
75+// rerender is the error of a merge the use case refused for a reason the
76+// owner reads on the page of the request: the page is shown again, with the
77+// status, and the reason in its merge box. Any other error keeps actionFailed.
78+type rerender struct {
79+ status int
80+ msg string
81+ err error
82+}
83+
84+func (e *rerender) Error() string { return e.err.Error() }
85+func (e *rerender) Unwrap() error { return e.err }
86+
87+type mergeErrorKey struct{}
88+
89+// withMergeError carries the reason of a refused merge to the page.
90+func withMergeError(ctx context.Context, msg string) context.Context {
91+ return context.WithValue(ctx, mergeErrorKey{}, msg)
92+}
93+
94+func mergeErrorOf(ctx context.Context) string {
95+ msg, _ := ctx.Value(mergeErrorKey{}).(string)
96+ return msg
97+}
98+
99+// rerenderMR writes the page of the request with the reason of the refusal.
100+// It reports false when the page cannot be built; the caller then answers as
101+// for any refused action.
102+func (s *Server) rerenderMR(ctx context.Context, w http.ResponseWriter, r *http.Request, re *rerender) bool {
103+ c, _, err := s.mrPage(withMergeError(ctx, re.msg), r, false)
104+ if err != nil {
105+ return false
106+ }
107+ w.Header().Set("Cache-Control", "no-store")
108+ s.write(ctx, w, r, re.status, c)
109+ return true
110+}
111+
71112 // actionFailed answers a refused action.
72113 func (s *Server) actionFailed(ctx context.Context, w http.ResponseWriter, r *http.Request, err error, back string) {
73114 // The words of the use case, for the rules the owner can fix.
@@ -322,8 +363,11 @@ func (s *Server) mrMerge(ctx context.Context, r *http.Request) (string, string,
322363 mode = domain.MergeModeFastForward
323364 case "squash":
324365 mode = domain.MergeModeSquash
366+ case "merge":
367+ mode = domain.MergeModeMerge
325368 default:
326- return "", back, formError("mode must be fast-forward or squash")
369+ err := formError("mode must be fast-forward, squash or merge")
370+ return "", back, &rerender{status: http.StatusBadRequest, msg: err.Error(), err: err}
327371 }
328372 name, err := domain.ParseRepoName(repo)
329373 if err != nil {
@@ -333,11 +377,19 @@ func (s *Server) mrMerge(ctx context.Context, r *http.Request) (string, string,
333377 if err != nil {
334378 return "", back, err
335379 }
336- if _, err := s.cfg.MergeRequests.Merge(ctx, principal(ctx), name, n, ports.MergeParams{
380+ res, err := s.cfg.MergeRequests.Merge(ctx, principal(ctx), name, n, ports.MergeParams{
337381 Mode: mode, HeadID: head, DeleteSource: r.PostFormValue("delete_branch") == "1",
338- }); err != nil {
382+ })
383+ switch {
384+ case errors.Is(err, domain.ErrPreconditionFailed), errors.Is(err, domain.ErrConflict):
385+ return "", back, &rerender{status: http.StatusConflict, msg: rule(err.Error(), "the merge request is not in a state that allows this."), err: err}
386+ case err != nil:
339387 return "", back, err
340388 }
389+ if res.SourceDeleted {
390+ // The deletion follows the answer: the page says it is being done.
391+ return back + "?deleting=1", back, nil
392+ }
341393 return back, back, nil
342394 }
343395
M internal/adapters/web/mr_templ.go +1188 -740 generated file: collapsed
A internal/adapters/web/mr_test.go +130 -0
@@ -0,0 +1,130 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "context"
7+ "testing"
8+
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/mock"
11+ "github.com/stretchr/testify/require"
12+
13+ "bnema.dev/ermite/internal/domain"
14+ "bnema.dev/ermite/internal/ports"
15+ "bnema.dev/ermite/internal/ports/mocks"
16+)
17+
18+func repoWithModes(modes ...domain.MergeMode) ports.RepoView {
19+ var v ports.RepoView
20+ v.Settings.AllowedMergeModes = modes
21+ return v
22+}
23+
24+func watchOf(ev domain.Event) ports.WatchEvent { return ports.WatchEvent{Event: ev} }
25+
26+func TestMergeabilityView(t *testing.T) {
27+ t.Parallel()
28+ for _, tt := range []struct {
29+ name string
30+ m domain.Mergeability
31+ label, detail string
32+ ffOK, mergeOK bool
33+ note string
34+ paths []string
35+ }{
36+ {"up to date", domain.Mergeability{State: domain.MergeabilityUpToDate}, "Ready to merge", "", true, true, "", nil},
37+ {"behind", domain.Mergeability{State: domain.MergeabilityBehind, Behind: 2}, "Behind main by 2 commits", "merges without conflicts", false, true, "", nil},
38+ {"behind by one", domain.Mergeability{State: domain.MergeabilityBehind, Behind: 1}, "Behind main by 1 commit", "merges without conflicts", false, true, "", nil},
39+ {"behind, capped", domain.Mergeability{State: domain.MergeabilityBehind, Behind: 1000, BehindCapped: true}, "Behind main by 1000+ commits", "merges without conflicts", false, true, "", nil},
40+ {"conflict", domain.Mergeability{State: domain.MergeabilityConflict, ConflictPaths: []string{"a.go"}}, "Conflicts with main", "rebase needed", false, false, "", []string{"a.go"}},
41+ {"conflict, truncated", domain.Mergeability{State: domain.MergeabilityConflict, ConflictPaths: []string{"a.go"}, ConflictPathsTruncated: true}, "Conflicts with main", "rebase needed", false, false, "more files conflict than are listed.", []string{"a.go"}},
42+ {"no common history", domain.Mergeability{State: domain.MergeabilityConflict}, "Conflicts with main", "rebase needed", false, false, "no common history.", nil},
43+ {"checking", domain.Mergeability{State: domain.MergeabilityChecking}, "Checking…", "", false, false, "", nil},
44+ {"none", domain.Mergeability{}, "Checking…", "", false, false, "", nil},
45+ } {
46+ t.Run(tt.name, func(t *testing.T) {
47+ t.Parallel()
48+ v := mergeabilityView(tt.m, "main")
49+ assert.Equal(t, tt.label, v.Label)
50+ assert.Equal(t, tt.detail, v.Detail)
51+ assert.Equal(t, tt.ffOK, v.FFOk)
52+ assert.Equal(t, tt.mergeOK, v.MergeOk)
53+ assert.Equal(t, tt.note, v.PathsNote)
54+ assert.Equal(t, tt.paths, v.Paths)
55+ assert.Equal(t, !tt.ffOK, v.FFReason != "", "a mode that is not possible says why")
56+ assert.Equal(t, !tt.mergeOK, v.MergeReason != "")
57+ })
58+ }
59+}
60+
61+func TestMergeBoxModes(t *testing.T) {
62+ t.Parallel()
63+ d := &repoData{repo: repoWithModes(domain.MergeModes()...)}
64+ s := &Server{}
65+
66+ behind := s.mergeBox(d, 1, "feat", "abc", "", mergeabilityView(domain.Mergeability{State: domain.MergeabilityBehind, Behind: 1}, "main"))
67+ require.Len(t, behind.Modes, 3)
68+ assert.True(t, behind.Modes[0].Disabled, "fast-forward is not possible when behind")
69+ assert.NotEmpty(t, behind.Modes[0].Reason)
70+ assert.False(t, behind.Modes[1].Disabled)
71+ assert.False(t, behind.Modes[2].Disabled)
72+ assert.Equal(t, 1, behind.Checked(), "the first possible mode is checked")
73+ assert.True(t, behind.CanMerge())
74+ assert.True(t, behind.Signed)
75+
76+ conflict := s.mergeBox(d, 1, "feat", "abc", "", mergeabilityView(domain.Mergeability{State: domain.MergeabilityConflict}, "main"))
77+ assert.False(t, conflict.CanMerge())
78+ assert.NotEmpty(t, conflict.MergeTitle(), "the button says why it is disabled")
79+ assert.Equal(t, 0, conflict.Checked())
80+ assert.Contains(t, conflict.CLI.Lines[2], "--mode merge")
81+ assert.NotContains(t, conflict.CLI.Lines[1], "--squash")
82+}
83+
84+func TestMergeBoxOnlyFastForwardIsNotSigned(t *testing.T) {
85+ t.Parallel()
86+ d := &repoData{repo: repoWithModes(domain.MergeModeFastForward)}
87+ b := (&Server{}).mergeBox(d, 1, "feat", "abc", "", mergeabilityView(domain.Mergeability{State: domain.MergeabilityUpToDate}, "main"))
88+ assert.False(t, b.Signed, "no commit is created, nothing to explain")
89+ assert.Len(t, b.Modes, 1)
90+}
91+
92+func TestMergeBoxNamesTheSigningKey(t *testing.T) {
93+ t.Parallel()
94+ key := mocks.NewSigningKeyService(t)
95+ key.EXPECT().Info().Return(domain.SigningKeyInfo{Key: domain.SigningPublicKey{Fingerprint: "SHA256:abc"}})
96+ d := &repoData{repo: repoWithModes(domain.MergeModes()...)}
97+ b := (&Server{cfg: Config{SigningKey: key}}).mergeBox(d, 1, "feat", "abc", "", mergeabilityView(domain.Mergeability{}, "main"))
98+ assert.Equal(t, "SHA256:abc", b.Fingerprint)
99+}
100+
101+func TestMergeErrorTravelsInTheContext(t *testing.T) {
102+ t.Parallel()
103+ assert.Empty(t, mergeErrorOf(context.Background()))
104+ assert.Equal(t, "boom", mergeErrorOf(withMergeError(context.Background(), "boom")))
105+}
106+
107+func TestMergeabilityLiveEvents(t *testing.T) {
108+ t.Parallel()
109+ assert.True(t, liveEvent(watchOf(domain.MergeRequestMergeability{Number: 1})))
110+ svc := mocks.NewMergeRequestService(t)
111+ svc.EXPECT().Mergeability(mock.Anything, mock.Anything, mock.Anything, int64(1)).
112+ Return(domain.Mergeability{State: domain.MergeabilityBehind, Behind: 3}, nil)
113+ l := liveStream{s: &Server{cfg: Config{MergeRequests: svc}}, repo: "demo", view: "mr", n: "1", target: "main"}
114+ ctx := context.WithValue(context.Background(), signedInKey{}, struct{}{})
115+
116+ fs, err := l.repoPage(ctx, watchOf(domain.MergeRequestMergeability{Number: 1}))
117+ require.NoError(t, err)
118+ require.Len(t, fs, 1)
119+ assert.Equal(t, "mr-mergeability", fs[0].event)
120+ assert.Contains(t, fs[0].html, "Behind main by 3 commits")
121+
122+ // Another request, or a page that is not one: nothing.
123+ fs, err = l.repoPage(ctx, watchOf(domain.MergeRequestMergeability{Number: 2}))
124+ require.NoError(t, err)
125+ assert.Empty(t, fs)
126+ // The box is the owner's.
127+ fs, err = l.repoPage(context.Background(), watchOf(domain.MergeRequestMergeability{Number: 1}))
128+ require.NoError(t, err)
129+ assert.Empty(t, fs)
130+}
M internal/adapters/web/static/css/60-mr.css +22 -1
@@ -50,6 +50,12 @@
5050 & .kv { margin: 0; }
5151 }
5252 .mmenu {
53+ & .menu-pop { box-sizing: border-box; min-width: 0; overflow-wrap: anywhere; }
54+ & .menu-pop > *, & form, & .d-modes, & .d-btns { min-width: 0; max-width: 100%; box-sizing: border-box; }
55+ & .d-acts { display: grid; gap: var(--s3); }
56+ & .d-modes label, & .d-check { min-width: 0; }
57+ & code { overflow-wrap: anywhere; }
58+ & .d-btns .btn { min-width: 0; max-width: 100%; white-space: normal; }
5359 & .d-modes { display: grid; gap: var(--s1); border: 0; padding: 0; margin: 0; }
5460 & .d-modes label, & .d-check {
5561 display: flex; gap: var(--s2); align-items: center; padding: 4px var(--s2); margin: 0 calc(-1 * var(--s2));
@@ -63,7 +69,22 @@
6369 & .d-btns { display: flex; margin-top: var(--s1); }
6470 & .d-btns .btn { flex: 1; justify-content: center; }
6571 & .d-draft { color: var(--ink-dim); }
66- }
72+ & .d-note { margin: 0; color: var(--ink-dim); }
73+ & .d-err { margin: 0; padding: var(--s2) var(--s3); color: var(--ink-hi); border: 1px solid var(--red); border-left-width: 3px; border-radius: var(--r-s); overflow-wrap: anywhere; }
74+ /* the badge is a live slot, but not a banner */
75+ & #mr-mergeability:not(:empty) { padding: 0; font-weight: normal; background: none; border: 0; display: grid; gap: var(--s1); }
76+ & .mg-paths { margin: 0; padding-left: 2ch; max-height: 8em; overflow: auto; }
77+ & .mg-paths code { overflow-wrap: anywhere; }
78+ & .d-modes label:has(:disabled) { color: var(--ink-mute); cursor: not-allowed; }
79+ & .d-modes label:has(:disabled):hover { background: none; }
80+ & .btn:disabled { opacity: .5; cursor: not-allowed; }
81+ }
82+ .badge[data-state] { display: inline-block; padding: 0 var(--s2); border: 1px solid var(--border-strong); border-radius: var(--r-s); font-weight: 600; color: var(--ink-hi); }
83+ .badge[data-state="up_to_date"] { border-color: var(--teal); color: var(--teal); }
84+ .badge[data-state="behind"] { border-color: var(--teal-2); }
85+ .badge[data-state="conflict"] { border-color: var(--red); color: var(--red); }
86+ .badge[data-state="checking"] { color: var(--ink-dim); border-style: dashed; }
87+ .note[role="status"] { color: var(--ink-dim); }
6788 .omenu {
6889 & > summary { font-size: 18px; line-height: 1; }
6990 & .menu-pop { width: 280px; }
M internal/adapters/web/static/js/mr.js +10 -1
@@ -219,7 +219,12 @@ window.ermiteMR = (() => {
219219 const box = $('input[name="delete_branch"]', m);
220220 if (box) box.checked = !box.checked;
221221 } else {
222- radios[act.mode].checked = true;
222+ const r = radios[act.mode];
223+ // A change event lets the live bindings enable the Merge button again.
224+ if (!r.disabled && !r.checked) {
225+ r.checked = true;
226+ r.dispatchEvent(new Event('change', { bubbles: true }));
227+ }
223228 }
224229 $('[data-default]', m)?.focus();
225230 return true;
@@ -291,6 +296,10 @@ window.ermiteMR = (() => {
291296 let folded = false;
292297 try { folded = localStorage.getItem('mr-files-folded') === '1'; } catch { /* no storage: open */ }
293298 if (folded) foldFiles(true);
299+ // The page answers a refused action from the address of the action: show
300+ // the address of the request, so that a reload does not post again.
301+ const canonical = $('#diffs[data-canonical]')?.dataset.canonical;
302+ if (canonical && location.pathname !== canonical) history.replaceState(history.state, '', canonical + location.hash);
294303 watch();
295304 // A link to a file or a comment that is not loaded yet.
296305 const hash = () => {
M internal/adapters/web/web.go +4 -0
@@ -76,6 +76,10 @@ type Config struct {
7676 // leads to it. Without it there is neither. Leave it unset rather than
7777 // assigning a typed nil pointer.
7878 Inbox ports.InboxService
79+ // SigningKey tells the key that signs the squash and merge commits, shown
80+ // in the merge box. Without it the box does not name the key. Leave it
81+ // unset rather than assigning a typed nil pointer.
82+ SigningKey ports.SigningKeyService
7983 // Runs serves the CI pages (/<repo>/actions) and the checks shown next to
8084 // commits and merge requests. Without it there are no such pages. Leave it
8185 // unset rather than assigning a typed nil pointer.
M internal/app/api_helpers_test.go +14 -1
@@ -167,8 +167,21 @@ func (h *harness) watch(c *clients, resume string, topics ...string) *stream {
167167 return s
168168 }
169169
170-// next returns the next event or fails after d.
170+// next returns the next event or fails after d. Mergeability events, which
171+// arrive asynchronously after a push or an opening, are skipped: use
172+// nextAny to see them.
171173 func (s *stream) next(t *testing.T, d time.Duration) *pb.Event {
174+ t.Helper()
175+ for {
176+ ev := s.nextAny(t, d)
177+ if ev.GetMergeRequestMergeability() == nil {
178+ return ev
179+ }
180+ }
181+}
182+
183+// nextAny returns the next event, whatever its kind, or fails after d.
184+func (s *stream) nextAny(t *testing.T, d time.Duration) *pb.Event {
172185 t.Helper()
173186 select {
174187 case ev, ok := <-s.events:
M internal/app/api_test.go +1 -1
@@ -196,7 +196,7 @@ func TestAC2ReadAccess(t *testing.T) {
196196 for _, who := range allPrincipals {
197197 resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{}))
198198 require.NoError(t, err, who)
199- assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures"}, resp.Msg.GetFeatures(), who)
199+ assert.ElementsMatch(t, []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.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures"}, resp.Msg.GetFeatures(), who)
200200 assert.NotEmpty(t, resp.Msg.GetVersion())
201201 }
202202 })
M internal/app/ci_schedule_trust_test.go +6 -2
@@ -3,6 +3,7 @@
33 package app
44
55 import (
6+ "errors"
67 "strings"
78 "testing"
89 "time"
@@ -230,8 +231,11 @@ func TestMergeRecordsTheTargetAndDropsTheSourceRecord(t *testing.T) {
230231 }))
231232 require.NoError(t, err)
232233
233- _, err = tips.GetRefTip(c.ctx, c.name("proj"), "refs/heads/feat")
234- assert.ErrorIs(t, err, domain.ErrNotFound, "the deleted source branch has no record")
234+ // The deletion and the removal of the record run after the merge returned.
235+ c.waitFor("the deleted source branch has no record", func() bool {
236+ _, err := tips.GetRefTip(c.ctx, c.name("proj"), "refs/heads/feat")
237+ return errors.Is(err, domain.ErrNotFound)
238+ })
235239 main, err := tips.GetRefTip(c.ctx, c.name("proj"), "refs/heads/main")
236240 require.NoError(t, err)
237241 assert.Equal(t, c.ref("refs/heads/main"), main.Sha, "the merge recorded the new tip")
M internal/app/cli_e2e_test.go +14 -2
@@ -123,8 +123,20 @@ func (c *cliHarness) startWatch(t *testing.T, secret string, args ...string) *wa
123123 return w
124124 }
125125
126-// next decodes the next event line or fails after d.
126+// next returns the next event line or fails after d. Mergeability events,
127+// which arrive asynchronously after a push or an opening, are skipped.
127128 func (w *watchProc) next(t *testing.T, d time.Duration) (raw string) {
129+ t.Helper()
130+ for {
131+ l := w.nextAny(t, d)
132+ if !strings.Contains(l, `"type":"`+eventMRMergeability+`"`) {
133+ return l
134+ }
135+ }
136+}
137+
138+// nextAny returns the next event line, whatever its kind, or fails after d.
139+func (w *watchProc) nextAny(t *testing.T, d time.Duration) (raw string) {
128140 t.Helper()
129141 select {
130142 case l, ok := <-w.lines:
@@ -338,7 +350,7 @@ func TestAC6CLIEndToEnd(t *testing.T) {
338350 ver := decode[versionOut](t, res.stdout)
339351 assert.Equal(t, c.url, ver.Server.URL)
340352 assert.NotEmpty(t, ver.Server.Version)
341- assert.ElementsMatch(t, []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.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures"}, ver.Server.Features)
353+ assert.ElementsMatch(t, []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.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures"}, ver.Server.Features)
342354 assert.Equal(t, []string{"passkeys"}, ver.Server.MissingFeatures)
343355 stdout, stderr, code = c.bin(t, secret, "version")
344356 require.Equal(t, 0, code, stderr)
M internal/app/cli_mr.go +150 -24
@@ -8,6 +8,7 @@ import (
88 "fmt"
99 "io"
1010 "net/url"
11+ "slices"
1112 "strconv"
1213 "strings"
1314
@@ -31,6 +32,7 @@ const (
3132
3233 modeFastForward = "fast-forward"
3334 modeSquash = "squash"
35+ modeMerge = "merge"
3436 )
3537
3638 func stateName(s ermite.MergeRequestState) string {
@@ -65,6 +67,8 @@ func modeName(m ermite.MergeMode) string {
6567 return modeFastForward
6668 case ermite.MergeMode_MERGE_MODE_SQUASH:
6769 return modeSquash
70+ case ermite.MergeMode_MERGE_MODE_MERGE_COMMIT:
71+ return modeMerge
6872 }
6973 return ""
7074 }
@@ -75,8 +79,10 @@ func parseMode(s string) (ermite.MergeMode, error) {
7579 return ermite.MergeMode_MERGE_MODE_FAST_FORWARD, nil
7680 case modeSquash:
7781 return ermite.MergeMode_MERGE_MODE_SQUASH, nil
82+ case modeMerge:
83+ return ermite.MergeMode_MERGE_MODE_MERGE_COMMIT, nil
7884 }
79- return 0, fmt.Errorf("invalid merge mode %q: use %s or %s", s, modeFastForward, modeSquash)
85+ return 0, fmt.Errorf("invalid merge mode %q: use %s, %s or %s", s, modeFastForward, modeSquash, modeMerge)
8086 }
8187
8288 func sideName(s ermite.DiffSide) string {
@@ -117,6 +123,71 @@ type jsonMergeRequest struct {
117123 MergeMode string `json:"merge_mode"`
118124 }
119125
126+// jsonMergeability is the stable JSON shape of the mergeability of an open
127+// merge request.
128+type jsonMergeability struct {
129+ // State is "checking", "up_to_date", "behind" or "conflict".
130+ State string `json:"state"`
131+ // Behind counts the commits of the target the head lacks (behind only);
132+ // BehindCapped says it is a lower bound.
133+ Behind int32 `json:"behind"`
134+ BehindCapped bool `json:"behind_capped"`
135+ ConflictPaths []string `json:"conflict_paths"`
136+ // ConflictPathsTruncated says that more paths conflict than listed.
137+ ConflictPathsTruncated bool `json:"conflict_paths_truncated"`
138+ TargetID string `json:"target_id"`
139+ HeadID string `json:"head_id"`
140+}
141+
142+func mergeabilityName(s ermite.MergeabilityState) string {
143+ switch s {
144+ case ermite.MergeabilityState_MERGEABILITY_STATE_CHECKING:
145+ return "checking"
146+ case ermite.MergeabilityState_MERGEABILITY_STATE_UP_TO_DATE:
147+ return "up_to_date"
148+ case ermite.MergeabilityState_MERGEABILITY_STATE_BEHIND:
149+ return "behind"
150+ case ermite.MergeabilityState_MERGEABILITY_STATE_CONFLICT:
151+ return "conflict"
152+ }
153+ return ""
154+}
155+
156+// toJSONMergeability maps a mergeability; nil when the server sent none.
157+func toJSONMergeability(m *ermite.Mergeability) *jsonMergeability {
158+ if m == nil {
159+ return nil
160+ }
161+ return &jsonMergeability{
162+ State: mergeabilityName(m.GetState()), Behind: m.GetBehind(), BehindCapped: m.GetBehindCapped(),
163+ ConflictPaths: append([]string{}, m.GetConflictPaths()...), ConflictPathsTruncated: m.GetConflictPathsTruncated(),
164+ TargetID: m.GetTargetId(), HeadID: m.GetHeadId(),
165+ }
166+}
167+
168+// mergeabilityText is the human form of a mergeability: "up to date",
169+// "behind by 2", "conflict (a, b, ...)", "checking".
170+func mergeabilityText(m *jsonMergeability) string {
171+ switch m.State {
172+ case "up_to_date":
173+ return "up to date with the target, can be fast-forwarded"
174+ case "behind":
175+ return fmt.Sprintf("behind by %d%s, merges without conflicts (squash or merge commit; fast-forward needs a rebase)", m.Behind, plus(m.BehindCapped))
176+ case "conflict":
177+ if len(m.ConflictPaths) == 0 {
178+ return "conflict (no common history with the target), rebase needed"
179+ }
180+ more := ""
181+ if m.ConflictPathsTruncated {
182+ more = ", ..."
183+ }
184+ return "conflict (" + strings.Join(m.ConflictPaths, ", ") + more + "), rebase needed"
185+ case "checking":
186+ return "checking"
187+ }
188+ return m.State
189+}
190+
120191 // jsonMerged is the output of `mr merge --json`.
121192 type jsonMerged struct {
122193 jsonMergeRequest
@@ -173,9 +244,15 @@ const jsonMRHelp = `
173244 JSON fields of a merge request (--json): repo, url (full web URL), number, title, description, source_branch,
174245 target_branch, state ("open", "merged" or "closed"), author (token label), created_at and
175246 updated_at (RFC 3339 UTC), head_id (the commit the source branch points to; empty when the
176-branch is gone), merged_commit_id and merge_mode ("fast-forward" or "squash"; both empty
247+branch is gone), merged_commit_id and merge_mode ("fast-forward", "squash" or "merge"; both empty
177248 unless merged).`
178249
250+const jsonMergeabilityHelp = `
251+mr show --json adds mergeability, null unless the request is open: state ("checking", "up_to_date",
252+"behind" or "conflict"), behind and behind_capped (commits of the target the head lacks; behind
253+only), conflict_paths and conflict_paths_truncated (conflict only), target_id and head_id (the
254+commits the result is for).`
255+
179256 const jsonCommentHelp = `
180257 JSON fields of a comment: id, author, path, line and side ("old" or "new"; empty path, line 0
181258 and empty side for a general comment), commit_id (the head when it was written), text,
@@ -412,6 +489,8 @@ type jsonCommit struct {
412489 // jsonMRDetail is the stable JSON shape of `mr show --json`.
413490 type jsonMRDetail struct {
414491 jsonMergeRequest
492+ // Mergeability is null unless the request is open and the server computes it.
493+ Mergeability *jsonMergeability `json:"mergeability"`
415494 Commits []jsonCommit `json:"commits"`
416495 CommitsTruncated bool `json:"commits_truncated"`
417496 DiffBaseID string `json:"diff_base_id"`
@@ -444,6 +523,7 @@ func lineKindName(k ermite.LineKind) string {
444523 func toJSONDetail(d *ermite.GetMergeRequestResponse, withDiff bool, baseURL string) jsonMRDetail {
445524 out := jsonMRDetail{
446525 jsonMergeRequest: toJSONMergeRequest(d.GetMergeRequest(), baseURL),
526+ Mergeability: toJSONMergeability(d.GetMergeRequest().GetMergeability()),
447527 Commits: []jsonCommit{}, CommitsTruncated: d.GetCommitsTruncated(),
448528 DiffBaseID: d.GetDiffBaseId(),
449529 Stats: jsonStats{
@@ -496,7 +576,9 @@ func newMRShowCmd(env *cliEnv) *cobra.Command {
496576 "--since-last-read shows the diff since the head recorded by --mark-read (or by the web) instead of\n" +
497577 "the whole change; after a force-push it still works when the recorded commit exists, else the\n" +
498578 "whole change is shown and the output says so. --mark-read records the head shown as read; it needs\n" +
499- "the mr:write scope." + jsonMRHelp + jsonCommentHelp + "\n" +
579+ "the mr:write scope. The mergeability line says whether the branch merges with its target now: up to\n" +
580+ "date (can be fast-forwarded), behind by N commits (merges without conflicts, squash or merge commit),\n" +
581+ "or in conflict with the paths that conflict (rebase needed)." + jsonMRHelp + jsonMergeabilityHelp + jsonCommentHelp + "\n" +
500582 "--json adds to the merge request fields: commits (id, author, summary), commits_truncated,\n" +
501583 "diff_base_id (the commit the diff starts from), stats (files_changed, additions, deletions),\n" +
502584 "diff_truncated, files (old_path, new_path, status, additions, deletions, binary, truncated and,\n" +
@@ -569,6 +651,9 @@ func printMRDetail(w io.Writer, d *ermite.GetMergeRequestResponse, withDiff, sin
569651 if m.GetState() == ermite.MergeRequestState_MERGE_REQUEST_STATE_MERGED {
570652 pairs = append(pairs, [2]string{"merged", orDash(m.GetMergedCommitId()) + " (" + modeName(m.GetMergeMode()) + ")"})
571653 }
654+ if mg := toJSONMergeability(m.GetMergeability()); mg != nil && m.GetState() == ermite.MergeRequestState_MERGE_REQUEST_STATE_OPEN {
655+ pairs = append(pairs, [2]string{"mergeability", mergeabilityText(mg)})
656+ }
572657 if len(d.GetVerdicts()) > 0 {
573658 var verdicts []string
574659 for _, v := range d.GetVerdicts() {
@@ -957,36 +1042,52 @@ func newMRResolveCmd(env *cliEnv) *cobra.Command {
9571042 }
9581043
9591044 func newMRMergeCmd(env *cliEnv) *cobra.Command {
960- var squash, deleteBranch bool
1045+ var mode string
1046+ var deleteBranch bool
9611047 var head string
9621048 cmd := &cobra.Command{
9631049 Use: "merge <repo> <number>",
9641050 Short: "Merge a merge request",
965- Long: "Merge an open merge request. The default is a fast-forward: the target branch moves to the head\n" +
966- "and no commit is created. --squash makes one new commit with the tree of the head and the target\n" +
967- "as only parent, signed by the server's key (see `ermite signing-key show`). The target must be an\n" +
968- "ancestor of the head, else rebase the branch and push it again; the repository settings may allow\n" +
969- "only one of the modes.\n\n" +
1051+ Long: "Merge an open merge request with --mode (default fast-forward; the repository settings may allow\n" +
1052+ "only some modes, see `ermite repo edit --merge-modes`):\n" +
1053+ " fast-forward the target branch moves to the head and no commit is created. The target must be\n" +
1054+ " an ancestor of the head, else rebase the branch and push it again, or use another mode.\n" +
1055+ " squash one new commit with the tree of the merge of the head and the target, the target tip\n" +
1056+ " as only parent. The branch must merge without conflicts.\n" +
1057+ " merge a merge commit with two parents, the target tip and the head. The branch must merge\n" +
1058+ " without conflicts.\n" +
1059+ "A squash and a merge commit are signed with the server's own key, generated automatically when the\n" +
1060+ "server first starts; `ermite signing-key show` prints it. The server never rebases. `ermite mr show`\n" +
1061+ "prints the mergeability (up to date, behind, conflict); the server checks again when merging and\n" +
1062+ "refuses a merge that is not possible, saying why.\n\n" +
9701063 "The server merges only if the source branch still points to the head you reviewed, so --head is\n" +
9711064 "required: give the full id of the head that `ermite mr show <repo> <number>` printed when you\n" +
9721065 "reviewed the change. If the branch moved since, the merge is refused: review again. Needs the\n" +
9731066 "mr:merge scope.\n\n" +
974- "--delete-branch deletes the source branch after the merge. The merge is refused when the branch\n" +
975- "is the default branch or used by another open merge request. If the deletion fails after the\n" +
976- "merge, the merge stands, the branch is kept and a warning goes to standard error." + jsonMRHelp +
977- "\n--json adds source_branch_deleted (true when the branch was deleted).",
1067+ "The merge answers as soon as the target branch has moved. --delete-branch asks the server to delete\n" +
1068+ "the source branch right after, in the background. The merge is refused when the branch is the\n" +
1069+ "default branch or used by another open merge request. If the deletion later fails (the branch\n" +
1070+ "moved, say), the merge stands and the branch is kept." + jsonMRHelp +
1071+ "\n--json adds source_branch_deleted (true when the deletion was requested: the server deletes the branch right after the merge).",
9781072 Args: cobra.ExactArgs(2),
9791073 ValidArgsFunction: completeMRRepoThenNumber(env),
9801074 }
981- cmd.Flags().BoolVar(&squash, "squash", false, "squash the branch into one signed commit instead of a fast-forward")
1075+ cmd.Flags().StringVar(&mode, "mode", modeFastForward, "merge mode: fast-forward, squash or merge (a merge commit)")
9821076 cmd.Flags().StringVar(&head, "head", "", "full id of the head you reviewed, from `ermite mr show` (required)")
9831077 cmd.Flags().BoolVar(&deleteBranch, "delete-branch", false, "delete the source branch after the merge")
1078+ _ = cmd.RegisterFlagCompletionFunc("mode", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
1079+ return []string{modeFastForward, modeSquash, modeMerge}, cobra.ShellCompDirectiveNoFileComp
1080+ })
9841081 asJSON := addJSONFlag(cmd)
9851082 cmd.RunE = func(cmd *cobra.Command, args []string) error {
9861083 n, err := parseNumber(args[1])
9871084 if err != nil {
9881085 return err
9891086 }
1087+ pbMode, err := parseMode(mode)
1088+ if err != nil {
1089+ return err
1090+ }
9901091 head = strings.TrimSpace(head)
9911092 if head == "" {
9921093 return fmt.Errorf("--head is required: run `ermite mr show %s %d` and give the head id it prints, "+
@@ -997,35 +1098,60 @@ func newMRMergeCmd(env *cliEnv) *cobra.Command {
9971098 return err
9981099 }
9991100 ctx := env.ctx(cmd)
1000- mode := ermite.MergeMode_MERGE_MODE_FAST_FORWARD
1001- if squash {
1002- mode = ermite.MergeMode_MERGE_MODE_SQUASH
1101+ if pbMode == ermite.MergeMode_MERGE_MODE_MERGE_COMMIT {
1102+ if err := requireFeature(ctx, c, client.FeatureMergeability, "has no merge commit mode (--mode merge)"); err != nil {
1103+ return err
1104+ }
10031105 }
10041106 res, err := c.MergeRequests().MergeMergeRequest(ctx, connect.NewRequest(&ermite.MergeMergeRequestRequest{
1005- Repo: args[0], Number: n, Mode: mode, HeadId: head, DeleteSourceBranch: deleteBranch,
1107+ Repo: args[0], Number: n, Mode: pbMode, HeadId: head, DeleteSourceBranch: deleteBranch,
10061108 }))
10071109 if err != nil {
10081110 return c.Describe(err)
10091111 }
10101112 m := res.Msg.GetMergeRequest()
10111113 deleted := res.Msg.GetSourceBranchDeleted()
1012- if deleteBranch && !deleted {
1013- _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "ermite: warning: the branch %s was kept (it moved after the merge, or the server cannot delete it)\n",
1014- clean(m.GetSourceBranch()))
1015- }
10161114 if *asJSON {
10171115 return writeJSON(cmd.OutOrStdout(), jsonMerged{jsonMergeRequest: toJSONMergeRequest(m, c.URL()), SourceBranchDeleted: deleted})
10181116 }
1019- _, err = fmt.Fprintf(cmd.OutOrStdout(), "merged %s!%d into %s (%s) at %s\n", clean(m.GetRepo()), m.GetNumber(),
1117+ out := cmd.OutOrStdout()
1118+ _, err = fmt.Fprintf(out, "merged %s!%d into %s (%s) at %s\n", clean(m.GetRepo()), m.GetNumber(),
10201119 clean(m.GetTargetBranch()), modeName(m.GetMergeMode()), shortID(m.GetMergedCommitId()))
1120+ if err == nil && pbMode != ermite.MergeMode_MERGE_MODE_FAST_FORWARD {
1121+ _, err = fmt.Fprintln(out, signedNote(ctx, c))
1122+ }
10211123 if err == nil && deleted {
1022- _, err = fmt.Fprintf(cmd.OutOrStdout(), "deleted branch %s\n", clean(m.GetSourceBranch()))
1124+ _, err = fmt.Fprintf(out, "deleting branch %s (done by the server after the merge)\n", clean(m.GetSourceBranch()))
10231125 }
10241126 return err
10251127 }
10261128 return cmd
10271129 }
10281130
1131+// signedNote says that a squash or merge commit is signed by the server's
1132+// key, with its fingerprint when the server tells it.
1133+func signedNote(ctx context.Context, c *client.Client) string {
1134+ const how = "; `ermite signing-key show` prints the key to verify it"
1135+ res, err := c.Server().GetSigningKey(ctx, connect.NewRequest(&ermite.GetSigningKeyRequest{}))
1136+ if err != nil || res.Msg.GetFingerprint() == "" {
1137+ return "the commit is signed by the server's key" + how
1138+ }
1139+ return "the commit is signed by the server's key " + clean(res.Msg.GetFingerprint()) + how
1140+}
1141+
1142+// requireFeature refuses to go on with a server that does not list the
1143+// feature. A server whose features cannot be read is refused too.
1144+func requireFeature(ctx context.Context, c *client.Client, feature, what string) error {
1145+ res, err := c.Server().GetVersion(ctx, connect.NewRequest(&ermite.GetVersionRequest{}))
1146+ if err != nil {
1147+ return c.Describe(err)
1148+ }
1149+ if !slices.Contains(res.Msg.GetFeatures(), feature) {
1150+ return fmt.Errorf("the server does not support %s, so it %s (upgrade the server, see `ermite version`)", feature, what)
1151+ }
1152+ return nil
1153+}
1154+
10291155 func newMRCloseCmd(env *cliEnv) *cobra.Command {
10301156 cmd := &cobra.Command{
10311157 Use: "close <repo> <number>",
M internal/app/cli_mr_e2e_test.go +6 -6
@@ -260,19 +260,19 @@ func TestMergeRequestsCLIEndToEnd(t *testing.T) {
260260 require.Error(t, res.err)
261261 assert.Contains(t, res.err.Error(), "precondition failed")
262262
263- // Squash, with --squash. Without --head the merge is refused before it reaches the server.
263+ // Squash, with --mode squash. Without --head the merge is refused before it reaches the server.
264264 c.mustGit(wt, "checkout", "-q", "-b", "feat/two", head3)
265265 c.mustGit(wt, "-c", "user.name=Cy", "-c", "user.email=cy@example.org", "commit", "-q", "--allow-empty", "-m", "empty by Cy")
266266 c.writeFile(wt, "two.txt", []byte("two\n"))
267267 c.mustGit(wt, "push", "-q", url, "feat/two")
268268 c.cli(t, secret, "mr", "create", "proj", "--source", "feat/two", "--title", "Second").ok(t)
269269 w.nextOfType(t, "merge_request_opened")
270- _, stderr, code = c.bin(t, secret, "mr", "merge", "proj", "2", "--squash")
270+ _, stderr, code = c.bin(t, secret, "mr", "merge", "proj", "2", "--mode", "squash")
271271 requireOneLineError(t, stderr, code, "--head is required", "ermite mr show proj 2")
272272 head4 := c.bareGit("proj", "rev-parse", "feat/two")
273- res = c.cli(t, secret, "mr", "merge", "proj", "2", "--squash", "--head", head4)
273+ res = c.cli(t, secret, "mr", "merge", "proj", "2", "--mode", "squash", "--head", head4)
274274 require.NoError(t, res.err, res.stderr)
275- assert.Regexp(t, `^merged proj!2 into main \(squash\) at [0-9a-f]{10}\n$`, res.stdout)
275+ assert.Regexp(t, `^merged proj!2 into main \(squash\) at [0-9a-f]{10}\nthe commit is signed by the server's key SHA256:\S+; .ermite signing-key show. prints the key to verify it\n$`, res.stdout)
276276 sq := c.bareGit("proj", "rev-parse", "main")
277277 assert.Equal(t, head3, c.bareGit("proj", "rev-parse", sq+"^"))
278278 assert.Equal(t, "Second (!2)", c.bareGit("proj", "log", "-1", "--format=%s", sq))
@@ -338,7 +338,7 @@ func TestSigningKeyShowVerifiesASquashCommit(t *testing.T) {
338338 c.writeFile(wt, "x.txt", []byte("x\n"))
339339 c.mustGit(wt, "push", "-q", url, "main", "feat")
340340 c.cli(t, adminToken, "mr", "create", "proj", "--source", "feat", "--title", "Add x").ok(t)
341- res := c.cli(t, adminToken, "mr", "merge", "proj", "1", "--squash", "--head", c.bareGit("proj", "rev-parse", "feat"))
341+ res := c.cli(t, adminToken, "mr", "merge", "proj", "1", "--mode", "squash", "--head", c.bareGit("proj", "rev-parse", "feat"))
342342 require.NoError(t, res.err, res.stderr)
343343 sq := c.bareGit("proj", "rev-parse", "main")
344344
@@ -381,7 +381,7 @@ func TestRepoEditMergeSettingsCLI(t *testing.T) {
381381 MergeModes []string `json:"merge_modes"`
382382 FailingChecksBlock bool `json:"failing_checks_block"`
383383 }](t, res.stdout)
384- assert.Equal(t, []string{"fast-forward", "squash"}, repo.MergeModes)
384+ assert.Equal(t, []string{"fast-forward", "squash", "merge"}, repo.MergeModes)
385385 assert.True(t, repo.FailingChecksBlock)
386386
387387 res = c.cli(t, adminToken, "repo", "edit", "proj", "--merge-modes", "squash", "--json").ok(t)
M internal/app/cli_mr_test.go +83 -7
@@ -3,6 +3,8 @@
33 package app
44
55 import (
6+ "encoding/json"
7+ "regexp"
68 "strings"
79 "testing"
810
@@ -174,6 +176,53 @@ func TestMRShow(t *testing.T) {
174176 assert.Contains(t, res.stderr, "head you last read is gone")
175177 }
176178
179+// The mergeability of an open request is a line of `mr show` and a field of
180+// its JSON.
181+func TestMRShowMergeability(t *testing.T) {
182+ withState := func(mg *ermite.Mergeability) *ermite.GetMergeRequestResponse {
183+ m := proto.Clone(mrOne).(*ermite.MergeRequest)
184+ m.Mergeability = mg
185+ return &ermite.GetMergeRequestResponse{MergeRequest: m}
186+ }
187+ var cur *ermite.GetMergeRequestResponse
188+ f := &fakeAPI{getMR: func(*ermite.GetMergeRequestRequest) (*ermite.GetMergeRequestResponse, error) { return cur, nil }}
189+ url := f.serve(t)
190+ for _, tt := range []struct {
191+ mg *ermite.Mergeability
192+ line string
193+ }{
194+ {&ermite.Mergeability{State: ermite.MergeabilityState_MERGEABILITY_STATE_UP_TO_DATE}, "up to date"},
195+ {&ermite.Mergeability{State: ermite.MergeabilityState_MERGEABILITY_STATE_BEHIND, Behind: 1000, BehindCapped: true}, "behind by 1000+"},
196+ {&ermite.Mergeability{State: ermite.MergeabilityState_MERGEABILITY_STATE_CONFLICT, ConflictPaths: []string{"a.txt", "b/c.go"}, ConflictPathsTruncated: true}, "conflict (a.txt, b/c.go, ...)"},
197+ {&ermite.Mergeability{State: ermite.MergeabilityState_MERGEABILITY_STATE_CHECKING}, "checking"},
198+ } {
199+ cur = withState(tt.mg)
200+ res := runCLI(t, envFor(url), "", "mr", "show", "alpha", "1").ok(t)
201+ assert.Regexp(t, `(?m)^mergeability\s+.*`+regexp.QuoteMeta(tt.line), res.stdout)
202+ }
203+ cur = withState(&ermite.Mergeability{State: ermite.MergeabilityState_MERGEABILITY_STATE_BEHIND, Behind: 2, TargetId: headA})
204+ res := runCLI(t, envFor(url), "", "mr", "show", "alpha", "1", "--json").ok(t)
205+ var got struct {
206+ Mergeability *struct {
207+ State string `json:"state"`
208+ Behind int `json:"behind"`
209+ TargetID string `json:"target_id"`
210+ } `json:"mergeability"`
211+ }
212+ require.NoError(t, json.Unmarshal([]byte(res.stdout), &got))
213+ require.NotNil(t, got.Mergeability)
214+ assert.Equal(t, "behind", got.Mergeability.State)
215+ assert.Equal(t, 2, got.Mergeability.Behind)
216+ assert.Equal(t, headA, got.Mergeability.TargetID)
217+
218+ // No mergeability (a merged request, or an older server): no line, null.
219+ cur = withState(nil)
220+ res = runCLI(t, envFor(url), "", "mr", "show", "alpha", "1").ok(t)
221+ assert.NotContains(t, res.stdout, "mergeability")
222+ res = runCLI(t, envFor(url), "", "mr", "show", "alpha", "1", "--json").ok(t)
223+ assert.Regexp(t, `"mergeability":\s*null`, res.stdout)
224+}
225+
177226 func TestMRShowMarkRead(t *testing.T) {
178227 f := &fakeAPI{
179228 getMR: func(*ermite.GetMergeRequestRequest) (*ermite.GetMergeRequestResponse, error) {
@@ -252,6 +301,12 @@ func TestMRResolveMergeAndClose(t *testing.T) {
252301 closeMR: func(*ermite.CloseMergeRequestRequest) (*ermite.CloseMergeRequestResponse, error) {
253302 return &ermite.CloseMergeRequestResponse{MergeRequest: closed}, nil
254303 },
304+ getSigningKey: func(*ermite.GetSigningKeyRequest) (*ermite.GetSigningKeyResponse, error) {
305+ return &ermite.GetSigningKeyResponse{Fingerprint: "SHA256:abc"}, nil
306+ },
307+ getVersion: func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) {
308+ return &ermite.GetVersionResponse{Version: "test", Features: []string{"merge_requests.mergeability"}}, nil
309+ },
255310 }
256311 url := f.serve(t)
257312
@@ -265,16 +320,37 @@ func TestMRResolveMergeAndClose(t *testing.T) {
265320 assert.False(t, reqs[1].(*ermite.ResolveCommentRequest).GetResolved())
266321
267322 // Merge with an explicit head and mode.
268- res = runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--squash", "--head", headA).ok(t)
269- assert.Equal(t, "merged alpha!1 into main (squash) at dddddddddd\n", res.stdout)
323+ res = runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--mode", "squash", "--head", headA).ok(t)
324+ assert.Equal(t, "merged alpha!1 into main (squash) at dddddddddd\n"+
325+ "the commit is signed by the server's key SHA256:abc; `ermite signing-key show` prints the key to verify it\n", res.stdout)
270326 assert.Empty(t, res.stderr)
271327 calls, reqs := f.snapshot()
272- assert.Equal(t, []string{"ResolveComment", "ResolveComment", "MergeMergeRequest"}, calls, "an explicit head needs no read first")
328+ assert.Equal(t, []string{"ResolveComment", "ResolveComment", "MergeMergeRequest", "GetSigningKey"}, calls, "an explicit head needs no read first; the signing key is for the note")
273329 m := reqs[2].(*ermite.MergeMergeRequestRequest)
274330 assert.Equal(t, ermite.MergeMode_MERGE_MODE_SQUASH, m.GetMode())
275331 assert.Equal(t, headA, m.GetHeadId())
276332 assert.EqualValues(t, 1, m.GetNumber())
277333
334+ // A merge commit, and a mode that does not exist.
335+ merged.MergeMode = ermite.MergeMode_MERGE_MODE_MERGE_COMMIT
336+ res = runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--mode", "merge", "--head", headA).ok(t)
337+ assert.Contains(t, res.stdout, "merged alpha!1 into main (merge) at dddddddddd\n")
338+ assert.Contains(t, res.stdout, "signed by the server's key SHA256:abc")
339+ _, reqs = f.snapshot()
340+ var lastMerge *ermite.MergeMergeRequestRequest
341+ for _, r := range reqs {
342+ if mr, ok := r.(*ermite.MergeMergeRequestRequest); ok {
343+ lastMerge = mr
344+ }
345+ }
346+ require.NotNil(t, lastMerge)
347+ assert.Equal(t, ermite.MergeMode_MERGE_MODE_MERGE_COMMIT, lastMerge.GetMode())
348+ bad := runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--mode", "rebase", "--head", headA)
349+ require.Error(t, bad.err)
350+ assert.Contains(t, bad.err.Error(), "invalid merge mode")
351+ assert.Error(t, runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--squash", "--head", headA).err, "--squash is gone")
352+ merged.MergeMode = ermite.MergeMode_MERGE_MODE_SQUASH
353+
278354 // The default mode is a fast-forward.
279355 res = runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--head", headA, "--json").ok(t)
280356 assert.Equal(t, "merged", decode[mrOut](t, res.stdout).State)
@@ -299,7 +375,7 @@ func TestMRMergeNeedsTheHead(t *testing.T) {
299375 url := f.serve(t)
300376 for _, args := range [][]string{
301377 {"mr", "merge", "alpha", "1"},
302- {"mr", "merge", "alpha", "1", "--squash"},
378+ {"mr", "merge", "alpha", "1", "--mode", "squash"},
303379 {"mr", "merge", "alpha", "1", "--head", " "},
304380 } {
305381 res := runCLI(t, envFor(url), "", args...)
@@ -326,7 +402,7 @@ func TestMRMergeDeleteBranch(t *testing.T) {
326402 url := f.serve(t)
327403
328404 res := runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--head", headA, "--delete-branch").ok(t)
329- assert.Equal(t, "merged alpha!1 into main (fast-forward) at "+headA[:10]+"\ndeleted branch feat/x\n", res.stdout)
405+ assert.Equal(t, "merged alpha!1 into main (fast-forward) at "+headA[:10]+"\ndeleting branch feat/x (done by the server after the merge)\n", res.stdout)
330406 assert.Empty(t, res.stderr)
331407 _, reqs := f.snapshot()
332408 assert.True(t, reqs[0].(*ermite.MergeMergeRequestRequest).GetDeleteSourceBranch())
@@ -334,11 +410,11 @@ func TestMRMergeDeleteBranch(t *testing.T) {
334410 res = runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--head", headA, "--delete-branch", "--json").ok(t)
335411 assert.Contains(t, res.stdout, `"source_branch_deleted": true`)
336412
337- // The merge stands when the branch is kept: a warning, no failure.
413+ // Deletion is asynchronous: the CLI makes no claim when the server did not
414+ // request it.
338415 deleted = false
339416 res = runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--head", headA, "--delete-branch").ok(t)
340417 assert.Equal(t, "merged alpha!1 into main (fast-forward) at "+headA[:10]+"\n", res.stdout)
341- assert.Contains(t, res.stderr, "ermite: warning: the branch feat/x was kept")
342418
343419 // Without the flag nothing is asked and nothing is said.
344420 res = runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--head", headA).ok(t)
M internal/app/cli_repo.go +9 -7
@@ -33,7 +33,7 @@ type jsonRepo struct {
3333 DefaultBranch string `json:"default_branch"`
3434 CreatedAt *string `json:"created_at"`
3535 PushedAt *string `json:"pushed_at"`
36- // MergeModes are the allowed merge modes ("fast-forward", "squash").
36+ // MergeModes are the allowed merge modes ("fast-forward", "squash", "merge").
3737 MergeModes []string `json:"merge_modes"`
3838 FailingChecksBlock bool `json:"failing_checks_block"`
3939 // Issues is the issue mode ("off", "owner" or "public").
@@ -114,8 +114,8 @@ func parseVisibility(s string) (ermite.Visibility, error) {
114114 const jsonRepoHelp = `
115115 JSON fields of a repository (--json): name, description, visibility ("public" or
116116 "private"), default_branch, created_at and pushed_at (RFC 3339 UTC; pushed_at is
117-null until the first push), merge_modes (array of the allowed merge modes, "fast-forward" and/or
118-"squash"), failing_checks_block (whether a failing check prevents a merge) and issues (the issue
117+null until the first push), merge_modes (array of the allowed merge modes, "fast-forward", "squash" and/or
118+"merge"), failing_checks_block (whether a failing check prevents a merge) and issues (the issue
119119 mode: "off", "owner" or "public").`
120120
121121 func newRepoCmd(env *cliEnv) *cobra.Command {
@@ -315,7 +315,9 @@ func newRepoEditCmd(env *cliEnv) *cobra.Command {
315315 Short: "Change a repository's description, topics, visibility, default branch, merge settings or issues",
316316 Long: "Change the fields given as flags and leave the others alone; --description \"\" clears the\n" +
317317 "description and --topics \"\" clears the topics. The default branch must already exist. --merge-modes lists the modes a merge request\n" +
318- "may be merged with (fast-forward, squash; default for a new repository: both), and\n" +
318+ "may be merged with (fast-forward, squash, merge; every mode is allowed by default, so to disable one,\n" +
319+ "list the others: --merge-modes fast-forward,squash disables the merge commit; squash and merge\n" +
320+ "commits are signed with the server's key, see `ermite signing-key show`), and\n" +
319321 "--failing-checks-block says whether a failing check prevents a merge (a failed or unfinished CI check of the head; see README, CI).\n" +
320322 "--issues sets who may use issues: off (default), owner (the owner and tokens with issue:write) or\n" +
321323 "public (visitors of a public repository may also propose issues through a moderated form).\nNeeds a server with the issues feature.\n" +
@@ -327,7 +329,7 @@ func newRepoEditCmd(env *cliEnv) *cobra.Command {
327329 cmd.Flags().StringSliceVar(&topics, "topics", nil, "new topics, comma-separated (empty clears them)")
328330 cmd.Flags().StringVar(&visibility, "visibility", "", "new visibility: public or private")
329331 cmd.Flags().StringVar(&defaultBranch, "default-branch", "", "short name of the new default branch")
330- cmd.Flags().StringSliceVar(&mergeModes, "merge-modes", nil, "allowed merge modes, comma-separated: fast-forward, squash")
332+ cmd.Flags().StringSliceVar(&mergeModes, "merge-modes", nil, "allowed merge modes, comma-separated: fast-forward, squash, merge (all by default; list the ones to keep)")
331333 cmd.Flags().BoolVar(&failingChecksBlock, "failing-checks-block", true, "a failing check prevents a merge (--failing-checks-block=false to allow it)")
332334 cmd.Flags().StringVar(&issues, "issues", "", "issue mode: off, owner or public")
333335 _ = cmd.RegisterFlagCompletionFunc("issues", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
@@ -335,7 +337,7 @@ func newRepoEditCmd(env *cliEnv) *cobra.Command {
335337 })
336338 _ = cmd.RegisterFlagCompletionFunc("visibility", visibilityCompletion)
337339 _ = cmd.RegisterFlagCompletionFunc("merge-modes", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
338- return []string{modeFastForward, modeSquash}, cobra.ShellCompDirectiveNoFileComp
340+ return []string{modeFastForward, modeSquash, modeMerge}, cobra.ShellCompDirectiveNoFileComp
339341 })
340342 asJSON := addJSONFlag(cmd)
341343 cmd.RunE = func(cmd *cobra.Command, args []string) error {
@@ -400,7 +402,7 @@ func buildUpdateRepo(cmd *cobra.Command, name, description string, topics []stri
400402 modes = append(modes, mode)
401403 }
402404 if len(modes) == 0 {
403- return nil, errors.New("--merge-modes must name at least one mode: fast-forward, squash")
405+ return nil, errors.New("--merge-modes must name at least one mode: fast-forward, squash, merge")
404406 }
405407 req.Settings = &ermite.RepoSettings{AllowedMergeModes: modes}
406408 paths = append(paths, "settings.allowed_merge_modes")
M internal/app/cli_version.go +1 -0
@@ -114,6 +114,7 @@ var featureUse = map[string]string{
114114 client.FeatureMergeRequests: "`ermite mr` needs: the commands will fail on this server",
115115 client.FeatureMergeDeleteBranch: "`ermite mr merge --delete-branch` needs: the merge works but the branch is kept",
116116 client.FeatureIssues: "`ermite issue` needs: the commands will fail on this server, and `repo edit --issues` and `token create --scope issue:write` are refused",
117+ client.FeatureMergeability: "`ermite mr show` uses to show whether the request merges: the line is missing",
117118 client.FeatureSigningKey: "`ermite signing-key show` needs: the command will fail on this server",
118119 client.FeatureSession: "`ermite web login` needs: the command will fail on this server",
119120 client.FeatureTokenBranchPrefixes: "`ermite token create --branch-prefix` needs: the command is refused, and `token list` does not show the limits",
M internal/app/cli_version_test.go +5 -5
@@ -17,7 +17,7 @@ import (
1717 )
1818
1919 // allFeatures are the features this client uses.
20-var allFeatures = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "signing_key", "session", "token.branch_prefixes", "ci.runs", "ci.runners", "ci.secrets", "ci.dispatch", "ci.workflows", "ci.artifacts", "commit_signatures", "passkeys"}
20+var allFeatures = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "merge_requests.mergeability", "signing_key", "session", "token.branch_prefixes", "ci.runs", "ci.runners", "ci.secrets", "ci.dispatch", "ci.workflows", "ci.artifacts", "commit_signatures", "passkeys"}
2121
2222 // Every feature the client uses needs a sentence for the version warning.
2323 func TestEveryUsedFeatureExplainsWhatBreaks(t *testing.T) {
@@ -45,15 +45,15 @@ func TestVersionWithServer(t *testing.T) {
4545 f := versionFake(allFeatures...)
4646 url := f.serve(t)
4747 res := runCLI(t, envFor(url), "", "version").ok(t)
48- assert.Equal(t, "client ermite v0.0.1\nserver v0.4.0 at "+url+"\nfeatures repo.update, events.resume, merge_requests, merge_requests.delete_branch, issues, signing_key, session, token.branch_prefixes, ci.runs, ci.runners, ci.secrets, ci.dispatch, ci.workflows, ci.artifacts, commit_signatures, passkeys\n", res.stdout)
48+ assert.Equal(t, "client ermite v0.0.1\nserver v0.4.0 at "+url+"\nfeatures repo.update, events.resume, merge_requests, merge_requests.delete_branch, issues, merge_requests.mergeability, signing_key, session, token.branch_prefixes, ci.runs, ci.runners, ci.secrets, ci.dispatch, ci.workflows, ci.artifacts, commit_signatures, passkeys\n", res.stdout)
4949 assert.Empty(t, res.stderr, "no warning when every feature is there")
5050
5151 res = runCLI(t, envFor(url), "", "version", "--json").ok(t)
52- assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","events.resume","merge_requests","merge_requests.delete_branch","issues","signing_key","session","token.branch_prefixes","ci.runs","ci.runners","ci.secrets","ci.dispatch","ci.workflows","ci.artifacts","commit_signatures","passkeys"],"missing_features":[]}}`, res.stdout)
52+ assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","events.resume","merge_requests","merge_requests.delete_branch","issues","merge_requests.mergeability","signing_key","session","token.branch_prefixes","ci.runs","ci.runners","ci.secrets","ci.dispatch","ci.workflows","ci.artifacts","commit_signatures","passkeys"],"missing_features":[]}}`, res.stdout)
5353 }
5454
5555 func TestVersionWarnsAboutMissingFeatures(t *testing.T) {
56- f := versionFake("repo.update", "merge_requests", "merge_requests.delete_branch", "issues", "signing_key", "session", "token.branch_prefixes", "ci.runs", "ci.runners", "ci.secrets", "ci.dispatch", "ci.workflows", "ci.artifacts", "commit_signatures", "passkeys")
56+ f := versionFake("repo.update", "merge_requests", "merge_requests.delete_branch", "issues", "merge_requests.mergeability", "signing_key", "session", "token.branch_prefixes", "ci.runs", "ci.runners", "ci.secrets", "ci.dispatch", "ci.workflows", "ci.artifacts", "commit_signatures", "passkeys")
5757 url := f.serve(t)
5858 res := runCLI(t, envFor(url), "", "version").ok(t)
5959 assert.Contains(t, res.stdout, "v0.4.0")
@@ -61,7 +61,7 @@ func TestVersionWarnsAboutMissingFeatures(t *testing.T) {
6161 assert.NotContains(t, res.stderr, "repo.update")
6262
6363 res = runCLI(t, envFor(url), "", "version", "--json").ok(t)
64- assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","merge_requests","merge_requests.delete_branch","issues","signing_key","session","token.branch_prefixes","ci.runs","ci.runners","ci.secrets","ci.dispatch","ci.workflows","ci.artifacts","commit_signatures","passkeys"],"missing_features":["events.resume"]}}`, res.stdout)
64+ assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","merge_requests","merge_requests.delete_branch","issues","merge_requests.mergeability","signing_key","session","token.branch_prefixes","ci.runs","ci.runners","ci.secrets","ci.dispatch","ci.workflows","ci.artifacts","commit_signatures","passkeys"],"missing_features":["events.resume"]}}`, res.stdout)
6565 assert.Contains(t, res.stderr, "events.resume", "the warning also goes to stderr with --json")
6666
6767 f = versionFake()
M internal/app/cli_watch.go +30 -0
@@ -29,6 +29,8 @@ type jsonEvent struct {
2929 MergeRequest *jsonMergeRequest `json:"merge_request,omitempty"`
3030 Number int64 `json:"number,omitempty"`
3131 Comment *jsonComment `json:"comment,omitempty"`
32+ // Mergeability is set for a merge_request_mergeability event.
33+ Mergeability *jsonMergeability `json:"mergeability,omitempty"`
3234 }
3335
3436 type jsonRefUpdate struct {
@@ -52,6 +54,7 @@ const (
5254 eventCommentEdited = "comment_edited"
5355 eventCommentDeleted = "comment_deleted"
5456 eventCommentResolved = "comment_resolved"
57+ eventMRMergeability = "merge_request_mergeability"
5558 )
5659
5760 // toJSONEvent maps an event; ok is false for kinds this client does not know,
@@ -94,6 +97,9 @@ func toJSONEvent(ev *ermite.Event, baseURL string) (jsonEvent, bool) {
9497 case *ermite.Event_CommentResolved:
9598 c := toJSONComment(k.CommentResolved.GetComment())
9699 out.Type, out.Repo, out.Number, out.Comment = eventCommentResolved, k.CommentResolved.GetRepo(), k.CommentResolved.GetNumber(), &c
100+ case *ermite.Event_MergeRequestMergeability:
101+ out.Type, out.Repo, out.Number = eventMRMergeability, k.MergeRequestMergeability.GetRepo(), k.MergeRequestMergeability.GetNumber()
102+ out.Mergeability = toJSONMergeability(k.MergeRequestMergeability.GetMergeability())
97103 case *ermite.Event_Gap:
98104 out.Type = eventGap
99105 default:
@@ -126,12 +132,36 @@ func eventLine(e jsonEvent) string {
126132 detail = fmt.Sprintf("%s!%d %s -> %s", e.Repo, e.MergeRequest.Number, e.MergeRequest.SourceBranch, e.MergeRequest.TargetBranch)
127133 case eventCommentAdded, eventCommentEdited, eventCommentDeleted, eventCommentResolved:
128134 detail = fmt.Sprintf("%s!%d by %s", e.Repo, e.Number, e.Comment.Author)
135+ case eventMRMergeability:
136+ detail = fmt.Sprintf("%s!%d mergeability: %s", e.Repo, e.Number, mergeabilityEventText(e.Mergeability))
129137 case eventGap:
130138 detail = "events were lost, reload what you display"
131139 }
132140 return clean(ts + " " + e.Type + " " + detail)
133141 }
134142
143+// mergeabilityEventText is the short form of a mergeability for a watch line:
144+// "up_to_date", "behind by 2", "conflict (a, b)", "checking".
145+func mergeabilityEventText(m *jsonMergeability) string {
146+ if m == nil {
147+ return "unknown"
148+ }
149+ switch m.State {
150+ case "behind":
151+ return fmt.Sprintf("behind by %d%s", m.Behind, plus(m.BehindCapped))
152+ case "conflict":
153+ if len(m.ConflictPaths) == 0 {
154+ return "conflict (no common history)"
155+ }
156+ more := ""
157+ if m.ConflictPathsTruncated {
158+ more = ", ..."
159+ }
160+ return "conflict (" + strings.Join(m.ConflictPaths, ", ") + more + ")"
161+ }
162+ return m.State
163+}
164+
135165 func refUpdateText(u jsonRefUpdate) string {
136166 short := func(id string) string {
137167 if len(id) > 8 {
M internal/app/cli_watch_test.go +43 -0
@@ -200,6 +200,49 @@ func TestWatchMergeRequestEvents(t *testing.T) {
200200 assert.Equal(t, "merge_request_closed", decode[eventOut](t, lines[3]).Type)
201201 }
202202
203+// A mergeability event shows the state, with how far behind or the paths.
204+func TestWatchMergeabilityEvents(t *testing.T) {
205+ mg := func(m *ermite.Mergeability) func(*ermite.Event) {
206+ return func(e *ermite.Event) {
207+ e.Kind = &ermite.Event_MergeRequestMergeability{MergeRequestMergeability: &ermite.MergeRequestMergeability{Repo: "alpha", Number: 1, Mergeability: m}}
208+ }
209+ }
210+ events := []*ermite.WatchResponse{
211+ evt("1", mg(&ermite.Mergeability{State: ermite.MergeabilityState_MERGEABILITY_STATE_UP_TO_DATE})),
212+ evt("2", mg(&ermite.Mergeability{State: ermite.MergeabilityState_MERGEABILITY_STATE_BEHIND, Behind: 2})),
213+ evt("3", mg(&ermite.Mergeability{State: ermite.MergeabilityState_MERGEABILITY_STATE_CONFLICT, ConflictPaths: []string{"a.txt", "b.txt"}})),
214+ }
215+ f := &fakeAPI{watch: finishing(events)}
216+ url := f.serve(t)
217+
218+ res := runCLI(t, envFor(url), "", "watch")
219+ require.Error(t, res.err)
220+ assert.Equal(t, ""+
221+ "2026-10-10T12:00:00Z merge_request_mergeability alpha!1 mergeability: up_to_date\n"+
222+ "2026-10-10T12:00:00Z merge_request_mergeability alpha!1 mergeability: behind by 2\n"+
223+ "2026-10-10T12:00:00Z merge_request_mergeability alpha!1 mergeability: conflict (a.txt, b.txt)\n", res.stdout)
224+
225+ res = runCLI(t, envFor(url), "", "watch", "--json")
226+ lines := strings.Split(strings.TrimSpace(res.stdout), "\n")
227+ require.Len(t, lines, 3)
228+ var ev struct {
229+ Type string `json:"type"`
230+ Number int64 `json:"number"`
231+ Mergeability struct {
232+ State string `json:"state"`
233+ Behind int `json:"behind"`
234+ ConflictPaths []string `json:"conflict_paths"`
235+ } `json:"mergeability"`
236+ }
237+ require.NoError(t, json.Unmarshal([]byte(lines[1]), &ev))
238+ assert.Equal(t, "merge_request_mergeability", ev.Type)
239+ assert.EqualValues(t, 1, ev.Number)
240+ assert.Equal(t, "behind", ev.Mergeability.State)
241+ assert.Equal(t, 2, ev.Mergeability.Behind)
242+ require.NoError(t, json.Unmarshal([]byte(lines[2]), &ev))
243+ assert.Equal(t, []string{"a.txt", "b.txt"}, ev.Mergeability.ConflictPaths)
244+}
245+
203246 func TestNoteWatchEvents(t *testing.T) {
204247 note := &ermite.Comment{Id: "4", ReplyTo: "2", StartLine: 10, Line: 24, Verdict: ermite.Verdict_VERDICT_APPROVE, EditedAt: ts("2026-10-10T12:00:00Z"), Deleted: true, Author: "owner"}
205248 events := []*ermite.Event{
M internal/app/forge.go +14 -1
@@ -137,6 +137,8 @@ type forge struct {
137137 // Inbox serves the owner's inbox of the web.
138138 Inbox *usecase.Inbox
139139 SigningKey *usecase.SigningKey
140+ // Mergeability keeps the mergeability of the open merge requests up to date.
141+ Mergeability *usecase.Mergeability
140142 // Signatures are the trusted keys and the commit signature checks
141143 // (ADR 0030).
142144 Signatures *usecase.Signatures
@@ -331,11 +333,16 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
331333 if err := f.signatures(store, reader, signer.PublicKey(), committer, clock); err != nil {
332334 return nil, err
333335 }
336+ f.Mergeability = usecase.NewMergeability(ctx, usecase.MergeabilityConfig{
337+ Store: store.MergeRequests(), Meta: store, Git: git, Bus: f.Bus, Clock: clock,
338+ })
339+ f.Pushes.WithMergeability(f.Mergeability)
334340 f.MergeRequests = usecase.NewMergeRequests(usecase.MergeRequestsConfig{
335341 Meta: store, Store: store.MergeRequests(), Repos: git, Git: git, Signer: signer,
336342 Committer: committer, Bus: f.Bus, Clock: clock, Maint: f.maint,
337- CI: f.CI, Checks: f.CI, PullRequests: f.CI, RefTips: store.CI(),
343+ CI: f.CI, Checks: f.CI, PullRequests: f.CI, RefTips: store.CI(), Mergeability: f.Mergeability,
338344 })
345+ f.Mergeability.Start()
339346 f.MergeRequests.WithVerifier(f.Signatures)
340347 f.Issues = usecase.NewIssues(usecase.IssuesConfig{
341348 Meta: store, Store: store.Issues(), MergeRequests: store.MergeRequests(), Bus: f.Bus, Clock: clock,
@@ -512,9 +519,15 @@ func (f *forge) Close(ctx context.Context) {
512519 if f.hooks != nil {
513520 f.hooks.Close(ctx)
514521 }
522+ if f.MergeRequests != nil {
523+ f.MergeRequests.Wait(ctx) // its side effects use CI and Mergeability
524+ }
515525 if f.CI != nil {
516526 f.CI.Close(ctx)
517527 }
528+ if f.Mergeability != nil {
529+ f.Mergeability.Close(ctx)
530+ }
518531 if f.EventLog != nil {
519532 f.EventLog.Close(ctx)
520533 }
M internal/app/mr_api_test.go +3 -2
@@ -298,7 +298,7 @@ func TestUpdateRepoSettingsOverTheAPI(t *testing.T) {
298298
299299 got, err := h.api("").repo.GetRepo(ctx, connect.NewRequest(&pb.GetRepoRequest{Repo: "proj"}))
300300 require.NoError(t, err)
301- assert.Equal(t, []pb.MergeMode{pb.MergeMode_MERGE_MODE_FAST_FORWARD, pb.MergeMode_MERGE_MODE_SQUASH}, got.Msg.GetRepo().GetSettings().GetAllowedMergeModes())
301+ assert.Equal(t, []pb.MergeMode{pb.MergeMode_MERGE_MODE_FAST_FORWARD, pb.MergeMode_MERGE_MODE_SQUASH, pb.MergeMode_MERGE_MODE_MERGE_COMMIT}, got.Msg.GetRepo().GetSettings().GetAllowedMergeModes())
302302 assert.True(t, got.Msg.GetRepo().GetSettings().GetFailingChecksBlock())
303303
304304 update := func(c *clients, paths []string, s *pb.RepoSettings) (*pb.Repo, error) {
@@ -375,7 +375,8 @@ func TestMergeDeletesTheSourceOverTheAPI(t *testing.T) {
375375 Repo: "proj", Number: 1, HeadId: r.head, DeleteSourceBranch: true,
376376 }))
377377 require.NoError(t, err)
378- assert.True(t, res.Msg.GetSourceBranchDeleted())
378+ assert.True(t, res.Msg.GetSourceBranchDeleted(), "requested")
379+ r.forge.MergeRequests.Wait(r.ctx) // the deletion follows the response
379380 assert.Empty(t, r.bareGit("proj", "for-each-ref", "refs/heads/feat"))
380381
381382 // The default branch as a source: refused before anything changes.
M internal/app/testdata/web/commits-root.html +1 -1
@@ -972,7 +972,7 @@
972972 htmx:sse:close, htmx:sse:error -> ermiteLive.lost();
973973 htmx:sse:error -> ermiteLive.error(event.detail.status);
974974 htmx:sse:before:connection -> ermiteLive.gate(event);
975- home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data);
975+ home-list, repo-last-push, refs-list, commits-head, mr-notice, mr-mergeability, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data);
976976 gap -> ermiteLive.reload()">
977977 </span>
978978 </div>
M internal/app/testdata/web/commits-small.html +1 -1
@@ -317,7 +317,7 @@
317317 htmx:sse:close, htmx:sse:error -> ermiteLive.lost();
318318 htmx:sse:error -> ermiteLive.error(event.detail.status);
319319 htmx:sse:before:connection -> ermiteLive.gate(event);
320- home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data);
320+ home-list, repo-last-push, refs-list, commits-head, mr-notice, mr-mergeability, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data);
321321 gap -> ermiteLive.reload()">
322322 </span>
323323 </div>
M internal/app/testdata/web/home.html +1 -1
@@ -191,7 +191,7 @@
191191 htmx:sse:close, htmx:sse:error -> ermiteLive.lost();
192192 htmx:sse:error -> ermiteLive.error(event.detail.status);
193193 htmx:sse:before:connection -> ermiteLive.gate(event);
194- home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data);
194+ home-list, repo-last-push, refs-list, commits-head, mr-notice, mr-mergeability, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data);
195195 gap -> ermiteLive.reload()">
196196 </span>
197197 </div>
M internal/app/testdata/web/refs.html +1 -1
@@ -199,7 +199,7 @@
199199 htmx:sse:close, htmx:sse:error -> ermiteLive.lost();
200200 htmx:sse:error -> ermiteLive.error(event.detail.status);
201201 htmx:sse:before:connection -> ermiteLive.gate(event);
202- home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data);
202+ home-list, repo-last-push, refs-list, commits-head, mr-notice, mr-mergeability, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data);
203203 gap -> ermiteLive.reload()">
204204 </span>
205205 </div>
M internal/app/testdata/web/summary.html +1 -1
@@ -225,7 +225,7 @@
225225 htmx:sse:close, htmx:sse:error -> ermiteLive.lost();
226226 htmx:sse:error -> ermiteLive.error(event.detail.status);
227227 htmx:sse:before:connection -> ermiteLive.gate(event);
228- home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data);
228+ home-list, repo-last-push, refs-list, commits-head, mr-notice, mr-mergeability, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data);
229229 gap -> ermiteLive.reload()">
230230 </span>
231231 </div>
M internal/app/web.go +5 -0
@@ -35,6 +35,10 @@ func newWeb(cfg config.Config, f *forge, limiter *clientlimit.Limiter) (http.Han
3535 if f.Passkeys != nil {
3636 passkeys = webPasskeys{f.Passkeys}
3737 }
38+ var signing ports.SigningKeyService
39+ if f.SigningKey != nil {
40+ signing = f.SigningKey
41+ }
3842 // Without artifacts (ERMITE_CI_ARTIFACTS=0) the run page has no section
3943 // for them and no download route.
4044 var artifacts ports.ArtifactService
@@ -50,6 +54,7 @@ func newWeb(cfg config.Config, f *forge, limiter *clientlimit.Limiter) (http.Han
5054 Inbox: f.Inbox,
5155 IssueForm: f.IssueSubmissions,
5256 Moderation: f.IssueModeration,
57+ SigningKey: signing,
5358 Runs: f.CI,
5459 Artifacts: artifacts,
5560 Events: f.EventLog,
M internal/app/web_events_test.go +9 -0
@@ -37,6 +37,12 @@ type sseStream struct {
3737 // background. It returns once the server answered, which it does when the
3838 // watch is registered: what happens after is not missed.
3939 func (h *harness) sse(query, lastID string) *sseStream {
40+ h.t.Helper()
41+ return h.sseAs(query, lastID, nil)
42+}
43+
44+// sseAs opens the stream with the headers given (the owner's cookie).
45+func (h *harness) sseAs(query, lastID string, headers map[string]string) *sseStream {
4046 h.t.Helper()
4147 ctx, cancel := context.WithCancel(h.ctx)
4248 h.t.Cleanup(cancel)
@@ -45,6 +51,9 @@ func (h *harness) sse(query, lastID string) *sseStream {
4551 if lastID != "" {
4652 req.Header.Set("Last-Event-ID", lastID)
4753 }
54+ for k, v := range headers {
55+ req.Header.Set(k, v)
56+ }
4857 resp, err := http.DefaultClient.Do(req)
4958 require.NoError(h.t, err)
5059 t := h.t
M internal/app/web_mr_test.go +202 -10
@@ -10,6 +10,7 @@ import (
1010 "regexp"
1111 "strings"
1212 "testing"
13+ "time"
1314
1415 "github.com/stretchr/testify/assert"
1516 "github.com/stretchr/testify/require"
@@ -165,12 +166,15 @@ func TestWebMergeRequestPagesRender(t *testing.T) {
165166 assert.Equal(t, head, formHead(t, own.body))
166167 assert.Contains(t, own.body, `action="/proj/merge-requests/1/merge"`)
167168 assert.Contains(t, own.body, `id="merge-menu"`, "the merge is a menu of the bar above the diff")
168- assert.Contains(t, own.body, `<input type="radio" name="mode" value="fast-forward" checked>`, "the first mode is the default")
169- assert.Contains(t, own.body, `<input type="radio" name="mode" value="squash">`)
169+ assert.Contains(t, own.body, `name="mode" value="fast-forward" checked`, "the first possible mode is the default")
170+ assert.Contains(t, own.body, `name="mode" value="squash"`)
171+ assert.Contains(t, own.body, `name="mode" value="merge"`, "every mode is allowed by default")
170172 assert.Contains(t, own.body, "ermite mr merge proj 1 --head "+head)
171173 assert.Contains(t, own.body, `name="delete_branch" value="1"> delete feat/search after the merge <kbd aria-hidden="true">D</kbd>`)
172174 assert.NotContains(t, own.body, `name="delete_branch" value="1" checked>`, "unchecked by default")
173- assert.Contains(t, own.body, "ermite mr merge proj 1 --head "+head+" --squash")
175+ assert.Contains(t, own.body, "ermite mr merge proj 1 --head "+head+" --mode squash")
176+ assert.Contains(t, own.body, "ermite mr merge proj 1 --head "+head+" --mode merge")
177+ assert.NotContains(t, own.body, "--squash")
174178 assert.Contains(t, own.body, `action="/proj/merge-requests/1/comments"`)
175179 assert.Contains(t, own.body, `action="/proj/merge-requests/1/close"`)
176180 assert.Contains(t, own.body, `href="/proj/merge-requests/1?on=new:1:search.go#f`, "line numbers link to their form")
@@ -392,6 +396,7 @@ func TestWebMergeRequestSquashCloseAndList(t *testing.T) {
392396 assert.Contains(t, b.get("/proj/merge-requests/1", as(token)).body, `name="delete_branch" value="1"`, "the box offers to delete the branch")
393397 res = b.form("/proj/merge-requests/1/merge", token, url.Values{"mode": {"squash"}, "head_id": {head}, "delete_branch": {"1"}})
394398 require.Equal(t, http.StatusSeeOther, res.status, res.body)
399+ r.forge.MergeRequests.Wait(r.ctx) // the deletion follows the redirect
395400 assert.Empty(t, r.bareGit("proj", "for-each-ref", "refs/heads/feat/search"), "the source branch is deleted")
396401 merged := r.headOf("main")
397402 assert.NotEqual(t, head, merged, "squash makes a new commit")
@@ -461,6 +466,9 @@ func TestWebMergeRequestDraft(t *testing.T) {
461466
462467 res := b.form("/proj/merge-requests/1/merge", token, url.Values{"mode": {"fast-forward"}, "head_id": {head}})
463468 assert.Equal(t, http.StatusConflict, res.status, "a draft is not merged")
469+ assert.Contains(t, res.body, `data-canonical="/proj/merge-requests/1"`, "the refused page names its own address")
470+ assert.Contains(t, res.body, `class="d-err"`, "the reason stays inline")
471+ assert.NotContains(t, b.get("/proj/merge-requests/1", as(token)).body, "data-canonical", "the plain page has no marker")
464472
465473 res = b.form("/proj/merge-requests/1/ready", token, nil)
466474 require.Equal(t, http.StatusSeeOther, res.status, res.body)
@@ -511,6 +519,7 @@ func TestWebMergeDeleteBranchBox(t *testing.T) {
511519 one := b.get("/proj/merge-requests/1", as(token))
512520 res := b.form("/proj/merge-requests/1/merge", token, url.Values{"mode": {"fast-forward"}, "head_id": {formHead(t, one.body)}, "delete_branch": {"1"}})
513521 require.Equal(t, http.StatusSeeOther, res.status, res.body)
522+ r.forge.MergeRequests.Wait(r.ctx) // the deletion follows the redirect
514523 assert.Empty(t, r.bareGit("proj", "for-each-ref", "refs/heads/feat/search"))
515524 done := b.get("/proj/merge-requests/1", nil)
516525 assert.Equal(t, 200, done.status, "the merged page reads without its branch")
@@ -569,8 +578,7 @@ func TestWebMergeRequestEvents(t *testing.T) {
569578 r := newMRRepo(t, domain.VisibilityPublic)
570579 list := r.sse("topic=repo:proj&view=mrs", "")
571580 r.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/search", "--title", "Secret <title>").ok(t)
572- ev := list.next(t, watchWait)
573- assert.Equal(t, "mr-notice", ev.name)
581+ ev := nextNamed(t, list, "mr-notice")
574582 assert.Contains(t, ev.data, `<div id="mr-notice" class="live-slot" role="status" data-slot>`)
575583 assert.Contains(t, ev.data, `href="/proj/merge-requests"`)
576584 assert.NotContains(t, ev.data, "Secret")
@@ -578,19 +586,18 @@ func TestWebMergeRequestEvents(t *testing.T) {
578586 one := r.sse("topic=repo:proj&view=mr&n=1&ref=feat/search", "")
579587 other := r.sse("topic=repo:proj&view=mr&n=2&ref=feat/other", "")
580588 r.cli(t, adminToken, "mr", "comment", "proj", "1", "-m", "private words").ok(t)
581- ev = one.next(t, watchWait)
582- assert.Equal(t, "mr-notice", ev.name)
589+ ev = nextNamed(t, one, "mr-notice")
583590 assert.Contains(t, ev.data, "a comment was added")
584591 assert.Contains(t, ev.data, `href="/proj/merge-requests/1"`)
585592 assert.NotContains(t, ev.data, "private words")
586593
587594 r.writeFile(r.wt, "more.txt", []byte("more\n"))
588595 r.mustGit(r.wt, "push", "-q", r.url, "feat/search")
589- ev = one.next(t, watchWait)
596+ ev = nextNamed(t, one, "mr-notice")
590597 assert.Contains(t, ev.data, "the branch changed")
591598
592599 r.cli(t, adminToken, "mr", "close", "proj", "1").ok(t)
593- assert.Contains(t, one.next(t, watchWait).data, "was closed")
600+ assert.Contains(t, nextNamed(t, one, "mr-notice").data, "was closed")
594601
595602 // A page of another request hears nothing of this one.
596603 for drained := false; !drained; {
@@ -603,7 +610,20 @@ func TestWebMergeRequestEvents(t *testing.T) {
603610 drained = true
604611 }
605612 }
606- assert.Contains(t, r.get1("/proj/merge-requests/1"), `hx-sse:connect="/-/events?topic=repo%3Aproj&amp;view=mr&amp;n=1&amp;ref=feat%2Fsearch&amp;head=`+r.headOf("feat/search")+`"`)
613+ assert.Contains(t, r.get1("/proj/merge-requests/1"), `hx-sse:connect="/-/events?topic=repo%3Aproj&amp;view=mr&amp;n=1&amp;ref=feat%2Fsearch&amp;head=`+r.headOf("feat/search")+`&amp;target=main"`)
614+}
615+
616+// nextNamed returns the next event that carries the fragment event: the
617+// events that change nothing on the page (an id alone) are skipped.
618+func nextNamed(t *testing.T, s *sseStream, name string) sseEvent {
619+ t.Helper()
620+ for range 10 {
621+ if ev := s.next(t, watchWait); ev.name == name {
622+ return ev
623+ }
624+ }
625+ t.Fatalf("no %s event", name)
626+ return sseEvent{}
607627 }
608628
609629 func (r *mrRepo) get1(path string) string {
@@ -722,3 +742,175 @@ func TestWebMergeRequestFileWindows(t *testing.T) {
722742 }
723743
724744 var reWindowSrc = regexp.MustCompile(`data-src="([^"]*from=25[^"]*)"`)
745+
746+// advanceMain pushes a commit to main that the feature branch lacks. file is
747+// the file it adds (give search.go to conflict with the feature branch).
748+func (r *mrRepo) advanceMain(file, content string) {
749+ r.t.Helper()
750+ r.mustGit(r.wt, "checkout", "-q", "main")
751+ r.writeFile(r.wt, file, []byte(content))
752+ r.mustGit(r.wt, "push", "-q", r.url, "main")
753+ r.mustGit(r.wt, "checkout", "-q", "feat/search")
754+}
755+
756+// mrPageWith polls the owner's page of !1 until it contains want.
757+func (r *mrRepo) mrPageWith(token, want string) string {
758+ r.t.Helper()
759+ var body string
760+ require.Eventually(r.t, func() bool {
761+ body = browser{r.harness}.get("/proj/merge-requests/1", as(token)).body
762+ return strings.Contains(body, want)
763+ }, 10*time.Second, 25*time.Millisecond, "the page never showed %q", want)
764+ return body
765+}
766+
767+// The merge box shows the mergeability of the request: ready, then behind
768+// (fast-forward is disabled with its reason, the other modes are not), then
769+// in conflict (no mode is possible and the button says why).
770+func TestWebMergeRequestMergeabilityBadge(t *testing.T) {
771+ r := newMRRepo(t, domain.VisibilityPublic)
772+ token := r.signIn(t)
773+ r.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/search", "--title", "Add search").ok(t)
774+
775+ body := r.mrPageWith(token, "Ready to merge")
776+ assert.Contains(t, body, `<div id="mr-mergeability" class="live-slot" role="status" data-slot>`)
777+ assert.Contains(t, body, `data-state="up_to_date"`)
778+ assert.Contains(t, body, `name="mode" value="fast-forward" checked`)
779+ assert.NotContains(t, body, `value="fast-forward" checked disabled`)
780+ assert.Contains(t, body, `id="mr-signing"`, "squash and merge commits say who signs them")
781+ assert.Contains(t, body, "signed with ermite's own key (generated on first start)")
782+ assert.Contains(t, body, "<code>ermite signing-key show</code>")
783+ assert.Contains(t, body, "SHA256:", "the fingerprint of the signing key")
784+ assert.Contains(t, body, `:disabled="`, "the controls follow the badge")
785+ assertNoInline(t, body)
786+
787+ // Main moves on: the branch is behind by one commit and merges cleanly.
788+ r.advanceMain("other.txt", "other\n")
789+ body = r.mrPageWith(token, "Behind main by 1 commit")
790+ assert.Contains(t, body, "merges without conflicts")
791+ assert.Contains(t, body, `data-ff-ok="false"`)
792+ assert.Contains(t, body, `data-merge-ok="true"`)
793+ assert.Regexp(t, `<input type="radio" name="mode" value="fast-forward"[^>]* disabled`, body)
794+ assert.Contains(t, body, `title="behind the target: rebase or use squash/merge"`)
795+ assert.Regexp(t, `name="mode" value="squash" checked`, body, "the first possible mode is checked")
796+ assert.NotRegexp(t, `name="mode" value="squash"[^>]* disabled`, body)
797+
798+ // A conflicting change to main: every mode is disabled and the files are listed.
799+ r.mustGit(r.wt, "checkout", "-q", "main")
800+ r.writeFile(r.wt, "search.go", []byte("package other\n"))
801+ r.mustGit(r.wt, "push", "-q", r.url, "main")
802+ body = r.mrPageWith(token, "Conflicts with main")
803+ assert.Contains(t, body, "rebase needed")
804+ assert.Contains(t, body, `<ul class="mg-paths" aria-label="conflicting files"><li><code>search.go</code></li></ul>`)
805+ assert.Regexp(t, `<button type="submit" class="btn primary" data-default disabled title="conflicts with the target"`, body)
806+ assert.NotContains(t, body, `name="mode" value="squash" checked`)
807+
808+ // The server refuses it too, and says so in the box of the page.
809+ res := browser{r.harness}.form("/proj/merge-requests/1/merge", token, url.Values{"mode": {"squash"}, "head_id": {formHead(t, body)}})
810+ assert.Equal(t, http.StatusConflict, res.status)
811+ assert.Contains(t, res.body, `<p class="d-err" role="alert">`)
812+ assert.Contains(t, res.body, "conflicts with main")
813+}
814+
815+// A merge the server refuses (the target moved after the page was read) shows
816+// the page of the request again, 409, with the reason in its merge box.
817+func TestWebMergeRequestInlineMergeError(t *testing.T) {
818+ r := newMRRepo(t, domain.VisibilityPublic)
819+ b := browser{r.harness}
820+ token := r.signIn(t)
821+ r.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/search", "--title", "Add search").ok(t)
822+ body := r.mrPageWith(token, "Ready to merge")
823+ head := formHead(t, body)
824+
825+ r.advanceMain("other.txt", "other\n") // raced: the form below still says fast-forward
826+ res := b.form("/proj/merge-requests/1/merge", token, url.Values{"mode": {"fast-forward"}, "head_id": {head}})
827+ assert.Equal(t, http.StatusConflict, res.status)
828+ assert.Equal(t, "no-store", res.hdr.Get("Cache-Control"))
829+ assert.Contains(t, res.body, `id="merge-menu"`, "the page of the request, not a state page")
830+ assert.Contains(t, res.body, `<p class="d-err" role="alert">`)
831+ assert.Contains(t, res.body, "the target has moved")
832+ assert.Contains(t, res.body, `<details class="menu mmenu" id="merge-menu" data-state="open" open>`, "the box is open on the error")
833+ assert.Equal(t, head, r.headOf("feat/search"))
834+ assert.NotContains(t, b.get("/proj/merge-requests/1", as(token)).body, "d-err", "a reload shows no error")
835+
836+ // A head that moved is refused the same way.
837+ r.writeFile(r.wt, "more.txt", []byte("more\n"))
838+ r.mustGit(r.wt, "push", "-q", r.url, "feat/search")
839+ res = b.form("/proj/merge-requests/1/merge", token, url.Values{"mode": {"squash"}, "head_id": {head}})
840+ assert.Equal(t, http.StatusConflict, res.status)
841+ assert.Contains(t, res.body, `<p class="d-err" role="alert">`)
842+ assert.Contains(t, res.body, "branch moved")
843+
844+ // Other refusals keep the state page; a mode that does not exist is a 400.
845+ assert.Equal(t, http.StatusBadRequest, b.form("/proj/merge-requests/1/merge", token, url.Values{"mode": {"rebase"}, "head_id": {head}}).status)
846+}
847+
848+// A merge commit through the page; with the branch deletion requested the
849+// merged page says the branch is being deleted, not that it is gone.
850+func TestWebMergeRequestMergeCommitAndDeletingNote(t *testing.T) {
851+ r := newMRRepo(t, domain.VisibilityPublic)
852+ b := browser{r.harness}
853+ token := r.signIn(t)
854+ r.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/search", "--title", "Add search").ok(t)
855+ r.advanceMain("other.txt", "other\n")
856+ body := r.mrPageWith(token, "Behind main by 1 commit")
857+ head := formHead(t, body)
858+ main0 := r.headOf("main")
859+
860+ res := b.form("/proj/merge-requests/1/merge", token, url.Values{"mode": {"merge"}, "head_id": {head}, "delete_branch": {"1"}})
861+ require.Equal(t, http.StatusSeeOther, res.status, res.body)
862+ assert.Equal(t, "/proj/merge-requests/1?deleting=1", res.hdr.Get("Location"))
863+ merged := r.headOf("main")
864+ assert.Equal(t, main0, r.bareGit("proj", "rev-parse", merged+"^1"))
865+ assert.Equal(t, head, r.bareGit("proj", "rev-parse", merged+"^2"))
866+ assert.Contains(t, r.bareGit("proj", "cat-file", "-p", merged), "gpgsig")
867+ page := b.get("/proj/merge-requests/1?deleting=1", as(token))
868+ assert.Contains(t, page.body, "merge commit")
869+ if strings.Contains(page.body, "is being deleted") {
870+ assert.Contains(t, page.body, "feat/search is being deleted")
871+ }
872+ assert.NotContains(t, page.body, "is deleted")
873+ r.forge.MergeRequests.Wait(r.ctx)
874+ assert.Empty(t, r.bareGit("proj", "for-each-ref", "refs/heads/feat/search"))
875+ assert.NotContains(t, b.get("/proj/merge-requests/1?deleting=1", as(token)).body, "is being deleted", "once gone, nothing is being deleted")
876+}
877+
878+// The badge follows the request live: a push to the target turns the fragment
879+// of the page to "checking" or "behind", and the settled result follows.
880+func TestWebMergeRequestMergeabilityLive(t *testing.T) {
881+ r := newMRRepo(t, domain.VisibilityPublic)
882+ token := r.signIn(t)
883+ r.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/search", "--title", "Add search").ok(t)
884+ r.mrPageWith(token, "Ready to merge")
885+
886+ one := r.sseAs("topic=repo:proj&view=mr&n=1&ref=feat/search&target=main", "", as(token))
887+ anon := r.sse("topic=repo:proj&view=mr&n=1&ref=feat/search&target=main", "")
888+ r.advanceMain("other.txt", "other\n")
889+ deadline := time.After(watchWait)
890+ for done := false; !done; {
891+ ev := nextNamed(t, one, "mr-mergeability")
892+ select {
893+ case <-deadline:
894+ t.Fatal("the badge never showed the result")
895+ default:
896+ }
897+ if strings.Contains(ev.data, "Behind main by 1 commit") {
898+ assert.Contains(t, ev.data, `<div id="mr-mergeability" class="live-slot" role="status" data-slot>`)
899+ assert.Contains(t, ev.data, `data-ff-ok="false"`)
900+ assert.Contains(t, ev.data, `data-merge-ok="true"`)
901+ done = true
902+ } else {
903+ assert.Contains(t, ev.data, "Checking…", "before the result, the badge says it checks")
904+ }
905+ }
906+ // The box is the owner's: a visitor's stream carries no badge.
907+ for {
908+ select {
909+ case ev := <-anon.events:
910+ assert.NotEqual(t, "mr-mergeability", ev.name)
911+ continue
912+ default:
913+ }
914+ break
915+ }
916+}
M internal/domain/event.go +14 -0
@@ -284,6 +284,20 @@ type CommentResolved struct {
284284 // EventKind implements Event.
285285 func (CommentResolved) EventKind() string { return "mr.comment_resolved" }
286286
287+// MergeRequestMergeability is published when the mergeability of an open merge
288+// request changed.
289+type MergeRequestMergeability struct {
290+ Repo RepoName
291+ Number int64
292+ Mergeability Mergeability
293+ At time.Time
294+ Visibility Visibility
295+ CreatedAt time.Time
296+}
297+
298+// EventKind implements Event.
299+func (MergeRequestMergeability) EventKind() string { return "mr.mergeability" }
300+
287301 // EventGap tells a subscriber that it missed events because it was too slow;
288302 // it must refresh its whole view.
289303 type EventGap struct{}
M internal/domain/mergerequest.go +155 -16
@@ -18,18 +18,23 @@ const (
1818 MergeModeFastForward MergeMode = "fast-forward"
1919 // MergeModeSquash makes one new signed commit with the tree of the head.
2020 MergeModeSquash MergeMode = "squash"
21+ // MergeModeMerge makes one new signed commit with two parents: the target
22+ // tip and the head.
23+ MergeModeMerge MergeMode = "merge"
2124 )
2225
23-// MergeModes returns the two modes in their canonical order.
24-func MergeModes() []MergeMode { return []MergeMode{MergeModeFastForward, MergeModeSquash} }
26+// MergeModes returns the modes in their canonical order.
27+func MergeModes() []MergeMode {
28+ return []MergeMode{MergeModeFastForward, MergeModeSquash, MergeModeMerge}
29+}
2530
26-// ParseMergeMode parses "fast-forward" or "squash".
31+// ParseMergeMode parses "fast-forward", "squash" or "merge".
2732 func ParseMergeMode(s string) (MergeMode, error) {
2833 m := MergeMode(s)
2934 if slices.Contains(MergeModes(), m) {
3035 return m, nil
3136 }
32- return "", fmt.Errorf("%w: merge mode %q (want fast-forward or squash)", ErrInvalid, s)
37+ return "", fmt.Errorf("%w: merge mode %q (want fast-forward, squash or merge)", ErrInvalid, s)
3338 }
3439
3540 // String returns the mode as text.
@@ -47,7 +52,7 @@ type RepoSettings struct {
4752 Issues IssuesMode
4853 }
4954
50-// DefaultRepoSettings are the settings of a new repository: both modes, and
55+// DefaultRepoSettings are the settings of a new repository: every mode, and
5156 // failing checks block the merge.
5257 func DefaultRepoSettings() RepoSettings {
5358 return RepoSettings{AllowedMergeModes: MergeModes(), FailingChecksBlock: true, Issues: IssuesOff}
@@ -249,10 +254,10 @@ type Identity struct {
249254 Name, Email string
250255 }
251256
252-// SquashCommit is the commit a squash merge creates: the tree of the head on
253-// top of the target.
254-type SquashCommit struct {
255- Tree, Parent ObjectID
257+// ServerCommit is a commit the server creates: a squash or a merge commit.
258+type ServerCommit struct {
259+ Tree ObjectID
260+ Parents []ObjectID
256261 Author, Committer Signature
257262 Message string
258263 }
@@ -267,7 +272,7 @@ const MaxSquashCommits = 1000
267272 // trailers, in order of appearance. The message is the title, " (!<number>)",
268273 // a blank line, the summaries of the commits oldest first as a list, and the
269274 // trailers.
270-func NewSquashCommit(mr MergeRequest, tree, parent ObjectID, commits []Commit, committer Identity, at time.Time) SquashCommit {
275+func NewSquashCommit(mr MergeRequest, tree, parent ObjectID, commits []Commit, committer Identity, at time.Time) ServerCommit {
271276 oldest := slices.Clone(commits)
272277 slices.Reverse(oldest)
273278 var first Signature
@@ -293,26 +298,41 @@ func NewSquashCommit(mr MergeRequest, tree, parent ObjectID, commits []Commit, c
293298 if len(trailers) > 0 {
294299 msg.WriteString("\n" + strings.Join(trailers, "\n") + "\n")
295300 }
296- return SquashCommit{
297- Tree: tree, Parent: parent,
301+ return ServerCommit{
302+ Tree: tree, Parents: []ObjectID{parent},
298303 Author: first,
299304 Committer: Signature{Name: committer.Name, Email: committer.Email, Time: at.UTC()},
300305 Message: msg.String(),
301306 }
302307 }
303308
309+// NewMergeCommit builds the merge commit of a merge request: the tree of the
310+// merge, the target tip and the head as parents, and the server identity as
311+// author and committer.
312+func NewMergeCommit(mr MergeRequest, tree, target, head ObjectID, committer Identity, at time.Time) ServerCommit {
313+ sig := Signature{Name: committer.Name, Email: committer.Email, Time: at.UTC()}
314+ return ServerCommit{
315+ Tree: tree, Parents: []ObjectID{target, head},
316+ Author: sig, Committer: sig,
317+ Message: fmt.Sprintf("Merge branch '%s' into %s (!%d)\n\n%s\n",
318+ singleLine(mr.Source), singleLine(mr.Target), mr.Number, singleLine(mr.Title.String())),
319+ }
320+}
321+
304322 // Payload returns the text of the commit object without a signature, which is
305323 // what the signature covers.
306-func (c SquashCommit) Payload() []byte { return c.encode("") }
324+func (c ServerCommit) Payload() []byte { return c.encode("") }
307325
308326 // Signed returns the text of the commit object with the armored SSH signature
309327 // in its gpgsig header.
310-func (c SquashCommit) Signed(armored string) []byte { return c.encode(armored) }
328+func (c ServerCommit) Signed(armored string) []byte { return c.encode(armored) }
311329
312-func (c SquashCommit) encode(armored string) []byte {
330+func (c ServerCommit) encode(armored string) []byte {
313331 var b strings.Builder
314332 b.WriteString("tree " + c.Tree.String() + "\n")
315- b.WriteString("parent " + c.Parent.String() + "\n")
333+ for _, p := range c.Parents {
334+ b.WriteString("parent " + p.String() + "\n")
335+ }
316336 b.WriteString("author " + signatureLine(c.Author) + "\n")
317337 b.WriteString("committer " + signatureLine(c.Committer) + "\n")
318338 if armored != "" {
@@ -430,3 +450,122 @@ func CurrentVerdicts(comments []Comment, head ObjectID) []AuthorVerdict {
430450 })
431451 return out
432452 }
453+
454+// Limits of a stored Mergeability.
455+const (
456+ // MaxBehindCount caps the commits counted as behind.
457+ MaxBehindCount = 1000
458+ // MaxConflictPaths caps the conflicted paths listed.
459+ MaxConflictPaths = 20
460+)
461+
462+// MergeabilityState is the outcome of a mergeability check.
463+type MergeabilityState string
464+
465+// Mergeability states.
466+const (
467+ // MergeabilityChecking means there is no up-to-date result yet; it is never
468+ // stored.
469+ MergeabilityChecking MergeabilityState = "checking"
470+ // MergeabilityUpToDate means the target is an ancestor of the head.
471+ MergeabilityUpToDate MergeabilityState = "up_to_date"
472+ // MergeabilityBehind means the head lacks commits of the target but merges
473+ // cleanly.
474+ MergeabilityBehind MergeabilityState = "behind"
475+ // MergeabilityConflict means the head does not merge cleanly.
476+ MergeabilityConflict MergeabilityState = "conflict"
477+)
478+
479+// Mergeability is whether an open merge request merges with its target, for
480+// given tips.
481+type Mergeability struct {
482+ Repo RepoName
483+ Number int64
484+ State MergeabilityState
485+ // TargetID and HeadID are the tips the result is for; zero for a derived
486+ // "checking".
487+ TargetID, HeadID ObjectID
488+ // Behind counts the commits of the target not in the head (behind only),
489+ // capped at MaxBehindCount.
490+ Behind int
491+ BehindCapped bool
492+ // ConflictPaths holds at most MaxConflictPaths paths (conflict only).
493+ ConflictPaths []string
494+ ConflictPathsTruncated bool
495+ CheckedAt time.Time
496+ // Version is set by the store on read, like MergeRequest.Version.
497+ Version uint64
498+}
499+
500+// Allows reports whether the mode may be used given the state, and if not,
501+// why.
502+func (m Mergeability) Allows(mode MergeMode) (bool, string) {
503+ switch mode {
504+ case MergeModeFastForward:
505+ if m.State == MergeabilityUpToDate {
506+ return true, ""
507+ }
508+ if m.State == MergeabilityBehind {
509+ return false, "behind the target: rebase or use squash/merge"
510+ }
511+ case MergeModeSquash, MergeModeMerge:
512+ if m.State != MergeabilityConflict && m.State != MergeabilityChecking && m.State != "" {
513+ return true, ""
514+ }
515+ }
516+ switch m.State {
517+ case MergeabilityConflict:
518+ return false, "conflicts with the target"
519+ case MergeabilityChecking, "":
520+ return false, "mergeability is being checked"
521+ }
522+ return false, "mode not allowed"
523+}
524+
525+// IsFor reports whether the result is for these tips, that is not stale.
526+func (m Mergeability) IsFor(target, head ObjectID) bool {
527+ return m.State != "" && m.State != MergeabilityChecking && m.TargetID == target && m.HeadID == head
528+}
529+
530+// MergeTreeResult is the outcome of merging two commits without a commit.
531+type MergeTreeResult struct {
532+ // Tree is the merged tree; zero when not clean.
533+ Tree ObjectID
534+ Clean bool
535+ ConflictPaths []string
536+ Truncated bool
537+}
538+
539+// Bounded returns r with at most MaxConflictPaths conflicted paths, Truncated
540+// set when some were dropped. Adapters return the raw result; the bound is
541+// the domain's.
542+func (r MergeTreeResult) Bounded() MergeTreeResult {
543+ kept := r.ConflictPaths
544+ if len(kept) > MaxConflictPaths {
545+ kept = kept[:MaxConflictPaths]
546+ r.Truncated = true
547+ }
548+ if kept != nil {
549+ // Exact-sized copies: neither the backing array nor the git output the
550+ // paths were cut from stays referenced.
551+ out := make([]string, len(kept))
552+ for i, p := range kept {
553+ out[i] = strings.Clone(p)
554+ }
555+ r.ConflictPaths = out
556+ }
557+ return r
558+}
559+
560+// BehindLimit is the limit to give a count of commits behind so that
561+// BoundBehind can tell whether MaxBehindCount was exceeded.
562+const BehindLimit = MaxBehindCount + 1
563+
564+// BoundBehind caps a raw count of commits behind (counted up to BehindLimit)
565+// at MaxBehindCount; capped is true when the count was over it.
566+func BoundBehind(n int) (count int, capped bool) {
567+ if n > MaxBehindCount {
568+ return MaxBehindCount, true
569+ }
570+ return n, false
571+}
M internal/domain/mergerequest_test.go +50 -3
@@ -3,6 +3,7 @@
33 package domain_test
44
55 import (
6+ "strconv"
67 "strings"
78 "testing"
89 "time"
@@ -23,7 +24,7 @@ func oid(t *testing.T, c byte) domain.ObjectID {
2324 func TestDefaultRepoSettings(t *testing.T) {
2425 t.Parallel()
2526 s := domain.DefaultRepoSettings()
26- assert.Equal(t, []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash}, s.AllowedMergeModes)
27+ assert.Equal(t, []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash, domain.MergeModeMerge}, s.AllowedMergeModes)
2728 assert.True(t, s.FailingChecksBlock)
2829 assert.True(t, s.Allows(domain.MergeModeSquash))
2930 assert.False(t, domain.RepoSettings{}.Allows(domain.MergeModeSquash))
@@ -39,7 +40,7 @@ func TestNormalizeMergeModes(t *testing.T) {
3940 t.Parallel()
4041 got, err := domain.NormalizeMergeModes([]domain.MergeMode{domain.MergeModeSquash, domain.MergeModeFastForward, domain.MergeModeSquash})
4142 require.NoError(t, err)
42- assert.Equal(t, domain.MergeModes(), got)
43+ assert.Equal(t, []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash}, got)
4344
4445 got, err = domain.NormalizeMergeModes([]domain.MergeMode{domain.MergeModeSquash})
4546 require.NoError(t, err)
@@ -56,7 +57,10 @@ func TestParseMergeMode(t *testing.T) {
5657 m, err := domain.ParseMergeMode("fast-forward")
5758 require.NoError(t, err)
5859 assert.Equal(t, domain.MergeModeFastForward, m)
59- _, err = domain.ParseMergeMode("merge")
60+ m, err = domain.ParseMergeMode("merge")
61+ require.NoError(t, err)
62+ assert.Equal(t, domain.MergeModeMerge, m)
63+ _, err = domain.ParseMergeMode("rebase")
6064 assert.ErrorIs(t, err, domain.ErrInvalid)
6165 }
6266
@@ -283,3 +287,46 @@ func TestReviewScopeOwnerRights(t *testing.T) {
283287 assert.Equal(t, tc.want, domain.AuthorName(tc.p))
284288 }
285289 }
290+
291+func TestNewMergeCommit(t *testing.T) {
292+ t.Parallel()
293+ title, err := domain.ParseMRTitle("Add things")
294+ require.NoError(t, err)
295+ mr := domain.MergeRequest{Number: 7, Title: title, Source: "feat/x", Target: "main"}
296+ at := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
297+ c := domain.NewMergeCommit(mr, oid(t, 'a'), oid(t, 'b'), oid(t, 'c'), domain.Identity{Name: "ermite", Email: "e@x"}, at)
298+ assert.Equal(t, []domain.ObjectID{oid(t, 'b'), oid(t, 'c')}, c.Parents)
299+ assert.Equal(t, c.Author, c.Committer)
300+ want := "tree " + strings.Repeat("a", 40) + "\nparent " + strings.Repeat("b", 40) + "\nparent " + strings.Repeat("c", 40) + "\n" +
301+ "author ermite <e@x> " + strconv.FormatInt(at.Unix(), 10) + " +0000\ncommitter ermite <e@x> " + strconv.FormatInt(at.Unix(), 10) + " +0000\n\n" +
302+ "Merge branch 'feat/x' into main (!7)\n\nAdd things\n"
303+ assert.Equal(t, want, string(c.Payload()))
304+ assert.Contains(t, string(c.Signed("-----BEGIN-----\nabc\n-----END-----\n")), "gpgsig -----BEGIN-----\n abc\n -----END-----\n")
305+}
306+
307+func TestMergeabilityAllowsAndIsFor(t *testing.T) {
308+ t.Parallel()
309+ for _, tc := range []struct {
310+ state domain.MergeabilityState
311+ ff, squash, mg bool
312+ }{
313+ {domain.MergeabilityUpToDate, true, true, true},
314+ {domain.MergeabilityBehind, false, true, true},
315+ {domain.MergeabilityConflict, false, false, false},
316+ {domain.MergeabilityChecking, false, false, false},
317+ } {
318+ m := domain.Mergeability{State: tc.state}
319+ ok, reason := m.Allows(domain.MergeModeFastForward)
320+ assert.Equal(t, tc.ff, ok, tc.state)
321+ assert.Equal(t, !ok, reason != "")
322+ ok, _ = m.Allows(domain.MergeModeSquash)
323+ assert.Equal(t, tc.squash, ok, tc.state)
324+ ok, _ = m.Allows(domain.MergeModeMerge)
325+ assert.Equal(t, tc.mg, ok, tc.state)
326+ }
327+ m := domain.Mergeability{State: domain.MergeabilityBehind, TargetID: oid(t, 'a'), HeadID: oid(t, 'b')}
328+ assert.True(t, m.IsFor(oid(t, 'a'), oid(t, 'b')))
329+ assert.False(t, m.IsFor(oid(t, 'c'), oid(t, 'b')))
330+ assert.False(t, m.IsFor(oid(t, 'a'), oid(t, 'c')))
331+ assert.False(t, domain.Mergeability{}.IsFor(domain.ObjectID{}, domain.ObjectID{}))
332+}
A internal/domain/mergetree_bound_test.go +53 -0
@@ -0,0 +1,53 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain_test
4+
5+import (
6+ "strconv"
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 TestMergeTreeResultBounded(t *testing.T) {
16+ var paths []string
17+ for i := 0; i < domain.MaxConflictPaths+2; i++ {
18+ paths = append(paths, strconv.Itoa(i))
19+ }
20+ got := domain.MergeTreeResult{ConflictPaths: paths}.Bounded()
21+ assert.Len(t, got.ConflictPaths, domain.MaxConflictPaths)
22+ assert.True(t, got.Truncated)
23+
24+ got = domain.MergeTreeResult{ConflictPaths: paths[:domain.MaxConflictPaths]}.Bounded()
25+ assert.Len(t, got.ConflictPaths, domain.MaxConflictPaths)
26+ assert.False(t, got.Truncated)
27+}
28+
29+func TestBoundBehind(t *testing.T) {
30+ n, capped := domain.BoundBehind(domain.MaxBehindCount)
31+ assert.Equal(t, domain.MaxBehindCount, n)
32+ assert.False(t, capped)
33+ n, capped = domain.BoundBehind(domain.BehindLimit)
34+ assert.Equal(t, domain.MaxBehindCount, n)
35+ assert.True(t, capped)
36+}
37+
38+func TestMergeTreeResultBoundedCopiesThePaths(t *testing.T) {
39+ var paths []string
40+ for i := 0; i < domain.MaxConflictPaths+2; i++ {
41+ paths = append(paths, strconv.Itoa(i))
42+ }
43+ got := domain.MergeTreeResult{ConflictPaths: paths}.Bounded()
44+ require.Len(t, got.ConflictPaths, domain.MaxConflictPaths)
45+ assert.Equal(t, len(got.ConflictPaths), cap(got.ConflictPaths))
46+ assert.NotSame(t, &paths[0], &got.ConflictPaths[0], "a new array, not the git output's")
47+ assert.Equal(t, paths[:domain.MaxConflictPaths], got.ConflictPaths)
48+
49+ got = domain.MergeTreeResult{ConflictPaths: paths[:3:10]}.Bounded()
50+ assert.Equal(t, 3, cap(got.ConflictPaths))
51+ assert.False(t, got.Truncated)
52+ assert.Nil(t, domain.MergeTreeResult{}.Bounded().ConflictPaths)
53+}
M internal/ports/ci.go +3 -0
@@ -93,6 +93,9 @@ type CIRefTipStore interface {
9393 GetRefTip(ctx context.Context, repo domain.RepoName, ref string) (domain.CIRefTip, error)
9494 // DeleteRefTip removes the record of a ref; a missing one is not an error.
9595 DeleteRefTip(ctx context.Context, repo domain.RepoName, ref string) error
96+ // DeleteRefTipIf removes the record of a ref only when it still names
97+ // sha; a missing or different one is left alone and is not an error.
98+ DeleteRefTipIf(ctx context.Context, repo domain.RepoName, ref, sha string) error
9699 }
97100
98101 // CIWorkflowStore keeps, per repository, the workflow files that are disabled
M internal/ports/dto.go +8 -2
@@ -198,6 +198,9 @@ type MergeRequestPage struct {
198198 MergeRequests []domain.MergeRequest
199199 // NextPageToken is empty on the last page.
200200 NextPageToken string
201+ // Mergeability holds, by number, the mergeability of the open requests of
202+ // the page (nil when the server does not compute it).
203+ Mergeability map[int64]domain.Mergeability
201204 }
202205
203206 // ListIssuesParams selects a page of issues from an IssueStore.
@@ -262,6 +265,8 @@ type MergeRequestDetail struct {
262265 Comments []domain.Comment
263266 // Verdicts are the latest verdict per author in note ID order.
264267 Verdicts []domain.AuthorVerdict
268+ // Mergeability is zero unless the request is open.
269+ Mergeability domain.Mergeability
265270 // LastReadID is the head recorded by MarkRead; zero when none.
266271 LastReadID domain.ObjectID
267272 // LastReadLost tells that since-last-read was asked for, a head was
@@ -319,8 +324,9 @@ type MergeParams struct {
319324 // MergeResult is a merged request and what happened to its source branch.
320325 type MergeResult struct {
321326 domain.MergeRequest
322- // SourceDeleted is true when DeleteSource was asked and the branch is
323- // gone.
327+ // SourceDeleted is true when DeleteSource was asked: the deletion is
328+ // requested, and done asynchronously after the merge returned (it is
329+ // skipped if the branch moved meanwhile).
324330 SourceDeleted bool
325331 }
326332
M internal/ports/mocks/CIRefTipStore.go +69 -0
@@ -110,6 +110,75 @@ func (_c *CIRefTipStore_DeleteRefTip_Call) RunAndReturn(run func(ctx context.Con
110110 return _c
111111 }
112112
113+// DeleteRefTipIf provides a mock function for the type CIRefTipStore
114+func (_mock *CIRefTipStore) DeleteRefTipIf(ctx context.Context, repo domain.RepoName, ref string, sha string) error {
115+ ret := _mock.Called(ctx, repo, ref, sha)
116+
117+ if len(ret) == 0 {
118+ panic("no return value specified for DeleteRefTipIf")
119+ }
120+
121+ var r0 error
122+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, string, string) error); ok {
123+ r0 = returnFunc(ctx, repo, ref, sha)
124+ } else {
125+ r0 = ret.Error(0)
126+ }
127+ return r0
128+}
129+
130+// CIRefTipStore_DeleteRefTipIf_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteRefTipIf'
131+type CIRefTipStore_DeleteRefTipIf_Call struct {
132+ *mock.Call
133+}
134+
135+// DeleteRefTipIf is a helper method to define mock.On call
136+// - ctx context.Context
137+// - repo domain.RepoName
138+// - ref string
139+// - sha string
140+func (_e *CIRefTipStore_Expecter) DeleteRefTipIf(ctx any, repo any, ref any, sha any) *CIRefTipStore_DeleteRefTipIf_Call {
141+ return &CIRefTipStore_DeleteRefTipIf_Call{Call: _e.mock.On("DeleteRefTipIf", ctx, repo, ref, sha)}
142+}
143+
144+func (_c *CIRefTipStore_DeleteRefTipIf_Call) Run(run func(ctx context.Context, repo domain.RepoName, ref string, sha string)) *CIRefTipStore_DeleteRefTipIf_Call {
145+ _c.Call.Run(func(args mock.Arguments) {
146+ var arg0 context.Context
147+ if args[0] != nil {
148+ arg0 = args[0].(context.Context)
149+ }
150+ var arg1 domain.RepoName
151+ if args[1] != nil {
152+ arg1 = args[1].(domain.RepoName)
153+ }
154+ var arg2 string
155+ if args[2] != nil {
156+ arg2 = args[2].(string)
157+ }
158+ var arg3 string
159+ if args[3] != nil {
160+ arg3 = args[3].(string)
161+ }
162+ run(
163+ arg0,
164+ arg1,
165+ arg2,
166+ arg3,
167+ )
168+ })
169+ return _c
170+}
171+
172+func (_c *CIRefTipStore_DeleteRefTipIf_Call) Return(err error) *CIRefTipStore_DeleteRefTipIf_Call {
173+ _c.Call.Return(err)
174+ return _c
175+}
176+
177+func (_c *CIRefTipStore_DeleteRefTipIf_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, ref string, sha string) error) *CIRefTipStore_DeleteRefTipIf_Call {
178+ _c.Call.Return(run)
179+ return _c
180+}
181+
113182 // GetRefTip provides a mock function for the type CIRefTipStore
114183 func (_mock *CIRefTipStore) GetRefTip(ctx context.Context, repo domain.RepoName, ref string) (domain.CIRefTip, error) {
115184 ret := _mock.Called(ctx, repo, ref)
M internal/ports/mocks/MergeGit.go +162 -0
@@ -191,6 +191,90 @@ func (_c *MergeGit_CommitTree_Call) RunAndReturn(run func(ctx context.Context, r
191191 return _c
192192 }
193193
194+// CountBehind provides a mock function for the type MergeGit
195+func (_mock *MergeGit) CountBehind(ctx context.Context, repo domain.RepoName, head domain.ObjectID, target domain.ObjectID, limit int) (int, error) {
196+ ret := _mock.Called(ctx, repo, head, target, limit)
197+
198+ if len(ret) == 0 {
199+ panic("no return value specified for CountBehind")
200+ }
201+
202+ var r0 int
203+ var r1 error
204+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, domain.ObjectID, domain.ObjectID, int) (int, error)); ok {
205+ return returnFunc(ctx, repo, head, target, limit)
206+ }
207+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, domain.ObjectID, domain.ObjectID, int) int); ok {
208+ r0 = returnFunc(ctx, repo, head, target, limit)
209+ } else {
210+ r0 = ret.Get(0).(int)
211+ }
212+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, domain.ObjectID, domain.ObjectID, int) error); ok {
213+ r1 = returnFunc(ctx, repo, head, target, limit)
214+ } else {
215+ r1 = ret.Error(1)
216+ }
217+ return r0, r1
218+}
219+
220+// MergeGit_CountBehind_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CountBehind'
221+type MergeGit_CountBehind_Call struct {
222+ *mock.Call
223+}
224+
225+// CountBehind is a helper method to define mock.On call
226+// - ctx context.Context
227+// - repo domain.RepoName
228+// - head domain.ObjectID
229+// - target domain.ObjectID
230+// - limit int
231+func (_e *MergeGit_Expecter) CountBehind(ctx any, repo any, head any, target any, limit any) *MergeGit_CountBehind_Call {
232+ return &MergeGit_CountBehind_Call{Call: _e.mock.On("CountBehind", ctx, repo, head, target, limit)}
233+}
234+
235+func (_c *MergeGit_CountBehind_Call) Run(run func(ctx context.Context, repo domain.RepoName, head domain.ObjectID, target domain.ObjectID, limit int)) *MergeGit_CountBehind_Call {
236+ _c.Call.Run(func(args mock.Arguments) {
237+ var arg0 context.Context
238+ if args[0] != nil {
239+ arg0 = args[0].(context.Context)
240+ }
241+ var arg1 domain.RepoName
242+ if args[1] != nil {
243+ arg1 = args[1].(domain.RepoName)
244+ }
245+ var arg2 domain.ObjectID
246+ if args[2] != nil {
247+ arg2 = args[2].(domain.ObjectID)
248+ }
249+ var arg3 domain.ObjectID
250+ if args[3] != nil {
251+ arg3 = args[3].(domain.ObjectID)
252+ }
253+ var arg4 int
254+ if args[4] != nil {
255+ arg4 = args[4].(int)
256+ }
257+ run(
258+ arg0,
259+ arg1,
260+ arg2,
261+ arg3,
262+ arg4,
263+ )
264+ })
265+ return _c
266+}
267+
268+func (_c *MergeGit_CountBehind_Call) Return(n int, err error) *MergeGit_CountBehind_Call {
269+ _c.Call.Return(n, err)
270+ return _c
271+}
272+
273+func (_c *MergeGit_CountBehind_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, head domain.ObjectID, target domain.ObjectID, limit int) (int, error)) *MergeGit_CountBehind_Call {
274+ _c.Call.Return(run)
275+ return _c
276+}
277+
194278 // DeleteBranch provides a mock function for the type MergeGit
195279 func (_mock *MergeGit) DeleteBranch(ctx context.Context, repo domain.RepoName, branch string, old domain.ObjectID) error {
196280 ret := _mock.Called(ctx, repo, branch, old)
@@ -670,6 +754,84 @@ func (_c *MergeGit_MergeBase_Call) RunAndReturn(run func(ctx context.Context, re
670754 return _c
671755 }
672756
757+// MergeTree provides a mock function for the type MergeGit
758+func (_mock *MergeGit) MergeTree(ctx context.Context, repo domain.RepoName, base domain.ObjectID, head domain.ObjectID) (domain.MergeTreeResult, error) {
759+ ret := _mock.Called(ctx, repo, base, head)
760+
761+ if len(ret) == 0 {
762+ panic("no return value specified for MergeTree")
763+ }
764+
765+ var r0 domain.MergeTreeResult
766+ var r1 error
767+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, domain.ObjectID, domain.ObjectID) (domain.MergeTreeResult, error)); ok {
768+ return returnFunc(ctx, repo, base, head)
769+ }
770+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, domain.ObjectID, domain.ObjectID) domain.MergeTreeResult); ok {
771+ r0 = returnFunc(ctx, repo, base, head)
772+ } else {
773+ r0 = ret.Get(0).(domain.MergeTreeResult)
774+ }
775+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, domain.ObjectID, domain.ObjectID) error); ok {
776+ r1 = returnFunc(ctx, repo, base, head)
777+ } else {
778+ r1 = ret.Error(1)
779+ }
780+ return r0, r1
781+}
782+
783+// MergeGit_MergeTree_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'MergeTree'
784+type MergeGit_MergeTree_Call struct {
785+ *mock.Call
786+}
787+
788+// MergeTree is a helper method to define mock.On call
789+// - ctx context.Context
790+// - repo domain.RepoName
791+// - base domain.ObjectID
792+// - head domain.ObjectID
793+func (_e *MergeGit_Expecter) MergeTree(ctx any, repo any, base any, head any) *MergeGit_MergeTree_Call {
794+ return &MergeGit_MergeTree_Call{Call: _e.mock.On("MergeTree", ctx, repo, base, head)}
795+}
796+
797+func (_c *MergeGit_MergeTree_Call) Run(run func(ctx context.Context, repo domain.RepoName, base domain.ObjectID, head domain.ObjectID)) *MergeGit_MergeTree_Call {
798+ _c.Call.Run(func(args mock.Arguments) {
799+ var arg0 context.Context
800+ if args[0] != nil {
801+ arg0 = args[0].(context.Context)
802+ }
803+ var arg1 domain.RepoName
804+ if args[1] != nil {
805+ arg1 = args[1].(domain.RepoName)
806+ }
807+ var arg2 domain.ObjectID
808+ if args[2] != nil {
809+ arg2 = args[2].(domain.ObjectID)
810+ }
811+ var arg3 domain.ObjectID
812+ if args[3] != nil {
813+ arg3 = args[3].(domain.ObjectID)
814+ }
815+ run(
816+ arg0,
817+ arg1,
818+ arg2,
819+ arg3,
820+ )
821+ })
822+ return _c
823+}
824+
825+func (_c *MergeGit_MergeTree_Call) Return(mergeTreeResult domain.MergeTreeResult, err error) *MergeGit_MergeTree_Call {
826+ _c.Call.Return(mergeTreeResult, err)
827+ return _c
828+}
829+
830+func (_c *MergeGit_MergeTree_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, base domain.ObjectID, head domain.ObjectID) (domain.MergeTreeResult, error)) *MergeGit_MergeTree_Call {
831+ _c.Call.Return(run)
832+ return _c
833+}
834+
673835 // UpdateBranch provides a mock function for the type MergeGit
674836 func (_mock *MergeGit) UpdateBranch(ctx context.Context, repo domain.RepoName, branch string, old domain.ObjectID, new domain.ObjectID) error {
675837 ret := _mock.Called(ctx, repo, branch, old, new)
M internal/ports/mocks/MergeRequestService.go +78 -0
@@ -942,6 +942,84 @@ func (_c *MergeRequestService_Merge_Call) RunAndReturn(run func(ctx context.Cont
942942 return _c
943943 }
944944
945+// Mergeability provides a mock function for the type MergeRequestService
946+func (_mock *MergeRequestService) Mergeability(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.Mergeability, error) {
947+ ret := _mock.Called(ctx, p, name, number)
948+
949+ if len(ret) == 0 {
950+ panic("no return value specified for Mergeability")
951+ }
952+
953+ var r0 domain.Mergeability
954+ var r1 error
955+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64) (domain.Mergeability, error)); ok {
956+ return returnFunc(ctx, p, name, number)
957+ }
958+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64) domain.Mergeability); ok {
959+ r0 = returnFunc(ctx, p, name, number)
960+ } else {
961+ r0 = ret.Get(0).(domain.Mergeability)
962+ }
963+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, int64) error); ok {
964+ r1 = returnFunc(ctx, p, name, number)
965+ } else {
966+ r1 = ret.Error(1)
967+ }
968+ return r0, r1
969+}
970+
971+// MergeRequestService_Mergeability_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Mergeability'
972+type MergeRequestService_Mergeability_Call struct {
973+ *mock.Call
974+}
975+
976+// Mergeability is a helper method to define mock.On call
977+// - ctx context.Context
978+// - p domain.Principal
979+// - name domain.RepoName
980+// - number int64
981+func (_e *MergeRequestService_Expecter) Mergeability(ctx any, p any, name any, number any) *MergeRequestService_Mergeability_Call {
982+ return &MergeRequestService_Mergeability_Call{Call: _e.mock.On("Mergeability", ctx, p, name, number)}
983+}
984+
985+func (_c *MergeRequestService_Mergeability_Call) Run(run func(ctx context.Context, p domain.Principal, name domain.RepoName, number int64)) *MergeRequestService_Mergeability_Call {
986+ _c.Call.Run(func(args mock.Arguments) {
987+ var arg0 context.Context
988+ if args[0] != nil {
989+ arg0 = args[0].(context.Context)
990+ }
991+ var arg1 domain.Principal
992+ if args[1] != nil {
993+ arg1 = args[1].(domain.Principal)
994+ }
995+ var arg2 domain.RepoName
996+ if args[2] != nil {
997+ arg2 = args[2].(domain.RepoName)
998+ }
999+ var arg3 int64
1000+ if args[3] != nil {
1001+ arg3 = args[3].(int64)
1002+ }
1003+ run(
1004+ arg0,
1005+ arg1,
1006+ arg2,
1007+ arg3,
1008+ )
1009+ })
1010+ return _c
1011+}
1012+
1013+func (_c *MergeRequestService_Mergeability_Call) Return(mergeability domain.Mergeability, err error) *MergeRequestService_Mergeability_Call {
1014+ _c.Call.Return(mergeability, err)
1015+ return _c
1016+}
1017+
1018+func (_c *MergeRequestService_Mergeability_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.Mergeability, error)) *MergeRequestService_Mergeability_Call {
1019+ _c.Call.Return(run)
1020+ return _c
1021+}
1022+
9451023 // ResolveComment provides a mock function for the type MergeRequestService
9461024 func (_mock *MergeRequestService) ResolveComment(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, commentID int64, resolved bool) (domain.Comment, error) {
9471025 ret := _mock.Called(ctx, p, name, number, commentID, resolved)
M internal/ports/mocks/MergeRequestStore.go +192 -0
@@ -179,6 +179,69 @@ func (_c *MergeRequestStore_Create_Call) RunAndReturn(run func(ctx context.Conte
179179 return _c
180180 }
181181
182+// DeleteMergeability provides a mock function for the type MergeRequestStore
183+func (_mock *MergeRequestStore) DeleteMergeability(ctx context.Context, repo domain.RepoName, number int64) error {
184+ ret := _mock.Called(ctx, repo, number)
185+
186+ if len(ret) == 0 {
187+ panic("no return value specified for DeleteMergeability")
188+ }
189+
190+ var r0 error
191+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, int64) error); ok {
192+ r0 = returnFunc(ctx, repo, number)
193+ } else {
194+ r0 = ret.Error(0)
195+ }
196+ return r0
197+}
198+
199+// MergeRequestStore_DeleteMergeability_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteMergeability'
200+type MergeRequestStore_DeleteMergeability_Call struct {
201+ *mock.Call
202+}
203+
204+// DeleteMergeability is a helper method to define mock.On call
205+// - ctx context.Context
206+// - repo domain.RepoName
207+// - number int64
208+func (_e *MergeRequestStore_Expecter) DeleteMergeability(ctx any, repo any, number any) *MergeRequestStore_DeleteMergeability_Call {
209+ return &MergeRequestStore_DeleteMergeability_Call{Call: _e.mock.On("DeleteMergeability", ctx, repo, number)}
210+}
211+
212+func (_c *MergeRequestStore_DeleteMergeability_Call) Run(run func(ctx context.Context, repo domain.RepoName, number int64)) *MergeRequestStore_DeleteMergeability_Call {
213+ _c.Call.Run(func(args mock.Arguments) {
214+ var arg0 context.Context
215+ if args[0] != nil {
216+ arg0 = args[0].(context.Context)
217+ }
218+ var arg1 domain.RepoName
219+ if args[1] != nil {
220+ arg1 = args[1].(domain.RepoName)
221+ }
222+ var arg2 int64
223+ if args[2] != nil {
224+ arg2 = args[2].(int64)
225+ }
226+ run(
227+ arg0,
228+ arg1,
229+ arg2,
230+ )
231+ })
232+ return _c
233+}
234+
235+func (_c *MergeRequestStore_DeleteMergeability_Call) Return(err error) *MergeRequestStore_DeleteMergeability_Call {
236+ _c.Call.Return(err)
237+ return _c
238+}
239+
240+func (_c *MergeRequestStore_DeleteMergeability_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, number int64) error) *MergeRequestStore_DeleteMergeability_Call {
241+ _c.Call.Return(run)
242+ return _c
243+}
244+
182245 // Get provides a mock function for the type MergeRequestStore
183246 func (_mock *MergeRequestStore) Get(ctx context.Context, repo domain.RepoName, number int64) (domain.MergeRequest, error) {
184247 ret := _mock.Called(ctx, repo, number)
@@ -329,6 +392,78 @@ func (_c *MergeRequestStore_GetComment_Call) RunAndReturn(run func(ctx context.C
329392 return _c
330393 }
331394
395+// GetMergeability provides a mock function for the type MergeRequestStore
396+func (_mock *MergeRequestStore) GetMergeability(ctx context.Context, repo domain.RepoName, number int64) (domain.Mergeability, error) {
397+ ret := _mock.Called(ctx, repo, number)
398+
399+ if len(ret) == 0 {
400+ panic("no return value specified for GetMergeability")
401+ }
402+
403+ var r0 domain.Mergeability
404+ var r1 error
405+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, int64) (domain.Mergeability, error)); ok {
406+ return returnFunc(ctx, repo, number)
407+ }
408+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, int64) domain.Mergeability); ok {
409+ r0 = returnFunc(ctx, repo, number)
410+ } else {
411+ r0 = ret.Get(0).(domain.Mergeability)
412+ }
413+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, int64) error); ok {
414+ r1 = returnFunc(ctx, repo, number)
415+ } else {
416+ r1 = ret.Error(1)
417+ }
418+ return r0, r1
419+}
420+
421+// MergeRequestStore_GetMergeability_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetMergeability'
422+type MergeRequestStore_GetMergeability_Call struct {
423+ *mock.Call
424+}
425+
426+// GetMergeability is a helper method to define mock.On call
427+// - ctx context.Context
428+// - repo domain.RepoName
429+// - number int64
430+func (_e *MergeRequestStore_Expecter) GetMergeability(ctx any, repo any, number any) *MergeRequestStore_GetMergeability_Call {
431+ return &MergeRequestStore_GetMergeability_Call{Call: _e.mock.On("GetMergeability", ctx, repo, number)}
432+}
433+
434+func (_c *MergeRequestStore_GetMergeability_Call) Run(run func(ctx context.Context, repo domain.RepoName, number int64)) *MergeRequestStore_GetMergeability_Call {
435+ _c.Call.Run(func(args mock.Arguments) {
436+ var arg0 context.Context
437+ if args[0] != nil {
438+ arg0 = args[0].(context.Context)
439+ }
440+ var arg1 domain.RepoName
441+ if args[1] != nil {
442+ arg1 = args[1].(domain.RepoName)
443+ }
444+ var arg2 int64
445+ if args[2] != nil {
446+ arg2 = args[2].(int64)
447+ }
448+ run(
449+ arg0,
450+ arg1,
451+ arg2,
452+ )
453+ })
454+ return _c
455+}
456+
457+func (_c *MergeRequestStore_GetMergeability_Call) Return(mergeability domain.Mergeability, err error) *MergeRequestStore_GetMergeability_Call {
458+ _c.Call.Return(mergeability, err)
459+ return _c
460+}
461+
462+func (_c *MergeRequestStore_GetMergeability_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, number int64) (domain.Mergeability, error)) *MergeRequestStore_GetMergeability_Call {
463+ _c.Call.Return(run)
464+ return _c
465+}
466+
332467 // GetRead provides a mock function for the type MergeRequestStore
333468 func (_mock *MergeRequestStore) GetRead(ctx context.Context, repo domain.RepoName, number int64) (domain.ObjectID, error) {
334469 ret := _mock.Called(ctx, repo, number)
@@ -845,6 +980,63 @@ func (_c *MergeRequestStore_OpenByTarget_Call) RunAndReturn(run func(ctx context
845980 return _c
846981 }
847982
983+// PutMergeability provides a mock function for the type MergeRequestStore
984+func (_mock *MergeRequestStore) PutMergeability(ctx context.Context, m domain.Mergeability) error {
985+ ret := _mock.Called(ctx, m)
986+
987+ if len(ret) == 0 {
988+ panic("no return value specified for PutMergeability")
989+ }
990+
991+ var r0 error
992+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Mergeability) error); ok {
993+ r0 = returnFunc(ctx, m)
994+ } else {
995+ r0 = ret.Error(0)
996+ }
997+ return r0
998+}
999+
1000+// MergeRequestStore_PutMergeability_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PutMergeability'
1001+type MergeRequestStore_PutMergeability_Call struct {
1002+ *mock.Call
1003+}
1004+
1005+// PutMergeability is a helper method to define mock.On call
1006+// - ctx context.Context
1007+// - m domain.Mergeability
1008+func (_e *MergeRequestStore_Expecter) PutMergeability(ctx any, m any) *MergeRequestStore_PutMergeability_Call {
1009+ return &MergeRequestStore_PutMergeability_Call{Call: _e.mock.On("PutMergeability", ctx, m)}
1010+}
1011+
1012+func (_c *MergeRequestStore_PutMergeability_Call) Run(run func(ctx context.Context, m domain.Mergeability)) *MergeRequestStore_PutMergeability_Call {
1013+ _c.Call.Run(func(args mock.Arguments) {
1014+ var arg0 context.Context
1015+ if args[0] != nil {
1016+ arg0 = args[0].(context.Context)
1017+ }
1018+ var arg1 domain.Mergeability
1019+ if args[1] != nil {
1020+ arg1 = args[1].(domain.Mergeability)
1021+ }
1022+ run(
1023+ arg0,
1024+ arg1,
1025+ )
1026+ })
1027+ return _c
1028+}
1029+
1030+func (_c *MergeRequestStore_PutMergeability_Call) Return(err error) *MergeRequestStore_PutMergeability_Call {
1031+ _c.Call.Return(err)
1032+ return _c
1033+}
1034+
1035+func (_c *MergeRequestStore_PutMergeability_Call) RunAndReturn(run func(ctx context.Context, m domain.Mergeability) error) *MergeRequestStore_PutMergeability_Call {
1036+ _c.Call.Return(run)
1037+ return _c
1038+}
1039+
8481040 // SaveComment provides a mock function for the type MergeRequestStore
8491041 func (_mock *MergeRequestStore) SaveComment(ctx context.Context, mr domain.MergeRequest, c domain.Comment) error {
8501042 ret := _mock.Called(ctx, mr, c)
A internal/ports/mocks/MergeabilityReader.go +111 -0
@@ -0,0 +1,111 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ "bnema.dev/ermite/internal/domain"
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// NewMergeabilityReader creates a new instance of MergeabilityReader. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func NewMergeabilityReader(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *MergeabilityReader {
20+ if helper, ok := t.(interface{ Helper() }); ok {
21+ helper.Helper()
22+ }
23+
24+ mock := &MergeabilityReader{}
25+ mock.Mock.Test(t)
26+
27+ t.Cleanup(func() {
28+ if helper, ok := t.(interface{ Helper() }); ok {
29+ helper.Helper()
30+ }
31+ mock.AssertExpectations(t)
32+ })
33+
34+ return mock
35+}
36+
37+// MergeabilityReader is an autogenerated mock type for the MergeabilityReader type
38+type MergeabilityReader struct {
39+ mock.Mock
40+}
41+
42+type MergeabilityReader_Expecter struct {
43+ mock *mock.Mock
44+}
45+
46+func (_m *MergeabilityReader) EXPECT() *MergeabilityReader_Expecter {
47+ return &MergeabilityReader_Expecter{mock: &_m.Mock}
48+}
49+
50+// Read provides a mock function for the type MergeabilityReader
51+func (_mock *MergeabilityReader) Read(ctx context.Context, mr domain.MergeRequest, targetTip domain.ObjectID) domain.Mergeability {
52+ ret := _mock.Called(ctx, mr, targetTip)
53+
54+ if len(ret) == 0 {
55+ panic("no return value specified for Read")
56+ }
57+
58+ var r0 domain.Mergeability
59+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.MergeRequest, domain.ObjectID) domain.Mergeability); ok {
60+ r0 = returnFunc(ctx, mr, targetTip)
61+ } else {
62+ r0 = ret.Get(0).(domain.Mergeability)
63+ }
64+ return r0
65+}
66+
67+// MergeabilityReader_Read_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Read'
68+type MergeabilityReader_Read_Call struct {
69+ *mock.Call
70+}
71+
72+// Read is a helper method to define mock.On call
73+// - ctx context.Context
74+// - mr domain.MergeRequest
75+// - targetTip domain.ObjectID
76+func (_e *MergeabilityReader_Expecter) Read(ctx any, mr any, targetTip any) *MergeabilityReader_Read_Call {
77+ return &MergeabilityReader_Read_Call{Call: _e.mock.On("Read", ctx, mr, targetTip)}
78+}
79+
80+func (_c *MergeabilityReader_Read_Call) Run(run func(ctx context.Context, mr domain.MergeRequest, targetTip domain.ObjectID)) *MergeabilityReader_Read_Call {
81+ _c.Call.Run(func(args mock.Arguments) {
82+ var arg0 context.Context
83+ if args[0] != nil {
84+ arg0 = args[0].(context.Context)
85+ }
86+ var arg1 domain.MergeRequest
87+ if args[1] != nil {
88+ arg1 = args[1].(domain.MergeRequest)
89+ }
90+ var arg2 domain.ObjectID
91+ if args[2] != nil {
92+ arg2 = args[2].(domain.ObjectID)
93+ }
94+ run(
95+ arg0,
96+ arg1,
97+ arg2,
98+ )
99+ })
100+ return _c
101+}
102+
103+func (_c *MergeabilityReader_Read_Call) Return(mergeability domain.Mergeability) *MergeabilityReader_Read_Call {
104+ _c.Call.Return(mergeability)
105+ return _c
106+}
107+
108+func (_c *MergeabilityReader_Read_Call) RunAndReturn(run func(ctx context.Context, mr domain.MergeRequest, targetTip domain.ObjectID) domain.Mergeability) *MergeabilityReader_Read_Call {
109+ _c.Call.Return(run)
110+ return _c
111+}
A internal/ports/mocks/MergeabilityTrigger.go +146 -0
@@ -0,0 +1,146 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ "bnema.dev/ermite/internal/domain"
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// NewMergeabilityTrigger creates a new instance of MergeabilityTrigger. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func NewMergeabilityTrigger(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *MergeabilityTrigger {
20+ if helper, ok := t.(interface{ Helper() }); ok {
21+ helper.Helper()
22+ }
23+
24+ mock := &MergeabilityTrigger{}
25+ mock.Mock.Test(t)
26+
27+ t.Cleanup(func() {
28+ if helper, ok := t.(interface{ Helper() }); ok {
29+ helper.Helper()
30+ }
31+ mock.AssertExpectations(t)
32+ })
33+
34+ return mock
35+}
36+
37+// MergeabilityTrigger is an autogenerated mock type for the MergeabilityTrigger type
38+type MergeabilityTrigger struct {
39+ mock.Mock
40+}
41+
42+type MergeabilityTrigger_Expecter struct {
43+ mock *mock.Mock
44+}
45+
46+func (_m *MergeabilityTrigger) EXPECT() *MergeabilityTrigger_Expecter {
47+ return &MergeabilityTrigger_Expecter{mock: &_m.Mock}
48+}
49+
50+// OnBranchesMoved provides a mock function for the type MergeabilityTrigger
51+func (_mock *MergeabilityTrigger) OnBranchesMoved(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate) {
52+ _mock.Called(ctx, repo, updates)
53+ return
54+}
55+
56+// MergeabilityTrigger_OnBranchesMoved_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OnBranchesMoved'
57+type MergeabilityTrigger_OnBranchesMoved_Call struct {
58+ *mock.Call
59+}
60+
61+// OnBranchesMoved is a helper method to define mock.On call
62+// - ctx context.Context
63+// - repo domain.RepoName
64+// - updates []domain.RefUpdate
65+func (_e *MergeabilityTrigger_Expecter) OnBranchesMoved(ctx any, repo any, updates any) *MergeabilityTrigger_OnBranchesMoved_Call {
66+ return &MergeabilityTrigger_OnBranchesMoved_Call{Call: _e.mock.On("OnBranchesMoved", ctx, repo, updates)}
67+}
68+
69+func (_c *MergeabilityTrigger_OnBranchesMoved_Call) Run(run func(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate)) *MergeabilityTrigger_OnBranchesMoved_Call {
70+ _c.Call.Run(func(args mock.Arguments) {
71+ var arg0 context.Context
72+ if args[0] != nil {
73+ arg0 = args[0].(context.Context)
74+ }
75+ var arg1 domain.RepoName
76+ if args[1] != nil {
77+ arg1 = args[1].(domain.RepoName)
78+ }
79+ var arg2 []domain.RefUpdate
80+ if args[2] != nil {
81+ arg2 = args[2].([]domain.RefUpdate)
82+ }
83+ run(
84+ arg0,
85+ arg1,
86+ arg2,
87+ )
88+ })
89+ return _c
90+}
91+
92+func (_c *MergeabilityTrigger_OnBranchesMoved_Call) Return() *MergeabilityTrigger_OnBranchesMoved_Call {
93+ _c.Call.Return()
94+ return _c
95+}
96+
97+func (_c *MergeabilityTrigger_OnBranchesMoved_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate)) *MergeabilityTrigger_OnBranchesMoved_Call {
98+ _c.Run(run)
99+ return _c
100+}
101+
102+// OnOpened provides a mock function for the type MergeabilityTrigger
103+func (_mock *MergeabilityTrigger) OnOpened(ctx context.Context, mr domain.MergeRequest) {
104+ _mock.Called(ctx, mr)
105+ return
106+}
107+
108+// MergeabilityTrigger_OnOpened_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OnOpened'
109+type MergeabilityTrigger_OnOpened_Call struct {
110+ *mock.Call
111+}
112+
113+// OnOpened is a helper method to define mock.On call
114+// - ctx context.Context
115+// - mr domain.MergeRequest
116+func (_e *MergeabilityTrigger_Expecter) OnOpened(ctx any, mr any) *MergeabilityTrigger_OnOpened_Call {
117+ return &MergeabilityTrigger_OnOpened_Call{Call: _e.mock.On("OnOpened", ctx, mr)}
118+}
119+
120+func (_c *MergeabilityTrigger_OnOpened_Call) Run(run func(ctx context.Context, mr domain.MergeRequest)) *MergeabilityTrigger_OnOpened_Call {
121+ _c.Call.Run(func(args mock.Arguments) {
122+ var arg0 context.Context
123+ if args[0] != nil {
124+ arg0 = args[0].(context.Context)
125+ }
126+ var arg1 domain.MergeRequest
127+ if args[1] != nil {
128+ arg1 = args[1].(domain.MergeRequest)
129+ }
130+ run(
131+ arg0,
132+ arg1,
133+ )
134+ })
135+ return _c
136+}
137+
138+func (_c *MergeabilityTrigger_OnOpened_Call) Return() *MergeabilityTrigger_OnOpened_Call {
139+ _c.Call.Return()
140+ return _c
141+}
142+
143+func (_c *MergeabilityTrigger_OnOpened_Call) RunAndReturn(run func(ctx context.Context, mr domain.MergeRequest)) *MergeabilityTrigger_OnOpened_Call {
144+ _c.Run(run)
145+ return _c
146+}
M internal/ports/ports.go +37 -0
@@ -377,6 +377,17 @@ type MergeRequestStore interface {
377377 // domain.ErrConflict when concurrent writes to the repository keep it
378378 // from landing.
379379 SetRead(ctx context.Context, repo domain.RepoName, number int64, head domain.ObjectID) error
380+ // GetMergeability returns the stored mergeability of an open merge request,
381+ // or domain.ErrNotFound when there is none.
382+ GetMergeability(ctx context.Context, repo domain.RepoName, number int64) (domain.Mergeability, error)
383+ // PutMergeability stores the mergeability. A Version of 0 requires that
384+ // none is stored, otherwise the stored one must be unchanged since it was
385+ // read (domain.ErrConflict). It returns domain.ErrNotFound when the merge
386+ // request is not stored or not open, so that a late write never resurrects
387+ // a record.
388+ PutMergeability(ctx context.Context, m domain.Mergeability) error
389+ // DeleteMergeability removes the stored mergeability; no error when absent.
390+ DeleteMergeability(ctx context.Context, repo domain.RepoName, number int64) error
380391 }
381392
382393 // MergeGit is what merge requests need from the Git repositories beyond
@@ -412,6 +423,32 @@ type MergeGit interface {
412423 // DeleteBranch deletes refs/heads/<branch> if it still points to old. It
413424 // returns domain.ErrConflict when the branch moved or is gone.
414425 DeleteBranch(ctx context.Context, repo domain.RepoName, branch string, old domain.ObjectID) error
426+ // MergeTree merges head into base without creating a commit. When the
427+ // merge is not clean (or the commits share no history) Clean is false and
428+ // ConflictPaths lists every conflicted path, unbounded: the caller bounds
429+ // them (MergeTreeResult.Bounded).
430+ MergeTree(ctx context.Context, repo domain.RepoName, base, head domain.ObjectID) (domain.MergeTreeResult, error)
431+ // CountBehind counts the commits of target that head lacks, stopping at
432+ // limit: the raw count, at most limit. The caller interprets it
433+ // (domain.BoundBehind).
434+ CountBehind(ctx context.Context, repo domain.RepoName, head, target domain.ObjectID, limit int) (n int, err error)
435+}
436+
437+// MergeabilityTrigger queues mergeability checks. It only queues the work and
438+// returns.
439+type MergeabilityTrigger interface {
440+ // OnBranchesMoved queues the open merge requests whose source or target
441+ // branch was moved or deleted by updates.
442+ OnBranchesMoved(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate)
443+ // OnOpened queues a newly opened merge request.
444+ OnOpened(ctx context.Context, mr domain.MergeRequest)
445+}
446+
447+// MergeabilityReader reads the mergeability of open merge requests.
448+type MergeabilityReader interface {
449+ // Read returns the stored mergeability for mr and targetTip, or a
450+ // "checking" one (and queues a check) when there is none or it is stale.
451+ Read(ctx context.Context, mr domain.MergeRequest, targetTip domain.ObjectID) domain.Mergeability
415452 }
416453
417454 // CommitSigner signs the commits ermite creates (ADR 0022). The private key
M internal/ports/services.go +4 -0
@@ -124,6 +124,10 @@ type MergeRequestService interface {
124124 // CountOpen is the number of open merge requests of a repository the
125125 // principal can read. It reads no Git: the tab of every page shows it.
126126 CountOpen(ctx context.Context, p domain.Principal, name domain.RepoName) (int, error)
127+ // Mergeability is the mergeability of an open merge request, "checking"
128+ // until the server has a result for the current tips; zero for a request
129+ // that is not open. It reads no diff: the live pages call it on each change.
130+ Mergeability(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.Mergeability, error)
127131 }
128132
129133 // IssueService is the issue use cases. *usecase.Issues implements it.
M internal/usecase/ci_unit_test.go +1 -0
@@ -54,6 +54,7 @@ func newMergeUnit(t *testing.T, block bool) *mergeUnit {
5454 u.git.EXPECT().BranchHead(mock.Anything, u.repo.Name, "feat").Return(m.head, nil)
5555 u.git.EXPECT().BranchHead(mock.Anything, u.repo.Name, "main").Return(tip, nil)
5656 u.git.EXPECT().IsAncestor(mock.Anything, u.repo.Name, tip, m.head).Return(true, nil)
57+ u.git.EXPECT().IsAncestor(mock.Anything, u.repo.Name, m.head, tip).Return(false, nil)
5758 return m
5859 }
5960
M internal/usecase/events.go +5 -1
@@ -198,6 +198,8 @@ func (e *Events) snapshot(ctx context.Context, ev domain.Event) bufferedEvent {
198198 return repoSnapshot(ev.Visibility, ev.CreatedAt)
199199 case domain.CommentResolved:
200200 return repoSnapshot(ev.Visibility, ev.CreatedAt)
201+ case domain.MergeRequestMergeability:
202+ return repoSnapshot(ev.Visibility, ev.CreatedAt)
201203 case domain.RunUpdated:
202204 return repoSnapshot(ev.Visibility, ev.CreatedAt)
203205 case domain.JobUpdated:
@@ -541,6 +543,8 @@ func (e *Events) deliver(ctx context.Context, w *watch, be bufferedEvent, send f
541543 return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)
542544 case domain.CommentResolved:
543545 return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)
546+ case domain.MergeRequestMergeability:
547+ return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)
544548 case domain.RunUpdated:
545549 // Home shows the CI state of each repository.
546550 return e.deliverRepo(ctx, w, ev.Run.Repo, false, be, out, send)
@@ -645,7 +649,7 @@ func isRepoLifecycleEvent(ev domain.Event) bool {
645649 func isItemEvent(ev domain.Event) bool {
646650 switch ev.(type) {
647651 case domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed,
648- domain.MergeRequestReady, domain.CommentAdded,
652+ domain.MergeRequestReady, domain.MergeRequestMergeability, domain.CommentAdded,
649653 domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved:
650654 return true
651655 }
M internal/usecase/events_test.go +13 -0
@@ -997,3 +997,16 @@ func TestNoteMutationEventVisibility(t *testing.T) {
997997 }
998998 })
999999 }
1000+
1001+func TestMergeabilityEventReachesOwnerTopic(t *testing.T) {
1002+ synctest.Test(t, func(t *testing.T) {
1003+ r := newEventRig(t, 0)
1004+ priv := r.repo("priv", domain.VisibilityPrivate)
1005+ ctx, cancel := context.WithCancel(context.Background())
1006+ defer cancel()
1007+ owner := r.watch(ctx, domain.Root(), ports.WatchParams{Topics: []string{"repo:priv"}})
1008+ synctest.Wait()
1009+ r.feed <- domain.MergeRequestMergeability{Repo: priv, Number: 1, Visibility: domain.VisibilityPrivate, CreatedAt: now, At: now}
1010+ assert.Equal(t, priv, owner.next(t).Event.(domain.MergeRequestMergeability).Repo)
1011+ })
1012+}
M internal/usecase/export_test.go +46 -0
@@ -161,3 +161,49 @@ func (s *IssueSubmissions) FillSpent(n int, exp time.Time) {
161161
162162 // MaxSpent is the bound of the spent set.
163163 const MaxSpent = maxSpent
164+
165+// SetAfterMergeGate makes the post-merge goroutine of m run f before anything
166+// else, to place a test between a merge and its side effects.
167+func (m *MergeRequests) SetAfterMergeGate(f func()) { m.afterGate = f }
168+
169+// ScheduledRetries gives the number of retries waiting in the scheduler.
170+func (s *Mergeability) ScheduledRetries() int {
171+ s.retryMu.Lock()
172+ defer s.retryMu.Unlock()
173+ return len(s.heap)
174+}
175+
176+// RetryAttempts gives the number of repositories whose
177+// failed attempts are counted.
178+func (s *Mergeability) RetryAttempts() int {
179+ s.retryMu.Lock()
180+ defer s.retryMu.Unlock()
181+ return len(s.retries)
182+}
183+
184+// ReconcileForTest runs a repository pass synchronously without workers.
185+func (s *Mergeability) ReconcileForTest(repo domain.RepoName) bool { return s.reconcile(repo) }
186+
187+// RetryForTest schedules a retry of repo as a failed pass does.
188+func (s *Mergeability) RetryForTest(repo domain.RepoName) { s.retry(repo) }
189+
190+// RetryDoneForTest forgets the retries of repo as a successful pass does.
191+func (s *Mergeability) RetryDoneForTest(repo domain.RepoName) { s.retryDone(repo) }
192+
193+// RetryDueForTest gives the deadline of the retry scheduled for repo.
194+func (s *Mergeability) RetryDueForTest(repo domain.RepoName) (time.Time, bool) {
195+ s.retryMu.Lock()
196+ defer s.retryMu.Unlock()
197+ for _, it := range s.heap {
198+ if it.key == repo {
199+ return it.due, true
200+ }
201+ }
202+ return time.Time{}, false
203+}
204+
205+// PopDueForTest is the scheduler step taken when the timer armed for the
206+// retry (repo, due) fires. It gives the repositories it released.
207+func (s *Mergeability) PopDueForTest(repo domain.RepoName, due time.Time) []domain.RepoName {
208+ return s.popDue(retryItem{key: repo, due: due})
209+}
A internal/usecase/mergeability.go +467 -0
@@ -0,0 +1,467 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
6+ "context"
7+ "errors"
8+ "slices"
9+ "strings"
10+ "sync"
11+ "time"
12+
13+ "bnema.dev/ermite/internal/domain"
14+ "bnema.dev/ermite/internal/ports"
15+)
16+
17+const (
18+ defaultMergeabilityWorkers = 2
19+ mergeabilityRetries = 5
20+ mergeabilityRetryInitial = time.Second
21+ mergeabilityRetryMax = time.Minute
22+)
23+
24+// mergeBackoff is the delay before retry number attempt (1-based).
25+func mergeBackoff(attempt int) time.Duration {
26+ d := mergeabilityRetryInitial
27+ for i := 1; i < attempt; i++ {
28+ d *= 2
29+ if d >= mergeabilityRetryMax {
30+ return mergeabilityRetryMax
31+ }
32+ }
33+ return d
34+}
35+
36+// MergeabilityConfig holds the dependencies of the repository reconciler.
37+type MergeabilityConfig struct {
38+ Store ports.MergeRequestStore
39+ Meta ports.RepoMetaStore
40+ Git ports.MergeGit
41+ Bus ports.EventBus
42+ Clock ports.Clock
43+ Workers int // defaults to 2
44+ // After is injectable to drive backoff without wall-clock waits in tests.
45+ After func(time.Duration) <-chan time.Time
46+}
47+
48+// Mergeability reconciles derived records, with one writer per repository.
49+// Its work and retry state are bounded by the number of repositories.
50+type Mergeability struct {
51+ cfg MergeabilityConfig
52+ ctx context.Context
53+ cancel context.CancelFunc
54+ wg sync.WaitGroup
55+ mu sync.Mutex
56+ dirty map[domain.RepoName]bool
57+ order []domain.RepoName
58+ running, again map[domain.RepoName]bool
59+ wake chan struct{}
60+ retryMu sync.Mutex
61+ retries map[domain.RepoName]int
62+ scheduled map[domain.RepoName]bool
63+ heap []retryItem
64+ retryKick chan struct{}
65+ closed bool
66+}
67+
68+// MergeabilityHook is the unchanged trigger and reader contract.
69+type MergeabilityHook interface {
70+ ports.MergeabilityTrigger
71+ ports.MergeabilityReader
72+}
73+
74+var _ MergeabilityHook = (*Mergeability)(nil)
75+
76+// NewMergeability builds a reconciler whose lifetime is controlled by Close.
77+func NewMergeability(ctx context.Context, cfg MergeabilityConfig) *Mergeability {
78+ if cfg.Workers <= 0 {
79+ cfg.Workers = defaultMergeabilityWorkers
80+ }
81+ if cfg.After == nil {
82+ cfg.After = time.After
83+ }
84+ ctx, cancel := context.WithCancel(context.WithoutCancel(ctx))
85+ return &Mergeability{cfg: cfg, ctx: ctx, cancel: cancel,
86+ dirty: map[domain.RepoName]bool{}, running: map[domain.RepoName]bool{}, again: map[domain.RepoName]bool{}, wake: make(chan struct{}, 1),
87+ retries: map[domain.RepoName]int{}, scheduled: map[domain.RepoName]bool{}, retryKick: make(chan struct{}, 1)}
88+}
89+
90+// Start starts workers and discovers repositories, retrying failed discovery.
91+func (s *Mergeability) Start() {
92+ s.wg.Add(2 + s.cfg.Workers)
93+ go s.scheduler()
94+ go func() {
95+ defer s.wg.Done()
96+ for attempt := 0; attempt <= mergeabilityRetries; attempt++ {
97+ repos, err := s.cfg.Meta.List(s.ctx)
98+ if err != nil && s.ctx.Err() == nil {
99+ ctxLog(s.ctx).Error().Err(err).Int("attempt", attempt+1).Msg("mergeability: list the repositories at startup")
100+ }
101+ if err == nil {
102+ for _, repo := range repos {
103+ s.markDirty(repo.Name)
104+ }
105+ return
106+ }
107+ if attempt == mergeabilityRetries {
108+ return
109+ }
110+ select {
111+ case <-s.ctx.Done():
112+ return
113+ case <-s.cfg.After(mergeBackoff(attempt + 1)):
114+ }
115+ }
116+ }()
117+ for range s.cfg.Workers {
118+ go s.loop()
119+ }
120+}
121+
122+// Close cancels workers and pending retry waits, then waits for them.
123+func (s *Mergeability) Close(_ context.Context) {
124+ s.retryMu.Lock()
125+ s.closed = true
126+ s.retryMu.Unlock()
127+ s.cancel()
128+ s.wg.Wait()
129+}
130+
131+func (s *Mergeability) signal() {
132+ select {
133+ case s.wake <- struct{}{}:
134+ default:
135+ }
136+}
137+
138+func (s *Mergeability) markDirty(repo domain.RepoName) {
139+ s.mu.Lock()
140+ if s.running[repo] {
141+ s.again[repo] = true
142+ } else if !s.dirty[repo] {
143+ s.dirty[repo] = true
144+ s.order = append(s.order, repo)
145+ }
146+ s.mu.Unlock()
147+ s.signal()
148+}
149+
150+func (s *Mergeability) next() (domain.RepoName, bool) {
151+ s.mu.Lock()
152+ defer s.mu.Unlock()
153+ if len(s.order) == 0 {
154+ return domain.RepoName{}, false
155+ }
156+ repo := s.order[0]
157+ s.order = s.order[1:]
158+ delete(s.dirty, repo)
159+ s.running[repo] = true
160+ if len(s.order) > 0 {
161+ s.signal()
162+ }
163+ return repo, true
164+}
165+
166+func (s *Mergeability) loop() {
167+ defer s.wg.Done()
168+ for s.ctx.Err() == nil {
169+ repo, found := s.next()
170+ if !found {
171+ select {
172+ case <-s.ctx.Done():
173+ return
174+ case <-s.wake:
175+ }
176+ continue
177+ }
178+ if s.reconcile(repo) {
179+ s.retryDone(repo)
180+ } else {
181+ s.retry(repo)
182+ }
183+ s.mu.Lock()
184+ delete(s.running, repo)
185+ if s.again[repo] {
186+ delete(s.again, repo)
187+ s.dirty[repo] = true
188+ s.order = append(s.order, repo)
189+ s.signal()
190+ }
191+ s.mu.Unlock()
192+ }
193+}
194+
195+// OnOpened implements ports.MergeabilityTrigger.
196+func (s *Mergeability) OnOpened(_ context.Context, mr domain.MergeRequest) { s.markDirty(mr.Repo) }
197+
198+// OnBranchesMoved dirties the repository for any branch update.
199+func (s *Mergeability) OnBranchesMoved(_ context.Context, repo domain.RepoName, updates []domain.RefUpdate) {
200+ for _, update := range updates {
201+ if strings.HasPrefix(update.Ref, "refs/heads/") {
202+ s.markDirty(repo)
203+ return
204+ }
205+ }
206+}
207+
208+// Read returns a current record, or checking and requests reconciliation.
209+func (s *Mergeability) Read(ctx context.Context, mr domain.MergeRequest, targetTip domain.ObjectID) domain.Mergeability {
210+ if !mr.HeadID.IsZero() && !targetTip.IsZero() {
211+ rec, err := s.cfg.Store.GetMergeability(ctx, mr.Repo, mr.Number)
212+ if err == nil && rec.IsFor(targetTip, mr.HeadID) {
213+ return rec
214+ }
215+ }
216+ s.markDirty(mr.Repo)
217+ return domain.Mergeability{Repo: mr.Repo, Number: mr.Number, State: domain.MergeabilityChecking}
218+}
219+
220+// reconcile settles each open request for the tips read in this pass. A push
221+// during computation requests another pass; old-tip records are harmless.
222+func (s *Mergeability) reconcile(repo domain.RepoName) bool {
223+ ctx := s.ctx
224+ meta, err := s.cfg.Meta.Get(ctx, repo)
225+ if errors.Is(err, domain.ErrNotFound) {
226+ return true
227+ }
228+ if err != nil {
229+ s.logFailure(ctx, err, "get the repository", repo, 0)
230+ return false
231+ }
232+ mrs, err := s.cfg.Store.ListOpen(ctx, repo)
233+ if err != nil {
234+ s.logFailure(ctx, err, "list the open merge requests", repo, 0)
235+ return false
236+ }
237+ ok := true
238+ for _, mr := range mrs {
239+ if ctx.Err() != nil {
240+ return true
241+ }
242+ head, tip, found, transient := s.tips(ctx, mr)
243+ if !found {
244+ ok = ok && !transient
245+ continue
246+ }
247+ prev, err := s.cfg.Store.GetMergeability(ctx, repo, mr.Number)
248+ if err != nil && !errors.Is(err, domain.ErrNotFound) {
249+ s.logFailure(ctx, err, "get the mergeability", repo, mr.Number)
250+ ok = false
251+ continue
252+ }
253+ if prev.IsFor(tip, head) {
254+ continue
255+ }
256+ res, err := s.compute(ctx, repo, mr.Number, tip, head)
257+ if err != nil {
258+ s.logFailure(ctx, err, "compute the mergeability", repo, mr.Number)
259+ ok = false
260+ continue
261+ }
262+ res.Version = prev.Version
263+ err = s.cfg.Store.PutMergeability(ctx, res)
264+ if errors.Is(err, domain.ErrNotFound) {
265+ continue
266+ }
267+ if err != nil {
268+ if !errors.Is(err, domain.ErrConflict) { // retried by the next pass
269+ s.logFailure(ctx, err, "store the mergeability", repo, mr.Number)
270+ }
271+ ok = false
272+ continue
273+ }
274+ s.cfg.Bus.Publish(ctx, domain.MergeRequestMergeability{
275+ Repo: repo, Number: mr.Number, Mergeability: res, At: res.CheckedAt,
276+ Visibility: meta.Visibility, CreatedAt: meta.CreatedAt,
277+ })
278+ }
279+ return ok
280+}
281+
282+// logFailure logs an unexpected failure; cancellation is expected on Close.
283+func (s *Mergeability) logFailure(ctx context.Context, err error, op string, repo domain.RepoName, number int64) {
284+ if ctx.Err() != nil || errors.Is(err, context.Canceled) || errors.Is(err, domain.ErrNotFound) {
285+ return
286+ }
287+ e := ctxLog(ctx).Error().Err(err).Str("repo", repo.String())
288+ if number != 0 {
289+ e = e.Int64("number", number)
290+ }
291+ e.Msg("mergeability: " + op)
292+}
293+
294+// retryItem is one scheduled retry.
295+type retryItem struct {
296+ due time.Time
297+ key domain.RepoName
298+}
299+
300+// retry schedules a retry of key after a bounded exponential backoff. It is
301+// deduplicated per key, gives up after mergeabilityRetries attempts and stops
302+// on Close. All retries share one scheduler goroutine.
303+func (s *Mergeability) retry(key domain.RepoName) {
304+ s.retryMu.Lock()
305+ defer s.retryMu.Unlock()
306+ if s.closed || s.scheduled[key] {
307+ return
308+ }
309+ s.retries[key]++
310+ n := s.retries[key]
311+ if n > mergeabilityRetries {
312+ delete(s.retries, key)
313+ return
314+ }
315+ s.scheduled[key] = true
316+ it := retryItem{due: s.cfg.Clock.Now().Add(mergeBackoff(n)), key: key}
317+ i, _ := slices.BinarySearchFunc(s.heap, it, func(a, b retryItem) int {
318+ if a.due.After(b.due) {
319+ return 1
320+ }
321+ return -1 // equal dues keep their insertion order
322+ })
323+ s.heap = slices.Insert(s.heap, i, it)
324+ if i == 0 {
325+ select {
326+ case s.retryKick <- struct{}{}:
327+ default:
328+ }
329+ }
330+}
331+
332+// scheduler is the only goroutine that waits for retries: it sleeps until the
333+// earliest is due and puts it back in the normal queue.
334+func (s *Mergeability) scheduler() {
335+ defer s.wg.Done()
336+ // The timer armed for waited; a kick re-arms it only when the earliest
337+ // retry changed, so a stale kick never starts a second timer.
338+ var (
339+ after <-chan time.Time
340+ waited retryItem
341+ )
342+ for {
343+ s.retryMu.Lock()
344+ var head retryItem
345+ has := len(s.heap) > 0
346+ if has {
347+ head = s.heap[0]
348+ }
349+ s.retryMu.Unlock()
350+ if !has {
351+ after = nil
352+ select {
353+ case <-s.ctx.Done():
354+ return
355+ case <-s.retryKick:
356+ continue
357+ }
358+ }
359+ if after == nil || head.key != waited.key || !head.due.Equal(waited.due) {
360+ after, waited = s.cfg.After(max(0, head.due.Sub(s.cfg.Clock.Now()))), head
361+ }
362+ select {
363+ case <-s.ctx.Done():
364+ return
365+ case <-s.retryKick:
366+ continue // an earlier retry may have arrived
367+ case <-after:
368+ after = nil
369+ }
370+ for _, k := range s.popDue(waited) {
371+ if s.ctx.Err() != nil {
372+ return
373+ }
374+ s.markDirty(k)
375+ }
376+ }
377+}
378+
379+// popDue removes the waited item and the ones due as well. The waited item is
380+// removed regardless of the clock only when both its key and its deadline
381+// match: a replacement retry of the same repository is kept until it is due.
382+func (s *Mergeability) popDue(waited retryItem) []domain.RepoName {
383+ s.retryMu.Lock()
384+ defer s.retryMu.Unlock()
385+ now := s.cfg.Clock.Now()
386+ var out []domain.RepoName
387+ s.heap = slices.DeleteFunc(s.heap, func(it retryItem) bool {
388+ if !(it.key == waited.key && it.due.Equal(waited.due)) && it.due.After(now) {
389+ return false
390+ }
391+ out = append(out, it.key)
392+ delete(s.scheduled, it.key)
393+ return true
394+ })
395+ return out
396+}
397+
398+// retryDone forgets the failures of key after a success.
399+func (s *Mergeability) retryDone(key domain.RepoName) {
400+ s.retryMu.Lock()
401+ delete(s.retries, key)
402+ delete(s.scheduled, key)
403+ s.heap = slices.DeleteFunc(s.heap, func(it retryItem) bool { return it.key == key })
404+ select {
405+ case s.retryKick <- struct{}{}:
406+ default:
407+ }
408+ s.retryMu.Unlock()
409+}
410+
411+// tips reads the heads of the source and the target of mr. ok is false when
412+// the check is over: a branch is gone (the record is deleted) or Git failed,
413+// which is transient.
414+func (s *Mergeability) tips(ctx context.Context, mr domain.MergeRequest) (head, tip domain.ObjectID, ok, transient bool) {
415+ head, herr := s.cfg.Git.BranchHead(ctx, mr.Repo, mr.Source)
416+ tip, terr := s.cfg.Git.BranchHead(ctx, mr.Repo, mr.Target)
417+ switch {
418+ case errors.Is(herr, domain.ErrNotFound) || errors.Is(terr, domain.ErrNotFound):
419+ if err := s.cfg.Store.DeleteMergeability(ctx, mr.Repo, mr.Number); err != nil {
420+ if ctx.Err() == nil {
421+ ctxLog(ctx).Error().Err(err).Str("repo", mr.Repo.String()).Int64("number", mr.Number).Msg("mergeability: delete the record of a merge request without a branch")
422+ }
423+ transient = ctx.Err() == nil
424+ }
425+ return domain.ObjectID{}, domain.ObjectID{}, false, transient
426+ case herr != nil || terr != nil:
427+ if ctx.Err() != nil {
428+ return domain.ObjectID{}, domain.ObjectID{}, false, false
429+ }
430+ ctxLog(ctx).Error().Err(errors.Join(herr, terr)).Str("repo", mr.Repo.String()).Int64("number", mr.Number).Msg("mergeability: read the branches")
431+ return domain.ObjectID{}, domain.ObjectID{}, false, true
432+ }
433+ return head, tip, true, false
434+}
435+
436+// compute runs the Git reads that decide the state of head against tip.
437+func (s *Mergeability) compute(ctx context.Context, repo domain.RepoName, number int64, tip, head domain.ObjectID) (domain.Mergeability, error) {
438+ m := domain.Mergeability{Repo: repo, Number: number, TargetID: tip, HeadID: head, CheckedAt: s.cfg.Clock.Now().UTC()}
439+ ancestor := tip == head
440+ if !ancestor {
441+ var err error
442+ if ancestor, err = s.cfg.Git.IsAncestor(ctx, repo, tip, head); err != nil {
443+ return m, err
444+ }
445+ }
446+ if ancestor {
447+ m.State = domain.MergeabilityUpToDate
448+ return m, nil
449+ }
450+ tree, err := s.cfg.Git.MergeTree(ctx, repo, tip, head)
451+ if err != nil {
452+ return m, err
453+ }
454+ if !tree.Clean {
455+ m.State = domain.MergeabilityConflict
456+ tree = tree.Bounded()
457+ m.ConflictPaths, m.ConflictPathsTruncated = tree.ConflictPaths, tree.Truncated
458+ return m, nil
459+ }
460+ m.State = domain.MergeabilityBehind
461+ n, err := s.cfg.Git.CountBehind(ctx, repo, head, tip, domain.BehindLimit)
462+ if err != nil {
463+ return m, err
464+ }
465+ m.Behind, m.BehindCapped = domain.BoundBehind(n)
466+ return m, nil
467+}
A internal/usecase/mergeability_reconcile_test.go +91 -0
@@ -0,0 +1,91 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
6+ "context"
7+ "sync/atomic"
8+ "testing"
9+ "time"
10+
11+ "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/mock"
13+ "github.com/stretchr/testify/require"
14+
15+ "bnema.dev/ermite/internal/domain"
16+ "bnema.dev/ermite/internal/ports/mocks"
17+)
18+
19+func TestMergeabilityBackoffIsBounded(t *testing.T) {
20+ assert.Equal(t, time.Second, mergeBackoff(1))
21+ assert.Equal(t, 2*time.Second, mergeBackoff(2))
22+ assert.Equal(t, 4*time.Second, mergeBackoff(3))
23+ assert.Equal(t, time.Minute, mergeBackoff(20))
24+}
25+
26+func TestMergeabilityReadMarksTheRepoDirty(t *testing.T) {
27+ repo, err := domain.ParseRepoName("proj")
28+ require.NoError(t, err)
29+ oid, err := domain.ParseObjectID("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
30+ require.NoError(t, err)
31+ store := mocks.NewMergeRequestStore(t)
32+ store.EXPECT().GetMergeability(mock.Anything, repo, int64(7)).Return(domain.Mergeability{}, domain.ErrNotFound)
33+ svc := NewMergeability(context.Background(), MergeabilityConfig{Store: store})
34+ defer svc.Close(context.Background())
35+ mr := domain.MergeRequest{Repo: repo, Number: 7, HeadID: oid}
36+ assert.Equal(t, domain.MergeabilityChecking, svc.Read(context.Background(), mr, oid).State)
37+ // Another MR in the same repository is the same unit of work.
38+ mr.Number = 8
39+ mr.HeadID = domain.ObjectID{}
40+ svc.Read(context.Background(), mr, oid)
41+ got, found := svc.next()
42+ require.True(t, found)
43+ assert.Equal(t, repo, got)
44+ _, found = svc.next()
45+ assert.False(t, found)
46+}
47+
48+func TestMergeabilityOnePassPerRepoAtATimeAndAgain(t *testing.T) {
49+ repo, err := domain.ParseRepoName("proj")
50+ require.NoError(t, err)
51+ other, err := domain.ParseRepoName("other")
52+ require.NoError(t, err)
53+ meta, store := mocks.NewRepoMetaStore(t), mocks.NewMergeRequestStore(t)
54+ meta.EXPECT().List(mock.Anything).Return(nil, nil)
55+ meta.EXPECT().Get(mock.Anything, repo).Return(domain.Repo{Name: repo}, nil)
56+ meta.EXPECT().Get(mock.Anything, other).Return(domain.Repo{Name: other}, nil)
57+ entered, release, otherPassed := make(chan struct{}, 4), make(chan struct{}), make(chan struct{})
58+ var passes, inflight, peak atomic.Int32
59+ store.EXPECT().ListOpen(mock.Anything, repo).RunAndReturn(func(context.Context, domain.RepoName) ([]domain.MergeRequest, error) {
60+ n := inflight.Add(1)
61+ if n > peak.Load() {
62+ peak.Store(n)
63+ }
64+ pass := passes.Add(1)
65+ entered <- struct{}{}
66+ if pass == 1 {
67+ <-release
68+ }
69+ inflight.Add(-1)
70+ return nil, nil
71+ })
72+ store.EXPECT().ListOpen(mock.Anything, other).RunAndReturn(func(context.Context, domain.RepoName) ([]domain.MergeRequest, error) {
73+ close(otherPassed)
74+ return nil, nil
75+ })
76+ svc := NewMergeability(context.Background(), MergeabilityConfig{Store: store, Meta: meta, Workers: 2})
77+ svc.Start()
78+ svc.OnOpened(context.Background(), domain.MergeRequest{Repo: repo})
79+ <-entered
80+ for range 50 {
81+ svc.OnOpened(context.Background(), domain.MergeRequest{Repo: repo})
82+ }
83+ svc.OnOpened(context.Background(), domain.MergeRequest{Repo: other})
84+ <-otherPassed // the other worker has serviced all work eligible meanwhile
85+ assert.EqualValues(t, 1, passes.Load())
86+ close(release)
87+ <-entered
88+ svc.Close(context.Background())
89+ assert.EqualValues(t, 2, passes.Load(), "all triggers during a pass coalesce into exactly one more pass")
90+ assert.EqualValues(t, 1, peak.Load())
91+}
A internal/usecase/mergeability_test.go +673 -0
@@ -0,0 +1,673 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase_test
4+
5+import (
6+ "context"
7+ "errors"
8+ "strconv"
9+ "strings"
10+ "sync/atomic"
11+ "testing"
12+ "time"
13+
14+ "github.com/stretchr/testify/assert"
15+ "github.com/stretchr/testify/mock"
16+ "github.com/stretchr/testify/require"
17+
18+ "bnema.dev/ermite/internal/domain"
19+ "bnema.dev/ermite/internal/ports"
20+ "bnema.dev/ermite/internal/ports/mocks"
21+ "bnema.dev/ermite/internal/usecase"
22+)
23+
24+func fakeOID(t *testing.T, c string) domain.ObjectID {
25+ t.Helper()
26+ return oidFrom(t, strings.Repeat(c, 40))
27+}
28+
29+// mergeRig runs the mergeability service over the real adapters of mrRig.
30+type mergeRig struct {
31+ *mrRig
32+ svc *usecase.Mergeability
33+ mev <-chan domain.Event
34+ history []domain.MergeRequestMergeability
35+}
36+
37+func newMergeRig(t *testing.T) *mergeRig {
38+ t.Helper()
39+ r := newMRRig(t)
40+ svc := usecase.NewMergeability(r.ctx, usecase.MergeabilityConfig{
41+ Store: r.store.MergeRequests(), Meta: r.store, Git: r.git, Bus: r.bus, Clock: fixedClock{}, Workers: 2,
42+ })
43+ r.mrs = usecase.NewMergeRequests(usecase.MergeRequestsConfig{
44+ Meta: r.store, Store: r.store.MergeRequests(), Repos: r.git, Git: r.git, Signer: nil,
45+ Committer: domain.Identity{Name: "ermite", Email: "ermite@git.example.org"}, Bus: r.bus, Clock: fixedClock{}, Maint: r.sched,
46+ Mergeability: svc,
47+ })
48+ t.Cleanup(func() { svc.Close(context.Background()) })
49+ return &mergeRig{mrRig: r, svc: svc, mev: r.bus.Subscribe(r.ctx)}
50+}
51+
52+func (r *mergeRig) record(n int64) (domain.Mergeability, error) {
53+ return r.store.MergeRequests().GetMergeability(r.ctx, r.repo, n)
54+}
55+
56+func (r *mergeRig) waitState(n int64, want domain.MergeabilityState, target, head domain.ObjectID) domain.Mergeability {
57+ r.t.Helper()
58+ var m domain.Mergeability
59+ deadline := time.After(10 * time.Second)
60+ for {
61+ var err error
62+ m, err = r.record(n)
63+ if err == nil && m.State == want && m.IsFor(target, head) {
64+ break
65+ }
66+ select {
67+ case ev := <-r.mev:
68+ if e, ok := ev.(domain.MergeRequestMergeability); ok {
69+ r.history = append(r.history, e)
70+ }
71+ case <-deadline:
72+ r.t.Fatalf("mergeability of !%d did not become %s", n, want)
73+ }
74+ }
75+
76+ return m
77+}
78+
79+// events returns the mergeability events published so far.
80+func (r *mergeRig) events() []domain.MergeRequestMergeability {
81+ out := r.history
82+ r.history = nil
83+ for {
84+ select {
85+ case ev := <-r.mev:
86+ if m, ok := ev.(domain.MergeRequestMergeability); ok {
87+ out = append(out, m)
88+ }
89+ default:
90+ return out
91+ }
92+ }
93+}
94+
95+// awaitEvents receives mergeability events until n arrived, or fails after a
96+// bound: the workers publish after they stored the record the tests wait for.
97+func (r *mergeRig) awaitEvents(n int) []domain.MergeRequestMergeability {
98+ r.t.Helper()
99+ out := r.history
100+ r.history = nil
101+ deadline := time.After(10 * time.Second)
102+ for len(out) < n {
103+ select {
104+ case ev := <-r.mev:
105+ if m, ok := ev.(domain.MergeRequestMergeability); ok {
106+ out = append(out, m)
107+ }
108+ case <-deadline:
109+ r.t.Fatalf("got %d mergeability events, want %d", len(out), n)
110+ }
111+ }
112+ return out
113+}
114+
115+func TestMergeabilityStatesEndToEnd(t *testing.T) {
116+ r := newMergeRig(t)
117+ r.svc.Start()
118+ main := r.seed()
119+ head := r.feature("feat")
120+ mr := r.open("feat")
121+
122+ // up to date: the target is an ancestor of the head.
123+ m := r.waitState(mr.Number, domain.MergeabilityUpToDate, main, head)
124+ assert.False(t, m.CheckedAt.IsZero())
125+
126+ // behind: the target moved with a change that merges cleanly.
127+ r.sh("checkout", "-q", "main")
128+ main2 := r.commit(author, "d.txt", "dee\n", "add d")
129+ r.push("main")
130+ r.svc.OnBranchesMoved(r.ctx, r.repo, []domain.RefUpdate{{Old: main.String(), New: main2.String(), Ref: "refs/heads/main"}})
131+ m = r.waitState(mr.Number, domain.MergeabilityBehind, main2, head)
132+ assert.Equal(t, 1, m.Behind)
133+ assert.False(t, m.BehindCapped)
134+
135+ // conflict: the target changes a line the head changed.
136+ main3 := r.commit(author, "a.txt", "one\nTWO\nthree\n", "conflict a")
137+ r.push("main")
138+ r.svc.OnBranchesMoved(r.ctx, r.repo, []domain.RefUpdate{{Old: main2.String(), New: main3.String(), Ref: "refs/heads/main"}})
139+ m = r.waitState(mr.Number, domain.MergeabilityConflict, main3, head)
140+ assert.Equal(t, []string{"a.txt"}, m.ConflictPaths)
141+
142+ got := r.awaitEvents(3)
143+ for i, s := range []domain.MergeabilityState{domain.MergeabilityUpToDate, domain.MergeabilityBehind, domain.MergeabilityConflict} {
144+ assert.Equal(t, s, got[i].Mergeability.State)
145+ assert.Equal(t, mr.Number, got[i].Number)
146+ }
147+}
148+
149+func TestMergeabilityReaderIsLazy(t *testing.T) {
150+ r := newMergeRig(t)
151+ main := r.seed()
152+ head := r.feature("feat")
153+ mr := r.open("feat") // queued, but the workers are not started
154+
155+ d, err := r.mrs.Get(r.ctx, root, r.repo, mr.Number, ports.GetMergeRequestOptions{})
156+ require.NoError(t, err)
157+ assert.Equal(t, domain.MergeabilityChecking, d.Mergeability.State)
158+
159+ r.svc.Start()
160+ r.waitState(mr.Number, domain.MergeabilityUpToDate, main, head)
161+ d, err = r.mrs.Get(r.ctx, root, r.repo, mr.Number, ports.GetMergeRequestOptions{})
162+ require.NoError(t, err)
163+ assert.Equal(t, domain.MergeabilityUpToDate, d.Mergeability.State)
164+
165+ page, err := r.mrs.List(r.ctx, root, r.repo, ports.ListMergeRequestsParams{})
166+ require.NoError(t, err)
167+ assert.Equal(t, domain.MergeabilityUpToDate, page.Mergeability[mr.Number].State)
168+}
169+
170+func TestMergeabilityPublishesEachSettledResultOnce(t *testing.T) {
171+ r := newMergeRig(t)
172+ r.seed()
173+ r.feature("feat")
174+ mr := r.open("feat")
175+ r.svc.ReconcileForTest(r.repo)
176+ require.Len(t, r.events(), 1)
177+
178+ // Same tips: nothing is computed, nothing is published.
179+ r.svc.ReconcileForTest(r.repo)
180+ assert.Empty(t, r.events())
181+
182+ // New tips publish even when the state and counts stay the same.
183+ r.sh("checkout", "-q", "feat")
184+ r.commit(author, "e.txt", "e\n", "more")
185+ r.push("feat")
186+ r.svc.ReconcileForTest(r.repo)
187+ assert.Len(t, r.events(), 1)
188+ m, err := r.record(mr.Number)
189+ require.NoError(t, err)
190+ assert.Equal(t, domain.MergeabilityUpToDate, m.State)
191+}
192+
193+func TestMergeabilityMergeReevaluatesOtherRequests(t *testing.T) {
194+ r := newMergeRig(t)
195+ r.svc.Start()
196+ main := r.seed()
197+ r.sh("checkout", "-q", "-B", "one", "main")
198+ oneHead := r.commit(author, "one.txt", "1\n", "one")
199+ r.push("one")
200+ r.sh("checkout", "-q", "-B", "two", "main")
201+ twoHead := r.commit(author, "two.txt", "2\n", "two")
202+ r.push("two")
203+ one, two := r.open("one"), r.open("two")
204+ r.waitState(one.Number, domain.MergeabilityUpToDate, main, oneHead)
205+ r.waitState(two.Number, domain.MergeabilityUpToDate, main, twoHead)
206+
207+ _, err := r.mrs.Merge(r.ctx, mrM, r.repo, one.Number, ports.MergeParams{HeadID: oneHead})
208+ require.NoError(t, err)
209+ m := r.waitState(two.Number, domain.MergeabilityBehind, oneHead, twoHead)
210+ assert.Equal(t, 1, m.Behind)
211+ _, err = r.record(one.Number)
212+ assert.ErrorIs(t, err, domain.ErrNotFound, "the merged request keeps no record")
213+}
214+
215+func TestMergeabilityClosedRequestRecordDeleted(t *testing.T) {
216+ r := newMergeRig(t)
217+ r.seed()
218+ r.feature("feat")
219+ mr := r.open("feat")
220+ r.svc.ReconcileForTest(r.repo)
221+ _, err := r.record(mr.Number)
222+ require.NoError(t, err)
223+
224+ _, err = r.mrs.Close(r.ctx, mrW, r.repo, mr.Number)
225+ require.NoError(t, err)
226+ _, err = r.record(mr.Number)
227+ assert.ErrorIs(t, err, domain.ErrNotFound)
228+ r.svc.ReconcileForTest(r.repo) // a late check writes nothing
229+ _, err = r.record(mr.Number)
230+ assert.ErrorIs(t, err, domain.ErrNotFound)
231+}
232+
233+func TestMergeabilityStartupRescanComputesMissingRecords(t *testing.T) {
234+ r := newMergeRig(t)
235+ main := r.seed()
236+ head := r.feature("feat")
237+ mr := r.open("feat")
238+ _, err := r.record(mr.Number)
239+ require.ErrorIs(t, err, domain.ErrNotFound)
240+
241+ // A fresh service has received no OnOpened or Read trigger.
242+ svc := usecase.NewMergeability(r.ctx, usecase.MergeabilityConfig{
243+ Store: r.store.MergeRequests(), Meta: r.store, Git: r.git, Bus: r.bus, Clock: fixedClock{},
244+ })
245+ defer svc.Close(context.Background())
246+ svc.Start()
247+ r.waitState(mr.Number, domain.MergeabilityUpToDate, main, head)
248+}
249+
250+func TestMergeabilityNeedsNoCI(t *testing.T) {
251+ // The service is built without any CI: a target move still updates the
252+ // record, and the repository gets no pull ref (the service has no CI to create a run with).
253+ r := newMergeRig(t)
254+ r.svc.Start()
255+ main := r.seed()
256+ head := r.feature("feat")
257+ mr := r.open("feat")
258+ r.waitState(mr.Number, domain.MergeabilityUpToDate, main, head)
259+ r.sh("checkout", "-q", "main")
260+ main2 := r.commit(author, "d.txt", "dee\n", "add d")
261+ r.push("main")
262+ r.svc.OnBranchesMoved(r.ctx, r.repo, []domain.RefUpdate{{Old: main.String(), New: main2.String(), Ref: "refs/heads/main"}})
263+ r.waitState(mr.Number, domain.MergeabilityBehind, main2, head)
264+
265+ assert.Empty(t, r.run(r.bare(), r.env(author), "for-each-ref", "refs/pull"))
266+}
267+
268+// --- unit tests over mocks ---
269+
270+type mockRig struct {
271+ store *mocks.MergeRequestStore
272+ meta *mocks.RepoMetaStore
273+ git *mocks.MergeGit
274+ bus *mocks.EventBus
275+ svc *usecase.Mergeability
276+ mr domain.MergeRequest
277+}
278+
279+func newMockRig(t *testing.T) *mockRig {
280+ t.Helper()
281+ repo := name(t, "proj")
282+ r := &mockRig{
283+ store: mocks.NewMergeRequestStore(t), meta: mocks.NewRepoMetaStore(t), git: mocks.NewMergeGit(t), bus: mocks.NewEventBus(t),
284+ mr: domain.MergeRequest{Repo: repo, Number: 7, Source: "feat", Target: "main", State: domain.MRStateOpen},
285+ }
286+ r.store.EXPECT().ListOpen(mock.Anything, repo).Return([]domain.MergeRequest{r.mr}, nil).Maybe()
287+ r.store.EXPECT().GetMergeability(mock.Anything, repo, int64(7)).Return(domain.Mergeability{}, domain.ErrNotFound).Maybe()
288+ r.meta.EXPECT().List(mock.Anything).Return(nil, nil).Maybe()
289+ r.meta.EXPECT().Get(mock.Anything, repo).Return(domain.Repo{Name: repo, CreatedAt: now}, nil).Maybe()
290+ r.svc = usecase.NewMergeability(context.Background(), usecase.MergeabilityConfig{
291+ Store: r.store, Meta: r.meta, Git: r.git, Bus: r.bus, Clock: fixedClock{},
292+ })
293+ t.Cleanup(func() { r.svc.Close(context.Background()) })
294+ return r
295+}
296+
297+func TestMergeabilityDedupsTriggers(t *testing.T) {
298+ r := newMockRig(t)
299+ repo := r.mr.Repo
300+ head, tip := fakeOID(t, "a"), fakeOID(t, "b")
301+ r.git.EXPECT().BranchHead(mock.Anything, repo, "feat").Return(head, nil)
302+ r.git.EXPECT().BranchHead(mock.Anything, repo, "main").Return(tip, nil)
303+ computed := make(chan struct{}, 1)
304+ var computes atomic.Int32
305+ r.git.EXPECT().IsAncestor(mock.Anything, repo, tip, head).RunAndReturn(
306+ func(context.Context, domain.RepoName, domain.ObjectID, domain.ObjectID) (bool, error) {
307+ computes.Add(1)
308+ computed <- struct{}{}
309+ return true, nil
310+ })
311+ r.store.EXPECT().PutMergeability(mock.Anything, mock.Anything).Return(nil)
312+ r.bus.EXPECT().Publish(mock.Anything, mock.Anything).Return()
313+
314+ for range 50 {
315+ r.svc.OnOpened(context.Background(), r.mr) // queued before any worker runs
316+ }
317+ r.svc.Start()
318+ <-computed
319+ r.svc.Close(context.Background()) // the workers have each looked at the queue once more
320+ assert.EqualValues(t, 1, computes.Load())
321+}
322+
323+func TestMergeabilityNoWriteForClosedRequest(t *testing.T) {
324+ r := newMockRig(t)
325+ repo := r.mr.Repo
326+ head, tip := fakeOID(t, "a"), fakeOID(t, "b")
327+ r.git.EXPECT().BranchHead(mock.Anything, repo, "feat").Return(head, nil)
328+ r.git.EXPECT().BranchHead(mock.Anything, repo, "main").Return(tip, nil)
329+ r.git.EXPECT().IsAncestor(mock.Anything, repo, tip, head).Return(true, nil)
330+ r.store.EXPECT().PutMergeability(mock.Anything, mock.Anything).Return(domain.ErrNotFound).Once() // closed meanwhile
331+ r.svc.ReconcileForTest(repo) // no event, no further write
332+}
333+
334+func TestMergeabilityBoundsConflictPathsAndBehind(t *testing.T) {
335+ t.Run("conflict paths", func(t *testing.T) {
336+ r := newMockRig(t)
337+ repo := r.mr.Repo
338+ head, tip := fakeOID(t, "a"), fakeOID(t, "b")
339+ r.git.EXPECT().BranchHead(mock.Anything, repo, "feat").Return(head, nil)
340+ r.git.EXPECT().BranchHead(mock.Anything, repo, "main").Return(tip, nil)
341+ r.git.EXPECT().IsAncestor(mock.Anything, repo, tip, head).Return(false, nil)
342+ var paths []string
343+ for i := 0; i < domain.MaxConflictPaths+3; i++ {
344+ paths = append(paths, strings.Repeat("p", i+1))
345+ }
346+ r.git.EXPECT().MergeTree(mock.Anything, repo, tip, head).Return(domain.MergeTreeResult{ConflictPaths: paths}, nil)
347+ var put domain.Mergeability
348+ r.store.EXPECT().PutMergeability(mock.Anything, mock.Anything).RunAndReturn(
349+ func(_ context.Context, m domain.Mergeability) error { put = m; return nil })
350+ r.bus.EXPECT().Publish(mock.Anything, mock.Anything).Return()
351+ r.svc.ReconcileForTest(repo)
352+ assert.Equal(t, domain.MergeabilityConflict, put.State)
353+ assert.Len(t, put.ConflictPaths, domain.MaxConflictPaths)
354+ assert.True(t, put.ConflictPathsTruncated)
355+ })
356+ t.Run("behind", func(t *testing.T) {
357+ r := newMockRig(t)
358+ repo := r.mr.Repo
359+ head, tip := fakeOID(t, "a"), fakeOID(t, "b")
360+ r.git.EXPECT().BranchHead(mock.Anything, repo, "feat").Return(head, nil)
361+ r.git.EXPECT().BranchHead(mock.Anything, repo, "main").Return(tip, nil)
362+ r.git.EXPECT().IsAncestor(mock.Anything, repo, tip, head).Return(false, nil)
363+ r.git.EXPECT().MergeTree(mock.Anything, repo, tip, head).Return(domain.MergeTreeResult{Clean: true}, nil)
364+ r.git.EXPECT().CountBehind(mock.Anything, repo, head, tip, domain.MaxBehindCount+1).Return(domain.MaxBehindCount+1, nil)
365+ var put domain.Mergeability
366+ r.store.EXPECT().PutMergeability(mock.Anything, mock.Anything).RunAndReturn(
367+ func(_ context.Context, m domain.Mergeability) error { put = m; return nil })
368+ r.bus.EXPECT().Publish(mock.Anything, mock.Anything).Return()
369+ r.svc.ReconcileForTest(repo)
370+ assert.Equal(t, domain.MaxBehindCount, put.Behind)
371+ assert.True(t, put.BehindCapped)
372+ })
373+}
374+
375+// A transient Git failure is retried once after the backoff, by rescanning
376+// the repository through the queue.
377+func TestMergeabilityRetriesATransientFailureWithBackoff(t *testing.T) {
378+ repo := name(t, "proj")
379+ head, tip := fakeOID(t, "a"), fakeOID(t, "b")
380+ mr := domain.MergeRequest{Repo: repo, Number: 7, Source: "feat", Target: "main", State: domain.MRStateOpen}
381+ store, meta, git, bus := mocks.NewMergeRequestStore(t), mocks.NewRepoMetaStore(t), mocks.NewMergeGit(t), mocks.NewEventBus(t)
382+ store.EXPECT().ListOpen(mock.Anything, repo).Return([]domain.MergeRequest{mr}, nil)
383+ store.EXPECT().GetMergeability(mock.Anything, repo, int64(7)).Return(domain.Mergeability{}, domain.ErrNotFound)
384+ store.EXPECT().PutMergeability(mock.Anything, mock.Anything).Return(nil)
385+ meta.EXPECT().List(mock.Anything).Return(nil, nil)
386+ meta.EXPECT().Get(mock.Anything, repo).Return(domain.Repo{Name: repo, CreatedAt: now}, nil)
387+ var fails atomic.Int32
388+ git.EXPECT().BranchHead(mock.Anything, repo, "feat").RunAndReturn(
389+ func(context.Context, domain.RepoName, string) (domain.ObjectID, error) {
390+ if fails.Add(1) == 1 {
391+ return domain.ObjectID{}, errors.New("transient")
392+ }
393+ return head, nil
394+ })
395+ git.EXPECT().BranchHead(mock.Anything, repo, "main").Return(tip, nil)
396+ git.EXPECT().IsAncestor(mock.Anything, repo, tip, head).Return(true, nil)
397+ published := make(chan struct{})
398+ bus.EXPECT().Publish(mock.Anything, mock.Anything).Run(func(context.Context, domain.Event) { close(published) }).Return().Once()
399+
400+ asked, fire := make(chan time.Duration, 4), make(chan time.Time)
401+ svc := usecase.NewMergeability(context.Background(), usecase.MergeabilityConfig{
402+ Store: store, Meta: meta, Git: git, Bus: bus, Clock: fixedClock{},
403+ After: func(d time.Duration) <-chan time.Time { asked <- d; return fire },
404+ })
405+ t.Cleanup(func() { svc.Close(context.Background()) })
406+ svc.Start()
407+ svc.OnOpened(context.Background(), mr)
408+
409+ assert.Equal(t, time.Second, <-asked)
410+ select {
411+ case <-published:
412+ t.Fatal("published before the backoff elapsed")
413+ default:
414+ }
415+ fire <- time.Time{}
416+ <-published
417+ assert.Empty(t, asked, "one retry was enough and only one was scheduled")
418+}
419+
420+// Close stops a pending retry timer's goroutine.
421+func TestMergeabilityCloseStopsPendingRetries(t *testing.T) {
422+ repo := name(t, "proj")
423+ mr := domain.MergeRequest{Repo: repo, Number: 7, Source: "feat", Target: "main", State: domain.MRStateOpen}
424+ store, meta, git, bus := mocks.NewMergeRequestStore(t), mocks.NewRepoMetaStore(t), mocks.NewMergeGit(t), mocks.NewEventBus(t)
425+ store.EXPECT().ListOpen(mock.Anything, repo).Return(nil, errors.New("db down"))
426+ meta.EXPECT().Get(mock.Anything, repo).Return(domain.Repo{Name: repo}, nil)
427+ meta.EXPECT().List(mock.Anything).Return(nil, nil)
428+ asked := make(chan time.Duration, 4)
429+ svc := usecase.NewMergeability(context.Background(), usecase.MergeabilityConfig{
430+ Store: store, Meta: meta, Git: git, Bus: bus, Clock: fixedClock{},
431+ After: func(d time.Duration) <-chan time.Time { asked <- d; return make(chan time.Time) },
432+ })
433+ svc.Start()
434+ svc.OnOpened(context.Background(), mr)
435+ <-asked
436+ done := make(chan struct{})
437+ go func() { svc.Close(context.Background()); close(done) }()
438+ <-done // Close waits for the timer goroutine: it returns only if it stopped
439+}
440+
441+// Workers are not stopped by the cancellation of the context given to the
442+// constructor; Close stops them.
443+func TestMergeabilityWorkersOutliveTheParentContext(t *testing.T) {
444+ repo := name(t, "proj")
445+ head, tip := fakeOID(t, "a"), fakeOID(t, "b")
446+ mr := domain.MergeRequest{Repo: repo, Number: 7, Source: "feat", Target: "main", State: domain.MRStateOpen}
447+ store, meta, git, bus := mocks.NewMergeRequestStore(t), mocks.NewRepoMetaStore(t), mocks.NewMergeGit(t), mocks.NewEventBus(t)
448+ store.EXPECT().ListOpen(mock.Anything, repo).Return([]domain.MergeRequest{mr}, nil)
449+ store.EXPECT().GetMergeability(mock.Anything, repo, int64(7)).Return(domain.Mergeability{}, domain.ErrNotFound)
450+ store.EXPECT().PutMergeability(mock.Anything, mock.Anything).Return(nil)
451+ meta.EXPECT().List(mock.Anything).Return(nil, nil)
452+ meta.EXPECT().Get(mock.Anything, repo).Return(domain.Repo{Name: repo, CreatedAt: now}, nil)
453+ git.EXPECT().BranchHead(mock.Anything, repo, "feat").Return(head, nil)
454+ git.EXPECT().BranchHead(mock.Anything, repo, "main").Return(tip, nil)
455+ git.EXPECT().IsAncestor(mock.Anything, repo, tip, head).Return(true, nil)
456+ published := make(chan struct{})
457+ bus.EXPECT().Publish(mock.Anything, mock.Anything).Run(func(context.Context, domain.Event) { close(published) }).Return().Once()
458+
459+ parent, cancel := context.WithCancel(context.Background())
460+ svc := usecase.NewMergeability(parent, usecase.MergeabilityConfig{Store: store, Meta: meta, Git: git, Bus: bus, Clock: fixedClock{}})
461+ svc.Start()
462+ cancel()
463+ svc.OnOpened(context.Background(), mr)
464+ <-published // computed although the parent is cancelled
465+ svc.Close(context.Background())
466+}
467+
468+// Failures of many requests of 3 repositories schedule at most one retry per
469+// repository on a single scheduler goroutine.
470+func TestMergeabilityRetriesAreBounded(t *testing.T) {
471+ const repos = 3
472+ store, meta := mocks.NewMergeRequestStore(t), mocks.NewRepoMetaStore(t)
473+ barrier := name(t, "barrier")
474+ done := make(chan struct{})
475+ store.EXPECT().ListOpen(mock.Anything, barrier).RunAndReturn(func(context.Context, domain.RepoName) ([]domain.MergeRequest, error) { close(done); return nil, nil }).Once()
476+ store.EXPECT().ListOpen(mock.Anything, mock.Anything).Return(nil, errors.New("db down"))
477+ meta.EXPECT().Get(mock.Anything, mock.Anything).Return(domain.Repo{}, nil)
478+ meta.EXPECT().List(mock.Anything).Return(nil, nil)
479+ asked := make(chan time.Duration, repos)
480+ svc := usecase.NewMergeability(context.Background(), usecase.MergeabilityConfig{
481+ Store: store, Meta: meta, Clock: fixedClock{}, Workers: 1,
482+ After: func(d time.Duration) <-chan time.Time { asked <- d; return make(chan time.Time) },
483+ })
484+ svc.Start()
485+ for r := range repos {
486+ repo := name(t, "proj"+strconv.Itoa(r))
487+ for range 20 {
488+ svc.OnOpened(context.Background(), domain.MergeRequest{Repo: repo})
489+ }
490+ }
491+ svc.OnOpened(context.Background(), domain.MergeRequest{Repo: barrier})
492+ <-done
493+ // The shared scheduler only arms the earliest pending retry.
494+ <-asked
495+ svc.Close(context.Background())
496+ assert.Equal(t, repos, svc.ScheduledRetries())
497+ assert.Equal(t, repos, svc.RetryAttempts())
498+}
499+
500+// retryRig is a service over mocks whose only repository holds request !7,
501+// whose read fails for its first failFor calls. Each repository rescan signals passes.
502+type retryRig struct {
503+ svc *usecase.Mergeability
504+ repo domain.RepoName
505+ reads atomic.Int32
506+ failFor atomic.Int32
507+ passes chan struct{}
508+ afters chan chan time.Time
509+ barrier chan struct{}
510+}
511+
512+func newRetryRig(t *testing.T) *retryRig {
513+ t.Helper()
514+ r := &retryRig{repo: name(t, "proj"), passes: make(chan struct{}, 32), afters: make(chan chan time.Time, 32), barrier: make(chan struct{})}
515+ store, meta, git, bus := mocks.NewMergeRequestStore(t), mocks.NewRepoMetaStore(t), mocks.NewMergeGit(t), mocks.NewEventBus(t)
516+ r.failFor.Store(1 << 30)
517+ meta.EXPECT().Get(mock.Anything, r.repo).Return(domain.Repo{Name: r.repo}, nil)
518+ store.EXPECT().ListOpen(mock.Anything, r.repo).RunAndReturn(
519+ func(context.Context, domain.RepoName) ([]domain.MergeRequest, error) {
520+ defer func() { r.passes <- struct{}{} }()
521+ if r.reads.Add(1) <= r.failFor.Load() {
522+ return nil, errors.New("db down")
523+ }
524+ return nil, nil
525+ })
526+ barrier := name(t, "barrier")
527+ meta.EXPECT().Get(mock.Anything, barrier).RunAndReturn(func(context.Context, domain.RepoName) (domain.Repo, error) {
528+ r.barrier <- struct{}{}
529+ return domain.Repo{}, domain.ErrNotFound
530+ })
531+ meta.EXPECT().List(mock.Anything).Return([]domain.Repo{{Name: r.repo}}, nil)
532+ r.svc = usecase.NewMergeability(context.Background(), usecase.MergeabilityConfig{
533+ Store: store, Meta: meta, Git: git, Bus: bus, Clock: fixedClock{}, Workers: 1,
534+ After: func(time.Duration) <-chan time.Time { ch := make(chan time.Time, 1); r.afters <- ch; return ch },
535+ })
536+ t.Cleanup(func() { r.svc.Close(context.Background()) })
537+ return r
538+}
539+
540+// next fires the pending retry and waits for the rescan it queues.
541+func (r *retryRig) next() {
542+ ch := <-r.afters
543+ ch <- time.Time{}
544+ <-r.passes
545+}
546+
547+func (r *retryRig) settled(t *testing.T) {
548+ t.Helper()
549+ r.svc.OnOpened(context.Background(), domain.MergeRequest{Repo: name(t, "barrier")})
550+ <-r.barrier
551+ require.Zero(t, r.svc.RetryAttempts())
552+ require.Zero(t, r.svc.ScheduledRetries())
553+}
554+
555+// A persistent failure is retried 5 times, then given up with no state kept.
556+func TestMergeabilityGivesUpAfterFiveRetries(t *testing.T) {
557+ r := newRetryRig(t)
558+ r.svc.Start()
559+ <-r.passes // the startup rescan fails
560+ for range 5 {
561+ r.next()
562+ }
563+ r.settled(t)
564+ assert.Empty(t, r.afters, "no sixth retry")
565+ assert.Empty(t, r.passes)
566+}
567+
568+// A successful full rescan resets the budget: a later failure gets 5 again.
569+func TestMergeabilitySuccessfulPassForgetsTheRetryBudget(t *testing.T) {
570+ r := newRetryRig(t)
571+ r.failFor.Store(2) // the first two reads fail, the third succeeds
572+ r.svc.Start()
573+ <-r.passes
574+ r.next() // second failing pass
575+ r.next() // success
576+ r.settled(t)
577+ r.failFor.Store(1 << 30)
578+ r.svc.OnOpened(context.Background(), domain.MergeRequest{Repo: r.repo})
579+ <-r.passes
580+ for range 5 {
581+ r.next()
582+ }
583+ r.settled(t)
584+ assert.Empty(t, r.afters)
585+ assert.Empty(t, r.passes)
586+}
587+
588+// stepClock is a clock the test moves.
589+type stepClock struct{ at atomic.Int64 }
590+
591+func (c *stepClock) Now() time.Time { return time.Unix(0, c.at.Load()).UTC() }
592+func (c *stepClock) advance(d time.Duration) { c.at.Add(int64(d)) }
593+
594+// An expired timer must not consume a replacement retry of its repository.
595+func TestMergeabilityStaleTimerKeepsReplacementRetry(t *testing.T) {
596+ repo := name(t, "proj")
597+ meta := mocks.NewRepoMetaStore(t)
598+ meta.EXPECT().List(mock.Anything).Return(nil, nil)
599+ clock := &stepClock{}
600+ clock.advance(time.Hour)
601+ armed := make(chan time.Duration, 8)
602+ svc := usecase.NewMergeability(context.Background(), usecase.MergeabilityConfig{
603+ Meta: meta, Clock: clock, Workers: 1,
604+ After: func(d time.Duration) <-chan time.Time { armed <- d; return make(chan time.Time) },
605+ })
606+ t.Cleanup(func() { svc.Close(context.Background()) })
607+ svc.Start()
608+
609+ svc.RetryForTest(repo)
610+ assert.Equal(t, time.Second, <-armed, "the scheduler armed the timer of retry A")
611+ oldDue, ok := svc.RetryDueForTest(repo)
612+ require.True(t, ok)
613+
614+ svc.RetryDoneForTest(repo)
615+ clock.advance(10 * time.Second)
616+ svc.RetryForTest(repo)
617+ newDue, ok := svc.RetryDueForTest(repo)
618+ require.True(t, ok)
619+ require.True(t, newDue.After(oldDue))
620+
621+ // The old timer fires while the replacement is not yet due.
622+ assert.Empty(t, svc.PopDueForTest(repo, oldDue))
623+ assert.Equal(t, 1, svc.ScheduledRetries())
624+ // The timer armed for the replacement itself releases it.
625+ assert.Equal(t, []domain.RepoName{repo}, svc.PopDueForTest(repo, newDue))
626+ assert.Zero(t, svc.ScheduledRetries())
627+}
628+
629+func reconcileMocks(t *testing.T, repo domain.RepoName) (*mocks.MergeRequestStore, *mocks.RepoMetaStore, *mocks.MergeGit, domain.MergeRequest) {
630+ t.Helper()
631+ mr := domain.MergeRequest{Repo: repo, Number: 7, Source: "feat", Target: "main", State: domain.MRStateOpen}
632+ store, meta, git := mocks.NewMergeRequestStore(t), mocks.NewRepoMetaStore(t), mocks.NewMergeGit(t)
633+ meta.EXPECT().Get(mock.Anything, repo).Return(domain.Repo{Name: repo, CreatedAt: now}, nil)
634+ store.EXPECT().ListOpen(mock.Anything, repo).Return([]domain.MergeRequest{mr}, nil)
635+ return store, meta, git, mr
636+}
637+
638+func TestMergeabilityDisappearedBranchDeletesTheRecord(t *testing.T) {
639+ repo := name(t, "proj")
640+ store, meta, git, _ := reconcileMocks(t, repo)
641+ git.EXPECT().BranchHead(mock.Anything, repo, "feat").Return(domain.ObjectID{}, domain.ErrNotFound)
642+ git.EXPECT().BranchHead(mock.Anything, repo, "main").Return(fakeOID(t, "b"), nil)
643+ store.EXPECT().DeleteMergeability(mock.Anything, repo, int64(7)).Return(nil).Once()
644+ svc := usecase.NewMergeability(context.Background(), usecase.MergeabilityConfig{
645+ Store: store, Meta: meta, Git: git, Clock: fixedClock{},
646+ })
647+ t.Cleanup(func() { svc.Close(context.Background()) })
648+ assert.True(t, svc.ReconcileForTest(repo))
649+}
650+
651+func TestMergeabilityConflictMakesThePassFailAndRetries(t *testing.T) {
652+ repo := name(t, "proj")
653+ head, tip := fakeOID(t, "a"), fakeOID(t, "b")
654+ store, meta, git, mr := reconcileMocks(t, repo)
655+ meta.EXPECT().List(mock.Anything).Return(nil, nil)
656+ git.EXPECT().BranchHead(mock.Anything, repo, "feat").Return(head, nil)
657+ git.EXPECT().BranchHead(mock.Anything, repo, "main").Return(tip, nil)
658+ git.EXPECT().IsAncestor(mock.Anything, repo, tip, head).Return(true, nil)
659+ store.EXPECT().GetMergeability(mock.Anything, repo, int64(7)).Return(domain.Mergeability{}, domain.ErrNotFound)
660+ store.EXPECT().PutMergeability(mock.Anything, mock.Anything).Return(domain.ErrConflict)
661+
662+ asked := make(chan time.Duration, 4)
663+ svc := usecase.NewMergeability(context.Background(), usecase.MergeabilityConfig{
664+ Store: store, Meta: meta, Git: git, Clock: fixedClock{}, Workers: 1,
665+ After: func(d time.Duration) <-chan time.Time { asked <- d; return make(chan time.Time) },
666+ })
667+ t.Cleanup(func() { svc.Close(context.Background()) })
668+ assert.False(t, svc.ReconcileForTest(repo))
669+
670+ svc.Start()
671+ svc.OnOpened(context.Background(), mr)
672+ assert.Equal(t, time.Second, <-asked, "the conflict scheduled a retry")
673+}
M internal/usecase/mergerequests.go +246 -70
@@ -8,6 +8,7 @@ import (
88 "errors"
99 "fmt"
1010 "math"
11+ "runtime/debug"
1112 "slices"
1213 "strconv"
1314 "strings"
@@ -56,9 +57,15 @@ type MergeRequests struct {
5657 pulls ports.PullRequestTrigger // nil: no pull_request workflows
5758 tips ports.CIRefTipStore // nil: who merges is not recorded
5859 verifier ports.CommitVerifier // nil: commits are not checked (ADR 0030)
60+ mergeab MergeabilityHook // nil: no mergeability
5961
6062 mu sync.Mutex
6163 locks map[domain.RepoName]chan struct{}
64+
65+ // after tracks the post-merge side effects running off the request path.
66+ after sync.WaitGroup
67+ // afterGate, when set by a test, runs first in the post-merge goroutine.
68+ afterGate func()
6269 }
6370
6471 var (
@@ -95,13 +102,16 @@ type MergeRequestsConfig struct {
95102 // deleted source branch) before the merge does, as a push does
96103 // (domain.CIRefTip). Optional.
97104 RefTips ports.CIRefTipStore
105+ // Mergeability is queued when a request is opened, closed or merged and
106+ // read by Get and List. Optional: without it Mergeability stays zero.
107+ Mergeability MergeabilityHook
98108 }
99109
100110 // NewMergeRequests builds the merge request use cases.
101111 func NewMergeRequests(cfg MergeRequestsConfig) *MergeRequests {
102112 return &MergeRequests{
103113 meta: cfg.Meta, store: cfg.Store, repos: cfg.Repos, git: cfg.Git, signer: cfg.Signer,
104- committer: cfg.Committer, bus: cfg.Bus, clock: cfg.Clock, maint: cfg.Maint, ci: cfg.CI, checks: cfg.Checks, pulls: cfg.PullRequests, tips: cfg.RefTips,
114+ committer: cfg.Committer, bus: cfg.Bus, clock: cfg.Clock, maint: cfg.Maint, ci: cfg.CI, checks: cfg.Checks, pulls: cfg.PullRequests, tips: cfg.RefTips, mergeab: cfg.Mergeability,
105115 locks: make(map[domain.RepoName]chan struct{}),
106116 }
107117 }
@@ -113,6 +123,21 @@ func (m *MergeRequests) WithVerifier(v ports.CommitVerifier) *MergeRequests {
113123 return m
114124 }
115125
126+// Wait waits for the post-merge side effects still running (source branch
127+// deletion, events, CI and mergeability hooks), or for ctx to end. Call it
128+// before closing what they use.
129+func (m *MergeRequests) Wait(ctx context.Context) {
130+ done := make(chan struct{})
131+ go func() {
132+ m.after.Wait()
133+ close(done)
134+ }()
135+ select {
136+ case <-done:
137+ case <-ctx.Done():
138+ }
139+}
140+
116141 // lockRepo takes the merge lock of a repository, waiting until ctx ends. The
117142 // merges of one repository, and the creation of its merge requests, run one at
118143 // a time. The returned function releases the lock.
@@ -255,6 +280,9 @@ func (m *MergeRequests) Create(ctx context.Context, p domain.Principal, name dom
255280 return domain.MergeRequest{}, err
256281 }
257282 m.bus.Publish(ctx, domain.MergeRequestOpened{MergeRequest: mr, At: now, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
283+ if m.mergeab != nil {
284+ m.mergeab.OnOpened(ctx, mr)
285+ }
258286 if m.pulls != nil {
259287 // The commits the opening is for are the ones of now.
260288 base, _ := m.branchOrZero(ctx, name, target)
@@ -335,10 +363,25 @@ func (m *MergeRequests) List(ctx context.Context, p domain.Principal, name domai
335363 return (params.State == "" || mr.State == params.State) && (after == 0 || mr.Number < after)
336364 },
337365 encodeMRToken)
366+ tips := map[string]domain.ObjectID{} // one BranchHead per distinct target
338367 for i, mr := range page.MergeRequests {
339368 if page.MergeRequests[i], err = m.withHead(ctx, mr); err != nil {
340369 return ports.MergeRequestPage{}, err
341370 }
371+ if m.mergeab == nil || !mr.IsOpen() {
372+ continue
373+ }
374+ tip, ok := tips[mr.Target]
375+ if !ok {
376+ if tip, err = m.branchOrZero(ctx, name, mr.Target); err != nil {
377+ return ports.MergeRequestPage{}, err
378+ }
379+ tips[mr.Target] = tip
380+ }
381+ if page.Mergeability == nil {
382+ page.Mergeability = map[int64]domain.Mergeability{}
383+ }
384+ page.Mergeability[mr.Number] = m.mergeab.Read(ctx, page.MergeRequests[i], tip)
342385 }
343386 return page, nil
344387 }
@@ -395,6 +438,28 @@ func (m *MergeRequests) CountOpen(ctx context.Context, p domain.Principal, name
395438 return n, nil
396439 }
397440
441+// Mergeability implements ports.MergeRequestService: the mergeability of an
442+// open merge request for the current tips, queued when there is no up-to-date
443+// result. It is the zero value for a request that is not open, or without a
444+// mergeability service.
445+func (m *MergeRequests) Mergeability(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.Mergeability, error) {
446+ _, mr, err := m.get(ctx, p, name, number)
447+ if err != nil {
448+ return domain.Mergeability{}, err
449+ }
450+ if m.mergeab == nil || !mr.IsOpen() {
451+ return domain.Mergeability{}, nil
452+ }
453+ if mr, err = m.withHead(ctx, mr); err != nil {
454+ return domain.Mergeability{}, err
455+ }
456+ tip, err := m.branchOrZero(ctx, name, mr.Target)
457+ if err != nil {
458+ return domain.Mergeability{}, err
459+ }
460+ return m.mergeab.Read(ctx, mr, tip), nil
461+}
462+
398463 func encodeMRToken(last domain.MergeRequest) string {
399464 return base64.RawURLEncoding.EncodeToString([]byte(mrTokenPrefix + strconv.FormatInt(last.Number, 10)))
400465 }
@@ -465,6 +530,13 @@ func (m *MergeRequests) Get(ctx context.Context, p domain.Principal, name domain
465530 return nil
466531 }
467532 d := ports.MergeRequestDetail{MergeRequest: mr}
533+ if m.mergeab != nil && mr.IsOpen() {
534+ tip, err := m.branchOrZero(ctx, name, mr.Target)
535+ if err != nil {
536+ return ports.MergeRequestDetail{}, err
537+ }
538+ d.Mergeability = m.mergeab.Read(ctx, mr, tip)
539+ }
468540 if d.Comments, err = m.store.ListComments(ctx, name, number); err != nil {
469541 return ports.MergeRequestDetail{}, fmt.Errorf("list comments of !%d in %q: %w", number, name, err)
470542 }
@@ -849,30 +921,77 @@ func (m *MergeRequests) MarkRead(ctx context.Context, p domain.Principal, name d
849921 // domain.ErrPreconditionFailed when the request is not open, the mode is not
850922 // allowed by the settings of the repository, the source branch no longer
851923 // points to the head ("branch moved, read again") or is gone, the target is
852-// gone or is not an ancestor of the head (the author rebases and pushes
853-// again), or the branch brings nothing. Checks come with CI (CI-1): there are
854-// none yet, so the setting that lets failing checks block the merge has
855-// nothing to act on.
924+// gone, the mode's own condition does not hold (see below), or the branch
925+// brings nothing. Checks come with CI (CI-1): there are none yet, so the
926+// setting that lets failing checks block the merge has nothing to act on.
856927 //
857-// A fast-forward moves the target to the head, with the old value as a
858-// precondition. A squash writes one commit with the tree of the head, the
859-// target as parent and the author and message of domain.NewSquashCommit,
860-// signed by the instance key, and moves the target to it the same way.
928+// The conditions are always checked with Git, under the lock, never from the
929+// stored mergeability. A fast-forward needs the target to be an ancestor of
930+// the head (the author rebases and pushes again) and moves the target to the
931+// head, with the old value as a precondition. A squash and a merge commit need
932+// the head to merge cleanly into the target (a behind branch is fine). A squash
933+// writes one commit with the merged tree, the target as parent and the author
934+// and message of domain.NewSquashCommit; a merge commit has two parents (the
935+// target, then the head) and the message of domain.NewMergeCommit. Both are
936+// signed by the instance key and move the target the same way.
861937 //
862938 // With DeleteSource the merge is also refused, before anything changes, when
863939 // the source is the default branch or the source or target of another open
864-// request. After the merge the source is deleted if it still points to the
865-// head; the PushEvent then carries the deletion too. A failed deletion is
866-// logged and leaves the branch: the merge stands and SourceDeleted is false.
940+// request.
941+//
942+// Only the merge itself runs before Merge returns, under the lock. Afterwards,
943+// off the request path and without the lock: the deletion of the source branch
944+// (if it still points to the head; a failure is logged and leaves the branch),
945+// MergeRequestMerged then a PushEvent (which carries the deletion too), and
946+// the CI and mergeability hooks. Wait waits for them. SourceDeleted therefore
947+// only says that the deletion was requested.
867948 func (m *MergeRequests) Merge(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params ports.MergeParams) (ports.MergeResult, error) {
868- mr, deleted, err := m.merge(ctx, p, name, number, params)
869- return ports.MergeResult{MergeRequest: mr, SourceDeleted: deleted}, err
949+ mr, err := m.merge(ctx, p, name, number, params)
950+ return ports.MergeResult{MergeRequest: mr, SourceDeleted: err == nil && params.DeleteSource}, err
870951 }
871952
872-func (m *MergeRequests) merge(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params ports.MergeParams) (domain.MergeRequest, bool, error) {
953+// maxConflictPathsShown is how many conflicting paths a refusal lists.
954+const maxConflictPathsShown = 5
955+
956+// checkMode is the authoritative per-mode check of a merge, done with Git. It
957+// returns the tree a squash or merge commit gets (zero for a fast-forward).
958+func (m *MergeRequests) checkMode(ctx context.Context, mr domain.MergeRequest, mode domain.MergeMode, tip, head domain.ObjectID) (domain.ObjectID, error) {
959+ if mode == domain.MergeModeFastForward {
960+ ok, err := m.git.IsAncestor(ctx, mr.Repo, tip, head)
961+ if err != nil {
962+ return domain.ObjectID{}, err
963+ }
964+ if !ok {
965+ return domain.ObjectID{}, fmt.Errorf("%w: the target has moved: rebase and push again, or merge with squash or a merge commit", domain.ErrPreconditionFailed)
966+ }
967+ return domain.ObjectID{}, nil
968+ }
969+ res, err := m.git.MergeTree(ctx, mr.Repo, tip, head)
970+ if err != nil {
971+ return domain.ObjectID{}, err
972+ }
973+ res = res.Bounded()
974+ if !res.Clean {
975+ what := "no common history"
976+ if len(res.ConflictPaths) > 0 {
977+ shown := res.ConflictPaths
978+ if len(shown) > maxConflictPathsShown {
979+ shown = shown[:maxConflictPathsShown]
980+ }
981+ what = strings.Join(shown, ", ")
982+ if len(shown) < len(res.ConflictPaths) || res.Truncated {
983+ what += ", ..."
984+ }
985+ }
986+ return domain.ObjectID{}, fmt.Errorf("%w: conflicts with %s: %s: rebase and push again", domain.ErrPreconditionFailed, mr.Target, what)
987+ }
988+ return res.Tree, nil
989+}
990+
991+func (m *MergeRequests) merge(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params ports.MergeParams) (domain.MergeRequest, error) {
873992 repo, err := m.writable(ctx, p, name, domain.ScopeMRMerge)
874993 if err != nil {
875- return domain.MergeRequest{}, false, err
994+ return domain.MergeRequest{}, err
876995 }
877996 mode := params.Mode
878997 if mode == "" {
@@ -886,116 +1005,134 @@ func (m *MergeRequests) merge(ctx context.Context, p domain.Principal, name doma
8861005 v.Add("head_id", "is required")
8871006 }
8881007 if err := v.Err(); err != nil {
889- return domain.MergeRequest{}, false, err
1008+ return domain.MergeRequest{}, err
8901009 }
8911010
8921011 unlock, err := m.lockRepo(ctx, name)
8931012 if err != nil {
894- return domain.MergeRequest{}, false, err
1013+ return domain.MergeRequest{}, err
8951014 }
896- defer unlock()
1015+ var once sync.Once
1016+ release := func() { once.Do(unlock) }
1017+ defer release()
8971018
8981019 mr, err := m.store.Get(ctx, name, number)
8991020 if err != nil {
900- return domain.MergeRequest{}, false, err
1021+ return domain.MergeRequest{}, err
9011022 }
9021023 // A merge moves the target and may delete the source: a token limited to
9031024 // branch prefixes may do so only within them.
9041025 if !p.AllowsRef("refs/heads/" + mr.Target) {
905- return domain.MergeRequest{}, false, fmt.Errorf("%w: this token may not write %s", domain.ErrForbidden, mr.Target)
1026+ return domain.MergeRequest{}, fmt.Errorf("%w: this token may not write %s", domain.ErrForbidden, mr.Target)
9061027 }
9071028 if params.DeleteSource && !p.AllowsRef("refs/heads/"+mr.Source) {
908- return domain.MergeRequest{}, false, fmt.Errorf("%w: this token may not write %s", domain.ErrForbidden, mr.Source)
1029+ return domain.MergeRequest{}, fmt.Errorf("%w: this token may not write %s", domain.ErrForbidden, mr.Source)
9091030 }
9101031 if !mr.IsOpen() {
911- return domain.MergeRequest{}, false, fmt.Errorf("%w: !%d is %s", domain.ErrPreconditionFailed, number, mr.State)
1032+ return domain.MergeRequest{}, fmt.Errorf("%w: !%d is %s", domain.ErrPreconditionFailed, number, mr.State)
9121033 }
9131034 if mr.IsDraft() {
914- return domain.MergeRequest{}, false, fmt.Errorf("%w: !%d is a draft: mark it ready for review first", domain.ErrPreconditionFailed, number)
1035+ return domain.MergeRequest{}, fmt.Errorf("%w: !%d is a draft: mark it ready for review first", domain.ErrPreconditionFailed, number)
9151036 }
9161037 // The settings are read again under the lock: a change applies at once.
9171038 repo, err = m.meta.Get(ctx, name)
9181039 if err != nil {
9191040 if errors.Is(err, domain.ErrNotFound) {
920- return domain.MergeRequest{}, false, err // deleted meanwhile
1041+ return domain.MergeRequest{}, err // deleted meanwhile
9211042 }
922- return domain.MergeRequest{}, false, fmt.Errorf("get repository %q: %w", name, err)
1043+ return domain.MergeRequest{}, fmt.Errorf("get repository %q: %w", name, err)
9231044 }
9241045 settings := repo.Settings
9251046 if settings.IsZero() {
9261047 settings = domain.DefaultRepoSettings()
9271048 }
9281049 if !settings.Allows(mode) {
929- return domain.MergeRequest{}, false, fmt.Errorf("%w: the %s merge mode is not allowed in %s", domain.ErrPreconditionFailed, mode, name)
1050+ return domain.MergeRequest{}, fmt.Errorf("%w: the %s merge mode is not allowed in %s", domain.ErrPreconditionFailed, mode, name)
9301051 }
9311052
9321053 head, err := m.branchOrZero(ctx, name, mr.Source)
9331054 switch {
9341055 case err != nil:
935- return domain.MergeRequest{}, false, err
1056+ return domain.MergeRequest{}, err
9361057 case head.IsZero():
937- return domain.MergeRequest{}, false, fmt.Errorf("%w: the source branch %q no longer exists", domain.ErrPreconditionFailed, mr.Source)
1058+ return domain.MergeRequest{}, fmt.Errorf("%w: the source branch %q no longer exists", domain.ErrPreconditionFailed, mr.Source)
9381059 case head != params.HeadID:
939- return domain.MergeRequest{}, false, fmt.Errorf("%w: branch moved, read again: %q is at %s, not %s", domain.ErrPreconditionFailed, mr.Source, head, params.HeadID)
1060+ return domain.MergeRequest{}, fmt.Errorf("%w: branch moved, read again: %q is at %s, not %s", domain.ErrPreconditionFailed, mr.Source, head, params.HeadID)
9401061 }
9411062 tip, err := m.branchOrZero(ctx, name, mr.Target)
9421063 switch {
9431064 case err != nil:
944- return domain.MergeRequest{}, false, err
1065+ return domain.MergeRequest{}, err
9451066 case tip.IsZero():
946- return domain.MergeRequest{}, false, fmt.Errorf("%w: the target branch %q no longer exists", domain.ErrPreconditionFailed, mr.Target)
1067+ return domain.MergeRequest{}, fmt.Errorf("%w: the target branch %q no longer exists", domain.ErrPreconditionFailed, mr.Target)
9471068 case tip == head:
948- return domain.MergeRequest{}, false, fmt.Errorf("%w: nothing to merge, %q already is at %s", domain.ErrPreconditionFailed, mr.Target, head)
1069+ return domain.MergeRequest{}, fmt.Errorf("%w: nothing to merge, %q already is at %s", domain.ErrPreconditionFailed, mr.Target, head)
9491070 }
950- switch ok, err := m.git.IsAncestor(ctx, name, tip, head); {
951- case err != nil:
952- return domain.MergeRequest{}, false, err
953- case !ok:
954- return domain.MergeRequest{}, false, fmt.Errorf("%w: %q is not an ancestor of %q: rebase it on %q and push again", domain.ErrPreconditionFailed, mr.Target, mr.Source, mr.Target)
1071+ // A head behind the target (or at it) has nothing to merge, in any mode.
1072+ if behind, err := m.git.IsAncestor(ctx, name, head, tip); err != nil {
1073+ return domain.MergeRequest{}, err
1074+ } else if behind {
1075+ return domain.MergeRequest{}, fmt.Errorf("%w: nothing to merge, %q already contains %s", domain.ErrPreconditionFailed, mr.Target, head)
1076+ }
1077+ tree, err := m.checkMode(ctx, mr, mode, tip, head)
1078+ if err != nil {
1079+ return domain.MergeRequest{}, err
9551080 }
9561081 if settings.FailingChecksBlock {
9571082 if err := m.checksAllow(ctx, name, head); err != nil {
958- return domain.MergeRequest{}, false, err
1083+ return domain.MergeRequest{}, err
9591084 }
9601085 }
9611086 if params.DeleteSource {
9621087 if err := m.checkDeletable(ctx, name, mr); err != nil {
963- return domain.MergeRequest{}, false, err
1088+ return domain.MergeRequest{}, err
9641089 }
9651090 }
9661091
967- merged, err := m.moveTarget(ctx, mr, mode, tip, head, p)
1092+ merged, err := m.moveTarget(ctx, mr, mode, tip, head, tree, p)
9681093 if err != nil {
969- return domain.MergeRequest{}, false, err
1094+ return domain.MergeRequest{}, err
9701095 }
9711096 m.recordPush(ctx, name) // the target moved, whatever happens next
9721097 mr, err = m.markMerged(ctx, name, number, mode, tip, head, merged)
9731098 if err != nil {
974- return domain.MergeRequest{}, false, err
1099+ return domain.MergeRequest{}, err
9751100 }
9761101 log := zerowrap.FromCtx(ctx)
9771102 log.Info().Str("repo", name.String()).Int64("number", number).Str("mode", mode.String()).
9781103 Str("target", mr.Target).Str("commit", merged.String()).
9791104 Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("merge request merged")
980- updates := []domain.RefUpdate{{Old: tip.String(), New: merged.String(), Ref: "refs/heads/" + mr.Target}}
981- deleted := false
982- if params.DeleteSource {
983- // The merge is done and recorded: a failure here keeps the branch. Who
984- // pushed the source goes first (the branch is gone either way for a
985- // schedule; a kept branch merely has no record: not trusted).
986- if err := recordRefTips(ctx, m.tips, name, []domain.RefUpdate{{Old: head.String(), New: domain.ZeroOID, Ref: "refs/heads/" + mr.Source}}, "", domain.CIPusher{}, false); err != nil {
987- log.Warn().Err(err).Str("repo", name.String()).Str("branch", mr.Source).Msg("the record of the source branch was kept")
988- }
989- switch err := m.git.DeleteBranch(context.WithoutCancel(ctx), name, mr.Source, head); {
990- case err != nil:
991- log.Warn().Err(err).Str("repo", name.String()).Int64("number", number).Str("branch", mr.Source).
992- Msg("source branch kept after the merge")
993- default:
994- deleted = true
995- updates = append(updates, domain.RefUpdate{Old: head.String(), New: domain.ZeroOID, Ref: "refs/heads/" + mr.Source})
996- log.Info().Str("repo", name.String()).Int64("number", number).Str("branch", mr.Source).
997- Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("source branch deleted after the merge")
1105+
1106+ // The merge is done and recorded. The rest runs without the lock and after
1107+ // the caller got its answer.
1108+ release()
1109+ bg := context.WithoutCancel(ctx)
1110+ m.after.Add(1)
1111+ go func() {
1112+ defer m.after.Done()
1113+ defer func() {
1114+ if r := recover(); r != nil {
1115+ log := zerowrap.FromCtx(bg)
1116+ log.Error().Str("repo", name.String()).Int64("number", number).
1117+ Str("panic", fmt.Sprint(r)).Bytes("stack", debug.Stack()).Msg("post-merge side effects panicked")
1118+ }
1119+ }()
1120+ if m.afterGate != nil {
1121+ m.afterGate()
9981122 }
1123+ m.afterMerge(bg, p, repo, mr, head, tip, merged, params.DeleteSource)
1124+ }()
1125+ return mr, nil
1126+}
1127+
1128+// afterMerge is what follows a merge off the request path: errors are logged,
1129+// never returned. mr is the merged request, tip the old target and merged the
1130+// new one.
1131+func (m *MergeRequests) afterMerge(ctx context.Context, p domain.Principal, repo domain.Repo, mr domain.MergeRequest, head, tip, merged domain.ObjectID, deleteSource bool) {
1132+ name := repo.Name
1133+ updates := []domain.RefUpdate{{Old: tip.String(), New: merged.String(), Ref: "refs/heads/" + mr.Target}}
1134+ if deleteSource && m.deleteSource(ctx, p, name, mr, head) {
1135+ updates = append(updates, domain.RefUpdate{Old: head.String(), New: domain.ZeroOID, Ref: "refs/heads/" + mr.Source})
9991136 }
10001137 at := mr.UpdatedAt
10011138 m.bus.Publish(ctx, domain.MergeRequestMerged{MergeRequest: mr, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
@@ -1003,14 +1140,47 @@ func (m *MergeRequests) merge(ctx context.Context, p domain.Principal, name doma
10031140 Repo: name, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt, Updates: updates,
10041141 })
10051142 if m.pulls != nil {
1006- m.pulls.OnPullRequest(ctx, name, ports.PullRequestEvent{Number: number, Action: domain.PullClosed, Head: mr.HeadID, Base: mr.BaseID, Merged: true}, p)
1143+ m.pulls.OnPullRequest(ctx, name, ports.PullRequestEvent{Number: mr.Number, Action: domain.PullClosed, Head: mr.HeadID, Base: mr.BaseID, Merged: true}, p)
10071144 }
10081145 if m.ci != nil {
10091146 // GitHub runs the "push" workflows of a merge, with the owner as actor;
10101147 // the run is trusted with what the merging principal may read.
10111148 m.ci.OnMerge(ctx, name, updates, p)
10121149 }
1013- return mr, deleted, nil
1150+ if m.mergeab != nil {
1151+ m.mergeab.OnBranchesMoved(ctx, name, updates) // the other requests of the moved target
1152+ }
1153+}
1154+
1155+// deleteSource deletes the source branch of a merged request if it is still
1156+// head and still unused, and reports whether it did. The eligibility is checked
1157+// again and the branch deleted in one critical section of the merge lock: a
1158+// request opened on the branch since the merge, or a push to it, keeps it. The
1159+// record of who pushed the branch goes only after a deletion, and only if it
1160+// still names head.
1161+func (m *MergeRequests) deleteSource(ctx context.Context, p domain.Principal, name domain.RepoName, mr domain.MergeRequest, head domain.ObjectID) bool {
1162+ log := zerowrap.FromCtx(ctx).With().Str("repo", name.String()).Int64("number", mr.Number).Str("branch", mr.Source).Logger()
1163+ unlock, err := m.lockRepo(ctx, name)
1164+ if err != nil {
1165+ log.Warn().Err(err).Msg("source branch kept after the merge")
1166+ return false
1167+ }
1168+ defer unlock()
1169+ if err := m.checkDeletable(ctx, name, mr); err != nil {
1170+ log.Warn().Err(err).Msg("source branch kept after the merge")
1171+ return false
1172+ }
1173+ if err := m.git.DeleteBranch(ctx, name, mr.Source, head); err != nil {
1174+ log.Warn().Err(err).Msg("source branch kept after the merge")
1175+ return false
1176+ }
1177+ if m.tips != nil {
1178+ if err := m.tips.DeleteRefTipIf(ctx, name, "refs/heads/"+mr.Source, head.String()); err != nil && !errors.Is(err, domain.ErrNotFound) {
1179+ log.Warn().Err(err).Msg("the record of the source branch was kept")
1180+ }
1181+ }
1182+ log.Info().Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("source branch deleted after the merge")
1183+ return true
10141184 }
10151185
10161186 // WhileOpen implements ports.PullGate: fn runs under the merge lock, so a
@@ -1103,10 +1273,20 @@ func (m *MergeRequests) recordPush(ctx context.Context, name domain.RepoName) {
11031273 }
11041274
11051275 // moveTarget moves the target branch from tip to the head (fast-forward) or to
1106-// a new signed squash commit, and returns the new tip.
1107-func (m *MergeRequests) moveTarget(ctx context.Context, mr domain.MergeRequest, mode domain.MergeMode, tip, head domain.ObjectID, p domain.Principal) (domain.ObjectID, error) {
1276+// a new signed squash or merge commit with the given tree, and returns the new tip.
1277+func (m *MergeRequests) moveTarget(ctx context.Context, mr domain.MergeRequest, mode domain.MergeMode, tip, head, tree domain.ObjectID, p domain.Principal) (domain.ObjectID, error) {
11081278 newTip := head
1109- if mode == domain.MergeModeSquash {
1279+ switch mode {
1280+ case domain.MergeModeMerge:
1281+ commit := domain.NewMergeCommit(mr, tree, tip, head, m.committer, m.clock.Now())
1282+ armored, err := m.signer.Sign(commit.Payload())
1283+ if err != nil {
1284+ return domain.ObjectID{}, fmt.Errorf("sign the merge commit of !%d in %q: %w", mr.Number, mr.Repo, err)
1285+ }
1286+ if newTip, err = m.git.WriteCommit(ctx, mr.Repo, commit.Signed(armored)); err != nil {
1287+ return domain.ObjectID{}, err
1288+ }
1289+ case domain.MergeModeSquash:
11101290 commits, more, err := m.git.ListRange(ctx, mr.Repo, tip, head, domain.MaxSquashCommits)
11111291 if err != nil {
11121292 return domain.ObjectID{}, err
@@ -1114,10 +1294,6 @@ func (m *MergeRequests) moveTarget(ctx context.Context, mr domain.MergeRequest,
11141294 if more {
11151295 return domain.ObjectID{}, fmt.Errorf("%w: the branch has more than %d commits to squash", domain.ErrPreconditionFailed, domain.MaxSquashCommits)
11161296 }
1117- tree, err := m.git.CommitTree(ctx, mr.Repo, head)
1118- if err != nil {
1119- return domain.ObjectID{}, err
1120- }
11211297 commit := domain.NewSquashCommit(mr, tree, tip, commits, m.committer, m.clock.Now())
11221298 armored, err := m.signer.Sign(commit.Payload())
11231299 if err != nil {
M internal/usecase/mergerequests_branches_test.go +1 -0
@@ -67,6 +67,7 @@ func TestMergeWithBranchLimitedToken(t *testing.T) {
6767 res, err := r.mrs.Merge(r.ctx, branchMerger, r.repo, mr.Number, ports.MergeParams{HeadID: head, DeleteSource: true})
6868 require.NoError(t, err)
6969 assert.True(t, res.SourceDeleted)
70+ r.mrs.Wait(r.ctx) // the deletion runs after Merge returned
7071 assert.Equal(t, head, r.branchHead("agent/claude/base"))
7172 _, err = r.git.BranchHead(r.ctx, r.repo, "agent/claude/feat")
7273 assert.ErrorIs(t, err, domain.ErrNotFound)
M internal/usecase/mergerequests_delete_test.go +149 -1
@@ -3,8 +3,13 @@
33 package usecase_test
44
55 import (
6+ "bytes"
67 "context"
8+ "sync"
79 "testing"
10+ "time"
11+
12+ "github.com/bnema/zerowrap"
813
914 "github.com/stretchr/testify/assert"
1015 "github.com/stretchr/testify/require"
@@ -31,6 +36,7 @@ func TestMergeDeletesTheSourceBranch(t *testing.T) {
3136 require.NoError(t, err)
3237 assert.True(t, res.SourceDeleted)
3338 assert.Equal(t, domain.MRStateMerged, res.State)
39+ r.mrs.Wait(r.ctx) // the deletion happens after Merge returned
3440 _, err = r.git.BranchHead(r.ctx, r.repo, "feat")
3541 assert.ErrorIs(t, err, domain.ErrNotFound, "the branch is gone")
3642
@@ -157,6 +163,7 @@ func TestCreateAfterAMergeDeletedTheBranch(t *testing.T) {
157163 mr := r.open("feat")
158164 _, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, DeleteSource: true})
159165 require.NoError(t, err)
166+ r.mrs.Wait(r.ctx) // the deletion runs after Merge returned
160167 _, err = r.mrs.Create(r.ctx, root, r.repo, ports.CreateMergeRequestParams{Title: "late", Source: "feat"})
161168 require.ErrorIs(t, err, domain.ErrInvalid)
162169 assert.Contains(t, fieldsOf(t, err), "source_branch")
@@ -192,11 +199,152 @@ func TestMergeKeepsASourceThatMovedAfterTheMerge(t *testing.T) {
192199
193200 res, err := mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, DeleteSource: true})
194201 require.NoError(t, err, "the merge stands")
195- assert.False(t, res.SourceDeleted)
202+ assert.True(t, res.SourceDeleted, "the deletion was requested")
196203 assert.Equal(t, domain.MRStateMerged, res.State)
204+ mrs.Wait(r.ctx)
197205 assert.Equal(t, head, r.branchHead("main"))
198206 assert.Equal(t, moved, r.branchHead("feat"), "the late commit is kept")
199207 evs := r.drain()
200208 require.Len(t, evs, 2)
201209 assert.Len(t, evs[1].(domain.PushEvent).Updates, 1, "only the target moved")
202210 }
211+
212+// afterMergeRig builds merge request use cases over the rig with ref-tip
213+// records, whose post-merge goroutine waits for the returned release function.
214+func (r *mrRig) afterMergeRig(extra func(*usecase.MergeRequestsConfig)) (mrs *usecase.MergeRequests, release func()) {
215+ gate := make(chan struct{})
216+ cfg := usecase.MergeRequestsConfig{
217+ Meta: r.store, Store: r.store.MergeRequests(), Repos: r.git, Git: r.git, Signer: nil,
218+ Committer: domain.Identity{Name: "ermite", Email: "ermite@git.example.org"},
219+ Bus: r.bus, Clock: fixedClock{}, Maint: r.sched, RefTips: r.store.CI(),
220+ }
221+ if extra != nil {
222+ extra(&cfg)
223+ }
224+ mrs = usecase.NewMergeRequests(cfg)
225+ mrs.SetAfterMergeGate(func() { <-gate })
226+ var once sync.Once
227+ return mrs, func() { once.Do(func() { close(gate) }) }
228+}
229+
230+// A request opened on the source after the merge, before the deletion runs,
231+// keeps the branch (GO-002).
232+func TestMergeKeepsTheSourceWhenAnotherRequestOpensOnItMeanwhile(t *testing.T) {
233+ t.Parallel()
234+ r := newMRRig(t)
235+ r.seed()
236+ head := r.feature("feat")
237+ mr := r.open("feat")
238+ r.sh("checkout", "-q", "-B", "release", "main")
239+ r.push("release")
240+ mrs, release := r.afterMergeRig(nil)
241+ defer release()
242+
243+ _, err := mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, DeleteSource: true})
244+ require.NoError(t, err)
245+ _, err = mrs.Create(r.ctx, root, r.repo, ports.CreateMergeRequestParams{Title: "to release", Source: "feat", Target: "release"})
246+ require.NoError(t, err, "the merge released the lock; the deletion has not run")
247+ release()
248+ mrs.Wait(r.ctx)
249+ assert.Equal(t, head, r.branchHead("feat"), "the branch is kept")
250+}
251+
252+// A push that moves the source after the merge keeps both the branch and the
253+// record of who pushed it (GO-003).
254+func TestMergeKeepsTheSourceAndItsRecordWhenAPushMovesItMeanwhile(t *testing.T) {
255+ t.Parallel()
256+ r := newMRRig(t)
257+ r.seed()
258+ head := r.feature("feat")
259+ mr := r.open("feat")
260+ mrs, release := r.afterMergeRig(nil)
261+ defer release()
262+
263+ _, err := mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, DeleteSource: true})
264+ require.NoError(t, err)
265+ r.sh("checkout", "-q", "feat")
266+ moved := r.commit(author, "later.txt", "later\n", "pushed meanwhile")
267+ r.push("feat")
268+ require.NoError(t, r.store.CI().PutRefTip(r.ctx, domain.CIRefTip{Repo: r.repo, Ref: "refs/heads/feat", Sha: moved.String(), Actor: "ada"}))
269+ release()
270+ mrs.Wait(r.ctx)
271+
272+ assert.Equal(t, moved, r.branchHead("feat"), "the branch is kept")
273+ tip, err := r.store.CI().GetRefTip(r.ctx, r.repo, "refs/heads/feat")
274+ require.NoError(t, err, "the record of the new push is kept")
275+ assert.Equal(t, moved.String(), tip.Sha)
276+}
277+
278+// The record of the source goes with the branch, once it is deleted.
279+func TestMergeRemovesTheSourceRecordAfterDeletingIt(t *testing.T) {
280+ t.Parallel()
281+ r := newMRRig(t)
282+ r.seed()
283+ head := r.feature("feat")
284+ mr := r.open("feat")
285+ mrs, release := r.afterMergeRig(nil)
286+ release()
287+ require.NoError(t, r.store.CI().PutRefTip(r.ctx, domain.CIRefTip{Repo: r.repo, Ref: "refs/heads/feat", Sha: head.String(), Actor: "ada"}))
288+
289+ _, err := mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, DeleteSource: true})
290+ require.NoError(t, err)
291+ mrs.Wait(r.ctx)
292+ _, err = r.git.BranchHead(r.ctx, r.repo, "feat")
293+ require.ErrorIs(t, err, domain.ErrNotFound)
294+ _, err = r.store.CI().GetRefTip(r.ctx, r.repo, "refs/heads/feat")
295+ assert.ErrorIs(t, err, domain.ErrNotFound)
296+}
297+
298+// panicHooks is a CI trigger whose merge hook panics.
299+type panicHooks struct{}
300+
301+func (panicHooks) OnPush(context.Context, domain.RepoName, []domain.RefUpdate, domain.Principal) {}
302+func (panicHooks) OnMerge(context.Context, domain.RepoName, []domain.RefUpdate, domain.Principal) {
303+ panic("boom from ci")
304+}
305+
306+func TestMergeLogsAPanickingSideEffectAndWaitReturns(t *testing.T) {
307+ t.Parallel()
308+ r := newMRRig(t)
309+ r.seed()
310+ head := r.feature("feat")
311+ mr := r.open("feat")
312+ mrs, release := r.afterMergeRig(func(c *usecase.MergeRequestsConfig) { c.CI = panicHooks{} })
313+ release()
314+ var logs bytes.Buffer
315+ ctx := zerowrap.WithCtx(r.ctx, zerowrap.New(zerowrap.Config{Level: "info", Format: "json", Output: &logs}))
316+
317+ _, err := mrs.Merge(ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head})
318+ require.NoError(t, err)
319+ waited := make(chan struct{})
320+ go func() { mrs.Wait(context.Background()); close(waited) }()
321+ select {
322+ case <-waited:
323+ case <-time.After(10 * time.Second):
324+ t.Fatal("Wait did not return after a panicking side effect")
325+ }
326+ assert.Contains(t, logs.String(), "post-merge side effects panicked")
327+ assert.Contains(t, logs.String(), "boom from ci")
328+}
329+
330+// A head behind the target has nothing to merge, whatever the mode (GO-007).
331+func TestMergeRefusesAHeadBehindTheTarget(t *testing.T) {
332+ t.Parallel()
333+ for _, mode := range []domain.MergeMode{domain.MergeModeSquash, domain.MergeModeMerge, domain.MergeModeFastForward} {
334+ t.Run(string(mode), func(t *testing.T) {
335+ t.Parallel()
336+ r := newMRRig(t)
337+ main := r.seed()
338+ r.sh("checkout", "-q", "-B", "old", "main~1")
339+ r.push("old")
340+ old := r.branchHead("old")
341+ require.NotEqual(t, main, old)
342+ mr := r.open("old")
343+
344+ _, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{Mode: mode, HeadID: old})
345+ require.ErrorIs(t, err, domain.ErrPreconditionFailed)
346+ assert.Contains(t, err.Error(), "nothing to merge")
347+ assert.Equal(t, main, r.branchHead("main"), "no commit was made")
348+ })
349+ }
350+}
M internal/usecase/mergerequests_integration_test.go +11 -6
@@ -207,6 +207,7 @@ func (r *mrRig) open(branch string) domain.MergeRequest {
207207 // subscriber before Publish returns: whatever a returned call published is
208208 // already in the channel.
209209 func (r *mrRig) drain() []domain.Event {
210+ r.mrs.Wait(r.ctx) // the events of a merge are published after it returned
210211 var out []domain.Event
211212 for {
212213 select {
@@ -959,11 +960,10 @@ func TestMergeRefusals(t *testing.T) {
959960 r.push("main")
960961 tip := r.branchHead("main")
961962
962- for _, mode := range []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash} {
963- _, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, Mode: mode})
964- require.ErrorIs(t, err, domain.ErrPreconditionFailed, mode)
965- assert.Contains(t, err.Error(), "rebase", mode)
966- }
963+ // Only a fast-forward needs the target to be an ancestor.
964+ _, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, Mode: domain.MergeModeFastForward})
965+ require.ErrorIs(t, err, domain.ErrPreconditionFailed)
966+ assert.Contains(t, err.Error(), "the target has moved: rebase and push again")
967967 assert.Equal(t, tip, r.branchHead("main"))
968968 d, err := r.mrs.Get(r.ctx, mrM, r.repo, mr.Number, ports.GetMergeRequestOptions{})
969969 require.NoError(t, err)
@@ -1047,8 +1047,13 @@ func TestConcurrentMergesSerialize(t *testing.T) {
10471047 const n = 6
10481048 heads := map[int64]domain.ObjectID{}
10491049 for i := range n {
1050+ // Every branch writes the same file differently: once one is merged the
1051+ // others neither fast-forward nor merge cleanly.
10501052 b := fmt.Sprintf("f%d", i)
1051- heads[r.openFeature(b).Number] = r.branchHead(b)
1053+ r.sh("checkout", "-q", "-B", b, "main")
1054+ r.commit(author, "same.txt", b+"\n", "add "+b)
1055+ r.push(b)
1056+ heads[r.open(b).Number] = r.branchHead(b)
10521057 }
10531058
10541059 type outcome struct {
A internal/usecase/mergerequests_merge_test.go +293 -0
@@ -0,0 +1,293 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase_test
4+
5+import (
6+ "bytes"
7+ "context"
8+ "crypto/ed25519"
9+ "strings"
10+ "sync"
11+ "testing"
12+ "time"
13+
14+ "github.com/stretchr/testify/assert"
15+ "github.com/stretchr/testify/require"
16+
17+ "bnema.dev/ermite/internal/adapters/sshsig"
18+ "bnema.dev/ermite/internal/domain"
19+ "bnema.dev/ermite/internal/ports"
20+ "bnema.dev/ermite/internal/usecase"
21+)
22+
23+// gateHooks is a CI and a pull_request trigger that block on a channel, to
24+// tell what runs before Merge returns.
25+type gateHooks struct {
26+ release chan struct{}
27+ mu sync.Mutex
28+ calls []string
29+}
30+
31+func newGateHooks() *gateHooks { return &gateHooks{release: make(chan struct{})} }
32+
33+func (g *gateHooks) note(s string) {
34+ <-g.release
35+ g.mu.Lock()
36+ defer g.mu.Unlock()
37+ g.calls = append(g.calls, s)
38+}
39+
40+func (g *gateHooks) ran() []string {
41+ g.mu.Lock()
42+ defer g.mu.Unlock()
43+ return append([]string(nil), g.calls...)
44+}
45+
46+func (g *gateHooks) OnPush(context.Context, domain.RepoName, []domain.RefUpdate, domain.Principal) {}
47+
48+func (g *gateHooks) OnMerge(context.Context, domain.RepoName, []domain.RefUpdate, domain.Principal) {
49+ g.note("ci")
50+}
51+
52+// OnPullRequest blocks for the closing of a request only: Create reports the
53+// opening on the request path.
54+func (g *gateHooks) OnPullRequest(_ context.Context, _ domain.RepoName, ev ports.PullRequestEvent, _ domain.Principal) {
55+ if ev.Action == domain.PullClosed {
56+ g.note("pulls")
57+ }
58+}
59+
60+// gated builds merge request use cases over the rig whose CI and pull hooks
61+// block until g.release is closed.
62+func (r *mrRig) gated(g *gateHooks) *usecase.MergeRequests {
63+ signer, err := sshsig.New(bytes.Repeat([]byte{9}, ed25519.SeedSize))
64+ require.NoError(r.t, err)
65+ return usecase.NewMergeRequests(usecase.MergeRequestsConfig{
66+ Meta: r.store, Store: r.store.MergeRequests(), Repos: r.git, Git: r.git, Signer: signer,
67+ Committer: domain.Identity{Name: "ermite", Email: "ermite@git.example.org"},
68+ Bus: r.bus, Clock: fixedClock{}, Maint: r.sched, CI: g, PullRequests: g,
69+ })
70+}
71+
72+// behind moves main on with a commit of its own (a file the feature does not
73+// touch), and returns the new tip.
74+func (r *mrRig) behind() domain.ObjectID {
75+ r.sh("checkout", "-q", "main")
76+ r.commit(author, "news.txt", "n\n", "main moved")
77+ r.push("main")
78+ return r.branchHead("main")
79+}
80+
81+func TestMergeModesWhenBehind(t *testing.T) {
82+ t.Parallel()
83+ t.Run("fast-forward is refused", func(t *testing.T) {
84+ t.Parallel()
85+ r := newMRRig(t)
86+ r.seed()
87+ head := r.feature("feat")
88+ mr := r.open("feat")
89+ tip := r.behind()
90+ _, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head})
91+ require.ErrorIs(t, err, domain.ErrPreconditionFailed)
92+ assert.Contains(t, err.Error(), "the target has moved: rebase and push again, or merge with squash or a merge commit")
93+ assert.Equal(t, tip, r.branchHead("main"))
94+ })
95+
96+ t.Run("squash merges the merged tree", func(t *testing.T) {
97+ t.Parallel()
98+ r := newMRRig(t)
99+ r.seed()
100+ head := r.feature("feat")
101+ mr := r.open("feat")
102+ tip := r.behind()
103+ res, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, Mode: domain.MergeModeSquash})
104+ require.NoError(t, err)
105+ sq := res.MergedCommitID.String()
106+ g := func(args ...string) string { return r.run(r.bare(), r.env(author), args...) }
107+ assert.Equal(t, tip.String(), g("rev-parse", sq+"^"), "the current tip is the parent")
108+ files := g("ls-tree", "-r", "--name-only", sq)
109+ assert.Contains(t, files, "news.txt", "the target's own change is kept")
110+ assert.Contains(t, files, "c.txt", "the branch's change is there")
111+ r.verifyCommit(sq)
112+ })
113+
114+ t.Run("merge commit has two parents, the merge tree and a good signature", func(t *testing.T) {
115+ t.Parallel()
116+ r := newMRRig(t)
117+ r.seed()
118+ head := r.feature("feat")
119+ mr := r.open("feat")
120+ tip := r.behind()
121+ want := r.run(r.bare(), r.env(author), "merge-tree", "--write-tree", tip.String(), head.String())
122+ res, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, Mode: domain.MergeModeMerge})
123+ require.NoError(t, err)
124+ assert.Equal(t, domain.MergeModeMerge, res.MergeMode)
125+ assert.Equal(t, tip, res.BaseID)
126+ id := res.MergedCommitID.String()
127+ assert.Equal(t, id, r.branchHead("main").String())
128+ g := func(args ...string) string { return r.run(r.bare(), r.env(author), args...) }
129+ assert.Equal(t, tip.String()+" "+head.String(), g("log", "-1", "--format=%P", id), "parents: target, head")
130+ assert.Equal(t, strings.TrimSpace(want), g("rev-parse", id+"^{tree}"))
131+ assert.Equal(t, "Merge branch 'feat' into main (!1)\n\nAdd feat", g("log", "-1", "--format=%B", id))
132+ assert.Equal(t, "ermite <ermite@git.example.org>", g("log", "-1", "--format=%an <%ae>", id))
133+ assert.Equal(t, "ermite <ermite@git.example.org>", g("log", "-1", "--format=%cn <%ce>", id))
134+ r.verifyCommit(id)
135+ })
136+
137+ t.Run("merge commit when up to date", func(t *testing.T) {
138+ t.Parallel()
139+ r := newMRRig(t)
140+ main := r.seed()
141+ head := r.feature("feat")
142+ mr := r.open("feat")
143+ res, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, Mode: domain.MergeModeMerge})
144+ require.NoError(t, err)
145+ id := res.MergedCommitID.String()
146+ g := func(args ...string) string { return r.run(r.bare(), r.env(author), args...) }
147+ assert.Equal(t, main.String()+" "+head.String(), g("log", "-1", "--format=%P", id))
148+ assert.Equal(t, g("rev-parse", head.String()+"^{tree}"), g("rev-parse", id+"^{tree}"))
149+ })
150+}
151+
152+func TestMergeRefusedOnConflictAndNoCommonHistory(t *testing.T) {
153+ t.Parallel()
154+ conflict := func(t *testing.T) (*mrRig, domain.MergeRequest, domain.ObjectID, domain.ObjectID) {
155+ r := newMRRig(t)
156+ r.seed()
157+ head := r.feature("feat") // changes line 2 of a.txt
158+ mr := r.open("feat")
159+ r.sh("checkout", "-q", "main")
160+ r.commit(author, "a.txt", "one\nTWO\nthree\n", "main changes a too")
161+ r.push("main")
162+ return r, mr, head, r.branchHead("main")
163+ }
164+ for _, mode := range []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash, domain.MergeModeMerge} {
165+ t.Run("conflict "+string(mode), func(t *testing.T) {
166+ t.Parallel()
167+ r, mr, head, tip := conflict(t)
168+ _, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, Mode: mode})
169+ require.ErrorIs(t, err, domain.ErrPreconditionFailed)
170+ if mode != domain.MergeModeFastForward {
171+ assert.Contains(t, err.Error(), "conflicts with main: a.txt: rebase and push again")
172+ }
173+ assert.Equal(t, tip, r.branchHead("main"))
174+ d, err := r.mrs.Get(r.ctx, mrM, r.repo, mr.Number, ports.GetMergeRequestOptions{})
175+ require.NoError(t, err)
176+ assert.Equal(t, domain.MRStateOpen, d.MergeRequest.State)
177+ })
178+ }
179+
180+ for _, mode := range []domain.MergeMode{domain.MergeModeSquash, domain.MergeModeMerge} {
181+ t.Run("no common history "+string(mode), func(t *testing.T) {
182+ t.Parallel()
183+ r := newMRRig(t)
184+ r.seed()
185+ r.sh("checkout", "-q", "--orphan", "island")
186+ r.commit(author, "island.txt", "i\n", "an unrelated root")
187+ r.push("island")
188+ head := r.branchHead("island")
189+ mr, err := r.mrs.Create(r.ctx, root, r.repo, ports.CreateMergeRequestParams{Title: "island", Source: "island"})
190+ if err != nil {
191+ t.Skipf("a request without common history cannot be opened: %v", err)
192+ }
193+ tip := r.branchHead("main")
194+ _, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, Mode: mode})
195+ require.ErrorIs(t, err, domain.ErrPreconditionFailed)
196+ assert.Contains(t, err.Error(), "no common history")
197+ assert.Equal(t, tip, r.branchHead("main"))
198+ })
199+ }
200+}
201+
202+func TestMergeReturnsBeforeTheSideEffects(t *testing.T) {
203+ t.Parallel()
204+ r := newMRRig(t)
205+ r.seed()
206+ head := r.feature("feat")
207+ mr := r.open("feat")
208+ r.drain()
209+ g := newGateHooks()
210+ mrs := r.gated(g)
211+
212+ done := make(chan error, 1)
213+ go func() {
214+ _, err := mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, DeleteSource: true})
215+ done <- err
216+ }()
217+ select {
218+ case err := <-done:
219+ require.NoError(t, err, "Merge does not wait for the blocked hooks")
220+ case <-time.After(10 * time.Second):
221+ t.Fatal("Merge waited for the side effects")
222+ }
223+ // The merge is complete and recorded though the hooks have not run.
224+ d, err := mrs.Get(r.ctx, mrM, r.repo, mr.Number, ports.GetMergeRequestOptions{})
225+ require.NoError(t, err)
226+ assert.Equal(t, domain.MRStateMerged, d.MergeRequest.State)
227+ assert.Equal(t, d.MergeRequest.MergedCommitID, r.branchHead("main"))
228+ assert.Empty(t, g.ran())
229+
230+ // Wait waits for them: it times out while they are blocked, then returns.
231+ ctx, cancel := context.WithTimeout(r.ctx, 50*time.Millisecond)
232+ start := time.Now()
233+ mrs.Wait(ctx)
234+ cancel()
235+ assert.GreaterOrEqual(t, time.Since(start), 40*time.Millisecond, "Wait waits for the pending side effects")
236+
237+ waited := make(chan struct{})
238+ go func() { mrs.Wait(context.Background()); close(waited) }()
239+ select {
240+ case <-waited:
241+ t.Fatal("Wait returned with side effects pending")
242+ case <-time.After(50 * time.Millisecond):
243+ }
244+ close(g.release)
245+ select {
246+ case <-waited:
247+ case <-time.After(10 * time.Second):
248+ t.Fatal("Wait did not return after the side effects ended")
249+ }
250+ assert.ElementsMatch(t, []string{"ci", "pulls"}, g.ran())
251+ _, err = r.git.BranchHead(r.ctx, r.repo, "feat")
252+ assert.ErrorIs(t, err, domain.ErrNotFound, "the source was deleted")
253+
254+ // Merged, then pushed, with the deletion in the push.
255+ evs := r.drain()
256+ require.Len(t, evs, 2)
257+ assert.IsType(t, domain.MergeRequestMerged{}, evs[0])
258+ push, ok := evs[1].(domain.PushEvent)
259+ require.True(t, ok)
260+ assert.Len(t, push.Updates, 2)
261+}
262+
263+func TestMergeReleasesTheLockBeforeTheSideEffects(t *testing.T) {
264+ t.Parallel()
265+ r := newMRRig(t)
266+ r.seed()
267+ head := r.feature("feat")
268+ mr := r.open("feat")
269+ r.sh("checkout", "-q", "main")
270+ other := r.openFeature("other")
271+ g := newGateHooks()
272+ defer func() {
273+ close(g.release)
274+ }()
275+ mrs := r.gated(g)
276+
277+ _, err := mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head})
278+ require.NoError(t, err)
279+ t.Cleanup(func() { mrs.Wait(context.Background()) })
280+ require.Empty(t, g.ran(), "the side effects are still blocked")
281+
282+ // Create and another Merge need the repository lock: they must finish.
283+ ctx, cancel := context.WithTimeout(r.ctx, 10*time.Second)
284+ defer cancel()
285+ r.sh("checkout", "-q", "-B", "third", "main")
286+ r.commit(author, "third.txt", "t\n", "third")
287+ r.push("third")
288+ _, err = mrs.Create(ctx, root, r.repo, ports.CreateMergeRequestParams{Title: "third", Source: "third"})
289+ require.NoError(t, err, "Create is not blocked by the side effects of a merge")
290+ _, err = mrs.Merge(ctx, mrM, r.repo, other.Number, ports.MergeParams{HeadID: r.branchHead("other"), Mode: domain.MergeModeMerge})
291+ require.NoError(t, err, "a second merge is not blocked either")
292+ assert.Empty(t, g.ran())
293+}
M internal/usecase/pushes.go +23 -11
@@ -20,17 +20,18 @@ const pushRetries = 5
2020
2121 // Pushes are the use cases around a Git push.
2222 type Pushes struct {
23- meta ports.RepoMetaStore
24- repos *Repos
25- git ports.GitRepos
26- bus ports.EventBus
27- clock ports.Clock
28- maint ports.MaintenanceScheduler
29- auth ports.Revalidator
30- fresh ports.RepoFreshener // nil: the reader needs no telling
31- ci ports.WorkflowTrigger // nil: no CI
32- wf ports.WorkflowGuard // nil: pushes by job tokens are refused
33- tips ports.CIRefTipStore // nil: who pushes a ref is not recorded
23+ meta ports.RepoMetaStore
24+ repos *Repos
25+ git ports.GitRepos
26+ bus ports.EventBus
27+ clock ports.Clock
28+ maint ports.MaintenanceScheduler
29+ auth ports.Revalidator
30+ fresh ports.RepoFreshener // nil: the reader needs no telling
31+ ci ports.WorkflowTrigger // nil: no CI
32+ wf ports.WorkflowGuard // nil: pushes by job tokens are refused
33+ tips ports.CIRefTipStore // nil: who pushes a ref is not recorded
34+ mergeab ports.MergeabilityTrigger // nil: mergeability is not tracked
3435 }
3536
3637 var (
@@ -59,6 +60,14 @@ func (u *Pushes) WithWorkflowTrigger(t ports.WorkflowTrigger) *Pushes {
5960 return u
6061 }
6162
63+// WithMergeability makes Record queue the mergeability checks of the merge
64+// requests whose branches the push moved. Call it before the first push; it
65+// returns u.
66+func (u *Pushes) WithMergeability(t ports.MergeabilityTrigger) *Pushes {
67+ u.mergeab = t
68+ return u
69+}
70+
6271 // WithRefTips makes Check record who is about to move each branch and tag
6372 // (domain.CIRefTip), before Git moves it. Call it before the first push; it
6473 // returns u.
@@ -233,6 +242,9 @@ func (u *Pushes) Record(ctx context.Context, p domain.Principal, name domain.Rep
233242 if u.ci != nil {
234243 u.ci.OnPush(ctx, name, updates, p) // queues only: never slows or fails the push
235244 }
245+ if u.mergeab != nil {
246+ u.mergeab.OnBranchesMoved(ctx, name, updates) // queues only
247+ }
236248 u.bus.Publish(ctx, domain.PushEvent{
237249 Repo: name, Updates: append([]domain.RefUpdate(nil), updates...), At: now,
238250 Visibility: repo.Visibility, CreatedAt: repo.CreatedAt,
M proto/ermite/storage/merge_request.proto +28 -0
@@ -15,6 +15,7 @@ enum MergeMode {
1515 MERGE_MODE_UNSPECIFIED = 0;
1616 MERGE_MODE_FAST_FORWARD = 1;
1717 MERGE_MODE_SQUASH = 2;
18+ MERGE_MODE_MERGE = 3;
1819 }
1920
2021 // RepoSettings are the merge settings of a repository, stored inside Repo.
@@ -24,6 +25,11 @@ message RepoSettings {
2425 bool failing_checks_block = 2;
2526 // Who may write issues; unspecified reads as off.
2627 IssuesMode issues = 3;
28+ // Set on every write since merge commits exist: the modes are the explicit
29+ // choice of the owner. When false (settings stored before), exactly
30+ // {fast-forward, squash} was the old default and gains MERGE_MODE_MERGE on
31+ // read.
32+ bool merge_modes_v2 = 4;
2733 }
2834
2935 // IssuesMode says who may write issues in a repository.
@@ -104,3 +110,25 @@ message Comment {
104110 message Sequence {
105111 int64 last = 1;
106112 }
113+
114+// MergeabilityState is the outcome of a mergeability check. "Checking" is
115+// never stored.
116+enum MergeabilityState {
117+ MERGEABILITY_STATE_UNSPECIFIED = 0;
118+ MERGEABILITY_STATE_UP_TO_DATE = 1;
119+ MERGEABILITY_STATE_BEHIND = 2;
120+ MERGEABILITY_STATE_CONFLICT = 3;
121+}
122+
123+// Mergeability is stored under the key "mrmerge/<repo>/<number:010d>": a
124+// derived, discardable record of an open merge request.
125+message Mergeability {
126+ MergeabilityState state = 1;
127+ string target_id = 2;
128+ string head_id = 3;
129+ int32 behind = 4;
130+ bool behind_capped = 5;
131+ repeated string conflict_paths = 6;
132+ bool conflict_paths_truncated = 7;
133+ google.protobuf.Timestamp checked_at = 8;
134+}
M skills/ermite/CLI.md +5 -4
@@ -11,7 +11,7 @@ Errors are one line on standard error (`ermite: ...`) with exit status 1. `--jso
1111 | `repo list [--json]` | none | repositories you can see |
1212 | `repo show <name> [--json]` | none | |
1313 | `repo create <name> [--description TEXT] [--visibility public]` | `repo:write` | private by default |
14-| `repo edit <name> [--description TEXT] [--visibility V] [--default-branch B] [--merge-modes fast-forward,squash] [--failing-checks-block=false]` | `repo:admin` | only the given flags change; `--description ""` clears it; the branch must exist |
14+| `repo edit <name> [--description TEXT] [--visibility V] [--default-branch B] [--merge-modes fast-forward,squash,merge] [--failing-checks-block=false]` | `repo:admin` | only the given flags change; every merge mode is allowed by default, list the ones to keep; `--description ""` clears it; the branch must exist |
1515 | `repo delete <name> [--yes]` | `repo:admin` | |
1616
1717 Repository JSON: `name`, `description`, `visibility`, `default_branch`, `created_at`, `pushed_at` (null before the first push), `merge_modes`, `failing_checks_block`.
@@ -56,12 +56,12 @@ A token can never grant more than the token that creates it. `--repo` (repeatabl
5656 |---|---|---|
5757 | `mr create <repo> --source B --title T [--target B] [--description TEXT\|-]` | `mr:write` | target defaults to the default branch; title of 120 characters max; `-` reads standard input |
5858 | `mr list <repo> [--state open\|merged\|closed\|all] [--json]` | none | default `open` |
59-| `mr show <repo> <n> [--diff] [--since-last-read] [--mark-read] [--json]` | `mr:write` for `--mark-read` | commits, changed files and comments; `--diff` adds hunks with line numbers |
59+| `mr show <repo> <n> [--diff] [--since-last-read] [--mark-read] [--json]` | `mr:write` for `--mark-read` | commits, changed files and comments; `--diff` adds hunks with line numbers; the `mergeability` line (JSON `mergeability`, null unless open) is `up_to_date`, `behind` (N commits, merges cleanly), `conflict` (paths) or `checking` |
6060 | `mr comment <repo> <n> -m TEXT\|- [--path P --line N\|A-B --side old\|new] [--reply ID] [--approve\|--request-changes]` | `mr:write`; verdicts `mr:review` | Markdown note (8000 characters max); the lines must be in the current diff on one side; a deleted file uses its old path; `--reply` takes a root note (`12`, `#12` or `^12`), inherits its anchor and cannot carry a verdict; a verdict note may have no message |
6161 | `mr edit-comment <repo> <n> <comment-id> -m TEXT\|-` | `mr:write` | author only, open merge request; the verdict is unchanged |
6262 | `mr delete-comment <repo> <n> <comment-id>` | `mr:write` | author only; the text is lost, the verdict is kept |
6363 | `mr resolve <repo> <n> <comment-id> [--undo]` | `mr:write` | on the thread root, covers its replies; doing it twice is not an error |
64-| `mr merge <repo> <n> --head <full-id> [--squash] [--delete-branch]` | `mr:merge` | refused if the branch moved or the target is not an ancestor of the head; `--delete-branch` deletes the source after the merge (refused for the default branch or a branch another open request uses; JSON adds `source_branch_deleted`) |
64+| `mr merge <repo> <n> --head <full-id> [--mode fast-forward\|squash\|merge] [--delete-branch]` | `mr:merge` | refused if the branch moved, if `fast-forward` and the target is not an ancestor of the head (behind: use `squash` or `merge`), or if `squash`/`merge` conflict; squash and merge commits are signed by the server key (`signing-key show`); answers once the target moved; `--delete-branch` deletes the source after the merge (refused for the default branch or a branch another open request uses; JSON adds `source_branch_deleted`) |
6565 | `mr close <repo> <n>` | `mr:write` | a closed merge request cannot be reopened |
6666
6767 `ermite mr create` and `ermite mr close` print a confirmation followed by the full
@@ -106,6 +106,7 @@ Workflow JSON: `repo`, `file` (always `.github/workflows/<file>`), `name` (the `
106106 | `repo_created`, `repo_updated`, `repo_deleted` | `repo`, `repository` |
107107 | `pushed` | `repo`, `ref_updates` (`ref`, `old_id`, `new_id`; an empty id means created or deleted) |
108108 | `merge_request_opened`, `merge_request_merged`, `merge_request_closed` | `repo`, `merge_request` |
109+| `merge_request_mergeability` | `repo`, `number`, `mergeability` (`state`: `checking`, `up_to_date`, `behind`, `conflict`; `behind`, `behind_capped`, `conflict_paths`, `conflict_paths_truncated`); sent when the state changes |
109110 | `comment_added`, `comment_edited`, `comment_deleted`, `comment_resolved` | `repo`, `number`, `comment` (a verdict note has `verdict` set) |
110111 | `gap` | none: events were lost, reload the state |
111112
@@ -116,7 +117,7 @@ Workflow JSON: `repo`, `file` (always `.github/workflows/<file>`), `name` (the `
116117 | `serve` | configured by `ERMITE_*` environment variables only (see the project README) |
117118 | `version [--json]` | client version; with a server URL (`ERMITE_URL` or the config file), the server version and features |
118119 | `config show [--json]` / `config set <url\|token.cmd> <value>` / `config unset <key>` | the CLI config file; `token.cmd` is a command that prints the token (any secret manager), never the token itself; `show` never runs it |
119-| `signing-key show` | public key and `allowed_signers` line to check squash commits with `git verify-commit` |
120+| `signing-key show` | public key and `allowed_signers` line to check squash and merge commits with `git verify-commit` |
120121 | `signing-key trust list [--json]` | keys commit signatures are checked against (the server key first) |
121122 | `signing-key trust add --format ssh\|gpg --title <t> --email <e>... <file\|->` / `trust remove <id>` | owner only; a commit shows verified when its committer email is listed for the key |
122123 | `web login [--json]` | one-time sign-in link (about 60 s); needs the admin token or a token with every scope, no repository list and no branch prefix; the link is a secret |
M skills/ermite/SKILL.md +2 -2
@@ -37,9 +37,9 @@ git push https://git.example.com/<repo>.git main # password = token,
3737 2. Read: `ermite mr show <repo> <n> --diff --json`. Keep `head_id`; take line numbers from the hunks.
3838 3. Comment on a line: `ermite mr comment <repo> <n> -m "text" --path <file> --line <n> --side new`; `--line 10-14` covers a range on one side. Without `--path` the comment is general. The line must appear in the diff. Notes are Markdown; `^N` refers to note N.
3939 4. End a review with a verdict (needs `mr:review`): `--approve` or `--request-changes`, alone or with a message. Verdicts are informative and cannot be edited; a new verdict replaces the previous one.
40-5. Merge only when asked: `ermite mr merge <repo> <n> --head <head_id>`, with `--squash` for one commit signed by the server and `--delete-branch` to delete the source branch afterwards.
40+5. Merge only when asked: `ermite mr merge <repo> <n> --head <head_id>`, with `--mode squash` for one commit or `--mode merge` for a merge commit (both signed by the server's key, see `ermite signing-key show`) and `--delete-branch` to delete the source branch afterwards.
4141
42-The merge is refused when the branch moved since `head_id` (review again) or when the target is not an ancestor of the head (rebase and push again). The repository may allow only one merge mode.
42+The merge is refused when the branch moved since `head_id` (review again) or, for `fast-forward`, when the target is not an ancestor of the head (rebase and push again, or use `squash`/`merge`), or when `squash`/`merge` conflict. `ermite mr show` prints the `mergeability` line (up to date, behind by N, conflict with paths). The repository may allow only some merge modes.
4343
4444 ### Act on a review
4545

Keyboard shortcuts

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

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

Branches 2

Tags 45

↑↓ moveenter openesc close