detached c50acea

Merge b2cf6d408bd6954d5250b7f8a28d0a637fc9636e into 3ed746903cd6a04584ae699b9d178cd5e6d53513

commit
c50acea7b263e689b266f27b1a483e9a82d4e203
author
ermite · · 2026-10-21 04:17 UTC
merge of
3ed7469 + b2cf6d4 · diff against the first parent
signature
unsigned
69 files · +3967 -492
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>
@@ -99,6 +99,7 @@ Mirrors push repositories to a remote over SSH, never forcing. See [Push mirror]
9999 - `--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`.
100100 - `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`.
101101 - `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 --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).
102103 - Errors are one line on stderr (`ermite: …`) with exit status 1.
103104 - Shell completion completes repository names from the server.
104105
M docs/merge-requests.md +14 -0
@@ -39,6 +39,20 @@ git -c gpg.format=ssh -c gpg.ssh.allowedSignersFile=allowed_signers verify-commi
3939
4040 The merge box of the web page and the output of `mr merge --mode squash|merge` repeat this with the fingerprint.
4141
42+## Auto-merge
43+
44+A merge request can merge by itself once its checks passed.
45+
46+- The repository setting `allow_auto_merge` is off by default: `ermite repo edit <repo> --allow-auto-merge` (`=false` to turn it off).
47+- 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.
48+- 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.
49+- 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.
50+- 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.
51+- 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).
52+- 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`.
53+- 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>`.
54+- Cancel it with `ermite mr merge <repo> <n> --cancel-auto` or **Cancel auto-merge** in the web merge box.
55+
4256 ## Mergeability
4357
4458 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 generated file: collapsed
M go.mod +1 -1
@@ -13,7 +13,7 @@ tool (
1313
1414 require (
1515 bnema.dev/ermite-actions v0.2.0
16- bnema.dev/ermite-api v0.12.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
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 }
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.
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 }
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) {
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", "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", "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.
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)}}
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}
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+}
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 }
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+}
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:
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
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 := ""
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>
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) {
M internal/adapters/web/mr_templ.go +554 -350
@@ -2571,185 +2571,389 @@ func mergeMenu(p MRPage) templ.Component {
25712571 if templ_7745c5c3_Err != nil {
25722572 return templ_7745c5c3_Err
25732573 }
2574- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 240, " <form class=\"d-acts\" method=\"post\" action=\"")
2574+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 240, " ")
25752575 if templ_7745c5c3_Err != nil {
25762576 return templ_7745c5c3_Err
25772577 }
2578- var templ_7745c5c3_Var120 templ.SafeURL
2579- templ_7745c5c3_Var120, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Box.MergeAction))
2578+ if p.Box.AutoMerge != nil {
2579+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 241, "<form class=\"d-acts\" method=\"post\" action=\"")
2580+ if templ_7745c5c3_Err != nil {
2581+ return templ_7745c5c3_Err
2582+ }
2583+ var templ_7745c5c3_Var120 templ.SafeURL
2584+ templ_7745c5c3_Var120, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Box.CancelAutoAction))
2585+ if templ_7745c5c3_Err != nil {
2586+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 512, Col: 86}
2587+ }
2588+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var120))
2589+ if templ_7745c5c3_Err != nil {
2590+ return templ_7745c5c3_Err
2591+ }
2592+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 242, "\"><p class=\"dim\" role=\"status\">Auto-merge: ")
2593+ if templ_7745c5c3_Err != nil {
2594+ return templ_7745c5c3_Err
2595+ }
2596+ var templ_7745c5c3_Var121 string
2597+ templ_7745c5c3_Var121, templ_7745c5c3_Err = templ.JoinStringErrs(p.Box.AutoMerge.Mode)
2598+ if templ_7745c5c3_Err != nil {
2599+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 514, Col: 41}
2600+ }
2601+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var121))
2602+ if templ_7745c5c3_Err != nil {
2603+ return templ_7745c5c3_Err
2604+ }
2605+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 243, " when ready, enabled by ")
2606+ if templ_7745c5c3_Err != nil {
2607+ return templ_7745c5c3_Err
2608+ }
2609+ var templ_7745c5c3_Var122 string
2610+ templ_7745c5c3_Var122, templ_7745c5c3_Err = templ.JoinStringErrs(p.Box.AutoMerge.EnabledBy)
2611+ if templ_7745c5c3_Err != nil {
2612+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 514, Col: 94}
2613+ }
2614+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var122))
2615+ if templ_7745c5c3_Err != nil {
2616+ return templ_7745c5c3_Err
2617+ }
2618+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 244, " ")
2619+ if templ_7745c5c3_Err != nil {
2620+ return templ_7745c5c3_Err
2621+ }
2622+ if p.Box.AutoMerge.When != "" {
2623+ var templ_7745c5c3_Var123 string
2624+ templ_7745c5c3_Var123, templ_7745c5c3_Err = templ.JoinStringErrs(" ")
2625+ if templ_7745c5c3_Err != nil {
2626+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 516, Col: 13}
2627+ }
2628+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var123))
2629+ if templ_7745c5c3_Err != nil {
2630+ return templ_7745c5c3_Err
2631+ }
2632+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 245, "<time datetime=\"")
2633+ if templ_7745c5c3_Err != nil {
2634+ return templ_7745c5c3_Err
2635+ }
2636+ var templ_7745c5c3_Var124 string
2637+ templ_7745c5c3_Var124, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Box.AutoMerge.WhenAt)
2638+ if templ_7745c5c3_Err != nil {
2639+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 516, Col: 54}
2640+ }
2641+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var124)
2642+ if templ_7745c5c3_Err != nil {
2643+ return templ_7745c5c3_Err
2644+ }
2645+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 246, "\">")
2646+ if templ_7745c5c3_Err != nil {
2647+ return templ_7745c5c3_Err
2648+ }
2649+ var templ_7745c5c3_Var125 string
2650+ templ_7745c5c3_Var125, templ_7745c5c3_Err = templ.JoinStringErrs(p.Box.AutoMerge.When)
2651+ if templ_7745c5c3_Err != nil {
2652+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 516, Col: 79}
2653+ }
2654+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var125))
2655+ if templ_7745c5c3_Err != nil {
2656+ return templ_7745c5c3_Err
2657+ }
2658+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 247, "</time> ")
2659+ if templ_7745c5c3_Err != nil {
2660+ return templ_7745c5c3_Err
2661+ }
2662+ }
2663+ var templ_7745c5c3_Var126 string
2664+ templ_7745c5c3_Var126, templ_7745c5c3_Err = templ.JoinStringErrs(" ")
2665+ if templ_7745c5c3_Err != nil {
2666+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 518, Col: 12}
2667+ }
2668+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var126))
2669+ if templ_7745c5c3_Err != nil {
2670+ return templ_7745c5c3_Err
2671+ }
2672+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 248, "(head ")
2673+ if templ_7745c5c3_Err != nil {
2674+ return templ_7745c5c3_Err
2675+ }
2676+ var templ_7745c5c3_Var127 string
2677+ templ_7745c5c3_Var127, templ_7745c5c3_Err = templ.JoinStringErrs(p.Box.AutoMerge.HeadShort)
2678+ if templ_7745c5c3_Err != nil {
2679+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 518, Col: 47}
2680+ }
2681+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var127))
2682+ if templ_7745c5c3_Err != nil {
2683+ return templ_7745c5c3_Err
2684+ }
2685+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 249, ")</p><div class=\"d-btns\"><button type=\"submit\" class=\"btn\">Cancel auto-merge</button></div></form>")
2686+ if templ_7745c5c3_Err != nil {
2687+ return templ_7745c5c3_Err
2688+ }
2689+ }
2690+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 250, " <form class=\"d-acts\" method=\"post\" action=\"")
2691+ if templ_7745c5c3_Err != nil {
2692+ return templ_7745c5c3_Err
2693+ }
2694+ var templ_7745c5c3_Var128 templ.SafeURL
2695+ templ_7745c5c3_Var128, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Box.MergeAction))
25802696 if templ_7745c5c3_Err != nil {
2581- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 511, Col: 80}
2697+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 525, Col: 80}
25822698 }
2583- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var120))
2699+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var128))
25842700 if templ_7745c5c3_Err != nil {
25852701 return templ_7745c5c3_Err
25862702 }
2587- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 241, "\"><input type=\"hidden\" name=\"head_id\" value=\"")
2703+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 251, "\"><input type=\"hidden\" name=\"head_id\" value=\"")
25882704 if templ_7745c5c3_Err != nil {
25892705 return templ_7745c5c3_Err
25902706 }
2591- var templ_7745c5c3_Var121 string
2592- templ_7745c5c3_Var121, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Box.Head)
2707+ var templ_7745c5c3_Var129 string
2708+ templ_7745c5c3_Var129, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Box.Head)
25932709 if templ_7745c5c3_Err != nil {
2594- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 512, Col: 59}
2710+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 526, Col: 59}
25952711 }
2596- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var121)
2712+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var129)
25972713 if templ_7745c5c3_Err != nil {
25982714 return templ_7745c5c3_Err
25992715 }
2600- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 242, "\"><fieldset class=\"d-modes\"><legend class=\"sr\">merge mode</legend> ")
2716+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 252, "\"><fieldset class=\"d-modes\"><legend class=\"sr\">merge mode</legend> ")
26012717 if templ_7745c5c3_Err != nil {
26022718 return templ_7745c5c3_Err
26032719 }
26042720 for i, m := range p.Box.Modes {
2605- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 243, "<label><input type=\"radio\" name=\"mode\" value=\"")
2721+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 253, "<label><input type=\"radio\" name=\"mode\" value=\"")
26062722 if templ_7745c5c3_Err != nil {
26072723 return templ_7745c5c3_Err
26082724 }
2609- var templ_7745c5c3_Var122 string
2610- templ_7745c5c3_Var122, templ_7745c5c3_Err = templ.ResolveAttributeValue(m.Value)
2725+ var templ_7745c5c3_Var130 string
2726+ templ_7745c5c3_Var130, templ_7745c5c3_Err = templ.ResolveAttributeValue(m.Value)
26112727 if templ_7745c5c3_Err != nil {
2612- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 520, Col: 24}
2728+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 534, Col: 24}
26132729 }
2614- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var122)
2730+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var130)
26152731 if templ_7745c5c3_Err != nil {
26162732 return templ_7745c5c3_Err
26172733 }
2618- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 244, "\"")
2734+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 254, "\"")
26192735 if templ_7745c5c3_Err != nil {
26202736 return templ_7745c5c3_Err
26212737 }
26222738 if i == p.Box.Checked() {
2623- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 245, " checked")
2739+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 255, " checked")
26242740 if templ_7745c5c3_Err != nil {
26252741 return templ_7745c5c3_Err
26262742 }
26272743 }
26282744 if m.Disabled {
2629- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 246, " disabled")
2745+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 256, " disabled")
26302746 if templ_7745c5c3_Err != nil {
26312747 return templ_7745c5c3_Err
26322748 }
26332749 }
2634- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 247, " title=\"")
2750+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 257, " title=\"")
26352751 if templ_7745c5c3_Err != nil {
26362752 return templ_7745c5c3_Err
26372753 }
2638- var templ_7745c5c3_Var123 string
2639- templ_7745c5c3_Var123, templ_7745c5c3_Err = templ.ResolveAttributeValue(m.Reason)
2754+ var templ_7745c5c3_Var131 string
2755+ templ_7745c5c3_Var131, templ_7745c5c3_Err = templ.ResolveAttributeValue(m.Reason)
26402756 if templ_7745c5c3_Err != nil {
2641- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 523, Col: 25}
2757+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 537, Col: 25}
26422758 }
2643- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var123)
2759+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var131)
26442760 if templ_7745c5c3_Err != nil {
26452761 return templ_7745c5c3_Err
26462762 }
2647- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 248, "\" :disabled=\"this.value === 'fast-forward' ? q('[data-mergeability]').data.ffOk !== true : q('[data-mergeability]').data.mergeOk !== true\" :title=\"this.value === 'fast-forward' ? q('[data-mergeability]').data.ffReason : q('[data-mergeability]').data.mergeReason\"> ")
2763+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 258, "\" :disabled=\"this.value === 'fast-forward' ? q('[data-mergeability]').data.ffOk !== true : q('[data-mergeability]').data.mergeOk !== true\" :title=\"this.value === 'fast-forward' ? q('[data-mergeability]').data.ffReason : q('[data-mergeability]').data.mergeReason\"> ")
26482764 if templ_7745c5c3_Err != nil {
26492765 return templ_7745c5c3_Err
26502766 }
2651- var templ_7745c5c3_Var124 string
2652- templ_7745c5c3_Var124, templ_7745c5c3_Err = templ.JoinStringErrs(m.Label)
2767+ var templ_7745c5c3_Var132 string
2768+ templ_7745c5c3_Var132, templ_7745c5c3_Err = templ.JoinStringErrs(m.Label)
26532769 if templ_7745c5c3_Err != nil {
2654- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 527, Col: 17}
2770+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 541, Col: 17}
26552771 }
2656- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var124))
2772+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var132))
26572773 if templ_7745c5c3_Err != nil {
26582774 return templ_7745c5c3_Err
26592775 }
2660- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 249, " ")
2776+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 259, " ")
26612777 if templ_7745c5c3_Err != nil {
26622778 return templ_7745c5c3_Err
26632779 }
26642780 if i < 9 {
2665- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 250, "<kbd aria-hidden=\"true\">")
2781+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 260, "<kbd aria-hidden=\"true\">")
26662782 if templ_7745c5c3_Err != nil {
26672783 return templ_7745c5c3_Err
26682784 }
2669- var templ_7745c5c3_Var125 string
2670- templ_7745c5c3_Var125, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(i + 1))
2785+ var templ_7745c5c3_Var133 string
2786+ templ_7745c5c3_Var133, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(i + 1))
26712787 if templ_7745c5c3_Err != nil {
2672- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 529, Col: 46}
2788+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 543, Col: 46}
26732789 }
2674- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var125))
2790+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var133))
26752791 if templ_7745c5c3_Err != nil {
26762792 return templ_7745c5c3_Err
26772793 }
2678- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 251, "</kbd>")
2794+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 261, "</kbd>")
26792795 if templ_7745c5c3_Err != nil {
26802796 return templ_7745c5c3_Err
26812797 }
26822798 }
2683- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 252, "</label>")
2799+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 262, "</label>")
26842800 if templ_7745c5c3_Err != nil {
26852801 return templ_7745c5c3_Err
26862802 }
26872803 }
2688- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 253, "</fieldset>")
2804+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 263, "</fieldset>")
26892805 if templ_7745c5c3_Err != nil {
26902806 return templ_7745c5c3_Err
26912807 }
26922808 if p.Box.CanDeleteBranch {
2693- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 254, "<label class=\"d-check\"><input type=\"checkbox\" name=\"delete_branch\" value=\"1\"> delete ")
2809+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 264, "<label class=\"d-check\"><input type=\"checkbox\" name=\"delete_branch\" value=\"1\"> delete ")
26942810 if templ_7745c5c3_Err != nil {
26952811 return templ_7745c5c3_Err
26962812 }
2697- var templ_7745c5c3_Var126 string
2698- templ_7745c5c3_Var126, templ_7745c5c3_Err = templ.JoinStringErrs(p.Source)
2813+ var templ_7745c5c3_Var134 string
2814+ templ_7745c5c3_Var134, templ_7745c5c3_Err = templ.JoinStringErrs(p.Source)
26992815 if templ_7745c5c3_Err != nil {
2700- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 535, Col: 102}
2816+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 549, Col: 102}
27012817 }
2702- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var126))
2818+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var134))
27032819 if templ_7745c5c3_Err != nil {
27042820 return templ_7745c5c3_Err
27052821 }
2706- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 255, " after the merge <kbd aria-hidden=\"true\">D</kbd></label>")
2822+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 265, " after the merge <kbd aria-hidden=\"true\">D</kbd></label>")
27072823 if templ_7745c5c3_Err != nil {
27082824 return templ_7745c5c3_Err
27092825 }
27102826 }
2711- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 256, "<div class=\"d-btns\"><button type=\"submit\" class=\"btn primary\" data-default")
2827+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 266, "<div class=\"d-btns\"><button type=\"submit\" class=\"btn primary\" data-default")
27122828 if templ_7745c5c3_Err != nil {
27132829 return templ_7745c5c3_Err
27142830 }
27152831 if !p.Box.CanMerge() {
2716- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 257, " disabled")
2832+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 267, " disabled")
27172833 if templ_7745c5c3_Err != nil {
27182834 return templ_7745c5c3_Err
27192835 }
27202836 }
2721- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 258, " title=\"")
2837+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 268, " title=\"")
27222838 if templ_7745c5c3_Err != nil {
27232839 return templ_7745c5c3_Err
27242840 }
2725- var templ_7745c5c3_Var127 string
2726- templ_7745c5c3_Var127, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Box.MergeTitle())
2841+ var templ_7745c5c3_Var135 string
2842+ templ_7745c5c3_Var135, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Box.MergeTitle())
27272843 if templ_7745c5c3_Err != nil {
2728- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 543, Col: 33}
2844+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 557, Col: 33}
27292845 }
2730- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var127)
2846+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var135)
27312847 if templ_7745c5c3_Err != nil {
27322848 return templ_7745c5c3_Err
27332849 }
2734- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 259, "\" :disabled=\"!this.form.querySelector('input[name=mode]:checked:not(:disabled)')\" :title=\"(this.form.querySelector('input[name=mode]:checked') || {}).title || ''\">Merge into ")
2850+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 269, "\" :disabled=\"!this.form.querySelector('input[name=mode]:checked:not(:disabled)')\" :title=\"(this.form.querySelector('input[name=mode]:checked') || {}).title || ''\">Merge into ")
27352851 if templ_7745c5c3_Err != nil {
27362852 return templ_7745c5c3_Err
27372853 }
2738- var templ_7745c5c3_Var128 string
2739- templ_7745c5c3_Var128, templ_7745c5c3_Err = templ.JoinStringErrs(p.Target)
2854+ var templ_7745c5c3_Var136 string
2855+ templ_7745c5c3_Var136, templ_7745c5c3_Err = templ.JoinStringErrs(p.Target)
27402856 if templ_7745c5c3_Err != nil {
2741- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 546, Col: 28}
2857+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 560, Col: 28}
27422858 }
2743- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var128))
2859+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var136))
27442860 if templ_7745c5c3_Err != nil {
27452861 return templ_7745c5c3_Err
27462862 }
2747- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 260, "</button></div></form>")
2863+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 270, "</button></div></form>")
27482864 if templ_7745c5c3_Err != nil {
27492865 return templ_7745c5c3_Err
27502866 }
2867+ if p.Box.CanAutoMerge {
2868+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 271, "<form class=\"d-acts\" method=\"post\" action=\"")
2869+ if templ_7745c5c3_Err != nil {
2870+ return templ_7745c5c3_Err
2871+ }
2872+ var templ_7745c5c3_Var137 templ.SafeURL
2873+ templ_7745c5c3_Var137, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Box.AutoAction))
2874+ if templ_7745c5c3_Err != nil {
2875+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 564, Col: 80}
2876+ }
2877+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var137))
2878+ if templ_7745c5c3_Err != nil {
2879+ return templ_7745c5c3_Err
2880+ }
2881+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 272, "\"><input type=\"hidden\" name=\"head_id\" value=\"")
2882+ if templ_7745c5c3_Err != nil {
2883+ return templ_7745c5c3_Err
2884+ }
2885+ var templ_7745c5c3_Var138 string
2886+ templ_7745c5c3_Var138, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Box.Head)
2887+ if templ_7745c5c3_Err != nil {
2888+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 565, Col: 60}
2889+ }
2890+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var138)
2891+ if templ_7745c5c3_Err != nil {
2892+ return templ_7745c5c3_Err
2893+ }
2894+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 273, "\"><fieldset class=\"d-modes\"><legend>Auto-merge mode</legend> ")
2895+ if templ_7745c5c3_Err != nil {
2896+ return templ_7745c5c3_Err
2897+ }
2898+ for i, m := range p.Box.Modes {
2899+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 274, "<label><input type=\"radio\" name=\"mode\" value=\"")
2900+ if templ_7745c5c3_Err != nil {
2901+ return templ_7745c5c3_Err
2902+ }
2903+ var templ_7745c5c3_Var139 string
2904+ templ_7745c5c3_Var139, templ_7745c5c3_Err = templ.ResolveAttributeValue(m.Value)
2905+ if templ_7745c5c3_Err != nil {
2906+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 569, Col: 62}
2907+ }
2908+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var139)
2909+ if templ_7745c5c3_Err != nil {
2910+ return templ_7745c5c3_Err
2911+ }
2912+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 275, "\"")
2913+ if templ_7745c5c3_Err != nil {
2914+ return templ_7745c5c3_Err
2915+ }
2916+ if i == 0 {
2917+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 276, " checked")
2918+ if templ_7745c5c3_Err != nil {
2919+ return templ_7745c5c3_Err
2920+ }
2921+ }
2922+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 277, "> ")
2923+ if templ_7745c5c3_Err != nil {
2924+ return templ_7745c5c3_Err
2925+ }
2926+ var templ_7745c5c3_Var140 string
2927+ templ_7745c5c3_Var140, templ_7745c5c3_Err = templ.JoinStringErrs(m.Label)
2928+ if templ_7745c5c3_Err != nil {
2929+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 569, Col: 96}
2930+ }
2931+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var140))
2932+ if templ_7745c5c3_Err != nil {
2933+ return templ_7745c5c3_Err
2934+ }
2935+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 278, "</label>")
2936+ if templ_7745c5c3_Err != nil {
2937+ return templ_7745c5c3_Err
2938+ }
2939+ }
2940+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 279, "</fieldset><div class=\"d-btns\"><button type=\"submit\" class=\"btn\"")
2941+ if templ_7745c5c3_Err != nil {
2942+ return templ_7745c5c3_Err
2943+ }
2944+ if len(p.Box.Modes) == 0 {
2945+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 280, " disabled")
2946+ if templ_7745c5c3_Err != nil {
2947+ return templ_7745c5c3_Err
2948+ }
2949+ }
2950+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 281, " title=\"merge once the checks pass\">Merge when ready</button></div></form>")
2951+ if templ_7745c5c3_Err != nil {
2952+ return templ_7745c5c3_Err
2953+ }
2954+ }
27512955 }
2752- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 261, "</div></details>")
2956+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 282, "</div></details>")
27532957 if templ_7745c5c3_Err != nil {
27542958 return templ_7745c5c3_Err
27552959 }
@@ -2775,182 +2979,182 @@ func MRMergeability(v MergeabilityView) templ.Component {
27752979 }()
27762980 }
27772981 ctx = templ.InitializeContext(ctx)
2778- templ_7745c5c3_Var129 := templ.GetChildren(ctx)
2779- if templ_7745c5c3_Var129 == nil {
2780- templ_7745c5c3_Var129 = templ.NopComponent
2982+ templ_7745c5c3_Var141 := templ.GetChildren(ctx)
2983+ if templ_7745c5c3_Var141 == nil {
2984+ templ_7745c5c3_Var141 = templ.NopComponent
27812985 }
27822986 ctx = templ.ClearChildren(ctx)
2783- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 262, "<span data-mergeability data-state=\"")
2987+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 283, "<span data-mergeability data-state=\"")
27842988 if templ_7745c5c3_Err != nil {
27852989 return templ_7745c5c3_Err
27862990 }
2787- var templ_7745c5c3_Var130 string
2788- templ_7745c5c3_Var130, templ_7745c5c3_Err = templ.ResolveAttributeValue(v.State)
2991+ var templ_7745c5c3_Var142 string
2992+ templ_7745c5c3_Var142, templ_7745c5c3_Err = templ.ResolveAttributeValue(v.State)
27892993 if templ_7745c5c3_Err != nil {
2790- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 559, Col: 22}
2994+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 587, Col: 22}
27912995 }
2792- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var130)
2996+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var142)
27932997 if templ_7745c5c3_Err != nil {
27942998 return templ_7745c5c3_Err
27952999 }
2796- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 263, "\" data-ff-ok=\"")
3000+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 284, "\" data-ff-ok=\"")
27973001 if templ_7745c5c3_Err != nil {
27983002 return templ_7745c5c3_Err
27993003 }
2800- var templ_7745c5c3_Var131 string
2801- templ_7745c5c3_Var131, templ_7745c5c3_Err = templ.ResolveAttributeValue(strconv.FormatBool(v.FFOk))
3004+ var templ_7745c5c3_Var143 string
3005+ templ_7745c5c3_Var143, templ_7745c5c3_Err = templ.ResolveAttributeValue(strconv.FormatBool(v.FFOk))
28023006 if templ_7745c5c3_Err != nil {
2803- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 560, Col: 41}
3007+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 588, Col: 41}
28043008 }
2805- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var131)
3009+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var143)
28063010 if templ_7745c5c3_Err != nil {
28073011 return templ_7745c5c3_Err
28083012 }
2809- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 264, "\" data-merge-ok=\"")
3013+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 285, "\" data-merge-ok=\"")
28103014 if templ_7745c5c3_Err != nil {
28113015 return templ_7745c5c3_Err
28123016 }
2813- var templ_7745c5c3_Var132 string
2814- templ_7745c5c3_Var132, templ_7745c5c3_Err = templ.ResolveAttributeValue(strconv.FormatBool(v.MergeOk))
3017+ var templ_7745c5c3_Var144 string
3018+ templ_7745c5c3_Var144, templ_7745c5c3_Err = templ.ResolveAttributeValue(strconv.FormatBool(v.MergeOk))
28153019 if templ_7745c5c3_Err != nil {
2816- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 561, Col: 47}
3020+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 589, Col: 47}
28173021 }
2818- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var132)
3022+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var144)
28193023 if templ_7745c5c3_Err != nil {
28203024 return templ_7745c5c3_Err
28213025 }
2822- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 265, "\" data-ff-reason=\"")
3026+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 286, "\" data-ff-reason=\"")
28233027 if templ_7745c5c3_Err != nil {
28243028 return templ_7745c5c3_Err
28253029 }
2826- var templ_7745c5c3_Var133 string
2827- templ_7745c5c3_Var133, templ_7745c5c3_Err = templ.ResolveAttributeValue(v.FFReason)
3030+ var templ_7745c5c3_Var145 string
3031+ templ_7745c5c3_Var145, templ_7745c5c3_Err = templ.ResolveAttributeValue(v.FFReason)
28283032 if templ_7745c5c3_Err != nil {
2829- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 562, Col: 29}
3033+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 590, Col: 29}
28303034 }
2831- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var133)
3035+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var145)
28323036 if templ_7745c5c3_Err != nil {
28333037 return templ_7745c5c3_Err
28343038 }
2835- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 266, "\" data-merge-reason=\"")
3039+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 287, "\" data-merge-reason=\"")
28363040 if templ_7745c5c3_Err != nil {
28373041 return templ_7745c5c3_Err
28383042 }
2839- var templ_7745c5c3_Var134 string
2840- templ_7745c5c3_Var134, templ_7745c5c3_Err = templ.ResolveAttributeValue(v.MergeReason)
3043+ var templ_7745c5c3_Var146 string
3044+ templ_7745c5c3_Var146, templ_7745c5c3_Err = templ.ResolveAttributeValue(v.MergeReason)
28413045 if templ_7745c5c3_Err != nil {
2842- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 563, Col: 35}
3046+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 591, Col: 35}
28433047 }
2844- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var134)
3048+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var146)
28453049 if templ_7745c5c3_Err != nil {
28463050 return templ_7745c5c3_Err
28473051 }
2848- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 267, "\"><span class=\"badge\" data-state=\"")
3052+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 288, "\"><span class=\"badge\" data-state=\"")
28493053 if templ_7745c5c3_Err != nil {
28503054 return templ_7745c5c3_Err
28513055 }
2852- var templ_7745c5c3_Var135 string
2853- templ_7745c5c3_Var135, templ_7745c5c3_Err = templ.ResolveAttributeValue(v.State)
3056+ var templ_7745c5c3_Var147 string
3057+ templ_7745c5c3_Var147, templ_7745c5c3_Err = templ.ResolveAttributeValue(v.State)
28543058 if templ_7745c5c3_Err != nil {
2855- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 565, Col: 42}
3059+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 593, Col: 42}
28563060 }
2857- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var135)
3061+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var147)
28583062 if templ_7745c5c3_Err != nil {
28593063 return templ_7745c5c3_Err
28603064 }
2861- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 268, "\">")
3065+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 289, "\">")
28623066 if templ_7745c5c3_Err != nil {
28633067 return templ_7745c5c3_Err
28643068 }
2865- var templ_7745c5c3_Var136 string
2866- templ_7745c5c3_Var136, templ_7745c5c3_Err = templ.JoinStringErrs(v.Label)
3069+ var templ_7745c5c3_Var148 string
3070+ templ_7745c5c3_Var148, templ_7745c5c3_Err = templ.JoinStringErrs(v.Label)
28673071 if templ_7745c5c3_Err != nil {
2868- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 565, Col: 54}
3072+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 593, Col: 54}
28693073 }
2870- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var136))
3074+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var148))
28713075 if templ_7745c5c3_Err != nil {
28723076 return templ_7745c5c3_Err
28733077 }
2874- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 269, "</span> ")
3078+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 290, "</span> ")
28753079 if templ_7745c5c3_Err != nil {
28763080 return templ_7745c5c3_Err
28773081 }
28783082 if v.Detail != "" {
2879- var templ_7745c5c3_Var137 string
2880- templ_7745c5c3_Var137, templ_7745c5c3_Err = templ.JoinStringErrs(" ")
3083+ var templ_7745c5c3_Var149 string
3084+ templ_7745c5c3_Var149, templ_7745c5c3_Err = templ.JoinStringErrs(" ")
28813085 if templ_7745c5c3_Err != nil {
2882- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 567, Col: 8}
3086+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 595, Col: 8}
28833087 }
2884- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var137))
3088+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var149))
28853089 if templ_7745c5c3_Err != nil {
28863090 return templ_7745c5c3_Err
28873091 }
2888- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 270, "<span class=\"dim\">")
3092+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 291, "<span class=\"dim\">")
28893093 if templ_7745c5c3_Err != nil {
28903094 return templ_7745c5c3_Err
28913095 }
2892- var templ_7745c5c3_Var138 string
2893- templ_7745c5c3_Var138, templ_7745c5c3_Err = templ.JoinStringErrs(v.Detail)
3096+ var templ_7745c5c3_Var150 string
3097+ templ_7745c5c3_Var150, templ_7745c5c3_Err = templ.JoinStringErrs(v.Detail)
28943098 if templ_7745c5c3_Err != nil {
2895- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 567, Col: 38}
3099+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 595, Col: 38}
28963100 }
2897- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var138))
3101+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var150))
28983102 if templ_7745c5c3_Err != nil {
28993103 return templ_7745c5c3_Err
29003104 }
2901- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 271, "</span>")
3105+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 292, "</span>")
29023106 if templ_7745c5c3_Err != nil {
29033107 return templ_7745c5c3_Err
29043108 }
29053109 }
2906- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 272, "</span> ")
3110+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 293, "</span> ")
29073111 if templ_7745c5c3_Err != nil {
29083112 return templ_7745c5c3_Err
29093113 }
29103114 if len(v.Paths) > 0 {
2911- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 273, "<ul class=\"mg-paths\" aria-label=\"conflicting files\">")
3115+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 294, "<ul class=\"mg-paths\" aria-label=\"conflicting files\">")
29123116 if templ_7745c5c3_Err != nil {
29133117 return templ_7745c5c3_Err
29143118 }
29153119 for _, path := range v.Paths {
2916- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 274, "<li><code>")
3120+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 295, "<li><code>")
29173121 if templ_7745c5c3_Err != nil {
29183122 return templ_7745c5c3_Err
29193123 }
2920- var templ_7745c5c3_Var139 string
2921- templ_7745c5c3_Var139, templ_7745c5c3_Err = templ.JoinStringErrs(path)
3124+ var templ_7745c5c3_Var151 string
3125+ templ_7745c5c3_Var151, templ_7745c5c3_Err = templ.JoinStringErrs(path)
29223126 if templ_7745c5c3_Err != nil {
2923- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 573, Col: 20}
3127+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 601, Col: 20}
29243128 }
2925- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var139))
3129+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var151))
29263130 if templ_7745c5c3_Err != nil {
29273131 return templ_7745c5c3_Err
29283132 }
2929- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 275, "</code></li>")
3133+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 296, "</code></li>")
29303134 if templ_7745c5c3_Err != nil {
29313135 return templ_7745c5c3_Err
29323136 }
29333137 }
2934- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 276, "</ul>")
3138+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 297, "</ul>")
29353139 if templ_7745c5c3_Err != nil {
29363140 return templ_7745c5c3_Err
29373141 }
29383142 }
29393143 if v.PathsNote != "" {
2940- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 277, "<p class=\"dim\">")
3144+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 298, "<p class=\"dim\">")
29413145 if templ_7745c5c3_Err != nil {
29423146 return templ_7745c5c3_Err
29433147 }
2944- var templ_7745c5c3_Var140 string
2945- templ_7745c5c3_Var140, templ_7745c5c3_Err = templ.JoinStringErrs(v.PathsNote)
3148+ var templ_7745c5c3_Var152 string
3149+ templ_7745c5c3_Var152, templ_7745c5c3_Err = templ.JoinStringErrs(v.PathsNote)
29463150 if templ_7745c5c3_Err != nil {
2947- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 578, Col: 30}
3151+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 606, Col: 30}
29483152 }
2949- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var140))
3153+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var152))
29503154 if templ_7745c5c3_Err != nil {
29513155 return templ_7745c5c3_Err
29523156 }
2953- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 278, "</p>")
3157+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 299, "</p>")
29543158 if templ_7745c5c3_Err != nil {
29553159 return templ_7745c5c3_Err
29563160 }
@@ -2977,12 +3181,12 @@ func mrMergeabilitySlot(v MergeabilityView) templ.Component {
29773181 }()
29783182 }
29793183 ctx = templ.InitializeContext(ctx)
2980- templ_7745c5c3_Var141 := templ.GetChildren(ctx)
2981- if templ_7745c5c3_Var141 == nil {
2982- templ_7745c5c3_Var141 = templ.NopComponent
3184+ templ_7745c5c3_Var153 := templ.GetChildren(ctx)
3185+ if templ_7745c5c3_Var153 == nil {
3186+ templ_7745c5c3_Var153 = templ.NopComponent
29833187 }
29843188 ctx = templ.ClearChildren(ctx)
2985- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 279, "<div id=\"mr-mergeability\" class=\"live-slot\" role=\"status\" data-slot>")
3189+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 300, "<div id=\"mr-mergeability\" class=\"live-slot\" role=\"status\" data-slot>")
29863190 if templ_7745c5c3_Err != nil {
29873191 return templ_7745c5c3_Err
29883192 }
@@ -2990,7 +3194,7 @@ func mrMergeabilitySlot(v MergeabilityView) templ.Component {
29903194 if templ_7745c5c3_Err != nil {
29913195 return templ_7745c5c3_Err
29923196 }
2993- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 280, "</div>")
3197+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 301, "</div>")
29943198 if templ_7745c5c3_Err != nil {
29953199 return templ_7745c5c3_Err
29963200 }
@@ -3015,9 +3219,9 @@ func MRMergeabilityFragment(v MergeabilityView) templ.Component {
30153219 }()
30163220 }
30173221 ctx = templ.InitializeContext(ctx)
3018- templ_7745c5c3_Var142 := templ.GetChildren(ctx)
3019- if templ_7745c5c3_Var142 == nil {
3020- templ_7745c5c3_Var142 = templ.NopComponent
3222+ templ_7745c5c3_Var154 := templ.GetChildren(ctx)
3223+ if templ_7745c5c3_Var154 == nil {
3224+ templ_7745c5c3_Var154 = templ.NopComponent
30213225 }
30223226 ctx = templ.ClearChildren(ctx)
30233227 templ_7745c5c3_Err = mrMergeabilitySlot(v).Render(ctx, templ_7745c5c3_Buffer)
@@ -3046,128 +3250,128 @@ func moreMenu(p MRPage) templ.Component {
30463250 }()
30473251 }
30483252 ctx = templ.InitializeContext(ctx)
3049- templ_7745c5c3_Var143 := templ.GetChildren(ctx)
3050- if templ_7745c5c3_Var143 == nil {
3051- templ_7745c5c3_Var143 = templ.NopComponent
3253+ templ_7745c5c3_Var155 := templ.GetChildren(ctx)
3254+ if templ_7745c5c3_Var155 == nil {
3255+ templ_7745c5c3_Var155 = templ.NopComponent
30523256 }
30533257 ctx = templ.ClearChildren(ctx)
3054- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 281, "<details class=\"menu omenu\"><summary class=\"btn icon\" aria-label=\"More actions\" title=\"more actions\">⋯</summary><div class=\"menu-pop\" role=\"group\" aria-label=\"More actions\"><section class=\"merge-settings\"><h2>Merge settings</h2><dl class=\"kv\"><dt>modes</dt><dd>")
3258+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 302, "<details class=\"menu omenu\"><summary class=\"btn icon\" aria-label=\"More actions\" title=\"more actions\">⋯</summary><div class=\"menu-pop\" role=\"group\" aria-label=\"More actions\"><section class=\"merge-settings\"><h2>Merge settings</h2><dl class=\"kv\"><dt>modes</dt><dd>")
30553259 if templ_7745c5c3_Err != nil {
30563260 return templ_7745c5c3_Err
30573261 }
3058- var templ_7745c5c3_Var144 string
3059- templ_7745c5c3_Var144, templ_7745c5c3_Err = templ.JoinStringErrs(strings.Join(p.Modes, ", "))
3262+ var templ_7745c5c3_Var156 string
3263+ templ_7745c5c3_Var156, templ_7745c5c3_Err = templ.JoinStringErrs(strings.Join(p.Modes, ", "))
30603264 if templ_7745c5c3_Err != nil {
3061- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 605, Col: 38}
3265+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 633, Col: 38}
30623266 }
3063- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var144))
3267+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var156))
30643268 if templ_7745c5c3_Err != nil {
30653269 return templ_7745c5c3_Err
30663270 }
3067- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 282, "</dd><dt>checks</dt><dd>")
3271+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 303, "</dd><dt>checks</dt><dd>")
30683272 if templ_7745c5c3_Err != nil {
30693273 return templ_7745c5c3_Err
30703274 }
30713275 if p.ChecksBlock {
3072- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 283, "failing ones block the merge")
3276+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 304, "failing ones block the merge")
30733277 if templ_7745c5c3_Err != nil {
30743278 return templ_7745c5c3_Err
30753279 }
30763280 } else {
3077- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 284, "do not block the merge")
3281+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 305, "do not block the merge")
30783282 if templ_7745c5c3_Err != nil {
30793283 return templ_7745c5c3_Err
30803284 }
30813285 }
3082- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 285, "</dd></dl></section>")
3286+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 306, "</dd></dl></section>")
30833287 if templ_7745c5c3_Err != nil {
30843288 return templ_7745c5c3_Err
30853289 }
30863290 if p.Box != nil {
30873291 if p.Box.Signed {
3088- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 286, "<p class=\"d-note\" id=\"mr-signing\">squash and merge commits are signed with ermite's own key (generated on first start). ")
3292+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 307, "<p class=\"d-note\" id=\"mr-signing\">squash and merge commits are signed with ermite's own key (generated on first start). ")
30893293 if templ_7745c5c3_Err != nil {
30903294 return templ_7745c5c3_Err
30913295 }
30923296 if p.Box.Fingerprint != "" {
3093- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 287, "fingerprint <code>")
3297+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 308, "fingerprint <code>")
30943298 if templ_7745c5c3_Err != nil {
30953299 return templ_7745c5c3_Err
30963300 }
3097- var templ_7745c5c3_Var145 string
3098- templ_7745c5c3_Var145, templ_7745c5c3_Err = templ.JoinStringErrs(p.Box.Fingerprint)
3301+ var templ_7745c5c3_Var157 string
3302+ templ_7745c5c3_Var157, templ_7745c5c3_Err = templ.JoinStringErrs(p.Box.Fingerprint)
30993303 if templ_7745c5c3_Err != nil {
3100- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 621, Col: 44}
3304+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 649, Col: 44}
31013305 }
3102- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var145))
3306+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var157))
31033307 if templ_7745c5c3_Err != nil {
31043308 return templ_7745c5c3_Err
31053309 }
3106- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 288, "</code> ")
3310+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 309, "</code> ")
31073311 if templ_7745c5c3_Err != nil {
31083312 return templ_7745c5c3_Err
31093313 }
31103314 }
3111- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 289, "<code>ermite signing-key show</code> prints it.</p>")
3315+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 310, "<code>ermite signing-key show</code> prints it.</p>")
31123316 if templ_7745c5c3_Err != nil {
31133317 return templ_7745c5c3_Err
31143318 }
31153319 }
3116- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 290, " ")
3320+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 311, " ")
31173321 if templ_7745c5c3_Err != nil {
31183322 return templ_7745c5c3_Err
31193323 }
31203324 if p.Box.CanMarkRead {
3121- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 291, "<form method=\"post\" action=\"")
3325+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 312, "<form method=\"post\" action=\"")
31223326 if templ_7745c5c3_Err != nil {
31233327 return templ_7745c5c3_Err
31243328 }
3125- var templ_7745c5c3_Var146 templ.SafeURL
3126- templ_7745c5c3_Var146, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Box.ReadAction))
3329+ var templ_7745c5c3_Var158 templ.SafeURL
3330+ templ_7745c5c3_Var158, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Box.ReadAction))
31273331 if templ_7745c5c3_Err != nil {
3128- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 627, Col: 65}
3332+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 655, Col: 65}
31293333 }
3130- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var146))
3334+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var158))
31313335 if templ_7745c5c3_Err != nil {
31323336 return templ_7745c5c3_Err
31333337 }
3134- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 292, "\"><input type=\"hidden\" name=\"head_id\" value=\"")
3338+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 313, "\"><input type=\"hidden\" name=\"head_id\" value=\"")
31353339 if templ_7745c5c3_Err != nil {
31363340 return templ_7745c5c3_Err
31373341 }
3138- var templ_7745c5c3_Var147 string
3139- templ_7745c5c3_Var147, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Box.Head)
3342+ var templ_7745c5c3_Var159 string
3343+ templ_7745c5c3_Var159, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Box.Head)
31403344 if templ_7745c5c3_Err != nil {
3141- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 628, Col: 60}
3345+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 656, Col: 60}
31423346 }
3143- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var147)
3347+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var159)
31443348 if templ_7745c5c3_Err != nil {
31453349 return templ_7745c5c3_Err
31463350 }
3147- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 293, "\"> <button type=\"submit\" title=\"the next visit can show only what changed after this head\" class=\"btn\" aria-keyshortcuts=\"Shift+R\" data-read>Mark read <kbd aria-hidden=\"true\">R</kbd></button></form>")
3351+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 314, "\"> <button type=\"submit\" title=\"the next visit can show only what changed after this head\" class=\"btn\" aria-keyshortcuts=\"Shift+R\" data-read>Mark read <kbd aria-hidden=\"true\">R</kbd></button></form>")
31483352 if templ_7745c5c3_Err != nil {
31493353 return templ_7745c5c3_Err
31503354 }
31513355 }
3152- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 294, " <form method=\"post\" action=\"")
3356+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 315, " <form method=\"post\" action=\"")
31533357 if templ_7745c5c3_Err != nil {
31543358 return templ_7745c5c3_Err
31553359 }
3156- var templ_7745c5c3_Var148 templ.SafeURL
3157- templ_7745c5c3_Var148, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Box.CloseAction))
3360+ var templ_7745c5c3_Var160 templ.SafeURL
3361+ templ_7745c5c3_Var160, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Box.CloseAction))
31583362 if templ_7745c5c3_Err != nil {
3159- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 632, Col: 65}
3363+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 660, Col: 65}
31603364 }
3161- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var148))
3365+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var160))
31623366 if templ_7745c5c3_Err != nil {
31633367 return templ_7745c5c3_Err
31643368 }
3165- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 295, "\"><button type=\"submit\" title=\"close without merging; the branch is kept\" class=\"btn danger\">Close</button></form>")
3369+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 316, "\"><button type=\"submit\" title=\"close without merging; the branch is kept\" class=\"btn danger\">Close</button></form>")
31663370 if templ_7745c5c3_Err != nil {
31673371 return templ_7745c5c3_Err
31683372 }
31693373 if !p.Box.HeadGone && p.Box.ReadyAction == "" {
3170- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 296, "<details class=\"d-or\"><summary class=\"mute\">or from a terminal</summary>")
3374+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 317, "<details class=\"d-or\"><summary class=\"mute\">or from a terminal</summary>")
31713375 if templ_7745c5c3_Err != nil {
31723376 return templ_7745c5c3_Err
31733377 }
@@ -3175,13 +3379,13 @@ func moreMenu(p MRPage) templ.Component {
31753379 if templ_7745c5c3_Err != nil {
31763380 return templ_7745c5c3_Err
31773381 }
3178- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 297, "</details>")
3382+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 318, "</details>")
31793383 if templ_7745c5c3_Err != nil {
31803384 return templ_7745c5c3_Err
31813385 }
31823386 }
31833387 }
3184- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 298, "</div></details>")
3388+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 319, "</div></details>")
31853389 if templ_7745c5c3_Err != nil {
31863390 return templ_7745c5c3_Err
31873391 }
@@ -3205,12 +3409,12 @@ func Compare(l Layout, p ComparePage) templ.Component {
32053409 }()
32063410 }
32073411 ctx = templ.InitializeContext(ctx)
3208- templ_7745c5c3_Var149 := templ.GetChildren(ctx)
3209- if templ_7745c5c3_Var149 == nil {
3210- templ_7745c5c3_Var149 = templ.NopComponent
3412+ templ_7745c5c3_Var161 := templ.GetChildren(ctx)
3413+ if templ_7745c5c3_Var161 == nil {
3414+ templ_7745c5c3_Var161 = templ.NopComponent
32113415 }
32123416 ctx = templ.ClearChildren(ctx)
3213- templ_7745c5c3_Var150 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
3417+ templ_7745c5c3_Var162 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
32143418 templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
32153419 templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
32163420 if !templ_7745c5c3_IsBuffer {
@@ -3222,263 +3426,263 @@ func Compare(l Layout, p ComparePage) templ.Component {
32223426 }()
32233427 }
32243428 ctx = templ.InitializeContext(ctx)
3225- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 299, "<header class=\"show mr-h\"><h1>")
3429+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 320, "<header class=\"show mr-h\"><h1>")
32263430 if templ_7745c5c3_Err != nil {
32273431 return templ_7745c5c3_Err
32283432 }
3229- var templ_7745c5c3_Var151 string
3230- templ_7745c5c3_Var151, templ_7745c5c3_Err = templ.JoinStringErrs(p.Source)
3433+ var templ_7745c5c3_Var163 string
3434+ templ_7745c5c3_Var163, templ_7745c5c3_Err = templ.JoinStringErrs(p.Source)
32313435 if templ_7745c5c3_Err != nil {
3232- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 649, Col: 17}
3436+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 677, Col: 17}
32333437 }
3234- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var151))
3438+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var163))
32353439 if templ_7745c5c3_Err != nil {
32363440 return templ_7745c5c3_Err
32373441 }
3238- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 300, " → ")
3442+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 321, " → ")
32393443 if templ_7745c5c3_Err != nil {
32403444 return templ_7745c5c3_Err
32413445 }
3242- var templ_7745c5c3_Var152 string
3243- templ_7745c5c3_Var152, templ_7745c5c3_Err = templ.JoinStringErrs(p.Target)
3446+ var templ_7745c5c3_Var164 string
3447+ templ_7745c5c3_Var164, templ_7745c5c3_Err = templ.JoinStringErrs(p.Target)
32443448 if templ_7745c5c3_Err != nil {
3245- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 649, Col: 34}
3449+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 677, Col: 34}
32463450 }
3247- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var152))
3451+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var164))
32483452 if templ_7745c5c3_Err != nil {
32493453 return templ_7745c5c3_Err
32503454 }
3251- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 301, "</h1><dl class=\"kv show-kv\"><dt>branch</dt><dd><a href=\"")
3455+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 322, "</h1><dl class=\"kv show-kv\"><dt>branch</dt><dd><a href=\"")
32523456 if templ_7745c5c3_Err != nil {
32533457 return templ_7745c5c3_Err
32543458 }
3255- var templ_7745c5c3_Var153 templ.SafeURL
3256- templ_7745c5c3_Var153, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.SourceHref))
3459+ var templ_7745c5c3_Var165 templ.SafeURL
3460+ templ_7745c5c3_Var165, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.SourceHref))
32573461 if templ_7745c5c3_Err != nil {
3258- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 652, Col: 45}
3462+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 680, Col: 45}
32593463 }
3260- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var153))
3464+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var165))
32613465 if templ_7745c5c3_Err != nil {
32623466 return templ_7745c5c3_Err
32633467 }
3264- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 302, "\">")
3468+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 323, "\">")
32653469 if templ_7745c5c3_Err != nil {
32663470 return templ_7745c5c3_Err
32673471 }
3268- var templ_7745c5c3_Var154 string
3269- templ_7745c5c3_Var154, templ_7745c5c3_Err = templ.JoinStringErrs(p.Source)
3472+ var templ_7745c5c3_Var166 string
3473+ templ_7745c5c3_Var166, templ_7745c5c3_Err = templ.JoinStringErrs(p.Source)
32703474 if templ_7745c5c3_Err != nil {
3271- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 652, Col: 58}
3475+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 680, Col: 58}
32723476 }
3273- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var154))
3477+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var166))
32743478 if templ_7745c5c3_Err != nil {
32753479 return templ_7745c5c3_Err
32763480 }
3277- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 303, "</a></dd><dt>target</dt><dd><a href=\"")
3481+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 324, "</a></dd><dt>target</dt><dd><a href=\"")
32783482 if templ_7745c5c3_Err != nil {
32793483 return templ_7745c5c3_Err
32803484 }
3281- var templ_7745c5c3_Var155 templ.SafeURL
3282- templ_7745c5c3_Var155, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.TargetHref))
3485+ var templ_7745c5c3_Var167 templ.SafeURL
3486+ templ_7745c5c3_Var167, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.TargetHref))
32833487 if templ_7745c5c3_Err != nil {
3284- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 654, Col: 45}
3488+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 682, Col: 45}
32853489 }
3286- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var155))
3490+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var167))
32873491 if templ_7745c5c3_Err != nil {
32883492 return templ_7745c5c3_Err
32893493 }
3290- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 304, "\">")
3494+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 325, "\">")
32913495 if templ_7745c5c3_Err != nil {
32923496 return templ_7745c5c3_Err
32933497 }
3294- var templ_7745c5c3_Var156 string
3295- templ_7745c5c3_Var156, templ_7745c5c3_Err = templ.JoinStringErrs(p.Target)
3498+ var templ_7745c5c3_Var168 string
3499+ templ_7745c5c3_Var168, templ_7745c5c3_Err = templ.JoinStringErrs(p.Target)
32963500 if templ_7745c5c3_Err != nil {
3297- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 654, Col: 58}
3501+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 682, Col: 58}
32983502 }
3299- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var156))
3503+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var168))
33003504 if templ_7745c5c3_Err != nil {
33013505 return templ_7745c5c3_Err
33023506 }
3303- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 305, "</a></dd><dt>ahead</dt><dd>")
3507+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 326, "</a></dd><dt>ahead</dt><dd>")
33043508 if templ_7745c5c3_Err != nil {
33053509 return templ_7745c5c3_Err
33063510 }
3307- var templ_7745c5c3_Var157 string
3308- templ_7745c5c3_Var157, templ_7745c5c3_Err = templ.JoinStringErrs(p.AheadText)
3511+ var templ_7745c5c3_Var169 string
3512+ templ_7745c5c3_Var169, templ_7745c5c3_Err = templ.JoinStringErrs(p.AheadText)
33093513 if templ_7745c5c3_Err != nil {
3310- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 656, Col: 21}
3514+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 684, Col: 21}
33113515 }
3312- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var157))
3516+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var169))
33133517 if templ_7745c5c3_Err != nil {
33143518 return templ_7745c5c3_Err
33153519 }
3316- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 306, "</dd></dl></header>")
3520+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 327, "</dd></dl></header>")
33173521 if templ_7745c5c3_Err != nil {
33183522 return templ_7745c5c3_Err
33193523 }
33203524 if p.Note != "" {
3321- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 307, "<p class=\"notice\">")
3525+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 328, "<p class=\"notice\">")
33223526 if templ_7745c5c3_Err != nil {
33233527 return templ_7745c5c3_Err
33243528 }
3325- var templ_7745c5c3_Var158 string
3326- templ_7745c5c3_Var158, templ_7745c5c3_Err = templ.JoinStringErrs(p.Note)
3529+ var templ_7745c5c3_Var170 string
3530+ templ_7745c5c3_Var170, templ_7745c5c3_Err = templ.JoinStringErrs(p.Note)
33273531 if templ_7745c5c3_Err != nil {
3328- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 660, Col: 29}
3532+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 688, Col: 29}
33293533 }
3330- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var158))
3534+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var170))
33313535 if templ_7745c5c3_Err != nil {
33323536 return templ_7745c5c3_Err
33333537 }
3334- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 308, "</p>")
3538+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 329, "</p>")
33353539 if templ_7745c5c3_Err != nil {
33363540 return templ_7745c5c3_Err
33373541 }
33383542 }
3339- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 309, " ")
3543+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 330, " ")
33403544 if templ_7745c5c3_Err != nil {
33413545 return templ_7745c5c3_Err
33423546 }
33433547 if p.Existing != nil {
3344- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 310, "<p class=\"notice\">an open merge request already covers these branches: <a href=\"")
3548+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 331, "<p class=\"notice\">an open merge request already covers these branches: <a href=\"")
33453549 if templ_7745c5c3_Err != nil {
33463550 return templ_7745c5c3_Err
33473551 }
3348- var templ_7745c5c3_Var159 templ.SafeURL
3349- templ_7745c5c3_Var159, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Existing.Href))
3552+ var templ_7745c5c3_Var171 templ.SafeURL
3553+ templ_7745c5c3_Var171, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Existing.Href))
33503554 if templ_7745c5c3_Err != nil {
3351- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 663, Col: 114}
3555+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 691, Col: 114}
33523556 }
3353- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var159))
3557+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var171))
33543558 if templ_7745c5c3_Err != nil {
33553559 return templ_7745c5c3_Err
33563560 }
3357- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 311, "\">")
3561+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 332, "\">")
33583562 if templ_7745c5c3_Err != nil {
33593563 return templ_7745c5c3_Err
33603564 }
3361- var templ_7745c5c3_Var160 string
3362- templ_7745c5c3_Var160, templ_7745c5c3_Err = templ.JoinStringErrs(p.Existing.Label)
3565+ var templ_7745c5c3_Var172 string
3566+ templ_7745c5c3_Var172, templ_7745c5c3_Err = templ.JoinStringErrs(p.Existing.Label)
33633567 if templ_7745c5c3_Err != nil {
3364- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 663, Col: 135}
3568+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 691, Col: 135}
33653569 }
3366- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var160))
3570+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var172))
33673571 if templ_7745c5c3_Err != nil {
33683572 return templ_7745c5c3_Err
33693573 }
3370- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 312, "</a></p>")
3574+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 333, "</a></p>")
33713575 if templ_7745c5c3_Err != nil {
33723576 return templ_7745c5c3_Err
33733577 }
33743578 }
3375- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 313, " ")
3579+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 334, " ")
33763580 if templ_7745c5c3_Err != nil {
33773581 return templ_7745c5c3_Err
33783582 }
33793583 if p.Create != nil {
3380- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 314, "<form class=\"cform create\" method=\"post\" action=\"")
3584+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 335, "<form class=\"cform create\" method=\"post\" action=\"")
33813585 if templ_7745c5c3_Err != nil {
33823586 return templ_7745c5c3_Err
33833587 }
3384- var templ_7745c5c3_Var161 templ.SafeURL
3385- templ_7745c5c3_Var161, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Create.Action))
3588+ var templ_7745c5c3_Var173 templ.SafeURL
3589+ templ_7745c5c3_Var173, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Create.Action))
33863590 if templ_7745c5c3_Err != nil {
3387- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 666, Col: 83}
3591+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 694, Col: 83}
33883592 }
3389- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var161))
3593+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var173))
33903594 if templ_7745c5c3_Err != nil {
33913595 return templ_7745c5c3_Err
33923596 }
3393- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 315, "\"><input type=\"hidden\" name=\"source\" value=\"")
3597+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 336, "\"><input type=\"hidden\" name=\"source\" value=\"")
33943598 if templ_7745c5c3_Err != nil {
33953599 return templ_7745c5c3_Err
33963600 }
3397- var templ_7745c5c3_Var162 string
3398- templ_7745c5c3_Var162, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Create.Source)
3601+ var templ_7745c5c3_Var174 string
3602+ templ_7745c5c3_Var174, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Create.Source)
33993603 if templ_7745c5c3_Err != nil {
3400- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 667, Col: 62}
3604+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 695, Col: 62}
34013605 }
3402- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var162)
3606+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var174)
34033607 if templ_7745c5c3_Err != nil {
34043608 return templ_7745c5c3_Err
34053609 }
3406- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 316, "\"> <input type=\"hidden\" name=\"target\" value=\"")
3610+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 337, "\"> <input type=\"hidden\" name=\"target\" value=\"")
34073611 if templ_7745c5c3_Err != nil {
34083612 return templ_7745c5c3_Err
34093613 }
3410- var templ_7745c5c3_Var163 string
3411- templ_7745c5c3_Var163, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Create.Target)
3614+ var templ_7745c5c3_Var175 string
3615+ templ_7745c5c3_Var175, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Create.Target)
34123616 if templ_7745c5c3_Err != nil {
3413- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 668, Col: 62}
3617+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 696, Col: 62}
34143618 }
3415- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var163)
3619+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var175)
34163620 if templ_7745c5c3_Err != nil {
34173621 return templ_7745c5c3_Err
34183622 }
3419- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 317, "\"> <label for=\"mr-title\">title</label> <input id=\"mr-title\" name=\"title\" type=\"text\" maxlength=\"120\" required value=\"")
3623+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 338, "\"> <label for=\"mr-title\">title</label> <input id=\"mr-title\" name=\"title\" type=\"text\" maxlength=\"120\" required value=\"")
34203624 if templ_7745c5c3_Err != nil {
34213625 return templ_7745c5c3_Err
34223626 }
3423- var templ_7745c5c3_Var164 string
3424- templ_7745c5c3_Var164, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Create.Title)
3627+ var templ_7745c5c3_Var176 string
3628+ templ_7745c5c3_Var176, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Create.Title)
34253629 if templ_7745c5c3_Err != nil {
3426- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 670, Col: 97}
3630+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 698, Col: 97}
34273631 }
3428- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var164)
3632+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var176)
34293633 if templ_7745c5c3_Err != nil {
34303634 return templ_7745c5c3_Err
34313635 }
3432- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 318, "\" autocomplete=\"off\"> <label for=\"mr-desc\">description</label> <textarea id=\"mr-desc\" name=\"description\" rows=\"4\" maxlength=\"8000\" placeholder=\"Markdown, optional\"></textarea><div class=\"f-btns\"><button type=\"submit\" class=\"btn primary\">Create merge request</button></div></form>")
3636+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 339, "\" autocomplete=\"off\"> <label for=\"mr-desc\">description</label> <textarea id=\"mr-desc\" name=\"description\" rows=\"4\" maxlength=\"8000\" placeholder=\"Markdown, optional\"></textarea><div class=\"f-btns\"><button type=\"submit\" class=\"btn primary\">Create merge request</button></div></form>")
34333637 if templ_7745c5c3_Err != nil {
34343638 return templ_7745c5c3_Err
34353639 }
34363640 }
3437- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 319, " ")
3641+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 340, " ")
34383642 if templ_7745c5c3_Err != nil {
34393643 return templ_7745c5c3_Err
34403644 }
34413645 if p.HasDiff {
3442- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 320, "<div class=\"diff-tools\" role=\"group\" aria-label=\"Diff view\"><span class=\"dim\">")
3646+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 341, "<div class=\"diff-tools\" role=\"group\" aria-label=\"Diff view\"><span class=\"dim\">")
34433647 if templ_7745c5c3_Err != nil {
34443648 return templ_7745c5c3_Err
34453649 }
3446- var templ_7745c5c3_Var165 string
3447- templ_7745c5c3_Var165, templ_7745c5c3_Err = templ.JoinStringErrs(plural(p.NFiles, "file", "files"))
3650+ var templ_7745c5c3_Var177 string
3651+ templ_7745c5c3_Var177, templ_7745c5c3_Err = templ.JoinStringErrs(plural(p.NFiles, "file", "files"))
34483652 if templ_7745c5c3_Err != nil {
3449- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 680, Col: 57}
3653+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 708, Col: 57}
34503654 }
3451- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var165))
3655+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var177))
34523656 if templ_7745c5c3_Err != nil {
34533657 return templ_7745c5c3_Err
34543658 }
3455- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 321, " · <span class=\"plus\">+")
3659+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 342, " · <span class=\"plus\">+")
34563660 if templ_7745c5c3_Err != nil {
34573661 return templ_7745c5c3_Err
34583662 }
3459- var templ_7745c5c3_Var166 string
3460- templ_7745c5c3_Var166, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.Adds))
3663+ var templ_7745c5c3_Var178 string
3664+ templ_7745c5c3_Var178, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.Adds))
34613665 if templ_7745c5c3_Err != nil {
3462- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 680, Col: 97}
3666+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 708, Col: 97}
34633667 }
3464- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var166))
3668+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var178))
34653669 if templ_7745c5c3_Err != nil {
34663670 return templ_7745c5c3_Err
34673671 }
3468- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 322, "</span> <span class=\"minus\">-")
3672+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 343, "</span> <span class=\"minus\">-")
34693673 if templ_7745c5c3_Err != nil {
34703674 return templ_7745c5c3_Err
34713675 }
3472- var templ_7745c5c3_Var167 string
3473- templ_7745c5c3_Var167, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.Dels))
3676+ var templ_7745c5c3_Var179 string
3677+ templ_7745c5c3_Var179, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.Dels))
34743678 if templ_7745c5c3_Err != nil {
3475- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 680, Col: 142}
3679+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 708, Col: 142}
34763680 }
3477- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var167))
3681+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var179))
34783682 if templ_7745c5c3_Err != nil {
34793683 return templ_7745c5c3_Err
34803684 }
3481- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 323, "</span></span> <span class=\"grow\"></span>")
3685+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 344, "</span></span> <span class=\"grow\"></span>")
34823686 if templ_7745c5c3_Err != nil {
34833687 return templ_7745c5c3_Err
34843688 }
@@ -3486,64 +3690,64 @@ func Compare(l Layout, p ComparePage) templ.Component {
34863690 if templ_7745c5c3_Err != nil {
34873691 return templ_7745c5c3_Err
34883692 }
3489- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 324, "</div>")
3693+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 345, "</div>")
34903694 if templ_7745c5c3_Err != nil {
34913695 return templ_7745c5c3_Err
34923696 }
34933697 if p.Invisible {
3494- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 325, "<p class=\"notice\">this diff holds invisible characters (direction controls, zero-width characters). They are shown as <span class=\"inv\">&lt;U+XXXX&gt;</span> markers, never applied.</p>")
3698+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 346, "<p class=\"notice\">this diff holds invisible characters (direction controls, zero-width characters). They are shown as <span class=\"inv\">&lt;U+XXXX&gt;</span> markers, never applied.</p>")
34953699 if templ_7745c5c3_Err != nil {
34963700 return templ_7745c5c3_Err
34973701 }
34983702 }
3499- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 326, " <div id=\"diffs\"")
3703+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 347, " <div id=\"diffs\"")
35003704 if templ_7745c5c3_Err != nil {
35013705 return templ_7745c5c3_Err
35023706 }
35033707 if p.Both {
3504- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 327, " data-diff=\"")
3708+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 348, " data-diff=\"")
35053709 if templ_7745c5c3_Err != nil {
35063710 return templ_7745c5c3_Err
35073711 }
3508- var templ_7745c5c3_Var168 string
3509- templ_7745c5c3_Var168, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.View)
3712+ var templ_7745c5c3_Var180 string
3713+ templ_7745c5c3_Var180, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.View)
35103714 if templ_7745c5c3_Err != nil {
3511- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 693, Col: 23}
3715+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 721, Col: 23}
35123716 }
3513- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var168)
3717+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var180)
35143718 if templ_7745c5c3_Err != nil {
35153719 return templ_7745c5c3_Err
35163720 }
3517- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 328, "\"")
3721+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 349, "\"")
35183722 if templ_7745c5c3_Err != nil {
35193723 return templ_7745c5c3_Err
35203724 }
35213725 } else {
3522- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 329, " data-view=\"")
3726+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 350, " data-view=\"")
35233727 if templ_7745c5c3_Err != nil {
35243728 return templ_7745c5c3_Err
35253729 }
3526- var templ_7745c5c3_Var169 string
3527- templ_7745c5c3_Var169, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.View)
3730+ var templ_7745c5c3_Var181 string
3731+ templ_7745c5c3_Var181, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.View)
35283732 if templ_7745c5c3_Err != nil {
3529- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 695, Col: 23}
3733+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 723, Col: 23}
35303734 }
3531- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var169)
3735+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var181)
35323736 if templ_7745c5c3_Err != nil {
35333737 return templ_7745c5c3_Err
35343738 }
3535- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 330, "\"")
3739+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 351, "\"")
35363740 if templ_7745c5c3_Err != nil {
35373741 return templ_7745c5c3_Err
35383742 }
35393743 }
35403744 if p.Both && !p.Pinned {
3541- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 331, " hx-live=\"this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff\"")
3745+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 352, " hx-live=\"this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff\"")
35423746 if templ_7745c5c3_Err != nil {
35433747 return templ_7745c5c3_Err
35443748 }
35453749 }
3546- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 332, ">")
3750+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 353, ">")
35473751 if templ_7745c5c3_Err != nil {
35483752 return templ_7745c5c3_Err
35493753 }
@@ -3553,14 +3757,14 @@ func Compare(l Layout, p ComparePage) templ.Component {
35533757 return templ_7745c5c3_Err
35543758 }
35553759 }
3556- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 333, "</div>")
3760+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 354, "</div>")
35573761 if templ_7745c5c3_Err != nil {
35583762 return templ_7745c5c3_Err
35593763 }
35603764 }
35613765 return nil
35623766 })
3563- templ_7745c5c3_Err = Shell(l, compareRail(p)).Render(templ.WithChildren(ctx, templ_7745c5c3_Var150), templ_7745c5c3_Buffer)
3767+ templ_7745c5c3_Err = Shell(l, compareRail(p)).Render(templ.WithChildren(ctx, templ_7745c5c3_Var162), templ_7745c5c3_Buffer)
35643768 if templ_7745c5c3_Err != nil {
35653769 return templ_7745c5c3_Err
35663770 }
@@ -3584,173 +3788,173 @@ func compareRail(p ComparePage) templ.Component {
35843788 }()
35853789 }
35863790 ctx = templ.InitializeContext(ctx)
3587- templ_7745c5c3_Var170 := templ.GetChildren(ctx)
3588- if templ_7745c5c3_Var170 == nil {
3589- templ_7745c5c3_Var170 = templ.NopComponent
3791+ templ_7745c5c3_Var182 := templ.GetChildren(ctx)
3792+ if templ_7745c5c3_Var182 == nil {
3793+ templ_7745c5c3_Var182 = templ.NopComponent
35903794 }
35913795 ctx = templ.ClearChildren(ctx)
3592- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 334, "<section aria-labelledby=\"r-ahead\"><h2 id=\"r-ahead\">Commits <span class=\"count\">")
3796+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 355, "<section aria-labelledby=\"r-ahead\"><h2 id=\"r-ahead\">Commits <span class=\"count\">")
35933797 if templ_7745c5c3_Err != nil {
35943798 return templ_7745c5c3_Err
35953799 }
3596- var templ_7745c5c3_Var171 string
3597- templ_7745c5c3_Var171, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Ahead)))
3800+ var templ_7745c5c3_Var183 string
3801+ templ_7745c5c3_Var183, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Ahead)))
35983802 if templ_7745c5c3_Err != nil {
3599- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 711, Col: 67}
3803+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 739, Col: 67}
36003804 }
3601- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var171))
3805+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var183))
36023806 if templ_7745c5c3_Err != nil {
36033807 return templ_7745c5c3_Err
36043808 }
3605- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 335, "</span></h2><ul class=\"pcommits\">")
3809+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 356, "</span></h2><ul class=\"pcommits\">")
36063810 if templ_7745c5c3_Err != nil {
36073811 return templ_7745c5c3_Err
36083812 }
36093813 for _, c := range p.Ahead {
3610- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 336, "<li><a href=\"")
3814+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 357, "<li><a href=\"")
36113815 if templ_7745c5c3_Err != nil {
36123816 return templ_7745c5c3_Err
36133817 }
3614- var templ_7745c5c3_Var172 templ.SafeURL
3615- templ_7745c5c3_Var172, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.CHref))
3818+ var templ_7745c5c3_Var184 templ.SafeURL
3819+ templ_7745c5c3_Var184, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.CHref))
36163820 if templ_7745c5c3_Err != nil {
3617- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 715, Col: 37}
3821+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 743, Col: 37}
36183822 }
3619- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var172))
3823+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var184))
36203824 if templ_7745c5c3_Err != nil {
36213825 return templ_7745c5c3_Err
36223826 }
3623- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 337, "\">")
3827+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 358, "\">")
36243828 if templ_7745c5c3_Err != nil {
36253829 return templ_7745c5c3_Err
36263830 }
3627- var templ_7745c5c3_Var173 string
3628- templ_7745c5c3_Var173, templ_7745c5c3_Err = templ.JoinStringErrs(c.Short)
3831+ var templ_7745c5c3_Var185 string
3832+ templ_7745c5c3_Var185, templ_7745c5c3_Err = templ.JoinStringErrs(c.Short)
36293833 if templ_7745c5c3_Err != nil {
3630- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 715, Col: 49}
3834+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 743, Col: 49}
36313835 }
3632- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var173))
3836+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var185))
36333837 if templ_7745c5c3_Err != nil {
36343838 return templ_7745c5c3_Err
36353839 }
3636- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 338, "</a> <span class=\"s\">")
3840+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 359, "</a> <span class=\"s\">")
36373841 if templ_7745c5c3_Err != nil {
36383842 return templ_7745c5c3_Err
36393843 }
3640- var templ_7745c5c3_Var174 string
3641- templ_7745c5c3_Var174, templ_7745c5c3_Err = templ.JoinStringErrs(c.Subject)
3844+ var templ_7745c5c3_Var186 string
3845+ templ_7745c5c3_Var186, templ_7745c5c3_Err = templ.JoinStringErrs(c.Subject)
36423846 if templ_7745c5c3_Err != nil {
3643- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 716, Col: 32}
3847+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 744, Col: 32}
36443848 }
3645- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var174))
3849+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var186))
36463850 if templ_7745c5c3_Err != nil {
36473851 return templ_7745c5c3_Err
36483852 }
3649- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 339, "</span> <span class=\"mute\">")
3853+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 360, "</span> <span class=\"mute\">")
36503854 if templ_7745c5c3_Err != nil {
36513855 return templ_7745c5c3_Err
36523856 }
3653- var templ_7745c5c3_Var175 string
3654- templ_7745c5c3_Var175, templ_7745c5c3_Err = templ.JoinStringErrs(c.Author)
3857+ var templ_7745c5c3_Var187 string
3858+ templ_7745c5c3_Var187, templ_7745c5c3_Err = templ.JoinStringErrs(c.Author)
36553859 if templ_7745c5c3_Err != nil {
3656- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 718, Col: 16}
3860+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 746, Col: 16}
36573861 }
3658- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var175))
3862+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var187))
36593863 if templ_7745c5c3_Err != nil {
36603864 return templ_7745c5c3_Err
36613865 }
3662- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 340, " ")
3866+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 361, " ")
36633867 if templ_7745c5c3_Err != nil {
36643868 return templ_7745c5c3_Err
36653869 }
36663870 if c.WhenAt != "" {
3667- var templ_7745c5c3_Var176 string
3668- templ_7745c5c3_Var176, templ_7745c5c3_Err = templ.JoinStringErrs(" · ")
3871+ var templ_7745c5c3_Var188 string
3872+ templ_7745c5c3_Var188, templ_7745c5c3_Err = templ.JoinStringErrs(" · ")
36693873 if templ_7745c5c3_Err != nil {
3670- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 720, Col: 15}
3874+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 748, Col: 15}
36713875 }
3672- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var176))
3876+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var188))
36733877 if templ_7745c5c3_Err != nil {
36743878 return templ_7745c5c3_Err
36753879 }
3676- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 341, "<time datetime=\"")
3880+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 362, "<time datetime=\"")
36773881 if templ_7745c5c3_Err != nil {
36783882 return templ_7745c5c3_Err
36793883 }
3680- var templ_7745c5c3_Var177 string
3681- templ_7745c5c3_Var177, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.WhenAt)
3884+ var templ_7745c5c3_Var189 string
3885+ templ_7745c5c3_Var189, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.WhenAt)
36823886 if templ_7745c5c3_Err != nil {
3683- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 720, Col: 42}
3887+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 748, Col: 42}
36843888 }
3685- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var177)
3889+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var189)
36863890 if templ_7745c5c3_Err != nil {
36873891 return templ_7745c5c3_Err
36883892 }
3689- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 342, "\">")
3893+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 363, "\">")
36903894 if templ_7745c5c3_Err != nil {
36913895 return templ_7745c5c3_Err
36923896 }
3693- var templ_7745c5c3_Var178 string
3694- templ_7745c5c3_Var178, templ_7745c5c3_Err = templ.JoinStringErrs(c.When)
3897+ var templ_7745c5c3_Var190 string
3898+ templ_7745c5c3_Var190, templ_7745c5c3_Err = templ.JoinStringErrs(c.When)
36953899 if templ_7745c5c3_Err != nil {
3696- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 720, Col: 53}
3900+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 748, Col: 53}
36973901 }
3698- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var178))
3902+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var190))
36993903 if templ_7745c5c3_Err != nil {
37003904 return templ_7745c5c3_Err
37013905 }
3702- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 343, "</time>")
3906+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 364, "</time>")
37033907 if templ_7745c5c3_Err != nil {
37043908 return templ_7745c5c3_Err
37053909 }
37063910 }
3707- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 344, "</span></li>")
3911+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 365, "</span></li>")
37083912 if templ_7745c5c3_Err != nil {
37093913 return templ_7745c5c3_Err
37103914 }
37113915 }
3712- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 345, "</ul>")
3916+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 366, "</ul>")
37133917 if templ_7745c5c3_Err != nil {
37143918 return templ_7745c5c3_Err
37153919 }
37163920 if len(p.Ahead) == 0 {
3717- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 346, "<p class=\"dim\">no commit ahead of ")
3921+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 367, "<p class=\"dim\">no commit ahead of ")
37183922 if templ_7745c5c3_Err != nil {
37193923 return templ_7745c5c3_Err
37203924 }
3721- var templ_7745c5c3_Var179 string
3722- templ_7745c5c3_Var179, templ_7745c5c3_Err = templ.JoinStringErrs(p.Target)
3925+ var templ_7745c5c3_Var191 string
3926+ templ_7745c5c3_Var191, templ_7745c5c3_Err = templ.JoinStringErrs(p.Target)
37233927 if templ_7745c5c3_Err != nil {
3724- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 727, Col: 47}
3928+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 755, Col: 47}
37253929 }
3726- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var179))
3930+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var191))
37273931 if templ_7745c5c3_Err != nil {
37283932 return templ_7745c5c3_Err
37293933 }
3730- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 347, ".</p>")
3934+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 368, ".</p>")
37313935 if templ_7745c5c3_Err != nil {
37323936 return templ_7745c5c3_Err
37333937 }
37343938 }
3735- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 348, "</section>")
3939+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 369, "</section>")
37363940 if templ_7745c5c3_Err != nil {
37373941 return templ_7745c5c3_Err
37383942 }
37393943 if p.HasDiff {
3740- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 349, "<section aria-labelledby=\"r-files\"><h2 id=\"r-files\">Files <span class=\"count\">")
3944+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 370, "<section aria-labelledby=\"r-files\"><h2 id=\"r-files\">Files <span class=\"count\">")
37413945 if templ_7745c5c3_Err != nil {
37423946 return templ_7745c5c3_Err
37433947 }
3744- var templ_7745c5c3_Var180 string
3745- templ_7745c5c3_Var180, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Files)))
3948+ var templ_7745c5c3_Var192 string
3949+ templ_7745c5c3_Var192, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Files)))
37463950 if templ_7745c5c3_Err != nil {
3747- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 732, Col: 66}
3951+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 760, Col: 66}
37483952 }
3749- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var180))
3953+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var192))
37503954 if templ_7745c5c3_Err != nil {
37513955 return templ_7745c5c3_Err
37523956 }
3753- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 350, "</span></h2>")
3957+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 371, "</span></h2>")
37543958 if templ_7745c5c3_Err != nil {
37553959 return templ_7745c5c3_Err
37563960 }
@@ -3758,12 +3962,12 @@ func compareRail(p ComparePage) templ.Component {
37583962 if templ_7745c5c3_Err != nil {
37593963 return templ_7745c5c3_Err
37603964 }
3761- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 351, "</section>")
3965+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 372, "</section>")
37623966 if templ_7745c5c3_Err != nil {
37633967 return templ_7745c5c3_Err
37643968 }
37653969 }
3766- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 352, "<section aria-labelledby=\"r-cli\"><h2 id=\"r-cli\">From a terminal</h2>")
3970+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 373, "<section aria-labelledby=\"r-cli\"><h2 id=\"r-cli\">From a terminal</h2>")
37673971 if templ_7745c5c3_Err != nil {
37683972 return templ_7745c5c3_Err
37693973 }
@@ -3771,7 +3975,7 @@ func compareRail(p ComparePage) templ.Component {
37713975 if templ_7745c5c3_Err != nil {
37723976 return templ_7745c5c3_Err
37733977 }
3774- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 353, "</section>")
3978+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 374, "</section>")
37753979 if templ_7745c5c3_Err != nil {
37763980 return templ_7745c5c3_Err
37773981 }
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+}
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))
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", "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", "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 })
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+}
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", "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", "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)
M internal/app/cli_fake_test.go +8 -0
@@ -62,6 +62,8 @@ type fakeAPI struct {
6262 closeMR func(*ermite.CloseMergeRequestRequest) (*ermite.CloseMergeRequestResponse, error)
6363 mergeMR func(*ermite.MergeMergeRequestRequest) (*ermite.MergeMergeRequestResponse, error)
6464 markRead func(*ermite.MarkReadRequest) (*ermite.MarkReadResponse, error)
65+ enableAuto func(*ermite.EnableAutoMergeRequest) (*ermite.EnableAutoMergeResponse, error)
66+ cancelAuto func(*ermite.CancelAutoMergeRequest) (*ermite.CancelAutoMergeResponse, error)
6567 createIssue func(*ermite.CreateIssueRequest) (*ermite.CreateIssueResponse, error)
6668 listIssues func(*ermite.ListIssuesRequest) (*ermite.ListIssuesResponse, error)
6769 getIssue func(*ermite.GetIssueRequest) (*ermite.GetIssueResponse, error)
@@ -152,6 +154,12 @@ func (f *fakeAPI) CloseMergeRequest(_ context.Context, r *connect.Request[ermite
152154 func (f *fakeAPI) MergeMergeRequest(_ context.Context, r *connect.Request[ermite.MergeMergeRequestRequest]) (*connect.Response[ermite.MergeMergeRequestResponse], error) {
153155 return respond(f, "MergeMergeRequest", r, f.mergeMR)
154156 }
157+func (f *fakeAPI) EnableAutoMerge(_ context.Context, r *connect.Request[ermite.EnableAutoMergeRequest]) (*connect.Response[ermite.EnableAutoMergeResponse], error) {
158+ return respond(f, "EnableAutoMerge", r, f.enableAuto)
159+}
160+func (f *fakeAPI) CancelAutoMerge(_ context.Context, r *connect.Request[ermite.CancelAutoMergeRequest]) (*connect.Response[ermite.CancelAutoMergeResponse], error) {
161+ return respond(f, "CancelAutoMerge", r, f.cancelAuto)
162+}
155163 func (f *fakeAPI) MarkRead(_ context.Context, r *connect.Request[ermite.MarkReadRequest]) (*connect.Response[ermite.MarkReadResponse], error) {
156164 return respond(f, "MarkRead", r, f.markRead)
157165 }
M internal/app/cli_mr.go +95 -3
@@ -121,6 +121,26 @@ type jsonMergeRequest struct {
121121 HeadID string `json:"head_id"`
122122 MergedCommitID string `json:"merged_commit_id"`
123123 MergeMode string `json:"merge_mode"`
124+ // AutoMerge is the pending auto-merge, null when there is none.
125+ AutoMerge *jsonAutoMerge `json:"auto_merge"`
126+ // AutoMerged is true when the server merged the request by auto-merge.
127+ AutoMerged bool `json:"auto_merged"`
128+}
129+
130+// jsonAutoMerge is the stable JSON shape of a pending auto-merge.
131+type jsonAutoMerge struct {
132+ Mode string `json:"mode"`
133+ HeadID string `json:"head_id"`
134+ EnabledBy string `json:"enabled_by"`
135+ EnabledAt *string `json:"enabled_at"`
136+}
137+
138+// toJSONAutoMerge maps an auto-merge; nil when there is none.
139+func toJSONAutoMerge(a *ermite.AutoMerge) *jsonAutoMerge {
140+ if a == nil {
141+ return nil
142+ }
143+ return &jsonAutoMerge{Mode: modeName(a.GetMode()), HeadID: a.GetHeadId(), EnabledBy: a.GetEnabledBy(), EnabledAt: jsonTime(a.GetEnabledAt())}
124144 }
125145
126146 // jsonMergeability is the stable JSON shape of the mergeability of an open
@@ -201,6 +221,7 @@ func toJSONMergeRequest(m *ermite.MergeRequest, baseURL string) jsonMergeRequest
201221 SourceBranch: m.GetSourceBranch(), TargetBranch: m.GetTargetBranch(), State: stateName(m.GetState()),
202222 Author: m.GetAuthor(), CreatedAt: jsonTime(m.GetCreatedAt()), UpdatedAt: jsonTime(m.GetUpdatedAt()),
203223 HeadID: m.GetHeadId(), MergedCommitID: m.GetMergedCommitId(), MergeMode: modeName(m.GetMergeMode()),
224+ AutoMerge: toJSONAutoMerge(m.GetAutoMerge()), AutoMerged: m.GetAutoMerged(),
204225 }
205226 }
206227
@@ -245,7 +266,8 @@ JSON fields of a merge request (--json): repo, url (full web URL), number, title
245266 target_branch, state ("open", "merged" or "closed"), author (token label), created_at and
246267 updated_at (RFC 3339 UTC), head_id (the commit the source branch points to; empty when the
247268 branch is gone), merged_commit_id and merge_mode ("fast-forward", "squash" or "merge"; both empty
248-unless merged).`
269+unless merged), auto_merge (null unless auto-merge is pending: mode, head_id, enabled_by,
270+enabled_at) and auto_merged (true when the server merged the request by auto-merge).`
249271
250272 const jsonMergeabilityHelp = `
251273 mr show --json adds mergeability, null unless the request is open: state ("checking", "up_to_date",
@@ -660,7 +682,14 @@ func printMRDetail(w io.Writer, d *ermite.GetMergeRequestResponse, withDiff, sin
660682 {"head", orDash(m.GetHeadId())},
661683 }
662684 if m.GetState() == ermite.MergeRequestState_MERGE_REQUEST_STATE_MERGED {
663- pairs = append(pairs, [2]string{"merged", orDash(m.GetMergedCommitId()) + " (" + modeName(m.GetMergeMode()) + ")"})
685+ merged := orDash(m.GetMergedCommitId()) + " (" + modeName(m.GetMergeMode()) + ")"
686+ pairs = append(pairs, [2]string{"merged", merged})
687+ if m.GetAutoMerged() {
688+ pairs = append(pairs, [2]string{"auto-merged", "yes, by the server once the checks passed"})
689+ }
690+ }
691+ if am := m.GetAutoMerge(); am != nil && m.GetState() == ermite.MergeRequestState_MERGE_REQUEST_STATE_OPEN {
692+ pairs = append(pairs, [2]string{"auto-merge", modeName(am.GetMode()) + " when ready (enabled by " + am.GetEnabledBy() + ")"})
664693 }
665694 if mg := toJSONMergeability(m.GetMergeability()); mg != nil && m.GetState() == ermite.MergeRequestState_MERGE_REQUEST_STATE_OPEN {
666695 pairs = append(pairs, [2]string{"mergeability", mergeabilityText(mg)})
@@ -1056,6 +1085,7 @@ func newMRMergeCmd(env *cliEnv) *cobra.Command {
10561085 var mode string
10571086 var deleteBranch bool
10581087 var head string
1088+ var auto, cancelAuto bool
10591089 cmd := &cobra.Command{
10601090 Use: "merge <repo> <number>",
10611091 Short: "Merge a merge request",
@@ -1078,7 +1108,12 @@ func newMRMergeCmd(env *cliEnv) *cobra.Command {
10781108 "The merge answers as soon as the target branch has moved. --delete-branch asks the server to delete\n" +
10791109 "the source branch right after, in the background. The merge is refused when the branch is the\n" +
10801110 "default branch or used by another open merge request. If the deletion later fails (the branch\n" +
1081- "moved, say), the merge stands and the branch is kept." + jsonMRHelp +
1111+ "moved, say), the merge stands and the branch is kept.\n\n" +
1112+ "--auto does not merge now: it asks the server to merge by itself, with --mode, once CI has settled\n" +
1113+ "with at least one successful check on the head and every check allows it. Needs the mr:merge scope,\n" +
1114+ "a server with CI, and `ermite repo edit --allow-auto-merge` on the repository. A new push to the\n" +
1115+ "source branch, a change of the settings or of your permissions cancels it. --auto takes --head\n" +
1116+ "like a plain merge: it is enabled only if the source branch still points to it. --cancel-auto cancels a pending auto-merge." + jsonMRHelp +
10821117 "\n--json adds source_branch_deleted (true when the deletion was requested: the server deletes the branch right after the merge).",
10831118 Args: cobra.ExactArgs(2),
10841119 ValidArgsFunction: completeMRRepoThenNumber(env),
@@ -1086,6 +1121,12 @@ func newMRMergeCmd(env *cliEnv) *cobra.Command {
10861121 cmd.Flags().StringVar(&mode, "mode", modeFastForward, "merge mode: fast-forward, squash or merge (a merge commit)")
10871122 cmd.Flags().StringVar(&head, "head", "", "full id of the head you reviewed, from `ermite mr show` (required)")
10881123 cmd.Flags().BoolVar(&deleteBranch, "delete-branch", false, "delete the source branch after the merge")
1124+ cmd.Flags().BoolVar(&auto, "auto", false, "merge by itself once the checks passed (needs `repo edit --allow-auto-merge`)")
1125+ cmd.Flags().BoolVar(&cancelAuto, "cancel-auto", false, "cancel the pending auto-merge")
1126+ cmd.MarkFlagsMutuallyExclusive("auto", "cancel-auto")
1127+ cmd.MarkFlagsMutuallyExclusive("cancel-auto", "delete-branch")
1128+ cmd.MarkFlagsMutuallyExclusive("cancel-auto", "head")
1129+ cmd.MarkFlagsMutuallyExclusive("auto", "delete-branch")
10891130 _ = cmd.RegisterFlagCompletionFunc("mode", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
10901131 return []string{modeFastForward, modeSquash, modeMerge}, cobra.ShellCompDirectiveNoFileComp
10911132 })
@@ -1095,6 +1136,9 @@ func newMRMergeCmd(env *cliEnv) *cobra.Command {
10951136 if err != nil {
10961137 return err
10971138 }
1139+ if cancelAuto {
1140+ return runMRCancelAuto(env, cmd, args[0], n, *asJSON)
1141+ }
10981142 pbMode, err := parseMode(mode)
10991143 if err != nil {
11001144 return err
@@ -1104,6 +1148,9 @@ func newMRMergeCmd(env *cliEnv) *cobra.Command {
11041148 return fmt.Errorf("--head is required: run `ermite mr show %s %d` and give the head id it prints, "+
11051149 "so that only the commit you reviewed is merged", args[0], n)
11061150 }
1151+ if auto {
1152+ return runMREnableAuto(env, cmd, args[0], n, pbMode, head, *asJSON)
1153+ }
11071154 c, err := env.client(cmd)
11081155 if err != nil {
11091156 return err
@@ -1139,6 +1186,51 @@ func newMRMergeCmd(env *cliEnv) *cobra.Command {
11391186 return cmd
11401187 }
11411188
1189+// runMREnableAuto is `mr merge --auto`: it asks the server to merge once ready.
1190+func runMREnableAuto(env *cliEnv, cmd *cobra.Command, repo string, n int64, mode ermite.MergeMode, head string, asJSON bool) error {
1191+ c, err := env.client(cmd)
1192+ if err != nil {
1193+ return err
1194+ }
1195+ ctx := env.ctx(cmd)
1196+ if err := requireFeature(ctx, c, client.FeatureAutoMerge, "cannot merge by itself (--auto)"); err != nil {
1197+ return err
1198+ }
1199+ res, err := c.MergeRequests().EnableAutoMerge(ctx, connect.NewRequest(&ermite.EnableAutoMergeRequest{Repo: repo, Number: n, Mode: mode, HeadId: head}))
1200+ if err != nil {
1201+ return c.Describe(err)
1202+ }
1203+ m := res.Msg.GetMergeRequest()
1204+ if asJSON {
1205+ return writeJSON(cmd.OutOrStdout(), toJSONMergeRequest(m, c.URL()))
1206+ }
1207+ _, err = fmt.Fprintf(cmd.OutOrStdout(), "auto-merge enabled on %s!%d: %s into %s when ready (enabled by %s)\n", clean(m.GetRepo()), m.GetNumber(),
1208+ modeName(m.GetAutoMerge().GetMode()), clean(m.GetTargetBranch()), clean(m.GetAutoMerge().GetEnabledBy()))
1209+ return err
1210+}
1211+
1212+// runMRCancelAuto is `mr merge --cancel-auto`.
1213+func runMRCancelAuto(env *cliEnv, cmd *cobra.Command, repo string, n int64, asJSON bool) error {
1214+ c, err := env.client(cmd)
1215+ if err != nil {
1216+ return err
1217+ }
1218+ ctx := env.ctx(cmd)
1219+ if err := requireFeature(ctx, c, client.FeatureAutoMerge, "has no auto-merge to cancel (--cancel-auto)"); err != nil {
1220+ return err
1221+ }
1222+ res, err := c.MergeRequests().CancelAutoMerge(ctx, connect.NewRequest(&ermite.CancelAutoMergeRequest{Repo: repo, Number: n}))
1223+ if err != nil {
1224+ return c.Describe(err)
1225+ }
1226+ m := res.Msg.GetMergeRequest()
1227+ if asJSON {
1228+ return writeJSON(cmd.OutOrStdout(), toJSONMergeRequest(m, c.URL()))
1229+ }
1230+ _, err = fmt.Fprintf(cmd.OutOrStdout(), "auto-merge cancelled on %s!%d\n", clean(m.GetRepo()), m.GetNumber())
1231+ return err
1232+}
1233+
11421234 // signedNote says that a squash or merge commit is signed by the server's
11431235 // key, with its fingerprint when the server tells it.
11441236 func signedNote(ctx context.Context, c *client.Client) string {
M internal/app/cli_mr_e2e_test.go +1 -1
@@ -33,7 +33,7 @@ type mrOut struct {
3333 MergeMode string `json:"merge_mode"`
3434 }
3535
36-var mrKeys = []string{"author", "created_at", "description", "head_id", "merge_mode", "merged_commit_id", "number", "repo", "source_branch", "state", "target_branch", "title", "updated_at", "url"}
36+var mrKeys = []string{"author", "auto_merge", "auto_merged", "created_at", "description", "head_id", "merge_mode", "merged_commit_id", "number", "repo", "source_branch", "state", "target_branch", "title", "updated_at", "url"}
3737
3838 type commentOut struct {
3939 ID string `json:"id"`
M internal/app/cli_mr_test.go +112 -1
@@ -51,7 +51,7 @@ func TestMRCreateMapsFlags(t *testing.T) {
5151 _ = res
5252
5353 res = runCLI(t, envFor(url), "", "mr", "create", "alpha", "--title", "t", "--source", "x", "--json").ok(t)
54- assert.JSONEq(t, `{"url":"`+url+`/alpha/merge-requests/1","repo":"alpha","number":1,"title":"Add search","description":"Finds things.","source_branch":"feat/x","target_branch":"main","state":"open","author":"agent-7","created_at":"2026-10-01T10:00:00Z","updated_at":"2026-10-02T11:30:00Z","head_id":"`+headA+`","merged_commit_id":"","merge_mode":""}`, res.stdout)
54+ assert.JSONEq(t, `{"url":"`+url+`/alpha/merge-requests/1","repo":"alpha","number":1,"title":"Add search","description":"Finds things.","source_branch":"feat/x","target_branch":"main","state":"open","author":"agent-7","created_at":"2026-10-01T10:00:00Z","updated_at":"2026-10-02T11:30:00Z","head_id":"`+headA+`","merged_commit_id":"","merge_mode":"","auto_merge":null,"auto_merged":false}`, res.stdout)
5555
5656 for _, args := range [][]string{{"mr", "create", "alpha", "--source", "x"}, {"mr", "create", "alpha", "--title", "t"}, {"mr", "create", "--title", "t", "--source", "x"}} {
5757 require.Error(t, runCLI(t, envFor(url), "", args...).err, "%v", args)
@@ -578,3 +578,114 @@ func TestJSONFileOmitted(t *testing.T) {
578578 assert.Contains(t, string(raw), `"truncated":true`)
579579 assert.Equal(t, 2, strings.Count(string(raw), `"omitted"`))
580580 }
581+
582+func autoMergeFake(t *testing.T, features ...string) (*fakeAPI, string) {
583+ t.Helper()
584+ auto := proto.Clone(mrOne).(*ermite.MergeRequest)
585+ auto.AutoMerge = &ermite.AutoMerge{Mode: ermite.MergeMode_MERGE_MODE_SQUASH, HeadId: headA, EnabledBy: "owner", EnabledAt: ts("2026-10-03T08:00:00Z")}
586+ f := &fakeAPI{
587+ enableAuto: func(*ermite.EnableAutoMergeRequest) (*ermite.EnableAutoMergeResponse, error) {
588+ return &ermite.EnableAutoMergeResponse{MergeRequest: auto}, nil
589+ },
590+ cancelAuto: func(*ermite.CancelAutoMergeRequest) (*ermite.CancelAutoMergeResponse, error) {
591+ return &ermite.CancelAutoMergeResponse{MergeRequest: mrOne}, nil
592+ },
593+ getVersion: func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) {
594+ return &ermite.GetVersionResponse{Version: "test", Features: features}, nil
595+ },
596+ }
597+ return f, f.serve(t)
598+}
599+
600+func TestMRMergeAuto(t *testing.T) {
601+ f, url := autoMergeFake(t, "merge_requests.auto_merge")
602+ res := runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--auto", "--mode", "squash", "--head", headA).ok(t)
603+ assert.Equal(t, "auto-merge enabled on alpha!1: squash into main when ready (enabled by owner)\n", res.stdout)
604+ calls, reqs := f.snapshot()
605+ assert.Equal(t, []string{"GetVersion", "EnableAutoMerge"}, calls)
606+ got := reqs[1].(*ermite.EnableAutoMergeRequest)
607+ assert.Equal(t, "alpha", got.GetRepo())
608+ assert.EqualValues(t, 1, got.GetNumber())
609+ assert.Equal(t, ermite.MergeMode_MERGE_MODE_SQUASH, got.GetMode())
610+ assert.Equal(t, headA, got.GetHeadId())
611+
612+ res = runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--auto", "--head", headA, "--json").ok(t)
613+ var out struct {
614+ AutoMerge struct {
615+ Mode string `json:"mode"`
616+ HeadID string `json:"head_id"`
617+ EnabledBy string `json:"enabled_by"`
618+ EnabledAt string `json:"enabled_at"`
619+ } `json:"auto_merge"`
620+ }
621+ require.NoError(t, json.Unmarshal([]byte(res.stdout), &out))
622+ assert.Equal(t, "squash", out.AutoMerge.Mode)
623+ assert.Equal(t, headA, out.AutoMerge.HeadID)
624+ assert.Equal(t, "owner", out.AutoMerge.EnabledBy)
625+ assert.Equal(t, "2026-10-03T08:00:00Z", out.AutoMerge.EnabledAt)
626+ _, reqs = f.snapshot()
627+ assert.Equal(t, ermite.MergeMode_MERGE_MODE_FAST_FORWARD, reqs[len(reqs)-1].(*ermite.EnableAutoMergeRequest).GetMode(), "fast-forward by default")
628+
629+ for _, args := range [][]string{
630+ {"--auto", "--head", headA, "--cancel-auto"}, {"--auto", "--head", headA, "--delete-branch"}, {"--cancel-auto", "--head", headA}, {"--auto"},
631+ } {
632+ require.Error(t, runCLI(t, envFor(url), "", append([]string{"mr", "merge", "alpha", "1"}, args...)...).err, "%v", args)
633+ }
634+}
635+
636+func TestMRMergeAutoNeedsTheFeature(t *testing.T) {
637+ f, url := autoMergeFake(t, "merge_requests")
638+ res := runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--auto", "--head", headA)
639+ require.Error(t, res.err)
640+ assert.Contains(t, res.err.Error(), "merge_requests.auto_merge")
641+ calls, _ := f.snapshot()
642+ assert.NotContains(t, calls, "EnableAutoMerge")
643+}
644+
645+func TestMRMergeCancelAuto(t *testing.T) {
646+ f, url := autoMergeFake(t, "merge_requests.auto_merge")
647+ res := runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--cancel-auto").ok(t)
648+ assert.Equal(t, "auto-merge cancelled on alpha!1\n", res.stdout)
649+ calls, reqs := f.snapshot()
650+ assert.Equal(t, []string{"GetVersion", "CancelAutoMerge"}, calls)
651+ assert.EqualValues(t, 1, reqs[1].(*ermite.CancelAutoMergeRequest).GetNumber())
652+
653+ res = runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--cancel-auto", "--json").ok(t)
654+ assert.Regexp(t, `"auto_merge":\s*null`, res.stdout)
655+
656+ for _, extra := range []string{"--delete-branch", "--head=" + headA} {
657+ require.Error(t, runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--cancel-auto", extra).err, extra)
658+ }
659+}
660+
661+func TestMRShowAutoMerge(t *testing.T) {
662+ m := proto.Clone(mrOne).(*ermite.MergeRequest)
663+ m.AutoMerge = &ermite.AutoMerge{Mode: ermite.MergeMode_MERGE_MODE_SQUASH, HeadId: headA, EnabledBy: "owner", EnabledAt: ts("2026-10-03T08:00:00Z")}
664+ cur := m
665+ f := &fakeAPI{getMR: func(*ermite.GetMergeRequestRequest) (*ermite.GetMergeRequestResponse, error) {
666+ return &ermite.GetMergeRequestResponse{MergeRequest: cur}, nil
667+ }}
668+ url := f.serve(t)
669+
670+ res := runCLI(t, envFor(url), "", "mr", "show", "alpha", "1").ok(t)
671+ assert.Regexp(t, `(?m)^auto-merge\s+squash when ready \(enabled by owner\)$`, res.stdout)
672+ res = runCLI(t, envFor(url), "", "mr", "show", "alpha", "1", "--json").ok(t)
673+ assert.Contains(t, res.stdout, `"mode": "squash"`)
674+ assert.Contains(t, res.stdout, `"enabled_by": "owner"`)
675+ assert.Contains(t, res.stdout, `"auto_merged": false`)
676+
677+ merged := proto.Clone(mrOne).(*ermite.MergeRequest)
678+ merged.State, merged.AutoMerged = ermite.MergeRequestState_MERGE_REQUEST_STATE_MERGED, true
679+ merged.MergedCommitId, merged.MergeMode = strings.Repeat("d", 40), ermite.MergeMode_MERGE_MODE_SQUASH
680+ cur = merged
681+ res = runCLI(t, envFor(url), "", "mr", "show", "alpha", "1").ok(t)
682+ assert.Regexp(t, `(?m)^auto-merged\s+yes`, res.stdout)
683+ assert.NotContains(t, res.stdout, "when ready")
684+ res = runCLI(t, envFor(url), "", "mr", "show", "alpha", "1", "--json").ok(t)
685+ assert.Contains(t, res.stdout, `"auto_merged": true`)
686+ assert.Regexp(t, `"auto_merge":\s*null`, res.stdout)
687+
688+ cur = mrOne
689+ res = runCLI(t, envFor(url), "", "mr", "show", "alpha", "1").ok(t)
690+ assert.NotContains(t, res.stdout, "auto-merge")
691+}
M internal/app/cli_repo.go +17 -6
@@ -36,6 +36,7 @@ type jsonRepo struct {
3636 // MergeModes are the allowed merge modes ("fast-forward", "squash", "merge").
3737 MergeModes []string `json:"merge_modes"`
3838 FailingChecksBlock bool `json:"failing_checks_block"`
39+ AllowAutoMerge bool `json:"allow_auto_merge"`
3940 // Issues is the issue mode ("off", "owner" or "public").
4041 Issues string `json:"issues"`
4142 }
@@ -48,6 +49,7 @@ func toJSONRepo(r *ermite.Repo) jsonRepo {
4849 return jsonRepo{
4950 MergeModes: modes,
5051 FailingChecksBlock: r.GetSettings().GetFailingChecksBlock(),
52+ AllowAutoMerge: r.GetSettings().GetAllowAutoMerge(),
5153 Issues: issuesModeName(r.GetSettings().GetIssues()),
5254 Name: r.GetName(),
5355 Description: r.GetDescription(),
@@ -115,8 +117,8 @@ const jsonRepoHelp = `
115117 JSON fields of a repository (--json): name, description, visibility ("public" or
116118 "private"), default_branch, created_at and pushed_at (RFC 3339 UTC; pushed_at is
117119 null until the first push), merge_modes (array of the allowed merge modes, "fast-forward", "squash" and/or
118-"merge"), failing_checks_block (whether a failing check prevents a merge) and issues (the issue
119-mode: "off", "owner" or "public").`
120+"merge"), failing_checks_block (whether a failing check prevents a merge), allow_auto_merge (whether
121+auto-merge can be enabled) and issues (the issue mode: "off", "owner" or "public").`
120122
121123 func newRepoCmd(env *cliEnv) *cobra.Command {
122124 repo := &cobra.Command{
@@ -229,6 +231,7 @@ func printRepo(w io.Writer, r *ermite.Repo) error {
229231 {"last push", shortTime(r.GetPushedAt(), "never")},
230232 {"merge modes", orDash(strings.Join(modes, ", "))},
231233 {"failing checks block", strconv.FormatBool(r.GetSettings().GetFailingChecksBlock())},
234+ {"allow auto-merge", strconv.FormatBool(r.GetSettings().GetAllowAutoMerge())},
232235 {"issues", issuesModeName(r.GetSettings().GetIssues())},
233236 })
234237 }
@@ -308,7 +311,7 @@ func buildCreateRepo(name, description string, topics []string, visibility strin
308311 func newRepoEditCmd(env *cliEnv) *cobra.Command {
309312 var description, visibility, defaultBranch string
310313 var topics, mergeModes []string
311- var failingChecksBlock bool
314+ var failingChecksBlock, allowAutoMerge bool
312315 var issues string
313316 cmd := &cobra.Command{
314317 Use: "edit <name>",
@@ -331,6 +334,7 @@ func newRepoEditCmd(env *cliEnv) *cobra.Command {
331334 cmd.Flags().StringVar(&defaultBranch, "default-branch", "", "short name of the new default branch")
332335 cmd.Flags().StringSliceVar(&mergeModes, "merge-modes", nil, "allowed merge modes, comma-separated: fast-forward, squash, merge (all by default; list the ones to keep)")
333336 cmd.Flags().BoolVar(&failingChecksBlock, "failing-checks-block", true, "a failing check prevents a merge (--failing-checks-block=false to allow it)")
337+ cmd.Flags().BoolVar(&allowAutoMerge, "allow-auto-merge", false, "allow auto-merge on merge requests (--allow-auto-merge=false to forbid it)")
334338 cmd.Flags().StringVar(&issues, "issues", "", "issue mode: off, owner or public")
335339 _ = cmd.RegisterFlagCompletionFunc("issues", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
336340 return []string{issuesOff, issuesOwner, issuesPublic}, cobra.ShellCompDirectiveNoFileComp
@@ -341,7 +345,7 @@ func newRepoEditCmd(env *cliEnv) *cobra.Command {
341345 })
342346 asJSON := addJSONFlag(cmd)
343347 cmd.RunE = func(cmd *cobra.Command, args []string) error {
344- req, err := buildUpdateRepo(cmd, args[0], description, topics, visibility, defaultBranch, mergeModes, failingChecksBlock, issues)
348+ req, err := buildUpdateRepo(cmd, args[0], description, topics, visibility, defaultBranch, mergeModes, failingChecksBlock, allowAutoMerge, issues)
345349 if err != nil {
346350 return err
347351 }
@@ -365,7 +369,7 @@ func newRepoEditCmd(env *cliEnv) *cobra.Command {
365369
366370 // buildUpdateRepo maps the flags of `repo edit` to the request: only the flags
367371 // the user passed go into the update mask.
368-func buildUpdateRepo(cmd *cobra.Command, name, description string, topics []string, visibility, defaultBranch string, mergeModes []string, failingChecksBlock bool, issues string) (*ermite.UpdateRepoRequest, error) {
372+func buildUpdateRepo(cmd *cobra.Command, name, description string, topics []string, visibility, defaultBranch string, mergeModes []string, failingChecksBlock, allowAutoMerge bool, issues string) (*ermite.UpdateRepoRequest, error) {
369373 req := &ermite.UpdateRepoRequest{Repo: name}
370374 var paths []string
371375 if cmd.Flags().Changed("description") {
@@ -414,6 +418,13 @@ func buildUpdateRepo(cmd *cobra.Command, name, description string, topics []stri
414418 req.Settings.FailingChecksBlock = failingChecksBlock
415419 paths = append(paths, "settings.failing_checks_block")
416420 }
421+ if cmd.Flags().Changed("allow-auto-merge") {
422+ if req.Settings == nil {
423+ req.Settings = &ermite.RepoSettings{}
424+ }
425+ req.Settings.AllowAutoMerge = allowAutoMerge
426+ paths = append(paths, "settings.allow_auto_merge")
427+ }
417428 if cmd.Flags().Changed("issues") {
418429 mode, err := parseIssuesMode(issues)
419430 if err != nil {
@@ -423,7 +434,7 @@ func buildUpdateRepo(cmd *cobra.Command, name, description string, topics []stri
423434 req.Issues = &mode
424435 }
425436 if len(paths) == 0 && req.Issues == nil {
426- return nil, errors.New("nothing to change: give at least one of --description, --topics, --visibility, --default-branch, --merge-modes, --failing-checks-block, --issues")
437+ return nil, errors.New("nothing to change: give at least one of --description, --topics, --visibility, --default-branch, --merge-modes, --failing-checks-block, --allow-auto-merge, --issues")
427438 }
428439 req.UpdateMask = &fieldmaskpb.FieldMask{Paths: paths}
429440 return req, nil
M internal/app/cli_repo_test.go +34 -4
@@ -54,8 +54,8 @@ func TestRepoList(t *testing.T) {
5454 {
5555 name: "json", repos: both, args: []string{"repo", "list", "--json"}, wantJSON: true,
5656 wantStdout: `[
57- {"name":"alpha","description":"The first one","topics":["cli","go"],"visibility":"public","default_branch":"main","created_at":"2026-01-02T03:04:05Z","pushed_at":"2026-03-04T05:06:07Z","merge_modes":["fast-forward","squash"],"failing_checks_block":true,"issues":"off"},
58- {"name":"beta","description":"","topics":[],"visibility":"private","default_branch":"trunk","created_at":"2026-02-02T00:00:00Z","pushed_at":null,"merge_modes":["squash"],"failing_checks_block":false,"issues":"off"}
57+ {"name":"alpha","description":"The first one","topics":["cli","go"],"visibility":"public","default_branch":"main","created_at":"2026-01-02T03:04:05Z","pushed_at":"2026-03-04T05:06:07Z","merge_modes":["fast-forward","squash"],"failing_checks_block":true,"allow_auto_merge":false,"issues":"off"},
58+ {"name":"beta","description":"","topics":[],"visibility":"private","default_branch":"trunk","created_at":"2026-02-02T00:00:00Z","pushed_at":null,"merge_modes":["squash"],"failing_checks_block":false,"allow_auto_merge":false,"issues":"off"}
5959 ]`,
6060 },
6161 {
@@ -116,12 +116,13 @@ func TestRepoShow(t *testing.T) {
116116 "last push 2026-03-04 05:06\n"+
117117 "merge modes fast-forward, squash\n"+
118118 "failing checks block true\n"+
119+ "allow auto-merge false\n"+
119120 "issues off\n", res.stdout)
120121 _, reqs := f.snapshot()
121122 assert.Equal(t, "alpha", reqs[0].(*ermite.GetRepoRequest).GetRepo())
122123
123124 res = runCLI(t, envFor(url), "", "repo", "show", "alpha", "--json").ok(t)
124- assert.JSONEq(t, `{"name":"alpha","description":"The first one","topics":["cli","go"],"visibility":"public","default_branch":"main","created_at":"2026-01-02T03:04:05Z","pushed_at":"2026-03-04T05:06:07Z","merge_modes":["fast-forward","squash"],"failing_checks_block":true,"issues":"off"}`, res.stdout)
125+ assert.JSONEq(t, `{"name":"alpha","description":"The first one","topics":["cli","go"],"visibility":"public","default_branch":"main","created_at":"2026-01-02T03:04:05Z","pushed_at":"2026-03-04T05:06:07Z","merge_modes":["fast-forward","squash"],"failing_checks_block":true,"allow_auto_merge":false,"issues":"off"}`, res.stdout)
125126 }
126127
127128 func TestRepoShowNeedsOneArg(t *testing.T) {
@@ -177,7 +178,7 @@ func TestRepoCreateJSON(t *testing.T) {
177178 return &ermite.CreateRepoResponse{Repo: repoBeta}, nil
178179 }}
179180 res := runCLI(t, envFor(f.serve(t)), "", "repo", "create", "beta", "--json").ok(t)
180- assert.JSONEq(t, `{"name":"beta","description":"","topics":[],"visibility":"private","default_branch":"trunk","created_at":"2026-02-02T00:00:00Z","pushed_at":null,"merge_modes":["squash"],"failing_checks_block":false,"issues":"off"}`, res.stdout)
181+ assert.JSONEq(t, `{"name":"beta","description":"","topics":[],"visibility":"private","default_branch":"trunk","created_at":"2026-02-02T00:00:00Z","pushed_at":null,"merge_modes":["squash"],"failing_checks_block":false,"allow_auto_merge":false,"issues":"off"}`, res.stdout)
181182 }
182183
183184 func TestRepoEditMask(t *testing.T) {
@@ -215,6 +216,13 @@ func TestRepoEditMask(t *testing.T) {
215216 {"failing checks block", []string{"--failing-checks-block"}, []string{"settings.failing_checks_block"}, func(t *testing.T, r *ermite.UpdateRepoRequest) {
216217 assert.True(t, r.GetSettings().GetFailingChecksBlock())
217218 }},
219+ {"allow auto-merge", []string{"--allow-auto-merge"}, []string{"settings.allow_auto_merge"}, func(t *testing.T, r *ermite.UpdateRepoRequest) {
220+ assert.True(t, r.GetSettings().GetAllowAutoMerge())
221+ assert.False(t, r.GetSettings().GetFailingChecksBlock())
222+ }},
223+ {"forbid auto-merge", []string{"--allow-auto-merge=false"}, []string{"settings.allow_auto_merge"}, func(t *testing.T, r *ermite.UpdateRepoRequest) {
224+ assert.False(t, r.GetSettings().GetAllowAutoMerge())
225+ }},
218226 {"all", []string{"--default-branch", "dev", "--visibility", "private", "--description", "d"},
219227 []string{"description", "visibility", "default_branch"}, func(t *testing.T, r *ermite.UpdateRepoRequest) {}},
220228 }
@@ -436,3 +444,25 @@ func TestToJSONRepoFromProto(t *testing.T) {
436444 assert.Equal(t, "private", r.Visibility)
437445 assert.Equal(t, "unknown", toJSONRepo(&ermite.Repo{}).Visibility)
438446 }
447+
448+func TestRepoEditAllowAutoMerge(t *testing.T) {
449+ updated := &ermite.Repo{Name: "alpha", Visibility: ermite.Visibility_VISIBILITY_PUBLIC, DefaultBranch: "main",
450+ Settings: &ermite.RepoSettings{AllowAutoMerge: true}}
451+ f := &fakeAPI{updateRepo: func(*ermite.UpdateRepoRequest) (*ermite.UpdateRepoResponse, error) {
452+ return &ermite.UpdateRepoResponse{Repo: updated}, nil
453+ }}
454+ url := f.serve(t)
455+
456+ res := runCLI(t, envFor(url), "", "repo", "edit", "alpha", "--allow-auto-merge").ok(t)
457+ assert.Regexp(t, `(?m)^allow auto-merge\s+true$`, res.stdout)
458+ _, reqs := f.snapshot()
459+ got := reqs[0].(*ermite.UpdateRepoRequest)
460+ assert.Equal(t, []string{"settings.allow_auto_merge"}, got.GetUpdateMask().GetPaths())
461+ assert.True(t, got.GetSettings().GetAllowAutoMerge())
462+
463+ res = runCLI(t, envFor(url), "", "repo", "edit", "alpha", "--allow-auto-merge", "--failing-checks-block=false", "--json").ok(t)
464+ assert.Contains(t, res.stdout, `"allow_auto_merge": true`)
465+ _, reqs = f.snapshot()
466+ got = reqs[1].(*ermite.UpdateRepoRequest)
467+ assert.ElementsMatch(t, []string{"settings.allow_auto_merge", "settings.failing_checks_block"}, got.GetUpdateMask().GetPaths())
468+}
M internal/app/cli_version.go +1 -0
@@ -126,4 +126,5 @@ var featureUse = map[string]string{
126126 client.FeatureCIArtifacts: "`ermite run artifacts` and `run download` need: the commands will fail on this server",
127127 client.FeaturePasskeys: "`ermite web passkey` needs: the commands will fail on this server (passkeys need an https public URL with a DNS host name)",
128128 client.FeatureCommitSignatures: "`ermite signing-key trust` needs: the commands will fail on this server",
129+ client.FeatureAutoMerge: "`ermite mr merge --auto` and `repo edit --allow-auto-merge` need: the commands are refused",
129130 }
M internal/app/cli_version_test.go +5 -5
@@ -17,7 +17,7 @@ import (
1717 )
1818
1919 // allFeatures are the features this client uses.
20-var allFeatures = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "merge_requests.mergeability", "signing_key", "session", "token.branch_prefixes", "ci.runs", "ci.runners", "ci.secrets", "ci.dispatch", "ci.workflows", "ci.artifacts", "commit_signatures", "passkeys"}
20+var allFeatures = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "merge_requests.mergeability", "signing_key", "session", "token.branch_prefixes", "ci.runs", "ci.runners", "ci.secrets", "ci.dispatch", "ci.workflows", "ci.artifacts", "commit_signatures", "merge_requests.auto_merge", "passkeys"}
2121
2222 // Every feature the client uses needs a sentence for the version warning.
2323 func TestEveryUsedFeatureExplainsWhatBreaks(t *testing.T) {
@@ -45,15 +45,15 @@ func TestVersionWithServer(t *testing.T) {
4545 f := versionFake(allFeatures...)
4646 url := f.serve(t)
4747 res := runCLI(t, envFor(url), "", "version").ok(t)
48- assert.Equal(t, "client ermite v0.0.1\nserver v0.4.0 at "+url+"\nfeatures repo.update, events.resume, merge_requests, merge_requests.delete_branch, issues, merge_requests.mergeability, signing_key, session, token.branch_prefixes, ci.runs, ci.runners, ci.secrets, ci.dispatch, ci.workflows, ci.artifacts, commit_signatures, passkeys\n", res.stdout)
48+ assert.Equal(t, "client ermite v0.0.1\nserver v0.4.0 at "+url+"\nfeatures repo.update, events.resume, merge_requests, merge_requests.delete_branch, issues, merge_requests.mergeability, signing_key, session, token.branch_prefixes, ci.runs, ci.runners, ci.secrets, ci.dispatch, ci.workflows, ci.artifacts, commit_signatures, merge_requests.auto_merge, passkeys\n", res.stdout)
4949 assert.Empty(t, res.stderr, "no warning when every feature is there")
5050
5151 res = runCLI(t, envFor(url), "", "version", "--json").ok(t)
52- assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","events.resume","merge_requests","merge_requests.delete_branch","issues","merge_requests.mergeability","signing_key","session","token.branch_prefixes","ci.runs","ci.runners","ci.secrets","ci.dispatch","ci.workflows","ci.artifacts","commit_signatures","passkeys"],"missing_features":[]}}`, res.stdout)
52+ assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","events.resume","merge_requests","merge_requests.delete_branch","issues","merge_requests.mergeability","signing_key","session","token.branch_prefixes","ci.runs","ci.runners","ci.secrets","ci.dispatch","ci.workflows","ci.artifacts","commit_signatures","merge_requests.auto_merge","passkeys"],"missing_features":[]}}`, res.stdout)
5353 }
5454
5555 func TestVersionWarnsAboutMissingFeatures(t *testing.T) {
56- f := versionFake("repo.update", "merge_requests", "merge_requests.delete_branch", "issues", "merge_requests.mergeability", "signing_key", "session", "token.branch_prefixes", "ci.runs", "ci.runners", "ci.secrets", "ci.dispatch", "ci.workflows", "ci.artifacts", "commit_signatures", "passkeys")
56+ f := versionFake("repo.update", "merge_requests", "merge_requests.delete_branch", "issues", "merge_requests.mergeability", "signing_key", "session", "token.branch_prefixes", "ci.runs", "ci.runners", "ci.secrets", "ci.dispatch", "ci.workflows", "ci.artifacts", "commit_signatures", "merge_requests.auto_merge", "passkeys")
5757 url := f.serve(t)
5858 res := runCLI(t, envFor(url), "", "version").ok(t)
5959 assert.Contains(t, res.stdout, "v0.4.0")
@@ -61,7 +61,7 @@ func TestVersionWarnsAboutMissingFeatures(t *testing.T) {
6161 assert.NotContains(t, res.stderr, "repo.update")
6262
6363 res = runCLI(t, envFor(url), "", "version", "--json").ok(t)
64- assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","merge_requests","merge_requests.delete_branch","issues","merge_requests.mergeability","signing_key","session","token.branch_prefixes","ci.runs","ci.runners","ci.secrets","ci.dispatch","ci.workflows","ci.artifacts","commit_signatures","passkeys"],"missing_features":["events.resume"]}}`, res.stdout)
64+ assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","merge_requests","merge_requests.delete_branch","issues","merge_requests.mergeability","signing_key","session","token.branch_prefixes","ci.runs","ci.runners","ci.secrets","ci.dispatch","ci.workflows","ci.artifacts","commit_signatures","merge_requests.auto_merge","passkeys"],"missing_features":["events.resume"]}}`, res.stdout)
6565 assert.Contains(t, res.stderr, "events.resume", "the warning also goes to stderr with --json")
6666
6767 f = versionFake()
M internal/app/cli_watch.go +25 -3
@@ -34,6 +34,10 @@ type jsonEvent struct {
3434 // State and FailureClass are set for a mirror_failed event.
3535 State string `json:"state,omitempty"`
3636 FailureClass string `json:"failure_class,omitempty"`
37+ // Auto is set for merge_request_merged: auto-merge made the merge.
38+ Auto bool `json:"auto,omitempty"`
39+ // Reason is set for merge_request_auto_merge_cancelled.
40+ Reason string `json:"reason,omitempty"`
3741 }
3842
3943 type jsonRefUpdate struct {
@@ -53,6 +57,8 @@ const (
5357 eventMROpened = "merge_request_opened"
5458 eventMRMerged = "merge_request_merged"
5559 eventMRClosed = "merge_request_closed"
60+ eventMRAutoEnabled = "merge_request_auto_merge_enabled"
61+ eventMRAutoCancelled = "merge_request_auto_merge_cancelled"
5662 eventCommentAdded = "comment_added"
5763 eventCommentEdited = "comment_edited"
5864 eventCommentDeleted = "comment_deleted"
@@ -87,6 +93,14 @@ func toJSONEvent(ev *ermite.Event, baseURL string) (jsonEvent, bool) {
8793 case *ermite.Event_MergeRequestMerged:
8894 m := toJSONMergeRequest(k.MergeRequestMerged.GetMergeRequest(), baseURL)
8995 out.Type, out.Repo, out.MergeRequest = eventMRMerged, m.Repo, &m
96+ out.Auto = k.MergeRequestMerged.GetAuto()
97+ case *ermite.Event_MergeRequestAutoMergeEnabled:
98+ m := toJSONMergeRequest(k.MergeRequestAutoMergeEnabled.GetMergeRequest(), baseURL)
99+ out.Type, out.Repo, out.MergeRequest = eventMRAutoEnabled, m.Repo, &m
100+ case *ermite.Event_MergeRequestAutoMergeCancelled:
101+ m := toJSONMergeRequest(k.MergeRequestAutoMergeCancelled.GetMergeRequest(), baseURL)
102+ out.Type, out.Repo, out.MergeRequest = eventMRAutoCancelled, m.Repo, &m
103+ out.Reason = k.MergeRequestAutoMergeCancelled.GetReason()
90104 case *ermite.Event_MergeRequestClosed:
91105 m := toJSONMergeRequest(k.MergeRequestClosed.GetMergeRequest(), baseURL)
92106 out.Type, out.Repo, out.MergeRequest = eventMRClosed, m.Repo, &m
@@ -140,6 +154,13 @@ func eventLine(e jsonEvent) string {
140154 detail = e.Repo + " " + strings.Join(parts, ", ")
141155 case eventMROpened, eventMRMerged, eventMRClosed:
142156 detail = fmt.Sprintf("%s!%d %s -> %s", e.Repo, e.MergeRequest.Number, e.MergeRequest.SourceBranch, e.MergeRequest.TargetBranch)
157+ if e.Auto {
158+ detail += " (auto-merge)"
159+ }
160+ case eventMRAutoEnabled:
161+ detail = fmt.Sprintf("%s!%d %s when ready", e.Repo, e.MergeRequest.Number, e.MergeRequest.AutoMerge.Mode)
162+ case eventMRAutoCancelled:
163+ detail = fmt.Sprintf("%s!%d: %s", e.Repo, e.MergeRequest.Number, e.Reason)
143164 case eventCommentAdded, eventCommentEdited, eventCommentDeleted, eventCommentResolved:
144165 detail = fmt.Sprintf("%s!%d by %s", e.Repo, e.Number, e.Comment.Author)
145166 case eventMRMergeability:
@@ -198,18 +219,19 @@ func newWatchCmd(env *cliEnv) *cobra.Command {
198219 Use: "watch [repo]",
199220 Short: "Print repository events as they happen",
200221 Long: "Print one line per event (repository created, updated or deleted, refs pushed, merge requests\n" +
201- "opened, merged or closed, notes added, edited, deleted or resolved) for every\n" +
222+ "opened, merged (by auto-merge or not) or closed, auto-merge enabled or cancelled, notes added, edited, deleted or resolved) for every\n" +
202223 "repository you can see, or only for [repo]. It runs until interrupted. When the connection drops it\n" +
203224 "reconnects with a growing delay and resumes after the last event it printed; if the server no longer\n" +
204225 "has that event it prints a gap event: reload what you display. Connection notices go to standard\n" +
205226 "error.\n\n" +
206227 "--json prints one JSON object per line (NDJSON). Fields: id, time (RFC 3339 UTC), type\n" +
207228 "(repo_created, repo_updated, repo_deleted, pushed, merge_request_opened, merge_request_merged,\n" +
208- "merge_request_closed, comment_added, comment_edited, comment_deleted, comment_resolved or gap)\n" +
229+ "merge_request_closed, merge_request_auto_merge_enabled, merge_request_auto_merge_cancelled\n" +
230+ "(with reason), comment_added, comment_edited, comment_deleted, comment_resolved or gap)\n" +
209231 "and, depending on the type, repo (name),\n" +
210232 "repository (the repository fields of `repo show --json`), ref_updates (array of ref, old_id,\n" +
211233 "new_id; an empty id means the ref was created or deleted), merge_request (the fields of\n" +
212- "`mr show --json`) and, for comment events, number (of the merge request) and comment. Unknown\n" +
234+ "`mr show --json`; merge_request_merged also has auto, true for an auto-merge) and, for comment events, number (of the merge request) and comment. Unknown\n" +
213235 "event types are skipped.",
214236 Args: cobra.MaximumNArgs(1),
215237 ValidArgsFunction: completeRepoNames(env),
M internal/app/cli_watch_test.go +55 -1
@@ -95,7 +95,7 @@ func TestWatchNDJSON(t *testing.T) {
9595 assert.True(t, json.Valid([]byte(sc.Text())), "one JSON value per line: %s", sc.Text())
9696 }
9797 require.Len(t, lines, 5)
98- assert.JSONEq(t, `{"id":"1","time":"2026-10-10T12:00:00Z","type":"repo_created","repo":"beta","repository":{"name":"beta","description":"","topics":[],"visibility":"private","default_branch":"trunk","created_at":"2026-02-02T00:00:00Z","pushed_at":null,"merge_modes":["squash"],"failing_checks_block":false,"issues":"off"}}`, lines[0])
98+ assert.JSONEq(t, `{"id":"1","time":"2026-10-10T12:00:00Z","type":"repo_created","repo":"beta","repository":{"name":"beta","description":"","topics":[],"visibility":"private","default_branch":"trunk","created_at":"2026-02-02T00:00:00Z","pushed_at":null,"merge_modes":["squash"],"failing_checks_block":false,"allow_auto_merge":false,"issues":"off"}}`, lines[0])
9999 assert.JSONEq(t, `{"id":"3","time":"2026-10-10T12:00:00Z","type":"pushed","repo":"alpha","ref_updates":[
100100 {"ref":"refs/heads/main","old_id":"1111111111111111","new_id":"2222222222222222"},
101101 {"ref":"refs/heads/new","old_id":"","new_id":"3333333333333333"},
@@ -283,3 +283,57 @@ func TestMirrorWatchEvents(t *testing.T) {
283283 assert.Contains(t, eventLine(recovered), "mirror_recovered alpha")
284284 assert.NotContains(t, eventLine(recovered), " \n")
285285 }
286+
287+func TestWatchAutoMergeEvents(t *testing.T) {
288+ events := []*ermite.WatchResponse{
289+ evt("1", func(e *ermite.Event) {
290+ e.Kind = &ermite.Event_MergeRequestAutoMergeEnabled{MergeRequestAutoMergeEnabled: &ermite.MergeRequestAutoMergeEnabled{MergeRequest: &ermite.MergeRequest{
291+ Repo: "alpha", Number: 1, SourceBranch: "feat/x", TargetBranch: "main",
292+ AutoMerge: &ermite.AutoMerge{Mode: ermite.MergeMode_MERGE_MODE_SQUASH, HeadId: headA, EnabledBy: "owner"},
293+ }}}
294+ }),
295+ evt("2", func(e *ermite.Event) {
296+ e.Kind = &ermite.Event_MergeRequestAutoMergeCancelled{MergeRequestAutoMergeCancelled: &ermite.MergeRequestAutoMergeCancelled{MergeRequest: mrOne, Reason: "the branch moved"}}
297+ }),
298+ evt("3", func(e *ermite.Event) {
299+ e.Kind = &ermite.Event_MergeRequestMerged{MergeRequestMerged: &ermite.MergeRequestMerged{MergeRequest: mrOne, Auto: true}}
300+ }),
301+ evt("4", func(e *ermite.Event) {
302+ e.Kind = &ermite.Event_MergeRequestMerged{MergeRequestMerged: &ermite.MergeRequestMerged{MergeRequest: mrOne}}
303+ }),
304+ }
305+ f := &fakeAPI{watch: finishing(events)}
306+ url := f.serve(t)
307+
308+ res := runCLI(t, envFor(url), "", "watch")
309+ require.Error(t, res.err)
310+ assert.Equal(t, ""+
311+ "2026-10-10T12:00:00Z merge_request_auto_merge_enabled alpha!1 squash when ready\n"+
312+ "2026-10-10T12:00:00Z merge_request_auto_merge_cancelled alpha!1: the branch moved\n"+
313+ "2026-10-10T12:00:00Z merge_request_merged alpha!1 feat/x -> main (auto-merge)\n"+
314+ "2026-10-10T12:00:00Z merge_request_merged alpha!1 feat/x -> main\n", res.stdout)
315+
316+ res = runCLI(t, envFor(url), "", "watch", "--json")
317+ lines := strings.Split(strings.TrimSpace(res.stdout), "\n")
318+ require.Len(t, lines, 4)
319+ var enabled, cancelled, auto, manual struct {
320+ Type string `json:"type"`
321+ Reason string `json:"reason"`
322+ Auto bool `json:"auto"`
323+ MergeRequest *struct {
324+ AutoMerge *struct {
325+ Mode string `json:"mode"`
326+ } `json:"auto_merge"`
327+ } `json:"merge_request"`
328+ }
329+ for i, dst := range []any{&enabled, &cancelled, &auto, &manual} {
330+ require.NoError(t, json.Unmarshal([]byte(lines[i]), dst))
331+ }
332+ assert.Equal(t, "merge_request_auto_merge_enabled", enabled.Type)
333+ require.NotNil(t, enabled.MergeRequest.AutoMerge)
334+ assert.Equal(t, "squash", enabled.MergeRequest.AutoMerge.Mode)
335+ assert.Equal(t, "merge_request_auto_merge_cancelled", cancelled.Type)
336+ assert.Equal(t, "the branch moved", cancelled.Reason)
337+ assert.True(t, auto.Auto)
338+ assert.False(t, manual.Auto)
339+}
M internal/app/forge.go +13 -0
@@ -143,6 +143,7 @@ type forge struct {
143143 SigningKey *usecase.SigningKey
144144 // Mergeability keeps the mergeability of the open merge requests up to date.
145145 Mergeability *usecase.Mergeability
146+ AutoMerge *usecase.AutoMerge
146147 // Signatures are the trusted keys and the commit signature checks
147148 // (ADR 0030).
148149 Signatures *usecase.Signatures
@@ -368,6 +369,14 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
368369 Committer: committer, Bus: f.Bus, Clock: clock, Maint: f.maint,
369370 CI: f.CI, Checks: f.CI, PullRequests: f.CI, RefTips: store.CI(), Mergeability: f.Mergeability,
370371 })
372+ f.AutoMerge = usecase.NewAutoMerge(ctx, usecase.AutoMergeConfig{
373+ MRs: f.MergeRequests, Store: store.MergeRequests(), Meta: store, Git: git,
374+ Checks: f.CI, Activity: f.CI, Tokens: store.Tokens(), Clock: clock,
375+ })
376+ f.MergeRequests.WithAutoMerge(f.AutoMerge)
377+ f.CI.SetAutoMergeTrigger(f.AutoMerge)
378+ f.Repos.WithAutoMerge(f.AutoMerge).WithMergeGate(f.MergeRequests)
379+ f.Mergeability.WithAutoMerge(f.AutoMerge)
371380 f.Mergeability.Start()
372381 f.MergeRequests.WithVerifier(f.Signatures).WithMirror(f.Mirrors)
373382 f.Issues = usecase.NewIssues(usecase.IssuesConfig{
@@ -391,6 +400,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
391400 Meta: store, MergeRequests: store.MergeRequests(), Issues: store.Issues(), Pending: store.PendingIssues(),
392401 })
393402 f.CI.SetPullGate(f.MergeRequests)
403+ f.AutoMerge.Start()
394404 log.Info().Str("fingerprint", signer.PublicKey().Fingerprint).Msg("commit signing key ready")
395405
396406 f.Sessions, err = usecase.NewSessions(ctx, store.Sessions(), clock, cfg.PublicURL)
@@ -543,6 +553,9 @@ func (f *forge) Close(ctx context.Context) {
543553 if f.hooks != nil {
544554 f.hooks.Close(ctx)
545555 }
556+ if f.AutoMerge != nil {
557+ f.AutoMerge.Close(ctx)
558+ }
546559 if f.MergeRequests != nil {
547560 f.MergeRequests.Wait(ctx) // its side effects use CI and Mergeability
548561 }
M internal/domain/event.go +28 -0
@@ -131,11 +131,39 @@ type MergeRequestMerged struct {
131131 At time.Time
132132 Visibility Visibility
133133 CreatedAt time.Time
134+ // Auto says that auto-merge made the merge.
135+ Auto bool
134136 }
135137
136138 // EventKind implements Event.
137139 func (MergeRequestMerged) EventKind() string { return "mr.merged" }
138140
141+// MergeRequestAutoMergeEnabled is published when auto-merge was enabled on a
142+// merge request.
143+type MergeRequestAutoMergeEnabled struct {
144+ MergeRequest MergeRequest
145+ At time.Time
146+ Visibility Visibility
147+ CreatedAt time.Time
148+}
149+
150+// EventKind implements Event.
151+func (MergeRequestAutoMergeEnabled) EventKind() string { return "mr.auto_merge_enabled" }
152+
153+// MergeRequestAutoMergeCancelled is published when auto-merge was cancelled
154+// on a merge request, by hand or by the server.
155+type MergeRequestAutoMergeCancelled struct {
156+ MergeRequest MergeRequest
157+ // Reason says why, in words.
158+ Reason string
159+ At time.Time
160+ Visibility Visibility
161+ CreatedAt time.Time
162+}
163+
164+// EventKind implements Event.
165+func (MergeRequestAutoMergeCancelled) EventKind() string { return "mr.auto_merge_cancelled" }
166+
139167 // MergeRequestClosed is published when a merge request was closed without
140168 // merging.
141169 type MergeRequestClosed struct {
M internal/domain/event_test.go +7 -0
@@ -52,3 +52,10 @@ func TestEventKinds(t *testing.T) {
5252 assert.Equal(t, "repo.pushed", domain.PushEvent{}.EventKind())
5353 assert.Equal(t, "gap", domain.EventGap{}.EventKind())
5454 }
55+
56+func TestAutoMergeEventKinds(t *testing.T) {
57+ t.Parallel()
58+ assert.Equal(t, "mr.auto_merge_enabled", domain.MergeRequestAutoMergeEnabled{}.EventKind())
59+ assert.Equal(t, "mr.auto_merge_cancelled", domain.MergeRequestAutoMergeCancelled{}.EventKind())
60+ assert.Equal(t, "mr.merged", domain.MergeRequestMerged{Auto: true}.EventKind())
61+}
M internal/domain/mergerequest.go +21 -0
@@ -50,6 +50,9 @@ type RepoSettings struct {
5050 FailingChecksBlock bool
5151 // Issues says who may write issues; the empty mode means IssuesOff.
5252 Issues IssuesMode
53+ // AllowAutoMerge says that merge requests may be set to merge themselves
54+ // once their checks pass. Off by default.
55+ AllowAutoMerge bool
5356 }
5457
5558 // DefaultRepoSettings are the settings of a new repository: every mode, and
@@ -139,11 +142,29 @@ type MergeRequest struct {
139142 MergeMode MergeMode
140143 // CommentSeq is the id of the last comment of the request.
141144 CommentSeq int64
145+ // AutoMerge is the pending auto-merge; nil when off.
146+ AutoMerge *AutoMerge
147+ // AutoMerged says that the request was merged by auto-merge.
148+ AutoMerged bool
142149 // Version is set by the store on read: Update fails with ErrConflict when
143150 // the stored request changed since. Opaque to everyone else.
144151 Version uint64
145152 }
146153
154+// AutoMerge is the pending auto-merge of an open merge request.
155+type AutoMerge struct {
156+ // Mode is the merge mode chosen when auto-merge was enabled.
157+ Mode MergeMode
158+ // HeadID is the head it was enabled for; a new head cancels it.
159+ HeadID ObjectID
160+ // EnabledBy is who enabled it; the merge is made on their behalf.
161+ EnabledBy CIPusher
162+ // EnablerLabel is the display name of the enabler.
163+ EnablerLabel string
164+ // EnabledAt is when auto-merge was enabled.
165+ EnabledAt time.Time
166+}
167+
147168 // draftPrefixes mark a draft at the start of a title, compared without regard
148169 // to case, as Forgejo and Gitea do.
149170 var draftPrefixes = []string{"wip:", "[wip]"}
M internal/domain/mergerequest_test.go +6 -0
@@ -330,3 +330,9 @@ func TestMergeabilityAllowsAndIsFor(t *testing.T) {
330330 assert.False(t, m.IsFor(oid(t, 'a'), oid(t, 'c')))
331331 assert.False(t, domain.Mergeability{}.IsFor(domain.ObjectID{}, domain.ObjectID{}))
332332 }
333+
334+func TestRepoSettingsAllowAutoMergeDefaultOff(t *testing.T) {
335+ t.Parallel()
336+ assert.False(t, domain.DefaultRepoSettings().AllowAutoMerge)
337+ assert.False(t, domain.RepoSettings{}.AllowAutoMerge)
338+}
M internal/ports/ci.go +20 -0
@@ -367,6 +367,18 @@ type PullGate interface {
367367 WhileOpen(ctx context.Context, repo domain.RepoName, number int64, fn func() error) error
368368 }
369369
370+// MergeGate serializes a change with the merges of a repository.
371+// *usecase.MergeRequests implements it.
372+// Lock order is merge lock → store (meta.Mutate / kv). Nothing holding a
373+// store transaction or a CI lock may take the merge lock. MergeRequests never
374+// calls Repos.Update.
375+type MergeGate interface {
376+ // WithMergeLock runs fn holding the repository merge lock (also taken by
377+ // Merge, Close and auto-merge). fn must be short and must not call the
378+ // merge request use cases.
379+ WithMergeLock(ctx context.Context, repo domain.RepoName, fn func() error) error
380+}
381+
370382 // PullGit is what the refs/pull/<n>/{head,merge} of the merge requests need
371383 // from Git. Every method returns domain.ErrNotFound when the repository has no
372384 // directory.
@@ -896,3 +908,11 @@ type JobSettings struct {
896908 type CIJobSettings interface {
897909 Resolve(ctx context.Context, req JobSettingsRequest) (JobSettings, error)
898910 }
911+
912+// CIActivity tells whether CI still has queued work that may create runs for a
913+// head. *usecase.CI implements it.
914+type CIActivity interface {
915+ // Settled reports that no push trigger of the repository and no pull event
916+ // of the merge request is queued or being planned.
917+ Settled(ctx context.Context, repo domain.RepoName, number int64) bool
918+}
M internal/ports/dto.go +11 -0
@@ -51,6 +51,17 @@ type UpdateRepoParams struct {
5151 FailingChecksBlock *bool
5252 // Issues replaces the issues mode.
5353 Issues *domain.IssuesMode
54+ // AllowAutoMerge replaces the setting of that name.
55+ AllowAutoMerge *bool
56+}
57+
58+// EnableAutoMergeParams are the settings of EnableAutoMerge.
59+type EnableAutoMergeParams struct {
60+ // Mode is the merge mode to use; empty means fast-forward.
61+ Mode domain.MergeMode
62+ // HeadID is the head the caller saw; required. Auto-merge is enabled only
63+ // if the source branch still points to it.
64+ HeadID domain.ObjectID
5465 }
5566
5667 // TokenPage is a page of stored tokens, newest first.
A internal/ports/mocks/AutoMergeTrigger.go +140 -0
@@ -0,0 +1,140 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ "bnema.dev/ermite/internal/domain"
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// NewAutoMergeTrigger creates a new instance of AutoMergeTrigger. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func NewAutoMergeTrigger(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *AutoMergeTrigger {
20+ if helper, ok := t.(interface{ Helper() }); ok {
21+ helper.Helper()
22+ }
23+
24+ mock := &AutoMergeTrigger{}
25+ mock.Mock.Test(t)
26+
27+ t.Cleanup(func() {
28+ if helper, ok := t.(interface{ Helper() }); ok {
29+ helper.Helper()
30+ }
31+ mock.AssertExpectations(t)
32+ })
33+
34+ return mock
35+}
36+
37+// AutoMergeTrigger is an autogenerated mock type for the AutoMergeTrigger type
38+type AutoMergeTrigger struct {
39+ mock.Mock
40+}
41+
42+type AutoMergeTrigger_Expecter struct {
43+ mock *mock.Mock
44+}
45+
46+func (_m *AutoMergeTrigger) EXPECT() *AutoMergeTrigger_Expecter {
47+ return &AutoMergeTrigger_Expecter{mock: &_m.Mock}
48+}
49+
50+// OnAutoMergeEnabled provides a mock function for the type AutoMergeTrigger
51+func (_mock *AutoMergeTrigger) OnAutoMergeEnabled(ctx context.Context, repo domain.RepoName) {
52+ _mock.Called(ctx, repo)
53+ return
54+}
55+
56+// AutoMergeTrigger_OnAutoMergeEnabled_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OnAutoMergeEnabled'
57+type AutoMergeTrigger_OnAutoMergeEnabled_Call struct {
58+ *mock.Call
59+}
60+
61+// OnAutoMergeEnabled is a helper method to define mock.On call
62+// - ctx context.Context
63+// - repo domain.RepoName
64+func (_e *AutoMergeTrigger_Expecter) OnAutoMergeEnabled(ctx any, repo any) *AutoMergeTrigger_OnAutoMergeEnabled_Call {
65+ return &AutoMergeTrigger_OnAutoMergeEnabled_Call{Call: _e.mock.On("OnAutoMergeEnabled", ctx, repo)}
66+}
67+
68+func (_c *AutoMergeTrigger_OnAutoMergeEnabled_Call) Run(run func(ctx context.Context, repo domain.RepoName)) *AutoMergeTrigger_OnAutoMergeEnabled_Call {
69+ _c.Call.Run(func(args mock.Arguments) {
70+ var arg0 context.Context
71+ if args[0] != nil {
72+ arg0 = args[0].(context.Context)
73+ }
74+ var arg1 domain.RepoName
75+ if args[1] != nil {
76+ arg1 = args[1].(domain.RepoName)
77+ }
78+ run(
79+ arg0,
80+ arg1,
81+ )
82+ })
83+ return _c
84+}
85+
86+func (_c *AutoMergeTrigger_OnAutoMergeEnabled_Call) Return() *AutoMergeTrigger_OnAutoMergeEnabled_Call {
87+ _c.Call.Return()
88+ return _c
89+}
90+
91+func (_c *AutoMergeTrigger_OnAutoMergeEnabled_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName)) *AutoMergeTrigger_OnAutoMergeEnabled_Call {
92+ _c.Run(run)
93+ return _c
94+}
95+
96+// OnRepoChanged provides a mock function for the type AutoMergeTrigger
97+func (_mock *AutoMergeTrigger) OnRepoChanged(ctx context.Context, repo domain.RepoName) {
98+ _mock.Called(ctx, repo)
99+ return
100+}
101+
102+// AutoMergeTrigger_OnRepoChanged_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OnRepoChanged'
103+type AutoMergeTrigger_OnRepoChanged_Call struct {
104+ *mock.Call
105+}
106+
107+// OnRepoChanged is a helper method to define mock.On call
108+// - ctx context.Context
109+// - repo domain.RepoName
110+func (_e *AutoMergeTrigger_Expecter) OnRepoChanged(ctx any, repo any) *AutoMergeTrigger_OnRepoChanged_Call {
111+ return &AutoMergeTrigger_OnRepoChanged_Call{Call: _e.mock.On("OnRepoChanged", ctx, repo)}
112+}
113+
114+func (_c *AutoMergeTrigger_OnRepoChanged_Call) Run(run func(ctx context.Context, repo domain.RepoName)) *AutoMergeTrigger_OnRepoChanged_Call {
115+ _c.Call.Run(func(args mock.Arguments) {
116+ var arg0 context.Context
117+ if args[0] != nil {
118+ arg0 = args[0].(context.Context)
119+ }
120+ var arg1 domain.RepoName
121+ if args[1] != nil {
122+ arg1 = args[1].(domain.RepoName)
123+ }
124+ run(
125+ arg0,
126+ arg1,
127+ )
128+ })
129+ return _c
130+}
131+
132+func (_c *AutoMergeTrigger_OnRepoChanged_Call) Return() *AutoMergeTrigger_OnRepoChanged_Call {
133+ _c.Call.Return()
134+ return _c
135+}
136+
137+func (_c *AutoMergeTrigger_OnRepoChanged_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName)) *AutoMergeTrigger_OnRepoChanged_Call {
138+ _c.Run(run)
139+ return _c
140+}
A internal/ports/mocks/CIActivity.go +111 -0
@@ -0,0 +1,111 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ "bnema.dev/ermite/internal/domain"
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// NewCIActivity creates a new instance of CIActivity. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func NewCIActivity(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *CIActivity {
20+ if helper, ok := t.(interface{ Helper() }); ok {
21+ helper.Helper()
22+ }
23+
24+ mock := &CIActivity{}
25+ mock.Mock.Test(t)
26+
27+ t.Cleanup(func() {
28+ if helper, ok := t.(interface{ Helper() }); ok {
29+ helper.Helper()
30+ }
31+ mock.AssertExpectations(t)
32+ })
33+
34+ return mock
35+}
36+
37+// CIActivity is an autogenerated mock type for the CIActivity type
38+type CIActivity struct {
39+ mock.Mock
40+}
41+
42+type CIActivity_Expecter struct {
43+ mock *mock.Mock
44+}
45+
46+func (_m *CIActivity) EXPECT() *CIActivity_Expecter {
47+ return &CIActivity_Expecter{mock: &_m.Mock}
48+}
49+
50+// Settled provides a mock function for the type CIActivity
51+func (_mock *CIActivity) Settled(ctx context.Context, repo domain.RepoName, number int64) bool {
52+ ret := _mock.Called(ctx, repo, number)
53+
54+ if len(ret) == 0 {
55+ panic("no return value specified for Settled")
56+ }
57+
58+ var r0 bool
59+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, int64) bool); ok {
60+ r0 = returnFunc(ctx, repo, number)
61+ } else {
62+ r0 = ret.Get(0).(bool)
63+ }
64+ return r0
65+}
66+
67+// CIActivity_Settled_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Settled'
68+type CIActivity_Settled_Call struct {
69+ *mock.Call
70+}
71+
72+// Settled is a helper method to define mock.On call
73+// - ctx context.Context
74+// - repo domain.RepoName
75+// - number int64
76+func (_e *CIActivity_Expecter) Settled(ctx any, repo any, number any) *CIActivity_Settled_Call {
77+ return &CIActivity_Settled_Call{Call: _e.mock.On("Settled", ctx, repo, number)}
78+}
79+
80+func (_c *CIActivity_Settled_Call) Run(run func(ctx context.Context, repo domain.RepoName, number int64)) *CIActivity_Settled_Call {
81+ _c.Call.Run(func(args mock.Arguments) {
82+ var arg0 context.Context
83+ if args[0] != nil {
84+ arg0 = args[0].(context.Context)
85+ }
86+ var arg1 domain.RepoName
87+ if args[1] != nil {
88+ arg1 = args[1].(domain.RepoName)
89+ }
90+ var arg2 int64
91+ if args[2] != nil {
92+ arg2 = args[2].(int64)
93+ }
94+ run(
95+ arg0,
96+ arg1,
97+ arg2,
98+ )
99+ })
100+ return _c
101+}
102+
103+func (_c *CIActivity_Settled_Call) Return(b bool) *CIActivity_Settled_Call {
104+ _c.Call.Return(b)
105+ return _c
106+}
107+
108+func (_c *CIActivity_Settled_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, number int64) bool) *CIActivity_Settled_Call {
109+ _c.Call.Return(run)
110+ return _c
111+}
A internal/ports/mocks/MergeGate.go +111 -0
@@ -0,0 +1,111 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ "bnema.dev/ermite/internal/domain"
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// NewMergeGate creates a new instance of MergeGate. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func NewMergeGate(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *MergeGate {
20+ if helper, ok := t.(interface{ Helper() }); ok {
21+ helper.Helper()
22+ }
23+
24+ mock := &MergeGate{}
25+ mock.Mock.Test(t)
26+
27+ t.Cleanup(func() {
28+ if helper, ok := t.(interface{ Helper() }); ok {
29+ helper.Helper()
30+ }
31+ mock.AssertExpectations(t)
32+ })
33+
34+ return mock
35+}
36+
37+// MergeGate is an autogenerated mock type for the MergeGate type
38+type MergeGate struct {
39+ mock.Mock
40+}
41+
42+type MergeGate_Expecter struct {
43+ mock *mock.Mock
44+}
45+
46+func (_m *MergeGate) EXPECT() *MergeGate_Expecter {
47+ return &MergeGate_Expecter{mock: &_m.Mock}
48+}
49+
50+// WithMergeLock provides a mock function for the type MergeGate
51+func (_mock *MergeGate) WithMergeLock(ctx context.Context, repo domain.RepoName, fn func() error) error {
52+ ret := _mock.Called(ctx, repo, fn)
53+
54+ if len(ret) == 0 {
55+ panic("no return value specified for WithMergeLock")
56+ }
57+
58+ var r0 error
59+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, func() error) error); ok {
60+ r0 = returnFunc(ctx, repo, fn)
61+ } else {
62+ r0 = ret.Error(0)
63+ }
64+ return r0
65+}
66+
67+// MergeGate_WithMergeLock_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'WithMergeLock'
68+type MergeGate_WithMergeLock_Call struct {
69+ *mock.Call
70+}
71+
72+// WithMergeLock is a helper method to define mock.On call
73+// - ctx context.Context
74+// - repo domain.RepoName
75+// - fn func() error
76+func (_e *MergeGate_Expecter) WithMergeLock(ctx any, repo any, fn any) *MergeGate_WithMergeLock_Call {
77+ return &MergeGate_WithMergeLock_Call{Call: _e.mock.On("WithMergeLock", ctx, repo, fn)}
78+}
79+
80+func (_c *MergeGate_WithMergeLock_Call) Run(run func(ctx context.Context, repo domain.RepoName, fn func() error)) *MergeGate_WithMergeLock_Call {
81+ _c.Call.Run(func(args mock.Arguments) {
82+ var arg0 context.Context
83+ if args[0] != nil {
84+ arg0 = args[0].(context.Context)
85+ }
86+ var arg1 domain.RepoName
87+ if args[1] != nil {
88+ arg1 = args[1].(domain.RepoName)
89+ }
90+ var arg2 func() error
91+ if args[2] != nil {
92+ arg2 = args[2].(func() error)
93+ }
94+ run(
95+ arg0,
96+ arg1,
97+ arg2,
98+ )
99+ })
100+ return _c
101+}
102+
103+func (_c *MergeGate_WithMergeLock_Call) Return(err error) *MergeGate_WithMergeLock_Call {
104+ _c.Call.Return(err)
105+ return _c
106+}
107+
108+func (_c *MergeGate_WithMergeLock_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, fn func() error) error) *MergeGate_WithMergeLock_Call {
109+ _c.Call.Return(run)
110+ return _c
111+}
M internal/ports/mocks/MergeRequestService.go +162 -0
@@ -132,6 +132,84 @@ func (_c *MergeRequestService_AddComment_Call) RunAndReturn(run func(ctx context
132132 return _c
133133 }
134134
135+// CancelAutoMerge provides a mock function for the type MergeRequestService
136+func (_mock *MergeRequestService) CancelAutoMerge(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.MergeRequest, error) {
137+ ret := _mock.Called(ctx, p, name, number)
138+
139+ if len(ret) == 0 {
140+ panic("no return value specified for CancelAutoMerge")
141+ }
142+
143+ var r0 domain.MergeRequest
144+ var r1 error
145+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64) (domain.MergeRequest, error)); ok {
146+ return returnFunc(ctx, p, name, number)
147+ }
148+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64) domain.MergeRequest); ok {
149+ r0 = returnFunc(ctx, p, name, number)
150+ } else {
151+ r0 = ret.Get(0).(domain.MergeRequest)
152+ }
153+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, int64) error); ok {
154+ r1 = returnFunc(ctx, p, name, number)
155+ } else {
156+ r1 = ret.Error(1)
157+ }
158+ return r0, r1
159+}
160+
161+// MergeRequestService_CancelAutoMerge_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CancelAutoMerge'
162+type MergeRequestService_CancelAutoMerge_Call struct {
163+ *mock.Call
164+}
165+
166+// CancelAutoMerge is a helper method to define mock.On call
167+// - ctx context.Context
168+// - p domain.Principal
169+// - name domain.RepoName
170+// - number int64
171+func (_e *MergeRequestService_Expecter) CancelAutoMerge(ctx any, p any, name any, number any) *MergeRequestService_CancelAutoMerge_Call {
172+ return &MergeRequestService_CancelAutoMerge_Call{Call: _e.mock.On("CancelAutoMerge", ctx, p, name, number)}
173+}
174+
175+func (_c *MergeRequestService_CancelAutoMerge_Call) Run(run func(ctx context.Context, p domain.Principal, name domain.RepoName, number int64)) *MergeRequestService_CancelAutoMerge_Call {
176+ _c.Call.Run(func(args mock.Arguments) {
177+ var arg0 context.Context
178+ if args[0] != nil {
179+ arg0 = args[0].(context.Context)
180+ }
181+ var arg1 domain.Principal
182+ if args[1] != nil {
183+ arg1 = args[1].(domain.Principal)
184+ }
185+ var arg2 domain.RepoName
186+ if args[2] != nil {
187+ arg2 = args[2].(domain.RepoName)
188+ }
189+ var arg3 int64
190+ if args[3] != nil {
191+ arg3 = args[3].(int64)
192+ }
193+ run(
194+ arg0,
195+ arg1,
196+ arg2,
197+ arg3,
198+ )
199+ })
200+ return _c
201+}
202+
203+func (_c *MergeRequestService_CancelAutoMerge_Call) Return(mergeRequest domain.MergeRequest, err error) *MergeRequestService_CancelAutoMerge_Call {
204+ _c.Call.Return(mergeRequest, err)
205+ return _c
206+}
207+
208+func (_c *MergeRequestService_CancelAutoMerge_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.MergeRequest, error)) *MergeRequestService_CancelAutoMerge_Call {
209+ _c.Call.Return(run)
210+ return _c
211+}
212+
135213 // Close provides a mock function for the type MergeRequestService
136214 func (_mock *MergeRequestService) Close(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.MergeRequest, error) {
137215 ret := _mock.Called(ctx, p, name, number)
@@ -618,6 +696,90 @@ func (_c *MergeRequestService_EditComment_Call) RunAndReturn(run func(ctx contex
618696 return _c
619697 }
620698
699+// EnableAutoMerge provides a mock function for the type MergeRequestService
700+func (_mock *MergeRequestService) EnableAutoMerge(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params ports.EnableAutoMergeParams) (domain.MergeRequest, error) {
701+ ret := _mock.Called(ctx, p, name, number, params)
702+
703+ if len(ret) == 0 {
704+ panic("no return value specified for EnableAutoMerge")
705+ }
706+
707+ var r0 domain.MergeRequest
708+ var r1 error
709+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64, ports.EnableAutoMergeParams) (domain.MergeRequest, error)); ok {
710+ return returnFunc(ctx, p, name, number, params)
711+ }
712+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64, ports.EnableAutoMergeParams) domain.MergeRequest); ok {
713+ r0 = returnFunc(ctx, p, name, number, params)
714+ } else {
715+ r0 = ret.Get(0).(domain.MergeRequest)
716+ }
717+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, int64, ports.EnableAutoMergeParams) error); ok {
718+ r1 = returnFunc(ctx, p, name, number, params)
719+ } else {
720+ r1 = ret.Error(1)
721+ }
722+ return r0, r1
723+}
724+
725+// MergeRequestService_EnableAutoMerge_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'EnableAutoMerge'
726+type MergeRequestService_EnableAutoMerge_Call struct {
727+ *mock.Call
728+}
729+
730+// EnableAutoMerge is a helper method to define mock.On call
731+// - ctx context.Context
732+// - p domain.Principal
733+// - name domain.RepoName
734+// - number int64
735+// - params ports.EnableAutoMergeParams
736+func (_e *MergeRequestService_Expecter) EnableAutoMerge(ctx any, p any, name any, number any, params any) *MergeRequestService_EnableAutoMerge_Call {
737+ return &MergeRequestService_EnableAutoMerge_Call{Call: _e.mock.On("EnableAutoMerge", ctx, p, name, number, params)}
738+}
739+
740+func (_c *MergeRequestService_EnableAutoMerge_Call) Run(run func(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params ports.EnableAutoMergeParams)) *MergeRequestService_EnableAutoMerge_Call {
741+ _c.Call.Run(func(args mock.Arguments) {
742+ var arg0 context.Context
743+ if args[0] != nil {
744+ arg0 = args[0].(context.Context)
745+ }
746+ var arg1 domain.Principal
747+ if args[1] != nil {
748+ arg1 = args[1].(domain.Principal)
749+ }
750+ var arg2 domain.RepoName
751+ if args[2] != nil {
752+ arg2 = args[2].(domain.RepoName)
753+ }
754+ var arg3 int64
755+ if args[3] != nil {
756+ arg3 = args[3].(int64)
757+ }
758+ var arg4 ports.EnableAutoMergeParams
759+ if args[4] != nil {
760+ arg4 = args[4].(ports.EnableAutoMergeParams)
761+ }
762+ run(
763+ arg0,
764+ arg1,
765+ arg2,
766+ arg3,
767+ arg4,
768+ )
769+ })
770+ return _c
771+}
772+
773+func (_c *MergeRequestService_EnableAutoMerge_Call) Return(mergeRequest domain.MergeRequest, err error) *MergeRequestService_EnableAutoMerge_Call {
774+ _c.Call.Return(mergeRequest, err)
775+ return _c
776+}
777+
778+func (_c *MergeRequestService_EnableAutoMerge_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params ports.EnableAutoMergeParams) (domain.MergeRequest, error)) *MergeRequestService_EnableAutoMerge_Call {
779+ _c.Call.Return(run)
780+ return _c
781+}
782+
621783 // Get provides a mock function for the type MergeRequestService
622784 func (_mock *MergeRequestService) Get(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, opts ports.GetMergeRequestOptions) (ports.MergeRequestDetail, error) {
623785 ret := _mock.Called(ctx, p, name, number, opts)
M internal/ports/ports.go +7 -0
@@ -437,6 +437,13 @@ type MergeGit interface {
437437 CountBehind(ctx context.Context, repo domain.RepoName, head, target domain.ObjectID, limit int) (n int, err error)
438438 }
439439
440+// AutoMergeTrigger asks the auto-merge evaluator to look at a repository
441+// again. It only marks the repository and returns.
442+type AutoMergeTrigger interface {
443+ OnRepoChanged(ctx context.Context, repo domain.RepoName)
444+ OnAutoMergeEnabled(ctx context.Context, repo domain.RepoName)
445+}
446+
440447 // MergeabilityTrigger queues mergeability checks. It only queues the work and
441448 // returns.
442449 type MergeabilityTrigger interface {
M internal/ports/services.go +5 -0
@@ -125,6 +125,11 @@ type MergeRequestService interface {
125125 Merge(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params MergeParams) (MergeResult, error)
126126 // MarkReady takes a draft out of draft: its title loses its "WIP:" prefix.
127127 MarkReady(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.MergeRequest, error)
128+ // EnableAutoMerge sets an open merge request to merge itself once its
129+ // checks pass; enabling again replaces the mode and the head.
130+ EnableAutoMerge(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params EnableAutoMergeParams) (domain.MergeRequest, error)
131+ // CancelAutoMerge turns auto-merge off on an open merge request.
132+ CancelAutoMerge(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.MergeRequest, error)
128133 // CountOpen is the number of open merge requests of a repository the
129134 // principal can read. It reads no Git: the tab of every page shows it.
130135 CountOpen(ctx context.Context, p domain.Principal, name domain.RepoName) (int, error)
A internal/usecase/automerge.go +413 -0
@@ -0,0 +1,413 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
6+ "context"
7+ "errors"
8+ "fmt"
9+ "slices"
10+ "strings"
11+ "sync"
12+ "time"
13+
14+ "bnema.dev/ermite/internal/domain"
15+ "bnema.dev/ermite/internal/ports"
16+)
17+
18+// AutoMergeConfig holds dependencies of the asynchronous merge evaluator.
19+type AutoMergeConfig struct {
20+ MRs *MergeRequests
21+ Store ports.MergeRequestStore
22+ Meta ports.RepoMetaStore
23+ Git ports.MergeGit
24+ Checks ports.CIChecksReader
25+ Activity ports.CIActivity
26+ Tokens ports.TokenStore
27+ Clock ports.Clock
28+ After func(time.Duration) <-chan time.Time
29+}
30+
31+// AutoMerge serializes evaluation within each repository. Triggers only enqueue
32+// work, including when invoked while CI or merge-request locks are held.
33+type AutoMerge struct {
34+ cfg AutoMergeConfig
35+ ctx context.Context
36+ cancel context.CancelFunc
37+ wg sync.WaitGroup
38+ mu sync.Mutex
39+ pending map[domain.RepoName]bool
40+ dirty map[domain.RepoName]bool
41+ order []domain.RepoName
42+ running, again map[domain.RepoName]bool
43+ wake chan struct{}
44+ retryMu sync.Mutex
45+ retries map[domain.RepoName]int
46+ scheduled map[domain.RepoName]bool
47+ heap []retryItem
48+ retryKick chan struct{}
49+ closed bool
50+}
51+
52+var _ ports.AutoMergeTrigger = (*AutoMerge)(nil)
53+
54+// NewAutoMerge builds an evaluator; call Start once, and Close before MRs.Wait.
55+func NewAutoMerge(ctx context.Context, cfg AutoMergeConfig) *AutoMerge {
56+ if cfg.After == nil {
57+ cfg.After = time.After
58+ }
59+ ctx, cancel := context.WithCancel(context.WithoutCancel(ctx))
60+ return &AutoMerge{cfg: cfg, ctx: ctx, cancel: cancel,
61+ pending: map[domain.RepoName]bool{}, dirty: map[domain.RepoName]bool{}, running: map[domain.RepoName]bool{}, again: map[domain.RepoName]bool{}, wake: make(chan struct{}, 1),
62+ retries: map[domain.RepoName]int{}, scheduled: map[domain.RepoName]bool{}, retryKick: make(chan struct{}, 1)}
63+}
64+
65+// Start starts workers and discovers repositories, retrying failed discovery.
66+func (s *AutoMerge) Start() {
67+ s.wg.Add(2 + defaultMergeabilityWorkers)
68+ go s.scheduler()
69+ go func() {
70+ defer s.wg.Done()
71+ for attempt := 0; attempt <= mergeabilityRetries; attempt++ {
72+ repos, err := s.cfg.Store.ListOpenAll(s.ctx)
73+ if err != nil && s.ctx.Err() == nil {
74+ ctxLog(s.ctx).Error().Err(err).Int("attempt", attempt+1).Msg("auto-merge: list the repositories at startup")
75+ }
76+ if err == nil {
77+ for _, repo := range repos {
78+ if repo.AutoMerge != nil {
79+ s.OnAutoMergeEnabled(s.ctx, repo.Repo)
80+ }
81+ }
82+ return
83+ }
84+ if attempt == mergeabilityRetries {
85+ return
86+ }
87+ select {
88+ case <-s.ctx.Done():
89+ return
90+ case <-s.cfg.After(mergeBackoff(attempt + 1)):
91+ }
92+ }
93+ }()
94+ for range defaultMergeabilityWorkers {
95+ go s.loop()
96+ }
97+}
98+
99+// Close cancels workers and pending retry waits, then waits for them.
100+func (s *AutoMerge) Close(_ context.Context) {
101+ s.retryMu.Lock()
102+ s.closed = true
103+ s.retryMu.Unlock()
104+ s.cancel()
105+ s.wg.Wait()
106+}
107+
108+func (s *AutoMerge) signal() {
109+ select {
110+ case s.wake <- struct{}{}:
111+ default:
112+ }
113+}
114+
115+func (s *AutoMerge) markDirty(repo domain.RepoName) {
116+ s.mu.Lock()
117+ if !s.pending[repo] {
118+ s.mu.Unlock()
119+ return
120+ }
121+ if s.running[repo] {
122+ s.again[repo] = true
123+ } else if !s.dirty[repo] {
124+ s.dirty[repo] = true
125+ s.order = append(s.order, repo)
126+ }
127+ s.mu.Unlock()
128+ s.signal()
129+}
130+
131+func (s *AutoMerge) next() (domain.RepoName, bool) {
132+ s.mu.Lock()
133+ defer s.mu.Unlock()
134+ if len(s.order) == 0 {
135+ return domain.RepoName{}, false
136+ }
137+ repo := s.order[0]
138+ s.order = s.order[1:]
139+ delete(s.dirty, repo)
140+ s.running[repo] = true
141+ if len(s.order) > 0 {
142+ s.signal()
143+ }
144+ return repo, true
145+}
146+
147+func (s *AutoMerge) loop() {
148+ defer s.wg.Done()
149+ for s.ctx.Err() == nil {
150+ repo, found := s.next()
151+ if !found {
152+ select {
153+ case <-s.ctx.Done():
154+ return
155+ case <-s.wake:
156+ }
157+ continue
158+ }
159+ if s.evaluate(repo) {
160+ s.retryDone(repo)
161+ } else {
162+ s.retry(repo)
163+ }
164+ s.mu.Lock()
165+ delete(s.running, repo)
166+ if s.again[repo] {
167+ delete(s.again, repo)
168+ s.dirty[repo] = true
169+ s.order = append(s.order, repo)
170+ s.signal()
171+ }
172+ s.mu.Unlock()
173+ }
174+}
175+
176+// OnRepoChanged marks and wakes only; it never merges on the caller's stack.
177+func (s *AutoMerge) OnRepoChanged(_ context.Context, repo domain.RepoName) { s.markDirty(repo) }
178+
179+// OnAutoMergeEnabled remembers an intent before queuing evaluation.
180+func (s *AutoMerge) OnAutoMergeEnabled(_ context.Context, repo domain.RepoName) {
181+ s.mu.Lock()
182+ s.pending[repo] = true
183+ if s.running[repo] {
184+ s.again[repo] = true
185+ }
186+ s.mu.Unlock()
187+ s.markDirty(repo)
188+}
189+
190+func (s *AutoMerge) evaluate(repo domain.RepoName) bool {
191+ err := s.evaluateRepo(repo)
192+ if err != nil && s.ctx.Err() == nil {
193+ ctxLog(s.ctx).Error().Err(err).Str("repo", repo.String()).Msg("auto-merge: evaluate repository")
194+ }
195+ return err == nil || s.ctx.Err() != nil
196+}
197+
198+func (s *AutoMerge) evaluateRepo(repo domain.RepoName) error {
199+ ctx := s.ctx
200+ meta, err := s.cfg.Meta.Get(ctx, repo)
201+ if errors.Is(err, domain.ErrNotFound) {
202+ return nil
203+ }
204+ if err != nil {
205+ return err
206+ }
207+ mrs, err := s.cfg.Store.ListOpen(ctx, repo)
208+ if err != nil {
209+ return err
210+ }
211+ hasIntent := slices.ContainsFunc(mrs, func(mr domain.MergeRequest) bool { return mr.AutoMerge != nil })
212+ if !hasIntent {
213+ s.mu.Lock()
214+ // An enable arriving during this scan requests another pass. Do not
215+ // erase its mark based on the older store snapshot.
216+ if !s.again[repo] {
217+ delete(s.pending, repo)
218+ }
219+ s.mu.Unlock()
220+ }
221+ var failures []error
222+ for _, mr := range mrs {
223+ if ctx.Err() != nil {
224+ return nil
225+ }
226+ if mr.AutoMerge == nil {
227+ continue
228+ }
229+ if err := s.evaluateMR(ctx, meta, mr); err != nil {
230+ failures = append(failures, err)
231+ }
232+ }
233+ return errors.Join(failures...)
234+}
235+
236+func (s *AutoMerge) evaluateMR(ctx context.Context, repo domain.Repo, mr domain.MergeRequest) error {
237+ intent := mr.AutoMerge
238+ settings := repo.EffectiveSettings()
239+ cancel := func(reason string) error { return s.cfg.MRs.cancelAuto(ctx, mr, reason) }
240+ if !settings.AllowAutoMerge {
241+ return cancel("auto-merge was turned off in the repository settings")
242+ }
243+ if !settings.Allows(intent.Mode) {
244+ return cancel(fmt.Sprintf("the %s merge mode is no longer allowed", intent.Mode))
245+ }
246+ head, err := s.cfg.Git.BranchHead(ctx, mr.Repo, mr.Source)
247+ if errors.Is(err, domain.ErrNotFound) || (err == nil && head.IsZero()) {
248+ return cancel("the source branch was deleted")
249+ }
250+ if err != nil {
251+ return err
252+ }
253+ if head != intent.HeadID {
254+ return cancel("the branch moved to " + head.String()[:7])
255+ }
256+ // An enabler that can no longer merge cancels at once, even while CI is
257+ // pending: the intent must not stay shown as enabled. This check only
258+ // cancels; the merge itself resolves the enabler again under the merge lock.
259+ if _, err := enablerCanMerge(ctx, s.cfg.Tokens, intent, mr, s.cfg.Clock.Now()); err != nil {
260+ if errors.Is(err, domain.ErrForbidden) {
261+ return cancel(autoMergeReason(err))
262+ }
263+ return err
264+ }
265+ if s.cfg.Activity != nil && !s.cfg.Activity.Settled(ctx, mr.Repo, mr.Number) {
266+ return nil
267+ }
268+ checks, err := s.cfg.Checks.CommitChecks(ctx, mr.Repo, head.String())
269+ if err != nil {
270+ return err
271+ }
272+ if !hasSuccessfulCheck(checks) {
273+ return nil
274+ }
275+ _, err = s.cfg.MRs.mergeAs(ctx, domain.Principal{}, mr.Repo, mr.Number, ports.MergeParams{Mode: intent.Mode, HeadID: intent.HeadID}, mergeOpts{auto: &autoMergeOpts{intent: intent, tokens: s.cfg.Tokens, activity: s.cfg.Activity}})
276+ switch {
277+ case err == nil, errors.Is(err, errAutoMergeWait):
278+ return nil
279+ case errors.Is(err, domain.ErrPreconditionFailed), errors.Is(err, domain.ErrForbidden), errors.Is(err, domain.ErrUnauthenticated):
280+ return cancel(autoMergeReason(err))
281+ default:
282+ return err
283+ }
284+}
285+
286+// Removing a known sentinel's presentation prefix is display formatting, not
287+// error classification (which always uses errors.Is).
288+func autoMergeReason(err error) string {
289+ text := err.Error()
290+ for _, sentinel := range []error{domain.ErrPreconditionFailed, domain.ErrForbidden, domain.ErrUnauthenticated} {
291+ text = strings.TrimPrefix(text, sentinel.Error()+": ")
292+ }
293+ return text
294+}
295+
296+func (s *AutoMerge) retry(key domain.RepoName) {
297+ s.retryMu.Lock()
298+ defer s.retryMu.Unlock()
299+ if s.closed || s.scheduled[key] {
300+ return
301+ }
302+ s.retries[key]++
303+ n := s.retries[key]
304+ if n > mergeabilityRetries {
305+ delete(s.retries, key)
306+ return
307+ }
308+ s.scheduled[key] = true
309+ it := retryItem{due: s.cfg.Clock.Now().Add(mergeBackoff(n)), key: key}
310+ i, _ := slices.BinarySearchFunc(s.heap, it, func(a, b retryItem) int {
311+ if a.due.After(b.due) {
312+ return 1
313+ }
314+ return -1 // equal dues keep their insertion order
315+ })
316+ s.heap = slices.Insert(s.heap, i, it)
317+ if i == 0 {
318+ select {
319+ case s.retryKick <- struct{}{}:
320+ default:
321+ }
322+ }
323+}
324+
325+// scheduler is the only goroutine that waits for retries: it sleeps until the
326+// earliest is due and puts it back in the normal queue.
327+func (s *AutoMerge) scheduler() {
328+ defer s.wg.Done()
329+ // The timer armed for waited; a kick re-arms it only when the earliest
330+ // retry changed, so a stale kick never starts a second timer.
331+ var (
332+ after <-chan time.Time
333+ waited retryItem
334+ )
335+ for {
336+ s.retryMu.Lock()
337+ var head retryItem
338+ has := len(s.heap) > 0
339+ if has {
340+ head = s.heap[0]
341+ }
342+ s.retryMu.Unlock()
343+ if !has {
344+ after = nil
345+ select {
346+ case <-s.ctx.Done():
347+ return
348+ case <-s.retryKick:
349+ continue
350+ }
351+ }
352+ if after == nil || head.key != waited.key || !head.due.Equal(waited.due) {
353+ after, waited = s.cfg.After(max(0, head.due.Sub(s.cfg.Clock.Now()))), head
354+ }
355+ select {
356+ case <-s.ctx.Done():
357+ return
358+ case <-s.retryKick:
359+ continue // an earlier retry may have arrived
360+ case <-after:
361+ after = nil
362+ }
363+ for _, k := range s.popDue(waited) {
364+ if s.ctx.Err() != nil {
365+ return
366+ }
367+ s.markDirty(k)
368+ }
369+ }
370+}
371+
372+// popDue removes the waited item and the ones due as well. The waited item is
373+// removed regardless of the clock only when both its key and its deadline
374+// match: a replacement retry of the same repository is kept until it is due.
375+func (s *AutoMerge) popDue(waited retryItem) []domain.RepoName {
376+ s.retryMu.Lock()
377+ defer s.retryMu.Unlock()
378+ now := s.cfg.Clock.Now()
379+ var out []domain.RepoName
380+ s.heap = slices.DeleteFunc(s.heap, func(it retryItem) bool {
381+ if (it.key != waited.key || !it.due.Equal(waited.due)) && it.due.After(now) {
382+ return false
383+ }
384+ out = append(out, it.key)
385+ delete(s.scheduled, it.key)
386+ return true
387+ })
388+ return out
389+}
390+
391+// retryDone forgets the failures of key after a success.
392+func (s *AutoMerge) retryDone(key domain.RepoName) {
393+ s.retryMu.Lock()
394+ delete(s.retries, key)
395+ delete(s.scheduled, key)
396+ s.heap = slices.DeleteFunc(s.heap, func(it retryItem) bool { return it.key == key })
397+ select {
398+ case s.retryKick <- struct{}{}:
399+ default:
400+ }
401+ s.retryMu.Unlock()
402+}
403+
404+func enablerCanMerge(ctx context.Context, tokens ports.TokenStore, intent *domain.AutoMerge, mr domain.MergeRequest, now time.Time) (domain.Principal, error) {
405+ fresh, valid, err := principalOfPusher(ctx, tokens, intent.EnabledBy, now)
406+ if err != nil {
407+ return domain.Principal{}, err
408+ }
409+ if !valid || !fresh.Allows(domain.ScopeMRMerge, mr.Repo) || !fresh.AllowsRef("refs/heads/"+mr.Target) {
410+ return domain.Principal{}, fmt.Errorf("%w: the principal that enabled auto-merge can no longer merge %s", domain.ErrForbidden, mr.Target)
411+ }
412+ return fresh, nil
413+}
A internal/usecase/automerge_index_test.go +97 -0
@@ -0,0 +1,97 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
6+ "context"
7+ "testing"
8+ "time"
9+
10+ "github.com/stretchr/testify/mock"
11+ "github.com/stretchr/testify/require"
12+
13+ "bnema.dev/ermite/internal/domain"
14+ "bnema.dev/ermite/internal/ports/mocks"
15+)
16+
17+func TestAutoMergePendingIndex(t *testing.T) {
18+ ctx := context.Background()
19+ repo, err := domain.ParseRepoName("proj")
20+ require.NoError(t, err)
21+ store := mocks.NewMergeRequestStore(t)
22+ meta := mocks.NewRepoMetaStore(t)
23+ a := NewAutoMerge(ctx, AutoMergeConfig{Store: store, Meta: meta})
24+ defer a.Close(ctx)
25+ // No expectations: an unindexed kick must not read either store.
26+ a.OnRepoChanged(ctx, repo)
27+ _, found := a.next()
28+ require.False(t, found)
29+ a.OnAutoMergeEnabled(ctx, repo)
30+ a.OnRepoChanged(ctx, repo)
31+ got, found := a.next()
32+ require.True(t, found)
33+ require.Equal(t, repo, got)
34+ meta.EXPECT().Get(mock.Anything, repo).Return(domain.Repo{Name: repo}, nil).Once()
35+ store.EXPECT().ListOpen(mock.Anything, repo).Return(nil, nil).Once()
36+ require.True(t, a.evaluate(repo))
37+ require.NotContains(t, a.pending, repo)
38+ a.mu.Lock()
39+ delete(a.running, repo)
40+ a.mu.Unlock()
41+ a.OnRepoChanged(ctx, repo)
42+ _, found = a.next()
43+ require.False(t, found)
44+}
45+
46+func TestAutoMergeEnableDuringEmptyScanKeepsPending(t *testing.T) {
47+ ctx := context.Background()
48+ repo, err := domain.ParseRepoName("proj")
49+ require.NoError(t, err)
50+ store := mocks.NewMergeRequestStore(t)
51+ meta := mocks.NewRepoMetaStore(t)
52+ a := NewAutoMerge(ctx, AutoMergeConfig{Store: store, Meta: meta})
53+ defer a.Close(ctx)
54+ a.OnAutoMergeEnabled(ctx, repo)
55+ _, found := a.next()
56+ require.True(t, found)
57+ meta.EXPECT().Get(mock.Anything, repo).Return(domain.Repo{Name: repo}, nil).Once()
58+ store.EXPECT().ListOpen(mock.Anything, repo).Run(func(context.Context, domain.RepoName) {
59+ // The returned empty snapshot predates this newly enabled intent.
60+ a.OnAutoMergeEnabled(ctx, repo)
61+ }).Return(nil, nil).Once()
62+ require.True(t, a.evaluate(repo))
63+ require.True(t, a.pending[repo])
64+ require.True(t, a.again[repo])
65+}
66+
67+func TestAutoMergeStartupFillsPendingIndex(t *testing.T) {
68+ ctx := context.Background()
69+ repo, err := domain.ParseRepoName("proj")
70+ require.NoError(t, err)
71+ store := mocks.NewMergeRequestStore(t)
72+ meta := mocks.NewRepoMetaStore(t)
73+ store.EXPECT().ListOpenAll(mock.Anything).Return([]domain.MergeRequest{{Repo: repo, AutoMerge: &domain.AutoMerge{}}}, nil).Once()
74+ scanned := make(chan struct{})
75+ meta.EXPECT().Get(mock.Anything, repo).Run(func(context.Context, domain.RepoName) { close(scanned) }).Return(domain.Repo{}, domain.ErrNotFound).Once()
76+ a := NewAutoMerge(ctx, AutoMergeConfig{Store: store, Meta: meta})
77+ a.Start()
78+ defer a.Close(ctx)
79+ select {
80+ case <-scanned:
81+ case <-time.After(5 * time.Second):
82+ t.Fatal("startup did not scan the pending repository")
83+ }
84+ a.mu.Lock()
85+ pending := a.pending[repo]
86+ a.mu.Unlock()
87+ require.True(t, pending)
88+}
89+
90+func TestManualPendingChecksMessage(t *testing.T) {
91+ err := checksPolicy([]domain.CICheck{{Name: "build", Status: domain.CIQueued}})
92+ require.ErrorIs(t, err, domain.ErrPreconditionFailed)
93+ require.EqualError(t, err, "failed precondition: checks have not finished: build")
94+ require.NotContains(t, err.Error(), "auto-merge")
95+ require.NotErrorIs(t, err, errAutoMergeWait)
96+ require.NoError(t, checksPolicy(nil))
97+}
A internal/usecase/automerge_test.go +453 -0
@@ -0,0 +1,453 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase_test
4+
5+import (
6+ "context"
7+ "errors"
8+ "testing"
9+ "time"
10+
11+ "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/mock"
13+ "github.com/stretchr/testify/require"
14+
15+ "bnema.dev/ermite/internal/domain"
16+ "bnema.dev/ermite/internal/ports"
17+ "bnema.dev/ermite/internal/ports/mocks"
18+ "bnema.dev/ermite/internal/usecase"
19+)
20+
21+func autoRig(t *testing.T) (*mrRig, domain.MergeRequest, *mocks.CIChecksReader) {
22+ t.Helper()
23+ r := newMRRig(t)
24+ r.seed()
25+ r.feature("feat")
26+ mr := r.open("feat")
27+ checks := mocks.NewCIChecksReader(t)
28+ r.mrs.SetChecks(checks)
29+ _, err := r.repos.Update(r.ctx, root, r.repo, ports.UpdateRepoParams{AllowAutoMerge: ptr(true)})
30+ require.NoError(t, err)
31+ return r, mr, checks
32+}
33+
34+func autoEvaluator(r *mrRig, checks ports.CIChecksReader, activity ports.CIActivity, tokens ports.TokenStore) *usecase.AutoMerge {
35+ return usecase.NewAutoMerge(r.ctx, usecase.AutoMergeConfig{MRs: r.mrs, Store: r.store.MergeRequests(), Meta: r.store, Git: r.git, Checks: checks, Activity: activity, Tokens: tokens, Clock: fixedClock{}})
36+}
37+
38+func enableAuto(t *testing.T, r *mrRig, mr domain.MergeRequest) domain.MergeRequest {
39+ t.Helper()
40+ enabled, err := r.mrs.EnableAutoMerge(r.ctx, root, r.repo, mr.Number, ports.EnableAutoMergeParams{HeadID: r.head()})
41+ require.NoError(t, err)
42+ require.NotNil(t, enabled.AutoMerge)
43+ return enabled
44+}
45+
46+func storedAuto(t *testing.T, r *mrRig, mr domain.MergeRequest) domain.MergeRequest {
47+ t.Helper()
48+ got, err := r.store.MergeRequests().Get(r.ctx, r.repo, mr.Number)
49+ require.NoError(t, err)
50+ return got
51+}
52+
53+func TestEnableAutoMergeNeedsSettingScopeAndRef(t *testing.T) {
54+ for _, tc := range []string{"setting", "scope", "ref", "draft", "no CI", "closed", "mode"} {
55+ t.Run(tc, func(t *testing.T) {
56+ r, mr, _ := autoRig(t)
57+ p := root
58+ want := domain.ErrPreconditionFailed
59+ switch tc {
60+ case "setting":
61+ _, err := r.repos.Update(r.ctx, root, r.repo, ports.UpdateRepoParams{AllowAutoMerge: ptr(false)})
62+ require.NoError(t, err)
63+ case "scope":
64+ p = tokenPrincipal(domain.ScopeMRWrite, r.repo.String())
65+ want = domain.ErrNotFound
66+ case "ref":
67+ p = domain.TokenPrincipal(domain.Token{ID: "limited", Scopes: []domain.Scope{domain.ScopeMRMerge}, Repos: []domain.RepoName{r.repo}, BranchPrefixes: []string{"feat"}})
68+ want = domain.ErrForbidden
69+ case "draft":
70+ mr.Title, _ = domain.ParseMRTitle("WIP: work")
71+ require.NoError(t, r.store.MergeRequests().Update(r.ctx, mr))
72+ case "no CI":
73+ r.mrs.SetChecks(nil)
74+ case "closed":
75+ _, err := r.mrs.Close(r.ctx, root, r.repo, mr.Number)
76+ require.NoError(t, err)
77+ case "mode":
78+ _, err := r.repos.Update(r.ctx, root, r.repo, ports.UpdateRepoParams{AllowedMergeModes: ptr([]domain.MergeMode{domain.MergeModeSquash})})
79+ require.NoError(t, err)
80+ }
81+ _, err := r.mrs.EnableAutoMerge(r.ctx, p, r.repo, mr.Number, ports.EnableAutoMergeParams{HeadID: r.head()})
82+ require.ErrorIs(t, err, want)
83+ })
84+ }
85+}
86+
87+func TestEnableAutoMergeRecordsEnablerModeHeadAndPublishes(t *testing.T) {
88+ r, mr, _ := autoRig(t)
89+ trigger := mocks.NewAutoMergeTrigger(t)
90+ trigger.EXPECT().OnAutoMergeEnabled(mock.Anything, r.repo).Return().Twice()
91+ r.mrs.WithAutoMerge(trigger)
92+ r.drain()
93+ got, err := r.mrs.EnableAutoMerge(r.ctx, domain.Owner(), r.repo, mr.Number, ports.EnableAutoMergeParams{Mode: domain.MergeModeSquash, HeadID: r.head()})
94+ require.NoError(t, err)
95+ assert.Equal(t, &domain.AutoMerge{Mode: domain.MergeModeSquash, HeadID: r.head(), EnabledBy: domain.PusherOf(domain.Owner()), EnablerLabel: domain.AuthorName(domain.Owner()), EnabledAt: now}, got.AutoMerge)
96+ events := r.drain()
97+ require.Len(t, events, 1)
98+ assert.IsType(t, domain.MergeRequestAutoMergeEnabled{}, events[0])
99+ got = enableAuto(t, r, mr)
100+ assert.Equal(t, domain.PusherOf(root), got.AutoMerge.EnabledBy)
101+ assert.Equal(t, domain.MergeModeFastForward, got.AutoMerge.Mode)
102+}
103+
104+func TestCancelAutoMergePublishesReason(t *testing.T) {
105+ r, mr, _ := autoRig(t)
106+ enableAuto(t, r, mr)
107+ r.drain()
108+ got, err := r.mrs.CancelAutoMerge(r.ctx, root, r.repo, mr.Number)
109+ require.NoError(t, err)
110+ assert.Nil(t, got.AutoMerge)
111+ events := r.drain()
112+ require.Len(t, events, 1)
113+ ev, ok := events[0].(domain.MergeRequestAutoMergeCancelled)
114+ require.True(t, ok)
115+ assert.NotEmpty(t, ev.Reason)
116+ _, err = r.mrs.CancelAutoMerge(r.ctx, root, r.repo, mr.Number)
117+ require.ErrorIs(t, err, domain.ErrPreconditionFailed)
118+}
119+
120+func TestAutoMergeWaitsWhileCINotSettledOrChecksPending(t *testing.T) {
121+ r, mr, checks := autoRig(t)
122+ enableAuto(t, r, mr)
123+ activity := mocks.NewCIActivity(t)
124+ activity.EXPECT().Settled(mock.Anything, r.repo, mr.Number).Return(false).Once()
125+ activity.EXPECT().Settled(mock.Anything, r.repo, mr.Number).Return(true).Twice()
126+ checks.EXPECT().CommitChecks(mock.Anything, r.repo, r.head().String()).Return([]domain.CICheck{{Status: domain.CICompleted, Conclusion: domain.CISuccess}, {Status: domain.CIQueued, Name: "pending"}}, nil)
127+ a := autoEvaluator(r, checks, activity, nil)
128+ defer a.Close(r.ctx)
129+ require.True(t, a.Evaluate(r.repo))
130+ require.True(t, a.Evaluate(r.repo))
131+ assert.True(t, storedAuto(t, r, mr).IsOpen())
132+ assert.NotNil(t, storedAuto(t, r, mr).AutoMerge)
133+}
134+
135+func TestAutoMergeMergesAsEnablerWhenAChecksPassed(t *testing.T) {
136+ r, mr, checks := autoRig(t)
137+ trigger := mocks.NewWorkflowTrigger(t)
138+ trigger.EXPECT().OnMerge(mock.Anything, r.repo, mock.Anything, root).Return().Once()
139+ r.mrs.SetWorkflowTrigger(trigger)
140+ enableAuto(t, r, mr)
141+ // One pre-lock fast-path read and exactly one under-lock snapshot.
142+ checks.EXPECT().CommitChecks(mock.Anything, r.repo, r.head().String()).Return([]domain.CICheck{{Status: domain.CICompleted, Conclusion: domain.CISuccess}}, nil).Twice()
143+ a := autoEvaluator(r, checks, nil, nil)
144+ defer a.Close(r.ctx)
145+ r.drain()
146+ require.True(t, a.Evaluate(r.repo))
147+ got := storedAuto(t, r, mr)
148+ assert.Equal(t, domain.MRStateMerged, got.State)
149+ assert.True(t, got.AutoMerged)
150+ assert.Nil(t, got.AutoMerge)
151+ events := r.drain()
152+ found := false
153+ for _, ev := range events {
154+ if merged, ok := ev.(domain.MergeRequestMerged); ok {
155+ assert.True(t, merged.Auto)
156+ found = true
157+ }
158+ }
159+ require.True(t, found)
160+}
161+
162+func TestAutoMergeIgnoresFailingChecksBlockOff(t *testing.T) {
163+ for _, tc := range []struct {
164+ name string
165+ check domain.CICheck
166+ }{
167+ {"failure", domain.CICheck{Status: domain.CICompleted, Conclusion: domain.CIFailure}},
168+ {"pending", domain.CICheck{Status: domain.CIQueued}},
169+ } {
170+ t.Run(tc.name, func(t *testing.T) {
171+ r, mr, checks := autoRig(t)
172+ enableAuto(t, r, mr)
173+ _, err := r.repos.Update(r.ctx, root, r.repo, ports.UpdateRepoParams{FailingChecksBlock: ptr(false)})
174+ require.NoError(t, err)
175+ checks.EXPECT().CommitChecks(mock.Anything, r.repo, r.head().String()).Return([]domain.CICheck{{Status: domain.CICompleted, Conclusion: domain.CISuccess}, tc.check}, nil)
176+ a := autoEvaluator(r, checks, nil, nil)
177+ defer a.Close(r.ctx)
178+ require.True(t, a.Evaluate(r.repo))
179+ assert.True(t, storedAuto(t, r, mr).IsOpen())
180+ assert.NotNil(t, storedAuto(t, r, mr).AutoMerge)
181+ })
182+ }
183+}
184+
185+func TestAutoMergeCancelsOnNewHead(t *testing.T) { testAutoCancellation(t, "head") }
186+func TestAutoMergeCancelsWhenModeNoLongerAllowed(t *testing.T) { testAutoCancellation(t, "mode") }
187+func TestAutoMergeCancelsWhenSettingTurnedOff(t *testing.T) { testAutoCancellation(t, "setting") }
188+func testAutoCancellation(t *testing.T, change string) {
189+ r, mr, checks := autoRig(t)
190+ enableAuto(t, r, mr)
191+ switch change {
192+ case "head":
193+ r.commit(author, "new", "new", "new head")
194+ r.push("feat")
195+ case "mode":
196+ _, err := r.repos.Update(r.ctx, root, r.repo, ports.UpdateRepoParams{AllowedMergeModes: ptr([]domain.MergeMode{domain.MergeModeSquash})})
197+ require.NoError(t, err)
198+ case "setting":
199+ _, err := r.repos.Update(r.ctx, root, r.repo, ports.UpdateRepoParams{AllowAutoMerge: ptr(false)})
200+ require.NoError(t, err)
201+ }
202+ a := autoEvaluator(r, checks, nil, nil)
203+ defer a.Close(r.ctx)
204+ r.drain()
205+ require.True(t, a.Evaluate(r.repo))
206+ assert.Nil(t, storedAuto(t, r, mr).AutoMerge)
207+ events := r.drain()
208+ require.Len(t, events, 1)
209+ assert.IsType(t, domain.MergeRequestAutoMergeCancelled{}, events[0])
210+}
211+
212+func TestAutoMergeCancelsWhenEnablerTokenRevokedOrExpired(t *testing.T) {
213+ for _, expired := range []bool{false, true} {
214+ t.Run(map[bool]string{false: "revoked", true: "expired"}[expired], func(t *testing.T) {
215+ r, mr, checks := autoRig(t)
216+ enableAuto(t, r, mr)
217+ enabled := storedAuto(t, r, mr)
218+ enabled.AutoMerge.EnabledBy = domain.CIPusher{Kind: domain.CIPusherToken, TokenID: "gone"}
219+ require.NoError(t, r.store.MergeRequests().Update(r.ctx, enabled))
220+ tokens := mocks.NewTokenStore(t)
221+ // The enabler is checked before CI: no check is read.
222+ if expired {
223+ tokens.EXPECT().Get(mock.Anything, "gone").Return(domain.Token{ExpiresAt: now.Add(-time.Hour)}, nil)
224+ } else {
225+ tokens.EXPECT().Get(mock.Anything, "gone").Return(domain.Token{}, domain.ErrNotFound)
226+ }
227+ a := autoEvaluator(r, checks, nil, tokens)
228+ defer a.Close(r.ctx)
229+ require.True(t, a.Evaluate(r.repo))
230+ assert.Nil(t, storedAuto(t, r, mr).AutoMerge)
231+ assert.True(t, storedAuto(t, r, mr).IsOpen())
232+ })
233+ }
234+}
235+
236+func TestAutoMergeWaitsOnConflictingOrBehindHead(t *testing.T) {
237+ for _, mode := range []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash} {
238+ t.Run(mode.String(), func(t *testing.T) {
239+ r, mr, checks := autoRig(t)
240+ _, err := r.mrs.EnableAutoMerge(r.ctx, root, r.repo, mr.Number, ports.EnableAutoMergeParams{Mode: mode, HeadID: r.head()})
241+ require.NoError(t, err)
242+ head := r.head()
243+ r.sh("checkout", "main")
244+ r.commit(author, "a.txt", "conflicting target\n", "move target")
245+ r.push("main")
246+ checks.EXPECT().CommitChecks(mock.Anything, r.repo, head.String()).Return([]domain.CICheck{{Status: domain.CICompleted, Conclusion: domain.CISuccess}}, nil)
247+ a := autoEvaluator(r, checks, nil, nil)
248+ defer a.Close(r.ctx)
249+ require.True(t, a.Evaluate(r.repo))
250+ assert.NotNil(t, storedAuto(t, r, mr).AutoMerge)
251+ assert.True(t, storedAuto(t, r, mr).IsOpen())
252+ })
253+ }
254+}
255+
256+func TestManualMergeOrCloseClearsAutoMerge(t *testing.T) {
257+ for _, closeMR := range []bool{false, true} {
258+ t.Run(map[bool]string{false: "merge", true: "close"}[closeMR], func(t *testing.T) {
259+ r, mr, checks := autoRig(t)
260+ enabled := enableAuto(t, r, mr)
261+ if closeMR {
262+ _, err := r.mrs.Close(r.ctx, root, r.repo, mr.Number)
263+ require.NoError(t, err)
264+ } else {
265+ checks.EXPECT().CommitChecks(mock.Anything, r.repo, r.head().String()).Return(nil, nil)
266+ _, err := r.mrs.Merge(r.ctx, root, r.repo, mr.Number, ports.MergeParams{HeadID: enabled.AutoMerge.HeadID})
267+ require.NoError(t, err)
268+ }
269+ got := storedAuto(t, r, mr)
270+ assert.Nil(t, got.AutoMerge)
271+ assert.False(t, got.AutoMerged)
272+ })
273+ }
274+}
275+
276+func TestAutoMergeStartupRescan(t *testing.T) {
277+ r, mr, checks := autoRig(t)
278+ enableAuto(t, r, mr)
279+ checks.EXPECT().CommitChecks(mock.Anything, r.repo, r.head().String()).Return([]domain.CICheck{{Status: domain.CICompleted, Conclusion: domain.CISuccess}}, nil)
280+ a := autoEvaluator(r, checks, nil, nil)
281+ a.Start()
282+ defer a.Close(r.ctx)
283+ require.Eventually(t, func() bool {
284+ got, err := r.store.MergeRequests().Get(r.ctx, r.repo, mr.Number)
285+ return err == nil && got.AutoMerged
286+ }, 5*time.Second, time.Millisecond)
287+ a.Close(r.ctx)
288+ r.mrs.Wait(r.ctx)
289+}
290+
291+func TestAutoMergeRetriesTransientErrors(t *testing.T) {
292+ ctx := context.Background()
293+ store := mocks.NewMergeRequestStore(t)
294+ meta := mocks.NewRepoMetaStore(t)
295+ clock := mocks.NewClock(t)
296+ clock.EXPECT().Now().Return(now).Maybe()
297+ repo := name(t, "proj")
298+ store.EXPECT().ListOpenAll(mock.Anything).Return([]domain.MergeRequest{{Repo: repo, AutoMerge: &domain.AutoMerge{}}}, nil)
299+ meta.EXPECT().Get(mock.Anything, repo).Return(domain.Repo{}, errors.New("temporary read error")).Once()
300+ meta.EXPECT().Get(mock.Anything, repo).Return(domain.Repo{Name: repo}, nil).Once()
301+ done := make(chan struct{}, 1)
302+ store.EXPECT().ListOpen(mock.Anything, repo).Run(func(context.Context, domain.RepoName) { done <- struct{}{} }).Return(nil, nil).Once()
303+ timer := make(chan time.Time, 1)
304+ armed := make(chan time.Duration, 1)
305+ a := usecase.NewAutoMerge(ctx, usecase.AutoMergeConfig{Store: store, Meta: meta, Clock: clock, After: func(d time.Duration) <-chan time.Time { armed <- d; return timer }})
306+ a.Start()
307+ defer a.Close(ctx)
308+ select {
309+ case d := <-armed:
310+ assert.Equal(t, time.Second, d)
311+ case <-time.After(5 * time.Second):
312+ t.Fatal("no retry scheduled")
313+ }
314+ timer <- now.Add(time.Second)
315+ select {
316+ case <-done:
317+ case <-time.After(5 * time.Second):
318+ t.Fatal("retry did not evaluate")
319+ }
320+}
321+
322+func TestAutoMergeKickFromUnderRunLockDoesNotDeadlock(t *testing.T) {
323+ r, mr, checks := autoRig(t)
324+ enableAuto(t, r, mr)
325+ checks.EXPECT().CommitChecks(mock.Anything, r.repo, r.head().String()).Return([]domain.CICheck{{Status: domain.CICompleted, Conclusion: domain.CISuccess}}, nil)
326+ a := autoEvaluator(r, checks, nil, nil)
327+ ci := newCIUnit(t, nil).ci
328+ ci.SetAutoMergeTrigger(a)
329+ gate := make(chan struct{})
330+ entered := make(chan struct{})
331+ r.mrs.SetAfterMergeGate(func() { close(entered); <-gate })
332+ defer func() { close(gate); a.Close(r.ctx); r.mrs.Wait(r.ctx) }()
333+ require.NoError(t, r.mrs.UnderRepoLock(r.ctx, r.repo, func() {
334+ a.Start()
335+ done := make(chan struct{})
336+ go func() {
337+ ci.UnderRunLock(1, func() { a.OnRepoChanged(r.ctx, r.repo) })
338+ close(done)
339+ }()
340+ select {
341+ case <-done:
342+ case <-time.After(5 * time.Second):
343+ t.Fatal("kick blocked behind merge lock")
344+ }
345+ }))
346+ select {
347+ case <-entered:
348+ case <-time.After(5 * time.Second):
349+ t.Fatal("merge never reached post-merge gate")
350+ }
351+}
352+
353+func TestAutoMergeRequiresSuccessfulCheck(t *testing.T) {
354+ for _, checksOnHead := range [][]domain.CICheck{nil, {{Status: domain.CICompleted, Conclusion: domain.CISkipped}}, {{Status: domain.CICompleted, Conclusion: domain.CINeutral}}} {
355+ r, mr, checks := autoRig(t)
356+ enableAuto(t, r, mr)
357+ checks.EXPECT().CommitChecks(mock.Anything, r.repo, r.head().String()).Return(checksOnHead, nil)
358+ a := autoEvaluator(r, checks, nil, nil)
359+ require.True(t, a.Evaluate(r.repo))
360+ a.Close(r.ctx)
361+ assert.True(t, storedAuto(t, r, mr).IsOpen())
362+ assert.NotNil(t, storedAuto(t, r, mr).AutoMerge)
363+ }
364+}
365+
366+func TestEnableAutoMergeNeedsTheHeadTheCallerSaw(t *testing.T) {
367+ t.Run("stale head", func(t *testing.T) {
368+ r, mr, _ := autoRig(t)
369+ stale := r.head()
370+ r.commit(author, "later.txt", "later\n", "push after review")
371+ r.push("feat")
372+ r.drain()
373+ _, err := r.mrs.EnableAutoMerge(r.ctx, root, r.repo, mr.Number, ports.EnableAutoMergeParams{HeadID: stale})
374+ require.ErrorIs(t, err, domain.ErrPreconditionFailed)
375+ assert.Contains(t, err.Error(), "branch moved, read again")
376+ assert.Nil(t, storedAuto(t, r, mr).AutoMerge)
377+ assert.Empty(t, r.drain(), "nothing is published")
378+ })
379+ t.Run("missing head", func(t *testing.T) {
380+ r, mr, _ := autoRig(t)
381+ _, err := r.mrs.EnableAutoMerge(r.ctx, root, r.repo, mr.Number, ports.EnableAutoMergeParams{})
382+ require.ErrorIs(t, err, domain.ErrInvalid)
383+ assert.Nil(t, storedAuto(t, r, mr).AutoMerge)
384+ })
385+}
386+
387+func TestMergeGateSettingsUpdateWaits(t *testing.T) {
388+ for _, params := range []ports.UpdateRepoParams{
389+ {AllowAutoMerge: ptr(false)},
390+ {AllowedMergeModes: ptr([]domain.MergeMode{domain.MergeModeSquash})},
391+ {FailingChecksBlock: ptr(false)},
392+ } {
393+ r, _, _ := autoRig(t)
394+ r.repos.WithMergeGate(r.mrs)
395+ entered, release, done := make(chan struct{}), make(chan struct{}), make(chan error, 1)
396+ go func() {
397+ done <- r.mrs.WithMergeLock(r.ctx, r.repo, func() error { close(entered); <-release; return nil })
398+ }()
399+ <-entered
400+ ctx, cancel := context.WithTimeout(r.ctx, 20*time.Millisecond)
401+ _, err := r.repos.Update(ctx, root, r.repo, params)
402+ cancel()
403+ require.ErrorIs(t, err, context.DeadlineExceeded)
404+ close(release)
405+ require.NoError(t, <-done)
406+ _, err = r.repos.Update(r.ctx, root, r.repo, params)
407+ require.NoError(t, err)
408+ }
409+}
410+
411+func TestAutoMergeResolvesEnablerAfterMergeGate(t *testing.T) {
412+ r, mr, checks := autoRig(t)
413+ enableAuto(t, r, mr)
414+ enabled := storedAuto(t, r, mr)
415+ enabled.AutoMerge.EnabledBy = domain.CIPusher{Kind: domain.CIPusherToken, TokenID: "revoked"}
416+ require.NoError(t, r.store.MergeRequests().Update(r.ctx, enabled))
417+ tokens := mocks.NewTokenStore(t)
418+ // Valid when the evaluator looks before CI, revoked by the time it holds the
419+ // merge lock: the merge must use the fresh answer.
420+ validTok := domain.Token{ID: "revoked", Scopes: []domain.Scope{domain.ScopeMRMerge}, Repos: []domain.RepoName{r.repo}}
421+ tokens.EXPECT().Get(mock.Anything, "revoked").Return(validTok, nil).Once()
422+ tokens.EXPECT().Get(mock.Anything, "revoked").Return(domain.Token{}, domain.ErrNotFound).Once()
423+ reached := make(chan struct{})
424+ checks.EXPECT().CommitChecks(mock.Anything, r.repo, r.head().String()).Run(func(context.Context, domain.RepoName, string) { close(reached) }).Return([]domain.CICheck{{Status: domain.CICompleted, Conclusion: domain.CISuccess}}, nil).Once()
425+ a := autoEvaluator(r, checks, nil, tokens)
426+ defer a.Close(r.ctx)
427+ done := make(chan bool, 1)
428+ require.NoError(t, r.mrs.WithMergeLock(r.ctx, r.repo, func() error {
429+ go func() { done <- a.Evaluate(r.repo) }()
430+ <-reached
431+ // Evaluation has passed its fast-path checks with the then-valid token
432+ // and waits for this lock. The token is revoked meanwhile (second Get).
433+ return nil
434+ }))
435+ require.True(t, <-done)
436+ got := storedAuto(t, r, mr)
437+ require.Nil(t, got.AutoMerge)
438+ require.True(t, got.IsOpen())
439+}
440+
441+func TestAutoMergeRechecksSettledUnderMergeGate(t *testing.T) {
442+ r, mr, checks := autoRig(t)
443+ enableAuto(t, r, mr)
444+ activity := mocks.NewCIActivity(t)
445+ activity.EXPECT().Settled(mock.Anything, r.repo, mr.Number).Return(true).Once()
446+ activity.EXPECT().Settled(mock.Anything, r.repo, mr.Number).Return(false).Once()
447+ checks.EXPECT().CommitChecks(mock.Anything, r.repo, r.head().String()).Return([]domain.CICheck{{Status: domain.CICompleted, Conclusion: domain.CISuccess}}, nil).Once()
448+ a := autoEvaluator(r, checks, activity, nil)
449+ defer a.Close(r.ctx)
450+ require.True(t, a.Evaluate(r.repo))
451+ require.True(t, storedAuto(t, r, mr).IsOpen())
452+ require.NotNil(t, storedAuto(t, r, mr).AutoMerge)
453+}
M internal/usecase/ci.go +61 -2
@@ -53,8 +53,9 @@ const (
5353 // Git, Diff, Bus, Clock, Owner and PublicURL are required (the config supplies
5454 // the defaults of Owner and Retention).
5555 type CIConfig struct {
56- Store ports.CIStore
57- Logs ports.CILogStore
56+ AutoMerge ports.AutoMergeTrigger
57+ Store ports.CIStore
58+ Logs ports.CILogStore
5859 // Summaries keeps the job summaries, deleted with the logs of their run.
5960 Summaries ports.CISummaryStore
6061 Meta ports.RepoMetaStore
@@ -266,6 +267,9 @@ type CI struct {
266267 pending notifier // woken when a job may be ready or a runner was removed
267268 flows *wfCache
268269
270+ pushes map[domain.RepoName]int
271+ pulls map[pullKey]int
272+ autoMerge ports.AutoMergeTrigger
269273 mu sync.Mutex
270274 heartbeats map[string]time.Time // job reference -> last heartbeat
271275 seen map[string]time.Time // runner id -> last seen
@@ -311,6 +315,7 @@ func NewCI(ctx context.Context, cfg CIConfig) *CI {
311315 cfg = cfg.withDefaults()
312316 run, stop := context.WithCancel(context.WithoutCancel(ctx))
313317 return &CI{
318+ pushes: map[domain.RepoName]int{}, pulls: map[pullKey]int{}, autoMerge: cfg.AutoMerge,
314319 cfg: cfg, boot: cfg.Clock.Now(), queue: make(chan ciTrigger, cfg.TriggerQueue), runEvents: make(chan runEvent, cfg.TriggerQueue), sched: newScheduler(cfg.Clock.Now()), pull: newPullQueue(cfg.TriggerQueue, cfg.PullRefreshes, cfg.PullAttempts),
315320 ctx: run, stop: stop, flows: newWfCache(64), remoteSlots: make(chan struct{}, cfg.RemoteConcurrency), remoteCache: newRefsCache(),
316321 heartbeats: map[string]time.Time{}, seen: map[string]time.Time{}, polls: map[string]pollRecord{},
@@ -343,6 +348,15 @@ func (c *CI) Close(_ context.Context) {
343348 c.mu.Unlock()
344349 c.stop()
345350 c.wg.Wait()
351+ // Workers stop on cancellation; release the planning parts they did not take.
352+ for {
353+ select {
354+ case t := <-c.queue:
355+ t.done()
356+ default:
357+ return
358+ }
359+ }
346360 }
347361
348362 func (c *CI) now() time.Time { return c.cfg.Clock.Now().UTC() }
@@ -560,6 +574,7 @@ func (rs *runState) flush(ctx context.Context) error {
560574 }
561575 if completing {
562576 c.onRunEvent(ctx, rs.run, runCompleted)
577+ c.kickAutoMerge(ctx, rs.run.Repo)
563578 }
564579 return nil
565580 }
@@ -740,3 +755,47 @@ func isHex40(s string) bool {
740755 }
741756 return strings.Trim(s, "0123456789abcdef") == ""
742757 }
758+
759+// SetAutoMergeTrigger installs the evaluator after constructing the merge use cases.
760+func (c *CI) SetAutoMergeTrigger(t ports.AutoMergeTrigger) {
761+ c.mu.Lock()
762+ c.autoMerge = t
763+ c.mu.Unlock()
764+}
765+
766+func (c *CI) kickAutoMerge(ctx context.Context, repo domain.RepoName) {
767+ c.mu.Lock()
768+ t := c.autoMerge
769+ c.mu.Unlock()
770+ if t != nil {
771+ t.OnRepoChanged(ctx, repo)
772+ }
773+}
774+
775+// Settled reports whether all push planning and pull activities have finished.
776+// Workflow-run events, schedules and dispatches are intentionally not counted.
777+// Repo-wide pushes conservatively delay requests even when an unrelated
778+// branch is updated.
779+func (c *CI) Settled(_ context.Context, repo domain.RepoName, number int64) bool {
780+ c.mu.Lock()
781+ defer c.mu.Unlock()
782+ return c.pushes[repo] == 0 && c.pulls[pullKey{repo: repo, number: number}] == 0
783+}
784+
785+func (c *CI) finishPush(repo domain.RepoName) {
786+ c.mu.Lock()
787+ c.pushes[repo]--
788+ if c.pushes[repo] == 0 {
789+ delete(c.pushes, repo)
790+ }
791+ c.mu.Unlock()
792+}
793+
794+func (c *CI) finishPull(key pullKey) {
795+ c.mu.Lock()
796+ c.pulls[key]--
797+ if c.pulls[key] == 0 {
798+ delete(c.pulls, key)
799+ }
800+ c.mu.Unlock()
801+}
M internal/usecase/ci_actions.go +22 -14
@@ -271,31 +271,39 @@ func (c *CI) pusherCanRead(ctx context.Context, by pusherAccess, runRepo domain.
271271 if repo.Name == runRepo {
272272 return true, nil
273273 }
274- var p domain.Principal
275- switch by.pusher.Kind {
274+ p, _, err := principalOfPusher(ctx, c.cfg.Tokens, by.pusher, c.now())
275+ if err != nil {
276+ return false, err
277+ }
278+ return canRead(p, repo), nil
279+}
280+
281+// principalOfPusher re-resolves a recorded actor without substituting a more
282+// privileged identity when its token no longer exists or has expired.
283+func principalOfPusher(ctx context.Context, tokens ports.TokenStore, by domain.CIPusher, now time.Time) (domain.Principal, bool, error) {
284+ switch by.Kind {
276285 case domain.CIPusherRoot:
277- p = domain.Root()
286+ return domain.Root(), true, nil
278287 case domain.CIPusherOwner:
279- p = domain.Owner()
288+ return domain.Owner(), true, nil
280289 case domain.CIPusherToken:
281- if c.cfg.Tokens == nil {
282- return false, nil
290+ if tokens == nil {
291+ return domain.Anonymous(), false, nil
283292 }
284- tok, err := c.cfg.Tokens.Get(ctx, by.pusher.TokenID)
293+ tok, err := tokens.Get(ctx, by.TokenID)
285294 if errors.Is(err, domain.ErrNotFound) {
286- return false, nil
295+ return domain.Anonymous(), false, nil
287296 }
288297 if err != nil {
289- return false, err
298+ return domain.Anonymous(), false, err
290299 }
291- if tok.Expired(c.now()) {
292- return false, nil
300+ if tok.Expired(now) {
301+ return domain.Anonymous(), false, nil
293302 }
294- p = domain.TokenPrincipal(tok)
303+ return domain.TokenPrincipal(tok), true, nil
295304 default:
296- p = domain.Anonymous()
305+ return domain.Anonymous(), false, nil
297306 }
298- return canRead(p, repo), nil
299307 }
300308
301309 // actionRef is a parsed "uses": owner/repo[/path]@ref.
A internal/usecase/ci_activity_test.go +72 -0
@@ -0,0 +1,72 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase_test
4+
5+import (
6+ "context"
7+ "testing"
8+
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/mock"
11+
12+ "bnema.dev/ermite/internal/domain"
13+ "bnema.dev/ermite/internal/ports/mocks"
14+ "bnema.dev/ermite/internal/usecase"
15+)
16+
17+func TestSettledCountsQueuedPushesAndPullEvents(t *testing.T) {
18+ trigger := mocks.NewAutoMergeTrigger(t)
19+ u := newCIUnit(t, func(cfg *usecase.CIConfig) { cfg.TriggerQueue = 1; cfg.AutoMerge = trigger })
20+ repo := name(t, "proj")
21+ trigger.EXPECT().OnRepoChanged(mock.Anything, repo).Return().Times(3)
22+ u.store.EXPECT().ListRunners(mock.Anything).Return(nil, nil).Once()
23+ ctx := context.Background()
24+ settled := func(n int64) bool { return u.ci.Settled(ctx, repo, n) }
25+ assert.True(t, settled(1))
26+ u.ci.QueuePush(ctx, repo)
27+ u.ci.QueuePush(ctx, repo) // dropped: queue capacity is one
28+ assert.False(t, settled(1))
29+ assert.False(t, settled(2)) // pushes conservatively cover the whole repo
30+ u.ci.QueuePull(repo, 1)
31+ u.ci.QueuePull(repo, 1) // dropped: no counter leak
32+ u.ci.PlanQueuedPush()
33+ assert.False(t, settled(1))
34+ assert.True(t, settled(2))
35+ u.ci.FinishQueuedPull()
36+ assert.True(t, settled(1))
37+}
38+
39+func TestSettledWaitsForPushDiscoveryAndReleasesOnError(t *testing.T) {
40+ mrs := mocks.NewMergeRequestStore(t)
41+ entered, release := make(chan struct{}), make(chan struct{})
42+ u := newCIUnit(t, func(cfg *usecase.CIConfig) {
43+ cfg.MRs = mrs
44+ cfg.Pull = mocks.NewPullGit(t)
45+ cfg.PullStage = func(string) { close(entered); <-release }
46+ })
47+ repo := name(t, "proj")
48+ mrs.EXPECT().OpenByTarget(mock.Anything, repo, mock.Anything, mock.Anything).Return(nil, false, nil)
49+ mrs.EXPECT().OpenBySource(mock.Anything, repo, mock.Anything, mock.Anything).Return(nil, false, domain.ErrUnavailable)
50+ u.store.EXPECT().ListRunners(mock.Anything).Return(nil, nil).Once()
51+ done := make(chan struct{})
52+ go func() {
53+ u.ci.OnPush(context.Background(), repo, []domain.RefUpdate{{Ref: "refs/heads/feat", New: "1111111111111111111111111111111111111111"}}, domain.Root())
54+ close(done)
55+ }()
56+ <-entered
57+ u.ci.PlanQueuedPush()
58+ assert.False(t, u.ci.Settled(context.Background(), repo, 1))
59+ close(release)
60+ <-done
61+ assert.True(t, u.ci.Settled(context.Background(), repo, 1))
62+}
63+
64+func TestSettledReleasesQueuedPushOnShutdown(t *testing.T) {
65+ u := newCIUnit(t, nil)
66+ repo := name(t, "proj")
67+ u.ci.QueuePush(context.Background(), repo)
68+ u.ci.Close(context.Background())
69+ assert.True(t, u.ci.Settled(context.Background(), repo, 1))
70+ u.ci.QueuePush(context.Background(), repo)
71+ assert.True(t, u.ci.Settled(context.Background(), repo, 1))
72+}
M internal/usecase/ci_github_test.go +21 -0
@@ -5,14 +5,17 @@ package usecase_test
55 import (
66 "bytes"
77 "context"
8+ "encoding/json"
89 "fmt"
910 "math"
1011 "strconv"
1112 "strings"
1213 "testing"
1314
15+ "bnema.dev/ermite/internal/ports/mocks"
1416 "github.com/bnema/zerowrap"
1517 "github.com/stretchr/testify/assert"
18+ "github.com/stretchr/testify/mock"
1619 "github.com/stretchr/testify/require"
1720
1821 "bnema.dev/ermite-actions/workflow"
@@ -109,3 +112,21 @@ func TestCheckRunID(t *testing.T) {
109112 assert.Contains(t, logs.String(), `"job_index":`+strconv.Itoa(c.index), name)
110113 }
111114 }
115+
116+func TestGitHubRepoReportsAllowAutoMerge(t *testing.T) {
117+ for _, allow := range []bool{false, true} {
118+ t.Run(strconv.FormatBool(allow), func(t *testing.T) {
119+ repos := mocks.NewGitRepos(t)
120+ u := newCIUnit(t, func(cfg *usecase.CIConfig) { cfg.Repos = repos })
121+ repo := domain.Repo{Name: name(t, "proj"), Visibility: domain.VisibilityPublic, Settings: domain.DefaultRepoSettings()}
122+ repo.Settings.AllowAutoMerge = allow
123+ u.meta.EXPECT().Get(mock.Anything, repo.Name).Return(repo, nil)
124+ repos.EXPECT().HeadBranch(mock.Anything, repo.Name).Return("main", nil)
125+ data, err := u.ci.GetRepo(context.Background(), domain.Root(), "owner", "proj")
126+ require.NoError(t, err)
127+ var obj map[string]any
128+ require.NoError(t, json.Unmarshal(data, &obj))
129+ assert.Equal(t, allow, obj["allow_auto_merge"])
130+ })
131+ }
132+}
M internal/usecase/ci_githubapi.go +1 -1
@@ -74,7 +74,7 @@ func (c *CI) GetRepo(ctx context.Context, p domain.Principal, owner, name string
7474 for k, v := range map[string]any{
7575 "network_count": 0, "subscribers_count": 0, "allow_squash_merge": repo.Settings.Allows(domain.MergeModeSquash),
7676 "allow_merge_commit": false, "allow_rebase_merge": repo.Settings.Allows(domain.MergeModeFastForward),
77- "allow_auto_merge": false, "delete_branch_on_merge": false, "allow_update_branch": false,
77+ "allow_auto_merge": repo.EffectiveSettings().AllowAutoMerge, "delete_branch_on_merge": false, "allow_update_branch": false,
7878 "use_squash_pr_title_as_default": false, "has_pull_requests": true,
7979 } {
8080 obj[k] = v
M internal/usecase/ci_pull.go +6 -0
@@ -482,6 +482,9 @@ func (c *CI) wakePull() {
482482 func (c *CI) queuePullEvent(e pullEvent) {
483483 c.mu.Lock()
484484 closed := c.closed
485+ if !closed {
486+ c.pulls[pullKey{repo: e.repo, number: e.number}]++
487+ }
485488 c.mu.Unlock()
486489 if closed {
487490 return
@@ -489,6 +492,7 @@ func (c *CI) queuePullEvent(e pullEvent) {
489492 select {
490493 case c.pull.events <- e:
491494 default:
495+ c.finishPull(pullKey{repo: e.repo, number: e.number})
492496 e.log.Error().Str("repo", e.repo.String()).Int64("number", e.number).Str("action", e.action).Msg("ci pull request queue is full: this activity of a merge request is not run")
493497 }
494498 }
@@ -614,6 +618,8 @@ func (c *CI) pullLoop() {
614618 return
615619 case e := <-c.pull.events:
616620 c.guardPull(e.log, e.repo, func(ctx context.Context) error { return c.handlePullEvent(ctx, e) })
621+ c.finishPull(pullKey{repo: e.repo, number: e.number})
622+ c.kickAutoMerge(c.ctx, e.repo)
617623 }
618624 }
619625 }
M internal/usecase/ci_trigger.go +41 -6
@@ -12,6 +12,7 @@ import (
1212 "slices"
1313 "strconv"
1414 "strings"
15+ "sync/atomic"
1516 "time"
1617
1718 "github.com/bnema/zerowrap"
@@ -33,6 +34,7 @@ const (
3334
3435 // ciTrigger is a push waiting to be planned.
3536 type ciTrigger struct {
37+ done func()
3638 repo domain.RepoName
3739 updates []domain.RefUpdate
3840 actor string
@@ -54,6 +56,9 @@ type ciTrigger struct {
5456 // with GITHUB_TOKEN start no run): that is what stops a workflow that pushes
5557 // from running itself again.
5658 func (c *CI) OnPush(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, pusher domain.Principal) {
59+ done := c.pushLifecycle(ctx, repo, 2)
60+ defer done()
61+
5762 // Whoever pushed, the workflow files of the default branch may have changed.
5863 // (Who pushed was recorded before the refs moved: Pushes.Check.)
5964 c.markSchedulesDirty(repo, updates)
@@ -63,9 +68,10 @@ func (c *CI) OnPush(ctx context.Context, repo domain.RepoName, updates []domain.
6368 if pusher.ViaJobToken() {
6469 log := zerowrap.FromCtx(ctx)
6570 log.Debug().Str("repo", repo.String()).Int("refs", len(updates)).Msg("push by a job token starts no run")
71+ done()
6672 return
6773 }
68- c.enqueue(ctx, repo, updates, domain.AuthorName(pusher), domain.PusherOf(pusher), domain.TrustedActor(pusher))
74+ c.enqueuePush(ctx, repo, updates, domain.AuthorName(pusher), domain.PusherOf(pusher), domain.TrustedActor(pusher), done)
6975 }
7076
7177 // OnMerge queues the move of a target branch by a merge. The actor of the run is
@@ -75,6 +81,9 @@ func (c *CI) OnPush(ctx context.Context, repo domain.RepoName, updates []domain.
7581 // merging. For the same reason the run is trusted (gets secrets) only when the
7682 // merger is: the displayed actor is not the one that decides.
7783 func (c *CI) OnMerge(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, merger domain.Principal) {
84+ done := c.pushLifecycle(ctx, repo, 2)
85+ defer done()
86+
7887 c.markSchedulesDirty(repo, updates)
7988 // The merge moved a target branch: the test merges of the other requests
8089 // into it are computed again (a merge starts no synchronize run), after the
@@ -83,12 +92,32 @@ func (c *CI) OnMerge(ctx context.Context, repo domain.RepoName, updates []domain
8392 if merger.ViaJobToken() {
8493 log := zerowrap.FromCtx(ctx)
8594 log.Debug().Str("repo", repo.String()).Int("refs", len(updates)).Msg("merge by a job token starts no run")
95+ done()
8696 return
8797 }
88- c.enqueue(ctx, repo, updates, domain.AuthorName(domain.Owner()), domain.PusherOf(merger), domain.TrustedActor(merger))
98+ c.enqueuePush(ctx, repo, updates, domain.AuthorName(domain.Owner()), domain.PusherOf(merger), domain.TrustedActor(merger), done)
8999 }
90100
91101 func (c *CI) enqueue(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, actor string, pusher domain.CIPusher, trusted bool) {
102+ c.enqueuePush(ctx, repo, updates, actor, pusher, trusted, c.pushLifecycle(ctx, repo, 1))
103+}
104+
105+// pushLifecycle holds one repository activity count until all parts finish.
106+func (c *CI) pushLifecycle(ctx context.Context, repo domain.RepoName, parts int32) func() {
107+ c.mu.Lock()
108+ c.pushes[repo]++
109+ c.mu.Unlock()
110+ var remaining atomic.Int32
111+ remaining.Store(parts)
112+ return func() {
113+ if remaining.Add(-1) == 0 {
114+ c.finishPush(repo)
115+ c.kickAutoMerge(ctx, repo)
116+ }
117+ }
118+}
119+
120+func (c *CI) enqueuePush(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, actor string, pusher domain.CIPusher, trusted bool, done func()) {
92121 var kept []domain.RefUpdate
93122 for _, u := range updates {
94123 if strings.HasPrefix(u.Ref, "refs/heads/") || strings.HasPrefix(u.Ref, "refs/tags/") {
@@ -96,20 +125,25 @@ func (c *CI) enqueue(ctx context.Context, repo domain.RepoName, updates []domain
96125 }
97126 }
98127 if len(kept) == 0 {
128+ done()
99129 return
100130 }
101- t := ciTrigger{repo: repo, updates: kept, actor: actor, pusher: pusher, trusted: trusted, at: c.now(), log: zerowrap.FromCtx(ctx)}
131+ t := ciTrigger{done: done, repo: repo, updates: kept, actor: actor, pusher: pusher, trusted: trusted, at: c.now(), log: zerowrap.FromCtx(ctx)}
102132 c.mu.Lock()
103- closed := c.closed
104- c.mu.Unlock()
105- if closed {
133+ if c.closed {
134+ c.mu.Unlock()
135+ done()
106136 return
107137 }
108138 select {
109139 case c.queue <- t:
140+ c.mu.Unlock()
110141 default:
142+ c.mu.Unlock()
143+ done()
111144 t.log.Error().Str("repo", repo.String()).Int("refs", len(kept)).Msg("ci trigger queue is full: the workflows of this push are not run")
112145 }
146+
113147 }
114148
115149 func (c *CI) triggerLoop() {
@@ -127,6 +161,7 @@ func (c *CI) triggerLoop() {
127161 // planPush plans one push; a panic or an error is logged and never escapes.
128162 func (c *CI) planPush(t ciTrigger) {
129163 ctx := zerowrap.WithCtx(c.ctx, t.log)
164+ defer t.done()
130165 defer func() {
131166 if r := recover(); r != nil {
132167 t.log.Error().Interface("panic", r).Bytes("stack", debug.Stack()).Str("repo", t.repo.String()).Msg("ci trigger panicked")
M internal/usecase/events.go +9 -0
@@ -182,6 +182,10 @@ func (e *Events) snapshot(ctx context.Context, ev domain.Event) bufferedEvent {
182182 return repoSnapshot(ev.Visibility, ev.CreatedAt)
183183 case domain.MergeRequestClosed:
184184 return repoSnapshot(ev.Visibility, ev.CreatedAt)
185+ case domain.MergeRequestAutoMergeEnabled:
186+ return repoSnapshot(ev.Visibility, ev.CreatedAt)
187+ case domain.MergeRequestAutoMergeCancelled:
188+ return repoSnapshot(ev.Visibility, ev.CreatedAt)
185189 case domain.MergeRequestReady:
186190 return repoSnapshot(ev.Visibility, ev.CreatedAt)
187191 case domain.CommentAdded:
@@ -533,6 +537,10 @@ func (e *Events) deliver(ctx context.Context, w *watch, be bufferedEvent, send f
533537 return e.deliverRepo(ctx, w, ev.MergeRequest.Repo, false, be, out, send)
534538 case domain.MergeRequestClosed:
535539 return e.deliverRepo(ctx, w, ev.MergeRequest.Repo, false, be, out, send)
540+ case domain.MergeRequestAutoMergeEnabled:
541+ return e.deliverRepo(ctx, w, ev.MergeRequest.Repo, false, be, out, send)
542+ case domain.MergeRequestAutoMergeCancelled:
543+ return e.deliverRepo(ctx, w, ev.MergeRequest.Repo, false, be, out, send)
536544 case domain.MergeRequestReady:
537545 return e.deliverRepo(ctx, w, ev.MergeRequest.Repo, false, be, out, send)
538546 case domain.CommentAdded:
@@ -657,6 +665,7 @@ func isRepoLifecycleEvent(ev domain.Event) bool {
657665 func isItemEvent(ev domain.Event) bool {
658666 switch ev.(type) {
659667 case domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed,
668+ domain.MergeRequestAutoMergeEnabled, domain.MergeRequestAutoMergeCancelled,
660669 domain.MergeRequestReady, domain.MergeRequestMergeability, domain.CommentAdded,
661670 domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved:
662671 return true
M internal/usecase/export_test.go +48 -0
@@ -220,3 +220,51 @@ func (s *Mergeability) PopDueForTest(repo domain.RepoName, due time.Time) []doma
220220
221221 // WindowLimits exposes windowLimits to the tests.
222222 var WindowLimits = windowLimits
223+
224+// SetChecks installs a checks reader before starting a test evaluator.
225+func (m *MergeRequests) SetChecks(checks ports.CIChecksReader) { m.checks = checks }
226+
227+// Evaluate exposes a single synchronous pass without starting workers.
228+func (s *AutoMerge) Evaluate(repo domain.RepoName) bool { return s.evaluate(repo) }
229+
230+// QueuePush exposes the normal trigger queue for activity accounting tests.
231+func (c *CI) QueuePush(ctx context.Context, repo domain.RepoName) {
232+ c.enqueue(ctx, repo, []domain.RefUpdate{{Ref: "refs/heads/main"}}, "owner", domain.PusherOf(domain.Root()), true)
233+}
234+
235+// PlanQueuedPush handles the next queued trigger without starting workers.
236+func (c *CI) PlanQueuedPush() { c.planPush(<-c.queue) }
237+
238+// QueuePull exposes the pull activity queue for accounting tests.
239+func (c *CI) QueuePull(repo domain.RepoName, number int64) {
240+ c.queuePullEvent(pullEvent{repo: repo, number: number})
241+}
242+
243+// FinishQueuedPull completes one test activity without planning workflows.
244+func (c *CI) FinishQueuedPull() {
245+ e := <-c.pull.events
246+ c.finishPull(pullKey{repo: e.repo, number: e.number})
247+ c.kickAutoMerge(c.ctx, e.repo)
248+}
249+
250+// SetWorkflowTrigger installs the merge hook before evaluation.
251+func (m *MergeRequests) SetWorkflowTrigger(t ports.WorkflowTrigger) { m.ci = t }
252+
253+// UnderRunLock exposes the production run lock to test nonblocking triggers.
254+func (c *CI) UnderRunLock(id int64, fn func()) {
255+ lock := c.runLock(id)
256+ lock.Lock()
257+ defer lock.Unlock()
258+ fn()
259+}
260+
261+// UnderRepoLock holds the production merge lock while a test interleaves a kick.
262+func (m *MergeRequests) UnderRepoLock(ctx context.Context, repo domain.RepoName, fn func()) error {
263+ unlock, err := m.lockRepo(ctx, repo)
264+ if err != nil {
265+ return err
266+ }
267+ defer unlock()
268+ fn()
269+ return nil
270+}
M internal/usecase/mergeability.go +18 -6
@@ -35,12 +35,13 @@ func mergeBackoff(attempt int) time.Duration {
3535
3636 // MergeabilityConfig holds the dependencies of the repository reconciler.
3737 type MergeabilityConfig struct {
38- Store ports.MergeRequestStore
39- Meta ports.RepoMetaStore
40- Git ports.MergeGit
41- Bus ports.EventBus
42- Clock ports.Clock
43- Workers int // defaults to 2
38+ AutoMerge ports.AutoMergeTrigger
39+ Store ports.MergeRequestStore
40+ Meta ports.RepoMetaStore
41+ Git ports.MergeGit
42+ Bus ports.EventBus
43+ Clock ports.Clock
44+ Workers int // defaults to 2
4445 // After is injectable to drive backoff without wall-clock waits in tests.
4546 After func(time.Duration) <-chan time.Time
4647 }
@@ -235,6 +236,7 @@ func (s *Mergeability) reconcile(repo domain.RepoName) bool {
235236 return false
236237 }
237238 ok := true
239+ changed := false
238240 for _, mr := range mrs {
239241 if ctx.Err() != nil {
240242 return true
@@ -275,6 +277,10 @@ func (s *Mergeability) reconcile(repo domain.RepoName) bool {
275277 Repo: repo, Number: mr.Number, Mergeability: res, At: res.CheckedAt,
276278 Visibility: meta.Visibility, CreatedAt: meta.CreatedAt,
277279 })
280+ changed = true
281+ }
282+ if changed && s.cfg.AutoMerge != nil {
283+ s.cfg.AutoMerge.OnRepoChanged(ctx, repo)
278284 }
279285 return ok
280286 }
@@ -465,3 +471,9 @@ func (s *Mergeability) compute(ctx context.Context, repo domain.RepoName, number
465471 m.Behind, m.BehindCapped = domain.BoundBehind(n)
466472 return m, nil
467473 }
474+
475+// WithAutoMerge installs the evaluator before Start.
476+func (s *Mergeability) WithAutoMerge(t ports.AutoMergeTrigger) *Mergeability {
477+ s.cfg.AutoMerge = t
478+ return s
479+}
M internal/usecase/mergerequests.go +93 -18
@@ -58,7 +58,8 @@ type MergeRequests struct {
5858 tips ports.CIRefTipStore // nil: who merges is not recorded
5959 verifier ports.CommitVerifier // nil: commits are not checked (ADR 0030)
6060 mergeab MergeabilityHook // nil: no mergeability
61- mirror ports.MirrorTrigger // nil: no outbound mirror
61+ autoMerge ports.AutoMergeTrigger
62+ mirror ports.MirrorTrigger // nil: no outbound mirror
6263
6364 mu sync.Mutex
6465 locks map[domain.RepoName]chan struct{}
@@ -148,6 +149,9 @@ func (m *MergeRequests) Wait(ctx context.Context) {
148149 // lockRepo takes the merge lock of a repository, waiting until ctx ends. The
149150 // merges of one repository, and the creation of its merge requests, run one at
150151 // a time. The returned function releases the lock.
152+// Lock order is merge lock → store (meta.Mutate / kv). Nothing holding a
153+// store transaction or a CI lock may take this lock. MergeRequests never
154+// calls Repos.Update.
151155 func (m *MergeRequests) lockRepo(ctx context.Context, name domain.RepoName) (unlock func(), err error) {
152156 m.mu.Lock()
153157 ch, ok := m.locks[name]
@@ -899,6 +903,8 @@ func (m *MergeRequests) Close(ctx context.Context, p domain.Principal, name doma
899903 if err != nil {
900904 return domain.MergeRequest{}, err
901905 }
906+ hadAutoMerge := mr.AutoMerge != nil
907+ mr.AutoMerge = nil
902908 mr.State, mr.BaseID, mr.UpdatedAt = domain.MRStateClosed, base, m.clock.Now().UTC()
903909 err = m.store.Update(ctx, mr)
904910 if errors.Is(err, domain.ErrConflict) {
@@ -912,6 +918,9 @@ func (m *MergeRequests) Close(ctx context.Context, p domain.Principal, name doma
912918 Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("merge request closed")
913919 recordActivity(ctx, m.meta, repo.Name, mr.UpdatedAt)
914920 m.bus.Publish(ctx, domain.MergeRequestClosed{MergeRequest: mr, At: mr.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
921+ if hadAutoMerge {
922+ m.bus.Publish(ctx, domain.MergeRequestAutoMergeCancelled{MergeRequest: mr, Reason: "closed", At: mr.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
923+ }
915924 if m.pulls != nil {
916925 m.pulls.OnPullRequest(ctx, name, ports.PullRequestEvent{Number: number, Action: domain.PullClosed, Head: mr.HeadID, Base: mr.BaseID}, p)
917926 }
@@ -989,7 +998,7 @@ func (m *MergeRequests) MarkRead(ctx context.Context, p domain.Principal, name d
989998 // the CI and mergeability hooks. Wait waits for them. SourceDeleted therefore
990999 // only says that the deletion was requested.
9911000 func (m *MergeRequests) Merge(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params ports.MergeParams) (ports.MergeResult, error) {
992- mr, err := m.merge(ctx, p, name, number, params)
1001+ mr, err := m.mergeAs(ctx, p, name, number, params, mergeOpts{})
9931002 return ports.MergeResult{MergeRequest: mr, SourceDeleted: err == nil && params.DeleteSource}, err
9941003 }
9951004
@@ -1031,9 +1040,23 @@ func (m *MergeRequests) checkMode(ctx context.Context, mr domain.MergeRequest, m
10311040 return res.Tree, nil
10321041 }
10331042
1034-func (m *MergeRequests) merge(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params ports.MergeParams) (domain.MergeRequest, error) {
1035- if _, err := m.writable(ctx, p, name, domain.ScopeMRMerge); err != nil {
1036- return domain.MergeRequest{}, err
1043+type mergeOpts struct {
1044+ auto *autoMergeOpts
1045+}
1046+
1047+type autoMergeOpts struct {
1048+ intent *domain.AutoMerge
1049+ tokens ports.TokenStore
1050+ activity ports.CIActivity
1051+}
1052+
1053+var errAutoMergeWait = errors.New("auto-merge is waiting for checks")
1054+
1055+func (m *MergeRequests) mergeAs(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params ports.MergeParams, opts mergeOpts) (domain.MergeRequest, error) {
1056+ if opts.auto == nil {
1057+ if _, err := m.writable(ctx, p, name, domain.ScopeMRMerge); err != nil {
1058+ return domain.MergeRequest{}, err
1059+ }
10371060 }
10381061 mode := params.Mode
10391062 if mode == "" {
@@ -1062,6 +1085,15 @@ func (m *MergeRequests) merge(ctx context.Context, p domain.Principal, name doma
10621085 if err != nil {
10631086 return domain.MergeRequest{}, err
10641087 }
1088+ if opts.auto != nil && (mr.AutoMerge == nil || opts.auto.intent == nil || *mr.AutoMerge != *opts.auto.intent) {
1089+ return domain.MergeRequest{}, errAutoMergeWait
1090+ }
1091+ if opts.auto != nil {
1092+ p, err = enablerCanMerge(ctx, opts.auto.tokens, opts.auto.intent, mr, m.clock.Now())
1093+ if err != nil {
1094+ return domain.MergeRequest{}, err
1095+ }
1096+ }
10651097 // A merge moves the target and may delete the source: a token limited to
10661098 // branch prefixes may do so only within them.
10671099 if !p.AllowsRef("refs/heads/" + mr.Target) {
@@ -1113,14 +1145,33 @@ func (m *MergeRequests) merge(ctx context.Context, p domain.Principal, name doma
11131145 } else if behind {
11141146 return domain.MergeRequest{}, fmt.Errorf("%w: nothing to merge, %q already contains %s", domain.ErrPreconditionFailed, mr.Target, head)
11151147 }
1148+ if opts.auto != nil && opts.auto.activity != nil && !opts.auto.activity.Settled(ctx, name, number) {
1149+ return domain.MergeRequest{}, errAutoMergeWait
1150+ }
11161151 tree, err := m.checkMode(ctx, mr, mode, tip, head)
11171152 if err != nil {
1153+ if opts.auto != nil && errors.Is(err, domain.ErrPreconditionFailed) {
1154+ return domain.MergeRequest{}, errAutoMergeWait
1155+ }
11181156 return domain.MergeRequest{}, err
11191157 }
1120- if settings.FailingChecksBlock {
1121- if err := m.checksAllow(ctx, name, head); err != nil {
1158+ if opts.auto != nil && !settings.AllowAutoMerge {
1159+ return domain.MergeRequest{}, fmt.Errorf("%w: auto-merge is off in %s", domain.ErrPreconditionFailed, name)
1160+ }
1161+ if settings.FailingChecksBlock || opts.auto != nil {
1162+ checks, err := m.readMergeChecks(ctx, name, head)
1163+ if err == nil {
1164+ err = checksPolicy(checks)
1165+ }
1166+ if err != nil {
1167+ if opts.auto != nil && errors.Is(err, domain.ErrPreconditionFailed) {
1168+ return domain.MergeRequest{}, errAutoMergeWait
1169+ }
11221170 return domain.MergeRequest{}, err
11231171 }
1172+ if opts.auto != nil && !hasSuccessfulCheck(checks) {
1173+ return domain.MergeRequest{}, errAutoMergeWait
1174+ }
11241175 }
11251176 if params.DeleteSource {
11261177 if err := m.checkDeletable(ctx, name, mr); err != nil {
@@ -1133,7 +1184,7 @@ func (m *MergeRequests) merge(ctx context.Context, p domain.Principal, name doma
11331184 return domain.MergeRequest{}, err
11341185 }
11351186 m.recordPush(ctx, name) // the target moved, whatever happens next
1136- mr, err = m.markMerged(ctx, name, number, mode, tip, head, merged)
1187+ mr, err = m.markMerged(ctx, name, number, mode, tip, head, merged, opts.auto != nil)
11371188 if err != nil {
11381189 return domain.MergeRequest{}, err
11391190 }
@@ -1175,7 +1226,7 @@ func (m *MergeRequests) afterMerge(ctx context.Context, p domain.Principal, repo
11751226 }
11761227 at := mr.UpdatedAt
11771228 recordActivity(ctx, m.meta, repo.Name, at)
1178- m.bus.Publish(ctx, domain.MergeRequestMerged{MergeRequest: mr, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
1229+ m.bus.Publish(ctx, domain.MergeRequestMerged{MergeRequest: mr, Auto: mr.AutoMerged, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
11791230 m.bus.Publish(ctx, domain.PushEvent{
11801231 Repo: name, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt, Updates: updates,
11811232 })
@@ -1245,21 +1296,34 @@ func (m *MergeRequests) WhileOpen(ctx context.Context, name domain.RepoName, num
12451296 return fn()
12461297 }
12471298
1248-// checksAllow is the merge rule of FailingChecksBlock: every check of the
1249-// head must have ended as success, skipped or neutral. A check still queued or
1250-// running blocks too (the merge could land code that then fails), and a head
1251-// without any check merges.
1252-func (m *MergeRequests) checksAllow(ctx context.Context, name domain.RepoName, head domain.ObjectID) error {
1299+// readMergeChecks reads one snapshot, classifying an incomplete result as a
1300+// failed precondition rather than treating it as a passing set of checks.
1301+func (m *MergeRequests) readMergeChecks(ctx context.Context, name domain.RepoName, head domain.ObjectID) ([]domain.CICheck, error) {
12531302 if m.checks == nil {
1254- return nil
1303+ return nil, nil
12551304 }
12561305 checks, err := m.checks.CommitChecks(ctx, name, head.String())
12571306 if errors.Is(err, domain.ErrResourceExhausted) {
1258- return fmt.Errorf("%w: too many checks to tell whether they passed: %v", domain.ErrPreconditionFailed, err)
1307+ return nil, fmt.Errorf("%w: too many checks to tell whether they passed: %v", domain.ErrPreconditionFailed, err)
12591308 }
12601309 if err != nil {
1261- return fmt.Errorf("read the checks of %s: %w", head, err)
1310+ return nil, fmt.Errorf("read the checks of %s: %w", head, err)
12621311 }
1312+ return checks, nil
1313+}
1314+
1315+func hasSuccessfulCheck(checks []domain.CICheck) bool {
1316+ for _, check := range checks {
1317+ if check.Status == domain.CICompleted && check.Conclusion == domain.CISuccess {
1318+ return true
1319+ }
1320+ }
1321+ return false
1322+}
1323+
1324+// checksPolicy requires every check to have ended as success, skipped or
1325+// neutral. Queued and running checks block too; an empty set is allowed.
1326+func checksPolicy(checks []domain.CICheck) error {
12631327 var failing, pending []string
12641328 for _, c := range checks {
12651329 switch {
@@ -1365,7 +1429,7 @@ func (m *MergeRequests) moveTarget(ctx context.Context, mr domain.MergeRequest,
13651429
13661430 // markMerged records the merge. The target already moved, so a write that
13671431 // loses a race (a comment added meanwhile) is repeated on the fresh request.
1368-func (m *MergeRequests) markMerged(ctx context.Context, name domain.RepoName, number int64, mode domain.MergeMode, base, head, merged domain.ObjectID) (domain.MergeRequest, error) {
1432+func (m *MergeRequests) markMerged(ctx context.Context, name domain.RepoName, number int64, mode domain.MergeMode, base, head, merged domain.ObjectID, auto bool) (domain.MergeRequest, error) {
13691433 // The merge must be recorded even when the caller gives up.
13701434 ctx = context.WithoutCancel(ctx)
13711435 for range maxWriteRetries {
@@ -1373,6 +1437,7 @@ func (m *MergeRequests) markMerged(ctx context.Context, name domain.RepoName, nu
13731437 if err != nil {
13741438 return domain.MergeRequest{}, fmt.Errorf("record the merge of !%d in %q: %w", number, name, err)
13751439 }
1440+ mr.AutoMerge, mr.AutoMerged = nil, auto
13761441 mr.State, mr.HeadID, mr.BaseID, mr.MergedCommitID, mr.MergeMode = domain.MRStateMerged, head, base, merged, mode
13771442 mr.UpdatedAt = m.clock.Now().UTC()
13781443 err = m.store.Update(ctx, mr)
@@ -1520,3 +1585,13 @@ func (m *MergeRequests) PushSummary(ctx context.Context, p domain.Principal, rep
15201585 }
15211586 return ports.PushSummary{Count: len(commits), More: more}, nil
15221587 }
1588+
1589+// WithMergeLock serializes fn with repository merges and closes.
1590+func (m *MergeRequests) WithMergeLock(ctx context.Context, repo domain.RepoName, fn func() error) error {
1591+ unlock, err := m.lockRepo(ctx, repo)
1592+ if err != nil {
1593+ return err
1594+ }
1595+ defer unlock()
1596+ return fn()
1597+}
A internal/usecase/mergerequests_auto.go +168 -0
@@ -0,0 +1,168 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
6+ "context"
7+ "errors"
8+ "fmt"
9+
10+ "bnema.dev/ermite/internal/domain"
11+ "bnema.dev/ermite/internal/ports"
12+)
13+
14+// WithAutoMerge installs the asynchronous evaluator before serving requests.
15+func (m *MergeRequests) WithAutoMerge(t ports.AutoMergeTrigger) *MergeRequests {
16+ m.autoMerge = t
17+ return m
18+}
19+
20+// EnableAutoMerge records the actor and the head the caller saw, which must be
21+// the current head of the source branch; re-enabling replaces the
22+// previous intent rather than preserving an older actor's authority.
23+func (m *MergeRequests) EnableAutoMerge(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params ports.EnableAutoMergeParams) (domain.MergeRequest, error) {
24+ if _, err := m.writable(ctx, p, name, domain.ScopeMRMerge); err != nil {
25+ return domain.MergeRequest{}, err
26+ }
27+ if m.checks == nil {
28+ return domain.MergeRequest{}, fmt.Errorf("%w: this server has no CI", domain.ErrPreconditionFailed)
29+ }
30+ mode := params.Mode
31+ if mode == "" {
32+ mode = domain.MergeModeFastForward
33+ }
34+ var v domain.Violations
35+ if _, err := domain.ParseMergeMode(mode.String()); err != nil {
36+ v.Add("mode", "is not a merge mode")
37+ }
38+ if params.HeadID.IsZero() {
39+ v.Add("head_id", "is required")
40+ }
41+ if err := v.Err(); err != nil {
42+ return domain.MergeRequest{}, err
43+ }
44+ unlock, err := m.lockRepo(ctx, name)
45+ if err != nil {
46+ return domain.MergeRequest{}, err
47+ }
48+ defer unlock()
49+ for range maxWriteRetries {
50+ mr, err := m.store.Get(ctx, name, number)
51+ if err != nil {
52+ return domain.MergeRequest{}, err
53+ }
54+ if !mr.IsOpen() {
55+ return domain.MergeRequest{}, fmt.Errorf("%w: !%d is %s", domain.ErrPreconditionFailed, number, mr.State)
56+ }
57+ if mr.IsDraft() {
58+ return domain.MergeRequest{}, fmt.Errorf("%w: !%d is a draft: mark it ready for review first", domain.ErrPreconditionFailed, number)
59+ }
60+ if !p.AllowsRef("refs/heads/" + mr.Target) {
61+ return domain.MergeRequest{}, fmt.Errorf("%w: this token may not write %s", domain.ErrForbidden, mr.Target)
62+ }
63+ repo, err := m.meta.Get(ctx, name)
64+ if err != nil {
65+ return domain.MergeRequest{}, err
66+ }
67+ settings := repo.EffectiveSettings()
68+ if !settings.AllowAutoMerge {
69+ return domain.MergeRequest{}, fmt.Errorf("%w: auto-merge is off in %s: turn it on with `ermite repo edit --allow-auto-merge`", domain.ErrPreconditionFailed, name)
70+ }
71+ if !settings.Allows(mode) {
72+ return domain.MergeRequest{}, fmt.Errorf("%w: the %s merge mode is not allowed in %s", domain.ErrPreconditionFailed, mode, name)
73+ }
74+ head, err := m.branchOrZero(ctx, name, mr.Source)
75+ if err != nil {
76+ return domain.MergeRequest{}, err
77+ }
78+ if head.IsZero() {
79+ return domain.MergeRequest{}, fmt.Errorf("%w: the source branch %q no longer exists", domain.ErrPreconditionFailed, mr.Source)
80+ }
81+ if head != params.HeadID {
82+ return domain.MergeRequest{}, fmt.Errorf("%w: branch moved, read again: %q is at %s, not %s", domain.ErrPreconditionFailed, mr.Source, head, params.HeadID)
83+ }
84+ mr.UpdatedAt = m.clock.Now().UTC()
85+ mr.AutoMerge = &domain.AutoMerge{Mode: mode, HeadID: head, EnabledBy: domain.PusherOf(p), EnablerLabel: domain.AuthorName(p), EnabledAt: mr.UpdatedAt}
86+ if err := m.store.Update(ctx, mr); errors.Is(err, domain.ErrConflict) {
87+ continue
88+ } else if err != nil {
89+ return domain.MergeRequest{}, err
90+ }
91+ recordActivity(ctx, m.meta, name, mr.UpdatedAt)
92+ m.bus.Publish(ctx, domain.MergeRequestAutoMergeEnabled{MergeRequest: mr, At: mr.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
93+ if m.autoMerge != nil {
94+ m.autoMerge.OnAutoMergeEnabled(ctx, name)
95+ }
96+ return m.withHead(ctx, mr)
97+ }
98+ return domain.MergeRequest{}, fmt.Errorf("enable auto-merge: %w", domain.ErrConflict)
99+}
100+
101+// CancelAutoMerge clears an open request's intent with the same target access
102+// required to enable it.
103+func (m *MergeRequests) CancelAutoMerge(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.MergeRequest, error) {
104+ if _, err := m.writable(ctx, p, name, domain.ScopeMRMerge); err != nil {
105+ return domain.MergeRequest{}, err
106+ }
107+ unlock, err := m.lockRepo(ctx, name)
108+ if err != nil {
109+ return domain.MergeRequest{}, err
110+ }
111+ defer unlock()
112+ mr, err := m.store.Get(ctx, name, number)
113+ if err != nil {
114+ return domain.MergeRequest{}, err
115+ }
116+ if !mr.IsOpen() {
117+ return domain.MergeRequest{}, fmt.Errorf("%w: !%d is %s", domain.ErrPreconditionFailed, number, mr.State)
118+ }
119+ if !p.AllowsRef("refs/heads/" + mr.Target) {
120+ return domain.MergeRequest{}, fmt.Errorf("%w: this token may not write %s", domain.ErrForbidden, mr.Target)
121+ }
122+ if mr.AutoMerge == nil {
123+ return domain.MergeRequest{}, fmt.Errorf("%w: auto-merge is not enabled", domain.ErrPreconditionFailed)
124+ }
125+ mr, err = m.cancelAutoLocked(ctx, mr, "cancelled by "+domain.AuthorName(p))
126+ if err != nil {
127+ return domain.MergeRequest{}, err
128+ }
129+ return m.withHead(ctx, mr)
130+}
131+
132+// cancelAuto takes the merge lock and only cancels the intent evaluated by the
133+// caller. A concurrent re-enable, close or merge must not be undone.
134+func (m *MergeRequests) cancelAuto(ctx context.Context, mr domain.MergeRequest, reason string) error {
135+ unlock, err := m.lockRepo(ctx, mr.Repo)
136+ if err != nil {
137+ return err
138+ }
139+ defer unlock()
140+ _, err = m.cancelAutoLocked(ctx, mr, reason)
141+ return err
142+}
143+
144+func (m *MergeRequests) cancelAutoLocked(ctx context.Context, expected domain.MergeRequest, reason string) (domain.MergeRequest, error) {
145+ for range maxWriteRetries {
146+ mr, err := m.store.Get(ctx, expected.Repo, expected.Number)
147+ if err != nil {
148+ return domain.MergeRequest{}, err
149+ }
150+ if !mr.IsOpen() || mr.AutoMerge == nil || expected.AutoMerge == nil || *mr.AutoMerge != *expected.AutoMerge {
151+ return mr, nil
152+ }
153+ repo, err := m.meta.Get(ctx, mr.Repo)
154+ if err != nil {
155+ return domain.MergeRequest{}, err
156+ }
157+ mr.AutoMerge, mr.UpdatedAt = nil, m.clock.Now().UTC()
158+ if err := m.store.Update(ctx, mr); errors.Is(err, domain.ErrConflict) {
159+ continue
160+ } else if err != nil {
161+ return domain.MergeRequest{}, err
162+ }
163+ recordActivity(ctx, m.meta, mr.Repo, mr.UpdatedAt)
164+ m.bus.Publish(ctx, domain.MergeRequestAutoMergeCancelled{MergeRequest: mr, Reason: reason, At: mr.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
165+ return mr, nil
166+ }
167+ return domain.MergeRequest{}, fmt.Errorf("cancel auto-merge: %w", domain.ErrConflict)
168+}
M internal/usecase/repos.go +14 -6
@@ -17,12 +17,14 @@ import (
1717
1818 // Repos are the repository use cases.
1919 type Repos struct {
20- meta ports.RepoMetaStore
21- git ports.GitRepos
22- bus ports.EventBus
23- clock ports.Clock
24- ci ports.CIRunCleaner // nil: no CI logs to remove
25- mirror ports.MirrorTrigger // nil: no outbound mirror
20+ meta ports.RepoMetaStore
21+ git ports.GitRepos
22+ bus ports.EventBus
23+ clock ports.Clock
24+ ci ports.CIRunCleaner // nil: no CI logs to remove
25+ autoMerge ports.AutoMergeTrigger
26+ mergeGate ports.MergeGate
27+ mirror ports.MirrorTrigger // nil: no outbound mirror
2628 }
2729
2830 var _ ports.RepoService = (*Repos)(nil)
@@ -233,3 +235,9 @@ func (r *Repos) Reconcile(ctx context.Context) (ReconcileReport, error) {
233235 }
234236 return rep, nil
235237 }
238+
239+// WithAutoMerge installs the settings-change trigger before serving requests.
240+func (r *Repos) WithAutoMerge(t ports.AutoMergeTrigger) *Repos { r.autoMerge = t; return r }
241+
242+// WithMergeGate installs the settings-write gate before serving requests.
243+func (r *Repos) WithMergeGate(g ports.MergeGate) *Repos { r.mergeGate = g; return r }
M internal/usecase/repos_write.go +19 -4
@@ -62,7 +62,7 @@ func (r *Repos) Update(ctx context.Context, p domain.Principal, name domain.Repo
6262 }
6363 before := repo
6464 if params.Description == nil && params.Topics == nil && params.Visibility == nil && params.DefaultBranch == nil &&
65- params.AllowedMergeModes == nil && params.FailingChecksBlock == nil && params.Issues == nil {
65+ params.AllowedMergeModes == nil && params.FailingChecksBlock == nil && params.AllowAutoMerge == nil && params.Issues == nil {
6666 return domain.Repo{}, fmt.Errorf("%w: nothing to update", domain.ErrInvalid)
6767 }
6868 if params.AllowedMergeModes != nil {
@@ -116,11 +116,23 @@ func (r *Repos) Update(ctx context.Context, p domain.Principal, name domain.Repo
116116 // branch was checked above, so the remaining causes are a vanished
117117 // repository or an I/O failure.
118118 metaDone := false
119- if params.Description != nil || params.Topics != nil || params.Visibility != nil || params.AllowedMergeModes != nil || params.FailingChecksBlock != nil || params.Issues != nil {
120- if repo, err = r.updateMeta(ctx, name, params); err != nil {
119+ if params.Description != nil || params.Topics != nil || params.Visibility != nil || params.AllowedMergeModes != nil || params.FailingChecksBlock != nil || params.AllowAutoMerge != nil || params.Issues != nil {
120+ update := func() error {
121+ repo, err = r.updateMeta(ctx, name, params)
122+ return err
123+ }
124+ if r.mergeGate != nil && (params.AllowedMergeModes != nil || params.FailingChecksBlock != nil || params.AllowAutoMerge != nil) {
125+ err = r.mergeGate.WithMergeLock(ctx, name, update)
126+ } else {
127+ err = update()
128+ }
129+ if err != nil {
121130 return domain.Repo{}, err
122131 }
123132 metaDone = true
133+ if r.autoMerge != nil {
134+ r.autoMerge.OnRepoChanged(ctx, name)
135+ }
124136 }
125137 var headErr error
126138 if params.DefaultBranch != nil {
@@ -142,7 +154,7 @@ func (r *Repos) Update(ctx context.Context, p domain.Principal, name domain.Repo
142154 Bool("topics", params.Topics != nil).
143155 Bool("visibility", params.Visibility != nil).
144156 Bool("default_branch", params.DefaultBranch != nil).
145- Bool("settings", params.AllowedMergeModes != nil || params.FailingChecksBlock != nil || params.Issues != nil).
157+ Bool("settings", params.AllowedMergeModes != nil || params.FailingChecksBlock != nil || params.AllowAutoMerge != nil || params.Issues != nil).
146158 Str("principal", p.Label()).Str("token_id", p.TokenID()).
147159 Msg("repository updated")
148160 r.bus.Publish(ctx, domain.RepoUpdated{
@@ -175,6 +187,9 @@ func (r *Repos) updateMeta(ctx context.Context, name domain.RepoName, params por
175187 if params.FailingChecksBlock != nil {
176188 repo.Settings.FailingChecksBlock = *params.FailingChecksBlock
177189 }
190+ if params.AllowAutoMerge != nil {
191+ repo.Settings.AllowAutoMerge = *params.AllowAutoMerge
192+ }
178193 if params.Issues != nil {
179194 repo.Settings.Issues = *params.Issues
180195 }
M internal/usecase/repos_write_test.go +17 -0
@@ -15,6 +15,7 @@ import (
1515
1616 "bnema.dev/ermite/internal/domain"
1717 "bnema.dev/ermite/internal/ports"
18+ "bnema.dev/ermite/internal/ports/mocks"
1819 "bnema.dev/ermite/internal/usecase"
1920 )
2021
@@ -27,6 +28,22 @@ func TestUpdate(t *testing.T) {
2728 priv := domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, Description: "old"}
2829 admin := tokenPrincipal(domain.ScopeRepoAdmin, "tools")
2930
31+ t.Run("auto-merge setting is stored and kicks evaluator", func(t *testing.T) {
32+ f := newFixture(t)
33+ trigger := mocks.NewAutoMergeTrigger(t)
34+ f.repos.WithAutoMerge(trigger)
35+ f.meta.EXPECT().Get(ctx, n).Return(priv, nil).Once()
36+ want := priv
37+ want.Settings = domain.DefaultRepoSettings()
38+ want.Settings.AllowAutoMerge = true
39+ expectRepoMutation(t, f.meta, priv, want)
40+ trigger.EXPECT().OnRepoChanged(ctx, n).Return().Once()
41+ f.bus.EXPECT().Publish(ctx, mock.Anything).Return().Once()
42+ got, err := f.repos.Update(ctx, admin, n, ports.UpdateRepoParams{AllowAutoMerge: ptr(true)})
43+ require.NoError(t, err)
44+ assert.True(t, got.EffectiveSettings().AllowAutoMerge)
45+ })
46+
3047 t.Run("description and visibility are stored and RepoUpdated is published", func(t *testing.T) {
3148 t.Parallel()
3249 f := newFixture(t)
M proto/ermite/storage/merge_request.proto +15 -0
@@ -30,6 +30,7 @@ message RepoSettings {
3030 // {fast-forward, squash} was the old default and gains MERGE_MODE_MERGE on
3131 // read.
3232 bool merge_modes_v2 = 4;
33+ bool allow_auto_merge = 5;
3334 }
3435
3536 // IssuesMode says who may write issues in a repository.
@@ -78,6 +79,20 @@ message MergeRequest {
7879 MergeMode merge_mode = 13;
7980 // Last comment id handed out for this request.
8081 int64 comment_seq = 14;
82+ // Pending auto-merge; unset when off.
83+ AutoMerge auto_merge = 15;
84+ bool auto_merged = 16;
85+}
86+
87+// AutoMerge is the pending auto-merge of an open merge request.
88+message AutoMerge {
89+ MergeMode mode = 1;
90+ string head_id = 2;
91+ // Kind and token id of the CIPusher that enabled it.
92+ string enabler_kind = 3;
93+ string enabler_token_id = 4;
94+ string enabler_label = 5;
95+ google.protobuf.Timestamp enabled_at = 6;
8196 }
8297
8398 // Verdict is the immutable review decision of a note.
M skills/ermite/CLI.md +4 -2
@@ -11,10 +11,10 @@ Errors are one line on standard error (`ermite: ...`) with exit status 1. `--jso
1111 | `repo list [--json]` | none | repositories you can see |
1212 | `repo show <name> [--json]` | none | |
1313 | `repo create <name> [--description TEXT] [--visibility public]` | `repo:write` | private by default |
14-| `repo edit <name> [--description TEXT] [--visibility V] [--default-branch B] [--merge-modes fast-forward,squash,merge] [--failing-checks-block=false]` | `repo:admin` | only the given flags change; every merge mode is allowed by default, list the ones to keep; `--description ""` clears it; the branch must exist |
14+| `repo edit <name> [--description TEXT] [--visibility V] [--default-branch B] [--merge-modes fast-forward,squash,merge] [--failing-checks-block=false] [--allow-auto-merge]` | `repo:admin` | only the given flags change; every merge mode is allowed by default, list the ones to keep; `--description ""` clears it; the branch must exist |
1515 | `repo delete <name> [--yes]` | `repo:admin` | |
1616
17-Repository JSON: `name`, `description`, `visibility`, `default_branch`, `created_at`, `pushed_at` (null before the first push), `merge_modes`, `failing_checks_block`.
17+Repository JSON: `name`, `description`, `visibility`, `default_branch`, `created_at`, `pushed_at` (null before the first push), `merge_modes`, `failing_checks_block`, `allow_auto_merge`.
1818
1919 ## Tokens
2020
@@ -62,6 +62,7 @@ A token can never grant more than the token that creates it. `--repo` (repeatabl
6262 | `mr delete-comment <repo> <n> <comment-id>` | `mr:write` | author only; the text is lost, the verdict is kept |
6363 | `mr resolve <repo> <n> <comment-id> [--undo]` | `mr:write` | on the thread root, covers its replies; doing it twice is not an error |
6464 | `mr merge <repo> <n> --head <full-id> [--mode fast-forward\|squash\|merge] [--delete-branch]` | `mr:merge` | refused if the branch moved, if `fast-forward` and the target is not an ancestor of the head (behind: use `squash` or `merge`), or if `squash`/`merge` conflict; squash and merge commits are signed by the server key (`signing-key show`); answers once the target moved; `--delete-branch` deletes the source after the merge (refused for the default branch or a branch another open request uses; JSON adds `source_branch_deleted`) |
65+| `mr merge <repo> <n> --head <full-id> --auto [--mode M]` / `mr merge <repo> <n> --cancel-auto` | `mr:merge` | the server merges as you once CI settled on the head with a passed check and no failing one; needs `repo edit --allow-auto-merge`; failing checks or conflicts wait with auto-merge on; a new head cancels it, as do a deleted source, a disallowed mode, the setting turned off or your token losing merge rights; a manual merge supersedes it (`merge_request_merged`, `auto=false`, no cancelled event); a manual close clears it (`merge_request_closed` then cancelled with reason `closed`); JSON `auto_merge` (null unless pending), `auto_merged` |
6566 | `mr close <repo> <n>` | `mr:write` | a closed merge request cannot be reopened |
6667
6768 `ermite mr create` and `ermite mr close` print a confirmation followed by the full
@@ -106,6 +107,7 @@ Workflow JSON: `repo`, `file` (always `.github/workflows/<file>`), `name` (the `
106107 | `repo_created`, `repo_updated`, `repo_deleted` | `repo`, `repository` |
107108 | `pushed` | `repo`, `ref_updates` (`ref`, `old_id`, `new_id`; an empty id means created or deleted) |
108109 | `merge_request_opened`, `merge_request_merged`, `merge_request_closed` | `repo`, `merge_request` |
110+| `merge_request_auto_merge_enabled`, `merge_request_auto_merge_cancelled` | `repo`, `merge_request`; cancelled also has `reason`. `merge_request_merged` has `auto` true after an auto-merge |
109111 | `merge_request_mergeability` | `repo`, `number`, `mergeability` (`state`: `checking`, `up_to_date`, `behind`, `conflict`; `behind`, `behind_capped`, `conflict_paths`, `conflict_paths_truncated`); sent when the state changes |
110112 | `comment_added`, `comment_edited`, `comment_deleted`, `comment_resolved` | `repo`, `number`, `comment` (a verdict note has `verdict` set) |
111113 | `gap` | none: events were lost, reload the state |
M skills/ermite/SKILL.md +2 -0
@@ -39,6 +39,8 @@ git push https://git.example.com/<repo>.git main # password = token,
3939 4. End a review with a verdict (needs `mr:review`): `--approve` or `--request-changes`, alone or with a message. Verdicts are informative and cannot be edited; a new verdict replaces the previous one.
4040 5. Merge only when asked: `ermite mr merge <repo> <n> --head <head_id>`, with `--mode squash` for one commit or `--mode merge` for a merge commit (both signed by the server's key, see `ermite signing-key show`) and `--delete-branch` to delete the source branch afterwards.
4141
42+To merge once CI passed instead of now, add `--auto` (`ermite mr merge <repo> <n> --head <head_id> --auto --mode squash`; needs `repo edit --allow-auto-merge`); `--cancel-auto` cancels it.
43+
4244 The merge is refused when the branch moved since `head_id` (review again) or, for `fast-forward`, when the target is not an ancestor of the head (rebase and push again, or use `squash`/`merge`), or when `squash`/`merge` conflict. `ermite mr show` prints the `mergeability` line (up to date, behind by N, conflict with paths). The repository may allow only some merge modes.
4345
4446 ### Act on a review

Keyboard shortcuts

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

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

Branches 2

Tags 45

↑↓ moveenter openesc close