detached 6135dd5

#19 feat(mr): merge request auto-merge

state
merged · 6135dd5 · fast-forward
branch
feat/auto-merge → main
opened
bnema ·
updated

Merge request auto-merge, step 1 of ADR 0033.

  • Repository setting allow_auto_merge (default off): ermite repo edit --allow-auto-merge.
  • Enable with ermite mr merge --auto --head <sha> --mode <mode> or Merge when ready on the web; cancel with --cancel-auto.
  • Merges once CI has settled on the recorded head, every run is complete, at least one check passed and none fails (whatever failing_checks_block says), as the principal who enabled it. That principal is re-checked at every evaluation and under the merge lock.
  • Failing or conflicting heads wait. A new head, close, setting off, mode no longer allowed or a principal that can no longer merge cancels it.
  • Events mr.auto_merge_enabled, mr.auto_merge_cancelled, merged.auto; feature merge_requests.auto_merge.
  • Settings changes that affect merges take the merge lock (MergeGate). CI keeps a pushed head unsettled until its pull events are discovered.

Requires ermite-api v0.14.0.

Checks run locally: build, vet, hexcheck -fail-on warn, mocks/templ drift, race tests (all packages). The two golden failures (TestWebFilesGolden/blob-markdown, TestWebGolden/summary) and three errcheck lint findings also fail on main.

CommitSubjectAuthorDateSignatureStatus
6135dd5test(mr): close linked issues when a merge request auto-mergesbriceGPG Verified
4b10ba7merge: main with issue and merge request links into feat/auto-mergebriceGPG Verified
b2cf6d4build(deps): require ermite-api v0.14.0 for merge request auto-mergebriceGPG Verified
be277d4test(mr): cover failing checks with failing_checks_block off, the pending auto-merge index, and stop relying on event orderbriceGPG Verified
84db480perf(mr): skip auto-merge scans of repositories without a pending auto-merge, read checks once under the merge lock and keep the manual pending-checks errorbriceGPG Verified
b6e9374fix(web): give merge when ready its own mode choice and drop branch deletion from itbriceGPG Verified
355f5f4fix(mr): cancel auto-merge when the enabler can no longer merge, even while checks are pendingbriceGPG Verified
cb71bdddocs(mr): describe when auto-merge waits and when it is clearedbriceGPG Verified
22ff0d6fix(ci): keep a pushed head unsettled until its pull events are discoveredbriceGPG Verified
6609752fix(mr): resolve the auto-merge enabler under the merge lockbriceGPG Verified
8539d6ffix(repo): serialize merge settings updates with mergesbriceGPG Verified
63e55ecfeat(mr): require the head the caller saw to enable auto-mergebriceGPG Verified
b5db7e3docs(mr): document merge request auto-mergebriceGPG Verified
718df65merge: web auto-merge controls into feat/auto-mergebriceGPG Verified
b271fd8feat(cli): add mr merge --auto/--cancel-auto and repo edit --allow-auto-mergebriceGPG Verified
d1f49e5feat(rpc): serve merge request auto-merge and its eventsbriceGPG Verified
5dfb71bfeat(web): add merge when ready and auto-merge state to the merge boxbriceGPG Verified
31adb55style(web): gofmt mr_test.gobriceGPG Verified
ba47d48feat(app): wire the auto-merge evaluatorbriceGPG Verified
1314a0dfeat(mr): enable, cancel and evaluate merge request auto-mergebriceGPG Verified
9795e63feat(ports): add auto-merge service methods, CIActivity and AutoMergeTrigger portsbriceGPG Verified
4bb6db7feat(mr): store auto-merge state and allow_auto_merge settingbriceGPG Verified

Checks 8

70 files · +3994 -492
7/8
M docs/cli.md +3 -2
@@ -48,7 +48,7 @@ It does not run:
4848
4949 ```sh
5050 ermite repo list | show <name> | create <name> [--description …] [--visibility public]
51-ermite repo edit <name> [--description …] [--visibility …] [--default-branch …] [--merge-modes …] [--failing-checks-block=false] [--issues off|owner|public]
51+ermite repo edit <name> [--description …] [--visibility …] [--default-branch …] [--merge-modes …] [--failing-checks-block=false] [--allow-auto-merge] [--issues off|owner|public]
5252 ermite repo delete <name> [--yes]
5353
5454 ermite token create <label> [--preset cli|agent|ci|runner] [--scope …] [--repo …] [--branch-prefix …] [--expires 30d]
@@ -57,7 +57,7 @@ ermite token list | revoke <id>
5757 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> [--mode fast-forward|squash|merge] [--delete-branch] | close <repo> <n>
60+ermite mr merge <repo> <n> --head <id> [--mode fast-forward|squash|merge] [--delete-branch] | merge <repo> <n> --head <id> --auto [--mode …] | merge <repo> <n> --cancel-auto | close <repo> <n>
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>
@@ -100,6 +100,7 @@ Mirrors push repositories to a remote over SSH, never forcing. See [Push mirror]
100100 - `--json` prints stable field names (listed in each `--help`) on `repo list|show|create|edit`, `issue create|list|show|comment|close|reopen`, `token create|list`, `run list|view|cancel|rerun`, `runner list`, `mirror key|config|status` and `version`.
101101 - `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`.
102102 - `mr merge --mode squash|merge` creates a commit signed by the server's own key (generated on first start); the output names its fingerprint, `ermite signing-key show` prints the key. Every mode is allowed by default; `repo edit --merge-modes fast-forward,squash` disables `merge`.
103+- `mr merge --auto` makes the server merge once CI passed on the head (`repo edit --allow-auto-merge` first); `--cancel-auto` cancels it; both are exclusive with each other and `--auto` with `--delete-branch`. `watch --json` adds `merge_request_auto_merge_enabled`, `merge_request_auto_merge_cancelled` (with `reason`), and `auto` (true) on `merge_request_merged` after an auto-merge. See [Merge requests](merge-requests.md#auto-merge).
103104 - Errors are one line on stderr (`ermite: …`) with exit status 1.
104105 - Shell completion completes repository names from the server.
105106
@@ -48,7 +48,7 @@ It does not run:
48 48
49 ```sh49 ```sh
50 ermite repo list | show <name> | create <name> [--description …] [--visibility public]50 ermite repo list | show <name> | create <name> [--description …] [--visibility public]
51-ermite repo edit <name> [--description …] [--visibility …] [--default-branch …] [--merge-modes …] [--failing-checks-block=false] [--issues off|owner|public]51+ermite repo edit <name> [--description …] [--visibility …] [--default-branch …] [--merge-modes …] [--failing-checks-block=false] [--allow-auto-merge] [--issues off|owner|public]
52 ermite repo delete <name> [--yes]52 ermite repo delete <name> [--yes]
53 53
54 ermite token create <label> [--preset cli|agent|ci|runner] [--scope …] [--repo …] [--branch-prefix …] [--expires 30d]54 ermite token create <label> [--preset cli|agent|ci|runner] [--scope …] [--repo …] [--branch-prefix …] [--expires 30d]
@@ -57,7 +57,7 @@ ermite token list | revoke <id>
57 ermite mr create <repo> --source <branch> --title … | list <repo> | show <repo> <n> [--diff] # show prints the mergeability: up to date, behind, conflict57 ermite mr create <repo> --source <branch> --title … | list <repo> | show <repo> <n> [--diff] # show prints the mergeability: up to date, behind, conflict
58 ermite mr comment <repo> <n> -m … [--path … --line N|A-B --side old|new] [--reply <id>] [--approve|--request-changes]58 ermite mr comment <repo> <n> -m … [--path … --line N|A-B --side old|new] [--reply <id>] [--approve|--request-changes]
59 ermite mr resolve <repo> <n> <comment> | edit-comment <repo> <n> <comment> -m … | delete-comment <repo> <n> <comment>59 ermite mr resolve <repo> <n> <comment> | edit-comment <repo> <n> <comment> -m … | delete-comment <repo> <n> <comment>
60-ermite mr merge <repo> <n> --head <id> [--mode fast-forward|squash|merge] [--delete-branch] | close <repo> <n>60+ermite mr merge <repo> <n> --head <id> [--mode fast-forward|squash|merge] [--delete-branch] | merge <repo> <n> --head <id> --auto [--mode …] | merge <repo> <n> --cancel-auto | close <repo> <n>
61 61
62 ermite issue create <repo> --title … [--body … | --body-file …] | list <repo> [--state open|closed|all]62 ermite issue create <repo> --title … [--body … | --body-file …] | list <repo> [--state open|closed|all]
63 ermite issue show <repo> <n> | comment <repo> <n> -m … | close <repo> <n> | reopen <repo> <n>63 ermite issue show <repo> <n> | comment <repo> <n> -m … | close <repo> <n> | reopen <repo> <n>
@@ -100,6 +100,7 @@ Mirrors push repositories to a remote over SSH, never forcing. See [Push mirror]
100 - `--json` prints stable field names (listed in each `--help`) on `repo list|show|create|edit`, `issue create|list|show|comment|close|reopen`, `token create|list`, `run list|view|cancel|rerun`, `runner list`, `mirror key|config|status` and `version`.100 - `--json` prints stable field names (listed in each `--help`) on `repo list|show|create|edit`, `issue create|list|show|comment|close|reopen`, `token create|list`, `run list|view|cancel|rerun`, `runner list`, `mirror key|config|status` and `version`.
101 - `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`.101 - `watch --json` prints one object per line. `merge_request_mergeability` events carry `mergeability` (`state`, `behind`, `conflict_paths`…) and print as `repo!n mergeability: behind by 2`.
102 - `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`.102 - `mr merge --mode squash|merge` creates a commit signed by the server's own key (generated on first start); the output names its fingerprint, `ermite signing-key show` prints the key. Every mode is allowed by default; `repo edit --merge-modes fast-forward,squash` disables `merge`.
103+- `mr merge --auto` makes the server merge once CI passed on the head (`repo edit --allow-auto-merge` first); `--cancel-auto` cancels it; both are exclusive with each other and `--auto` with `--delete-branch`. `watch --json` adds `merge_request_auto_merge_enabled`, `merge_request_auto_merge_cancelled` (with `reason`), and `auto` (true) on `merge_request_merged` after an auto-merge. See [Merge requests](merge-requests.md#auto-merge).
103 - Errors are one line on stderr (`ermite: …`) with exit status 1.104 - Errors are one line on stderr (`ermite: …`) with exit status 1.
104 - Shell completion completes repository names from the server.105 - Shell completion completes repository names from the server.
105 106
M docs/merge-requests.md +14 -0
@@ -41,6 +41,20 @@ git -c gpg.format=ssh -c gpg.ssh.allowedSignersFile=allowed_signers verify-commi
4141
4242 The merge box of the web page and the output of `mr merge --mode squash|merge` repeat this with the fingerprint.
4343
44+## Auto-merge
45+
46+A merge request can merge by itself once its checks passed.
47+
48+- The repository setting `allow_auto_merge` is off by default: `ermite repo edit <repo> --allow-auto-merge` (`=false` to turn it off).
49+- Enable it with `ermite mr merge <repo> <n> --head <commit> --auto --mode squash` (`--head` is required, as for a plain merge; a head that moved is refused with "branch moved"), or with **Merge when ready** in the web merge box. Its separate mode choice offers every repository-allowed mode, even while the head conflicts or is behind. Auto-merge keeps the source branch; the delete-source checkbox applies only to an immediate merge. It needs `mr:merge` and a server with CI. Re-enabling replaces the previous mode and enabler.
50+- The server merges when CI has settled on the head with every run complete and at least one check passed. A failing or unfinished check always blocks, whatever `failing_checks_block` says. The mode must still be allowed.
51+- The merge is made as the person who enabled it. Their token is checked at every evaluation, even while checks are pending, and again at merge time under the merge lock: it must still be valid and allow `mr:merge` on the repository and the target branch. A token that no longer qualifies cancels the auto-merge.
52+- A pending auto-merge shows in `mr show` (`auto_merge`), in the web merge box and as `merge_request_auto_merge_enabled` in `ermite watch`. A merge request merged this way has `auto_merged` true.
53+- A failing, unfinished or conflicting head waits with auto-merge still on. A re-run or a target fix can make it pass; pushing a new source head cancels the recorded intent (the reason names the new head).
54+- A manual merge supersedes auto-merge and publishes `merge_request_merged` with `auto=false`, without a cancelled event. A manual close clears it and publishes `merge_request_closed` followed by `merge_request_auto_merge_cancelled` with reason `closed`.
55+- Cancellation reasons are `cancelled by <author>`, `closed`, `auto-merge was turned off in the repository settings`, `the <mode> merge mode is no longer allowed`, `the source branch was deleted`, `the branch moved to <short-head>`, and `the principal that enabled auto-merge can no longer merge <target>`. If a change wins the merge lock after the evaluator's first read, the merge-time reason is instead `auto-merge is off in <repo>`, `the <mode> merge mode is not allowed in <repo>`, `the source branch "<source>" no longer exists`, or `branch moved, read again: "<source>" is at <head>, not <recorded-head>`. Other permanent merge-time preconditions report `!<n> is <state>`, `!<n> is a draft: mark it ready for review first`, `the target branch "<target>" no longer exists`, `nothing to merge, "<target>" already is at <head>`, or `nothing to merge, "<target>" already contains <head>`.
56+- Cancel it with `ermite mr merge <repo> <n> --cancel-auto` or **Cancel auto-merge** in the web merge box.
57+
4458 ## Mergeability
4559
4660 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.
@@ -41,6 +41,20 @@ git -c gpg.format=ssh -c gpg.ssh.allowedSignersFile=allowed_signers verify-commi
41 41
42 The merge box of the web page and the output of `mr merge --mode squash|merge` repeat this with the fingerprint.42 The merge box of the web page and the output of `mr merge --mode squash|merge` repeat this with the fingerprint.
43 43
44+## Auto-merge
45+
46+A merge request can merge by itself once its checks passed.
47+
48+- The repository setting `allow_auto_merge` is off by default: `ermite repo edit <repo> --allow-auto-merge` (`=false` to turn it off).
49+- Enable it with `ermite mr merge <repo> <n> --head <commit> --auto --mode squash` (`--head` is required, as for a plain merge; a head that moved is refused with "branch moved"), or with **Merge when ready** in the web merge box. Its separate mode choice offers every repository-allowed mode, even while the head conflicts or is behind. Auto-merge keeps the source branch; the delete-source checkbox applies only to an immediate merge. It needs `mr:merge` and a server with CI. Re-enabling replaces the previous mode and enabler.
50+- The server merges when CI has settled on the head with every run complete and at least one check passed. A failing or unfinished check always blocks, whatever `failing_checks_block` says. The mode must still be allowed.
51+- The merge is made as the person who enabled it. Their token is checked at every evaluation, even while checks are pending, and again at merge time under the merge lock: it must still be valid and allow `mr:merge` on the repository and the target branch. A token that no longer qualifies cancels the auto-merge.
52+- A pending auto-merge shows in `mr show` (`auto_merge`), in the web merge box and as `merge_request_auto_merge_enabled` in `ermite watch`. A merge request merged this way has `auto_merged` true.
53+- A failing, unfinished or conflicting head waits with auto-merge still on. A re-run or a target fix can make it pass; pushing a new source head cancels the recorded intent (the reason names the new head).
54+- A manual merge supersedes auto-merge and publishes `merge_request_merged` with `auto=false`, without a cancelled event. A manual close clears it and publishes `merge_request_closed` followed by `merge_request_auto_merge_cancelled` with reason `closed`.
55+- Cancellation reasons are `cancelled by <author>`, `closed`, `auto-merge was turned off in the repository settings`, `the <mode> merge mode is no longer allowed`, `the source branch was deleted`, `the branch moved to <short-head>`, and `the principal that enabled auto-merge can no longer merge <target>`. If a change wins the merge lock after the evaluator's first read, the merge-time reason is instead `auto-merge is off in <repo>`, `the <mode> merge mode is not allowed in <repo>`, `the source branch "<source>" no longer exists`, or `branch moved, read again: "<source>" is at <head>, not <recorded-head>`. Other permanent merge-time preconditions report `!<n> is <state>`, `!<n> is a draft: mark it ready for review first`, `the target branch "<target>" no longer exists`, `nothing to merge, "<target>" already is at <head>`, or `nothing to merge, "<target>" already contains <head>`.
56+- Cancel it with `ermite mr merge <repo> <n> --cancel-auto` or **Cancel auto-merge** in the web merge box.
57+
44 ## Mergeability58 ## Mergeability
45 59
46 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.60 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.
M gen/ermite/storage/merge_request.pb.go +163 -35
@@ -352,9 +352,10 @@ type RepoSettings struct {
352352 // choice of the owner. When false (settings stored before), exactly
353353 // {fast-forward, squash} was the old default and gains MERGE_MODE_MERGE on
354354 // read.
355- MergeModesV2 bool `protobuf:"varint,4,opt,name=merge_modes_v2,json=mergeModesV2,proto3" json:"merge_modes_v2,omitempty"`
356- unknownFields protoimpl.UnknownFields
357- sizeCache protoimpl.SizeCache
355+ MergeModesV2 bool `protobuf:"varint,4,opt,name=merge_modes_v2,json=mergeModesV2,proto3" json:"merge_modes_v2,omitempty"`
356+ AllowAutoMerge bool `protobuf:"varint,5,opt,name=allow_auto_merge,json=allowAutoMerge,proto3" json:"allow_auto_merge,omitempty"`
357+ unknownFields protoimpl.UnknownFields
358+ sizeCache protoimpl.SizeCache
358359 }
359360
360361 func (x *RepoSettings) Reset() {
@@ -415,6 +416,13 @@ func (x *RepoSettings) GetMergeModesV2() bool {
415416 return false
416417 }
417418
419+func (x *RepoSettings) GetAllowAutoMerge() bool {
420+ if x != nil {
421+ return x.AllowAutoMerge
422+ }
423+ return false
424+}
425+
418426 // MergeRequest is stored under the key "mr/<repo>/<number:010d>".
419427 type MergeRequest struct {
420428 state protoimpl.MessageState `protogen:"open.v1"`
@@ -437,7 +445,10 @@ type MergeRequest struct {
437445 MergedCommitId string `protobuf:"bytes,12,opt,name=merged_commit_id,json=mergedCommitId,proto3" json:"merged_commit_id,omitempty"`
438446 MergeMode MergeMode `protobuf:"varint,13,opt,name=merge_mode,json=mergeMode,proto3,enum=ermite.storage.MergeMode" json:"merge_mode,omitempty"`
439447 // Last comment id handed out for this request.
440- CommentSeq int64 `protobuf:"varint,14,opt,name=comment_seq,json=commentSeq,proto3" json:"comment_seq,omitempty"`
448+ CommentSeq int64 `protobuf:"varint,14,opt,name=comment_seq,json=commentSeq,proto3" json:"comment_seq,omitempty"`
449+ // Pending auto-merge; unset when off.
450+ AutoMerge *AutoMerge `protobuf:"bytes,15,opt,name=auto_merge,json=autoMerge,proto3" json:"auto_merge,omitempty"`
451+ AutoMerged bool `protobuf:"varint,16,opt,name=auto_merged,json=autoMerged,proto3" json:"auto_merged,omitempty"`
441452 unknownFields protoimpl.UnknownFields
442453 sizeCache protoimpl.SizeCache
443454 }
@@ -570,6 +581,106 @@ func (x *MergeRequest) GetCommentSeq() int64 {
570581 return 0
571582 }
572583
584+func (x *MergeRequest) GetAutoMerge() *AutoMerge {
585+ if x != nil {
586+ return x.AutoMerge
587+ }
588+ return nil
589+}
590+
591+func (x *MergeRequest) GetAutoMerged() bool {
592+ if x != nil {
593+ return x.AutoMerged
594+ }
595+ return false
596+}
597+
598+// AutoMerge is the pending auto-merge of an open merge request.
599+type AutoMerge struct {
600+ state protoimpl.MessageState `protogen:"open.v1"`
601+ Mode MergeMode `protobuf:"varint,1,opt,name=mode,proto3,enum=ermite.storage.MergeMode" json:"mode,omitempty"`
602+ HeadId string `protobuf:"bytes,2,opt,name=head_id,json=headId,proto3" json:"head_id,omitempty"`
603+ // Kind and token id of the CIPusher that enabled it.
604+ EnablerKind string `protobuf:"bytes,3,opt,name=enabler_kind,json=enablerKind,proto3" json:"enabler_kind,omitempty"`
605+ EnablerTokenId string `protobuf:"bytes,4,opt,name=enabler_token_id,json=enablerTokenId,proto3" json:"enabler_token_id,omitempty"`
606+ EnablerLabel string `protobuf:"bytes,5,opt,name=enabler_label,json=enablerLabel,proto3" json:"enabler_label,omitempty"`
607+ EnabledAt *timestamppb.Timestamp `protobuf:"bytes,6,opt,name=enabled_at,json=enabledAt,proto3" json:"enabled_at,omitempty"`
608+ unknownFields protoimpl.UnknownFields
609+ sizeCache protoimpl.SizeCache
610+}
611+
612+func (x *AutoMerge) Reset() {
613+ *x = AutoMerge{}
614+ mi := &file_ermite_storage_merge_request_proto_msgTypes[2]
615+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
616+ ms.StoreMessageInfo(mi)
617+}
618+
619+func (x *AutoMerge) String() string {
620+ return protoimpl.X.MessageStringOf(x)
621+}
622+
623+func (*AutoMerge) ProtoMessage() {}
624+
625+func (x *AutoMerge) ProtoReflect() protoreflect.Message {
626+ mi := &file_ermite_storage_merge_request_proto_msgTypes[2]
627+ if x != nil {
628+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
629+ if ms.LoadMessageInfo() == nil {
630+ ms.StoreMessageInfo(mi)
631+ }
632+ return ms
633+ }
634+ return mi.MessageOf(x)
635+}
636+
637+// Deprecated: Use AutoMerge.ProtoReflect.Descriptor instead.
638+func (*AutoMerge) Descriptor() ([]byte, []int) {
639+ return file_ermite_storage_merge_request_proto_rawDescGZIP(), []int{2}
640+}
641+
642+func (x *AutoMerge) GetMode() MergeMode {
643+ if x != nil {
644+ return x.Mode
645+ }
646+ return MergeMode_MERGE_MODE_UNSPECIFIED
647+}
648+
649+func (x *AutoMerge) GetHeadId() string {
650+ if x != nil {
651+ return x.HeadId
652+ }
653+ return ""
654+}
655+
656+func (x *AutoMerge) GetEnablerKind() string {
657+ if x != nil {
658+ return x.EnablerKind
659+ }
660+ return ""
661+}
662+
663+func (x *AutoMerge) GetEnablerTokenId() string {
664+ if x != nil {
665+ return x.EnablerTokenId
666+ }
667+ return ""
668+}
669+
670+func (x *AutoMerge) GetEnablerLabel() string {
671+ if x != nil {
672+ return x.EnablerLabel
673+ }
674+ return ""
675+}
676+
677+func (x *AutoMerge) GetEnabledAt() *timestamppb.Timestamp {
678+ if x != nil {
679+ return x.EnabledAt
680+ }
681+ return nil
682+}
683+
573684 // Comment is stored under the key "mrc/<repo>/<number:010d>/<id:010d>".
574685 type Comment struct {
575686 state protoimpl.MessageState `protogen:"open.v1"`
@@ -593,7 +704,7 @@ type Comment struct {
593704
594705 func (x *Comment) Reset() {
595706 *x = Comment{}
596- mi := &file_ermite_storage_merge_request_proto_msgTypes[2]
707+ mi := &file_ermite_storage_merge_request_proto_msgTypes[3]
597708 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
598709 ms.StoreMessageInfo(mi)
599710 }
@@ -605,7 +716,7 @@ func (x *Comment) String() string {
605716 func (*Comment) ProtoMessage() {}
606717
607718 func (x *Comment) ProtoReflect() protoreflect.Message {
608- mi := &file_ermite_storage_merge_request_proto_msgTypes[2]
719+ mi := &file_ermite_storage_merge_request_proto_msgTypes[3]
609720 if x != nil {
610721 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
611722 if ms.LoadMessageInfo() == nil {
@@ -618,7 +729,7 @@ func (x *Comment) ProtoReflect() protoreflect.Message {
618729
619730 // Deprecated: Use Comment.ProtoReflect.Descriptor instead.
620731 func (*Comment) Descriptor() ([]byte, []int) {
621- return file_ermite_storage_merge_request_proto_rawDescGZIP(), []int{2}
732+ return file_ermite_storage_merge_request_proto_rawDescGZIP(), []int{3}
622733 }
623734
624735 func (x *Comment) GetId() int64 {
@@ -730,7 +841,7 @@ type Sequence struct {
730841
731842 func (x *Sequence) Reset() {
732843 *x = Sequence{}
733- mi := &file_ermite_storage_merge_request_proto_msgTypes[3]
844+ mi := &file_ermite_storage_merge_request_proto_msgTypes[4]
734845 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
735846 ms.StoreMessageInfo(mi)
736847 }
@@ -742,7 +853,7 @@ func (x *Sequence) String() string {
742853 func (*Sequence) ProtoMessage() {}
743854
744855 func (x *Sequence) ProtoReflect() protoreflect.Message {
745- mi := &file_ermite_storage_merge_request_proto_msgTypes[3]
856+ mi := &file_ermite_storage_merge_request_proto_msgTypes[4]
746857 if x != nil {
747858 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
748859 if ms.LoadMessageInfo() == nil {
@@ -755,7 +866,7 @@ func (x *Sequence) ProtoReflect() protoreflect.Message {
755866
756867 // Deprecated: Use Sequence.ProtoReflect.Descriptor instead.
757868 func (*Sequence) Descriptor() ([]byte, []int) {
758- return file_ermite_storage_merge_request_proto_rawDescGZIP(), []int{3}
869+ return file_ermite_storage_merge_request_proto_rawDescGZIP(), []int{4}
759870 }
760871
761872 func (x *Sequence) GetLast() int64 {
@@ -783,7 +894,7 @@ type Mergeability struct {
783894
784895 func (x *Mergeability) Reset() {
785896 *x = Mergeability{}
786- mi := &file_ermite_storage_merge_request_proto_msgTypes[4]
897+ mi := &file_ermite_storage_merge_request_proto_msgTypes[5]
787898 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
788899 ms.StoreMessageInfo(mi)
789900 }
@@ -795,7 +906,7 @@ func (x *Mergeability) String() string {
795906 func (*Mergeability) ProtoMessage() {}
796907
797908 func (x *Mergeability) ProtoReflect() protoreflect.Message {
798- mi := &file_ermite_storage_merge_request_proto_msgTypes[4]
909+ mi := &file_ermite_storage_merge_request_proto_msgTypes[5]
799910 if x != nil {
800911 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
801912 if ms.LoadMessageInfo() == nil {
@@ -808,7 +919,7 @@ func (x *Mergeability) ProtoReflect() protoreflect.Message {
808919
809920 // Deprecated: Use Mergeability.ProtoReflect.Descriptor instead.
810921 func (*Mergeability) Descriptor() ([]byte, []int) {
811- return file_ermite_storage_merge_request_proto_rawDescGZIP(), []int{4}
922+ return file_ermite_storage_merge_request_proto_rawDescGZIP(), []int{5}
812923 }
813924
814925 func (x *Mergeability) GetState() MergeabilityState {
@@ -871,12 +982,13 @@ var File_ermite_storage_merge_request_proto protoreflect.FileDescriptor
871982
872983 const file_ermite_storage_merge_request_proto_rawDesc = "" +
873984 "\n" +
874- "\"ermite/storage/merge_request.proto\x12\x0eermite.storage\x1a\x1fgoogle/protobuf/timestamp.proto\"\xe5\x01\n" +
985+ "\"ermite/storage/merge_request.proto\x12\x0eermite.storage\x1a\x1fgoogle/protobuf/timestamp.proto\"\x8f\x02\n" +
875986 "\fRepoSettings\x12I\n" +
876987 "\x13allowed_merge_modes\x18\x01 \x03(\x0e2\x19.ermite.storage.MergeModeR\x11allowedMergeModes\x120\n" +
877988 "\x14failing_checks_block\x18\x02 \x01(\bR\x12failingChecksBlock\x122\n" +
878989 "\x06issues\x18\x03 \x01(\x0e2\x1a.ermite.storage.IssuesModeR\x06issues\x12$\n" +
879- "\x0emerge_modes_v2\x18\x04 \x01(\bR\fmergeModesV2\"\xa6\x04\n" +
990+ "\x0emerge_modes_v2\x18\x04 \x01(\bR\fmergeModesV2\x12(\n" +
991+ "\x10allow_auto_merge\x18\x05 \x01(\bR\x0eallowAutoMerge\"\x81\x05\n" +
880992 "\fMergeRequest\x12\x16\n" +
881993 "\x06number\x18\x01 \x01(\x03R\x06number\x12\x14\n" +
882994 "\x05title\x18\x02 \x01(\tR\x05title\x12 \n" +
@@ -896,7 +1008,19 @@ const file_ermite_storage_merge_request_proto_rawDesc = "" +
8961008 "\n" +
8971009 "merge_mode\x18\r \x01(\x0e2\x19.ermite.storage.MergeModeR\tmergeMode\x12\x1f\n" +
8981010 "\vcomment_seq\x18\x0e \x01(\x03R\n" +
899- "commentSeq\"\xcf\x03\n" +
1011+ "commentSeq\x128\n" +
1012+ "\n" +
1013+ "auto_merge\x18\x0f \x01(\v2\x19.ermite.storage.AutoMergeR\tautoMerge\x12\x1f\n" +
1014+ "\vauto_merged\x18\x10 \x01(\bR\n" +
1015+ "autoMerged\"\x80\x02\n" +
1016+ "\tAutoMerge\x12-\n" +
1017+ "\x04mode\x18\x01 \x01(\x0e2\x19.ermite.storage.MergeModeR\x04mode\x12\x17\n" +
1018+ "\ahead_id\x18\x02 \x01(\tR\x06headId\x12!\n" +
1019+ "\fenabler_kind\x18\x03 \x01(\tR\venablerKind\x12(\n" +
1020+ "\x10enabler_token_id\x18\x04 \x01(\tR\x0eenablerTokenId\x12#\n" +
1021+ "\renabler_label\x18\x05 \x01(\tR\fenablerLabel\x129\n" +
1022+ "\n" +
1023+ "enabled_at\x18\x06 \x01(\v2\x1a.google.protobuf.TimestampR\tenabledAt\"\xcf\x03\n" +
9001024 "\aComment\x12\x0e\n" +
9011025 "\x02id\x18\x01 \x01(\x03R\x02id\x12\x16\n" +
9021026 "\x06author\x18\x02 \x01(\tR\x06author\x12\x12\n" +
@@ -970,7 +1094,7 @@ func file_ermite_storage_merge_request_proto_rawDescGZIP() []byte {
9701094 }
9711095
9721096 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)
1097+var file_ermite_storage_merge_request_proto_msgTypes = make([]protoimpl.MessageInfo, 6)
9741098 var file_ermite_storage_merge_request_proto_goTypes = []any{
9751099 (MergeMode)(0), // 0: ermite.storage.MergeMode
9761100 (IssuesMode)(0), // 1: ermite.storage.IssuesMode
@@ -980,29 +1104,33 @@ var file_ermite_storage_merge_request_proto_goTypes = []any{
9801104 (MergeabilityState)(0), // 5: ermite.storage.MergeabilityState
9811105 (*RepoSettings)(nil), // 6: ermite.storage.RepoSettings
9821106 (*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
1107+ (*AutoMerge)(nil), // 8: ermite.storage.AutoMerge
1108+ (*Comment)(nil), // 9: ermite.storage.Comment
1109+ (*Sequence)(nil), // 10: ermite.storage.Sequence
1110+ (*Mergeability)(nil), // 11: ermite.storage.Mergeability
1111+ (*timestamppb.Timestamp)(nil), // 12: google.protobuf.Timestamp
9871112 }
9881113 var file_ermite_storage_merge_request_proto_depIdxs = []int32{
9891114 0, // 0: ermite.storage.RepoSettings.allowed_merge_modes:type_name -> ermite.storage.MergeMode
9901115 1, // 1: ermite.storage.RepoSettings.issues:type_name -> ermite.storage.IssuesMode
9911116 2, // 2: ermite.storage.MergeRequest.state:type_name -> ermite.storage.MergeRequestState
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
1117+ 12, // 3: ermite.storage.MergeRequest.created_at:type_name -> google.protobuf.Timestamp
1118+ 12, // 4: ermite.storage.MergeRequest.updated_at:type_name -> google.protobuf.Timestamp
9941119 0, // 5: ermite.storage.MergeRequest.merge_mode:type_name -> ermite.storage.MergeMode
995- 3, // 6: ermite.storage.Comment.side:type_name -> ermite.storage.DiffSide
996- 11, // 7: ermite.storage.Comment.created_at:type_name -> google.protobuf.Timestamp
997- 4, // 8: ermite.storage.Comment.verdict:type_name -> ermite.storage.Verdict
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
1120+ 8, // 6: ermite.storage.MergeRequest.auto_merge:type_name -> ermite.storage.AutoMerge
1121+ 0, // 7: ermite.storage.AutoMerge.mode:type_name -> ermite.storage.MergeMode
1122+ 12, // 8: ermite.storage.AutoMerge.enabled_at:type_name -> google.protobuf.Timestamp
1123+ 3, // 9: ermite.storage.Comment.side:type_name -> ermite.storage.DiffSide
1124+ 12, // 10: ermite.storage.Comment.created_at:type_name -> google.protobuf.Timestamp
1125+ 4, // 11: ermite.storage.Comment.verdict:type_name -> ermite.storage.Verdict
1126+ 12, // 12: ermite.storage.Comment.edited_at:type_name -> google.protobuf.Timestamp
1127+ 5, // 13: ermite.storage.Mergeability.state:type_name -> ermite.storage.MergeabilityState
1128+ 12, // 14: ermite.storage.Mergeability.checked_at:type_name -> google.protobuf.Timestamp
1129+ 15, // [15:15] is the sub-list for method output_type
1130+ 15, // [15:15] is the sub-list for method input_type
1131+ 15, // [15:15] is the sub-list for extension type_name
1132+ 15, // [15:15] is the sub-list for extension extendee
1133+ 0, // [0:15] is the sub-list for field type_name
10061134 }
10071135
10081136 func init() { file_ermite_storage_merge_request_proto_init() }
@@ -1016,7 +1144,7 @@ func file_ermite_storage_merge_request_proto_init() {
10161144 GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
10171145 RawDescriptor: unsafe.Slice(unsafe.StringData(file_ermite_storage_merge_request_proto_rawDesc), len(file_ermite_storage_merge_request_proto_rawDesc)),
10181146 NumEnums: 6,
1019- NumMessages: 5,
1147+ NumMessages: 6,
10201148 NumExtensions: 0,
10211149 NumServices: 0,
10221150 },
@@ -352,9 +352,10 @@ type RepoSettings struct {
352 // choice of the owner. When false (settings stored before), exactly352 // choice of the owner. When false (settings stored before), exactly
353 // {fast-forward, squash} was the old default and gains MERGE_MODE_MERGE on353 // {fast-forward, squash} was the old default and gains MERGE_MODE_MERGE on
354 // read.354 // read.
355- MergeModesV2 bool `protobuf:"varint,4,opt,name=merge_modes_v2,json=mergeModesV2,proto3" json:"merge_modes_v2,omitempty"`355+ MergeModesV2 bool `protobuf:"varint,4,opt,name=merge_modes_v2,json=mergeModesV2,proto3" json:"merge_modes_v2,omitempty"`
356- unknownFields protoimpl.UnknownFields356+ AllowAutoMerge bool `protobuf:"varint,5,opt,name=allow_auto_merge,json=allowAutoMerge,proto3" json:"allow_auto_merge,omitempty"`
357- sizeCache protoimpl.SizeCache357+ unknownFields protoimpl.UnknownFields
358+ sizeCache protoimpl.SizeCache
358 }359 }
359 360
360 func (x *RepoSettings) Reset() {361 func (x *RepoSettings) Reset() {
@@ -415,6 +416,13 @@ func (x *RepoSettings) GetMergeModesV2() bool {
415 return false416 return false
416 }417 }
417 418
419+func (x *RepoSettings) GetAllowAutoMerge() bool {
420+ if x != nil {
421+ return x.AllowAutoMerge
422+ }
423+ return false
424+}
425+
418 // MergeRequest is stored under the key "mr/<repo>/<number:010d>".426 // MergeRequest is stored under the key "mr/<repo>/<number:010d>".
419 type MergeRequest struct {427 type MergeRequest struct {
420 state protoimpl.MessageState `protogen:"open.v1"`428 state protoimpl.MessageState `protogen:"open.v1"`
@@ -437,7 +445,10 @@ type MergeRequest struct {
437 MergedCommitId string `protobuf:"bytes,12,opt,name=merged_commit_id,json=mergedCommitId,proto3" json:"merged_commit_id,omitempty"`445 MergedCommitId string `protobuf:"bytes,12,opt,name=merged_commit_id,json=mergedCommitId,proto3" json:"merged_commit_id,omitempty"`
438 MergeMode MergeMode `protobuf:"varint,13,opt,name=merge_mode,json=mergeMode,proto3,enum=ermite.storage.MergeMode" json:"merge_mode,omitempty"`446 MergeMode MergeMode `protobuf:"varint,13,opt,name=merge_mode,json=mergeMode,proto3,enum=ermite.storage.MergeMode" json:"merge_mode,omitempty"`
439 // Last comment id handed out for this request.447 // Last comment id handed out for this request.
440- CommentSeq int64 `protobuf:"varint,14,opt,name=comment_seq,json=commentSeq,proto3" json:"comment_seq,omitempty"`448+ CommentSeq int64 `protobuf:"varint,14,opt,name=comment_seq,json=commentSeq,proto3" json:"comment_seq,omitempty"`
449+ // Pending auto-merge; unset when off.
450+ AutoMerge *AutoMerge `protobuf:"bytes,15,opt,name=auto_merge,json=autoMerge,proto3" json:"auto_merge,omitempty"`
451+ AutoMerged bool `protobuf:"varint,16,opt,name=auto_merged,json=autoMerged,proto3" json:"auto_merged,omitempty"`
441 unknownFields protoimpl.UnknownFields452 unknownFields protoimpl.UnknownFields
442 sizeCache protoimpl.SizeCache453 sizeCache protoimpl.SizeCache
443 }454 }
@@ -570,6 +581,106 @@ func (x *MergeRequest) GetCommentSeq() int64 {
570 return 0581 return 0
571 }582 }
572 583
584+func (x *MergeRequest) GetAutoMerge() *AutoMerge {
585+ if x != nil {
586+ return x.AutoMerge
587+ }
588+ return nil
589+}
590+
591+func (x *MergeRequest) GetAutoMerged() bool {
592+ if x != nil {
593+ return x.AutoMerged
594+ }
595+ return false
596+}
597+
598+// AutoMerge is the pending auto-merge of an open merge request.
599+type AutoMerge struct {
600+ state protoimpl.MessageState `protogen:"open.v1"`
601+ Mode MergeMode `protobuf:"varint,1,opt,name=mode,proto3,enum=ermite.storage.MergeMode" json:"mode,omitempty"`
602+ HeadId string `protobuf:"bytes,2,opt,name=head_id,json=headId,proto3" json:"head_id,omitempty"`
603+ // Kind and token id of the CIPusher that enabled it.
604+ EnablerKind string `protobuf:"bytes,3,opt,name=enabler_kind,json=enablerKind,proto3" json:"enabler_kind,omitempty"`
605+ EnablerTokenId string `protobuf:"bytes,4,opt,name=enabler_token_id,json=enablerTokenId,proto3" json:"enabler_token_id,omitempty"`
606+ EnablerLabel string `protobuf:"bytes,5,opt,name=enabler_label,json=enablerLabel,proto3" json:"enabler_label,omitempty"`
607+ EnabledAt *timestamppb.Timestamp `protobuf:"bytes,6,opt,name=enabled_at,json=enabledAt,proto3" json:"enabled_at,omitempty"`
608+ unknownFields protoimpl.UnknownFields
609+ sizeCache protoimpl.SizeCache
610+}
611+
612+func (x *AutoMerge) Reset() {
613+ *x = AutoMerge{}
614+ mi := &file_ermite_storage_merge_request_proto_msgTypes[2]
615+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
616+ ms.StoreMessageInfo(mi)
617+}
618+
619+func (x *AutoMerge) String() string {
620+ return protoimpl.X.MessageStringOf(x)
621+}
622+
623+func (*AutoMerge) ProtoMessage() {}
624+
625+func (x *AutoMerge) ProtoReflect() protoreflect.Message {
626+ mi := &file_ermite_storage_merge_request_proto_msgTypes[2]
627+ if x != nil {
628+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
629+ if ms.LoadMessageInfo() == nil {
630+ ms.StoreMessageInfo(mi)
631+ }
632+ return ms
633+ }
634+ return mi.MessageOf(x)
635+}
636+
637+// Deprecated: Use AutoMerge.ProtoReflect.Descriptor instead.
638+func (*AutoMerge) Descriptor() ([]byte, []int) {
639+ return file_ermite_storage_merge_request_proto_rawDescGZIP(), []int{2}
640+}
641+
642+func (x *AutoMerge) GetMode() MergeMode {
643+ if x != nil {
644+ return x.Mode
645+ }
646+ return MergeMode_MERGE_MODE_UNSPECIFIED
647+}
648+
649+func (x *AutoMerge) GetHeadId() string {
650+ if x != nil {
651+ return x.HeadId
652+ }
653+ return ""
654+}
655+
656+func (x *AutoMerge) GetEnablerKind() string {
657+ if x != nil {
658+ return x.EnablerKind
659+ }
660+ return ""
661+}
662+
663+func (x *AutoMerge) GetEnablerTokenId() string {
664+ if x != nil {
665+ return x.EnablerTokenId
666+ }
667+ return ""
668+}
669+
670+func (x *AutoMerge) GetEnablerLabel() string {
671+ if x != nil {
672+ return x.EnablerLabel
673+ }
674+ return ""
675+}
676+
677+func (x *AutoMerge) GetEnabledAt() *timestamppb.Timestamp {
678+ if x != nil {
679+ return x.EnabledAt
680+ }
681+ return nil
682+}
683+
573 // Comment is stored under the key "mrc/<repo>/<number:010d>/<id:010d>".684 // Comment is stored under the key "mrc/<repo>/<number:010d>/<id:010d>".
574 type Comment struct {685 type Comment struct {
575 state protoimpl.MessageState `protogen:"open.v1"`686 state protoimpl.MessageState `protogen:"open.v1"`
@@ -593,7 +704,7 @@ type Comment struct {
593 704
594 func (x *Comment) Reset() {705 func (x *Comment) Reset() {
595 *x = Comment{}706 *x = Comment{}
596- mi := &file_ermite_storage_merge_request_proto_msgTypes[2]707+ mi := &file_ermite_storage_merge_request_proto_msgTypes[3]
597 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))708 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
598 ms.StoreMessageInfo(mi)709 ms.StoreMessageInfo(mi)
599 }710 }
@@ -605,7 +716,7 @@ func (x *Comment) String() string {
605 func (*Comment) ProtoMessage() {}716 func (*Comment) ProtoMessage() {}
606 717
607 func (x *Comment) ProtoReflect() protoreflect.Message {718 func (x *Comment) ProtoReflect() protoreflect.Message {
608- mi := &file_ermite_storage_merge_request_proto_msgTypes[2]719+ mi := &file_ermite_storage_merge_request_proto_msgTypes[3]
609 if x != nil {720 if x != nil {
610 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))721 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
611 if ms.LoadMessageInfo() == nil {722 if ms.LoadMessageInfo() == nil {
@@ -618,7 +729,7 @@ func (x *Comment) ProtoReflect() protoreflect.Message {
618 729
619 // Deprecated: Use Comment.ProtoReflect.Descriptor instead.730 // Deprecated: Use Comment.ProtoReflect.Descriptor instead.
620 func (*Comment) Descriptor() ([]byte, []int) {731 func (*Comment) Descriptor() ([]byte, []int) {
621- return file_ermite_storage_merge_request_proto_rawDescGZIP(), []int{2}732+ return file_ermite_storage_merge_request_proto_rawDescGZIP(), []int{3}
622 }733 }
623 734
624 func (x *Comment) GetId() int64 {735 func (x *Comment) GetId() int64 {
@@ -730,7 +841,7 @@ type Sequence struct {
730 841
731 func (x *Sequence) Reset() {842 func (x *Sequence) Reset() {
732 *x = Sequence{}843 *x = Sequence{}
733- mi := &file_ermite_storage_merge_request_proto_msgTypes[3]844+ mi := &file_ermite_storage_merge_request_proto_msgTypes[4]
734 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))845 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
735 ms.StoreMessageInfo(mi)846 ms.StoreMessageInfo(mi)
736 }847 }
@@ -742,7 +853,7 @@ func (x *Sequence) String() string {
742 func (*Sequence) ProtoMessage() {}853 func (*Sequence) ProtoMessage() {}
743 854
744 func (x *Sequence) ProtoReflect() protoreflect.Message {855 func (x *Sequence) ProtoReflect() protoreflect.Message {
745- mi := &file_ermite_storage_merge_request_proto_msgTypes[3]856+ mi := &file_ermite_storage_merge_request_proto_msgTypes[4]
746 if x != nil {857 if x != nil {
747 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))858 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
748 if ms.LoadMessageInfo() == nil {859 if ms.LoadMessageInfo() == nil {
@@ -755,7 +866,7 @@ func (x *Sequence) ProtoReflect() protoreflect.Message {
755 866
756 // Deprecated: Use Sequence.ProtoReflect.Descriptor instead.867 // Deprecated: Use Sequence.ProtoReflect.Descriptor instead.
757 func (*Sequence) Descriptor() ([]byte, []int) {868 func (*Sequence) Descriptor() ([]byte, []int) {
758- return file_ermite_storage_merge_request_proto_rawDescGZIP(), []int{3}869+ return file_ermite_storage_merge_request_proto_rawDescGZIP(), []int{4}
759 }870 }
760 871
761 func (x *Sequence) GetLast() int64 {872 func (x *Sequence) GetLast() int64 {
@@ -783,7 +894,7 @@ type Mergeability struct {
783 894
784 func (x *Mergeability) Reset() {895 func (x *Mergeability) Reset() {
785 *x = Mergeability{}896 *x = Mergeability{}
786- mi := &file_ermite_storage_merge_request_proto_msgTypes[4]897+ mi := &file_ermite_storage_merge_request_proto_msgTypes[5]
787 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))898 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
788 ms.StoreMessageInfo(mi)899 ms.StoreMessageInfo(mi)
789 }900 }
@@ -795,7 +906,7 @@ func (x *Mergeability) String() string {
795 func (*Mergeability) ProtoMessage() {}906 func (*Mergeability) ProtoMessage() {}
796 907
797 func (x *Mergeability) ProtoReflect() protoreflect.Message {908 func (x *Mergeability) ProtoReflect() protoreflect.Message {
798- mi := &file_ermite_storage_merge_request_proto_msgTypes[4]909+ mi := &file_ermite_storage_merge_request_proto_msgTypes[5]
799 if x != nil {910 if x != nil {
800 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))911 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
801 if ms.LoadMessageInfo() == nil {912 if ms.LoadMessageInfo() == nil {
@@ -808,7 +919,7 @@ func (x *Mergeability) ProtoReflect() protoreflect.Message {
808 919
809 // Deprecated: Use Mergeability.ProtoReflect.Descriptor instead.920 // Deprecated: Use Mergeability.ProtoReflect.Descriptor instead.
810 func (*Mergeability) Descriptor() ([]byte, []int) {921 func (*Mergeability) Descriptor() ([]byte, []int) {
811- return file_ermite_storage_merge_request_proto_rawDescGZIP(), []int{4}922+ return file_ermite_storage_merge_request_proto_rawDescGZIP(), []int{5}
812 }923 }
813 924
814 func (x *Mergeability) GetState() MergeabilityState {925 func (x *Mergeability) GetState() MergeabilityState {
@@ -871,12 +982,13 @@ var File_ermite_storage_merge_request_proto protoreflect.FileDescriptor
871 982
872 const file_ermite_storage_merge_request_proto_rawDesc = "" +983 const file_ermite_storage_merge_request_proto_rawDesc = "" +
873 "\n" +984 "\n" +
874- "\"ermite/storage/merge_request.proto\x12\x0eermite.storage\x1a\x1fgoogle/protobuf/timestamp.proto\"\xe5\x01\n" +985+ "\"ermite/storage/merge_request.proto\x12\x0eermite.storage\x1a\x1fgoogle/protobuf/timestamp.proto\"\x8f\x02\n" +
875 "\fRepoSettings\x12I\n" +986 "\fRepoSettings\x12I\n" +
876 "\x13allowed_merge_modes\x18\x01 \x03(\x0e2\x19.ermite.storage.MergeModeR\x11allowedMergeModes\x120\n" +987 "\x13allowed_merge_modes\x18\x01 \x03(\x0e2\x19.ermite.storage.MergeModeR\x11allowedMergeModes\x120\n" +
877 "\x14failing_checks_block\x18\x02 \x01(\bR\x12failingChecksBlock\x122\n" +988 "\x14failing_checks_block\x18\x02 \x01(\bR\x12failingChecksBlock\x122\n" +
878 "\x06issues\x18\x03 \x01(\x0e2\x1a.ermite.storage.IssuesModeR\x06issues\x12$\n" +989 "\x06issues\x18\x03 \x01(\x0e2\x1a.ermite.storage.IssuesModeR\x06issues\x12$\n" +
879- "\x0emerge_modes_v2\x18\x04 \x01(\bR\fmergeModesV2\"\xa6\x04\n" +990+ "\x0emerge_modes_v2\x18\x04 \x01(\bR\fmergeModesV2\x12(\n" +
991+ "\x10allow_auto_merge\x18\x05 \x01(\bR\x0eallowAutoMerge\"\x81\x05\n" +
880 "\fMergeRequest\x12\x16\n" +992 "\fMergeRequest\x12\x16\n" +
881 "\x06number\x18\x01 \x01(\x03R\x06number\x12\x14\n" +993 "\x06number\x18\x01 \x01(\x03R\x06number\x12\x14\n" +
882 "\x05title\x18\x02 \x01(\tR\x05title\x12 \n" +994 "\x05title\x18\x02 \x01(\tR\x05title\x12 \n" +
@@ -896,7 +1008,19 @@ const file_ermite_storage_merge_request_proto_rawDesc = "" +
896 "\n" +1008 "\n" +
897 "merge_mode\x18\r \x01(\x0e2\x19.ermite.storage.MergeModeR\tmergeMode\x12\x1f\n" +1009 "merge_mode\x18\r \x01(\x0e2\x19.ermite.storage.MergeModeR\tmergeMode\x12\x1f\n" +
898 "\vcomment_seq\x18\x0e \x01(\x03R\n" +1010 "\vcomment_seq\x18\x0e \x01(\x03R\n" +
899- "commentSeq\"\xcf\x03\n" +1011+ "commentSeq\x128\n" +
1012+ "\n" +
1013+ "auto_merge\x18\x0f \x01(\v2\x19.ermite.storage.AutoMergeR\tautoMerge\x12\x1f\n" +
1014+ "\vauto_merged\x18\x10 \x01(\bR\n" +
1015+ "autoMerged\"\x80\x02\n" +
1016+ "\tAutoMerge\x12-\n" +
1017+ "\x04mode\x18\x01 \x01(\x0e2\x19.ermite.storage.MergeModeR\x04mode\x12\x17\n" +
1018+ "\ahead_id\x18\x02 \x01(\tR\x06headId\x12!\n" +
1019+ "\fenabler_kind\x18\x03 \x01(\tR\venablerKind\x12(\n" +
1020+ "\x10enabler_token_id\x18\x04 \x01(\tR\x0eenablerTokenId\x12#\n" +
1021+ "\renabler_label\x18\x05 \x01(\tR\fenablerLabel\x129\n" +
1022+ "\n" +
1023+ "enabled_at\x18\x06 \x01(\v2\x1a.google.protobuf.TimestampR\tenabledAt\"\xcf\x03\n" +
900 "\aComment\x12\x0e\n" +1024 "\aComment\x12\x0e\n" +
901 "\x02id\x18\x01 \x01(\x03R\x02id\x12\x16\n" +1025 "\x02id\x18\x01 \x01(\x03R\x02id\x12\x16\n" +
902 "\x06author\x18\x02 \x01(\tR\x06author\x12\x12\n" +1026 "\x06author\x18\x02 \x01(\tR\x06author\x12\x12\n" +
@@ -970,7 +1094,7 @@ func file_ermite_storage_merge_request_proto_rawDescGZIP() []byte {
970 }1094 }
971 1095
972 var file_ermite_storage_merge_request_proto_enumTypes = make([]protoimpl.EnumInfo, 6)1096 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)1097+var file_ermite_storage_merge_request_proto_msgTypes = make([]protoimpl.MessageInfo, 6)
974 var file_ermite_storage_merge_request_proto_goTypes = []any{1098 var file_ermite_storage_merge_request_proto_goTypes = []any{
975 (MergeMode)(0), // 0: ermite.storage.MergeMode1099 (MergeMode)(0), // 0: ermite.storage.MergeMode
976 (IssuesMode)(0), // 1: ermite.storage.IssuesMode1100 (IssuesMode)(0), // 1: ermite.storage.IssuesMode
@@ -980,29 +1104,33 @@ var file_ermite_storage_merge_request_proto_goTypes = []any{
980 (MergeabilityState)(0), // 5: ermite.storage.MergeabilityState1104 (MergeabilityState)(0), // 5: ermite.storage.MergeabilityState
981 (*RepoSettings)(nil), // 6: ermite.storage.RepoSettings1105 (*RepoSettings)(nil), // 6: ermite.storage.RepoSettings
982 (*MergeRequest)(nil), // 7: ermite.storage.MergeRequest1106 (*MergeRequest)(nil), // 7: ermite.storage.MergeRequest
983- (*Comment)(nil), // 8: ermite.storage.Comment1107+ (*AutoMerge)(nil), // 8: ermite.storage.AutoMerge
984- (*Sequence)(nil), // 9: ermite.storage.Sequence1108+ (*Comment)(nil), // 9: ermite.storage.Comment
985- (*Mergeability)(nil), // 10: ermite.storage.Mergeability1109+ (*Sequence)(nil), // 10: ermite.storage.Sequence
986- (*timestamppb.Timestamp)(nil), // 11: google.protobuf.Timestamp1110+ (*Mergeability)(nil), // 11: ermite.storage.Mergeability
1111+ (*timestamppb.Timestamp)(nil), // 12: google.protobuf.Timestamp
987 }1112 }
988 var file_ermite_storage_merge_request_proto_depIdxs = []int32{1113 var file_ermite_storage_merge_request_proto_depIdxs = []int32{
989 0, // 0: ermite.storage.RepoSettings.allowed_merge_modes:type_name -> ermite.storage.MergeMode1114 0, // 0: ermite.storage.RepoSettings.allowed_merge_modes:type_name -> ermite.storage.MergeMode
990 1, // 1: ermite.storage.RepoSettings.issues:type_name -> ermite.storage.IssuesMode1115 1, // 1: ermite.storage.RepoSettings.issues:type_name -> ermite.storage.IssuesMode
991 2, // 2: ermite.storage.MergeRequest.state:type_name -> ermite.storage.MergeRequestState1116 2, // 2: ermite.storage.MergeRequest.state:type_name -> ermite.storage.MergeRequestState
992- 11, // 3: ermite.storage.MergeRequest.created_at:type_name -> google.protobuf.Timestamp1117+ 12, // 3: ermite.storage.MergeRequest.created_at:type_name -> google.protobuf.Timestamp
993- 11, // 4: ermite.storage.MergeRequest.updated_at:type_name -> google.protobuf.Timestamp1118+ 12, // 4: ermite.storage.MergeRequest.updated_at:type_name -> google.protobuf.Timestamp
994 0, // 5: ermite.storage.MergeRequest.merge_mode:type_name -> ermite.storage.MergeMode1119 0, // 5: ermite.storage.MergeRequest.merge_mode:type_name -> ermite.storage.MergeMode
995- 3, // 6: ermite.storage.Comment.side:type_name -> ermite.storage.DiffSide1120+ 8, // 6: ermite.storage.MergeRequest.auto_merge:type_name -> ermite.storage.AutoMerge
996- 11, // 7: ermite.storage.Comment.created_at:type_name -> google.protobuf.Timestamp1121+ 0, // 7: ermite.storage.AutoMerge.mode:type_name -> ermite.storage.MergeMode
997- 4, // 8: ermite.storage.Comment.verdict:type_name -> ermite.storage.Verdict1122+ 12, // 8: ermite.storage.AutoMerge.enabled_at:type_name -> google.protobuf.Timestamp
998- 11, // 9: ermite.storage.Comment.edited_at:type_name -> google.protobuf.Timestamp1123+ 3, // 9: ermite.storage.Comment.side:type_name -> ermite.storage.DiffSide
999- 5, // 10: ermite.storage.Mergeability.state:type_name -> ermite.storage.MergeabilityState1124+ 12, // 10: ermite.storage.Comment.created_at:type_name -> google.protobuf.Timestamp
1000- 11, // 11: ermite.storage.Mergeability.checked_at:type_name -> google.protobuf.Timestamp1125+ 4, // 11: ermite.storage.Comment.verdict:type_name -> ermite.storage.Verdict
1001- 12, // [12:12] is the sub-list for method output_type1126+ 12, // 12: ermite.storage.Comment.edited_at:type_name -> google.protobuf.Timestamp
1002- 12, // [12:12] is the sub-list for method input_type1127+ 5, // 13: ermite.storage.Mergeability.state:type_name -> ermite.storage.MergeabilityState
1003- 12, // [12:12] is the sub-list for extension type_name1128+ 12, // 14: ermite.storage.Mergeability.checked_at:type_name -> google.protobuf.Timestamp
1004- 12, // [12:12] is the sub-list for extension extendee1129+ 15, // [15:15] is the sub-list for method output_type
1005- 0, // [0:12] is the sub-list for field type_name1130+ 15, // [15:15] is the sub-list for method input_type
1131+ 15, // [15:15] is the sub-list for extension type_name
1132+ 15, // [15:15] is the sub-list for extension extendee
1133+ 0, // [0:15] is the sub-list for field type_name
1006 }1134 }
1007 1135
1008 func init() { file_ermite_storage_merge_request_proto_init() }1136 func init() { file_ermite_storage_merge_request_proto_init() }
@@ -1016,7 +1144,7 @@ func file_ermite_storage_merge_request_proto_init() {
1016 GoPackagePath: reflect.TypeOf(x{}).PkgPath(),1144 GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
1017 RawDescriptor: unsafe.Slice(unsafe.StringData(file_ermite_storage_merge_request_proto_rawDesc), len(file_ermite_storage_merge_request_proto_rawDesc)),1145 RawDescriptor: unsafe.Slice(unsafe.StringData(file_ermite_storage_merge_request_proto_rawDesc), len(file_ermite_storage_merge_request_proto_rawDesc)),
1018 NumEnums: 6,1146 NumEnums: 6,
1019- NumMessages: 5,1147+ NumMessages: 6,
1020 NumExtensions: 0,1148 NumExtensions: 0,
1021 NumServices: 0,1149 NumServices: 0,
1022 },1150 },
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.13.0
16+ bnema.dev/ermite-api v0.14.0
1717 bnema.dev/ermite-git v0.1.0
1818 bnema.dev/ermite-kv v0.1.0
1919 connectrpc.com/connect v1.21.0
@@ -13,7 +13,7 @@ tool (
13 13
14 require (14 require (
15 bnema.dev/ermite-actions v0.2.015 bnema.dev/ermite-actions v0.2.0
16- bnema.dev/ermite-api v0.13.016+ bnema.dev/ermite-api v0.14.0
17 bnema.dev/ermite-git v0.1.017 bnema.dev/ermite-git v0.1.0
18 bnema.dev/ermite-kv v0.1.018 bnema.dev/ermite-kv v0.1.0
19 connectrpc.com/connect v1.21.019 connectrpc.com/connect v1.21.0
M go.sum +2 -2 generated file: collapsed
M internal/adapters/client/client_test.go +3 -3
@@ -139,16 +139,16 @@ 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.FeatureMergeability, 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, client.FeatureAutoMerge},
143143 client.MissingFeatures([]string{client.FeatureRepoUpdate, client.FeatureSession, client.FeaturePasskeys, "other"}))
144144 assert.Equal(t, []string{client.FeatureSigningKey}, client.MissingFeatures([]string{
145145 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,
147- client.FeatureCIArtifacts, client.FeaturePasskeys, client.FeatureCommitSignatures,
147+ client.FeatureCIArtifacts, client.FeaturePasskeys, client.FeatureCommitSignatures, client.FeatureAutoMerge,
148148 }))
149149 assert.Equal(t, []string{client.FeaturePasskeys}, client.MissingFeatures([]string{
150150 client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureIssues, client.FeatureMergeability, client.FeatureSigningKey, client.FeatureSession,
151- client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, client.FeatureCIWorkflows, client.FeatureCIArtifacts, client.FeatureCommitSignatures,
151+ client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, client.FeatureCIWorkflows, client.FeatureCIArtifacts, client.FeatureCommitSignatures, client.FeatureAutoMerge,
152152 }))
153153 assert.Equal(t, client.UsedFeatures(), client.MissingFeatures(nil))
154154 }
@@ -139,16 +139,16 @@ func TestDescribeBoundsLongMessages(t *testing.T) {
139 func TestMissingFeatures(t *testing.T) {139 func TestMissingFeatures(t *testing.T) {
140 assert.Empty(t, client.MissingFeatures(client.UsedFeatures()))140 assert.Empty(t, client.MissingFeatures(client.UsedFeatures()))
141 assert.Equal(t,141 assert.Equal(t,
142- []string{client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureIssues, client.FeatureMergeability, 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, client.FeatureAutoMerge},
143 client.MissingFeatures([]string{client.FeatureRepoUpdate, client.FeatureSession, client.FeaturePasskeys, "other"}))143 client.MissingFeatures([]string{client.FeatureRepoUpdate, client.FeatureSession, client.FeaturePasskeys, "other"}))
144 assert.Equal(t, []string{client.FeatureSigningKey}, client.MissingFeatures([]string{144 assert.Equal(t, []string{client.FeatureSigningKey}, client.MissingFeatures([]string{
145 client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureIssues, client.FeatureMergeability, client.FeatureSession,145 client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureIssues, client.FeatureMergeability, client.FeatureSession,
146 client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, client.FeatureCIWorkflows,146 client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, client.FeatureCIWorkflows,
147- client.FeatureCIArtifacts, client.FeaturePasskeys, client.FeatureCommitSignatures,147+ client.FeatureCIArtifacts, client.FeaturePasskeys, client.FeatureCommitSignatures, client.FeatureAutoMerge,
148 }))148 }))
149 assert.Equal(t, []string{client.FeaturePasskeys}, client.MissingFeatures([]string{149 assert.Equal(t, []string{client.FeaturePasskeys}, client.MissingFeatures([]string{
150 client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureIssues, client.FeatureMergeability, client.FeatureSigningKey, client.FeatureSession,150 client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureIssues, client.FeatureMergeability, client.FeatureSigningKey, client.FeatureSession,
151- client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, client.FeatureCIWorkflows, client.FeatureCIArtifacts, client.FeatureCommitSignatures,151+ client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, client.FeatureCIWorkflows, client.FeatureCIArtifacts, client.FeatureCommitSignatures, client.FeatureAutoMerge,
152 }))152 }))
153 assert.Equal(t, client.UsedFeatures(), client.MissingFeatures(nil))153 assert.Equal(t, client.UsedFeatures(), client.MissingFeatures(nil))
154 }154 }
M internal/adapters/client/features.go +5 -1
@@ -57,11 +57,15 @@ const (
5757 // FeatureMergeability is MergeRequest.mergeability and the
5858 // merge_request_mergeability event.
5959 FeatureMergeability = "merge_requests.mergeability"
60+ // FeatureAutoMerge is MergeRequestService.EnableAutoMerge and
61+ // CancelAutoMerge, RepoSettings.allow_auto_merge and the auto-merge events
62+ // (`ermite mr merge --auto`).
63+ FeatureAutoMerge = "merge_requests.auto_merge"
6064 )
6165
6266 // UsedFeatures lists the features the CLI uses, in the order they are reported.
6367 func UsedFeatures() []string {
64- return []string{FeatureRepoUpdate, FeatureEventsResume, FeatureMergeRequests, FeatureMergeDeleteBranch, FeatureIssues, FeatureMergeability, FeatureSigningKey, FeatureSession, FeatureTokenBranchPrefixes, FeatureCIRuns, FeatureCIRunners, FeatureCISecrets, FeatureCIDispatch, FeatureCIWorkflows, FeatureCIArtifacts, FeaturePasskeys, FeatureCommitSignatures}
68+ return []string{FeatureRepoUpdate, FeatureEventsResume, FeatureMergeRequests, FeatureMergeDeleteBranch, FeatureIssues, FeatureMergeability, FeatureSigningKey, FeatureSession, FeatureTokenBranchPrefixes, FeatureCIRuns, FeatureCIRunners, FeatureCISecrets, FeatureCIDispatch, FeatureCIWorkflows, FeatureCIArtifacts, FeaturePasskeys, FeatureCommitSignatures, FeatureAutoMerge}
6569 }
6670
6771 // MissingFeatures returns the features the CLI uses that have is lacking.
@@ -57,11 +57,15 @@ const (
57 // FeatureMergeability is MergeRequest.mergeability and the57 // FeatureMergeability is MergeRequest.mergeability and the
58 // merge_request_mergeability event.58 // merge_request_mergeability event.
59 FeatureMergeability = "merge_requests.mergeability"59 FeatureMergeability = "merge_requests.mergeability"
60+ // FeatureAutoMerge is MergeRequestService.EnableAutoMerge and
61+ // CancelAutoMerge, RepoSettings.allow_auto_merge and the auto-merge events
62+ // (`ermite mr merge --auto`).
63+ FeatureAutoMerge = "merge_requests.auto_merge"
60 )64 )
61 65
62 // UsedFeatures lists the features the CLI uses, in the order they are reported.66 // UsedFeatures lists the features the CLI uses, in the order they are reported.
63 func UsedFeatures() []string {67 func UsedFeatures() []string {
64- return []string{FeatureRepoUpdate, FeatureEventsResume, FeatureMergeRequests, FeatureMergeDeleteBranch, FeatureIssues, FeatureMergeability, FeatureSigningKey, FeatureSession, FeatureTokenBranchPrefixes, FeatureCIRuns, FeatureCIRunners, FeatureCISecrets, FeatureCIDispatch, FeatureCIWorkflows, FeatureCIArtifacts, FeaturePasskeys, FeatureCommitSignatures}68+ return []string{FeatureRepoUpdate, FeatureEventsResume, FeatureMergeRequests, FeatureMergeDeleteBranch, FeatureIssues, FeatureMergeability, FeatureSigningKey, FeatureSession, FeatureTokenBranchPrefixes, FeatureCIRuns, FeatureCIRunners, FeatureCISecrets, FeatureCIDispatch, FeatureCIWorkflows, FeatureCIArtifacts, FeaturePasskeys, FeatureCommitSignatures, FeatureAutoMerge}
65 }69 }
66 70
67 // MissingFeatures returns the features the CLI uses that have is lacking.71 // MissingFeatures returns the features the CLI uses that have is lacking.
M internal/adapters/kvstore/mergerequests.go +32 -2
@@ -728,6 +728,18 @@ func encodeMergeRequest(mr domain.MergeRequest) ([]byte, error) {
728728 if err != nil {
729729 return nil, err
730730 }
731+ var autoMerge *storagepb.AutoMerge
732+ if am := mr.AutoMerge; am != nil {
733+ amMode, err := encodeMode(am.Mode)
734+ if err != nil || amMode == storagepb.MergeMode_MERGE_MODE_UNSPECIFIED {
735+ return nil, fmt.Errorf("%w: merge mode %q in the auto-merge", domain.ErrInvalid, am.Mode)
736+ }
737+ autoMerge = &storagepb.AutoMerge{
738+ Mode: amMode, HeadId: am.HeadID.String(),
739+ EnablerKind: string(am.EnabledBy.Kind), EnablerTokenId: am.EnabledBy.TokenID,
740+ EnablerLabel: am.EnablerLabel, EnabledAt: encodeTime(am.EnabledAt),
741+ }
742+ }
731743 return mustMarshal(&storagepb.MergeRequest{
732744 Number: mr.Number,
733745 Title: mr.Title.String(),
@@ -743,6 +755,8 @@ func encodeMergeRequest(mr domain.MergeRequest) ([]byte, error) {
743755 MergedCommitId: mr.MergedCommitID.String(),
744756 MergeMode: mode,
745757 CommentSeq: mr.CommentSeq,
758+ AutoMerge: autoMerge,
759+ AutoMerged: mr.AutoMerged,
746760 }), nil
747761 }
748762
@@ -773,8 +787,24 @@ func decodeMergeRequest(repo domain.RepoName, item kv.Item) (domain.MergeRequest
773787 CreatedAt: decodeTime(pb.GetCreatedAt()), UpdatedAt: decodeTime(pb.GetUpdatedAt()),
774788 MergeMode: decodeMode(pb.GetMergeMode()),
775789 CommentSeq: pb.GetCommentSeq(),
790+ AutoMerged: pb.GetAutoMerged(),
776791 Version: item.Version,
777792 }
793+ if am := pb.GetAutoMerge(); am != nil {
794+ head, err := domain.ParseObjectID(am.GetHeadId())
795+ if err != nil {
796+ return fail(err)
797+ }
798+ mode := decodeMode(am.GetMode())
799+ if mode == "" {
800+ return fail(fmt.Errorf("unknown auto-merge mode %d", am.GetMode()))
801+ }
802+ mr.AutoMerge = &domain.AutoMerge{
803+ Mode: mode, HeadID: head,
804+ EnabledBy: domain.CIPusher{Kind: domain.CIPusherKind(am.GetEnablerKind()), TokenID: am.GetEnablerTokenId()},
805+ EnablerLabel: am.GetEnablerLabel(), EnabledAt: decodeTime(am.GetEnabledAt()),
806+ }
807+ }
778808 for _, f := range []struct {
779809 dst *domain.ObjectID
780810 src string
@@ -909,7 +939,7 @@ func decodeSide(s storagepb.DiffSide) domain.DiffSide {
909939 }
910940
911941 func encodeSettings(s domain.RepoSettings) (*storagepb.RepoSettings, error) {
912- pb := &storagepb.RepoSettings{FailingChecksBlock: s.FailingChecksBlock, MergeModesV2: true}
942+ pb := &storagepb.RepoSettings{FailingChecksBlock: s.FailingChecksBlock, MergeModesV2: true, AllowAutoMerge: s.AllowAutoMerge}
913943 for _, m := range s.AllowedMergeModes {
914944 mode, err := encodeMode(m)
915945 if err != nil || mode == storagepb.MergeMode_MERGE_MODE_UNSPECIFIED {
@@ -949,5 +979,5 @@ func decodeSettings(pb *storagepb.RepoSettings) domain.RepoSettings {
949979 if !pb.GetMergeModesV2() && slices.Equal(normalized, []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash}) {
950980 normalized = domain.MergeModes()
951981 }
952- return domain.RepoSettings{AllowedMergeModes: normalized, FailingChecksBlock: pb.GetFailingChecksBlock(), Issues: decodeIssuesMode(pb.GetIssues())}
982+ return domain.RepoSettings{AllowedMergeModes: normalized, FailingChecksBlock: pb.GetFailingChecksBlock(), Issues: decodeIssuesMode(pb.GetIssues()), AllowAutoMerge: pb.GetAllowAutoMerge()}
953983 }
@@ -728,6 +728,18 @@ func encodeMergeRequest(mr domain.MergeRequest) ([]byte, error) {
728 if err != nil {728 if err != nil {
729 return nil, err729 return nil, err
730 }730 }
731+ var autoMerge *storagepb.AutoMerge
732+ if am := mr.AutoMerge; am != nil {
733+ amMode, err := encodeMode(am.Mode)
734+ if err != nil || amMode == storagepb.MergeMode_MERGE_MODE_UNSPECIFIED {
735+ return nil, fmt.Errorf("%w: merge mode %q in the auto-merge", domain.ErrInvalid, am.Mode)
736+ }
737+ autoMerge = &storagepb.AutoMerge{
738+ Mode: amMode, HeadId: am.HeadID.String(),
739+ EnablerKind: string(am.EnabledBy.Kind), EnablerTokenId: am.EnabledBy.TokenID,
740+ EnablerLabel: am.EnablerLabel, EnabledAt: encodeTime(am.EnabledAt),
741+ }
742+ }
731 return mustMarshal(&storagepb.MergeRequest{743 return mustMarshal(&storagepb.MergeRequest{
732 Number: mr.Number,744 Number: mr.Number,
733 Title: mr.Title.String(),745 Title: mr.Title.String(),
@@ -743,6 +755,8 @@ func encodeMergeRequest(mr domain.MergeRequest) ([]byte, error) {
743 MergedCommitId: mr.MergedCommitID.String(),755 MergedCommitId: mr.MergedCommitID.String(),
744 MergeMode: mode,756 MergeMode: mode,
745 CommentSeq: mr.CommentSeq,757 CommentSeq: mr.CommentSeq,
758+ AutoMerge: autoMerge,
759+ AutoMerged: mr.AutoMerged,
746 }), nil760 }), nil
747 }761 }
748 762
@@ -773,8 +787,24 @@ func decodeMergeRequest(repo domain.RepoName, item kv.Item) (domain.MergeRequest
773 CreatedAt: decodeTime(pb.GetCreatedAt()), UpdatedAt: decodeTime(pb.GetUpdatedAt()),787 CreatedAt: decodeTime(pb.GetCreatedAt()), UpdatedAt: decodeTime(pb.GetUpdatedAt()),
774 MergeMode: decodeMode(pb.GetMergeMode()),788 MergeMode: decodeMode(pb.GetMergeMode()),
775 CommentSeq: pb.GetCommentSeq(),789 CommentSeq: pb.GetCommentSeq(),
790+ AutoMerged: pb.GetAutoMerged(),
776 Version: item.Version,791 Version: item.Version,
777 }792 }
793+ if am := pb.GetAutoMerge(); am != nil {
794+ head, err := domain.ParseObjectID(am.GetHeadId())
795+ if err != nil {
796+ return fail(err)
797+ }
798+ mode := decodeMode(am.GetMode())
799+ if mode == "" {
800+ return fail(fmt.Errorf("unknown auto-merge mode %d", am.GetMode()))
801+ }
802+ mr.AutoMerge = &domain.AutoMerge{
803+ Mode: mode, HeadID: head,
804+ EnabledBy: domain.CIPusher{Kind: domain.CIPusherKind(am.GetEnablerKind()), TokenID: am.GetEnablerTokenId()},
805+ EnablerLabel: am.GetEnablerLabel(), EnabledAt: decodeTime(am.GetEnabledAt()),
806+ }
807+ }
778 for _, f := range []struct {808 for _, f := range []struct {
779 dst *domain.ObjectID809 dst *domain.ObjectID
780 src string810 src string
@@ -909,7 +939,7 @@ func decodeSide(s storagepb.DiffSide) domain.DiffSide {
909 }939 }
910 940
911 func encodeSettings(s domain.RepoSettings) (*storagepb.RepoSettings, error) {941 func encodeSettings(s domain.RepoSettings) (*storagepb.RepoSettings, error) {
912- pb := &storagepb.RepoSettings{FailingChecksBlock: s.FailingChecksBlock, MergeModesV2: true}942+ pb := &storagepb.RepoSettings{FailingChecksBlock: s.FailingChecksBlock, MergeModesV2: true, AllowAutoMerge: s.AllowAutoMerge}
913 for _, m := range s.AllowedMergeModes {943 for _, m := range s.AllowedMergeModes {
914 mode, err := encodeMode(m)944 mode, err := encodeMode(m)
915 if err != nil || mode == storagepb.MergeMode_MERGE_MODE_UNSPECIFIED {945 if err != nil || mode == storagepb.MergeMode_MERGE_MODE_UNSPECIFIED {
@@ -949,5 +979,5 @@ func decodeSettings(pb *storagepb.RepoSettings) domain.RepoSettings {
949 if !pb.GetMergeModesV2() && slices.Equal(normalized, []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash}) {979 if !pb.GetMergeModesV2() && slices.Equal(normalized, []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash}) {
950 normalized = domain.MergeModes()980 normalized = domain.MergeModes()
951 }981 }
952- return domain.RepoSettings{AllowedMergeModes: normalized, FailingChecksBlock: pb.GetFailingChecksBlock(), Issues: decodeIssuesMode(pb.GetIssues())}982+ return domain.RepoSettings{AllowedMergeModes: normalized, FailingChecksBlock: pb.GetFailingChecksBlock(), Issues: decodeIssuesMode(pb.GetIssues()), AllowAutoMerge: pb.GetAllowAutoMerge()}
953 }983 }
M internal/adapters/kvstore/mergerequests_test.go +47 -0
@@ -94,6 +94,35 @@ func TestMergeRequestRoundTrip(t *testing.T) {
9494 assert.ErrorIs(t, err, domain.ErrNotFound)
9595 }
9696
97+func TestMergeRequestAutoMergeRoundTrip(t *testing.T) {
98+ ctx := context.Background()
99+ _, mrs, n := repoWithMRs(t)
100+
101+ set := newMR(t, n, "Auto")
102+ set.AutoMerge = &domain.AutoMerge{
103+ Mode: domain.MergeModeSquash, HeadID: oid('d'),
104+ EnabledBy: domain.CIPusher{Kind: domain.CIPusherToken, TokenID: "tok1"},
105+ EnablerLabel: "ci-bot", EnabledAt: created,
106+ }
107+ created1, err := mrs.Create(ctx, set)
108+ require.NoError(t, err)
109+ got, err := mrs.Get(ctx, n, created1.Number)
110+ require.NoError(t, err)
111+ require.NotNil(t, got.AutoMerge)
112+ assert.Equal(t, *set.AutoMerge, *got.AutoMerge)
113+ assert.False(t, got.AutoMerged)
114+
115+ unset := newMR(t, n, "Merged by auto")
116+ unset.State = domain.MRStateMerged
117+ unset.AutoMerged = true
118+ created2, err := mrs.Create(ctx, unset)
119+ require.NoError(t, err)
120+ got, err = mrs.Get(ctx, n, created2.Number)
121+ require.NoError(t, err)
122+ assert.Nil(t, got.AutoMerge)
123+ assert.True(t, got.AutoMerged)
124+}
125+
97126 func TestMergeRequestListIsNewestFirstAndPerRepository(t *testing.T) {
98127 ctx := context.Background()
99128 s, mrs, n := repoWithMRs(t)
@@ -453,6 +482,24 @@ func TestRepoSettings(t *testing.T) {
453482 assert.ErrorIs(t, err, domain.ErrInvalid)
454483 }
455484
485+func TestRepoSettingsKeepAllowAutoMerge(t *testing.T) {
486+ ctx := context.Background()
487+ s := openStore(t, t.TempDir())
488+ n := name(t, "proj")
489+ require.NoError(t, s.Create(ctx, domain.Repo{Name: n, CreatedAt: created, Visibility: domain.VisibilityPrivate}))
490+ got, err := s.Get(ctx, n)
491+ require.NoError(t, err)
492+ assert.False(t, got.Settings.AllowAutoMerge)
493+
494+ settings := domain.DefaultRepoSettings()
495+ settings.AllowAutoMerge = true
496+ _, err = s.Mutate(ctx, n, func(r *domain.Repo) (bool, error) { r.Settings = settings; return true, nil })
497+ require.NoError(t, err)
498+ got, err = s.Get(ctx, n)
499+ require.NoError(t, err)
500+ assert.Equal(t, settings, got.Settings)
501+}
502+
456503 // TestRepoWithoutStoredSettingsReadsTheDefaults: a repository stored before
457504 // merge requests existed has no settings field.
458505 func TestRepoWithoutStoredSettingsReadsTheDefaults(t *testing.T) {
@@ -94,6 +94,35 @@ func TestMergeRequestRoundTrip(t *testing.T) {
94 assert.ErrorIs(t, err, domain.ErrNotFound)94 assert.ErrorIs(t, err, domain.ErrNotFound)
95 }95 }
96 96
97+func TestMergeRequestAutoMergeRoundTrip(t *testing.T) {
98+ ctx := context.Background()
99+ _, mrs, n := repoWithMRs(t)
100+
101+ set := newMR(t, n, "Auto")
102+ set.AutoMerge = &domain.AutoMerge{
103+ Mode: domain.MergeModeSquash, HeadID: oid('d'),
104+ EnabledBy: domain.CIPusher{Kind: domain.CIPusherToken, TokenID: "tok1"},
105+ EnablerLabel: "ci-bot", EnabledAt: created,
106+ }
107+ created1, err := mrs.Create(ctx, set)
108+ require.NoError(t, err)
109+ got, err := mrs.Get(ctx, n, created1.Number)
110+ require.NoError(t, err)
111+ require.NotNil(t, got.AutoMerge)
112+ assert.Equal(t, *set.AutoMerge, *got.AutoMerge)
113+ assert.False(t, got.AutoMerged)
114+
115+ unset := newMR(t, n, "Merged by auto")
116+ unset.State = domain.MRStateMerged
117+ unset.AutoMerged = true
118+ created2, err := mrs.Create(ctx, unset)
119+ require.NoError(t, err)
120+ got, err = mrs.Get(ctx, n, created2.Number)
121+ require.NoError(t, err)
122+ assert.Nil(t, got.AutoMerge)
123+ assert.True(t, got.AutoMerged)
124+}
125+
97 func TestMergeRequestListIsNewestFirstAndPerRepository(t *testing.T) {126 func TestMergeRequestListIsNewestFirstAndPerRepository(t *testing.T) {
98 ctx := context.Background()127 ctx := context.Background()
99 s, mrs, n := repoWithMRs(t)128 s, mrs, n := repoWithMRs(t)
@@ -453,6 +482,24 @@ func TestRepoSettings(t *testing.T) {
453 assert.ErrorIs(t, err, domain.ErrInvalid)482 assert.ErrorIs(t, err, domain.ErrInvalid)
454 }483 }
455 484
485+func TestRepoSettingsKeepAllowAutoMerge(t *testing.T) {
486+ ctx := context.Background()
487+ s := openStore(t, t.TempDir())
488+ n := name(t, "proj")
489+ require.NoError(t, s.Create(ctx, domain.Repo{Name: n, CreatedAt: created, Visibility: domain.VisibilityPrivate}))
490+ got, err := s.Get(ctx, n)
491+ require.NoError(t, err)
492+ assert.False(t, got.Settings.AllowAutoMerge)
493+
494+ settings := domain.DefaultRepoSettings()
495+ settings.AllowAutoMerge = true
496+ _, err = s.Mutate(ctx, n, func(r *domain.Repo) (bool, error) { r.Settings = settings; return true, nil })
497+ require.NoError(t, err)
498+ got, err = s.Get(ctx, n)
499+ require.NoError(t, err)
500+ assert.Equal(t, settings, got.Settings)
501+}
502+
456 // TestRepoWithoutStoredSettingsReadsTheDefaults: a repository stored before503 // TestRepoWithoutStoredSettingsReadsTheDefaults: a repository stored before
457 // merge requests existed has no settings field.504 // merge requests existed has no settings field.
458 func TestRepoWithoutStoredSettingsReadsTheDefaults(t *testing.T) {505 func TestRepoWithoutStoredSettingsReadsTheDefaults(t *testing.T) {
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", "issues.links", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.cache", "ci.workflows"}
38+var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "issues.links", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.cache", "ci.workflows", "merge_requests.auto_merge"}
3939
4040 // FeaturePasskeys is listed by GetVersion when passkeys are available.
4141 // FeatureCIArtifacts (artifact.go) when the artifact services are served.
@@ -35,7 +35,7 @@ const RunnerMaxRequestBytes = MaxRequestBytes + 64<<10
35 // always implements, as GetVersion lists them. FeaturePasskeys comes on top35 // always implements, as GetVersion lists them. FeaturePasskeys comes on top
36 // when Config.Passkeys is set, and FeatureCISecrets when the settings services36 // when Config.Passkeys is set, and FeatureCISecrets when the settings services
37 // are, and FeatureCommitSignatures when Config.TrustedKeys is set.37 // are, and FeatureCommitSignatures when Config.TrustedKeys is set.
38-var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "issues.links", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.cache", "ci.workflows"}38+var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "issues.links", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.cache", "ci.workflows", "merge_requests.auto_merge"}
39 39
40 // FeaturePasskeys is listed by GetVersion when passkeys are available.40 // FeaturePasskeys is listed by GetVersion when passkeys are available.
41 // FeatureCIArtifacts (artifact.go) when the artifact services are served.41 // FeatureCIArtifacts (artifact.go) when the artifact services are served.
M internal/adapters/rpc/convert.go +8 -2
@@ -72,7 +72,7 @@ func repoToPB(v ports.RepoView) *pb.Repo {
7272 }
7373
7474 func settingsToPB(s domain.RepoSettings) *pb.RepoSettings {
75- out := &pb.RepoSettings{FailingChecksBlock: s.FailingChecksBlock, Issues: issuesModeToPB(s.Issues)}
75+ out := &pb.RepoSettings{FailingChecksBlock: s.FailingChecksBlock, AllowAutoMerge: s.AllowAutoMerge, Issues: issuesModeToPB(s.Issues)}
7676 for _, m := range s.AllowedMergeModes {
7777 out.AllowedMergeModes = append(out.AllowedMergeModes, mergeModeToPB(m))
7878 }
@@ -483,7 +483,13 @@ func watchEventToPB(we ports.WatchEvent) *pb.Event {
483483 out.Kind = &pb.Event_MergeRequestOpened{MergeRequestOpened: &pb.MergeRequestOpened{MergeRequest: mergeRequestToPB(ev.MergeRequest)}}
484484 case domain.MergeRequestMerged:
485485 out.Time = timestamp(ev.At)
486- out.Kind = &pb.Event_MergeRequestMerged{MergeRequestMerged: &pb.MergeRequestMerged{MergeRequest: mergeRequestToPB(ev.MergeRequest)}}
486+ out.Kind = &pb.Event_MergeRequestMerged{MergeRequestMerged: &pb.MergeRequestMerged{MergeRequest: mergeRequestToPB(ev.MergeRequest), Auto: ev.Auto}}
487+ case domain.MergeRequestAutoMergeEnabled:
488+ out.Time = timestamp(ev.At)
489+ out.Kind = &pb.Event_MergeRequestAutoMergeEnabled{MergeRequestAutoMergeEnabled: &pb.MergeRequestAutoMergeEnabled{MergeRequest: mergeRequestToPB(ev.MergeRequest)}}
490+ case domain.MergeRequestAutoMergeCancelled:
491+ out.Time = timestamp(ev.At)
492+ out.Kind = &pb.Event_MergeRequestAutoMergeCancelled{MergeRequestAutoMergeCancelled: &pb.MergeRequestAutoMergeCancelled{MergeRequest: mergeRequestToPB(ev.MergeRequest), Reason: ev.Reason}}
487493 case domain.MergeRequestClosed:
488494 out.Time = timestamp(ev.At)
489495 out.Kind = &pb.Event_MergeRequestClosed{MergeRequestClosed: &pb.MergeRequestClosed{MergeRequest: mergeRequestToPB(ev.MergeRequest)}}
@@ -72,7 +72,7 @@ func repoToPB(v ports.RepoView) *pb.Repo {
72 }72 }
73 73
74 func settingsToPB(s domain.RepoSettings) *pb.RepoSettings {74 func settingsToPB(s domain.RepoSettings) *pb.RepoSettings {
75- out := &pb.RepoSettings{FailingChecksBlock: s.FailingChecksBlock, Issues: issuesModeToPB(s.Issues)}75+ out := &pb.RepoSettings{FailingChecksBlock: s.FailingChecksBlock, AllowAutoMerge: s.AllowAutoMerge, Issues: issuesModeToPB(s.Issues)}
76 for _, m := range s.AllowedMergeModes {76 for _, m := range s.AllowedMergeModes {
77 out.AllowedMergeModes = append(out.AllowedMergeModes, mergeModeToPB(m))77 out.AllowedMergeModes = append(out.AllowedMergeModes, mergeModeToPB(m))
78 }78 }
@@ -483,7 +483,13 @@ func watchEventToPB(we ports.WatchEvent) *pb.Event {
483 out.Kind = &pb.Event_MergeRequestOpened{MergeRequestOpened: &pb.MergeRequestOpened{MergeRequest: mergeRequestToPB(ev.MergeRequest)}}483 out.Kind = &pb.Event_MergeRequestOpened{MergeRequestOpened: &pb.MergeRequestOpened{MergeRequest: mergeRequestToPB(ev.MergeRequest)}}
484 case domain.MergeRequestMerged:484 case domain.MergeRequestMerged:
485 out.Time = timestamp(ev.At)485 out.Time = timestamp(ev.At)
486- out.Kind = &pb.Event_MergeRequestMerged{MergeRequestMerged: &pb.MergeRequestMerged{MergeRequest: mergeRequestToPB(ev.MergeRequest)}}486+ out.Kind = &pb.Event_MergeRequestMerged{MergeRequestMerged: &pb.MergeRequestMerged{MergeRequest: mergeRequestToPB(ev.MergeRequest), Auto: ev.Auto}}
487+ case domain.MergeRequestAutoMergeEnabled:
488+ out.Time = timestamp(ev.At)
489+ out.Kind = &pb.Event_MergeRequestAutoMergeEnabled{MergeRequestAutoMergeEnabled: &pb.MergeRequestAutoMergeEnabled{MergeRequest: mergeRequestToPB(ev.MergeRequest)}}
490+ case domain.MergeRequestAutoMergeCancelled:
491+ out.Time = timestamp(ev.At)
492+ out.Kind = &pb.Event_MergeRequestAutoMergeCancelled{MergeRequestAutoMergeCancelled: &pb.MergeRequestAutoMergeCancelled{MergeRequest: mergeRequestToPB(ev.MergeRequest), Reason: ev.Reason}}
487 case domain.MergeRequestClosed:493 case domain.MergeRequestClosed:
488 out.Time = timestamp(ev.At)494 out.Time = timestamp(ev.At)
489 out.Kind = &pb.Event_MergeRequestClosed{MergeRequestClosed: &pb.MergeRequestClosed{MergeRequest: mergeRequestToPB(ev.MergeRequest)}}495 out.Kind = &pb.Event_MergeRequestClosed{MergeRequestClosed: &pb.MergeRequestClosed{MergeRequest: mergeRequestToPB(ev.MergeRequest)}}
M internal/adapters/rpc/mergerequest.go +39 -0
@@ -19,6 +19,38 @@ type mergeRequestService struct {
1919 mrs ports.MergeRequestService
2020 }
2121
22+func (s *mergeRequestService) EnableAutoMerge(ctx context.Context, req *connect.Request[pb.EnableAutoMergeRequest]) (*connect.Response[pb.EnableAutoMergeResponse], error) {
23+ name, err := domain.ParseRepoName(req.Msg.GetRepo())
24+ if err != nil {
25+ return nil, connectError(ctx, err)
26+ }
27+ mode, err := mergeModeFromPB(req.Msg.GetMode())
28+ if err != nil {
29+ return nil, connectError(ctx, err)
30+ }
31+ head, err := headID(req.Msg.GetHeadId())
32+ if err != nil {
33+ return nil, connectError(ctx, err)
34+ }
35+ mr, err := s.mrs.EnableAutoMerge(ctx, caller(ctx), name, req.Msg.GetNumber(), ports.EnableAutoMergeParams{Mode: mode, HeadID: head})
36+ if err != nil {
37+ return nil, connectError(ctx, err)
38+ }
39+ return connect.NewResponse(&pb.EnableAutoMergeResponse{MergeRequest: mergeRequestToPB(mr)}), nil
40+}
41+
42+func (s *mergeRequestService) CancelAutoMerge(ctx context.Context, req *connect.Request[pb.CancelAutoMergeRequest]) (*connect.Response[pb.CancelAutoMergeResponse], error) {
43+ name, err := domain.ParseRepoName(req.Msg.GetRepo())
44+ if err != nil {
45+ return nil, connectError(ctx, err)
46+ }
47+ mr, err := s.mrs.CancelAutoMerge(ctx, caller(ctx), name, req.Msg.GetNumber())
48+ if err != nil {
49+ return nil, connectError(ctx, err)
50+ }
51+ return connect.NewResponse(&pb.CancelAutoMergeResponse{MergeRequest: mergeRequestToPB(mr)}), nil
52+}
53+
2254 func (s *mergeRequestService) EditComment(ctx context.Context, req *connect.Request[pb.EditCommentRequest]) (*connect.Response[pb.EditCommentResponse], error) {
2355 name, err := domain.ParseRepoName(req.Msg.GetRepo())
2456 if err != nil {
@@ -133,6 +165,13 @@ func mergeRequestToPB(mr domain.MergeRequest) *pb.MergeRequest {
133165 HeadId: mr.HeadID.String(),
134166 MergedCommitId: mr.MergedCommitID.String(),
135167 MergeMode: mergeModeToPB(mr.MergeMode),
168+ AutoMerged: mr.AutoMerged,
169+ }
170+ if am := mr.AutoMerge; am != nil {
171+ out.AutoMerge = &pb.AutoMerge{
172+ Mode: mergeModeToPB(am.Mode), HeadId: am.HeadID.String(),
173+ EnabledBy: am.EnablerLabel, EnabledAt: timestamp(am.EnabledAt),
174+ }
136175 }
137176 if mr.IsOpen() {
138177 out.Mergeability = &pb.Mergeability{State: pb.MergeabilityState_MERGEABILITY_STATE_CHECKING}
@@ -19,6 +19,38 @@ type mergeRequestService struct {
19 mrs ports.MergeRequestService19 mrs ports.MergeRequestService
20 }20 }
21 21
22+func (s *mergeRequestService) EnableAutoMerge(ctx context.Context, req *connect.Request[pb.EnableAutoMergeRequest]) (*connect.Response[pb.EnableAutoMergeResponse], error) {
23+ name, err := domain.ParseRepoName(req.Msg.GetRepo())
24+ if err != nil {
25+ return nil, connectError(ctx, err)
26+ }
27+ mode, err := mergeModeFromPB(req.Msg.GetMode())
28+ if err != nil {
29+ return nil, connectError(ctx, err)
30+ }
31+ head, err := headID(req.Msg.GetHeadId())
32+ if err != nil {
33+ return nil, connectError(ctx, err)
34+ }
35+ mr, err := s.mrs.EnableAutoMerge(ctx, caller(ctx), name, req.Msg.GetNumber(), ports.EnableAutoMergeParams{Mode: mode, HeadID: head})
36+ if err != nil {
37+ return nil, connectError(ctx, err)
38+ }
39+ return connect.NewResponse(&pb.EnableAutoMergeResponse{MergeRequest: mergeRequestToPB(mr)}), nil
40+}
41+
42+func (s *mergeRequestService) CancelAutoMerge(ctx context.Context, req *connect.Request[pb.CancelAutoMergeRequest]) (*connect.Response[pb.CancelAutoMergeResponse], error) {
43+ name, err := domain.ParseRepoName(req.Msg.GetRepo())
44+ if err != nil {
45+ return nil, connectError(ctx, err)
46+ }
47+ mr, err := s.mrs.CancelAutoMerge(ctx, caller(ctx), name, req.Msg.GetNumber())
48+ if err != nil {
49+ return nil, connectError(ctx, err)
50+ }
51+ return connect.NewResponse(&pb.CancelAutoMergeResponse{MergeRequest: mergeRequestToPB(mr)}), nil
52+}
53+
22 func (s *mergeRequestService) EditComment(ctx context.Context, req *connect.Request[pb.EditCommentRequest]) (*connect.Response[pb.EditCommentResponse], error) {54 func (s *mergeRequestService) EditComment(ctx context.Context, req *connect.Request[pb.EditCommentRequest]) (*connect.Response[pb.EditCommentResponse], error) {
23 name, err := domain.ParseRepoName(req.Msg.GetRepo())55 name, err := domain.ParseRepoName(req.Msg.GetRepo())
24 if err != nil {56 if err != nil {
@@ -133,6 +165,13 @@ func mergeRequestToPB(mr domain.MergeRequest) *pb.MergeRequest {
133 HeadId: mr.HeadID.String(),165 HeadId: mr.HeadID.String(),
134 MergedCommitId: mr.MergedCommitID.String(),166 MergedCommitId: mr.MergedCommitID.String(),
135 MergeMode: mergeModeToPB(mr.MergeMode),167 MergeMode: mergeModeToPB(mr.MergeMode),
168+ AutoMerged: mr.AutoMerged,
169+ }
170+ if am := mr.AutoMerge; am != nil {
171+ out.AutoMerge = &pb.AutoMerge{
172+ Mode: mergeModeToPB(am.Mode), HeadId: am.HeadID.String(),
173+ EnabledBy: am.EnablerLabel, EnabledAt: timestamp(am.EnabledAt),
174+ }
136 }175 }
137 if mr.IsOpen() {176 if mr.IsOpen() {
138 out.Mergeability = &pb.Mergeability{State: pb.MergeabilityState_MERGEABILITY_STATE_CHECKING}177 out.Mergeability = &pb.Mergeability{State: pb.MergeabilityState_MERGEABILITY_STATE_CHECKING}
M internal/adapters/rpc/mr_api_test.go +80 -0
@@ -3,6 +3,7 @@
33 package rpc
44
55 import (
6+ "strings"
67 "testing"
78 "time"
89
@@ -122,3 +123,82 @@ func TestNoteVerdictsDetailAndScope(t *testing.T) {
122123 _, err = (&mergeRequestService{m}).AddComment(ownerCtx(), connect.NewRequest(&pb.AddCommentRequest{Repo: "demo", Number: 1, Verdict: pb.Verdict_VERDICT_APPROVE}))
123124 requireCode(t, connect.CodePermissionDenied, err)
124125 }
126+
127+func TestAutoMergeRPCCodesAndFields(t *testing.T) {
128+ now := time.Date(2026, 10, 20, 9, 0, 0, 0, time.UTC)
129+ head, err := domain.ParseObjectID(strings.Repeat("a", 40))
130+ require.NoError(t, err)
131+ armed := domain.MergeRequest{
132+ Repo: mustRepoName(t, "demo"), Number: 1, State: domain.MRStateOpen, Source: "feat", Target: "main", HeadID: head,
133+ AutoMerge: &domain.AutoMerge{Mode: domain.MergeModeSquash, HeadID: head, EnablerLabel: "owner", EnabledAt: now},
134+ }
135+ idle := armed
136+ idle.AutoMerge = nil
137+
138+ pbArmed := mergeRequestToPB(armed)
139+ require.NotNil(t, pbArmed.GetAutoMerge())
140+ assert.Equal(t, pb.MergeMode_MERGE_MODE_SQUASH, pbArmed.GetAutoMerge().GetMode())
141+ assert.Equal(t, head.String(), pbArmed.GetAutoMerge().GetHeadId())
142+ assert.Equal(t, "owner", pbArmed.GetAutoMerge().GetEnabledBy())
143+ assert.Equal(t, now, pbArmed.GetAutoMerge().GetEnabledAt().AsTime())
144+ assert.False(t, pbArmed.GetAutoMerged())
145+ assert.Nil(t, mergeRequestToPB(idle).GetAutoMerge())
146+ done := idle
147+ done.State, done.AutoMerged = domain.MRStateMerged, true
148+ assert.True(t, mergeRequestToPB(done).GetAutoMerged())
149+
150+ // Success: the mode and the request reach the service, the fields come back.
151+ m := mocks.NewMergeRequestService(t)
152+ m.EXPECT().EnableAutoMerge(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(1), ports.EnableAutoMergeParams{Mode: domain.MergeModeSquash, HeadID: mustObjectID(t, strings.Repeat("a", domain.ObjectIDLen))}).Return(armed, nil).Once()
153+ res, err := (&mergeRequestService{m}).EnableAutoMerge(ownerCtx(), connect.NewRequest(&pb.EnableAutoMergeRequest{Repo: "demo", Number: 1, Mode: pb.MergeMode_MERGE_MODE_SQUASH, HeadId: strings.Repeat("a", domain.ObjectIDLen)}))
154+ require.NoError(t, err)
155+ assert.Equal(t, pbArmed, res.Msg.GetMergeRequest())
156+ m.EXPECT().CancelAutoMerge(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(1)).Return(idle, nil).Once()
157+ cres, err := (&mergeRequestService{m}).CancelAutoMerge(ownerCtx(), connect.NewRequest(&pb.CancelAutoMergeRequest{Repo: "demo", Number: 1}))
158+ require.NoError(t, err)
159+ assert.Nil(t, cres.Msg.GetMergeRequest().GetAutoMerge())
160+
161+ // Bad input never reaches the service.
162+ _, err = (&mergeRequestService{m}).EnableAutoMerge(ownerCtx(), connect.NewRequest(&pb.EnableAutoMergeRequest{Repo: "demo", Number: 1, Mode: pb.MergeMode(99), HeadId: strings.Repeat("a", domain.ObjectIDLen)}))
163+ requireCode(t, connect.CodeInvalidArgument, err)
164+ _, err = (&mergeRequestService{m}).EnableAutoMerge(ownerCtx(), connect.NewRequest(&pb.EnableAutoMergeRequest{Repo: "demo", Number: 1, HeadId: "abc"}))
165+ requireCode(t, connect.CodeInvalidArgument, err)
166+ _, err = (&mergeRequestService{m}).CancelAutoMerge(ownerCtx(), connect.NewRequest(&pb.CancelAutoMergeRequest{Repo: "", Number: 1}))
167+ requireCode(t, connect.CodeInvalidArgument, err)
168+
169+ // Domain errors map like the other merge request calls.
170+ for _, tc := range []struct {
171+ err error
172+ code connect.Code
173+ }{
174+ {domain.ErrPreconditionFailed, connect.CodeFailedPrecondition},
175+ {domain.ErrForbidden, connect.CodePermissionDenied},
176+ {domain.ErrNotFound, connect.CodeNotFound},
177+ } {
178+ m := mocks.NewMergeRequestService(t)
179+ m.EXPECT().EnableAutoMerge(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(1), mock.Anything).Return(domain.MergeRequest{}, tc.err).Once()
180+ _, err := (&mergeRequestService{m}).EnableAutoMerge(ownerCtx(), connect.NewRequest(&pb.EnableAutoMergeRequest{Repo: "demo", Number: 1, HeadId: strings.Repeat("a", domain.ObjectIDLen)}))
181+ requireCode(t, tc.code, err)
182+ m.EXPECT().CancelAutoMerge(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(1)).Return(domain.MergeRequest{}, tc.err).Once()
183+ _, err = (&mergeRequestService{m}).CancelAutoMerge(ownerCtx(), connect.NewRequest(&pb.CancelAutoMergeRequest{Repo: "demo", Number: 1}))
184+ requireCode(t, tc.code, err)
185+ }
186+}
187+
188+func TestAutoMergeWatchEventsAndSettingsToPB(t *testing.T) {
189+ armed := domain.MergeRequest{Repo: mustRepoName(t, "demo"), Number: 1, State: domain.MRStateOpen,
190+ AutoMerge: &domain.AutoMerge{Mode: domain.MergeModeMerge, EnablerLabel: "owner"}}
191+ got := watchEventToPB(ports.WatchEvent{ID: "1", Event: domain.MergeRequestAutoMergeEnabled{MergeRequest: armed}})
192+ require.NotNil(t, got.GetMergeRequestAutoMergeEnabled())
193+ assert.Equal(t, pb.MergeMode_MERGE_MODE_MERGE_COMMIT, got.GetMergeRequestAutoMergeEnabled().GetMergeRequest().GetAutoMerge().GetMode())
194+ got = watchEventToPB(ports.WatchEvent{ID: "2", Event: domain.MergeRequestAutoMergeCancelled{MergeRequest: armed, Reason: "the branch moved"}})
195+ require.NotNil(t, got.GetMergeRequestAutoMergeCancelled())
196+ assert.Equal(t, "the branch moved", got.GetMergeRequestAutoMergeCancelled().GetReason())
197+ got = watchEventToPB(ports.WatchEvent{ID: "3", Event: domain.MergeRequestMerged{MergeRequest: armed, Auto: true}})
198+ assert.True(t, got.GetMergeRequestMerged().GetAuto())
199+ got = watchEventToPB(ports.WatchEvent{ID: "4", Event: domain.MergeRequestMerged{MergeRequest: armed}})
200+ assert.False(t, got.GetMergeRequestMerged().GetAuto())
201+
202+ assert.True(t, settingsToPB(domain.RepoSettings{AllowAutoMerge: true}).GetAllowAutoMerge())
203+ assert.False(t, settingsToPB(domain.RepoSettings{}).GetAllowAutoMerge())
204+}
@@ -3,6 +3,7 @@
3 package rpc3 package rpc
4 4
5 import (5 import (
6+ "strings"
6 "testing"7 "testing"
7 "time"8 "time"
8 9
@@ -122,3 +123,82 @@ func TestNoteVerdictsDetailAndScope(t *testing.T) {
122 _, err = (&mergeRequestService{m}).AddComment(ownerCtx(), connect.NewRequest(&pb.AddCommentRequest{Repo: "demo", Number: 1, Verdict: pb.Verdict_VERDICT_APPROVE}))123 _, err = (&mergeRequestService{m}).AddComment(ownerCtx(), connect.NewRequest(&pb.AddCommentRequest{Repo: "demo", Number: 1, Verdict: pb.Verdict_VERDICT_APPROVE}))
123 requireCode(t, connect.CodePermissionDenied, err)124 requireCode(t, connect.CodePermissionDenied, err)
124 }125 }
126+
127+func TestAutoMergeRPCCodesAndFields(t *testing.T) {
128+ now := time.Date(2026, 10, 20, 9, 0, 0, 0, time.UTC)
129+ head, err := domain.ParseObjectID(strings.Repeat("a", 40))
130+ require.NoError(t, err)
131+ armed := domain.MergeRequest{
132+ Repo: mustRepoName(t, "demo"), Number: 1, State: domain.MRStateOpen, Source: "feat", Target: "main", HeadID: head,
133+ AutoMerge: &domain.AutoMerge{Mode: domain.MergeModeSquash, HeadID: head, EnablerLabel: "owner", EnabledAt: now},
134+ }
135+ idle := armed
136+ idle.AutoMerge = nil
137+
138+ pbArmed := mergeRequestToPB(armed)
139+ require.NotNil(t, pbArmed.GetAutoMerge())
140+ assert.Equal(t, pb.MergeMode_MERGE_MODE_SQUASH, pbArmed.GetAutoMerge().GetMode())
141+ assert.Equal(t, head.String(), pbArmed.GetAutoMerge().GetHeadId())
142+ assert.Equal(t, "owner", pbArmed.GetAutoMerge().GetEnabledBy())
143+ assert.Equal(t, now, pbArmed.GetAutoMerge().GetEnabledAt().AsTime())
144+ assert.False(t, pbArmed.GetAutoMerged())
145+ assert.Nil(t, mergeRequestToPB(idle).GetAutoMerge())
146+ done := idle
147+ done.State, done.AutoMerged = domain.MRStateMerged, true
148+ assert.True(t, mergeRequestToPB(done).GetAutoMerged())
149+
150+ // Success: the mode and the request reach the service, the fields come back.
151+ m := mocks.NewMergeRequestService(t)
152+ m.EXPECT().EnableAutoMerge(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(1), ports.EnableAutoMergeParams{Mode: domain.MergeModeSquash, HeadID: mustObjectID(t, strings.Repeat("a", domain.ObjectIDLen))}).Return(armed, nil).Once()
153+ res, err := (&mergeRequestService{m}).EnableAutoMerge(ownerCtx(), connect.NewRequest(&pb.EnableAutoMergeRequest{Repo: "demo", Number: 1, Mode: pb.MergeMode_MERGE_MODE_SQUASH, HeadId: strings.Repeat("a", domain.ObjectIDLen)}))
154+ require.NoError(t, err)
155+ assert.Equal(t, pbArmed, res.Msg.GetMergeRequest())
156+ m.EXPECT().CancelAutoMerge(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(1)).Return(idle, nil).Once()
157+ cres, err := (&mergeRequestService{m}).CancelAutoMerge(ownerCtx(), connect.NewRequest(&pb.CancelAutoMergeRequest{Repo: "demo", Number: 1}))
158+ require.NoError(t, err)
159+ assert.Nil(t, cres.Msg.GetMergeRequest().GetAutoMerge())
160+
161+ // Bad input never reaches the service.
162+ _, err = (&mergeRequestService{m}).EnableAutoMerge(ownerCtx(), connect.NewRequest(&pb.EnableAutoMergeRequest{Repo: "demo", Number: 1, Mode: pb.MergeMode(99), HeadId: strings.Repeat("a", domain.ObjectIDLen)}))
163+ requireCode(t, connect.CodeInvalidArgument, err)
164+ _, err = (&mergeRequestService{m}).EnableAutoMerge(ownerCtx(), connect.NewRequest(&pb.EnableAutoMergeRequest{Repo: "demo", Number: 1, HeadId: "abc"}))
165+ requireCode(t, connect.CodeInvalidArgument, err)
166+ _, err = (&mergeRequestService{m}).CancelAutoMerge(ownerCtx(), connect.NewRequest(&pb.CancelAutoMergeRequest{Repo: "", Number: 1}))
167+ requireCode(t, connect.CodeInvalidArgument, err)
168+
169+ // Domain errors map like the other merge request calls.
170+ for _, tc := range []struct {
171+ err error
172+ code connect.Code
173+ }{
174+ {domain.ErrPreconditionFailed, connect.CodeFailedPrecondition},
175+ {domain.ErrForbidden, connect.CodePermissionDenied},
176+ {domain.ErrNotFound, connect.CodeNotFound},
177+ } {
178+ m := mocks.NewMergeRequestService(t)
179+ m.EXPECT().EnableAutoMerge(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(1), mock.Anything).Return(domain.MergeRequest{}, tc.err).Once()
180+ _, err := (&mergeRequestService{m}).EnableAutoMerge(ownerCtx(), connect.NewRequest(&pb.EnableAutoMergeRequest{Repo: "demo", Number: 1, HeadId: strings.Repeat("a", domain.ObjectIDLen)}))
181+ requireCode(t, tc.code, err)
182+ m.EXPECT().CancelAutoMerge(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(1)).Return(domain.MergeRequest{}, tc.err).Once()
183+ _, err = (&mergeRequestService{m}).CancelAutoMerge(ownerCtx(), connect.NewRequest(&pb.CancelAutoMergeRequest{Repo: "demo", Number: 1}))
184+ requireCode(t, tc.code, err)
185+ }
186+}
187+
188+func TestAutoMergeWatchEventsAndSettingsToPB(t *testing.T) {
189+ armed := domain.MergeRequest{Repo: mustRepoName(t, "demo"), Number: 1, State: domain.MRStateOpen,
190+ AutoMerge: &domain.AutoMerge{Mode: domain.MergeModeMerge, EnablerLabel: "owner"}}
191+ got := watchEventToPB(ports.WatchEvent{ID: "1", Event: domain.MergeRequestAutoMergeEnabled{MergeRequest: armed}})
192+ require.NotNil(t, got.GetMergeRequestAutoMergeEnabled())
193+ assert.Equal(t, pb.MergeMode_MERGE_MODE_MERGE_COMMIT, got.GetMergeRequestAutoMergeEnabled().GetMergeRequest().GetAutoMerge().GetMode())
194+ got = watchEventToPB(ports.WatchEvent{ID: "2", Event: domain.MergeRequestAutoMergeCancelled{MergeRequest: armed, Reason: "the branch moved"}})
195+ require.NotNil(t, got.GetMergeRequestAutoMergeCancelled())
196+ assert.Equal(t, "the branch moved", got.GetMergeRequestAutoMergeCancelled().GetReason())
197+ got = watchEventToPB(ports.WatchEvent{ID: "3", Event: domain.MergeRequestMerged{MergeRequest: armed, Auto: true}})
198+ assert.True(t, got.GetMergeRequestMerged().GetAuto())
199+ got = watchEventToPB(ports.WatchEvent{ID: "4", Event: domain.MergeRequestMerged{MergeRequest: armed}})
200+ assert.False(t, got.GetMergeRequestMerged().GetAuto())
201+
202+ assert.True(t, settingsToPB(domain.RepoSettings{AllowAutoMerge: true}).GetAllowAutoMerge())
203+ assert.False(t, settingsToPB(domain.RepoSettings{}).GetAllowAutoMerge())
204+}
M internal/adapters/rpc/repo.go +9 -5
@@ -80,8 +80,8 @@ func (s *repoService) UpdateRepo(ctx context.Context, req *connect.Request[pb.Up
8080
8181 // updateParams maps the field mask of an update. The mask must name at least
8282 // one of "description", "topics", "visibility", "default_branch", "settings",
83-// "settings.allowed_merge_modes" and "settings.failing_checks_block", and
84-// nothing else; the optional issues field stands outside the mask, and alone
83+// "settings.allowed_merge_modes", "settings.failing_checks_block" and
84+// "settings.allow_auto_merge", and nothing else; the optional issues field stands outside the mask, and alone
8585 // it is a valid update.
8686 func updateParams(msg *pb.UpdateRepoRequest) (ports.UpdateRepoParams, error) {
8787 var params ports.UpdateRepoParams
@@ -113,17 +113,21 @@ func updateParams(msg *pb.UpdateRepoRequest) (ports.UpdateRepoParams, error) {
113113 params.Visibility = &vis
114114 case "default_branch":
115115 params.DefaultBranch = new(msg.GetDefaultBranch())
116- case "settings", "settings.allowed_merge_modes", "settings.failing_checks_block":
117- if path != "settings.failing_checks_block" {
116+ case "settings", "settings.allowed_merge_modes", "settings.failing_checks_block", "settings.allow_auto_merge":
117+ all := path == "settings"
118+ if all || path == "settings.allowed_merge_modes" {
118119 modes, err := mergeModesFromPB(msg.GetSettings().GetAllowedMergeModes())
119120 if err != nil {
120121 return params, err
121122 }
122123 params.AllowedMergeModes = &modes
123124 }
124- if path != "settings.allowed_merge_modes" {
125+ if all || path == "settings.failing_checks_block" {
125126 params.FailingChecksBlock = new(msg.GetSettings().GetFailingChecksBlock())
126127 }
128+ if all || path == "settings.allow_auto_merge" {
129+ params.AllowAutoMerge = new(msg.GetSettings().GetAllowAutoMerge())
130+ }
127131 default:
128132 return params, fmt.Errorf("%w: unknown path %q in update_mask", domain.ErrInvalid, path)
129133 }
@@ -80,8 +80,8 @@ func (s *repoService) UpdateRepo(ctx context.Context, req *connect.Request[pb.Up
80 80
81 // updateParams maps the field mask of an update. The mask must name at least81 // updateParams maps the field mask of an update. The mask must name at least
82 // one of "description", "topics", "visibility", "default_branch", "settings",82 // one of "description", "topics", "visibility", "default_branch", "settings",
83-// "settings.allowed_merge_modes" and "settings.failing_checks_block", and83+// "settings.allowed_merge_modes", "settings.failing_checks_block" and
84-// nothing else; the optional issues field stands outside the mask, and alone84+// "settings.allow_auto_merge", and nothing else; the optional issues field stands outside the mask, and alone
85 // it is a valid update.85 // it is a valid update.
86 func updateParams(msg *pb.UpdateRepoRequest) (ports.UpdateRepoParams, error) {86 func updateParams(msg *pb.UpdateRepoRequest) (ports.UpdateRepoParams, error) {
87 var params ports.UpdateRepoParams87 var params ports.UpdateRepoParams
@@ -113,17 +113,21 @@ func updateParams(msg *pb.UpdateRepoRequest) (ports.UpdateRepoParams, error) {
113 params.Visibility = &vis113 params.Visibility = &vis
114 case "default_branch":114 case "default_branch":
115 params.DefaultBranch = new(msg.GetDefaultBranch())115 params.DefaultBranch = new(msg.GetDefaultBranch())
116- case "settings", "settings.allowed_merge_modes", "settings.failing_checks_block":116+ case "settings", "settings.allowed_merge_modes", "settings.failing_checks_block", "settings.allow_auto_merge":
117- if path != "settings.failing_checks_block" {117+ all := path == "settings"
118+ if all || path == "settings.allowed_merge_modes" {
118 modes, err := mergeModesFromPB(msg.GetSettings().GetAllowedMergeModes())119 modes, err := mergeModesFromPB(msg.GetSettings().GetAllowedMergeModes())
119 if err != nil {120 if err != nil {
120 return params, err121 return params, err
121 }122 }
122 params.AllowedMergeModes = &modes123 params.AllowedMergeModes = &modes
123 }124 }
124- if path != "settings.allowed_merge_modes" {125+ if all || path == "settings.failing_checks_block" {
125 params.FailingChecksBlock = new(msg.GetSettings().GetFailingChecksBlock())126 params.FailingChecksBlock = new(msg.GetSettings().GetFailingChecksBlock())
126 }127 }
128+ if all || path == "settings.allow_auto_merge" {
129+ params.AllowAutoMerge = new(msg.GetSettings().GetAllowAutoMerge())
130+ }
127 default:131 default:
128 return params, fmt.Errorf("%w: unknown path %q in update_mask", domain.ErrInvalid, path)132 return params, fmt.Errorf("%w: unknown path %q in update_mask", domain.ErrInvalid, path)
129 }133 }
M internal/adapters/rpc/repo_test.go +25 -0
@@ -280,3 +280,28 @@ func TestRepoToPBTopics(t *testing.T) {
280280 assert.Equal(t, []string{"cli", "go"}, got.GetTopics())
281281 assert.Empty(t, repoToPB(ports.RepoView{Repo: domain.Repo{Name: mustRepoName(t, "demo")}}).GetTopics())
282282 }
283+
284+func TestUpdateParamsAllowAutoMerge(t *testing.T) {
285+ t.Parallel()
286+ mask := func(paths ...string) *fieldmaskpb.FieldMask { return &fieldmaskpb.FieldMask{Paths: paths} }
287+ settings := &pb.RepoSettings{AllowAutoMerge: true, FailingChecksBlock: true, AllowedMergeModes: []pb.MergeMode{pb.MergeMode_MERGE_MODE_SQUASH}}
288+
289+ params, err := updateParams(&pb.UpdateRepoRequest{UpdateMask: mask("settings.allow_auto_merge"), Settings: settings})
290+ require.NoError(t, err)
291+ require.NotNil(t, params.AllowAutoMerge)
292+ assert.True(t, *params.AllowAutoMerge)
293+ assert.Nil(t, params.FailingChecksBlock, "only the masked setting is set")
294+ assert.Nil(t, params.AllowedMergeModes)
295+
296+ params, err = updateParams(&pb.UpdateRepoRequest{UpdateMask: mask("settings"), Settings: settings})
297+ require.NoError(t, err)
298+ require.NotNil(t, params.AllowAutoMerge, "settings covers allow_auto_merge")
299+ assert.True(t, *params.AllowAutoMerge)
300+ require.NotNil(t, params.FailingChecksBlock)
301+ require.NotNil(t, params.AllowedMergeModes)
302+
303+ params, err = updateParams(&pb.UpdateRepoRequest{UpdateMask: mask("settings.failing_checks_block"), Settings: settings})
304+ require.NoError(t, err)
305+ assert.Nil(t, params.AllowAutoMerge)
306+ require.NotNil(t, params.FailingChecksBlock)
307+}
@@ -280,3 +280,28 @@ func TestRepoToPBTopics(t *testing.T) {
280 assert.Equal(t, []string{"cli", "go"}, got.GetTopics())280 assert.Equal(t, []string{"cli", "go"}, got.GetTopics())
281 assert.Empty(t, repoToPB(ports.RepoView{Repo: domain.Repo{Name: mustRepoName(t, "demo")}}).GetTopics())281 assert.Empty(t, repoToPB(ports.RepoView{Repo: domain.Repo{Name: mustRepoName(t, "demo")}}).GetTopics())
282 }282 }
283+
284+func TestUpdateParamsAllowAutoMerge(t *testing.T) {
285+ t.Parallel()
286+ mask := func(paths ...string) *fieldmaskpb.FieldMask { return &fieldmaskpb.FieldMask{Paths: paths} }
287+ settings := &pb.RepoSettings{AllowAutoMerge: true, FailingChecksBlock: true, AllowedMergeModes: []pb.MergeMode{pb.MergeMode_MERGE_MODE_SQUASH}}
288+
289+ params, err := updateParams(&pb.UpdateRepoRequest{UpdateMask: mask("settings.allow_auto_merge"), Settings: settings})
290+ require.NoError(t, err)
291+ require.NotNil(t, params.AllowAutoMerge)
292+ assert.True(t, *params.AllowAutoMerge)
293+ assert.Nil(t, params.FailingChecksBlock, "only the masked setting is set")
294+ assert.Nil(t, params.AllowedMergeModes)
295+
296+ params, err = updateParams(&pb.UpdateRepoRequest{UpdateMask: mask("settings"), Settings: settings})
297+ require.NoError(t, err)
298+ require.NotNil(t, params.AllowAutoMerge, "settings covers allow_auto_merge")
299+ assert.True(t, *params.AllowAutoMerge)
300+ require.NotNil(t, params.FailingChecksBlock)
301+ require.NotNil(t, params.AllowedMergeModes)
302+
303+ params, err = updateParams(&pb.UpdateRepoRequest{UpdateMask: mask("settings.failing_checks_block"), Settings: settings})
304+ require.NoError(t, err)
305+ assert.Nil(t, params.AllowAutoMerge)
306+ require.NotNil(t, params.FailingChecksBlock)
307+}
M internal/adapters/web/fragments.go +5 -1
@@ -61,7 +61,7 @@ func liveEvent(we ports.WatchEvent) bool {
6161 }
6262 switch we.Event.(type) {
6363 case domain.RepoCreated, domain.RepoUpdated, domain.RepoDeleted, domain.PushEvent,
64- domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.MergeRequestReady, domain.MergeRequestMergeability, domain.CommentAdded, domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved,
64+ domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.MergeRequestReady, domain.MergeRequestAutoMergeEnabled, domain.MergeRequestAutoMergeCancelled, domain.MergeRequestMergeability, domain.CommentAdded, domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved,
6565 domain.RunUpdated, domain.JobUpdated,
6666 domain.IssueOpened, domain.IssueClosed, domain.IssueReopened, domain.IssueCommented, domain.IssueSubmitted, domain.IssuePendingRemoved, domain.IssueReferenced:
6767 return true
@@ -189,6 +189,10 @@ func (l *liveStream) repoPage(ctx context.Context, we ports.WatchEvent) ([]fragm
189189 return l.mrNotice(ctx, ev.MergeRequest.Number, "this merge request was closed — reload")
190190 case domain.MergeRequestReady:
191191 return l.mrNotice(ctx, ev.MergeRequest.Number, "this merge request is ready for review — reload")
192+ case domain.MergeRequestAutoMergeEnabled:
193+ return l.mrNotice(ctx, ev.MergeRequest.Number, "auto-merge was enabled — reload")
194+ case domain.MergeRequestAutoMergeCancelled:
195+ return l.mrNotice(ctx, ev.MergeRequest.Number, "auto-merge was cancelled — reload")
192196 case domain.MergeRequestMergeability:
193197 if l.view != "mr" || strconv.FormatInt(ev.Number, 10) != l.n {
194198 return nil, nil
@@ -61,7 +61,7 @@ func liveEvent(we ports.WatchEvent) bool {
61 }61 }
62 switch we.Event.(type) {62 switch we.Event.(type) {
63 case domain.RepoCreated, domain.RepoUpdated, domain.RepoDeleted, domain.PushEvent,63 case domain.RepoCreated, domain.RepoUpdated, domain.RepoDeleted, domain.PushEvent,
64- domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.MergeRequestReady, domain.MergeRequestMergeability, domain.CommentAdded, domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved,64+ domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.MergeRequestReady, domain.MergeRequestAutoMergeEnabled, domain.MergeRequestAutoMergeCancelled, domain.MergeRequestMergeability, domain.CommentAdded, domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved,
65 domain.RunUpdated, domain.JobUpdated,65 domain.RunUpdated, domain.JobUpdated,
66 domain.IssueOpened, domain.IssueClosed, domain.IssueReopened, domain.IssueCommented, domain.IssueSubmitted, domain.IssuePendingRemoved, domain.IssueReferenced:66 domain.IssueOpened, domain.IssueClosed, domain.IssueReopened, domain.IssueCommented, domain.IssueSubmitted, domain.IssuePendingRemoved, domain.IssueReferenced:
67 return true67 return true
@@ -189,6 +189,10 @@ func (l *liveStream) repoPage(ctx context.Context, we ports.WatchEvent) ([]fragm
189 return l.mrNotice(ctx, ev.MergeRequest.Number, "this merge request was closed — reload")189 return l.mrNotice(ctx, ev.MergeRequest.Number, "this merge request was closed — reload")
190 case domain.MergeRequestReady:190 case domain.MergeRequestReady:
191 return l.mrNotice(ctx, ev.MergeRequest.Number, "this merge request is ready for review — reload")191 return l.mrNotice(ctx, ev.MergeRequest.Number, "this merge request is ready for review — reload")
192+ case domain.MergeRequestAutoMergeEnabled:
193+ return l.mrNotice(ctx, ev.MergeRequest.Number, "auto-merge was enabled — reload")
194+ case domain.MergeRequestAutoMergeCancelled:
195+ return l.mrNotice(ctx, ev.MergeRequest.Number, "auto-merge was cancelled — reload")
192 case domain.MergeRequestMergeability:196 case domain.MergeRequestMergeability:
193 if l.view != "mr" || strconv.FormatInt(ev.Number, 10) != l.n {197 if l.view != "mr" || strconv.FormatInt(ev.Number, 10) != l.n {
194 return nil, nil198 return nil, nil
M internal/adapters/web/mr.go +26 -0
@@ -11,6 +11,7 @@ import (
1111 "net/url"
1212 "strconv"
1313 "strings"
14+ "time"
1415
1516 "github.com/a-h/templ"
1617
@@ -202,6 +203,18 @@ type MergeBox struct {
202203 Fingerprint string
203204 // Error is why the last merge was refused, shown in the box.
204205 Error string
206+ // AutoAction and CancelAutoAction post to the auto-merge routes.
207+ AutoAction, CancelAutoAction string
208+ // CanAutoMerge offers "Merge when ready": the repository allows
209+ // auto-merge and the server has CI to wait for.
210+ CanAutoMerge bool
211+ // AutoMerge is the pending auto-merge, nil when off.
212+ AutoMerge *AutoMergeView
213+}
214+
215+// AutoMergeView is a pending auto-merge, ready to show.
216+type AutoMergeView struct {
217+ Mode, EnabledBy, When, WhenAt, HeadShort string
205218 }
206219
207220 // ModeOption is a merge mode the repository allows. Disabled says that the
@@ -666,9 +679,20 @@ func (s *Server) mrBox(ctx context.Context, d *repoData, res ports.MergeRequestD
666679 if m.IsDraft() {
667680 box.ReadyAction = mrHref(d.name(), m.Number, "ready")
668681 }
682+ box.AutoMerge = autoMergeView(s.cfg.Now(), m.AutoMerge)
669683 return box
670684 }
671685
686+// autoMergeView is the pending auto-merge ready to show, nil when off.
687+func autoMergeView(now time.Time, am *domain.AutoMerge) *AutoMergeView {
688+ if am == nil {
689+ return nil
690+ }
691+ v := &AutoMergeView{Mode: am.Mode.String(), EnabledBy: am.EnablerLabel, HeadShort: short(am.HeadID.String())}
692+ v.When, v.WhenAt = whenOf(now, am.EnabledAt)
693+ return v
694+}
695+
672696 // mrFacts formats the description, metadata, branch links and checks.
673697 func (s *Server) mrFacts(ctx context.Context, r *http.Request, d *repoData, p *MRPage, res ports.MergeRequestDetail) error {
674698 m := res.MergeRequest
@@ -790,6 +814,8 @@ func (s *Server) mergeBox(d *repoData, n int64, source, head string, canMarkRead
790814 CanMarkRead: canMarkRead,
791815 CanDeleteBranch: source != d.repo.DefaultBranch,
792816 Mergeability: mg,
817+ AutoAction: mrHref(d.name(), n, "auto-merge"), CancelAutoAction: mrHref(d.name(), n, "auto-merge/cancel"),
818+ CanAutoMerge: d.repo.Settings.AllowAutoMerge && s.cfg.Runs != nil,
793819 }
794820 var lines []string
795821 cmd := ""
@@ -11,6 +11,7 @@ import (
11 "net/url"11 "net/url"
12 "strconv"12 "strconv"
13 "strings"13 "strings"
14+ "time"
14 15
15 "github.com/a-h/templ"16 "github.com/a-h/templ"
16 17
@@ -202,6 +203,18 @@ type MergeBox struct {
202 Fingerprint string203 Fingerprint string
203 // Error is why the last merge was refused, shown in the box.204 // Error is why the last merge was refused, shown in the box.
204 Error string205 Error string
206+ // AutoAction and CancelAutoAction post to the auto-merge routes.
207+ AutoAction, CancelAutoAction string
208+ // CanAutoMerge offers "Merge when ready": the repository allows
209+ // auto-merge and the server has CI to wait for.
210+ CanAutoMerge bool
211+ // AutoMerge is the pending auto-merge, nil when off.
212+ AutoMerge *AutoMergeView
213+}
214+
215+// AutoMergeView is a pending auto-merge, ready to show.
216+type AutoMergeView struct {
217+ Mode, EnabledBy, When, WhenAt, HeadShort string
205 }218 }
206 219
207 // ModeOption is a merge mode the repository allows. Disabled says that the220 // ModeOption is a merge mode the repository allows. Disabled says that the
@@ -666,9 +679,20 @@ func (s *Server) mrBox(ctx context.Context, d *repoData, res ports.MergeRequestD
666 if m.IsDraft() {679 if m.IsDraft() {
667 box.ReadyAction = mrHref(d.name(), m.Number, "ready")680 box.ReadyAction = mrHref(d.name(), m.Number, "ready")
668 }681 }
682+ box.AutoMerge = autoMergeView(s.cfg.Now(), m.AutoMerge)
669 return box683 return box
670 }684 }
671 685
686+// autoMergeView is the pending auto-merge ready to show, nil when off.
687+func autoMergeView(now time.Time, am *domain.AutoMerge) *AutoMergeView {
688+ if am == nil {
689+ return nil
690+ }
691+ v := &AutoMergeView{Mode: am.Mode.String(), EnabledBy: am.EnablerLabel, HeadShort: short(am.HeadID.String())}
692+ v.When, v.WhenAt = whenOf(now, am.EnabledAt)
693+ return v
694+}
695+
672 // mrFacts formats the description, metadata, branch links and checks.696 // mrFacts formats the description, metadata, branch links and checks.
673 func (s *Server) mrFacts(ctx context.Context, r *http.Request, d *repoData, p *MRPage, res ports.MergeRequestDetail) error {697 func (s *Server) mrFacts(ctx context.Context, r *http.Request, d *repoData, p *MRPage, res ports.MergeRequestDetail) error {
674 m := res.MergeRequest698 m := res.MergeRequest
@@ -790,6 +814,8 @@ func (s *Server) mergeBox(d *repoData, n int64, source, head string, canMarkRead
790 CanMarkRead: canMarkRead,814 CanMarkRead: canMarkRead,
791 CanDeleteBranch: source != d.repo.DefaultBranch,815 CanDeleteBranch: source != d.repo.DefaultBranch,
792 Mergeability: mg,816 Mergeability: mg,
817+ AutoAction: mrHref(d.name(), n, "auto-merge"), CancelAutoAction: mrHref(d.name(), n, "auto-merge/cancel"),
818+ CanAutoMerge: d.repo.Settings.AllowAutoMerge && s.cfg.Runs != nil,
793 }819 }
794 var lines []string820 var lines []string
795 cmd := ""821 cmd := ""
M internal/adapters/web/mr.templ +28 -0
@@ -508,6 +508,20 @@ templ mergeMenu(p MRPage) {
508508 </form>
509509 } else if !p.Box.HeadGone {
510510 @mrMergeabilitySlot(p.Box.Mergeability)
511+ if p.Box.AutoMerge != nil {
512+ <form class="d-acts" method="post" action={ templ.SafeURL(p.Box.CancelAutoAction) }>
513+ <p class="dim" role="status">
514+ Auto-merge: { p.Box.AutoMerge.Mode } when ready, enabled by { p.Box.AutoMerge.EnabledBy }
515+ if p.Box.AutoMerge.When != "" {
516+ { " " }<time datetime={ p.Box.AutoMerge.WhenAt }>{ p.Box.AutoMerge.When }</time>
517+ }
518+ { " " }(head { p.Box.AutoMerge.HeadShort })
519+ </p>
520+ <div class="d-btns">
521+ <button type="submit" class="btn">Cancel auto-merge</button>
522+ </div>
523+ </form>
524+ }
511525 <form class="d-acts" method="post" action={ templ.SafeURL(p.Box.MergeAction) }>
512526 <input type="hidden" name="head_id" value={ p.Box.Head }/>
513527 <fieldset class="d-modes">
@@ -546,6 +560,20 @@ templ mergeMenu(p MRPage) {
546560 >Merge into { p.Target }</button>
547561 </div>
548562 </form>
563+ if p.Box.CanAutoMerge {
564+ <form class="d-acts" method="post" action={ templ.SafeURL(p.Box.AutoAction) }>
565+ <input type="hidden" name="head_id" value={ p.Box.Head }/>
566+ <fieldset class="d-modes">
567+ <legend>Auto-merge mode</legend>
568+ for i, m := range p.Box.Modes {
569+ <label><input type="radio" name="mode" value={ m.Value } checked?={ i == 0 }/> { m.Label }</label>
570+ }
571+ </fieldset>
572+ <div class="d-btns">
573+ <button type="submit" class="btn" disabled?={ len(p.Box.Modes) == 0 } title="merge once the checks pass">Merge when ready</button>
574+ </div>
575+ </form>
576+ }
549577 }
550578 </div>
551579 </details>
@@ -508,6 +508,20 @@ templ mergeMenu(p MRPage) {
508 </form>508 </form>
509 } else if !p.Box.HeadGone {509 } else if !p.Box.HeadGone {
510 @mrMergeabilitySlot(p.Box.Mergeability)510 @mrMergeabilitySlot(p.Box.Mergeability)
511+ if p.Box.AutoMerge != nil {
512+ <form class="d-acts" method="post" action={ templ.SafeURL(p.Box.CancelAutoAction) }>
513+ <p class="dim" role="status">
514+ Auto-merge: { p.Box.AutoMerge.Mode } when ready, enabled by { p.Box.AutoMerge.EnabledBy }
515+ if p.Box.AutoMerge.When != "" {
516+ { " " }<time datetime={ p.Box.AutoMerge.WhenAt }>{ p.Box.AutoMerge.When }</time>
517+ }
518+ { " " }(head { p.Box.AutoMerge.HeadShort })
519+ </p>
520+ <div class="d-btns">
521+ <button type="submit" class="btn">Cancel auto-merge</button>
522+ </div>
523+ </form>
524+ }
511 <form class="d-acts" method="post" action={ templ.SafeURL(p.Box.MergeAction) }>525 <form class="d-acts" method="post" action={ templ.SafeURL(p.Box.MergeAction) }>
512 <input type="hidden" name="head_id" value={ p.Box.Head }/>526 <input type="hidden" name="head_id" value={ p.Box.Head }/>
513 <fieldset class="d-modes">527 <fieldset class="d-modes">
@@ -546,6 +560,20 @@ templ mergeMenu(p MRPage) {
546 >Merge into { p.Target }</button>560 >Merge into { p.Target }</button>
547 </div>561 </div>
548 </form>562 </form>
563+ if p.Box.CanAutoMerge {
564+ <form class="d-acts" method="post" action={ templ.SafeURL(p.Box.AutoAction) }>
565+ <input type="hidden" name="head_id" value={ p.Box.Head }/>
566+ <fieldset class="d-modes">
567+ <legend>Auto-merge mode</legend>
568+ for i, m := range p.Box.Modes {
569+ <label><input type="radio" name="mode" value={ m.Value } checked?={ i == 0 }/> { m.Label }</label>
570+ }
571+ </fieldset>
572+ <div class="d-btns">
573+ <button type="submit" class="btn" disabled?={ len(p.Box.Modes) == 0 } title="merge once the checks pass">Merge when ready</button>
574+ </div>
575+ </form>
576+ }
549 }577 }
550 </div>578 </div>
551 </details>579 </details>
M internal/adapters/web/mr_post.go +62 -0
@@ -391,6 +391,68 @@ func (s *Server) mrMerge(ctx context.Context, r *http.Request) (string, string,
391391 return back, back, nil
392392 }
393393
394+// mrAutoMerge is POST /<repo>/merge-requests/<n>/auto-merge: the form of the
395+// merge (mode, head_id) asks to merge once the checks pass. The server records
396+// refuses to enable when the source branch is no longer at head_id.
397+func (s *Server) mrAutoMerge(ctx context.Context, r *http.Request) (string, string, error) {
398+ repo := r.PathValue("repo")
399+ n, err := number(r)
400+ if err != nil {
401+ return "", "", err
402+ }
403+ back := mrHref(repo, n)
404+ var mode domain.MergeMode
405+ switch r.PostFormValue("mode") {
406+ case "fast-forward":
407+ mode = domain.MergeModeFastForward
408+ case "squash":
409+ mode = domain.MergeModeSquash
410+ case "merge":
411+ mode = domain.MergeModeMerge
412+ default:
413+ err := formError("mode must be fast-forward, squash or merge")
414+ return "", back, &rerender{status: http.StatusBadRequest, msg: err.Error(), err: err}
415+ }
416+ name, err := domain.ParseRepoName(repo)
417+ if err != nil {
418+ return "", back, err
419+ }
420+ head, err := headID(r.PostFormValue("head_id"))
421+ if err != nil {
422+ return "", back, err
423+ }
424+ _, err = s.cfg.MergeRequests.EnableAutoMerge(ctx, principal(ctx), name, n, ports.EnableAutoMergeParams{Mode: mode, HeadID: head})
425+ switch {
426+ case errors.Is(err, domain.ErrPreconditionFailed), errors.Is(err, domain.ErrConflict):
427+ return "", back, &rerender{status: http.StatusConflict, msg: rule(err.Error(), "the merge request is not in a state that allows this."), err: err}
428+ case err != nil:
429+ return "", back, err
430+ }
431+ return back, back, nil
432+}
433+
434+// mrCancelAutoMerge is POST /<repo>/merge-requests/<n>/auto-merge/cancel.
435+func (s *Server) mrCancelAutoMerge(ctx context.Context, r *http.Request) (string, string, error) {
436+ repo := r.PathValue("repo")
437+ n, err := number(r)
438+ if err != nil {
439+ return "", "", err
440+ }
441+ back := mrHref(repo, n)
442+ name, err := domain.ParseRepoName(repo)
443+ if err != nil {
444+ return "", back, err
445+ }
446+ _, err = s.cfg.MergeRequests.CancelAutoMerge(ctx, principal(ctx), name, n)
447+ switch {
448+ case errors.Is(err, domain.ErrPreconditionFailed), errors.Is(err, domain.ErrConflict):
449+ return "", back, &rerender{status: http.StatusConflict, msg: rule(err.Error(), "the merge request is not in a state that allows this."), err: err}
450+ case err != nil:
451+ return "", back, err
452+ }
453+ return back, back, nil
454+}
455+
394456 // mrRead is POST /<repo>/merge-requests/<n>/read: the owner marks the head
395457 // they were shown as read. Reading a page never does it (see the top of mr.go).
396458 func (s *Server) mrRead(ctx context.Context, r *http.Request) (string, string, error) {
@@ -391,6 +391,68 @@ func (s *Server) mrMerge(ctx context.Context, r *http.Request) (string, string,
391 return back, back, nil391 return back, back, nil
392 }392 }
393 393
394+// mrAutoMerge is POST /<repo>/merge-requests/<n>/auto-merge: the form of the
395+// merge (mode, head_id) asks to merge once the checks pass. The server records
396+// refuses to enable when the source branch is no longer at head_id.
397+func (s *Server) mrAutoMerge(ctx context.Context, r *http.Request) (string, string, error) {
398+ repo := r.PathValue("repo")
399+ n, err := number(r)
400+ if err != nil {
401+ return "", "", err
402+ }
403+ back := mrHref(repo, n)
404+ var mode domain.MergeMode
405+ switch r.PostFormValue("mode") {
406+ case "fast-forward":
407+ mode = domain.MergeModeFastForward
408+ case "squash":
409+ mode = domain.MergeModeSquash
410+ case "merge":
411+ mode = domain.MergeModeMerge
412+ default:
413+ err := formError("mode must be fast-forward, squash or merge")
414+ return "", back, &rerender{status: http.StatusBadRequest, msg: err.Error(), err: err}
415+ }
416+ name, err := domain.ParseRepoName(repo)
417+ if err != nil {
418+ return "", back, err
419+ }
420+ head, err := headID(r.PostFormValue("head_id"))
421+ if err != nil {
422+ return "", back, err
423+ }
424+ _, err = s.cfg.MergeRequests.EnableAutoMerge(ctx, principal(ctx), name, n, ports.EnableAutoMergeParams{Mode: mode, HeadID: head})
425+ switch {
426+ case errors.Is(err, domain.ErrPreconditionFailed), errors.Is(err, domain.ErrConflict):
427+ return "", back, &rerender{status: http.StatusConflict, msg: rule(err.Error(), "the merge request is not in a state that allows this."), err: err}
428+ case err != nil:
429+ return "", back, err
430+ }
431+ return back, back, nil
432+}
433+
434+// mrCancelAutoMerge is POST /<repo>/merge-requests/<n>/auto-merge/cancel.
435+func (s *Server) mrCancelAutoMerge(ctx context.Context, r *http.Request) (string, string, error) {
436+ repo := r.PathValue("repo")
437+ n, err := number(r)
438+ if err != nil {
439+ return "", "", err
440+ }
441+ back := mrHref(repo, n)
442+ name, err := domain.ParseRepoName(repo)
443+ if err != nil {
444+ return "", back, err
445+ }
446+ _, err = s.cfg.MergeRequests.CancelAutoMerge(ctx, principal(ctx), name, n)
447+ switch {
448+ case errors.Is(err, domain.ErrPreconditionFailed), errors.Is(err, domain.ErrConflict):
449+ return "", back, &rerender{status: http.StatusConflict, msg: rule(err.Error(), "the merge request is not in a state that allows this."), err: err}
450+ case err != nil:
451+ return "", back, err
452+ }
453+ return back, back, nil
454+}
455+
394 // mrRead is POST /<repo>/merge-requests/<n>/read: the owner marks the head456 // mrRead is POST /<repo>/merge-requests/<n>/read: the owner marks the head
395 // they were shown as read. Reading a page never does it (see the top of mr.go).457 // they were shown as read. Reading a page never does it (see the top of mr.go).
396 func (s *Server) mrRead(ctx context.Context, r *http.Request) (string, string, error) {458 func (s *Server) mrRead(ctx context.Context, r *http.Request) (string, string, error) {
M internal/adapters/web/mr_templ.go +554 -350 generated file: collapsed
M internal/adapters/web/mr_test.go +103 -1
@@ -4,8 +4,11 @@ package web
44
55 import (
66 "context"
7- "time"
7+ "net/http"
8+ "net/http/httptest"
9+ "strings"
810 "testing"
11+ "time"
912
1013 "github.com/stretchr/testify/assert"
1114 "github.com/stretchr/testify/mock"
@@ -195,3 +198,102 @@ func TestMRReadPresentationCommitCount(t *testing.T) {
195198 })
196199 }
197200 }
201+
202+func TestMergeBoxAutoMerge(t *testing.T) {
203+ t.Parallel()
204+ mg := mergeabilityView(domain.Mergeability{State: domain.MergeabilityUpToDate}, "main")
205+ mk := func(allow bool, runs ports.RunService) *MergeBox {
206+ v := repoWithModes(domain.MergeModes()...)
207+ v.Settings.AllowAutoMerge = allow
208+ return (&Server{cfg: Config{Runs: runs}}).mergeBox(&repoData{repo: v}, 1, "feat", "abc", false, mg)
209+ }
210+ assert.False(t, mk(false, mocks.NewRunService(t)).CanAutoMerge)
211+ assert.False(t, mk(true, nil).CanAutoMerge, "no CI, nothing to wait for")
212+ on := mk(true, mocks.NewRunService(t))
213+ assert.True(t, on.CanAutoMerge)
214+
215+ page := MRPage{Target: "main", Box: on}
216+ html := renderToString(t, mergeMenu(page))
217+ assert.Contains(t, html, "Merge when ready")
218+ assert.Contains(t, html, `action="`+on.AutoAction+`"`)
219+ assert.NotContains(t, html, "Cancel auto-merge")
220+ // Current mergeability must only gate the immediate form. Every allowed
221+ // mode remains selectable in the separate auto-merge form.
222+ for i := range on.Modes {
223+ on.Modes[i].Disabled = true
224+ }
225+ on.CanDeleteBranch = true
226+ page.Source = "feat"
227+ html = renderToString(t, mergeMenu(page))
228+ _, autoForm, found := strings.Cut(html, `action="`+on.AutoAction+`"`)
229+ require.True(t, found)
230+ autoForm, _, _ = strings.Cut(autoForm, "</form>")
231+ assert.NotContains(t, autoForm, "delete_branch")
232+ assert.NotContains(t, autoForm, "disabled")
233+ assert.Contains(t, autoForm, `name="head_id"`)
234+ for _, mode := range on.Modes {
235+ assert.Contains(t, autoForm, `value="`+mode.Value+`"`)
236+ }
237+ page.Box = mk(false, mocks.NewRunService(t))
238+ assert.NotContains(t, renderToString(t, mergeMenu(page)), "Merge when ready")
239+
240+ on.AutoMerge = &AutoMergeView{Mode: "squash", EnabledBy: "owner", HeadShort: "abc"}
241+ html = renderToString(t, mergeMenu(MRPage{Target: "main", Box: on}))
242+ assert.Contains(t, html, "Auto-merge: squash when ready, enabled by owner")
243+ assert.Contains(t, html, `action="`+on.CancelAutoAction+`"`)
244+ assert.Contains(t, html, "Cancel auto-merge")
245+}
246+
247+func autoMergeRequest(path, body string) *http.Request {
248+ r := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body))
249+ r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
250+ r.SetPathValue("repo", "demo")
251+ r.SetPathValue("n", "3")
252+ return r
253+}
254+
255+func TestAutoMergeHandlers(t *testing.T) {
256+ t.Parallel()
257+ name, err := domain.ParseRepoName("demo")
258+ require.NoError(t, err)
259+ ctx := context.Background()
260+
261+ head, err := domain.ParseObjectID(strings.Repeat("a", domain.ObjectIDLen))
262+ require.NoError(t, err)
263+ svc := mocks.NewMergeRequestService(t)
264+ svc.EXPECT().EnableAutoMerge(mock.Anything, mock.Anything, name, int64(3), ports.EnableAutoMergeParams{Mode: domain.MergeModeSquash, HeadID: head}).
265+ Return(domain.MergeRequest{}, nil).Once()
266+ svc.EXPECT().CancelAutoMerge(mock.Anything, mock.Anything, name, int64(3)).Return(domain.MergeRequest{}, nil).Once()
267+ s := &Server{cfg: Config{MergeRequests: svc}}
268+ to, _, err := s.mrAutoMerge(ctx, autoMergeRequest("/demo/merge-requests/3/auto-merge", "mode=squash&head_id="+head.String()))
269+ require.NoError(t, err)
270+ assert.Equal(t, mrHref("demo", 3), to)
271+ to, _, err = s.mrCancelAutoMerge(ctx, autoMergeRequest("/demo/merge-requests/3/auto-merge/cancel", ""))
272+ require.NoError(t, err)
273+ assert.Equal(t, mrHref("demo", 3), to)
274+
275+ // A bad mode is refused before the service.
276+ _, _, err = s.mrAutoMerge(ctx, autoMergeRequest("/x", "mode=rebase"))
277+ var re *rerender
278+ require.ErrorAs(t, err, &re)
279+ assert.Equal(t, http.StatusBadRequest, re.status)
280+
281+ // A refusal of the use case is shown in the box, like a refused merge.
282+ svc2 := mocks.NewMergeRequestService(t)
283+ svc2.EXPECT().EnableAutoMerge(mock.Anything, mock.Anything, name, int64(3), mock.Anything).
284+ Return(domain.MergeRequest{}, domain.ErrPreconditionFailed).Once()
285+ svc2.EXPECT().CancelAutoMerge(mock.Anything, mock.Anything, name, int64(3)).
286+ Return(domain.MergeRequest{}, domain.ErrNotFound).Once()
287+ s2 := &Server{cfg: Config{MergeRequests: svc2}}
288+ _, _, err = s2.mrAutoMerge(ctx, autoMergeRequest("/x", "mode=squash&head_id="+head.String()))
289+ require.ErrorAs(t, err, &re)
290+ assert.Equal(t, http.StatusConflict, re.status)
291+ _, _, err = s2.mrCancelAutoMerge(ctx, autoMergeRequest("/x", ""))
292+ require.ErrorIs(t, err, domain.ErrNotFound)
293+}
294+
295+func TestAutoMergeLiveEvents(t *testing.T) {
296+ t.Parallel()
297+ assert.True(t, liveEvent(watchOf(domain.MergeRequestAutoMergeEnabled{})))
298+ assert.True(t, liveEvent(watchOf(domain.MergeRequestAutoMergeCancelled{})))
299+}
@@ -4,8 +4,11 @@ package web
4 4
5 import (5 import (
6 "context"6 "context"
7- "time"7+ "net/http"
8+ "net/http/httptest"
9+ "strings"
8 "testing"10 "testing"
11+ "time"
9 12
10 "github.com/stretchr/testify/assert"13 "github.com/stretchr/testify/assert"
11 "github.com/stretchr/testify/mock"14 "github.com/stretchr/testify/mock"
@@ -195,3 +198,102 @@ func TestMRReadPresentationCommitCount(t *testing.T) {
195 })198 })
196 }199 }
197 }200 }
201+
202+func TestMergeBoxAutoMerge(t *testing.T) {
203+ t.Parallel()
204+ mg := mergeabilityView(domain.Mergeability{State: domain.MergeabilityUpToDate}, "main")
205+ mk := func(allow bool, runs ports.RunService) *MergeBox {
206+ v := repoWithModes(domain.MergeModes()...)
207+ v.Settings.AllowAutoMerge = allow
208+ return (&Server{cfg: Config{Runs: runs}}).mergeBox(&repoData{repo: v}, 1, "feat", "abc", false, mg)
209+ }
210+ assert.False(t, mk(false, mocks.NewRunService(t)).CanAutoMerge)
211+ assert.False(t, mk(true, nil).CanAutoMerge, "no CI, nothing to wait for")
212+ on := mk(true, mocks.NewRunService(t))
213+ assert.True(t, on.CanAutoMerge)
214+
215+ page := MRPage{Target: "main", Box: on}
216+ html := renderToString(t, mergeMenu(page))
217+ assert.Contains(t, html, "Merge when ready")
218+ assert.Contains(t, html, `action="`+on.AutoAction+`"`)
219+ assert.NotContains(t, html, "Cancel auto-merge")
220+ // Current mergeability must only gate the immediate form. Every allowed
221+ // mode remains selectable in the separate auto-merge form.
222+ for i := range on.Modes {
223+ on.Modes[i].Disabled = true
224+ }
225+ on.CanDeleteBranch = true
226+ page.Source = "feat"
227+ html = renderToString(t, mergeMenu(page))
228+ _, autoForm, found := strings.Cut(html, `action="`+on.AutoAction+`"`)
229+ require.True(t, found)
230+ autoForm, _, _ = strings.Cut(autoForm, "</form>")
231+ assert.NotContains(t, autoForm, "delete_branch")
232+ assert.NotContains(t, autoForm, "disabled")
233+ assert.Contains(t, autoForm, `name="head_id"`)
234+ for _, mode := range on.Modes {
235+ assert.Contains(t, autoForm, `value="`+mode.Value+`"`)
236+ }
237+ page.Box = mk(false, mocks.NewRunService(t))
238+ assert.NotContains(t, renderToString(t, mergeMenu(page)), "Merge when ready")
239+
240+ on.AutoMerge = &AutoMergeView{Mode: "squash", EnabledBy: "owner", HeadShort: "abc"}
241+ html = renderToString(t, mergeMenu(MRPage{Target: "main", Box: on}))
242+ assert.Contains(t, html, "Auto-merge: squash when ready, enabled by owner")
243+ assert.Contains(t, html, `action="`+on.CancelAutoAction+`"`)
244+ assert.Contains(t, html, "Cancel auto-merge")
245+}
246+
247+func autoMergeRequest(path, body string) *http.Request {
248+ r := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body))
249+ r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
250+ r.SetPathValue("repo", "demo")
251+ r.SetPathValue("n", "3")
252+ return r
253+}
254+
255+func TestAutoMergeHandlers(t *testing.T) {
256+ t.Parallel()
257+ name, err := domain.ParseRepoName("demo")
258+ require.NoError(t, err)
259+ ctx := context.Background()
260+
261+ head, err := domain.ParseObjectID(strings.Repeat("a", domain.ObjectIDLen))
262+ require.NoError(t, err)
263+ svc := mocks.NewMergeRequestService(t)
264+ svc.EXPECT().EnableAutoMerge(mock.Anything, mock.Anything, name, int64(3), ports.EnableAutoMergeParams{Mode: domain.MergeModeSquash, HeadID: head}).
265+ Return(domain.MergeRequest{}, nil).Once()
266+ svc.EXPECT().CancelAutoMerge(mock.Anything, mock.Anything, name, int64(3)).Return(domain.MergeRequest{}, nil).Once()
267+ s := &Server{cfg: Config{MergeRequests: svc}}
268+ to, _, err := s.mrAutoMerge(ctx, autoMergeRequest("/demo/merge-requests/3/auto-merge", "mode=squash&head_id="+head.String()))
269+ require.NoError(t, err)
270+ assert.Equal(t, mrHref("demo", 3), to)
271+ to, _, err = s.mrCancelAutoMerge(ctx, autoMergeRequest("/demo/merge-requests/3/auto-merge/cancel", ""))
272+ require.NoError(t, err)
273+ assert.Equal(t, mrHref("demo", 3), to)
274+
275+ // A bad mode is refused before the service.
276+ _, _, err = s.mrAutoMerge(ctx, autoMergeRequest("/x", "mode=rebase"))
277+ var re *rerender
278+ require.ErrorAs(t, err, &re)
279+ assert.Equal(t, http.StatusBadRequest, re.status)
280+
281+ // A refusal of the use case is shown in the box, like a refused merge.
282+ svc2 := mocks.NewMergeRequestService(t)
283+ svc2.EXPECT().EnableAutoMerge(mock.Anything, mock.Anything, name, int64(3), mock.Anything).
284+ Return(domain.MergeRequest{}, domain.ErrPreconditionFailed).Once()
285+ svc2.EXPECT().CancelAutoMerge(mock.Anything, mock.Anything, name, int64(3)).
286+ Return(domain.MergeRequest{}, domain.ErrNotFound).Once()
287+ s2 := &Server{cfg: Config{MergeRequests: svc2}}
288+ _, _, err = s2.mrAutoMerge(ctx, autoMergeRequest("/x", "mode=squash&head_id="+head.String()))
289+ require.ErrorAs(t, err, &re)
290+ assert.Equal(t, http.StatusConflict, re.status)
291+ _, _, err = s2.mrCancelAutoMerge(ctx, autoMergeRequest("/x", ""))
292+ require.ErrorIs(t, err, domain.ErrNotFound)
293+}
294+
295+func TestAutoMergeLiveEvents(t *testing.T) {
296+ t.Parallel()
297+ assert.True(t, liveEvent(watchOf(domain.MergeRequestAutoMergeEnabled{})))
298+ assert.True(t, liveEvent(watchOf(domain.MergeRequestAutoMergeCancelled{})))
299+}
M internal/adapters/web/web.go +2 -0
@@ -231,6 +231,8 @@ func (s *Server) routes() *http.ServeMux {
231231 mux.HandleFunc("POST /{repo}/merge-requests/{n}/comments/{id}/edit", s.mrAction(s.mrEditComment))
232232 mux.HandleFunc("POST /{repo}/merge-requests/{n}/comments/{id}/delete", s.mrAction(s.mrDeleteComment))
233233 mux.HandleFunc("POST /{repo}/merge-requests/{n}/merge", s.mrAction(s.mrMerge))
234+ mux.HandleFunc("POST /{repo}/merge-requests/{n}/auto-merge", s.mrAction(s.mrAutoMerge))
235+ mux.HandleFunc("POST /{repo}/merge-requests/{n}/auto-merge/cancel", s.mrAction(s.mrCancelAutoMerge))
234236 mux.HandleFunc("POST /{repo}/merge-requests/{n}/close", s.mrAction(s.mrClose))
235237 mux.HandleFunc("POST /{repo}/merge-requests/{n}/ready", s.mrAction(s.mrReady))
236238 mux.HandleFunc("POST /{repo}/merge-requests/{n}/read", s.mrAction(s.mrRead))
@@ -231,6 +231,8 @@ func (s *Server) routes() *http.ServeMux {
231 mux.HandleFunc("POST /{repo}/merge-requests/{n}/comments/{id}/edit", s.mrAction(s.mrEditComment))231 mux.HandleFunc("POST /{repo}/merge-requests/{n}/comments/{id}/edit", s.mrAction(s.mrEditComment))
232 mux.HandleFunc("POST /{repo}/merge-requests/{n}/comments/{id}/delete", s.mrAction(s.mrDeleteComment))232 mux.HandleFunc("POST /{repo}/merge-requests/{n}/comments/{id}/delete", s.mrAction(s.mrDeleteComment))
233 mux.HandleFunc("POST /{repo}/merge-requests/{n}/merge", s.mrAction(s.mrMerge))233 mux.HandleFunc("POST /{repo}/merge-requests/{n}/merge", s.mrAction(s.mrMerge))
234+ mux.HandleFunc("POST /{repo}/merge-requests/{n}/auto-merge", s.mrAction(s.mrAutoMerge))
235+ mux.HandleFunc("POST /{repo}/merge-requests/{n}/auto-merge/cancel", s.mrAction(s.mrCancelAutoMerge))
234 mux.HandleFunc("POST /{repo}/merge-requests/{n}/close", s.mrAction(s.mrClose))236 mux.HandleFunc("POST /{repo}/merge-requests/{n}/close", s.mrAction(s.mrClose))
235 mux.HandleFunc("POST /{repo}/merge-requests/{n}/ready", s.mrAction(s.mrReady))237 mux.HandleFunc("POST /{repo}/merge-requests/{n}/ready", s.mrAction(s.mrReady))
236 mux.HandleFunc("POST /{repo}/merge-requests/{n}/read", s.mrAction(s.mrRead))238 mux.HandleFunc("POST /{repo}/merge-requests/{n}/read", s.mrAction(s.mrRead))
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", "issues.links", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures", "mirror"}, resp.Msg.GetFeatures(), who)
199+ assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "issues.links", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures", "mirror", "merge_requests.auto_merge"}, resp.Msg.GetFeatures(), who)
200200 assert.NotEmpty(t, resp.Msg.GetVersion())
201201 }
202202 })
@@ -196,7 +196,7 @@ func TestAC2ReadAccess(t *testing.T) {
196 for _, who := range allPrincipals {196 for _, who := range allPrincipals {
197 resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{}))197 resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{}))
198 require.NoError(t, err, who)198 require.NoError(t, err, who)
199- assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "issues.links", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures", "mirror"}, resp.Msg.GetFeatures(), who)199+ assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "issues.links", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures", "mirror", "merge_requests.auto_merge"}, resp.Msg.GetFeatures(), who)
200 assert.NotEmpty(t, resp.Msg.GetVersion())200 assert.NotEmpty(t, resp.Msg.GetVersion())
201 }201 }
202 })202 })
A internal/app/automerge_e2e_test.go +163 -0
@@ -0,0 +1,163 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "testing"
7+ "time"
8+
9+ "connectrpc.com/connect"
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+ "google.golang.org/protobuf/types/known/fieldmaskpb"
13+
14+ pb "bnema.dev/ermite-api/gen/ermite"
15+ "bnema.dev/ermite/internal/domain"
16+ "bnema.dev/ermite/internal/ports"
17+)
18+
19+// This test exercises the composition root through use cases and domain
20+// events; TestAutoMergeEndToEndAPI does the same through the RPC API.
21+func TestAutoMergeEndToEnd(t *testing.T) {
22+ c := newCIHarness(t, nil)
23+ runner := c.registerRunner("alpha", "ubuntu-latest")
24+ c.push("main", map[string]string{".github/workflows/ci.yml": `name: ci
25+on:
26+ push:
27+ branches: [feat]
28+ pull_request:
29+jobs:
30+ build:
31+ runs-on: ubuntu-latest
32+ steps:
33+ - run: echo hello
34+`}, "")
35+ head := c.push("feat", map[string]string{"change.txt": "feature\n"}, "")
36+ number := c.openMR(c.root, "feat")
37+ repo := c.name("proj")
38+ allow := true
39+ _, err := c.forge.Repos.Update(c.ctx, domain.Root(), repo, ports.UpdateRepoParams{AllowAutoMerge: &allow})
40+ require.NoError(t, err)
41+ headID, err := domain.ParseObjectID(head)
42+ require.NoError(t, err)
43+ events := c.forge.Bus.Subscribe(c.ctx)
44+ enabled, err := c.forge.MergeRequests.EnableAutoMerge(c.ctx, domain.Owner(), repo, number, ports.EnableAutoMergeParams{HeadID: headID})
45+ require.NoError(t, err)
46+ require.NotNil(t, enabled.AutoMerge)
47+ assert.Equal(t, head, enabled.AutoMerge.HeadID.String())
48+ enabledDeadline := time.After(5 * time.Second)
49+waitEnabled:
50+ for {
51+ select {
52+ case ev := <-events:
53+ if enabled, ok := ev.(domain.MergeRequestAutoMergeEnabled); ok && enabled.MergeRequest.Repo == repo && enabled.MergeRequest.Number == number {
54+ break waitEnabled
55+ }
56+ case <-enabledDeadline:
57+ t.Fatal("no enabled event")
58+ }
59+ }
60+ c.finish(runner)
61+ // A successful push check alone must not merge while the pull run is queued.
62+ got, err := c.forge.MergeRequests.Get(c.ctx, domain.Root(), repo, number, ports.GetMergeRequestOptions{})
63+ require.NoError(t, err)
64+ assert.True(t, got.MergeRequest.IsOpen())
65+ c.finish(runner)
66+ deadline := time.After(10 * time.Second)
67+ for {
68+ select {
69+ case ev := <-events:
70+ if merged, ok := ev.(domain.MergeRequestMerged); ok && merged.MergeRequest.Number == number {
71+ assert.True(t, merged.Auto)
72+ assert.True(t, merged.MergeRequest.AutoMerged)
73+ assert.Nil(t, merged.MergeRequest.AutoMerge)
74+ return
75+ }
76+ case <-deadline:
77+ t.Fatal("no automatic merged event")
78+ }
79+ }
80+}
81+
82+// TestAutoMergeEndToEndAPI enables auto-merge through the API client, lets CI
83+// succeed, and reads the Watch stream: enabled, then merged with auto=true.
84+func TestAutoMergeEndToEndAPI(t *testing.T) {
85+ c := newCIHarness(t, nil)
86+ runner := c.registerRunner("alpha", "ubuntu-latest")
87+ c.push("main", map[string]string{".github/workflows/ci.yml": `name: ci
88+on:
89+ push:
90+ branches: [feat]
91+ pull_request:
92+jobs:
93+ build:
94+ runs-on: ubuntu-latest
95+ steps:
96+ - run: echo hello
97+`}, "")
98+ head := c.push("feat", map[string]string{"change.txt": "feature\n"}, "")
99+ number := c.openMR(c.root, "feat")
100+
101+ // Off by default: the server refuses.
102+ _, err := c.root.mr.EnableAutoMerge(c.ctx, connect.NewRequest(&pb.EnableAutoMergeRequest{Repo: c.repo, Number: number, HeadId: head}))
103+ require.Error(t, err)
104+ assert.Equal(t, connect.CodeFailedPrecondition, connect.CodeOf(err))
105+
106+ ver, err := c.root.server.GetVersion(c.ctx, connect.NewRequest(&pb.GetVersionRequest{}))
107+ require.NoError(t, err)
108+ assert.Contains(t, ver.Msg.GetFeatures(), "merge_requests.auto_merge")
109+
110+ up, err := c.root.repo.UpdateRepo(c.ctx, connect.NewRequest(&pb.UpdateRepoRequest{
111+ Repo: c.repo,
112+ Settings: &pb.RepoSettings{AllowAutoMerge: true},
113+ UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"settings.allow_auto_merge"}},
114+ }))
115+ require.NoError(t, err)
116+ assert.True(t, up.Msg.GetRepo().GetSettings().GetAllowAutoMerge())
117+
118+ w := c.watch(c.root, "", "home")
119+ res, err := c.root.mr.EnableAutoMerge(c.ctx, connect.NewRequest(&pb.EnableAutoMergeRequest{
120+ Repo: c.repo, Number: number, Mode: pb.MergeMode_MERGE_MODE_SQUASH, HeadId: head,
121+ }))
122+ require.NoError(t, err)
123+ am := res.Msg.GetMergeRequest().GetAutoMerge()
124+ require.NotNil(t, am)
125+ assert.Equal(t, pb.MergeMode_MERGE_MODE_SQUASH, am.GetMode())
126+ assert.Equal(t, res.Msg.GetMergeRequest().GetHeadId(), am.GetHeadId())
127+ assert.NotEmpty(t, am.GetEnabledBy())
128+ assert.NotNil(t, am.GetEnabledAt())
129+
130+ got, err := c.root.mr.GetMergeRequest(c.ctx, connect.NewRequest(&pb.GetMergeRequestRequest{Repo: c.repo, Number: number}))
131+ require.NoError(t, err)
132+ assert.NotNil(t, got.Msg.GetMergeRequest().GetAutoMerge(), "Get reports the pending auto-merge")
133+
134+ c.finish(runner)
135+ c.finish(runner)
136+
137+ var sawEnabled bool
138+ deadline := time.After(15 * time.Second)
139+ for {
140+ select {
141+ case ev, ok := <-w.events:
142+ require.True(t, ok, "the stream ended: %v", w.err)
143+ if e := ev.GetMergeRequestAutoMergeEnabled(); e != nil {
144+ assert.EqualValues(t, number, e.GetMergeRequest().GetNumber())
145+ assert.NotNil(t, e.GetMergeRequest().GetAutoMerge())
146+ sawEnabled = true
147+ }
148+ if m := ev.GetMergeRequestMerged(); m != nil && m.GetMergeRequest().GetNumber() == number {
149+ assert.True(t, sawEnabled, "enabled comes before merged")
150+ assert.True(t, m.GetAuto())
151+ assert.True(t, m.GetMergeRequest().GetAutoMerged())
152+ assert.Nil(t, m.GetMergeRequest().GetAutoMerge())
153+ final, err := c.root.mr.GetMergeRequest(c.ctx, connect.NewRequest(&pb.GetMergeRequestRequest{Repo: c.repo, Number: number}))
154+ require.NoError(t, err)
155+ assert.Equal(t, pb.MergeRequestState_MERGE_REQUEST_STATE_MERGED, final.Msg.GetMergeRequest().GetState())
156+ assert.True(t, final.Msg.GetMergeRequest().GetAutoMerged())
157+ return
158+ }
159+ case <-deadline:
160+ t.Fatal("no automatic merged event")
161+ }
162+ }
163+}
@@ -0,0 +1,163 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "testing"
7+ "time"
8+
9+ "connectrpc.com/connect"
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+ "google.golang.org/protobuf/types/known/fieldmaskpb"
13+
14+ pb "bnema.dev/ermite-api/gen/ermite"
15+ "bnema.dev/ermite/internal/domain"
16+ "bnema.dev/ermite/internal/ports"
17+)
18+
19+// This test exercises the composition root through use cases and domain
20+// events; TestAutoMergeEndToEndAPI does the same through the RPC API.
21+func TestAutoMergeEndToEnd(t *testing.T) {
22+ c := newCIHarness(t, nil)
23+ runner := c.registerRunner("alpha", "ubuntu-latest")
24+ c.push("main", map[string]string{".github/workflows/ci.yml": `name: ci
25+on:
26+ push:
27+ branches: [feat]
28+ pull_request:
29+jobs:
30+ build:
31+ runs-on: ubuntu-latest
32+ steps:
33+ - run: echo hello
34+`}, "")
35+ head := c.push("feat", map[string]string{"change.txt": "feature\n"}, "")
36+ number := c.openMR(c.root, "feat")
37+ repo := c.name("proj")
38+ allow := true
39+ _, err := c.forge.Repos.Update(c.ctx, domain.Root(), repo, ports.UpdateRepoParams{AllowAutoMerge: &allow})
40+ require.NoError(t, err)
41+ headID, err := domain.ParseObjectID(head)
42+ require.NoError(t, err)
43+ events := c.forge.Bus.Subscribe(c.ctx)
44+ enabled, err := c.forge.MergeRequests.EnableAutoMerge(c.ctx, domain.Owner(), repo, number, ports.EnableAutoMergeParams{HeadID: headID})
45+ require.NoError(t, err)
46+ require.NotNil(t, enabled.AutoMerge)
47+ assert.Equal(t, head, enabled.AutoMerge.HeadID.String())
48+ enabledDeadline := time.After(5 * time.Second)
49+waitEnabled:
50+ for {
51+ select {
52+ case ev := <-events:
53+ if enabled, ok := ev.(domain.MergeRequestAutoMergeEnabled); ok && enabled.MergeRequest.Repo == repo && enabled.MergeRequest.Number == number {
54+ break waitEnabled
55+ }
56+ case <-enabledDeadline:
57+ t.Fatal("no enabled event")
58+ }
59+ }
60+ c.finish(runner)
61+ // A successful push check alone must not merge while the pull run is queued.
62+ got, err := c.forge.MergeRequests.Get(c.ctx, domain.Root(), repo, number, ports.GetMergeRequestOptions{})
63+ require.NoError(t, err)
64+ assert.True(t, got.MergeRequest.IsOpen())
65+ c.finish(runner)
66+ deadline := time.After(10 * time.Second)
67+ for {
68+ select {
69+ case ev := <-events:
70+ if merged, ok := ev.(domain.MergeRequestMerged); ok && merged.MergeRequest.Number == number {
71+ assert.True(t, merged.Auto)
72+ assert.True(t, merged.MergeRequest.AutoMerged)
73+ assert.Nil(t, merged.MergeRequest.AutoMerge)
74+ return
75+ }
76+ case <-deadline:
77+ t.Fatal("no automatic merged event")
78+ }
79+ }
80+}
81+
82+// TestAutoMergeEndToEndAPI enables auto-merge through the API client, lets CI
83+// succeed, and reads the Watch stream: enabled, then merged with auto=true.
84+func TestAutoMergeEndToEndAPI(t *testing.T) {
85+ c := newCIHarness(t, nil)
86+ runner := c.registerRunner("alpha", "ubuntu-latest")
87+ c.push("main", map[string]string{".github/workflows/ci.yml": `name: ci
88+on:
89+ push:
90+ branches: [feat]
91+ pull_request:
92+jobs:
93+ build:
94+ runs-on: ubuntu-latest
95+ steps:
96+ - run: echo hello
97+`}, "")
98+ head := c.push("feat", map[string]string{"change.txt": "feature\n"}, "")
99+ number := c.openMR(c.root, "feat")
100+
101+ // Off by default: the server refuses.
102+ _, err := c.root.mr.EnableAutoMerge(c.ctx, connect.NewRequest(&pb.EnableAutoMergeRequest{Repo: c.repo, Number: number, HeadId: head}))
103+ require.Error(t, err)
104+ assert.Equal(t, connect.CodeFailedPrecondition, connect.CodeOf(err))
105+
106+ ver, err := c.root.server.GetVersion(c.ctx, connect.NewRequest(&pb.GetVersionRequest{}))
107+ require.NoError(t, err)
108+ assert.Contains(t, ver.Msg.GetFeatures(), "merge_requests.auto_merge")
109+
110+ up, err := c.root.repo.UpdateRepo(c.ctx, connect.NewRequest(&pb.UpdateRepoRequest{
111+ Repo: c.repo,
112+ Settings: &pb.RepoSettings{AllowAutoMerge: true},
113+ UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"settings.allow_auto_merge"}},
114+ }))
115+ require.NoError(t, err)
116+ assert.True(t, up.Msg.GetRepo().GetSettings().GetAllowAutoMerge())
117+
118+ w := c.watch(c.root, "", "home")
119+ res, err := c.root.mr.EnableAutoMerge(c.ctx, connect.NewRequest(&pb.EnableAutoMergeRequest{
120+ Repo: c.repo, Number: number, Mode: pb.MergeMode_MERGE_MODE_SQUASH, HeadId: head,
121+ }))
122+ require.NoError(t, err)
123+ am := res.Msg.GetMergeRequest().GetAutoMerge()
124+ require.NotNil(t, am)
125+ assert.Equal(t, pb.MergeMode_MERGE_MODE_SQUASH, am.GetMode())
126+ assert.Equal(t, res.Msg.GetMergeRequest().GetHeadId(), am.GetHeadId())
127+ assert.NotEmpty(t, am.GetEnabledBy())
128+ assert.NotNil(t, am.GetEnabledAt())
129+
130+ got, err := c.root.mr.GetMergeRequest(c.ctx, connect.NewRequest(&pb.GetMergeRequestRequest{Repo: c.repo, Number: number}))
131+ require.NoError(t, err)
132+ assert.NotNil(t, got.Msg.GetMergeRequest().GetAutoMerge(), "Get reports the pending auto-merge")
133+
134+ c.finish(runner)
135+ c.finish(runner)
136+
137+ var sawEnabled bool
138+ deadline := time.After(15 * time.Second)
139+ for {
140+ select {
141+ case ev, ok := <-w.events:
142+ require.True(t, ok, "the stream ended: %v", w.err)
143+ if e := ev.GetMergeRequestAutoMergeEnabled(); e != nil {
144+ assert.EqualValues(t, number, e.GetMergeRequest().GetNumber())
145+ assert.NotNil(t, e.GetMergeRequest().GetAutoMerge())
146+ sawEnabled = true
147+ }
148+ if m := ev.GetMergeRequestMerged(); m != nil && m.GetMergeRequest().GetNumber() == number {
149+ assert.True(t, sawEnabled, "enabled comes before merged")
150+ assert.True(t, m.GetAuto())
151+ assert.True(t, m.GetMergeRequest().GetAutoMerged())
152+ assert.Nil(t, m.GetMergeRequest().GetAutoMerge())
153+ final, err := c.root.mr.GetMergeRequest(c.ctx, connect.NewRequest(&pb.GetMergeRequestRequest{Repo: c.repo, Number: number}))
154+ require.NoError(t, err)
155+ assert.Equal(t, pb.MergeRequestState_MERGE_REQUEST_STATE_MERGED, final.Msg.GetMergeRequest().GetState())
156+ assert.True(t, final.Msg.GetMergeRequest().GetAutoMerged())
157+ return
158+ }
159+ case <-deadline:
160+ t.Fatal("no automatic merged event")
161+ }
162+ }
163+}
M internal/app/cli_e2e_test.go +2 -2
@@ -203,7 +203,7 @@ type eventOut struct {
203203 MergeRequest *mrOut `json:"merge_request"`
204204 }
205205
206-var repoKeys = []string{"created_at", "default_branch", "description", "failing_checks_block", "issues", "merge_modes", "name", "pushed_at", "topics", "visibility"}
206+var repoKeys = []string{"allow_auto_merge", "created_at", "default_branch", "description", "failing_checks_block", "issues", "merge_modes", "name", "pushed_at", "topics", "visibility"}
207207
208208 // AC6: the CLI end to end against a real forge.
209209 func TestAC6CLIEndToEnd(t *testing.T) {
@@ -350,7 +350,7 @@ func TestAC6CLIEndToEnd(t *testing.T) {
350350 ver := decode[versionOut](t, res.stdout)
351351 assert.Equal(t, c.url, ver.Server.URL)
352352 assert.NotEmpty(t, ver.Server.Version)
353- assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "issues.links", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures", "mirror"}, ver.Server.Features)
353+ assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "issues.links", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures", "mirror", "merge_requests.auto_merge"}, ver.Server.Features)
354354 assert.Equal(t, []string{"passkeys"}, ver.Server.MissingFeatures)
355355 stdout, stderr, code = c.bin(t, secret, "version")
356356 require.Equal(t, 0, code, stderr)
@@ -203,7 +203,7 @@ type eventOut struct {
203 MergeRequest *mrOut `json:"merge_request"`203 MergeRequest *mrOut `json:"merge_request"`
204 }204 }
205 205
206-var repoKeys = []string{"created_at", "default_branch", "description", "failing_checks_block", "issues", "merge_modes", "name", "pushed_at", "topics", "visibility"}206+var repoKeys = []string{"allow_auto_merge", "created_at", "default_branch", "description", "failing_checks_block", "issues", "merge_modes", "name", "pushed_at", "topics", "visibility"}
207 207
208 // AC6: the CLI end to end against a real forge.208 // AC6: the CLI end to end against a real forge.
209 func TestAC6CLIEndToEnd(t *testing.T) {209 func TestAC6CLIEndToEnd(t *testing.T) {
@@ -350,7 +350,7 @@ func TestAC6CLIEndToEnd(t *testing.T) {
350 ver := decode[versionOut](t, res.stdout)350 ver := decode[versionOut](t, res.stdout)
351 assert.Equal(t, c.url, ver.Server.URL)351 assert.Equal(t, c.url, ver.Server.URL)
352 assert.NotEmpty(t, ver.Server.Version)352 assert.NotEmpty(t, ver.Server.Version)
353- assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "issues.links", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures", "mirror"}, ver.Server.Features)353+ assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "issues.links", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures", "mirror", "merge_requests.auto_merge"}, ver.Server.Features)
354 assert.Equal(t, []string{"passkeys"}, ver.Server.MissingFeatures)354 assert.Equal(t, []string{"passkeys"}, ver.Server.MissingFeatures)
355 stdout, stderr, code = c.bin(t, secret, "version")355 stdout, stderr, code = c.bin(t, secret, "version")
356 require.Equal(t, 0, code, stderr)356 require.Equal(t, 0, code, stderr)
M internal/app/cli_fake_test.go +8 -0
M internal/app/cli_mr.go +95 -3
M internal/app/cli_mr_e2e_test.go +1 -1
M internal/app/cli_mr_test.go +112 -1
M internal/app/cli_repo.go +17 -6
M internal/app/cli_repo_test.go +34 -4
M internal/app/cli_version.go +1 -0
M internal/app/cli_version_test.go +5 -5
M internal/app/cli_watch.go +25 -3
M internal/app/cli_watch_test.go +55 -1
M internal/app/forge.go +13 -0
M internal/domain/event.go +28 -0
M internal/domain/event_test.go +7 -0
M internal/domain/mergerequest.go +21 -0
M internal/domain/mergerequest_test.go +6 -0
M internal/ports/ci.go +20 -0
M internal/ports/dto.go +11 -0
A internal/ports/mocks/AutoMergeTrigger.go +140 -0
A internal/ports/mocks/CIActivity.go +111 -0
A internal/ports/mocks/MergeGate.go +111 -0
M internal/ports/mocks/MergeRequestService.go +162 -0
M internal/ports/ports.go +7 -0
M internal/ports/services.go +5 -0
A internal/usecase/automerge.go +413 -0
A internal/usecase/automerge_index_test.go +97 -0
A internal/usecase/automerge_test.go +453 -0
M internal/usecase/ci.go +61 -2
M internal/usecase/ci_actions.go +22 -14
A internal/usecase/ci_activity_test.go +72 -0
M internal/usecase/ci_github_test.go +21 -0
M internal/usecase/ci_githubapi.go +1 -1
M internal/usecase/ci_pull.go +6 -0
M internal/usecase/ci_trigger.go +41 -6
M internal/usecase/events.go +9 -0
M internal/usecase/export_test.go +48 -0
M internal/usecase/issue_links_test.go +27 -0
M internal/usecase/mergeability.go +18 -6
M internal/usecase/mergerequests.go +93 -18
A internal/usecase/mergerequests_auto.go +168 -0
M internal/usecase/repos.go +14 -6
M internal/usecase/repos_write.go +19 -4
M internal/usecase/repos_write_test.go +17 -0
M proto/ermite/storage/merge_request.proto +15 -0
M skills/ermite/CLI.md +4 -2
M skills/ermite/SKILL.md +2 -0

Comments0 open / 0

no comments yet.

Keyboard shortcuts

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

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

Branches 2

Tags 41

↑↓ moveenter openesc close