Merge 6135dd51e565e014733437cd74457f6dfef45a53 into 4bec9a08f1d16de05320cc93a6cc759e32faa7a4
70 files · +3994 -492
M docs/cli.md +3 -2
| @@ -48,7 +48,7 @@ It does not run: | |||
| 48 | 48 | ||
| 49 | 49 | ```sh | |
| 50 | 50 | ermite repo list | show <name> | create <name> [--description …] [--visibility public] | |
| 51 | - | ermite repo edit <name> [--description …] [--visibility …] [--default-branch …] [--merge-modes …] [--failing-checks-block=false] [--issues off|owner|public] | |
| 51 | + | ermite repo edit <name> [--description …] [--visibility …] [--default-branch …] [--merge-modes …] [--failing-checks-block=false] [--allow-auto-merge] [--issues off|owner|public] | |
| 52 | 52 | ermite repo delete <name> [--yes] | |
| 53 | 53 | ||
| 54 | 54 | ermite token create <label> [--preset cli|agent|ci|runner] [--scope …] [--repo …] [--branch-prefix …] [--expires 30d] | |
| @@ -57,7 +57,7 @@ ermite token list | revoke <id> | |||
| 57 | 57 | ermite mr create <repo> --source <branch> --title … | list <repo> | show <repo> <n> [--diff] # show prints the mergeability: up to date, behind, conflict | |
| 58 | 58 | ermite mr comment <repo> <n> -m … [--path … --line N|A-B --side old|new] [--reply <id>] [--approve|--request-changes] | |
| 59 | 59 | ermite mr resolve <repo> <n> <comment> | edit-comment <repo> <n> <comment> -m … | delete-comment <repo> <n> <comment> | |
| 60 | - | ermite mr merge <repo> <n> --head <id> [--mode fast-forward|squash|merge] [--delete-branch] | close <repo> <n> | |
| 60 | + | ermite mr merge <repo> <n> --head <id> [--mode fast-forward|squash|merge] [--delete-branch] | merge <repo> <n> --head <id> --auto [--mode …] | merge <repo> <n> --cancel-auto | close <repo> <n> | |
| 61 | 61 | ||
| 62 | 62 | ermite issue create <repo> --title … [--body … | --body-file …] | list <repo> [--state open|closed|all] | |
| 63 | 63 | ermite issue show <repo> <n> | comment <repo> <n> -m … | close <repo> <n> | reopen <repo> <n> | |
| @@ -100,6 +100,7 @@ Mirrors push repositories to a remote over SSH, never forcing. See [Push mirror] | |||
| 100 | 100 | - `--json` prints stable field names (listed in each `--help`) on `repo list|show|create|edit`, `issue create|list|show|comment|close|reopen`, `token create|list`, `run list|view|cancel|rerun`, `runner list`, `mirror key|config|status` and `version`. | |
| 101 | 101 | - `watch --json` prints one object per line. `merge_request_mergeability` events carry `mergeability` (`state`, `behind`, `conflict_paths`…) and print as `repo!n mergeability: behind by 2`. | |
| 102 | 102 | - `mr merge --mode squash|merge` creates a commit signed by the server's own key (generated on first start); the output names its fingerprint, `ermite signing-key show` prints the key. Every mode is allowed by default; `repo edit --merge-modes fast-forward,squash` disables `merge`. | |
| 103 | + | - `mr merge --auto` makes the server merge once CI passed on the head (`repo edit --allow-auto-merge` first); `--cancel-auto` cancels it; both are exclusive with each other and `--auto` with `--delete-branch`. `watch --json` adds `merge_request_auto_merge_enabled`, `merge_request_auto_merge_cancelled` (with `reason`), and `auto` (true) on `merge_request_merged` after an auto-merge. See [Merge requests](merge-requests.md#auto-merge). | |
| 103 | 104 | - Errors are one line on stderr (`ermite: …`) with exit status 1. | |
| 104 | 105 | - Shell completion completes repository names from the server. | |
| 105 | 106 | ||
M docs/merge-requests.md +14 -0
| @@ -41,6 +41,20 @@ git -c gpg.format=ssh -c gpg.ssh.allowedSignersFile=allowed_signers verify-commi | |||
| 41 | 41 | ||
| 42 | 42 | The merge box of the web page and the output of `mr merge --mode squash|merge` repeat this with the fingerprint. | |
| 43 | 43 | ||
| 44 | + | ## Auto-merge | |
| 45 | + | ||
| 46 | + | A merge request can merge by itself once its checks passed. | |
| 47 | + | ||
| 48 | + | - The repository setting `allow_auto_merge` is off by default: `ermite repo edit <repo> --allow-auto-merge` (`=false` to turn it off). | |
| 49 | + | - Enable it with `ermite mr merge <repo> <n> --head <commit> --auto --mode squash` (`--head` is required, as for a plain merge; a head that moved is refused with "branch moved"), or with **Merge when ready** in the web merge box. Its separate mode choice offers every repository-allowed mode, even while the head conflicts or is behind. Auto-merge keeps the source branch; the delete-source checkbox applies only to an immediate merge. It needs `mr:merge` and a server with CI. Re-enabling replaces the previous mode and enabler. | |
| 50 | + | - The server merges when CI has settled on the head with every run complete and at least one check passed. A failing or unfinished check always blocks, whatever `failing_checks_block` says. The mode must still be allowed. | |
| 51 | + | - The merge is made as the person who enabled it. Their token is checked at every evaluation, even while checks are pending, and again at merge time under the merge lock: it must still be valid and allow `mr:merge` on the repository and the target branch. A token that no longer qualifies cancels the auto-merge. | |
| 52 | + | - A pending auto-merge shows in `mr show` (`auto_merge`), in the web merge box and as `merge_request_auto_merge_enabled` in `ermite watch`. A merge request merged this way has `auto_merged` true. | |
| 53 | + | - A failing, unfinished or conflicting head waits with auto-merge still on. A re-run or a target fix can make it pass; pushing a new source head cancels the recorded intent (the reason names the new head). | |
| 54 | + | - A manual merge supersedes auto-merge and publishes `merge_request_merged` with `auto=false`, without a cancelled event. A manual close clears it and publishes `merge_request_closed` followed by `merge_request_auto_merge_cancelled` with reason `closed`. | |
| 55 | + | - Cancellation reasons are `cancelled by <author>`, `closed`, `auto-merge was turned off in the repository settings`, `the <mode> merge mode is no longer allowed`, `the source branch was deleted`, `the branch moved to <short-head>`, and `the principal that enabled auto-merge can no longer merge <target>`. If a change wins the merge lock after the evaluator's first read, the merge-time reason is instead `auto-merge is off in <repo>`, `the <mode> merge mode is not allowed in <repo>`, `the source branch "<source>" no longer exists`, or `branch moved, read again: "<source>" is at <head>, not <recorded-head>`. Other permanent merge-time preconditions report `!<n> is <state>`, `!<n> is a draft: mark it ready for review first`, `the target branch "<target>" no longer exists`, `nothing to merge, "<target>" already is at <head>`, or `nothing to merge, "<target>" already contains <head>`. | |
| 56 | + | - Cancel it with `ermite mr merge <repo> <n> --cancel-auto` or **Cancel auto-merge** in the web merge box. | |
| 57 | + | ||
| 44 | 58 | ## Mergeability | |
| 45 | 59 | ||
| 46 | 60 | The server checks in the background whether each open request merges with its target, and again whenever the source or the target moves. `mr show` prints it; the web merge box shows it as a live badge; `ermite watch` reports each change. | |
M gen/ermite/storage/merge_request.pb.go +163 -35 generated file: collapsed
M go.mod +1 -1
| @@ -13,7 +13,7 @@ tool ( | |||
| 13 | 13 | ||
| 14 | 14 | require ( | |
| 15 | 15 | bnema.dev/ermite-actions v0.2.0 | |
| 16 | - | bnema.dev/ermite-api v0.13.0 | |
| 16 | + | bnema.dev/ermite-api v0.14.0 | |
| 17 | 17 | bnema.dev/ermite-git v0.1.0 | |
| 18 | 18 | bnema.dev/ermite-kv v0.1.0 | |
| 19 | 19 | 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) { | |||
| 139 | 139 | func TestMissingFeatures(t *testing.T) { | |
| 140 | 140 | assert.Empty(t, client.MissingFeatures(client.UsedFeatures())) | |
| 141 | 141 | assert.Equal(t, | |
| 142 | - | []string{client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureIssues, client.FeatureMergeability, client.FeatureSigningKey, client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, client.FeatureCIWorkflows, client.FeatureCIArtifacts, client.FeatureCommitSignatures}, | |
| 142 | + | []string{client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureIssues, client.FeatureMergeability, client.FeatureSigningKey, client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, client.FeatureCIWorkflows, client.FeatureCIArtifacts, client.FeatureCommitSignatures, client.FeatureAutoMerge}, | |
| 143 | 143 | client.MissingFeatures([]string{client.FeatureRepoUpdate, client.FeatureSession, client.FeaturePasskeys, "other"})) | |
| 144 | 144 | assert.Equal(t, []string{client.FeatureSigningKey}, client.MissingFeatures([]string{ | |
| 145 | 145 | client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureIssues, client.FeatureMergeability, client.FeatureSession, | |
| 146 | 146 | client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, client.FeatureCIWorkflows, | |
| 147 | - | client.FeatureCIArtifacts, client.FeaturePasskeys, client.FeatureCommitSignatures, | |
| 147 | + | client.FeatureCIArtifacts, client.FeaturePasskeys, client.FeatureCommitSignatures, client.FeatureAutoMerge, | |
| 148 | 148 | })) | |
| 149 | 149 | assert.Equal(t, []string{client.FeaturePasskeys}, client.MissingFeatures([]string{ | |
| 150 | 150 | client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureIssues, client.FeatureMergeability, client.FeatureSigningKey, client.FeatureSession, | |
| 151 | - | client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, client.FeatureCIWorkflows, client.FeatureCIArtifacts, client.FeatureCommitSignatures, | |
| 151 | + | client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, client.FeatureCIWorkflows, client.FeatureCIArtifacts, client.FeatureCommitSignatures, client.FeatureAutoMerge, | |
| 152 | 152 | })) | |
| 153 | 153 | assert.Equal(t, client.UsedFeatures(), client.MissingFeatures(nil)) | |
| 154 | 154 | } | |
M internal/adapters/client/features.go +5 -1
| @@ -57,11 +57,15 @@ const ( | |||
| 57 | 57 | // FeatureMergeability is MergeRequest.mergeability and the | |
| 58 | 58 | // merge_request_mergeability event. | |
| 59 | 59 | FeatureMergeability = "merge_requests.mergeability" | |
| 60 | + | // FeatureAutoMerge is MergeRequestService.EnableAutoMerge and | |
| 61 | + | // CancelAutoMerge, RepoSettings.allow_auto_merge and the auto-merge events | |
| 62 | + | // (`ermite mr merge --auto`). | |
| 63 | + | FeatureAutoMerge = "merge_requests.auto_merge" | |
| 60 | 64 | ) | |
| 61 | 65 | ||
| 62 | 66 | // UsedFeatures lists the features the CLI uses, in the order they are reported. | |
| 63 | 67 | func UsedFeatures() []string { | |
| 64 | - | return []string{FeatureRepoUpdate, FeatureEventsResume, FeatureMergeRequests, FeatureMergeDeleteBranch, FeatureIssues, FeatureMergeability, FeatureSigningKey, FeatureSession, FeatureTokenBranchPrefixes, FeatureCIRuns, FeatureCIRunners, FeatureCISecrets, FeatureCIDispatch, FeatureCIWorkflows, FeatureCIArtifacts, FeaturePasskeys, FeatureCommitSignatures} | |
| 68 | + | return []string{FeatureRepoUpdate, FeatureEventsResume, FeatureMergeRequests, FeatureMergeDeleteBranch, FeatureIssues, FeatureMergeability, FeatureSigningKey, FeatureSession, FeatureTokenBranchPrefixes, FeatureCIRuns, FeatureCIRunners, FeatureCISecrets, FeatureCIDispatch, FeatureCIWorkflows, FeatureCIArtifacts, FeaturePasskeys, FeatureCommitSignatures, FeatureAutoMerge} | |
| 65 | 69 | } | |
| 66 | 70 | ||
| 67 | 71 | // MissingFeatures returns the features the CLI uses that have is lacking. | |
M internal/adapters/kvstore/mergerequests.go +32 -2
| @@ -728,6 +728,18 @@ func encodeMergeRequest(mr domain.MergeRequest) ([]byte, error) { | |||
| 728 | 728 | if err != nil { | |
| 729 | 729 | return nil, err | |
| 730 | 730 | } | |
| 731 | + | var autoMerge *storagepb.AutoMerge | |
| 732 | + | if am := mr.AutoMerge; am != nil { | |
| 733 | + | amMode, err := encodeMode(am.Mode) | |
| 734 | + | if err != nil || amMode == storagepb.MergeMode_MERGE_MODE_UNSPECIFIED { | |
| 735 | + | return nil, fmt.Errorf("%w: merge mode %q in the auto-merge", domain.ErrInvalid, am.Mode) | |
| 736 | + | } | |
| 737 | + | autoMerge = &storagepb.AutoMerge{ | |
| 738 | + | Mode: amMode, HeadId: am.HeadID.String(), | |
| 739 | + | EnablerKind: string(am.EnabledBy.Kind), EnablerTokenId: am.EnabledBy.TokenID, | |
| 740 | + | EnablerLabel: am.EnablerLabel, EnabledAt: encodeTime(am.EnabledAt), | |
| 741 | + | } | |
| 742 | + | } | |
| 731 | 743 | return mustMarshal(&storagepb.MergeRequest{ | |
| 732 | 744 | Number: mr.Number, | |
| 733 | 745 | Title: mr.Title.String(), | |
| @@ -743,6 +755,8 @@ func encodeMergeRequest(mr domain.MergeRequest) ([]byte, error) { | |||
| 743 | 755 | MergedCommitId: mr.MergedCommitID.String(), | |
| 744 | 756 | MergeMode: mode, | |
| 745 | 757 | CommentSeq: mr.CommentSeq, | |
| 758 | + | AutoMerge: autoMerge, | |
| 759 | + | AutoMerged: mr.AutoMerged, | |
| 746 | 760 | }), nil | |
| 747 | 761 | } | |
| 748 | 762 | ||
| @@ -773,8 +787,24 @@ func decodeMergeRequest(repo domain.RepoName, item kv.Item) (domain.MergeRequest | |||
| 773 | 787 | CreatedAt: decodeTime(pb.GetCreatedAt()), UpdatedAt: decodeTime(pb.GetUpdatedAt()), | |
| 774 | 788 | MergeMode: decodeMode(pb.GetMergeMode()), | |
| 775 | 789 | CommentSeq: pb.GetCommentSeq(), | |
| 790 | + | AutoMerged: pb.GetAutoMerged(), | |
| 776 | 791 | Version: item.Version, | |
| 777 | 792 | } | |
| 793 | + | if am := pb.GetAutoMerge(); am != nil { | |
| 794 | + | head, err := domain.ParseObjectID(am.GetHeadId()) | |
| 795 | + | if err != nil { | |
| 796 | + | return fail(err) | |
| 797 | + | } | |
| 798 | + | mode := decodeMode(am.GetMode()) | |
| 799 | + | if mode == "" { | |
| 800 | + | return fail(fmt.Errorf("unknown auto-merge mode %d", am.GetMode())) | |
| 801 | + | } | |
| 802 | + | mr.AutoMerge = &domain.AutoMerge{ | |
| 803 | + | Mode: mode, HeadID: head, | |
| 804 | + | EnabledBy: domain.CIPusher{Kind: domain.CIPusherKind(am.GetEnablerKind()), TokenID: am.GetEnablerTokenId()}, | |
| 805 | + | EnablerLabel: am.GetEnablerLabel(), EnabledAt: decodeTime(am.GetEnabledAt()), | |
| 806 | + | } | |
| 807 | + | } | |
| 778 | 808 | for _, f := range []struct { | |
| 779 | 809 | dst *domain.ObjectID | |
| 780 | 810 | src string | |
| @@ -909,7 +939,7 @@ func decodeSide(s storagepb.DiffSide) domain.DiffSide { | |||
| 909 | 939 | } | |
| 910 | 940 | ||
| 911 | 941 | func encodeSettings(s domain.RepoSettings) (*storagepb.RepoSettings, error) { | |
| 912 | - | pb := &storagepb.RepoSettings{FailingChecksBlock: s.FailingChecksBlock, MergeModesV2: true} | |
| 942 | + | pb := &storagepb.RepoSettings{FailingChecksBlock: s.FailingChecksBlock, MergeModesV2: true, AllowAutoMerge: s.AllowAutoMerge} | |
| 913 | 943 | for _, m := range s.AllowedMergeModes { | |
| 914 | 944 | mode, err := encodeMode(m) | |
| 915 | 945 | if err != nil || mode == storagepb.MergeMode_MERGE_MODE_UNSPECIFIED { | |
| @@ -949,5 +979,5 @@ func decodeSettings(pb *storagepb.RepoSettings) domain.RepoSettings { | |||
| 949 | 979 | if !pb.GetMergeModesV2() && slices.Equal(normalized, []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash}) { | |
| 950 | 980 | normalized = domain.MergeModes() | |
| 951 | 981 | } | |
| 952 | - | return domain.RepoSettings{AllowedMergeModes: normalized, FailingChecksBlock: pb.GetFailingChecksBlock(), Issues: decodeIssuesMode(pb.GetIssues())} | |
| 982 | + | return domain.RepoSettings{AllowedMergeModes: normalized, FailingChecksBlock: pb.GetFailingChecksBlock(), Issues: decodeIssuesMode(pb.GetIssues()), AllowAutoMerge: pb.GetAllowAutoMerge()} | |
| 953 | 983 | } | |
M internal/adapters/kvstore/mergerequests_test.go +47 -0
| @@ -94,6 +94,35 @@ func TestMergeRequestRoundTrip(t *testing.T) { | |||
| 94 | 94 | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| 95 | 95 | } | |
| 96 | 96 | ||
| 97 | + | func TestMergeRequestAutoMergeRoundTrip(t *testing.T) { | |
| 98 | + | ctx := context.Background() | |
| 99 | + | _, mrs, n := repoWithMRs(t) | |
| 100 | + | ||
| 101 | + | set := newMR(t, n, "Auto") | |
| 102 | + | set.AutoMerge = &domain.AutoMerge{ | |
| 103 | + | Mode: domain.MergeModeSquash, HeadID: oid('d'), | |
| 104 | + | EnabledBy: domain.CIPusher{Kind: domain.CIPusherToken, TokenID: "tok1"}, | |
| 105 | + | EnablerLabel: "ci-bot", EnabledAt: created, | |
| 106 | + | } | |
| 107 | + | created1, err := mrs.Create(ctx, set) | |
| 108 | + | require.NoError(t, err) | |
| 109 | + | got, err := mrs.Get(ctx, n, created1.Number) | |
| 110 | + | require.NoError(t, err) | |
| 111 | + | require.NotNil(t, got.AutoMerge) | |
| 112 | + | assert.Equal(t, *set.AutoMerge, *got.AutoMerge) | |
| 113 | + | assert.False(t, got.AutoMerged) | |
| 114 | + | ||
| 115 | + | unset := newMR(t, n, "Merged by auto") | |
| 116 | + | unset.State = domain.MRStateMerged | |
| 117 | + | unset.AutoMerged = true | |
| 118 | + | created2, err := mrs.Create(ctx, unset) | |
| 119 | + | require.NoError(t, err) | |
| 120 | + | got, err = mrs.Get(ctx, n, created2.Number) | |
| 121 | + | require.NoError(t, err) | |
| 122 | + | assert.Nil(t, got.AutoMerge) | |
| 123 | + | assert.True(t, got.AutoMerged) | |
| 124 | + | } | |
| 125 | + | ||
| 97 | 126 | func TestMergeRequestListIsNewestFirstAndPerRepository(t *testing.T) { | |
| 98 | 127 | ctx := context.Background() | |
| 99 | 128 | s, mrs, n := repoWithMRs(t) | |
| @@ -453,6 +482,24 @@ func TestRepoSettings(t *testing.T) { | |||
| 453 | 482 | assert.ErrorIs(t, err, domain.ErrInvalid) | |
| 454 | 483 | } | |
| 455 | 484 | ||
| 485 | + | func TestRepoSettingsKeepAllowAutoMerge(t *testing.T) { | |
| 486 | + | ctx := context.Background() | |
| 487 | + | s := openStore(t, t.TempDir()) | |
| 488 | + | n := name(t, "proj") | |
| 489 | + | require.NoError(t, s.Create(ctx, domain.Repo{Name: n, CreatedAt: created, Visibility: domain.VisibilityPrivate})) | |
| 490 | + | got, err := s.Get(ctx, n) | |
| 491 | + | require.NoError(t, err) | |
| 492 | + | assert.False(t, got.Settings.AllowAutoMerge) | |
| 493 | + | ||
| 494 | + | settings := domain.DefaultRepoSettings() | |
| 495 | + | settings.AllowAutoMerge = true | |
| 496 | + | _, err = s.Mutate(ctx, n, func(r *domain.Repo) (bool, error) { r.Settings = settings; return true, nil }) | |
| 497 | + | require.NoError(t, err) | |
| 498 | + | got, err = s.Get(ctx, n) | |
| 499 | + | require.NoError(t, err) | |
| 500 | + | assert.Equal(t, settings, got.Settings) | |
| 501 | + | } | |
| 502 | + | ||
| 456 | 503 | // TestRepoWithoutStoredSettingsReadsTheDefaults: a repository stored before | |
| 457 | 504 | // merge requests existed has no settings field. | |
| 458 | 505 | func TestRepoWithoutStoredSettingsReadsTheDefaults(t *testing.T) { | |
M internal/adapters/rpc/api.go +1 -1
| @@ -35,7 +35,7 @@ const RunnerMaxRequestBytes = MaxRequestBytes + 64<<10 | |||
| 35 | 35 | // always implements, as GetVersion lists them. FeaturePasskeys comes on top | |
| 36 | 36 | // when Config.Passkeys is set, and FeatureCISecrets when the settings services | |
| 37 | 37 | // are, and FeatureCommitSignatures when Config.TrustedKeys is set. | |
| 38 | - | var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "issues.links", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.cache", "ci.workflows"} | |
| 38 | + | var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "issues.links", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.cache", "ci.workflows", "merge_requests.auto_merge"} | |
| 39 | 39 | ||
| 40 | 40 | // FeaturePasskeys is listed by GetVersion when passkeys are available. | |
| 41 | 41 | // FeatureCIArtifacts (artifact.go) when the artifact services are served. | |
M internal/adapters/rpc/convert.go +8 -2
| @@ -72,7 +72,7 @@ func repoToPB(v ports.RepoView) *pb.Repo { | |||
| 72 | 72 | } | |
| 73 | 73 | ||
| 74 | 74 | func settingsToPB(s domain.RepoSettings) *pb.RepoSettings { | |
| 75 | - | out := &pb.RepoSettings{FailingChecksBlock: s.FailingChecksBlock, Issues: issuesModeToPB(s.Issues)} | |
| 75 | + | out := &pb.RepoSettings{FailingChecksBlock: s.FailingChecksBlock, AllowAutoMerge: s.AllowAutoMerge, Issues: issuesModeToPB(s.Issues)} | |
| 76 | 76 | for _, m := range s.AllowedMergeModes { | |
| 77 | 77 | out.AllowedMergeModes = append(out.AllowedMergeModes, mergeModeToPB(m)) | |
| 78 | 78 | } | |
| @@ -483,7 +483,13 @@ func watchEventToPB(we ports.WatchEvent) *pb.Event { | |||
| 483 | 483 | out.Kind = &pb.Event_MergeRequestOpened{MergeRequestOpened: &pb.MergeRequestOpened{MergeRequest: mergeRequestToPB(ev.MergeRequest)}} | |
| 484 | 484 | case domain.MergeRequestMerged: | |
| 485 | 485 | out.Time = timestamp(ev.At) | |
| 486 | - | out.Kind = &pb.Event_MergeRequestMerged{MergeRequestMerged: &pb.MergeRequestMerged{MergeRequest: mergeRequestToPB(ev.MergeRequest)}} | |
| 486 | + | out.Kind = &pb.Event_MergeRequestMerged{MergeRequestMerged: &pb.MergeRequestMerged{MergeRequest: mergeRequestToPB(ev.MergeRequest), Auto: ev.Auto}} | |
| 487 | + | case domain.MergeRequestAutoMergeEnabled: | |
| 488 | + | out.Time = timestamp(ev.At) | |
| 489 | + | out.Kind = &pb.Event_MergeRequestAutoMergeEnabled{MergeRequestAutoMergeEnabled: &pb.MergeRequestAutoMergeEnabled{MergeRequest: mergeRequestToPB(ev.MergeRequest)}} | |
| 490 | + | case domain.MergeRequestAutoMergeCancelled: | |
| 491 | + | out.Time = timestamp(ev.At) | |
| 492 | + | out.Kind = &pb.Event_MergeRequestAutoMergeCancelled{MergeRequestAutoMergeCancelled: &pb.MergeRequestAutoMergeCancelled{MergeRequest: mergeRequestToPB(ev.MergeRequest), Reason: ev.Reason}} | |
| 487 | 493 | case domain.MergeRequestClosed: | |
| 488 | 494 | out.Time = timestamp(ev.At) | |
| 489 | 495 | out.Kind = &pb.Event_MergeRequestClosed{MergeRequestClosed: &pb.MergeRequestClosed{MergeRequest: mergeRequestToPB(ev.MergeRequest)}} | |
M internal/adapters/rpc/mergerequest.go +39 -0
| @@ -19,6 +19,38 @@ type mergeRequestService struct { | |||
| 19 | 19 | mrs ports.MergeRequestService | |
| 20 | 20 | } | |
| 21 | 21 | ||
| 22 | + | func (s *mergeRequestService) EnableAutoMerge(ctx context.Context, req *connect.Request[pb.EnableAutoMergeRequest]) (*connect.Response[pb.EnableAutoMergeResponse], error) { | |
| 23 | + | name, err := domain.ParseRepoName(req.Msg.GetRepo()) | |
| 24 | + | if err != nil { | |
| 25 | + | return nil, connectError(ctx, err) | |
| 26 | + | } | |
| 27 | + | mode, err := mergeModeFromPB(req.Msg.GetMode()) | |
| 28 | + | if err != nil { | |
| 29 | + | return nil, connectError(ctx, err) | |
| 30 | + | } | |
| 31 | + | head, err := headID(req.Msg.GetHeadId()) | |
| 32 | + | if err != nil { | |
| 33 | + | return nil, connectError(ctx, err) | |
| 34 | + | } | |
| 35 | + | mr, err := s.mrs.EnableAutoMerge(ctx, caller(ctx), name, req.Msg.GetNumber(), ports.EnableAutoMergeParams{Mode: mode, HeadID: head}) | |
| 36 | + | if err != nil { | |
| 37 | + | return nil, connectError(ctx, err) | |
| 38 | + | } | |
| 39 | + | return connect.NewResponse(&pb.EnableAutoMergeResponse{MergeRequest: mergeRequestToPB(mr)}), nil | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | func (s *mergeRequestService) CancelAutoMerge(ctx context.Context, req *connect.Request[pb.CancelAutoMergeRequest]) (*connect.Response[pb.CancelAutoMergeResponse], error) { | |
| 43 | + | name, err := domain.ParseRepoName(req.Msg.GetRepo()) | |
| 44 | + | if err != nil { | |
| 45 | + | return nil, connectError(ctx, err) | |
| 46 | + | } | |
| 47 | + | mr, err := s.mrs.CancelAutoMerge(ctx, caller(ctx), name, req.Msg.GetNumber()) | |
| 48 | + | if err != nil { | |
| 49 | + | return nil, connectError(ctx, err) | |
| 50 | + | } | |
| 51 | + | return connect.NewResponse(&pb.CancelAutoMergeResponse{MergeRequest: mergeRequestToPB(mr)}), nil | |
| 52 | + | } | |
| 53 | + | ||
| 22 | 54 | func (s *mergeRequestService) EditComment(ctx context.Context, req *connect.Request[pb.EditCommentRequest]) (*connect.Response[pb.EditCommentResponse], error) { | |
| 23 | 55 | name, err := domain.ParseRepoName(req.Msg.GetRepo()) | |
| 24 | 56 | if err != nil { | |
| @@ -133,6 +165,13 @@ func mergeRequestToPB(mr domain.MergeRequest) *pb.MergeRequest { | |||
| 133 | 165 | HeadId: mr.HeadID.String(), | |
| 134 | 166 | MergedCommitId: mr.MergedCommitID.String(), | |
| 135 | 167 | MergeMode: mergeModeToPB(mr.MergeMode), | |
| 168 | + | AutoMerged: mr.AutoMerged, | |
| 169 | + | } | |
| 170 | + | if am := mr.AutoMerge; am != nil { | |
| 171 | + | out.AutoMerge = &pb.AutoMerge{ | |
| 172 | + | Mode: mergeModeToPB(am.Mode), HeadId: am.HeadID.String(), | |
| 173 | + | EnabledBy: am.EnablerLabel, EnabledAt: timestamp(am.EnabledAt), | |
| 174 | + | } | |
| 136 | 175 | } | |
| 137 | 176 | if mr.IsOpen() { | |
| 138 | 177 | out.Mergeability = &pb.Mergeability{State: pb.MergeabilityState_MERGEABILITY_STATE_CHECKING} | |
M internal/adapters/rpc/mr_api_test.go +80 -0
| @@ -3,6 +3,7 @@ | |||
| 3 | 3 | package rpc | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | + | "strings" | |
| 6 | 7 | "testing" | |
| 7 | 8 | "time" | |
| 8 | 9 | ||
| @@ -122,3 +123,82 @@ func TestNoteVerdictsDetailAndScope(t *testing.T) { | |||
| 122 | 123 | _, err = (&mergeRequestService{m}).AddComment(ownerCtx(), connect.NewRequest(&pb.AddCommentRequest{Repo: "demo", Number: 1, Verdict: pb.Verdict_VERDICT_APPROVE})) | |
| 123 | 124 | requireCode(t, connect.CodePermissionDenied, err) | |
| 124 | 125 | } | |
| 126 | + | ||
| 127 | + | func TestAutoMergeRPCCodesAndFields(t *testing.T) { | |
| 128 | + | now := time.Date(2026, 10, 20, 9, 0, 0, 0, time.UTC) | |
| 129 | + | head, err := domain.ParseObjectID(strings.Repeat("a", 40)) | |
| 130 | + | require.NoError(t, err) | |
| 131 | + | armed := domain.MergeRequest{ | |
| 132 | + | Repo: mustRepoName(t, "demo"), Number: 1, State: domain.MRStateOpen, Source: "feat", Target: "main", HeadID: head, | |
| 133 | + | AutoMerge: &domain.AutoMerge{Mode: domain.MergeModeSquash, HeadID: head, EnablerLabel: "owner", EnabledAt: now}, | |
| 134 | + | } | |
| 135 | + | idle := armed | |
| 136 | + | idle.AutoMerge = nil | |
| 137 | + | ||
| 138 | + | pbArmed := mergeRequestToPB(armed) | |
| 139 | + | require.NotNil(t, pbArmed.GetAutoMerge()) | |
| 140 | + | assert.Equal(t, pb.MergeMode_MERGE_MODE_SQUASH, pbArmed.GetAutoMerge().GetMode()) | |
| 141 | + | assert.Equal(t, head.String(), pbArmed.GetAutoMerge().GetHeadId()) | |
| 142 | + | assert.Equal(t, "owner", pbArmed.GetAutoMerge().GetEnabledBy()) | |
| 143 | + | assert.Equal(t, now, pbArmed.GetAutoMerge().GetEnabledAt().AsTime()) | |
| 144 | + | assert.False(t, pbArmed.GetAutoMerged()) | |
| 145 | + | assert.Nil(t, mergeRequestToPB(idle).GetAutoMerge()) | |
| 146 | + | done := idle | |
| 147 | + | done.State, done.AutoMerged = domain.MRStateMerged, true | |
| 148 | + | assert.True(t, mergeRequestToPB(done).GetAutoMerged()) | |
| 149 | + | ||
| 150 | + | // Success: the mode and the request reach the service, the fields come back. | |
| 151 | + | m := mocks.NewMergeRequestService(t) | |
| 152 | + | m.EXPECT().EnableAutoMerge(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(1), ports.EnableAutoMergeParams{Mode: domain.MergeModeSquash, HeadID: mustObjectID(t, strings.Repeat("a", domain.ObjectIDLen))}).Return(armed, nil).Once() | |
| 153 | + | res, err := (&mergeRequestService{m}).EnableAutoMerge(ownerCtx(), connect.NewRequest(&pb.EnableAutoMergeRequest{Repo: "demo", Number: 1, Mode: pb.MergeMode_MERGE_MODE_SQUASH, HeadId: strings.Repeat("a", domain.ObjectIDLen)})) | |
| 154 | + | require.NoError(t, err) | |
| 155 | + | assert.Equal(t, pbArmed, res.Msg.GetMergeRequest()) | |
| 156 | + | m.EXPECT().CancelAutoMerge(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(1)).Return(idle, nil).Once() | |
| 157 | + | cres, err := (&mergeRequestService{m}).CancelAutoMerge(ownerCtx(), connect.NewRequest(&pb.CancelAutoMergeRequest{Repo: "demo", Number: 1})) | |
| 158 | + | require.NoError(t, err) | |
| 159 | + | assert.Nil(t, cres.Msg.GetMergeRequest().GetAutoMerge()) | |
| 160 | + | ||
| 161 | + | // Bad input never reaches the service. | |
| 162 | + | _, err = (&mergeRequestService{m}).EnableAutoMerge(ownerCtx(), connect.NewRequest(&pb.EnableAutoMergeRequest{Repo: "demo", Number: 1, Mode: pb.MergeMode(99), HeadId: strings.Repeat("a", domain.ObjectIDLen)})) | |
| 163 | + | requireCode(t, connect.CodeInvalidArgument, err) | |
| 164 | + | _, err = (&mergeRequestService{m}).EnableAutoMerge(ownerCtx(), connect.NewRequest(&pb.EnableAutoMergeRequest{Repo: "demo", Number: 1, HeadId: "abc"})) | |
| 165 | + | requireCode(t, connect.CodeInvalidArgument, err) | |
| 166 | + | _, err = (&mergeRequestService{m}).CancelAutoMerge(ownerCtx(), connect.NewRequest(&pb.CancelAutoMergeRequest{Repo: "", Number: 1})) | |
| 167 | + | requireCode(t, connect.CodeInvalidArgument, err) | |
| 168 | + | ||
| 169 | + | // Domain errors map like the other merge request calls. | |
| 170 | + | for _, tc := range []struct { | |
| 171 | + | err error | |
| 172 | + | code connect.Code | |
| 173 | + | }{ | |
| 174 | + | {domain.ErrPreconditionFailed, connect.CodeFailedPrecondition}, | |
| 175 | + | {domain.ErrForbidden, connect.CodePermissionDenied}, | |
| 176 | + | {domain.ErrNotFound, connect.CodeNotFound}, | |
| 177 | + | } { | |
| 178 | + | m := mocks.NewMergeRequestService(t) | |
| 179 | + | m.EXPECT().EnableAutoMerge(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(1), mock.Anything).Return(domain.MergeRequest{}, tc.err).Once() | |
| 180 | + | _, err := (&mergeRequestService{m}).EnableAutoMerge(ownerCtx(), connect.NewRequest(&pb.EnableAutoMergeRequest{Repo: "demo", Number: 1, HeadId: strings.Repeat("a", domain.ObjectIDLen)})) | |
| 181 | + | requireCode(t, tc.code, err) | |
| 182 | + | m.EXPECT().CancelAutoMerge(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(1)).Return(domain.MergeRequest{}, tc.err).Once() | |
| 183 | + | _, err = (&mergeRequestService{m}).CancelAutoMerge(ownerCtx(), connect.NewRequest(&pb.CancelAutoMergeRequest{Repo: "demo", Number: 1})) | |
| 184 | + | requireCode(t, tc.code, err) | |
| 185 | + | } | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | func TestAutoMergeWatchEventsAndSettingsToPB(t *testing.T) { | |
| 189 | + | armed := domain.MergeRequest{Repo: mustRepoName(t, "demo"), Number: 1, State: domain.MRStateOpen, | |
| 190 | + | AutoMerge: &domain.AutoMerge{Mode: domain.MergeModeMerge, EnablerLabel: "owner"}} | |
| 191 | + | got := watchEventToPB(ports.WatchEvent{ID: "1", Event: domain.MergeRequestAutoMergeEnabled{MergeRequest: armed}}) | |
| 192 | + | require.NotNil(t, got.GetMergeRequestAutoMergeEnabled()) | |
| 193 | + | assert.Equal(t, pb.MergeMode_MERGE_MODE_MERGE_COMMIT, got.GetMergeRequestAutoMergeEnabled().GetMergeRequest().GetAutoMerge().GetMode()) | |
| 194 | + | got = watchEventToPB(ports.WatchEvent{ID: "2", Event: domain.MergeRequestAutoMergeCancelled{MergeRequest: armed, Reason: "the branch moved"}}) | |
| 195 | + | require.NotNil(t, got.GetMergeRequestAutoMergeCancelled()) | |
| 196 | + | assert.Equal(t, "the branch moved", got.GetMergeRequestAutoMergeCancelled().GetReason()) | |
| 197 | + | got = watchEventToPB(ports.WatchEvent{ID: "3", Event: domain.MergeRequestMerged{MergeRequest: armed, Auto: true}}) | |
| 198 | + | assert.True(t, got.GetMergeRequestMerged().GetAuto()) | |
| 199 | + | got = watchEventToPB(ports.WatchEvent{ID: "4", Event: domain.MergeRequestMerged{MergeRequest: armed}}) | |
| 200 | + | assert.False(t, got.GetMergeRequestMerged().GetAuto()) | |
| 201 | + | ||
| 202 | + | assert.True(t, settingsToPB(domain.RepoSettings{AllowAutoMerge: true}).GetAllowAutoMerge()) | |
| 203 | + | assert.False(t, settingsToPB(domain.RepoSettings{}).GetAllowAutoMerge()) | |
| 204 | + | } | |
M internal/adapters/rpc/repo.go +9 -5
| @@ -80,8 +80,8 @@ func (s *repoService) UpdateRepo(ctx context.Context, req *connect.Request[pb.Up | |||
| 80 | 80 | ||
| 81 | 81 | // updateParams maps the field mask of an update. The mask must name at least | |
| 82 | 82 | // 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 | |
| 85 | 85 | // it is a valid update. | |
| 86 | 86 | func updateParams(msg *pb.UpdateRepoRequest) (ports.UpdateRepoParams, error) { | |
| 87 | 87 | var params ports.UpdateRepoParams | |
| @@ -113,17 +113,21 @@ func updateParams(msg *pb.UpdateRepoRequest) (ports.UpdateRepoParams, error) { | |||
| 113 | 113 | params.Visibility = &vis | |
| 114 | 114 | case "default_branch": | |
| 115 | 115 | 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" { | |
| 118 | 119 | modes, err := mergeModesFromPB(msg.GetSettings().GetAllowedMergeModes()) | |
| 119 | 120 | if err != nil { | |
| 120 | 121 | return params, err | |
| 121 | 122 | } | |
| 122 | 123 | params.AllowedMergeModes = &modes | |
| 123 | 124 | } | |
| 124 | - | if path != "settings.allowed_merge_modes" { | |
| 125 | + | if all || path == "settings.failing_checks_block" { | |
| 125 | 126 | params.FailingChecksBlock = new(msg.GetSettings().GetFailingChecksBlock()) | |
| 126 | 127 | } | |
| 128 | + | if all || path == "settings.allow_auto_merge" { | |
| 129 | + | params.AllowAutoMerge = new(msg.GetSettings().GetAllowAutoMerge()) | |
| 130 | + | } | |
| 127 | 131 | default: | |
| 128 | 132 | return params, fmt.Errorf("%w: unknown path %q in update_mask", domain.ErrInvalid, path) | |
| 129 | 133 | } | |
M internal/adapters/rpc/repo_test.go +25 -0
| @@ -280,3 +280,28 @@ func TestRepoToPBTopics(t *testing.T) { | |||
| 280 | 280 | assert.Equal(t, []string{"cli", "go"}, got.GetTopics()) | |
| 281 | 281 | assert.Empty(t, repoToPB(ports.RepoView{Repo: domain.Repo{Name: mustRepoName(t, "demo")}}).GetTopics()) | |
| 282 | 282 | } | |
| 283 | + | ||
| 284 | + | func TestUpdateParamsAllowAutoMerge(t *testing.T) { | |
| 285 | + | t.Parallel() | |
| 286 | + | mask := func(paths ...string) *fieldmaskpb.FieldMask { return &fieldmaskpb.FieldMask{Paths: paths} } | |
| 287 | + | settings := &pb.RepoSettings{AllowAutoMerge: true, FailingChecksBlock: true, AllowedMergeModes: []pb.MergeMode{pb.MergeMode_MERGE_MODE_SQUASH}} | |
| 288 | + | ||
| 289 | + | params, err := updateParams(&pb.UpdateRepoRequest{UpdateMask: mask("settings.allow_auto_merge"), Settings: settings}) | |
| 290 | + | require.NoError(t, err) | |
| 291 | + | require.NotNil(t, params.AllowAutoMerge) | |
| 292 | + | assert.True(t, *params.AllowAutoMerge) | |
| 293 | + | assert.Nil(t, params.FailingChecksBlock, "only the masked setting is set") | |
| 294 | + | assert.Nil(t, params.AllowedMergeModes) | |
| 295 | + | ||
| 296 | + | params, err = updateParams(&pb.UpdateRepoRequest{UpdateMask: mask("settings"), Settings: settings}) | |
| 297 | + | require.NoError(t, err) | |
| 298 | + | require.NotNil(t, params.AllowAutoMerge, "settings covers allow_auto_merge") | |
| 299 | + | assert.True(t, *params.AllowAutoMerge) | |
| 300 | + | require.NotNil(t, params.FailingChecksBlock) | |
| 301 | + | require.NotNil(t, params.AllowedMergeModes) | |
| 302 | + | ||
| 303 | + | params, err = updateParams(&pb.UpdateRepoRequest{UpdateMask: mask("settings.failing_checks_block"), Settings: settings}) | |
| 304 | + | require.NoError(t, err) | |
| 305 | + | assert.Nil(t, params.AllowAutoMerge) | |
| 306 | + | require.NotNil(t, params.FailingChecksBlock) | |
| 307 | + | } | |
M internal/adapters/web/fragments.go +5 -1
| @@ -61,7 +61,7 @@ func liveEvent(we ports.WatchEvent) bool { | |||
| 61 | 61 | } | |
| 62 | 62 | switch we.Event.(type) { | |
| 63 | 63 | case domain.RepoCreated, domain.RepoUpdated, domain.RepoDeleted, domain.PushEvent, | |
| 64 | - | domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.MergeRequestReady, domain.MergeRequestMergeability, domain.CommentAdded, domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved, | |
| 64 | + | domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.MergeRequestReady, domain.MergeRequestAutoMergeEnabled, domain.MergeRequestAutoMergeCancelled, domain.MergeRequestMergeability, domain.CommentAdded, domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved, | |
| 65 | 65 | domain.RunUpdated, domain.JobUpdated, | |
| 66 | 66 | domain.IssueOpened, domain.IssueClosed, domain.IssueReopened, domain.IssueCommented, domain.IssueSubmitted, domain.IssuePendingRemoved, domain.IssueReferenced: | |
| 67 | 67 | return true | |
| @@ -189,6 +189,10 @@ func (l *liveStream) repoPage(ctx context.Context, we ports.WatchEvent) ([]fragm | |||
| 189 | 189 | return l.mrNotice(ctx, ev.MergeRequest.Number, "this merge request was closed — reload") | |
| 190 | 190 | case domain.MergeRequestReady: | |
| 191 | 191 | return l.mrNotice(ctx, ev.MergeRequest.Number, "this merge request is ready for review — reload") | |
| 192 | + | case domain.MergeRequestAutoMergeEnabled: | |
| 193 | + | return l.mrNotice(ctx, ev.MergeRequest.Number, "auto-merge was enabled — reload") | |
| 194 | + | case domain.MergeRequestAutoMergeCancelled: | |
| 195 | + | return l.mrNotice(ctx, ev.MergeRequest.Number, "auto-merge was cancelled — reload") | |
| 192 | 196 | case domain.MergeRequestMergeability: | |
| 193 | 197 | if l.view != "mr" || strconv.FormatInt(ev.Number, 10) != l.n { | |
| 194 | 198 | return nil, nil | |
M internal/adapters/web/mr.go +26 -0
| @@ -11,6 +11,7 @@ import ( | |||
| 11 | 11 | "net/url" | |
| 12 | 12 | "strconv" | |
| 13 | 13 | "strings" | |
| 14 | + | "time" | |
| 14 | 15 | ||
| 15 | 16 | "github.com/a-h/templ" | |
| 16 | 17 | ||
| @@ -202,6 +203,18 @@ type MergeBox struct { | |||
| 202 | 203 | Fingerprint string | |
| 203 | 204 | // Error is why the last merge was refused, shown in the box. | |
| 204 | 205 | Error string | |
| 206 | + | // AutoAction and CancelAutoAction post to the auto-merge routes. | |
| 207 | + | AutoAction, CancelAutoAction string | |
| 208 | + | // CanAutoMerge offers "Merge when ready": the repository allows | |
| 209 | + | // auto-merge and the server has CI to wait for. | |
| 210 | + | CanAutoMerge bool | |
| 211 | + | // AutoMerge is the pending auto-merge, nil when off. | |
| 212 | + | AutoMerge *AutoMergeView | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | // AutoMergeView is a pending auto-merge, ready to show. | |
| 216 | + | type AutoMergeView struct { | |
| 217 | + | Mode, EnabledBy, When, WhenAt, HeadShort string | |
| 205 | 218 | } | |
| 206 | 219 | ||
| 207 | 220 | // ModeOption is a merge mode the repository allows. Disabled says that the | |
| @@ -666,9 +679,20 @@ func (s *Server) mrBox(ctx context.Context, d *repoData, res ports.MergeRequestD | |||
| 666 | 679 | if m.IsDraft() { | |
| 667 | 680 | box.ReadyAction = mrHref(d.name(), m.Number, "ready") | |
| 668 | 681 | } | |
| 682 | + | box.AutoMerge = autoMergeView(s.cfg.Now(), m.AutoMerge) | |
| 669 | 683 | return box | |
| 670 | 684 | } | |
| 671 | 685 | ||
| 686 | + | // autoMergeView is the pending auto-merge ready to show, nil when off. | |
| 687 | + | func autoMergeView(now time.Time, am *domain.AutoMerge) *AutoMergeView { | |
| 688 | + | if am == nil { | |
| 689 | + | return nil | |
| 690 | + | } | |
| 691 | + | v := &AutoMergeView{Mode: am.Mode.String(), EnabledBy: am.EnablerLabel, HeadShort: short(am.HeadID.String())} | |
| 692 | + | v.When, v.WhenAt = whenOf(now, am.EnabledAt) | |
| 693 | + | return v | |
| 694 | + | } | |
| 695 | + | ||
| 672 | 696 | // mrFacts formats the description, metadata, branch links and checks. | |
| 673 | 697 | func (s *Server) mrFacts(ctx context.Context, r *http.Request, d *repoData, p *MRPage, res ports.MergeRequestDetail) error { | |
| 674 | 698 | m := res.MergeRequest | |
| @@ -790,6 +814,8 @@ func (s *Server) mergeBox(d *repoData, n int64, source, head string, canMarkRead | |||
| 790 | 814 | CanMarkRead: canMarkRead, | |
| 791 | 815 | CanDeleteBranch: source != d.repo.DefaultBranch, | |
| 792 | 816 | Mergeability: mg, | |
| 817 | + | AutoAction: mrHref(d.name(), n, "auto-merge"), CancelAutoAction: mrHref(d.name(), n, "auto-merge/cancel"), | |
| 818 | + | CanAutoMerge: d.repo.Settings.AllowAutoMerge && s.cfg.Runs != nil, | |
| 793 | 819 | } | |
| 794 | 820 | var lines []string | |
| 795 | 821 | cmd := "" | |
M internal/adapters/web/mr.templ +28 -0
| @@ -508,6 +508,20 @@ templ mergeMenu(p MRPage) { | |||
| 508 | 508 | </form> | |
| 509 | 509 | } else if !p.Box.HeadGone { | |
| 510 | 510 | @mrMergeabilitySlot(p.Box.Mergeability) | |
| 511 | + | if p.Box.AutoMerge != nil { | |
| 512 | + | <form class="d-acts" method="post" action={ templ.SafeURL(p.Box.CancelAutoAction) }> | |
| 513 | + | <p class="dim" role="status"> | |
| 514 | + | Auto-merge: { p.Box.AutoMerge.Mode } when ready, enabled by { p.Box.AutoMerge.EnabledBy } | |
| 515 | + | if p.Box.AutoMerge.When != "" { | |
| 516 | + | { " " }<time datetime={ p.Box.AutoMerge.WhenAt }>{ p.Box.AutoMerge.When }</time> | |
| 517 | + | } | |
| 518 | + | { " " }(head { p.Box.AutoMerge.HeadShort }) | |
| 519 | + | </p> | |
| 520 | + | <div class="d-btns"> | |
| 521 | + | <button type="submit" class="btn">Cancel auto-merge</button> | |
| 522 | + | </div> | |
| 523 | + | </form> | |
| 524 | + | } | |
| 511 | 525 | <form class="d-acts" method="post" action={ templ.SafeURL(p.Box.MergeAction) }> | |
| 512 | 526 | <input type="hidden" name="head_id" value={ p.Box.Head }/> | |
| 513 | 527 | <fieldset class="d-modes"> | |
| @@ -546,6 +560,20 @@ templ mergeMenu(p MRPage) { | |||
| 546 | 560 | >Merge into { p.Target }</button> | |
| 547 | 561 | </div> | |
| 548 | 562 | </form> | |
| 563 | + | if p.Box.CanAutoMerge { | |
| 564 | + | <form class="d-acts" method="post" action={ templ.SafeURL(p.Box.AutoAction) }> | |
| 565 | + | <input type="hidden" name="head_id" value={ p.Box.Head }/> | |
| 566 | + | <fieldset class="d-modes"> | |
| 567 | + | <legend>Auto-merge mode</legend> | |
| 568 | + | for i, m := range p.Box.Modes { | |
| 569 | + | <label><input type="radio" name="mode" value={ m.Value } checked?={ i == 0 }/> { m.Label }</label> | |
| 570 | + | } | |
| 571 | + | </fieldset> | |
| 572 | + | <div class="d-btns"> | |
| 573 | + | <button type="submit" class="btn" disabled?={ len(p.Box.Modes) == 0 } title="merge once the checks pass">Merge when ready</button> | |
| 574 | + | </div> | |
| 575 | + | </form> | |
| 576 | + | } | |
| 549 | 577 | } | |
| 550 | 578 | </div> | |
| 551 | 579 | </details> | |
M internal/adapters/web/mr_post.go +62 -0
| @@ -391,6 +391,68 @@ func (s *Server) mrMerge(ctx context.Context, r *http.Request) (string, string, | |||
| 391 | 391 | return back, back, nil | |
| 392 | 392 | } | |
| 393 | 393 | ||
| 394 | + | // mrAutoMerge is POST /<repo>/merge-requests/<n>/auto-merge: the form of the | |
| 395 | + | // merge (mode, head_id) asks to merge once the checks pass. The server records | |
| 396 | + | // refuses to enable when the source branch is no longer at head_id. | |
| 397 | + | func (s *Server) mrAutoMerge(ctx context.Context, r *http.Request) (string, string, error) { | |
| 398 | + | repo := r.PathValue("repo") | |
| 399 | + | n, err := number(r) | |
| 400 | + | if err != nil { | |
| 401 | + | return "", "", err | |
| 402 | + | } | |
| 403 | + | back := mrHref(repo, n) | |
| 404 | + | var mode domain.MergeMode | |
| 405 | + | switch r.PostFormValue("mode") { | |
| 406 | + | case "fast-forward": | |
| 407 | + | mode = domain.MergeModeFastForward | |
| 408 | + | case "squash": | |
| 409 | + | mode = domain.MergeModeSquash | |
| 410 | + | case "merge": | |
| 411 | + | mode = domain.MergeModeMerge | |
| 412 | + | default: | |
| 413 | + | err := formError("mode must be fast-forward, squash or merge") | |
| 414 | + | return "", back, &rerender{status: http.StatusBadRequest, msg: err.Error(), err: err} | |
| 415 | + | } | |
| 416 | + | name, err := domain.ParseRepoName(repo) | |
| 417 | + | if err != nil { | |
| 418 | + | return "", back, err | |
| 419 | + | } | |
| 420 | + | head, err := headID(r.PostFormValue("head_id")) | |
| 421 | + | if err != nil { | |
| 422 | + | return "", back, err | |
| 423 | + | } | |
| 424 | + | _, err = s.cfg.MergeRequests.EnableAutoMerge(ctx, principal(ctx), name, n, ports.EnableAutoMergeParams{Mode: mode, HeadID: head}) | |
| 425 | + | switch { | |
| 426 | + | case errors.Is(err, domain.ErrPreconditionFailed), errors.Is(err, domain.ErrConflict): | |
| 427 | + | return "", back, &rerender{status: http.StatusConflict, msg: rule(err.Error(), "the merge request is not in a state that allows this."), err: err} | |
| 428 | + | case err != nil: | |
| 429 | + | return "", back, err | |
| 430 | + | } | |
| 431 | + | return back, back, nil | |
| 432 | + | } | |
| 433 | + | ||
| 434 | + | // mrCancelAutoMerge is POST /<repo>/merge-requests/<n>/auto-merge/cancel. | |
| 435 | + | func (s *Server) mrCancelAutoMerge(ctx context.Context, r *http.Request) (string, string, error) { | |
| 436 | + | repo := r.PathValue("repo") | |
| 437 | + | n, err := number(r) | |
| 438 | + | if err != nil { | |
| 439 | + | return "", "", err | |
| 440 | + | } | |
| 441 | + | back := mrHref(repo, n) | |
| 442 | + | name, err := domain.ParseRepoName(repo) | |
| 443 | + | if err != nil { | |
| 444 | + | return "", back, err | |
| 445 | + | } | |
| 446 | + | _, err = s.cfg.MergeRequests.CancelAutoMerge(ctx, principal(ctx), name, n) | |
| 447 | + | switch { | |
| 448 | + | case errors.Is(err, domain.ErrPreconditionFailed), errors.Is(err, domain.ErrConflict): | |
| 449 | + | return "", back, &rerender{status: http.StatusConflict, msg: rule(err.Error(), "the merge request is not in a state that allows this."), err: err} | |
| 450 | + | case err != nil: | |
| 451 | + | return "", back, err | |
| 452 | + | } | |
| 453 | + | return back, back, nil | |
| 454 | + | } | |
| 455 | + | ||
| 394 | 456 | // mrRead is POST /<repo>/merge-requests/<n>/read: the owner marks the head | |
| 395 | 457 | // they were shown as read. Reading a page never does it (see the top of mr.go). | |
| 396 | 458 | func (s *Server) mrRead(ctx context.Context, r *http.Request) (string, string, error) { | |
M internal/adapters/web/mr_templ.go +554 -350 generated file: collapsed
M internal/adapters/web/mr_test.go +103 -1
| @@ -4,8 +4,11 @@ package web | |||
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "context" | |
| 7 | - | "time" | |
| 7 | + | "net/http" | |
| 8 | + | "net/http/httptest" | |
| 9 | + | "strings" | |
| 8 | 10 | "testing" | |
| 11 | + | "time" | |
| 9 | 12 | ||
| 10 | 13 | "github.com/stretchr/testify/assert" | |
| 11 | 14 | "github.com/stretchr/testify/mock" | |
| @@ -195,3 +198,102 @@ func TestMRReadPresentationCommitCount(t *testing.T) { | |||
| 195 | 198 | }) | |
| 196 | 199 | } | |
| 197 | 200 | } | |
| 201 | + | ||
| 202 | + | func TestMergeBoxAutoMerge(t *testing.T) { | |
| 203 | + | t.Parallel() | |
| 204 | + | mg := mergeabilityView(domain.Mergeability{State: domain.MergeabilityUpToDate}, "main") | |
| 205 | + | mk := func(allow bool, runs ports.RunService) *MergeBox { | |
| 206 | + | v := repoWithModes(domain.MergeModes()...) | |
| 207 | + | v.Settings.AllowAutoMerge = allow | |
| 208 | + | return (&Server{cfg: Config{Runs: runs}}).mergeBox(&repoData{repo: v}, 1, "feat", "abc", false, mg) | |
| 209 | + | } | |
| 210 | + | assert.False(t, mk(false, mocks.NewRunService(t)).CanAutoMerge) | |
| 211 | + | assert.False(t, mk(true, nil).CanAutoMerge, "no CI, nothing to wait for") | |
| 212 | + | on := mk(true, mocks.NewRunService(t)) | |
| 213 | + | assert.True(t, on.CanAutoMerge) | |
| 214 | + | ||
| 215 | + | page := MRPage{Target: "main", Box: on} | |
| 216 | + | html := renderToString(t, mergeMenu(page)) | |
| 217 | + | assert.Contains(t, html, "Merge when ready") | |
| 218 | + | assert.Contains(t, html, `action="`+on.AutoAction+`"`) | |
| 219 | + | assert.NotContains(t, html, "Cancel auto-merge") | |
| 220 | + | // Current mergeability must only gate the immediate form. Every allowed | |
| 221 | + | // mode remains selectable in the separate auto-merge form. | |
| 222 | + | for i := range on.Modes { | |
| 223 | + | on.Modes[i].Disabled = true | |
| 224 | + | } | |
| 225 | + | on.CanDeleteBranch = true | |
| 226 | + | page.Source = "feat" | |
| 227 | + | html = renderToString(t, mergeMenu(page)) | |
| 228 | + | _, autoForm, found := strings.Cut(html, `action="`+on.AutoAction+`"`) | |
| 229 | + | require.True(t, found) | |
| 230 | + | autoForm, _, _ = strings.Cut(autoForm, "</form>") | |
| 231 | + | assert.NotContains(t, autoForm, "delete_branch") | |
| 232 | + | assert.NotContains(t, autoForm, "disabled") | |
| 233 | + | assert.Contains(t, autoForm, `name="head_id"`) | |
| 234 | + | for _, mode := range on.Modes { | |
| 235 | + | assert.Contains(t, autoForm, `value="`+mode.Value+`"`) | |
| 236 | + | } | |
| 237 | + | page.Box = mk(false, mocks.NewRunService(t)) | |
| 238 | + | assert.NotContains(t, renderToString(t, mergeMenu(page)), "Merge when ready") | |
| 239 | + | ||
| 240 | + | on.AutoMerge = &AutoMergeView{Mode: "squash", EnabledBy: "owner", HeadShort: "abc"} | |
| 241 | + | html = renderToString(t, mergeMenu(MRPage{Target: "main", Box: on})) | |
| 242 | + | assert.Contains(t, html, "Auto-merge: squash when ready, enabled by owner") | |
| 243 | + | assert.Contains(t, html, `action="`+on.CancelAutoAction+`"`) | |
| 244 | + | assert.Contains(t, html, "Cancel auto-merge") | |
| 245 | + | } | |
| 246 | + | ||
| 247 | + | func autoMergeRequest(path, body string) *http.Request { | |
| 248 | + | r := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body)) | |
| 249 | + | r.Header.Set("Content-Type", "application/x-www-form-urlencoded") | |
| 250 | + | r.SetPathValue("repo", "demo") | |
| 251 | + | r.SetPathValue("n", "3") | |
| 252 | + | return r | |
| 253 | + | } | |
| 254 | + | ||
| 255 | + | func TestAutoMergeHandlers(t *testing.T) { | |
| 256 | + | t.Parallel() | |
| 257 | + | name, err := domain.ParseRepoName("demo") | |
| 258 | + | require.NoError(t, err) | |
| 259 | + | ctx := context.Background() | |
| 260 | + | ||
| 261 | + | head, err := domain.ParseObjectID(strings.Repeat("a", domain.ObjectIDLen)) | |
| 262 | + | require.NoError(t, err) | |
| 263 | + | svc := mocks.NewMergeRequestService(t) | |
| 264 | + | svc.EXPECT().EnableAutoMerge(mock.Anything, mock.Anything, name, int64(3), ports.EnableAutoMergeParams{Mode: domain.MergeModeSquash, HeadID: head}). | |
| 265 | + | Return(domain.MergeRequest{}, nil).Once() | |
| 266 | + | svc.EXPECT().CancelAutoMerge(mock.Anything, mock.Anything, name, int64(3)).Return(domain.MergeRequest{}, nil).Once() | |
| 267 | + | s := &Server{cfg: Config{MergeRequests: svc}} | |
| 268 | + | to, _, err := s.mrAutoMerge(ctx, autoMergeRequest("/demo/merge-requests/3/auto-merge", "mode=squash&head_id="+head.String())) | |
| 269 | + | require.NoError(t, err) | |
| 270 | + | assert.Equal(t, mrHref("demo", 3), to) | |
| 271 | + | to, _, err = s.mrCancelAutoMerge(ctx, autoMergeRequest("/demo/merge-requests/3/auto-merge/cancel", "")) | |
| 272 | + | require.NoError(t, err) | |
| 273 | + | assert.Equal(t, mrHref("demo", 3), to) | |
| 274 | + | ||
| 275 | + | // A bad mode is refused before the service. | |
| 276 | + | _, _, err = s.mrAutoMerge(ctx, autoMergeRequest("/x", "mode=rebase")) | |
| 277 | + | var re *rerender | |
| 278 | + | require.ErrorAs(t, err, &re) | |
| 279 | + | assert.Equal(t, http.StatusBadRequest, re.status) | |
| 280 | + | ||
| 281 | + | // A refusal of the use case is shown in the box, like a refused merge. | |
| 282 | + | svc2 := mocks.NewMergeRequestService(t) | |
| 283 | + | svc2.EXPECT().EnableAutoMerge(mock.Anything, mock.Anything, name, int64(3), mock.Anything). | |
| 284 | + | Return(domain.MergeRequest{}, domain.ErrPreconditionFailed).Once() | |
| 285 | + | svc2.EXPECT().CancelAutoMerge(mock.Anything, mock.Anything, name, int64(3)). | |
| 286 | + | Return(domain.MergeRequest{}, domain.ErrNotFound).Once() | |
| 287 | + | s2 := &Server{cfg: Config{MergeRequests: svc2}} | |
| 288 | + | _, _, err = s2.mrAutoMerge(ctx, autoMergeRequest("/x", "mode=squash&head_id="+head.String())) | |
| 289 | + | require.ErrorAs(t, err, &re) | |
| 290 | + | assert.Equal(t, http.StatusConflict, re.status) | |
| 291 | + | _, _, err = s2.mrCancelAutoMerge(ctx, autoMergeRequest("/x", "")) | |
| 292 | + | require.ErrorIs(t, err, domain.ErrNotFound) | |
| 293 | + | } | |
| 294 | + | ||
| 295 | + | func TestAutoMergeLiveEvents(t *testing.T) { | |
| 296 | + | t.Parallel() | |
| 297 | + | assert.True(t, liveEvent(watchOf(domain.MergeRequestAutoMergeEnabled{}))) | |
| 298 | + | assert.True(t, liveEvent(watchOf(domain.MergeRequestAutoMergeCancelled{}))) | |
| 299 | + | } | |
M internal/adapters/web/web.go +2 -0
| @@ -231,6 +231,8 @@ func (s *Server) routes() *http.ServeMux { | |||
| 231 | 231 | mux.HandleFunc("POST /{repo}/merge-requests/{n}/comments/{id}/edit", s.mrAction(s.mrEditComment)) | |
| 232 | 232 | mux.HandleFunc("POST /{repo}/merge-requests/{n}/comments/{id}/delete", s.mrAction(s.mrDeleteComment)) | |
| 233 | 233 | mux.HandleFunc("POST /{repo}/merge-requests/{n}/merge", s.mrAction(s.mrMerge)) | |
| 234 | + | mux.HandleFunc("POST /{repo}/merge-requests/{n}/auto-merge", s.mrAction(s.mrAutoMerge)) | |
| 235 | + | mux.HandleFunc("POST /{repo}/merge-requests/{n}/auto-merge/cancel", s.mrAction(s.mrCancelAutoMerge)) | |
| 234 | 236 | mux.HandleFunc("POST /{repo}/merge-requests/{n}/close", s.mrAction(s.mrClose)) | |
| 235 | 237 | mux.HandleFunc("POST /{repo}/merge-requests/{n}/ready", s.mrAction(s.mrReady)) | |
| 236 | 238 | mux.HandleFunc("POST /{repo}/merge-requests/{n}/read", s.mrAction(s.mrRead)) | |
M internal/app/api_test.go +1 -1
| @@ -196,7 +196,7 @@ func TestAC2ReadAccess(t *testing.T) { | |||
| 196 | 196 | for _, who := range allPrincipals { | |
| 197 | 197 | resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{})) | |
| 198 | 198 | require.NoError(t, err, who) | |
| 199 | - | assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "issues.links", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures", "mirror"}, resp.Msg.GetFeatures(), who) | |
| 199 | + | assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "issues.links", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures", "mirror", "merge_requests.auto_merge"}, resp.Msg.GetFeatures(), who) | |
| 200 | 200 | assert.NotEmpty(t, resp.Msg.GetVersion()) | |
| 201 | 201 | } | |
| 202 | 202 | }) | |
A internal/app/automerge_e2e_test.go +163 -0
| @@ -0,0 +1,163 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package app | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "testing" | |
| 7 | + | "time" | |
| 8 | + | ||
| 9 | + | "connectrpc.com/connect" | |
| 10 | + | "github.com/stretchr/testify/assert" | |
| 11 | + | "github.com/stretchr/testify/require" | |
| 12 | + | "google.golang.org/protobuf/types/known/fieldmaskpb" | |
| 13 | + | ||
| 14 | + | pb "bnema.dev/ermite-api/gen/ermite" | |
| 15 | + | "bnema.dev/ermite/internal/domain" | |
| 16 | + | "bnema.dev/ermite/internal/ports" | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | // This test exercises the composition root through use cases and domain | |
| 20 | + | // events; TestAutoMergeEndToEndAPI does the same through the RPC API. | |
| 21 | + | func TestAutoMergeEndToEnd(t *testing.T) { | |
| 22 | + | c := newCIHarness(t, nil) | |
| 23 | + | runner := c.registerRunner("alpha", "ubuntu-latest") | |
| 24 | + | c.push("main", map[string]string{".github/workflows/ci.yml": `name: ci | |
| 25 | + | on: | |
| 26 | + | push: | |
| 27 | + | branches: [feat] | |
| 28 | + | pull_request: | |
| 29 | + | jobs: | |
| 30 | + | build: | |
| 31 | + | runs-on: ubuntu-latest | |
| 32 | + | steps: | |
| 33 | + | - run: echo hello | |
| 34 | + | `}, "") | |
| 35 | + | head := c.push("feat", map[string]string{"change.txt": "feature\n"}, "") | |
| 36 | + | number := c.openMR(c.root, "feat") | |
| 37 | + | repo := c.name("proj") | |
| 38 | + | allow := true | |
| 39 | + | _, err := c.forge.Repos.Update(c.ctx, domain.Root(), repo, ports.UpdateRepoParams{AllowAutoMerge: &allow}) | |
| 40 | + | require.NoError(t, err) | |
| 41 | + | headID, err := domain.ParseObjectID(head) | |
| 42 | + | require.NoError(t, err) | |
| 43 | + | events := c.forge.Bus.Subscribe(c.ctx) | |
| 44 | + | enabled, err := c.forge.MergeRequests.EnableAutoMerge(c.ctx, domain.Owner(), repo, number, ports.EnableAutoMergeParams{HeadID: headID}) | |
| 45 | + | require.NoError(t, err) | |
| 46 | + | require.NotNil(t, enabled.AutoMerge) | |
| 47 | + | assert.Equal(t, head, enabled.AutoMerge.HeadID.String()) | |
| 48 | + | enabledDeadline := time.After(5 * time.Second) | |
| 49 | + | waitEnabled: | |
| 50 | + | for { | |
| 51 | + | select { | |
| 52 | + | case ev := <-events: | |
| 53 | + | if enabled, ok := ev.(domain.MergeRequestAutoMergeEnabled); ok && enabled.MergeRequest.Repo == repo && enabled.MergeRequest.Number == number { | |
| 54 | + | break waitEnabled | |
| 55 | + | } | |
| 56 | + | case <-enabledDeadline: | |
| 57 | + | t.Fatal("no enabled event") | |
| 58 | + | } | |
| 59 | + | } | |
| 60 | + | c.finish(runner) | |
| 61 | + | // A successful push check alone must not merge while the pull run is queued. | |
| 62 | + | got, err := c.forge.MergeRequests.Get(c.ctx, domain.Root(), repo, number, ports.GetMergeRequestOptions{}) | |
| 63 | + | require.NoError(t, err) | |
| 64 | + | assert.True(t, got.MergeRequest.IsOpen()) | |
| 65 | + | c.finish(runner) | |
| 66 | + | deadline := time.After(10 * time.Second) | |
| 67 | + | for { | |
| 68 | + | select { | |
| 69 | + | case ev := <-events: | |
| 70 | + | if merged, ok := ev.(domain.MergeRequestMerged); ok && merged.MergeRequest.Number == number { | |
| 71 | + | assert.True(t, merged.Auto) | |
| 72 | + | assert.True(t, merged.MergeRequest.AutoMerged) | |
| 73 | + | assert.Nil(t, merged.MergeRequest.AutoMerge) | |
| 74 | + | return | |
| 75 | + | } | |
| 76 | + | case <-deadline: | |
| 77 | + | t.Fatal("no automatic merged event") | |
| 78 | + | } | |
| 79 | + | } | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | // TestAutoMergeEndToEndAPI enables auto-merge through the API client, lets CI | |
| 83 | + | // succeed, and reads the Watch stream: enabled, then merged with auto=true. | |
| 84 | + | func TestAutoMergeEndToEndAPI(t *testing.T) { | |
| 85 | + | c := newCIHarness(t, nil) | |
| 86 | + | runner := c.registerRunner("alpha", "ubuntu-latest") | |
| 87 | + | c.push("main", map[string]string{".github/workflows/ci.yml": `name: ci | |
| 88 | + | on: | |
| 89 | + | push: | |
| 90 | + | branches: [feat] | |
| 91 | + | pull_request: | |
| 92 | + | jobs: | |
| 93 | + | build: | |
| 94 | + | runs-on: ubuntu-latest | |
| 95 | + | steps: | |
| 96 | + | - run: echo hello | |
| 97 | + | `}, "") | |
| 98 | + | head := c.push("feat", map[string]string{"change.txt": "feature\n"}, "") | |
| 99 | + | number := c.openMR(c.root, "feat") | |
| 100 | + | ||
| 101 | + | // Off by default: the server refuses. | |
| 102 | + | _, err := c.root.mr.EnableAutoMerge(c.ctx, connect.NewRequest(&pb.EnableAutoMergeRequest{Repo: c.repo, Number: number, HeadId: head})) | |
| 103 | + | require.Error(t, err) | |
| 104 | + | assert.Equal(t, connect.CodeFailedPrecondition, connect.CodeOf(err)) | |
| 105 | + | ||
| 106 | + | ver, err := c.root.server.GetVersion(c.ctx, connect.NewRequest(&pb.GetVersionRequest{})) | |
| 107 | + | require.NoError(t, err) | |
| 108 | + | assert.Contains(t, ver.Msg.GetFeatures(), "merge_requests.auto_merge") | |
| 109 | + | ||
| 110 | + | up, err := c.root.repo.UpdateRepo(c.ctx, connect.NewRequest(&pb.UpdateRepoRequest{ | |
| 111 | + | Repo: c.repo, | |
| 112 | + | Settings: &pb.RepoSettings{AllowAutoMerge: true}, | |
| 113 | + | UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"settings.allow_auto_merge"}}, | |
| 114 | + | })) | |
| 115 | + | require.NoError(t, err) | |
| 116 | + | assert.True(t, up.Msg.GetRepo().GetSettings().GetAllowAutoMerge()) | |
| 117 | + | ||
| 118 | + | w := c.watch(c.root, "", "home") | |
| 119 | + | res, err := c.root.mr.EnableAutoMerge(c.ctx, connect.NewRequest(&pb.EnableAutoMergeRequest{ | |
| 120 | + | Repo: c.repo, Number: number, Mode: pb.MergeMode_MERGE_MODE_SQUASH, HeadId: head, | |
| 121 | + | })) | |
| 122 | + | require.NoError(t, err) | |
| 123 | + | am := res.Msg.GetMergeRequest().GetAutoMerge() | |
| 124 | + | require.NotNil(t, am) | |
| 125 | + | assert.Equal(t, pb.MergeMode_MERGE_MODE_SQUASH, am.GetMode()) | |
| 126 | + | assert.Equal(t, res.Msg.GetMergeRequest().GetHeadId(), am.GetHeadId()) | |
| 127 | + | assert.NotEmpty(t, am.GetEnabledBy()) | |
| 128 | + | assert.NotNil(t, am.GetEnabledAt()) | |
| 129 | + | ||
| 130 | + | got, err := c.root.mr.GetMergeRequest(c.ctx, connect.NewRequest(&pb.GetMergeRequestRequest{Repo: c.repo, Number: number})) | |
| 131 | + | require.NoError(t, err) | |
| 132 | + | assert.NotNil(t, got.Msg.GetMergeRequest().GetAutoMerge(), "Get reports the pending auto-merge") | |
| 133 | + | ||
| 134 | + | c.finish(runner) | |
| 135 | + | c.finish(runner) | |
| 136 | + | ||
| 137 | + | var sawEnabled bool | |
| 138 | + | deadline := time.After(15 * time.Second) | |
| 139 | + | for { | |
| 140 | + | select { | |
| 141 | + | case ev, ok := <-w.events: | |
| 142 | + | require.True(t, ok, "the stream ended: %v", w.err) | |
| 143 | + | if e := ev.GetMergeRequestAutoMergeEnabled(); e != nil { | |
| 144 | + | assert.EqualValues(t, number, e.GetMergeRequest().GetNumber()) | |
| 145 | + | assert.NotNil(t, e.GetMergeRequest().GetAutoMerge()) | |
| 146 | + | sawEnabled = true | |
| 147 | + | } | |
| 148 | + | if m := ev.GetMergeRequestMerged(); m != nil && m.GetMergeRequest().GetNumber() == number { | |
| 149 | + | assert.True(t, sawEnabled, "enabled comes before merged") | |
| 150 | + | assert.True(t, m.GetAuto()) | |
| 151 | + | assert.True(t, m.GetMergeRequest().GetAutoMerged()) | |
| 152 | + | assert.Nil(t, m.GetMergeRequest().GetAutoMerge()) | |
| 153 | + | final, err := c.root.mr.GetMergeRequest(c.ctx, connect.NewRequest(&pb.GetMergeRequestRequest{Repo: c.repo, Number: number})) | |
| 154 | + | require.NoError(t, err) | |
| 155 | + | assert.Equal(t, pb.MergeRequestState_MERGE_REQUEST_STATE_MERGED, final.Msg.GetMergeRequest().GetState()) | |
| 156 | + | assert.True(t, final.Msg.GetMergeRequest().GetAutoMerged()) | |
| 157 | + | return | |
| 158 | + | } | |
| 159 | + | case <-deadline: | |
| 160 | + | t.Fatal("no automatic merged event") | |
| 161 | + | } | |
| 162 | + | } | |
| 163 | + | } | |
M internal/app/cli_e2e_test.go +2 -2
| @@ -203,7 +203,7 @@ type eventOut struct { | |||
| 203 | 203 | MergeRequest *mrOut `json:"merge_request"` | |
| 204 | 204 | } | |
| 205 | 205 | ||
| 206 | - | var repoKeys = []string{"created_at", "default_branch", "description", "failing_checks_block", "issues", "merge_modes", "name", "pushed_at", "topics", "visibility"} | |
| 206 | + | var repoKeys = []string{"allow_auto_merge", "created_at", "default_branch", "description", "failing_checks_block", "issues", "merge_modes", "name", "pushed_at", "topics", "visibility"} | |
| 207 | 207 | ||
| 208 | 208 | // AC6: the CLI end to end against a real forge. | |
| 209 | 209 | func TestAC6CLIEndToEnd(t *testing.T) { | |
| @@ -350,7 +350,7 @@ func TestAC6CLIEndToEnd(t *testing.T) { | |||
| 350 | 350 | ver := decode[versionOut](t, res.stdout) | |
| 351 | 351 | assert.Equal(t, c.url, ver.Server.URL) | |
| 352 | 352 | assert.NotEmpty(t, ver.Server.Version) | |
| 353 | - | assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "issues.links", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures", "mirror"}, ver.Server.Features) | |
| 353 | + | assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "issues.links", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures", "mirror", "merge_requests.auto_merge"}, ver.Server.Features) | |
| 354 | 354 | assert.Equal(t, []string{"passkeys"}, ver.Server.MissingFeatures) | |
| 355 | 355 | stdout, stderr, code = c.bin(t, secret, "version") | |
| 356 | 356 | require.Equal(t, 0, code, stderr) | |
M internal/app/cli_fake_test.go +8 -0
| @@ -62,6 +62,8 @@ type fakeAPI struct { | |||
| 62 | 62 | closeMR func(*ermite.CloseMergeRequestRequest) (*ermite.CloseMergeRequestResponse, error) | |
| 63 | 63 | mergeMR func(*ermite.MergeMergeRequestRequest) (*ermite.MergeMergeRequestResponse, error) | |
| 64 | 64 | markRead func(*ermite.MarkReadRequest) (*ermite.MarkReadResponse, error) | |
| 65 | + | enableAuto func(*ermite.EnableAutoMergeRequest) (*ermite.EnableAutoMergeResponse, error) | |
| 66 | + | cancelAuto func(*ermite.CancelAutoMergeRequest) (*ermite.CancelAutoMergeResponse, error) | |
| 65 | 67 | createIssue func(*ermite.CreateIssueRequest) (*ermite.CreateIssueResponse, error) | |
| 66 | 68 | listIssues func(*ermite.ListIssuesRequest) (*ermite.ListIssuesResponse, error) | |
| 67 | 69 | getIssue func(*ermite.GetIssueRequest) (*ermite.GetIssueResponse, error) | |
| @@ -152,6 +154,12 @@ func (f *fakeAPI) CloseMergeRequest(_ context.Context, r *connect.Request[ermite | |||
| 152 | 154 | func (f *fakeAPI) MergeMergeRequest(_ context.Context, r *connect.Request[ermite.MergeMergeRequestRequest]) (*connect.Response[ermite.MergeMergeRequestResponse], error) { | |
| 153 | 155 | return respond(f, "MergeMergeRequest", r, f.mergeMR) | |
| 154 | 156 | } | |
| 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 | + | } | |
| 155 | 163 | func (f *fakeAPI) MarkRead(_ context.Context, r *connect.Request[ermite.MarkReadRequest]) (*connect.Response[ermite.MarkReadResponse], error) { | |
| 156 | 164 | return respond(f, "MarkRead", r, f.markRead) | |
| 157 | 165 | } | |
M internal/app/cli_mr.go +95 -3
| @@ -121,6 +121,26 @@ type jsonMergeRequest struct { | |||
| 121 | 121 | HeadID string `json:"head_id"` | |
| 122 | 122 | MergedCommitID string `json:"merged_commit_id"` | |
| 123 | 123 | 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())} | |
| 124 | 144 | } | |
| 125 | 145 | ||
| 126 | 146 | // jsonMergeability is the stable JSON shape of the mergeability of an open | |
| @@ -201,6 +221,7 @@ func toJSONMergeRequest(m *ermite.MergeRequest, baseURL string) jsonMergeRequest | |||
| 201 | 221 | SourceBranch: m.GetSourceBranch(), TargetBranch: m.GetTargetBranch(), State: stateName(m.GetState()), | |
| 202 | 222 | Author: m.GetAuthor(), CreatedAt: jsonTime(m.GetCreatedAt()), UpdatedAt: jsonTime(m.GetUpdatedAt()), | |
| 203 | 223 | HeadID: m.GetHeadId(), MergedCommitID: m.GetMergedCommitId(), MergeMode: modeName(m.GetMergeMode()), | |
| 224 | + | AutoMerge: toJSONAutoMerge(m.GetAutoMerge()), AutoMerged: m.GetAutoMerged(), | |
| 204 | 225 | } | |
| 205 | 226 | } | |
| 206 | 227 | ||
| @@ -245,7 +266,8 @@ JSON fields of a merge request (--json): repo, url (full web URL), number, title | |||
| 245 | 266 | target_branch, state ("open", "merged" or "closed"), author (token label), created_at and | |
| 246 | 267 | updated_at (RFC 3339 UTC), head_id (the commit the source branch points to; empty when the | |
| 247 | 268 | 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).` | |
| 249 | 271 | ||
| 250 | 272 | const jsonMergeabilityHelp = ` | |
| 251 | 273 | 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 | |||
| 660 | 682 | {"head", orDash(m.GetHeadId())}, | |
| 661 | 683 | } | |
| 662 | 684 | 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() + ")"}) | |
| 664 | 693 | } | |
| 665 | 694 | if mg := toJSONMergeability(m.GetMergeability()); mg != nil && m.GetState() == ermite.MergeRequestState_MERGE_REQUEST_STATE_OPEN { | |
| 666 | 695 | pairs = append(pairs, [2]string{"mergeability", mergeabilityText(mg)}) | |
| @@ -1056,6 +1085,7 @@ func newMRMergeCmd(env *cliEnv) *cobra.Command { | |||
| 1056 | 1085 | var mode string | |
| 1057 | 1086 | var deleteBranch bool | |
| 1058 | 1087 | var head string | |
| 1088 | + | var auto, cancelAuto bool | |
| 1059 | 1089 | cmd := &cobra.Command{ | |
| 1060 | 1090 | Use: "merge <repo> <number>", | |
| 1061 | 1091 | Short: "Merge a merge request", | |
| @@ -1078,7 +1108,12 @@ func newMRMergeCmd(env *cliEnv) *cobra.Command { | |||
| 1078 | 1108 | "The merge answers as soon as the target branch has moved. --delete-branch asks the server to delete\n" + | |
| 1079 | 1109 | "the source branch right after, in the background. The merge is refused when the branch is the\n" + | |
| 1080 | 1110 | "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 + | |
| 1082 | 1117 | "\n--json adds source_branch_deleted (true when the deletion was requested: the server deletes the branch right after the merge).", | |
| 1083 | 1118 | Args: cobra.ExactArgs(2), | |
| 1084 | 1119 | ValidArgsFunction: completeMRRepoThenNumber(env), | |
| @@ -1086,6 +1121,12 @@ func newMRMergeCmd(env *cliEnv) *cobra.Command { | |||
| 1086 | 1121 | cmd.Flags().StringVar(&mode, "mode", modeFastForward, "merge mode: fast-forward, squash or merge (a merge commit)") | |
| 1087 | 1122 | cmd.Flags().StringVar(&head, "head", "", "full id of the head you reviewed, from `ermite mr show` (required)") | |
| 1088 | 1123 | 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") | |
| 1089 | 1130 | _ = cmd.RegisterFlagCompletionFunc("mode", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { | |
| 1090 | 1131 | return []string{modeFastForward, modeSquash, modeMerge}, cobra.ShellCompDirectiveNoFileComp | |
| 1091 | 1132 | }) | |
| @@ -1095,6 +1136,9 @@ func newMRMergeCmd(env *cliEnv) *cobra.Command { | |||
| 1095 | 1136 | if err != nil { | |
| 1096 | 1137 | return err | |
| 1097 | 1138 | } | |
| 1139 | + | if cancelAuto { | |
| 1140 | + | return runMRCancelAuto(env, cmd, args[0], n, *asJSON) | |
| 1141 | + | } | |
| 1098 | 1142 | pbMode, err := parseMode(mode) | |
| 1099 | 1143 | if err != nil { | |
| 1100 | 1144 | return err | |
| @@ -1104,6 +1148,9 @@ func newMRMergeCmd(env *cliEnv) *cobra.Command { | |||
| 1104 | 1148 | return fmt.Errorf("--head is required: run `ermite mr show %s %d` and give the head id it prints, "+ | |
| 1105 | 1149 | "so that only the commit you reviewed is merged", args[0], n) | |
| 1106 | 1150 | } | |
| 1151 | + | if auto { | |
| 1152 | + | return runMREnableAuto(env, cmd, args[0], n, pbMode, head, *asJSON) | |
| 1153 | + | } | |
| 1107 | 1154 | c, err := env.client(cmd) | |
| 1108 | 1155 | if err != nil { | |
| 1109 | 1156 | return err | |
| @@ -1139,6 +1186,51 @@ func newMRMergeCmd(env *cliEnv) *cobra.Command { | |||
| 1139 | 1186 | return cmd | |
| 1140 | 1187 | } | |
| 1141 | 1188 | ||
| 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 | + | ||
| 1142 | 1234 | // signedNote says that a squash or merge commit is signed by the server's | |
| 1143 | 1235 | // key, with its fingerprint when the server tells it. | |
| 1144 | 1236 | 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 { | |||
| 33 | 33 | MergeMode string `json:"merge_mode"` | |
| 34 | 34 | } | |
| 35 | 35 | ||
| 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"} | |
| 37 | 37 | ||
| 38 | 38 | type commentOut struct { | |
| 39 | 39 | ID string `json:"id"` | |
M internal/app/cli_mr_test.go +112 -1
| @@ -51,7 +51,7 @@ func TestMRCreateMapsFlags(t *testing.T) { | |||
| 51 | 51 | _ = res | |
| 52 | 52 | ||
| 53 | 53 | 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) | |
| 55 | 55 | ||
| 56 | 56 | for _, args := range [][]string{{"mr", "create", "alpha", "--source", "x"}, {"mr", "create", "alpha", "--title", "t"}, {"mr", "create", "--title", "t", "--source", "x"}} { | |
| 57 | 57 | require.Error(t, runCLI(t, envFor(url), "", args...).err, "%v", args) | |
| @@ -578,3 +578,114 @@ func TestJSONFileOmitted(t *testing.T) { | |||
| 578 | 578 | assert.Contains(t, string(raw), `"truncated":true`) | |
| 579 | 579 | assert.Equal(t, 2, strings.Count(string(raw), `"omitted"`)) | |
| 580 | 580 | } | |
| 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 { | |||
| 36 | 36 | // MergeModes are the allowed merge modes ("fast-forward", "squash", "merge"). | |
| 37 | 37 | MergeModes []string `json:"merge_modes"` | |
| 38 | 38 | FailingChecksBlock bool `json:"failing_checks_block"` | |
| 39 | + | AllowAutoMerge bool `json:"allow_auto_merge"` | |
| 39 | 40 | // Issues is the issue mode ("off", "owner" or "public"). | |
| 40 | 41 | Issues string `json:"issues"` | |
| 41 | 42 | } | |
| @@ -48,6 +49,7 @@ func toJSONRepo(r *ermite.Repo) jsonRepo { | |||
| 48 | 49 | return jsonRepo{ | |
| 49 | 50 | MergeModes: modes, | |
| 50 | 51 | FailingChecksBlock: r.GetSettings().GetFailingChecksBlock(), | |
| 52 | + | AllowAutoMerge: r.GetSettings().GetAllowAutoMerge(), | |
| 51 | 53 | Issues: issuesModeName(r.GetSettings().GetIssues()), | |
| 52 | 54 | Name: r.GetName(), | |
| 53 | 55 | Description: r.GetDescription(), | |
| @@ -115,8 +117,8 @@ const jsonRepoHelp = ` | |||
| 115 | 117 | JSON fields of a repository (--json): name, description, visibility ("public" or | |
| 116 | 118 | "private"), default_branch, created_at and pushed_at (RFC 3339 UTC; pushed_at is | |
| 117 | 119 | 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").` | |
| 120 | 122 | ||
| 121 | 123 | func newRepoCmd(env *cliEnv) *cobra.Command { | |
| 122 | 124 | repo := &cobra.Command{ | |
| @@ -229,6 +231,7 @@ func printRepo(w io.Writer, r *ermite.Repo) error { | |||
| 229 | 231 | {"last push", shortTime(r.GetPushedAt(), "never")}, | |
| 230 | 232 | {"merge modes", orDash(strings.Join(modes, ", "))}, | |
| 231 | 233 | {"failing checks block", strconv.FormatBool(r.GetSettings().GetFailingChecksBlock())}, | |
| 234 | + | {"allow auto-merge", strconv.FormatBool(r.GetSettings().GetAllowAutoMerge())}, | |
| 232 | 235 | {"issues", issuesModeName(r.GetSettings().GetIssues())}, | |
| 233 | 236 | }) | |
| 234 | 237 | } | |
| @@ -308,7 +311,7 @@ func buildCreateRepo(name, description string, topics []string, visibility strin | |||
| 308 | 311 | func newRepoEditCmd(env *cliEnv) *cobra.Command { | |
| 309 | 312 | var description, visibility, defaultBranch string | |
| 310 | 313 | var topics, mergeModes []string | |
| 311 | - | var failingChecksBlock bool | |
| 314 | + | var failingChecksBlock, allowAutoMerge bool | |
| 312 | 315 | var issues string | |
| 313 | 316 | cmd := &cobra.Command{ | |
| 314 | 317 | Use: "edit <name>", | |
| @@ -331,6 +334,7 @@ func newRepoEditCmd(env *cliEnv) *cobra.Command { | |||
| 331 | 334 | cmd.Flags().StringVar(&defaultBranch, "default-branch", "", "short name of the new default branch") | |
| 332 | 335 | cmd.Flags().StringSliceVar(&mergeModes, "merge-modes", nil, "allowed merge modes, comma-separated: fast-forward, squash, merge (all by default; list the ones to keep)") | |
| 333 | 336 | 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)") | |
| 334 | 338 | cmd.Flags().StringVar(&issues, "issues", "", "issue mode: off, owner or public") | |
| 335 | 339 | _ = cmd.RegisterFlagCompletionFunc("issues", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { | |
| 336 | 340 | return []string{issuesOff, issuesOwner, issuesPublic}, cobra.ShellCompDirectiveNoFileComp | |
| @@ -341,7 +345,7 @@ func newRepoEditCmd(env *cliEnv) *cobra.Command { | |||
| 341 | 345 | }) | |
| 342 | 346 | asJSON := addJSONFlag(cmd) | |
| 343 | 347 | 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) | |
| 345 | 349 | if err != nil { | |
| 346 | 350 | return err | |
| 347 | 351 | } | |
| @@ -365,7 +369,7 @@ func newRepoEditCmd(env *cliEnv) *cobra.Command { | |||
| 365 | 369 | ||
| 366 | 370 | // buildUpdateRepo maps the flags of `repo edit` to the request: only the flags | |
| 367 | 371 | // 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) { | |
| 369 | 373 | req := &ermite.UpdateRepoRequest{Repo: name} | |
| 370 | 374 | var paths []string | |
| 371 | 375 | if cmd.Flags().Changed("description") { | |
| @@ -414,6 +418,13 @@ func buildUpdateRepo(cmd *cobra.Command, name, description string, topics []stri | |||
| 414 | 418 | req.Settings.FailingChecksBlock = failingChecksBlock | |
| 415 | 419 | paths = append(paths, "settings.failing_checks_block") | |
| 416 | 420 | } | |
| 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 | + | } | |
| 417 | 428 | if cmd.Flags().Changed("issues") { | |
| 418 | 429 | mode, err := parseIssuesMode(issues) | |
| 419 | 430 | if err != nil { | |
| @@ -423,7 +434,7 @@ func buildUpdateRepo(cmd *cobra.Command, name, description string, topics []stri | |||
| 423 | 434 | req.Issues = &mode | |
| 424 | 435 | } | |
| 425 | 436 | 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") | |
| 427 | 438 | } | |
| 428 | 439 | req.UpdateMask = &fieldmaskpb.FieldMask{Paths: paths} | |
| 429 | 440 | return req, nil | |
M internal/app/cli_repo_test.go +34 -4
| @@ -54,8 +54,8 @@ func TestRepoList(t *testing.T) { | |||
| 54 | 54 | { | |
| 55 | 55 | name: "json", repos: both, args: []string{"repo", "list", "--json"}, wantJSON: true, | |
| 56 | 56 | 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"} | |
| 59 | 59 | ]`, | |
| 60 | 60 | }, | |
| 61 | 61 | { | |
| @@ -116,12 +116,13 @@ func TestRepoShow(t *testing.T) { | |||
| 116 | 116 | "last push 2026-03-04 05:06\n"+ | |
| 117 | 117 | "merge modes fast-forward, squash\n"+ | |
| 118 | 118 | "failing checks block true\n"+ | |
| 119 | + | "allow auto-merge false\n"+ | |
| 119 | 120 | "issues off\n", res.stdout) | |
| 120 | 121 | _, reqs := f.snapshot() | |
| 121 | 122 | assert.Equal(t, "alpha", reqs[0].(*ermite.GetRepoRequest).GetRepo()) | |
| 122 | 123 | ||
| 123 | 124 | 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) | |
| 125 | 126 | } | |
| 126 | 127 | ||
| 127 | 128 | func TestRepoShowNeedsOneArg(t *testing.T) { | |
| @@ -177,7 +178,7 @@ func TestRepoCreateJSON(t *testing.T) { | |||
| 177 | 178 | return &ermite.CreateRepoResponse{Repo: repoBeta}, nil | |
| 178 | 179 | }} | |
| 179 | 180 | 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) | |
| 181 | 182 | } | |
| 182 | 183 | ||
| 183 | 184 | func TestRepoEditMask(t *testing.T) { | |
| @@ -215,6 +216,13 @@ func TestRepoEditMask(t *testing.T) { | |||
| 215 | 216 | {"failing checks block", []string{"--failing-checks-block"}, []string{"settings.failing_checks_block"}, func(t *testing.T, r *ermite.UpdateRepoRequest) { | |
| 216 | 217 | assert.True(t, r.GetSettings().GetFailingChecksBlock()) | |
| 217 | 218 | }}, | |
| 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 | + | }}, | |
| 218 | 226 | {"all", []string{"--default-branch", "dev", "--visibility", "private", "--description", "d"}, | |
| 219 | 227 | []string{"description", "visibility", "default_branch"}, func(t *testing.T, r *ermite.UpdateRepoRequest) {}}, | |
| 220 | 228 | } | |
| @@ -436,3 +444,25 @@ func TestToJSONRepoFromProto(t *testing.T) { | |||
| 436 | 444 | assert.Equal(t, "private", r.Visibility) | |
| 437 | 445 | assert.Equal(t, "unknown", toJSONRepo(&ermite.Repo{}).Visibility) | |
| 438 | 446 | } | |
| 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{ | |||
| 126 | 126 | client.FeatureCIArtifacts: "`ermite run artifacts` and `run download` need: the commands will fail on this server", | |
| 127 | 127 | client.FeaturePasskeys: "`ermite web passkey` needs: the commands will fail on this server (passkeys need an https public URL with a DNS host name)", | |
| 128 | 128 | 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", | |
| 129 | 130 | } | |
M internal/app/cli_version_test.go +5 -5
| @@ -17,7 +17,7 @@ import ( | |||
| 17 | 17 | ) | |
| 18 | 18 | ||
| 19 | 19 | // 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"} | |
| 21 | 21 | ||
| 22 | 22 | // Every feature the client uses needs a sentence for the version warning. | |
| 23 | 23 | func TestEveryUsedFeatureExplainsWhatBreaks(t *testing.T) { | |
| @@ -45,15 +45,15 @@ func TestVersionWithServer(t *testing.T) { | |||
| 45 | 45 | f := versionFake(allFeatures...) | |
| 46 | 46 | url := f.serve(t) | |
| 47 | 47 | 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) | |
| 49 | 49 | assert.Empty(t, res.stderr, "no warning when every feature is there") | |
| 50 | 50 | ||
| 51 | 51 | 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) | |
| 53 | 53 | } | |
| 54 | 54 | ||
| 55 | 55 | 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") | |
| 57 | 57 | url := f.serve(t) | |
| 58 | 58 | res := runCLI(t, envFor(url), "", "version").ok(t) | |
| 59 | 59 | assert.Contains(t, res.stdout, "v0.4.0") | |
| @@ -61,7 +61,7 @@ func TestVersionWarnsAboutMissingFeatures(t *testing.T) { | |||
| 61 | 61 | assert.NotContains(t, res.stderr, "repo.update") | |
| 62 | 62 | ||
| 63 | 63 | 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) | |
| 65 | 65 | assert.Contains(t, res.stderr, "events.resume", "the warning also goes to stderr with --json") | |
| 66 | 66 | ||
| 67 | 67 | f = versionFake() | |
M internal/app/cli_watch.go +25 -3
| @@ -34,6 +34,10 @@ type jsonEvent struct { | |||
| 34 | 34 | // State and FailureClass are set for a mirror_failed event. | |
| 35 | 35 | State string `json:"state,omitempty"` | |
| 36 | 36 | 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"` | |
| 37 | 41 | } | |
| 38 | 42 | ||
| 39 | 43 | type jsonRefUpdate struct { | |
| @@ -53,6 +57,8 @@ const ( | |||
| 53 | 57 | eventMROpened = "merge_request_opened" | |
| 54 | 58 | eventMRMerged = "merge_request_merged" | |
| 55 | 59 | eventMRClosed = "merge_request_closed" | |
| 60 | + | eventMRAutoEnabled = "merge_request_auto_merge_enabled" | |
| 61 | + | eventMRAutoCancelled = "merge_request_auto_merge_cancelled" | |
| 56 | 62 | eventCommentAdded = "comment_added" | |
| 57 | 63 | eventCommentEdited = "comment_edited" | |
| 58 | 64 | eventCommentDeleted = "comment_deleted" | |
| @@ -87,6 +93,14 @@ func toJSONEvent(ev *ermite.Event, baseURL string) (jsonEvent, bool) { | |||
| 87 | 93 | case *ermite.Event_MergeRequestMerged: | |
| 88 | 94 | m := toJSONMergeRequest(k.MergeRequestMerged.GetMergeRequest(), baseURL) | |
| 89 | 95 | 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() | |
| 90 | 104 | case *ermite.Event_MergeRequestClosed: | |
| 91 | 105 | m := toJSONMergeRequest(k.MergeRequestClosed.GetMergeRequest(), baseURL) | |
| 92 | 106 | out.Type, out.Repo, out.MergeRequest = eventMRClosed, m.Repo, &m | |
| @@ -140,6 +154,13 @@ func eventLine(e jsonEvent) string { | |||
| 140 | 154 | detail = e.Repo + " " + strings.Join(parts, ", ") | |
| 141 | 155 | case eventMROpened, eventMRMerged, eventMRClosed: | |
| 142 | 156 | 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) | |
| 143 | 164 | case eventCommentAdded, eventCommentEdited, eventCommentDeleted, eventCommentResolved: | |
| 144 | 165 | detail = fmt.Sprintf("%s!%d by %s", e.Repo, e.Number, e.Comment.Author) | |
| 145 | 166 | case eventMRMergeability: | |
| @@ -198,18 +219,19 @@ func newWatchCmd(env *cliEnv) *cobra.Command { | |||
| 198 | 219 | Use: "watch [repo]", | |
| 199 | 220 | Short: "Print repository events as they happen", | |
| 200 | 221 | 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" + | |
| 202 | 223 | "repository you can see, or only for [repo]. It runs until interrupted. When the connection drops it\n" + | |
| 203 | 224 | "reconnects with a growing delay and resumes after the last event it printed; if the server no longer\n" + | |
| 204 | 225 | "has that event it prints a gap event: reload what you display. Connection notices go to standard\n" + | |
| 205 | 226 | "error.\n\n" + | |
| 206 | 227 | "--json prints one JSON object per line (NDJSON). Fields: id, time (RFC 3339 UTC), type\n" + | |
| 207 | 228 | "(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" + | |
| 209 | 231 | "and, depending on the type, repo (name),\n" + | |
| 210 | 232 | "repository (the repository fields of `repo show --json`), ref_updates (array of ref, old_id,\n" + | |
| 211 | 233 | "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" + | |
| 213 | 235 | "event types are skipped.", | |
| 214 | 236 | Args: cobra.MaximumNArgs(1), | |
| 215 | 237 | ValidArgsFunction: completeRepoNames(env), | |
M internal/app/cli_watch_test.go +55 -1
| @@ -95,7 +95,7 @@ func TestWatchNDJSON(t *testing.T) { | |||
| 95 | 95 | assert.True(t, json.Valid([]byte(sc.Text())), "one JSON value per line: %s", sc.Text()) | |
| 96 | 96 | } | |
| 97 | 97 | 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]) | |
| 99 | 99 | assert.JSONEq(t, `{"id":"3","time":"2026-10-10T12:00:00Z","type":"pushed","repo":"alpha","ref_updates":[ | |
| 100 | 100 | {"ref":"refs/heads/main","old_id":"1111111111111111","new_id":"2222222222222222"}, | |
| 101 | 101 | {"ref":"refs/heads/new","old_id":"","new_id":"3333333333333333"}, | |
| @@ -283,3 +283,57 @@ func TestMirrorWatchEvents(t *testing.T) { | |||
| 283 | 283 | assert.Contains(t, eventLine(recovered), "mirror_recovered alpha") | |
| 284 | 284 | assert.NotContains(t, eventLine(recovered), " \n") | |
| 285 | 285 | } | |
| 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 { | |||
| 143 | 143 | SigningKey *usecase.SigningKey | |
| 144 | 144 | // Mergeability keeps the mergeability of the open merge requests up to date. | |
| 145 | 145 | Mergeability *usecase.Mergeability | |
| 146 | + | AutoMerge *usecase.AutoMerge | |
| 146 | 147 | // Signatures are the trusted keys and the commit signature checks | |
| 147 | 148 | // (ADR 0030). | |
| 148 | 149 | Signatures *usecase.Signatures | |
| @@ -370,6 +371,14 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e | |||
| 370 | 371 | Committer: committer, Bus: f.Bus, Clock: clock, Maint: f.maint, | |
| 371 | 372 | CI: f.CI, Checks: f.CI, PullRequests: f.CI, RefTips: store.CI(), Mergeability: f.Mergeability, | |
| 372 | 373 | }) | |
| 374 | + | f.AutoMerge = usecase.NewAutoMerge(ctx, usecase.AutoMergeConfig{ | |
| 375 | + | MRs: f.MergeRequests, Store: store.MergeRequests(), Meta: store, Git: git, | |
| 376 | + | Checks: f.CI, Activity: f.CI, Tokens: store.Tokens(), Clock: clock, | |
| 377 | + | }) | |
| 378 | + | f.MergeRequests.WithAutoMerge(f.AutoMerge) | |
| 379 | + | f.CI.SetAutoMergeTrigger(f.AutoMerge) | |
| 380 | + | f.Repos.WithAutoMerge(f.AutoMerge).WithMergeGate(f.MergeRequests) | |
| 381 | + | f.Mergeability.WithAutoMerge(f.AutoMerge) | |
| 373 | 382 | f.Mergeability.Start() | |
| 374 | 383 | f.MergeRequests.WithVerifier(f.Signatures).WithMirror(f.Mirrors) | |
| 375 | 384 | f.Issues = usecase.NewIssues(usecase.IssuesConfig{ | |
| @@ -393,6 +402,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e | |||
| 393 | 402 | Meta: store, MergeRequests: store.MergeRequests(), Issues: store.Issues(), Pending: store.PendingIssues(), | |
| 394 | 403 | }) | |
| 395 | 404 | f.CI.SetPullGate(f.MergeRequests) | |
| 405 | + | f.AutoMerge.Start() | |
| 396 | 406 | log.Info().Str("fingerprint", signer.PublicKey().Fingerprint).Msg("commit signing key ready") | |
| 397 | 407 | ||
| 398 | 408 | f.Sessions, err = usecase.NewSessions(ctx, store.Sessions(), clock, cfg.PublicURL) | |
| @@ -545,6 +555,9 @@ func (f *forge) Close(ctx context.Context) { | |||
| 545 | 555 | if f.hooks != nil { | |
| 546 | 556 | f.hooks.Close(ctx) | |
| 547 | 557 | } | |
| 558 | + | if f.AutoMerge != nil { | |
| 559 | + | f.AutoMerge.Close(ctx) | |
| 560 | + | } | |
| 548 | 561 | if f.MergeRequests != nil { | |
| 549 | 562 | f.MergeRequests.Wait(ctx) // its side effects use CI and Mergeability | |
| 550 | 563 | } | |
M internal/domain/event.go +28 -0
| @@ -131,11 +131,39 @@ type MergeRequestMerged struct { | |||
| 131 | 131 | At time.Time | |
| 132 | 132 | Visibility Visibility | |
| 133 | 133 | CreatedAt time.Time | |
| 134 | + | // Auto says that auto-merge made the merge. | |
| 135 | + | Auto bool | |
| 134 | 136 | } | |
| 135 | 137 | ||
| 136 | 138 | // EventKind implements Event. | |
| 137 | 139 | func (MergeRequestMerged) EventKind() string { return "mr.merged" } | |
| 138 | 140 | ||
| 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 | + | ||
| 139 | 167 | // MergeRequestClosed is published when a merge request was closed without | |
| 140 | 168 | // merging. | |
| 141 | 169 | type MergeRequestClosed struct { | |
M internal/domain/event_test.go +7 -0
| @@ -52,3 +52,10 @@ func TestEventKinds(t *testing.T) { | |||
| 52 | 52 | assert.Equal(t, "repo.pushed", domain.PushEvent{}.EventKind()) | |
| 53 | 53 | assert.Equal(t, "gap", domain.EventGap{}.EventKind()) | |
| 54 | 54 | } | |
| 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 { | |||
| 50 | 50 | FailingChecksBlock bool | |
| 51 | 51 | // Issues says who may write issues; the empty mode means IssuesOff. | |
| 52 | 52 | 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 | |
| 53 | 56 | } | |
| 54 | 57 | ||
| 55 | 58 | // DefaultRepoSettings are the settings of a new repository: every mode, and | |
| @@ -139,11 +142,29 @@ type MergeRequest struct { | |||
| 139 | 142 | MergeMode MergeMode | |
| 140 | 143 | // CommentSeq is the id of the last comment of the request. | |
| 141 | 144 | 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 | |
| 142 | 149 | // Version is set by the store on read: Update fails with ErrConflict when | |
| 143 | 150 | // the stored request changed since. Opaque to everyone else. | |
| 144 | 151 | Version uint64 | |
| 145 | 152 | } | |
| 146 | 153 | ||
| 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 | + | ||
| 147 | 168 | // draftPrefixes mark a draft at the start of a title, compared without regard | |
| 148 | 169 | // to case, as Forgejo and Gitea do. | |
| 149 | 170 | var draftPrefixes = []string{"wip:", "[wip]"} | |
M internal/domain/mergerequest_test.go +6 -0
| @@ -330,3 +330,9 @@ func TestMergeabilityAllowsAndIsFor(t *testing.T) { | |||
| 330 | 330 | assert.False(t, m.IsFor(oid(t, 'a'), oid(t, 'c'))) | |
| 331 | 331 | assert.False(t, domain.Mergeability{}.IsFor(domain.ObjectID{}, domain.ObjectID{})) | |
| 332 | 332 | } | |
| 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 { | |||
| 367 | 367 | WhileOpen(ctx context.Context, repo domain.RepoName, number int64, fn func() error) error | |
| 368 | 368 | } | |
| 369 | 369 | ||
| 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 | + | ||
| 370 | 382 | // PullGit is what the refs/pull/<n>/{head,merge} of the merge requests need | |
| 371 | 383 | // from Git. Every method returns domain.ErrNotFound when the repository has no | |
| 372 | 384 | // directory. | |
| @@ -896,3 +908,11 @@ type JobSettings struct { | |||
| 896 | 908 | type CIJobSettings interface { | |
| 897 | 909 | Resolve(ctx context.Context, req JobSettingsRequest) (JobSettings, error) | |
| 898 | 910 | } | |
| 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 { | |||
| 51 | 51 | FailingChecksBlock *bool | |
| 52 | 52 | // Issues replaces the issues mode. | |
| 53 | 53 | 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 | |
| 54 | 65 | } | |
| 55 | 66 | ||
| 56 | 67 | // 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 | |||
| 132 | 132 | return _c | |
| 133 | 133 | } | |
| 134 | 134 | ||
| 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 | + | ||
| 135 | 213 | // Close provides a mock function for the type MergeRequestService | |
| 136 | 214 | func (_mock *MergeRequestService) Close(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.MergeRequest, error) { | |
| 137 | 215 | ret := _mock.Called(ctx, p, name, number) | |
| @@ -618,6 +696,90 @@ func (_c *MergeRequestService_EditComment_Call) RunAndReturn(run func(ctx contex | |||
| 618 | 696 | return _c | |
| 619 | 697 | } | |
| 620 | 698 | ||
| 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 | + | ||
| 621 | 783 | // Get provides a mock function for the type MergeRequestService | |
| 622 | 784 | func (_mock *MergeRequestService) Get(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, opts ports.GetMergeRequestOptions) (ports.MergeRequestDetail, error) { | |
| 623 | 785 | ret := _mock.Called(ctx, p, name, number, opts) | |
M internal/ports/ports.go +7 -0
| @@ -437,6 +437,13 @@ type MergeGit interface { | |||
| 437 | 437 | CountBehind(ctx context.Context, repo domain.RepoName, head, target domain.ObjectID, limit int) (n int, err error) | |
| 438 | 438 | } | |
| 439 | 439 | ||
| 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 | + | ||
| 440 | 447 | // MergeabilityTrigger queues mergeability checks. It only queues the work and | |
| 441 | 448 | // returns. | |
| 442 | 449 | type MergeabilityTrigger interface { | |
M internal/ports/services.go +5 -0
| @@ -125,6 +125,11 @@ type MergeRequestService interface { | |||
| 125 | 125 | Merge(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params MergeParams) (MergeResult, error) | |
| 126 | 126 | // MarkReady takes a draft out of draft: its title loses its "WIP:" prefix. | |
| 127 | 127 | 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) | |
| 128 | 133 | // CountOpen is the number of open merge requests of a repository the | |
| 129 | 134 | // principal can read. It reads no Git: the tab of every page shows it. | |
| 130 | 135 | 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 ( | |||
| 53 | 53 | // Git, Diff, Bus, Clock, Owner and PublicURL are required (the config supplies | |
| 54 | 54 | // the defaults of Owner and Retention). | |
| 55 | 55 | type CIConfig struct { | |
| 56 | - | Store ports.CIStore | |
| 57 | - | Logs ports.CILogStore | |
| 56 | + | AutoMerge ports.AutoMergeTrigger | |
| 57 | + | Store ports.CIStore | |
| 58 | + | Logs ports.CILogStore | |
| 58 | 59 | // Summaries keeps the job summaries, deleted with the logs of their run. | |
| 59 | 60 | Summaries ports.CISummaryStore | |
| 60 | 61 | Meta ports.RepoMetaStore | |
| @@ -266,6 +267,9 @@ type CI struct { | |||
| 266 | 267 | pending notifier // woken when a job may be ready or a runner was removed | |
| 267 | 268 | flows *wfCache | |
| 268 | 269 | ||
| 270 | + | pushes map[domain.RepoName]int | |
| 271 | + | pulls map[pullKey]int | |
| 272 | + | autoMerge ports.AutoMergeTrigger | |
| 269 | 273 | mu sync.Mutex | |
| 270 | 274 | heartbeats map[string]time.Time // job reference -> last heartbeat | |
| 271 | 275 | seen map[string]time.Time // runner id -> last seen | |
| @@ -311,6 +315,7 @@ func NewCI(ctx context.Context, cfg CIConfig) *CI { | |||
| 311 | 315 | cfg = cfg.withDefaults() | |
| 312 | 316 | run, stop := context.WithCancel(context.WithoutCancel(ctx)) | |
| 313 | 317 | return &CI{ | |
| 318 | + | pushes: map[domain.RepoName]int{}, pulls: map[pullKey]int{}, autoMerge: cfg.AutoMerge, | |
| 314 | 319 | 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), | |
| 315 | 320 | ctx: run, stop: stop, flows: newWfCache(64), remoteSlots: make(chan struct{}, cfg.RemoteConcurrency), remoteCache: newRefsCache(), | |
| 316 | 321 | heartbeats: map[string]time.Time{}, seen: map[string]time.Time{}, polls: map[string]pollRecord{}, | |
| @@ -343,6 +348,15 @@ func (c *CI) Close(_ context.Context) { | |||
| 343 | 348 | c.mu.Unlock() | |
| 344 | 349 | c.stop() | |
| 345 | 350 | 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 | + | } | |
| 346 | 360 | } | |
| 347 | 361 | ||
| 348 | 362 | func (c *CI) now() time.Time { return c.cfg.Clock.Now().UTC() } | |
| @@ -560,6 +574,7 @@ func (rs *runState) flush(ctx context.Context) error { | |||
| 560 | 574 | } | |
| 561 | 575 | if completing { | |
| 562 | 576 | c.onRunEvent(ctx, rs.run, runCompleted) | |
| 577 | + | c.kickAutoMerge(ctx, rs.run.Repo) | |
| 563 | 578 | } | |
| 564 | 579 | return nil | |
| 565 | 580 | } | |
| @@ -740,3 +755,47 @@ func isHex40(s string) bool { | |||
| 740 | 755 | } | |
| 741 | 756 | return strings.Trim(s, "0123456789abcdef") == "" | |
| 742 | 757 | } | |
| 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. | |||
| 271 | 271 | if repo.Name == runRepo { | |
| 272 | 272 | return true, nil | |
| 273 | 273 | } | |
| 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 { | |
| 276 | 285 | case domain.CIPusherRoot: | |
| 277 | - | p = domain.Root() | |
| 286 | + | return domain.Root(), true, nil | |
| 278 | 287 | case domain.CIPusherOwner: | |
| 279 | - | p = domain.Owner() | |
| 288 | + | return domain.Owner(), true, nil | |
| 280 | 289 | case domain.CIPusherToken: | |
| 281 | - | if c.cfg.Tokens == nil { | |
| 282 | - | return false, nil | |
| 290 | + | if tokens == nil { | |
| 291 | + | return domain.Anonymous(), false, nil | |
| 283 | 292 | } | |
| 284 | - | tok, err := c.cfg.Tokens.Get(ctx, by.pusher.TokenID) | |
| 293 | + | tok, err := tokens.Get(ctx, by.TokenID) | |
| 285 | 294 | if errors.Is(err, domain.ErrNotFound) { | |
| 286 | - | return false, nil | |
| 295 | + | return domain.Anonymous(), false, nil | |
| 287 | 296 | } | |
| 288 | 297 | if err != nil { | |
| 289 | - | return false, err | |
| 298 | + | return domain.Anonymous(), false, err | |
| 290 | 299 | } | |
| 291 | - | if tok.Expired(c.now()) { | |
| 292 | - | return false, nil | |
| 300 | + | if tok.Expired(now) { | |
| 301 | + | return domain.Anonymous(), false, nil | |
| 293 | 302 | } | |
| 294 | - | p = domain.TokenPrincipal(tok) | |
| 303 | + | return domain.TokenPrincipal(tok), true, nil | |
| 295 | 304 | default: | |
| 296 | - | p = domain.Anonymous() | |
| 305 | + | return domain.Anonymous(), false, nil | |
| 297 | 306 | } | |
| 298 | - | return canRead(p, repo), nil | |
| 299 | 307 | } | |
| 300 | 308 | ||
| 301 | 309 | // 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 | |||
| 5 | 5 | import ( | |
| 6 | 6 | "bytes" | |
| 7 | 7 | "context" | |
| 8 | + | "encoding/json" | |
| 8 | 9 | "fmt" | |
| 9 | 10 | "math" | |
| 10 | 11 | "strconv" | |
| 11 | 12 | "strings" | |
| 12 | 13 | "testing" | |
| 13 | 14 | ||
| 15 | + | "bnema.dev/ermite/internal/ports/mocks" | |
| 14 | 16 | "github.com/bnema/zerowrap" | |
| 15 | 17 | "github.com/stretchr/testify/assert" | |
| 18 | + | "github.com/stretchr/testify/mock" | |
| 16 | 19 | "github.com/stretchr/testify/require" | |
| 17 | 20 | ||
| 18 | 21 | "bnema.dev/ermite-actions/workflow" | |
| @@ -109,3 +112,21 @@ func TestCheckRunID(t *testing.T) { | |||
| 109 | 112 | assert.Contains(t, logs.String(), `"job_index":`+strconv.Itoa(c.index), name) | |
| 110 | 113 | } | |
| 111 | 114 | } | |
| 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 | |||
| 74 | 74 | for k, v := range map[string]any{ | |
| 75 | 75 | "network_count": 0, "subscribers_count": 0, "allow_squash_merge": repo.Settings.Allows(domain.MergeModeSquash), | |
| 76 | 76 | "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, | |
| 78 | 78 | "use_squash_pr_title_as_default": false, "has_pull_requests": true, | |
| 79 | 79 | } { | |
| 80 | 80 | obj[k] = v | |
M internal/usecase/ci_pull.go +6 -0
| @@ -482,6 +482,9 @@ func (c *CI) wakePull() { | |||
| 482 | 482 | func (c *CI) queuePullEvent(e pullEvent) { | |
| 483 | 483 | c.mu.Lock() | |
| 484 | 484 | closed := c.closed | |
| 485 | + | if !closed { | |
| 486 | + | c.pulls[pullKey{repo: e.repo, number: e.number}]++ | |
| 487 | + | } | |
| 485 | 488 | c.mu.Unlock() | |
| 486 | 489 | if closed { | |
| 487 | 490 | return | |
| @@ -489,6 +492,7 @@ func (c *CI) queuePullEvent(e pullEvent) { | |||
| 489 | 492 | select { | |
| 490 | 493 | case c.pull.events <- e: | |
| 491 | 494 | default: | |
| 495 | + | c.finishPull(pullKey{repo: e.repo, number: e.number}) | |
| 492 | 496 | 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") | |
| 493 | 497 | } | |
| 494 | 498 | } | |
| @@ -614,6 +618,8 @@ func (c *CI) pullLoop() { | |||
| 614 | 618 | return | |
| 615 | 619 | case e := <-c.pull.events: | |
| 616 | 620 | 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) | |
| 617 | 623 | } | |
| 618 | 624 | } | |
| 619 | 625 | } | |
M internal/usecase/ci_trigger.go +41 -6
| @@ -12,6 +12,7 @@ import ( | |||
| 12 | 12 | "slices" | |
| 13 | 13 | "strconv" | |
| 14 | 14 | "strings" | |
| 15 | + | "sync/atomic" | |
| 15 | 16 | "time" | |
| 16 | 17 | ||
| 17 | 18 | "github.com/bnema/zerowrap" | |
| @@ -33,6 +34,7 @@ const ( | |||
| 33 | 34 | ||
| 34 | 35 | // ciTrigger is a push waiting to be planned. | |
| 35 | 36 | type ciTrigger struct { | |
| 37 | + | done func() | |
| 36 | 38 | repo domain.RepoName | |
| 37 | 39 | updates []domain.RefUpdate | |
| 38 | 40 | actor string | |
| @@ -54,6 +56,9 @@ type ciTrigger struct { | |||
| 54 | 56 | // with GITHUB_TOKEN start no run): that is what stops a workflow that pushes | |
| 55 | 57 | // from running itself again. | |
| 56 | 58 | 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 | + | ||
| 57 | 62 | // Whoever pushed, the workflow files of the default branch may have changed. | |
| 58 | 63 | // (Who pushed was recorded before the refs moved: Pushes.Check.) | |
| 59 | 64 | c.markSchedulesDirty(repo, updates) | |
| @@ -63,9 +68,10 @@ func (c *CI) OnPush(ctx context.Context, repo domain.RepoName, updates []domain. | |||
| 63 | 68 | if pusher.ViaJobToken() { | |
| 64 | 69 | log := zerowrap.FromCtx(ctx) | |
| 65 | 70 | log.Debug().Str("repo", repo.String()).Int("refs", len(updates)).Msg("push by a job token starts no run") | |
| 71 | + | done() | |
| 66 | 72 | return | |
| 67 | 73 | } | |
| 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) | |
| 69 | 75 | } | |
| 70 | 76 | ||
| 71 | 77 | // 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. | |||
| 75 | 81 | // merging. For the same reason the run is trusted (gets secrets) only when the | |
| 76 | 82 | // merger is: the displayed actor is not the one that decides. | |
| 77 | 83 | 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 | + | ||
| 78 | 87 | c.markSchedulesDirty(repo, updates) | |
| 79 | 88 | // The merge moved a target branch: the test merges of the other requests | |
| 80 | 89 | // 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 | |||
| 83 | 92 | if merger.ViaJobToken() { | |
| 84 | 93 | log := zerowrap.FromCtx(ctx) | |
| 85 | 94 | log.Debug().Str("repo", repo.String()).Int("refs", len(updates)).Msg("merge by a job token starts no run") | |
| 95 | + | done() | |
| 86 | 96 | return | |
| 87 | 97 | } | |
| 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) | |
| 89 | 99 | } | |
| 90 | 100 | ||
| 91 | 101 | 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()) { | |
| 92 | 121 | var kept []domain.RefUpdate | |
| 93 | 122 | for _, u := range updates { | |
| 94 | 123 | 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 | |||
| 96 | 125 | } | |
| 97 | 126 | } | |
| 98 | 127 | if len(kept) == 0 { | |
| 128 | + | done() | |
| 99 | 129 | return | |
| 100 | 130 | } | |
| 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)} | |
| 102 | 132 | c.mu.Lock() | |
| 103 | - | closed := c.closed | |
| 104 | - | c.mu.Unlock() | |
| 105 | - | if closed { | |
| 133 | + | if c.closed { | |
| 134 | + | c.mu.Unlock() | |
| 135 | + | done() | |
| 106 | 136 | return | |
| 107 | 137 | } | |
| 108 | 138 | select { | |
| 109 | 139 | case c.queue <- t: | |
| 140 | + | c.mu.Unlock() | |
| 110 | 141 | default: | |
| 142 | + | c.mu.Unlock() | |
| 143 | + | done() | |
| 111 | 144 | 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") | |
| 112 | 145 | } | |
| 146 | + | ||
| 113 | 147 | } | |
| 114 | 148 | ||
| 115 | 149 | func (c *CI) triggerLoop() { | |
| @@ -127,6 +161,7 @@ func (c *CI) triggerLoop() { | |||
| 127 | 161 | // planPush plans one push; a panic or an error is logged and never escapes. | |
| 128 | 162 | func (c *CI) planPush(t ciTrigger) { | |
| 129 | 163 | ctx := zerowrap.WithCtx(c.ctx, t.log) | |
| 164 | + | defer t.done() | |
| 130 | 165 | defer func() { | |
| 131 | 166 | if r := recover(); r != nil { | |
| 132 | 167 | 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 { | |||
| 182 | 182 | return repoSnapshot(ev.Visibility, ev.CreatedAt) | |
| 183 | 183 | case domain.MergeRequestClosed: | |
| 184 | 184 | 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) | |
| 185 | 189 | case domain.MergeRequestReady: | |
| 186 | 190 | return repoSnapshot(ev.Visibility, ev.CreatedAt) | |
| 187 | 191 | case domain.CommentAdded: | |
| @@ -535,6 +539,10 @@ func (e *Events) deliver(ctx context.Context, w *watch, be bufferedEvent, send f | |||
| 535 | 539 | return e.deliverRepo(ctx, w, ev.MergeRequest.Repo, false, be, out, send) | |
| 536 | 540 | case domain.MergeRequestClosed: | |
| 537 | 541 | return e.deliverRepo(ctx, w, ev.MergeRequest.Repo, false, be, out, send) | |
| 542 | + | case domain.MergeRequestAutoMergeEnabled: | |
| 543 | + | return e.deliverRepo(ctx, w, ev.MergeRequest.Repo, false, be, out, send) | |
| 544 | + | case domain.MergeRequestAutoMergeCancelled: | |
| 545 | + | return e.deliverRepo(ctx, w, ev.MergeRequest.Repo, false, be, out, send) | |
| 538 | 546 | case domain.MergeRequestReady: | |
| 539 | 547 | return e.deliverRepo(ctx, w, ev.MergeRequest.Repo, false, be, out, send) | |
| 540 | 548 | case domain.CommentAdded: | |
| @@ -663,6 +671,7 @@ func isItemEvent(ev domain.Event) bool { | |||
| 663 | 671 | case domain.IssueReferenced: | |
| 664 | 672 | return false // back-reference refreshes do not change the inbox | |
| 665 | 673 | case domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, | |
| 674 | + | domain.MergeRequestAutoMergeEnabled, domain.MergeRequestAutoMergeCancelled, | |
| 666 | 675 | domain.MergeRequestReady, domain.MergeRequestMergeability, domain.CommentAdded, | |
| 667 | 676 | domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved: | |
| 668 | 677 | 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 | |||
| 220 | 220 | ||
| 221 | 221 | // WindowLimits exposes windowLimits to the tests. | |
| 222 | 222 | 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/issue_links_test.go +27 -0
| @@ -11,8 +11,10 @@ import ( | |||
| 11 | 11 | ||
| 12 | 12 | "bnema.dev/ermite/internal/domain" | |
| 13 | 13 | "bnema.dev/ermite/internal/ports" | |
| 14 | + | "bnema.dev/ermite/internal/ports/mocks" | |
| 14 | 15 | "bnema.dev/ermite/internal/usecase" | |
| 15 | 16 | "github.com/stretchr/testify/assert" | |
| 17 | + | "github.com/stretchr/testify/mock" | |
| 16 | 18 | "github.com/stretchr/testify/require" | |
| 17 | 19 | ) | |
| 18 | 20 | ||
| @@ -233,3 +235,28 @@ func TestIssueLinksMergeReindexesRequestsWhoseSourceMoved(t *testing.T) { | |||
| 233 | 235 | require.Len(t, after, 1) | |
| 234 | 236 | assert.Equal(t, issue.Number, after[0].Issue) | |
| 235 | 237 | } | |
| 238 | + | ||
| 239 | + | func TestAutoMergeClosesLinkedIssue(t *testing.T) { | |
| 240 | + | r, _, issues := linkedRig(t) | |
| 241 | + | issue, err := issues.Create(r.ctx, root, r.repo, ports.CreateIssueParams{Title: "bug"}) | |
| 242 | + | require.NoError(t, err) | |
| 243 | + | r.feature("feat") | |
| 244 | + | mr, err := r.mrs.Create(r.ctx, root, r.repo, ports.CreateMergeRequestParams{Title: "fix", Source: "feat", Description: fmt.Sprintf("closes #%d", issue.Number)}) | |
| 245 | + | require.NoError(t, err) | |
| 246 | + | _, err = r.repos.Update(r.ctx, root, r.repo, ports.UpdateRepoParams{AllowAutoMerge: ptr(true)}) | |
| 247 | + | require.NoError(t, err) | |
| 248 | + | checks := mocks.NewCIChecksReader(t) | |
| 249 | + | r.mrs.SetChecks(checks) | |
| 250 | + | enableAuto(t, r, mr) | |
| 251 | + | checks.EXPECT().CommitChecks(mock.Anything, r.repo, r.head().String()).Return([]domain.CICheck{{Status: domain.CICompleted, Conclusion: domain.CISuccess}}, nil).Twice() | |
| 252 | + | a := autoEvaluator(r, checks, nil, nil) | |
| 253 | + | defer a.Close(r.ctx) | |
| 254 | + | require.True(t, a.Evaluate(r.repo)) | |
| 255 | + | r.mrs.Wait(r.ctx) | |
| 256 | + | merged := storedAuto(t, r, mr) | |
| 257 | + | require.Equal(t, domain.MRStateMerged, merged.State) | |
| 258 | + | require.True(t, merged.AutoMerged) | |
| 259 | + | got, err := issues.Get(r.ctx, root, r.repo, issue.Number) | |
| 260 | + | require.NoError(t, err) | |
| 261 | + | assert.False(t, got.Issue.IsOpen()) | |
| 262 | + | } | |
M internal/usecase/mergeability.go +18 -6
| @@ -35,12 +35,13 @@ func mergeBackoff(attempt int) time.Duration { | |||
| 35 | 35 | ||
| 36 | 36 | // MergeabilityConfig holds the dependencies of the repository reconciler. | |
| 37 | 37 | type MergeabilityConfig struct { | |
| 38 | - | Store ports.MergeRequestStore | |
| 39 | - | Meta ports.RepoMetaStore | |
| 40 | - | Git ports.MergeGit | |
| 41 | - | Bus ports.EventBus | |
| 42 | - | Clock ports.Clock | |
| 43 | - | Workers int // defaults to 2 | |
| 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 | |
| 44 | 45 | // After is injectable to drive backoff without wall-clock waits in tests. | |
| 45 | 46 | After func(time.Duration) <-chan time.Time | |
| 46 | 47 | } | |
| @@ -235,6 +236,7 @@ func (s *Mergeability) reconcile(repo domain.RepoName) bool { | |||
| 235 | 236 | return false | |
| 236 | 237 | } | |
| 237 | 238 | ok := true | |
| 239 | + | changed := false | |
| 238 | 240 | for _, mr := range mrs { | |
| 239 | 241 | if ctx.Err() != nil { | |
| 240 | 242 | return true | |
| @@ -275,6 +277,10 @@ func (s *Mergeability) reconcile(repo domain.RepoName) bool { | |||
| 275 | 277 | Repo: repo, Number: mr.Number, Mergeability: res, At: res.CheckedAt, | |
| 276 | 278 | Visibility: meta.Visibility, CreatedAt: meta.CreatedAt, | |
| 277 | 279 | }) | |
| 280 | + | changed = true | |
| 281 | + | } | |
| 282 | + | if changed && s.cfg.AutoMerge != nil { | |
| 283 | + | s.cfg.AutoMerge.OnRepoChanged(ctx, repo) | |
| 278 | 284 | } | |
| 279 | 285 | return ok | |
| 280 | 286 | } | |
| @@ -465,3 +471,9 @@ func (s *Mergeability) compute(ctx context.Context, repo domain.RepoName, number | |||
| 465 | 471 | m.Behind, m.BehindCapped = domain.BoundBehind(n) | |
| 466 | 472 | return m, nil | |
| 467 | 473 | } | |
| 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
| @@ -59,7 +59,8 @@ type MergeRequests struct { | |||
| 59 | 59 | tips ports.CIRefTipStore // nil: who merges is not recorded | |
| 60 | 60 | verifier ports.CommitVerifier // nil: commits are not checked (ADR 0030) | |
| 61 | 61 | mergeab MergeabilityHook // nil: no mergeability | |
| 62 | - | mirror ports.MirrorTrigger // nil: no outbound mirror | |
| 62 | + | autoMerge ports.AutoMergeTrigger | |
| 63 | + | mirror ports.MirrorTrigger // nil: no outbound mirror | |
| 63 | 64 | ||
| 64 | 65 | mu sync.Mutex | |
| 65 | 66 | locks map[domain.RepoName]chan struct{} | |
| @@ -150,6 +151,9 @@ func (m *MergeRequests) Wait(ctx context.Context) { | |||
| 150 | 151 | // lockRepo takes the merge lock of a repository, waiting until ctx ends. The | |
| 151 | 152 | // merges of one repository, and the creation of its merge requests, run one at | |
| 152 | 153 | // a time. The returned function releases the lock. | |
| 154 | + | // Lock order is merge lock → store (meta.Mutate / kv). Nothing holding a | |
| 155 | + | // store transaction or a CI lock may take this lock. MergeRequests never | |
| 156 | + | // calls Repos.Update. | |
| 153 | 157 | func (m *MergeRequests) lockRepo(ctx context.Context, name domain.RepoName) (unlock func(), err error) { | |
| 154 | 158 | m.mu.Lock() | |
| 155 | 159 | ch, ok := m.locks[name] | |
| @@ -917,6 +921,8 @@ func (m *MergeRequests) Close(ctx context.Context, p domain.Principal, name doma | |||
| 917 | 921 | if err != nil { | |
| 918 | 922 | return domain.MergeRequest{}, err | |
| 919 | 923 | } | |
| 924 | + | hadAutoMerge := mr.AutoMerge != nil | |
| 925 | + | mr.AutoMerge = nil | |
| 920 | 926 | mr.State, mr.BaseID, mr.UpdatedAt = domain.MRStateClosed, base, m.clock.Now().UTC() | |
| 921 | 927 | err = m.store.Update(ctx, mr) | |
| 922 | 928 | if errors.Is(err, domain.ErrConflict) { | |
| @@ -930,6 +936,9 @@ func (m *MergeRequests) Close(ctx context.Context, p domain.Principal, name doma | |||
| 930 | 936 | Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("merge request closed") | |
| 931 | 937 | recordActivity(ctx, m.meta, repo.Name, mr.UpdatedAt) | |
| 932 | 938 | m.bus.Publish(ctx, domain.MergeRequestClosed{MergeRequest: mr, At: mr.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt}) | |
| 939 | + | if hadAutoMerge { | |
| 940 | + | m.bus.Publish(ctx, domain.MergeRequestAutoMergeCancelled{MergeRequest: mr, Reason: "closed", At: mr.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt}) | |
| 941 | + | } | |
| 933 | 942 | if m.links != nil { | |
| 934 | 943 | m.links.OnMergeRequestSettled(ctx, repo, mr) | |
| 935 | 944 | } | |
| @@ -1010,7 +1019,7 @@ func (m *MergeRequests) MarkRead(ctx context.Context, p domain.Principal, name d | |||
| 1010 | 1019 | // the CI and mergeability hooks. Wait waits for them. SourceDeleted therefore | |
| 1011 | 1020 | // only says that the deletion was requested. | |
| 1012 | 1021 | func (m *MergeRequests) Merge(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params ports.MergeParams) (ports.MergeResult, error) { | |
| 1013 | - | mr, err := m.merge(ctx, p, name, number, params) | |
| 1022 | + | mr, err := m.mergeAs(ctx, p, name, number, params, mergeOpts{}) | |
| 1014 | 1023 | return ports.MergeResult{MergeRequest: mr, SourceDeleted: err == nil && params.DeleteSource}, err | |
| 1015 | 1024 | } | |
| 1016 | 1025 | ||
| @@ -1052,9 +1061,23 @@ func (m *MergeRequests) checkMode(ctx context.Context, mr domain.MergeRequest, m | |||
| 1052 | 1061 | return res.Tree, nil | |
| 1053 | 1062 | } | |
| 1054 | 1063 | ||
| 1055 | - | func (m *MergeRequests) merge(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params ports.MergeParams) (domain.MergeRequest, error) { | |
| 1056 | - | if _, err := m.writable(ctx, p, name, domain.ScopeMRMerge); err != nil { | |
| 1057 | - | return domain.MergeRequest{}, err | |
| 1064 | + | type mergeOpts struct { | |
| 1065 | + | auto *autoMergeOpts | |
| 1066 | + | } | |
| 1067 | + | ||
| 1068 | + | type autoMergeOpts struct { | |
| 1069 | + | intent *domain.AutoMerge | |
| 1070 | + | tokens ports.TokenStore | |
| 1071 | + | activity ports.CIActivity | |
| 1072 | + | } | |
| 1073 | + | ||
| 1074 | + | var errAutoMergeWait = errors.New("auto-merge is waiting for checks") | |
| 1075 | + | ||
| 1076 | + | func (m *MergeRequests) mergeAs(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params ports.MergeParams, opts mergeOpts) (domain.MergeRequest, error) { | |
| 1077 | + | if opts.auto == nil { | |
| 1078 | + | if _, err := m.writable(ctx, p, name, domain.ScopeMRMerge); err != nil { | |
| 1079 | + | return domain.MergeRequest{}, err | |
| 1080 | + | } | |
| 1058 | 1081 | } | |
| 1059 | 1082 | mode := params.Mode | |
| 1060 | 1083 | if mode == "" { | |
| @@ -1083,6 +1106,15 @@ func (m *MergeRequests) merge(ctx context.Context, p domain.Principal, name doma | |||
| 1083 | 1106 | if err != nil { | |
| 1084 | 1107 | return domain.MergeRequest{}, err | |
| 1085 | 1108 | } | |
| 1109 | + | if opts.auto != nil && (mr.AutoMerge == nil || opts.auto.intent == nil || *mr.AutoMerge != *opts.auto.intent) { | |
| 1110 | + | return domain.MergeRequest{}, errAutoMergeWait | |
| 1111 | + | } | |
| 1112 | + | if opts.auto != nil { | |
| 1113 | + | p, err = enablerCanMerge(ctx, opts.auto.tokens, opts.auto.intent, mr, m.clock.Now()) | |
| 1114 | + | if err != nil { | |
| 1115 | + | return domain.MergeRequest{}, err | |
| 1116 | + | } | |
| 1117 | + | } | |
| 1086 | 1118 | // A merge moves the target and may delete the source: a token limited to | |
| 1087 | 1119 | // branch prefixes may do so only within them. | |
| 1088 | 1120 | if !p.AllowsRef("refs/heads/" + mr.Target) { | |
| @@ -1134,14 +1166,33 @@ func (m *MergeRequests) merge(ctx context.Context, p domain.Principal, name doma | |||
| 1134 | 1166 | } else if behind { | |
| 1135 | 1167 | return domain.MergeRequest{}, fmt.Errorf("%w: nothing to merge, %q already contains %s", domain.ErrPreconditionFailed, mr.Target, head) | |
| 1136 | 1168 | } | |
| 1169 | + | if opts.auto != nil && opts.auto.activity != nil && !opts.auto.activity.Settled(ctx, name, number) { | |
| 1170 | + | return domain.MergeRequest{}, errAutoMergeWait | |
| 1171 | + | } | |
| 1137 | 1172 | tree, err := m.checkMode(ctx, mr, mode, tip, head) | |
| 1138 | 1173 | if err != nil { | |
| 1174 | + | if opts.auto != nil && errors.Is(err, domain.ErrPreconditionFailed) { | |
| 1175 | + | return domain.MergeRequest{}, errAutoMergeWait | |
| 1176 | + | } | |
| 1139 | 1177 | return domain.MergeRequest{}, err | |
| 1140 | 1178 | } | |
| 1141 | - | if settings.FailingChecksBlock { | |
| 1142 | - | if err := m.checksAllow(ctx, name, head); err != nil { | |
| 1179 | + | if opts.auto != nil && !settings.AllowAutoMerge { | |
| 1180 | + | return domain.MergeRequest{}, fmt.Errorf("%w: auto-merge is off in %s", domain.ErrPreconditionFailed, name) | |
| 1181 | + | } | |
| 1182 | + | if settings.FailingChecksBlock || opts.auto != nil { | |
| 1183 | + | checks, err := m.readMergeChecks(ctx, name, head) | |
| 1184 | + | if err == nil { | |
| 1185 | + | err = checksPolicy(checks) | |
| 1186 | + | } | |
| 1187 | + | if err != nil { | |
| 1188 | + | if opts.auto != nil && errors.Is(err, domain.ErrPreconditionFailed) { | |
| 1189 | + | return domain.MergeRequest{}, errAutoMergeWait | |
| 1190 | + | } | |
| 1143 | 1191 | return domain.MergeRequest{}, err | |
| 1144 | 1192 | } | |
| 1193 | + | if opts.auto != nil && !hasSuccessfulCheck(checks) { | |
| 1194 | + | return domain.MergeRequest{}, errAutoMergeWait | |
| 1195 | + | } | |
| 1145 | 1196 | } | |
| 1146 | 1197 | if params.DeleteSource { | |
| 1147 | 1198 | if err := m.checkDeletable(ctx, name, mr); err != nil { | |
| @@ -1154,7 +1205,7 @@ func (m *MergeRequests) merge(ctx context.Context, p domain.Principal, name doma | |||
| 1154 | 1205 | return domain.MergeRequest{}, err | |
| 1155 | 1206 | } | |
| 1156 | 1207 | m.recordPush(ctx, name) // the target moved, whatever happens next | |
| 1157 | - | mr, err = m.markMerged(ctx, name, number, mode, tip, head, merged) | |
| 1208 | + | mr, err = m.markMerged(ctx, name, number, mode, tip, head, merged, opts.auto != nil) | |
| 1158 | 1209 | if err != nil { | |
| 1159 | 1210 | return domain.MergeRequest{}, err | |
| 1160 | 1211 | } | |
| @@ -1199,7 +1250,7 @@ func (m *MergeRequests) afterMerge(ctx context.Context, p domain.Principal, repo | |||
| 1199 | 1250 | } | |
| 1200 | 1251 | at := mr.UpdatedAt | |
| 1201 | 1252 | recordActivity(ctx, m.meta, repo.Name, at) | |
| 1202 | - | m.bus.Publish(ctx, domain.MergeRequestMerged{MergeRequest: mr, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt}) | |
| 1253 | + | m.bus.Publish(ctx, domain.MergeRequestMerged{MergeRequest: mr, Auto: mr.AutoMerged, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt}) | |
| 1203 | 1254 | m.bus.Publish(ctx, domain.PushEvent{ | |
| 1204 | 1255 | Repo: name, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt, Updates: updates, | |
| 1205 | 1256 | }) | |
| @@ -1272,21 +1323,34 @@ func (m *MergeRequests) WhileOpen(ctx context.Context, name domain.RepoName, num | |||
| 1272 | 1323 | return fn() | |
| 1273 | 1324 | } | |
| 1274 | 1325 | ||
| 1275 | - | // checksAllow is the merge rule of FailingChecksBlock: every check of the | |
| 1276 | - | // head must have ended as success, skipped or neutral. A check still queued or | |
| 1277 | - | // running blocks too (the merge could land code that then fails), and a head | |
| 1278 | - | // without any check merges. | |
| 1279 | - | func (m *MergeRequests) checksAllow(ctx context.Context, name domain.RepoName, head domain.ObjectID) error { | |
| 1326 | + | // readMergeChecks reads one snapshot, classifying an incomplete result as a | |
| 1327 | + | // failed precondition rather than treating it as a passing set of checks. | |
| 1328 | + | func (m *MergeRequests) readMergeChecks(ctx context.Context, name domain.RepoName, head domain.ObjectID) ([]domain.CICheck, error) { | |
| 1280 | 1329 | if m.checks == nil { | |
| 1281 | - | return nil | |
| 1330 | + | return nil, nil | |
| 1282 | 1331 | } | |
| 1283 | 1332 | checks, err := m.checks.CommitChecks(ctx, name, head.String()) | |
| 1284 | 1333 | if errors.Is(err, domain.ErrResourceExhausted) { | |
| 1285 | - | return fmt.Errorf("%w: too many checks to tell whether they passed: %v", domain.ErrPreconditionFailed, err) | |
| 1334 | + | return nil, fmt.Errorf("%w: too many checks to tell whether they passed: %v", domain.ErrPreconditionFailed, err) | |
| 1286 | 1335 | } | |
| 1287 | 1336 | if err != nil { | |
| 1288 | - | return fmt.Errorf("read the checks of %s: %w", head, err) | |
| 1337 | + | return nil, fmt.Errorf("read the checks of %s: %w", head, err) | |
| 1289 | 1338 | } | |
| 1339 | + | return checks, nil | |
| 1340 | + | } | |
| 1341 | + | ||
| 1342 | + | func hasSuccessfulCheck(checks []domain.CICheck) bool { | |
| 1343 | + | for _, check := range checks { | |
| 1344 | + | if check.Status == domain.CICompleted && check.Conclusion == domain.CISuccess { | |
| 1345 | + | return true | |
| 1346 | + | } | |
| 1347 | + | } | |
| 1348 | + | return false | |
| 1349 | + | } | |
| 1350 | + | ||
| 1351 | + | // checksPolicy requires every check to have ended as success, skipped or | |
| 1352 | + | // neutral. Queued and running checks block too; an empty set is allowed. | |
| 1353 | + | func checksPolicy(checks []domain.CICheck) error { | |
| 1290 | 1354 | var failing, pending []string | |
| 1291 | 1355 | for _, c := range checks { | |
| 1292 | 1356 | switch { | |
| @@ -1392,7 +1456,7 @@ func (m *MergeRequests) moveTarget(ctx context.Context, mr domain.MergeRequest, | |||
| 1392 | 1456 | ||
| 1393 | 1457 | // markMerged records the merge. The target already moved, so a write that | |
| 1394 | 1458 | // loses a race (a comment added meanwhile) is repeated on the fresh request. | |
| 1395 | - | func (m *MergeRequests) markMerged(ctx context.Context, name domain.RepoName, number int64, mode domain.MergeMode, base, head, merged domain.ObjectID) (domain.MergeRequest, error) { | |
| 1459 | + | 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) { | |
| 1396 | 1460 | // The merge must be recorded even when the caller gives up. | |
| 1397 | 1461 | ctx = context.WithoutCancel(ctx) | |
| 1398 | 1462 | for range maxWriteRetries { | |
| @@ -1400,6 +1464,7 @@ func (m *MergeRequests) markMerged(ctx context.Context, name domain.RepoName, nu | |||
| 1400 | 1464 | if err != nil { | |
| 1401 | 1465 | return domain.MergeRequest{}, fmt.Errorf("record the merge of !%d in %q: %w", number, name, err) | |
| 1402 | 1466 | } | |
| 1467 | + | mr.AutoMerge, mr.AutoMerged = nil, auto | |
| 1403 | 1468 | mr.State, mr.HeadID, mr.BaseID, mr.MergedCommitID, mr.MergeMode = domain.MRStateMerged, head, base, merged, mode | |
| 1404 | 1469 | mr.UpdatedAt = m.clock.Now().UTC() | |
| 1405 | 1470 | err = m.store.Update(ctx, mr) | |
| @@ -1550,3 +1615,13 @@ func (m *MergeRequests) PushSummary(ctx context.Context, p domain.Principal, rep | |||
| 1550 | 1615 | } | |
| 1551 | 1616 | return ports.PushSummary{Count: len(commits), More: more}, nil | |
| 1552 | 1617 | } | |
| 1618 | + | ||
| 1619 | + | // WithMergeLock serializes fn with repository merges and closes. | |
| 1620 | + | func (m *MergeRequests) WithMergeLock(ctx context.Context, repo domain.RepoName, fn func() error) error { | |
| 1621 | + | unlock, err := m.lockRepo(ctx, repo) | |
| 1622 | + | if err != nil { | |
| 1623 | + | return err | |
| 1624 | + | } | |
| 1625 | + | defer unlock() | |
| 1626 | + | return fn() | |
| 1627 | + | } | |
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 ( | |||
| 17 | 17 | ||
| 18 | 18 | // Repos are the repository use cases. | |
| 19 | 19 | 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 | |
| 26 | 28 | } | |
| 27 | 29 | ||
| 28 | 30 | var _ ports.RepoService = (*Repos)(nil) | |
| @@ -233,3 +235,9 @@ func (r *Repos) Reconcile(ctx context.Context) (ReconcileReport, error) { | |||
| 233 | 235 | } | |
| 234 | 236 | return rep, nil | |
| 235 | 237 | } | |
| 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 | |||
| 62 | 62 | } | |
| 63 | 63 | before := repo | |
| 64 | 64 | 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 { | |
| 66 | 66 | return domain.Repo{}, fmt.Errorf("%w: nothing to update", domain.ErrInvalid) | |
| 67 | 67 | } | |
| 68 | 68 | if params.AllowedMergeModes != nil { | |
| @@ -116,11 +116,23 @@ func (r *Repos) Update(ctx context.Context, p domain.Principal, name domain.Repo | |||
| 116 | 116 | // branch was checked above, so the remaining causes are a vanished | |
| 117 | 117 | // repository or an I/O failure. | |
| 118 | 118 | 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 { | |
| 121 | 130 | return domain.Repo{}, err | |
| 122 | 131 | } | |
| 123 | 132 | metaDone = true | |
| 133 | + | if r.autoMerge != nil { | |
| 134 | + | r.autoMerge.OnRepoChanged(ctx, name) | |
| 135 | + | } | |
| 124 | 136 | } | |
| 125 | 137 | var headErr error | |
| 126 | 138 | if params.DefaultBranch != nil { | |
| @@ -142,7 +154,7 @@ func (r *Repos) Update(ctx context.Context, p domain.Principal, name domain.Repo | |||
| 142 | 154 | Bool("topics", params.Topics != nil). | |
| 143 | 155 | Bool("visibility", params.Visibility != nil). | |
| 144 | 156 | 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). | |
| 146 | 158 | Str("principal", p.Label()).Str("token_id", p.TokenID()). | |
| 147 | 159 | Msg("repository updated") | |
| 148 | 160 | r.bus.Publish(ctx, domain.RepoUpdated{ | |
| @@ -175,6 +187,9 @@ func (r *Repos) updateMeta(ctx context.Context, name domain.RepoName, params por | |||
| 175 | 187 | if params.FailingChecksBlock != nil { | |
| 176 | 188 | repo.Settings.FailingChecksBlock = *params.FailingChecksBlock | |
| 177 | 189 | } | |
| 190 | + | if params.AllowAutoMerge != nil { | |
| 191 | + | repo.Settings.AllowAutoMerge = *params.AllowAutoMerge | |
| 192 | + | } | |
| 178 | 193 | if params.Issues != nil { | |
| 179 | 194 | repo.Settings.Issues = *params.Issues | |
| 180 | 195 | } | |
M internal/usecase/repos_write_test.go +17 -0
| @@ -15,6 +15,7 @@ import ( | |||
| 15 | 15 | ||
| 16 | 16 | "bnema.dev/ermite/internal/domain" | |
| 17 | 17 | "bnema.dev/ermite/internal/ports" | |
| 18 | + | "bnema.dev/ermite/internal/ports/mocks" | |
| 18 | 19 | "bnema.dev/ermite/internal/usecase" | |
| 19 | 20 | ) | |
| 20 | 21 | ||
| @@ -27,6 +28,22 @@ func TestUpdate(t *testing.T) { | |||
| 27 | 28 | priv := domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, Description: "old"} | |
| 28 | 29 | admin := tokenPrincipal(domain.ScopeRepoAdmin, "tools") | |
| 29 | 30 | ||
| 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 | + | ||
| 30 | 47 | t.Run("description and visibility are stored and RepoUpdated is published", func(t *testing.T) { | |
| 31 | 48 | t.Parallel() | |
| 32 | 49 | f := newFixture(t) | |
M proto/ermite/storage/merge_request.proto +15 -0
| @@ -30,6 +30,7 @@ message RepoSettings { | |||
| 30 | 30 | // {fast-forward, squash} was the old default and gains MERGE_MODE_MERGE on | |
| 31 | 31 | // read. | |
| 32 | 32 | bool merge_modes_v2 = 4; | |
| 33 | + | bool allow_auto_merge = 5; | |
| 33 | 34 | } | |
| 34 | 35 | ||
| 35 | 36 | // IssuesMode says who may write issues in a repository. | |
| @@ -78,6 +79,20 @@ message MergeRequest { | |||
| 78 | 79 | MergeMode merge_mode = 13; | |
| 79 | 80 | // Last comment id handed out for this request. | |
| 80 | 81 | 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; | |
| 81 | 96 | } | |
| 82 | 97 | ||
| 83 | 98 | // 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 | |||
| 11 | 11 | | `repo list [--json]` | none | repositories you can see | | |
| 12 | 12 | | `repo show <name> [--json]` | none | | | |
| 13 | 13 | | `repo create <name> [--description TEXT] [--visibility public]` | `repo:write` | private by default | | |
| 14 | - | | `repo edit <name> [--description TEXT] [--visibility V] [--default-branch B] [--merge-modes fast-forward,squash,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 | | |
| 15 | 15 | | `repo delete <name> [--yes]` | `repo:admin` | | | |
| 16 | 16 | ||
| 17 | - | Repository JSON: `name`, `description`, `visibility`, `default_branch`, `created_at`, `pushed_at` (null before the first push), `merge_modes`, `failing_checks_block`. | |
| 17 | + | Repository JSON: `name`, `description`, `visibility`, `default_branch`, `created_at`, `pushed_at` (null before the first push), `merge_modes`, `failing_checks_block`, `allow_auto_merge`. | |
| 18 | 18 | ||
| 19 | 19 | ## Tokens | |
| 20 | 20 | ||
| @@ -62,6 +62,7 @@ A token can never grant more than the token that creates it. `--repo` (repeatabl | |||
| 62 | 62 | | `mr delete-comment <repo> <n> <comment-id>` | `mr:write` | author only; the text is lost, the verdict is kept | | |
| 63 | 63 | | `mr resolve <repo> <n> <comment-id> [--undo]` | `mr:write` | on the thread root, covers its replies; doing it twice is not an error | | |
| 64 | 64 | | `mr merge <repo> <n> --head <full-id> [--mode fast-forward\|squash\|merge] [--delete-branch]` | `mr:merge` | refused if the branch moved, if `fast-forward` and the target is not an ancestor of the head (behind: use `squash` or `merge`), or if `squash`/`merge` conflict; squash and merge commits are signed by the server key (`signing-key show`); answers once the target moved; `--delete-branch` deletes the source after the merge (refused for the default branch or a branch another open request uses; JSON adds `source_branch_deleted`) | | |
| 65 | + | | `mr 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` | | |
| 65 | 66 | | `mr close <repo> <n>` | `mr:write` | a closed merge request cannot be reopened | | |
| 66 | 67 | ||
| 67 | 68 | `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 ` | |||
| 106 | 107 | | `repo_created`, `repo_updated`, `repo_deleted` | `repo`, `repository` | | |
| 107 | 108 | | `pushed` | `repo`, `ref_updates` (`ref`, `old_id`, `new_id`; an empty id means created or deleted) | | |
| 108 | 109 | | `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 | | |
| 109 | 111 | | `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 | | |
| 110 | 112 | | `comment_added`, `comment_edited`, `comment_deleted`, `comment_resolved` | `repo`, `number`, `comment` (a verdict note has `verdict` set) | | |
| 111 | 113 | | `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, | |||
| 39 | 39 | 4. End a review with a verdict (needs `mr:review`): `--approve` or `--request-changes`, alone or with a message. Verdicts are informative and cannot be edited; a new verdict replaces the previous one. | |
| 40 | 40 | 5. Merge only when asked: `ermite mr merge <repo> <n> --head <head_id>`, with `--mode squash` for one commit or `--mode merge` for a merge commit (both signed by the server's key, see `ermite signing-key show`) and `--delete-branch` to delete the source branch afterwards. | |
| 41 | 41 | ||
| 42 | + | 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 | + | ||
| 42 | 44 | The merge is refused when the branch moved since `head_id` (review again) or, for `fast-forward`, when the target is not an ancestor of the head (rebase and push again, or use `squash`/`merge`), or when `squash`/`merge` conflict. `ermite mr show` prints the `mergeability` line (up to date, behind by N, conflict with paths). The repository may allow only some merge modes. | |
| 43 | 45 | ||
| 44 | 46 | ### Act on a review | |