Merge 843ebed5ec107f59b860cc5e66bbcaf8e64a8425 into d8e7d5b03a01f5f16f8d49f12a7e132355b40f06
88 files · +6356 -1026
M README.md +1 -1
| @@ -122,7 +122,7 @@ ermite mr show myrepo 1 --since-last-read --mark-read | |||
| 122 | 122 | **5. You merge** (or click Merge in the web interface): | |
| 123 | 123 | ||
| 124 | 124 | ```sh | |
| 125 | - | ermite mr merge myrepo 1 --head <head id> --squash --delete-branch | |
| 125 | + | ermite mr merge myrepo 1 --head <head id> --mode squash --delete-branch | |
| 126 | 126 | ``` | |
| 127 | 127 | ||
| 128 | 128 | Give the agent the [`skills/ermite`](skills/ermite/SKILL.md) skill so it knows the CLI. Rules of agent tokens: [docs/tokens.md](docs/tokens.md). | |
M docs/cli.md +5 -4
| @@ -54,10 +54,10 @@ ermite repo delete <name> [--yes] | |||
| 54 | 54 | ermite token create <label> [--preset cli|agent|ci|runner] [--scope …] [--repo …] [--branch-prefix …] [--expires 30d] | |
| 55 | 55 | ermite token list | revoke <id> | |
| 56 | 56 | ||
| 57 | - | ermite mr create <repo> --source <branch> --title … | list <repo> | show <repo> <n> [--diff] | |
| 57 | + | ermite mr create <repo> --source <branch> --title … | list <repo> | show <repo> <n> [--diff] # show prints the mergeability: up to date, behind, conflict | |
| 58 | 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> [--squash] [--delete-branch] | close <repo> <n> | |
| 60 | + | ermite mr merge <repo> <n> --head <id> [--mode fast-forward|squash|merge] [--delete-branch] | close <repo> <n> | |
| 61 | 61 | ||
| 62 | 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> | |
| @@ -78,7 +78,7 @@ ermite environment create|list|delete … --repo <repo> | |||
| 78 | 78 | ermite web login # one-time sign-in link (60 s) | |
| 79 | 79 | ermite web logout --all # sign every browser out | |
| 80 | 80 | ermite web passkey list | remove <id> | |
| 81 | - | ermite signing-key show # public key of squash commits | |
| 81 | + | ermite signing-key show # public key of squash and merge commits (server-signed) | |
| 82 | 82 | ermite oidc rotate-key | |
| 83 | 83 | ermite watch [repo] [--json] # follow live events | |
| 84 | 84 | ermite version # client and server versions | |
| @@ -90,7 +90,8 @@ Issues need a server with the `issues` feature and `ermite repo edit <repo> --is | |||
| 90 | 90 | ## Output | |
| 91 | 91 | ||
| 92 | 92 | - `--json` prints stable field names (listed in each `--help`) on `repo list|show|create|edit`, `issue create|list|show|comment|close|reopen`, `token create|list`, `run list|view|cancel|rerun`, `runner list` and `version`. | |
| 93 | - | - `watch --json` prints one object per line. | |
| 93 | + | - `watch --json` prints one object per line. `merge_request_mergeability` events carry `mergeability` (`state`, `behind`, `conflict_paths`…) and print as `repo!n mergeability: behind by 2`. | |
| 94 | + | - `mr merge --mode squash|merge` creates a commit signed by the server's own key (generated on first start); the output names its fingerprint, `ermite signing-key show` prints the key. Every mode is allowed by default; `repo edit --merge-modes fast-forward,squash` disables `merge`. | |
| 94 | 95 | - Errors are one line on stderr (`ermite: …`) with exit status 1. | |
| 95 | 96 | - Shell completion completes repository names from the server. | |
| 96 | 97 | ||
M docs/commit-signatures.md +1 -1
| @@ -36,5 +36,5 @@ ermite signing-key trust remove <id> | |||
| 36 | 36 | ||
| 37 | 37 | - A key may sign for 1 to 10 emails; up to 100 keys. | |
| 38 | 38 | - The id is the fingerprint: `SHA256:…` for SSH, the primary key fingerprint for OpenPGP. | |
| 39 | - | - The server's own key (`ermite signing-key show`), which signs squash merges, is always trusted for the committer email and cannot be removed. | |
| 39 | + | - The server's own key (`ermite signing-key show`), which signs squash and merge commits (generated automatically on first start), is always trusted for the committer email and cannot be removed. | |
| 40 | 40 | - Private keys are refused. | |
M docs/configuration.md +2 -2
| @@ -34,8 +34,8 @@ ermite serve | |||
| 34 | 34 | |---|---|---| | |
| 35 | 35 | | `ERMITE_GIT` | `git` | git 2.45 or newer | | |
| 36 | 36 | | `ERMITE_GIT_READER` | `ermite-git` | `ermite-git` reads with the [ermite-git](https://github.com/bnema/ermite-git) library where it is faster, and falls back to `git`. `cli` uses `git` for every read: set it and restart if a read looks wrong | | |
| 37 | - | | `ERMITE_COMMITTER_NAME` | `ermite` | committer of squash merges | | |
| 38 | - | | `ERMITE_COMMITTER_EMAIL` | `ermite@<public host>` | committer email of squash merges | | |
| 37 | + | | `ERMITE_COMMITTER_NAME` | `ermite` | committer of squash and merge commits | | |
| 38 | + | | `ERMITE_COMMITTER_EMAIL` | `ermite@<public host>` | committer email of squash and merge commits | | |
| 39 | 39 | ||
| 40 | 40 | ## CI | |
| 41 | 41 | ||
M docs/merge-requests.md +45 -7
| @@ -5,26 +5,64 @@ A merge request proposes a branch for a target (the default branch unless set). | |||
| 5 | 5 | ```sh | |
| 6 | 6 | ermite mr create myrepo --source feature --title "Add feature" | |
| 7 | 7 | ermite mr show myrepo 1 --diff | |
| 8 | - | ermite mr merge myrepo 1 --head <commit> [--squash] [--delete-branch] | |
| 8 | + | ermite mr merge myrepo 1 --head <commit> [--mode fast-forward|squash|merge] [--delete-branch] | |
| 9 | 9 | ``` | |
| 10 | 10 | ||
| 11 | 11 | `ermite mr create` and `ermite mr close` print a confirmation followed by the full | |
| 12 | 12 | merge request web URL on its own line. With `--json`, merge request objects | |
| 13 | 13 | (create, show, list, close and merge) include `url` alongside the other fields. | |
| 14 | 14 | ||
| 15 | - | - **Fast-forward** moves the target. **Squash** writes one commit signed by the server's SSH key. | |
| 16 | - | - The merge is refused when the branch moved since the reviewed `--head`, or when the target is not an ancestor of it. | |
| 15 | + | ## Merge modes | |
| 16 | + | ||
| 17 | + | | Mode | Result | Needs | | |
| 18 | + | |---|---|---| | |
| 19 | + | | `fast-forward` (default) | the target moves to the head, no commit | the target is an ancestor of the head | | |
| 20 | + | | `squash` | one new commit with the merged tree, the target tip as only parent | the branch merges without conflicts | | |
| 21 | + | | `merge` | a merge commit with two parents, the target tip and the head | the branch merges without conflicts | | |
| 22 | + | ||
| 23 | + | - The server never rebases. A branch behind its target merges with `squash` or `merge`, or is rebased and pushed again for a fast-forward. | |
| 24 | + | - The server checks the mode again when it merges, whatever the badge showed, and refuses with the reason (the target moved, a conflict with the paths). | |
| 25 | + | - The merge is also refused when the branch moved since the reviewed `--head`. | |
| 17 | 26 | - With `failing_checks_block` on (default), the merge is refused while a check of the head failed or is still running. | |
| 18 | - | - `--delete-branch` deletes the source branch, unless it is the default branch or another open request uses it. | |
| 19 | - | - `ermite repo edit --merge-modes` limits the modes per repository. | |
| 27 | + | - The merge answers as soon as the target has moved. `--delete-branch` then deletes the source branch right after, in the background (unless it is the default branch or another open request uses it); if the branch moved meanwhile, it is kept. | |
| 28 | + | - Every mode is allowed by default, including in repositories that had only `fast-forward` and `squash` before `merge` existed: those gain `merge`. Disable a mode per repository with `ermite repo edit <repo> --merge-modes fast-forward,squash` (list the modes to keep). | |
| 20 | 29 | - Scopes: `mr:write` to open and comment, `mr:review` to give a verdict, `mr:merge` to merge. | |
| 21 | 30 | ||
| 22 | - | Verify squash commits with the `allowed_signers` line from `ermite signing-key show`: | |
| 31 | + | ## Signed commits | |
| 32 | + | ||
| 33 | + | Squash and merge commits are signed by ermite's own key. The key is generated automatically the first time the server starts, belongs to the server, and its committer identity is `ERMITE_COMMITTER_NAME` / `ERMITE_COMMITTER_EMAIL`. `ermite signing-key show` prints its fingerprint and the `allowed_signers` line; use it to verify locally, or register the public key on GitHub or Forgejo. | |
| 34 | + | ||
| 35 | + | ```sh | |
| 36 | + | ermite signing-key show --allowed-signers > allowed_signers | |
| 37 | + | git -c gpg.format=ssh -c gpg.ssh.allowedSignersFile=allowed_signers verify-commit <commit> | |
| 38 | + | ``` | |
| 39 | + | ||
| 40 | + | The merge box of the web page and the output of `mr merge --mode squash|merge` repeat this with the fingerprint. | |
| 41 | + | ||
| 42 | + | ## Mergeability | |
| 43 | + | ||
| 44 | + | The server checks in the background whether each open request merges with its target, and again whenever the source or the target moves. `mr show` prints it; the web merge box shows it as a live badge; `ermite watch` reports each change. | |
| 45 | + | ||
| 46 | + | | State | Meaning | Modes possible | | |
| 47 | + | |---|---|---| | |
| 48 | + | | `checking` | no result yet for the current branches | none (wait) | | |
| 49 | + | | `up_to_date` | the target is an ancestor of the head | all | | |
| 50 | + | | `behind` | the head lacks `behind` commits of the target (`behind_capped`: at least 1000) but merges without conflicts | `squash`, `merge` | | |
| 51 | + | | `conflict` | the merge conflicts; up to 20 `conflict_paths` are listed (`conflict_paths_truncated` when there are more); no paths means no common history | none (rebase) | | |
| 23 | 52 | ||
| 24 | 53 | ```sh | |
| 25 | - | git verify-commit <commit> | |
| 54 | + | ermite mr show myrepo 1 | |
| 55 | + | # mergeability behind by 2, merges without conflicts (squash or merge commit; fast-forward needs a rebase) | |
| 26 | 56 | ``` | |
| 27 | 57 | ||
| 58 | + | In `mr show --json`, `mergeability` holds `state`, `behind`, `behind_capped`, `conflict_paths`, `conflict_paths_truncated`, `target_id` and `head_id`; it is `null` unless the request is open. `ermite watch` prints `merge_request_mergeability` events when the state changes (not on every push): | |
| 59 | + | ||
| 60 | + | ```text | |
| 61 | + | 2026-10-19T14:00:00Z merge_request_mergeability myrepo!1 mergeability: behind by 2 | |
| 62 | + | ``` | |
| 63 | + | ||
| 64 | + | In the web merge box a mode that is not possible is disabled with its reason, and the Merge button too when no mode is possible. A merge the server refuses (target moved, conflict, branch moved) shows the page again with the reason in the box. | |
| 65 | + | ||
| 28 | 66 | ## Notes | |
| 29 | 67 | ||
| 30 | 68 | A note is the only review object. It is published as soon as it is written: there is no draft and no submit step. | |
M gen/ermite/storage/merge_request.pb.go +216 -24 generated file: collapsed
M go.mod +1 -1
| @@ -13,7 +13,7 @@ tool ( | |||
| 13 | 13 | ||
| 14 | 14 | require ( | |
| 15 | 15 | bnema.dev/ermite-actions v0.2.0 | |
| 16 | - | bnema.dev/ermite-api v0.9.0 | |
| 16 | + | bnema.dev/ermite-api v0.10.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,15 +139,15 @@ 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.FeatureSigningKey, client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, client.FeatureCIWorkflows, client.FeatureCIArtifacts, client.FeatureCommitSignatures}, | |
| 142 | + | []string{client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureIssues, client.FeatureMergeability, client.FeatureSigningKey, client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, client.FeatureCIWorkflows, client.FeatureCIArtifacts, client.FeatureCommitSignatures}, | |
| 143 | 143 | client.MissingFeatures([]string{client.FeatureRepoUpdate, client.FeatureSession, client.FeaturePasskeys, "other"})) | |
| 144 | 144 | assert.Equal(t, []string{client.FeatureSigningKey}, client.MissingFeatures([]string{ | |
| 145 | - | client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureIssues, client.FeatureSession, | |
| 145 | + | client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureIssues, client.FeatureMergeability, client.FeatureSession, | |
| 146 | 146 | client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, client.FeatureCIWorkflows, | |
| 147 | 147 | client.FeatureCIArtifacts, client.FeaturePasskeys, client.FeatureCommitSignatures, | |
| 148 | 148 | })) | |
| 149 | 149 | assert.Equal(t, []string{client.FeaturePasskeys}, client.MissingFeatures([]string{ | |
| 150 | - | client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureIssues, client.FeatureSigningKey, client.FeatureSession, | |
| 150 | + | client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureIssues, client.FeatureMergeability, client.FeatureSigningKey, client.FeatureSession, | |
| 151 | 151 | client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, client.FeatureCIWorkflows, client.FeatureCIArtifacts, client.FeatureCommitSignatures, | |
| 152 | 152 | })) | |
| 153 | 153 | assert.Equal(t, client.UsedFeatures(), client.MissingFeatures(nil)) | |
M internal/adapters/client/features.go +4 -1
| @@ -54,11 +54,14 @@ const ( | |||
| 54 | 54 | // FeatureCommitSignatures is TrustedKeyService and Commit.verification | |
| 55 | 55 | // (`ermite signing-key trust`). | |
| 56 | 56 | FeatureCommitSignatures = "commit_signatures" | |
| 57 | + | // FeatureMergeability is MergeRequest.mergeability and the | |
| 58 | + | // merge_request_mergeability event. | |
| 59 | + | FeatureMergeability = "merge_requests.mergeability" | |
| 57 | 60 | ) | |
| 58 | 61 | ||
| 59 | 62 | // UsedFeatures lists the features the CLI uses, in the order they are reported. | |
| 60 | 63 | func UsedFeatures() []string { | |
| 61 | - | return []string{FeatureRepoUpdate, FeatureEventsResume, FeatureMergeRequests, FeatureMergeDeleteBranch, FeatureIssues, FeatureSigningKey, FeatureSession, FeatureTokenBranchPrefixes, FeatureCIRuns, FeatureCIRunners, FeatureCISecrets, FeatureCIDispatch, FeatureCIWorkflows, FeatureCIArtifacts, FeaturePasskeys, FeatureCommitSignatures} | |
| 64 | + | return []string{FeatureRepoUpdate, FeatureEventsResume, FeatureMergeRequests, FeatureMergeDeleteBranch, FeatureIssues, FeatureMergeability, FeatureSigningKey, FeatureSession, FeatureTokenBranchPrefixes, FeatureCIRuns, FeatureCIRunners, FeatureCISecrets, FeatureCIDispatch, FeatureCIWorkflows, FeatureCIArtifacts, FeaturePasskeys, FeatureCommitSignatures} | |
| 62 | 65 | } | |
| 63 | 66 | ||
| 64 | 67 | // MissingFeatures returns the features the CLI uses that have is lacking. | |
M internal/adapters/gitcli/git.go +12 -2
| @@ -131,6 +131,16 @@ func (g *Git) run(ctx context.Context, args ...string) (string, error) { | |||
| 131 | 131 | ||
| 132 | 132 | // runInput is run with the given standard input; nil means none. | |
| 133 | 133 | func (g *Git) runInput(ctx context.Context, stdin io.Reader, args ...string) (string, error) { | |
| 134 | + | out, err := g.runCapture(ctx, stdin, args...) | |
| 135 | + | if err != nil { | |
| 136 | + | return "", err | |
| 137 | + | } | |
| 138 | + | return out, nil | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | // runCapture is runInput that also returns the standard output of a failed | |
| 142 | + | // run. | |
| 143 | + | func (g *Git) runCapture(ctx context.Context, stdin io.Reader, args ...string) (string, error) { | |
| 134 | 144 | if _, ok := ctx.Deadline(); !ok { | |
| 135 | 145 | var cancel context.CancelFunc | |
| 136 | 146 | ctx, cancel = context.WithTimeout(ctx, commandTimeout) | |
| @@ -156,9 +166,9 @@ func (g *Git) runInput(ctx context.Context, stdin io.Reader, args ...string) (st | |||
| 156 | 166 | } | |
| 157 | 167 | var exit *exec.ExitError | |
| 158 | 168 | if errors.As(err, &exit) && msg != "" { | |
| 159 | - | return "", fmt.Errorf("git %s: %w: %s", subcommand(args), err, msg) | |
| 169 | + | return stdout.String(), fmt.Errorf("git %s: %w: %s", subcommand(args), err, msg) | |
| 160 | 170 | } | |
| 161 | - | return "", fmt.Errorf("git %s: %w", subcommand(args), err) | |
| 171 | + | return stdout.String(), fmt.Errorf("git %s: %w", subcommand(args), err) | |
| 162 | 172 | } | |
| 163 | 173 | return stdout.String(), nil | |
| 164 | 174 | } | |
M internal/adapters/gitcli/merge_test.go +83 -0
| @@ -4,6 +4,7 @@ package gitcli_test | |||
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "context" | |
| 7 | + | "fmt" | |
| 7 | 8 | "strings" | |
| 8 | 9 | "testing" | |
| 9 | 10 | ||
| @@ -231,3 +232,85 @@ func TestDeleteBranch(t *testing.T) { | |||
| 231 | 232 | err = g.DeleteBranch(ctx, name(t, "missing"), "main", m.mainTip) | |
| 232 | 233 | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| 233 | 234 | } | |
| 235 | + | ||
| 236 | + | func TestMergeTree(t *testing.T) { | |
| 237 | + | g, data := newGit(t) | |
| 238 | + | ctx := context.Background() | |
| 239 | + | m := newMergeFixture(t, data) | |
| 240 | + | t.Cleanup(func() { _ = g.Close(ctx) }) | |
| 241 | + | ||
| 242 | + | // Clean: main is an ancestor of feat. | |
| 243 | + | res, err := g.MergeTree(ctx, m.f.Name, m.mainTip, m.featTip) | |
| 244 | + | require.NoError(t, err) | |
| 245 | + | assert.True(t, res.Clean) | |
| 246 | + | assert.False(t, res.Tree.IsZero()) | |
| 247 | + | assert.Empty(t, res.ConflictPaths) | |
| 248 | + | ||
| 249 | + | // Conflict: both sides change a.txt and c.txt differently. | |
| 250 | + | m.f.Git("checkout", "--quiet", "-b", "clash", m.mainTip.String()) | |
| 251 | + | m.f.Write("a.txt", "one\nTWO\nthree\n") | |
| 252 | + | m.f.Write("c.txt", "other\n") | |
| 253 | + | clash := oidOf(t, m.f.Commit("clash")) | |
| 254 | + | res, err = g.MergeTree(ctx, m.f.Name, clash, m.featTip) | |
| 255 | + | require.NoError(t, err) | |
| 256 | + | assert.False(t, res.Clean) | |
| 257 | + | assert.True(t, res.Tree.IsZero()) | |
| 258 | + | assert.ElementsMatch(t, []string{"a.txt", "c.txt"}, res.ConflictPaths) | |
| 259 | + | assert.False(t, res.Truncated) | |
| 260 | + | ||
| 261 | + | // No common history: not clean, no paths. | |
| 262 | + | m.f.Git("checkout", "--quiet", "--orphan", "other") | |
| 263 | + | m.f.Git("rm", "-rf", "--quiet", ".") | |
| 264 | + | m.f.Write("o.txt", "other\n") | |
| 265 | + | other := oidOf(t, m.f.Commit("other root")) | |
| 266 | + | res, err = g.MergeTree(ctx, m.f.Name, other, m.featTip) | |
| 267 | + | require.NoError(t, err) | |
| 268 | + | assert.False(t, res.Clean) | |
| 269 | + | assert.Empty(t, res.ConflictPaths) | |
| 270 | + | ||
| 271 | + | // More conflicts than the cap. | |
| 272 | + | m.f.Git("checkout", "--quiet", "-b", "wide-a", m.a.String()) | |
| 273 | + | for i := 0; i < domain.MaxConflictPaths+5; i++ { | |
| 274 | + | m.f.Write(fmt.Sprintf("f%02d.txt", i), "base\n") | |
| 275 | + | } | |
| 276 | + | base := oidOf(t, m.f.Commit("many")) | |
| 277 | + | m.f.Git("checkout", "--quiet", "-b", "wide-b", base.String()) | |
| 278 | + | for i := 0; i < domain.MaxConflictPaths+5; i++ { | |
| 279 | + | m.f.Write(fmt.Sprintf("f%02d.txt", i), "b\n") | |
| 280 | + | } | |
| 281 | + | wb := oidOf(t, m.f.Commit("b side")) | |
| 282 | + | m.f.Git("checkout", "--quiet", "wide-a") | |
| 283 | + | for i := 0; i < domain.MaxConflictPaths+5; i++ { | |
| 284 | + | m.f.Write(fmt.Sprintf("f%02d.txt", i), "a\n") | |
| 285 | + | } | |
| 286 | + | wa := oidOf(t, m.f.Commit("a side")) | |
| 287 | + | res, err = g.MergeTree(ctx, m.f.Name, wa, wb) | |
| 288 | + | require.NoError(t, err) | |
| 289 | + | assert.False(t, res.Clean) | |
| 290 | + | assert.Len(t, res.ConflictPaths, domain.MaxConflictPaths+5, "the adapter returns every path") | |
| 291 | + | assert.False(t, res.Truncated, "bounding is the domain's") | |
| 292 | + | } | |
| 293 | + | ||
| 294 | + | func TestCountBehind(t *testing.T) { | |
| 295 | + | g, data := newGit(t) | |
| 296 | + | ctx := context.Background() | |
| 297 | + | m := newMergeFixture(t, data) | |
| 298 | + | t.Cleanup(func() { _ = g.Close(ctx) }) | |
| 299 | + | ||
| 300 | + | // main has b that a-based head lacks. | |
| 301 | + | n, err := g.CountBehind(ctx, m.f.Name, m.a, m.mainTip, 10) | |
| 302 | + | require.NoError(t, err) | |
| 303 | + | assert.Equal(t, 1, n) | |
| 304 | + | ||
| 305 | + | n, err = g.CountBehind(ctx, m.f.Name, m.featTip, m.mainTip, 10) | |
| 306 | + | require.NoError(t, err) | |
| 307 | + | assert.Equal(t, 0, n) | |
| 308 | + | ||
| 309 | + | // feat is 3 commits ahead of a: the raw count stops at the limit. | |
| 310 | + | n, err = g.CountBehind(ctx, m.f.Name, m.a, m.featTip, 2) | |
| 311 | + | require.NoError(t, err) | |
| 312 | + | assert.Equal(t, 2, n) | |
| 313 | + | n, err = g.CountBehind(ctx, m.f.Name, m.a, m.featTip, 4) | |
| 314 | + | require.NoError(t, err) | |
| 315 | + | assert.Equal(t, 3, n) | |
| 316 | + | } | |
A internal/adapters/gitcli/mergeability.go +87 -0
| @@ -0,0 +1,87 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package gitcli | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "errors" | |
| 8 | + | "fmt" | |
| 9 | + | "slices" | |
| 10 | + | "strconv" | |
| 11 | + | "strings" | |
| 12 | + | ||
| 13 | + | "bnema.dev/ermite/internal/domain" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | // MergeTree runs `git merge-tree --write-tree -z --name-only`, which merges in | |
| 17 | + | // the object database and touches no ref or index. It prints the tree of the | |
| 18 | + | // result, then on a conflict (exit 1) the NUL-separated conflicted paths. | |
| 19 | + | // Commits with no common history do not merge: not clean, without paths. | |
| 20 | + | func (g *Git) MergeTree(ctx context.Context, repo domain.RepoName, base, head domain.ObjectID) (domain.MergeTreeResult, error) { | |
| 21 | + | shared, err := g.sharesHistory(ctx, repo, base, head) | |
| 22 | + | if err != nil || !shared { | |
| 23 | + | return domain.MergeTreeResult{}, err | |
| 24 | + | } | |
| 25 | + | out, conflict, err := g.mergeTreeOutput(ctx, repo, base, head) | |
| 26 | + | if err != nil { | |
| 27 | + | return domain.MergeTreeResult{}, err | |
| 28 | + | } | |
| 29 | + | return parseMergeTree(out, conflict) | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | // mergeTreeOutput runs merge-tree and returns what it printed; conflict is its | |
| 33 | + | // exit status 1. | |
| 34 | + | func (g *Git) mergeTreeOutput(ctx context.Context, repo domain.RepoName, base, head domain.ObjectID) (out string, conflict bool, err error) { | |
| 35 | + | dir, err := g.repoDir(repo) | |
| 36 | + | if err != nil { | |
| 37 | + | return "", false, err | |
| 38 | + | } | |
| 39 | + | out, err = g.runCapture(ctx, nil, readArgs(dir, "merge-tree", "--write-tree", "-z", "--name-only", "--no-messages", "--end-of-options", base.String(), head.String())...) | |
| 40 | + | conflict = exitCode(err) == 1 | |
| 41 | + | if err != nil && !conflict { | |
| 42 | + | return "", false, fmt.Errorf("merge %s into %s in %q: %w", head, base, repo, err) | |
| 43 | + | } | |
| 44 | + | return out, conflict, nil | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | // sharesHistory reports whether the commits have a merge base. | |
| 48 | + | func (g *Git) sharesHistory(ctx context.Context, repo domain.RepoName, a, b domain.ObjectID) (bool, error) { | |
| 49 | + | _, err := g.MergeBase(ctx, repo, a, b) | |
| 50 | + | if errors.Is(err, domain.ErrNotFound) { | |
| 51 | + | return false, nil | |
| 52 | + | } | |
| 53 | + | return err == nil, err | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | // parseMergeTree reads the output of merge-tree: the tree, then on a conflict | |
| 57 | + | // the NUL-separated paths, all of them. | |
| 58 | + | func parseMergeTree(out string, conflict bool) (domain.MergeTreeResult, error) { | |
| 59 | + | fields := strings.Split(out, "\x00") | |
| 60 | + | tree, err := parseID(fields[0]) | |
| 61 | + | if !conflict { | |
| 62 | + | return domain.MergeTreeResult{Tree: tree, Clean: true}, err | |
| 63 | + | } | |
| 64 | + | paths := fields[1:] | |
| 65 | + | if i := slices.Index(paths, ""); i >= 0 { | |
| 66 | + | paths = paths[:i] | |
| 67 | + | } | |
| 68 | + | return domain.MergeTreeResult{ConflictPaths: paths}, nil | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | // CountBehind runs `git rev-list --count --max-count=<limit> <target> ^<head>` | |
| 72 | + | // and returns the count git printed. | |
| 73 | + | func (g *Git) CountBehind(ctx context.Context, repo domain.RepoName, head, target domain.ObjectID, limit int) (int, error) { | |
| 74 | + | dir, err := g.repoDir(repo) | |
| 75 | + | if err != nil { | |
| 76 | + | return 0, err | |
| 77 | + | } | |
| 78 | + | out, err := g.run(ctx, readArgs(dir, "rev-list", "--count", "--max-count="+strconv.Itoa(limit), target.String(), "^"+head.String(), "--")...) | |
| 79 | + | if err != nil { | |
| 80 | + | return 0, fmt.Errorf("count behind %s in %q: %w", target, repo, err) | |
| 81 | + | } | |
| 82 | + | n, err := strconv.Atoi(strings.TrimSpace(out)) | |
| 83 | + | if err != nil { | |
| 84 | + | return 0, fmt.Errorf("git printed %q: %w", strings.TrimSpace(out), err) | |
| 85 | + | } | |
| 86 | + | return n, nil | |
| 87 | + | } | |
M internal/adapters/kvstore/ci_tip.go +29 -0
| @@ -89,3 +89,32 @@ func (c *CI) DeleteRefTip(ctx context.Context, repo domain.RepoName, ref string) | |||
| 89 | 89 | } | |
| 90 | 90 | return nil | |
| 91 | 91 | } | |
| 92 | + | ||
| 93 | + | // DeleteRefTipIf removes the record of a ref only when it still names sha. | |
| 94 | + | func (c *CI) DeleteRefTipIf(ctx context.Context, repo domain.RepoName, ref, sha string) error { | |
| 95 | + | for range updateAttemptsTip { | |
| 96 | + | it, ok := c.db.Get(ctx, refTipKey(repo, ref)) | |
| 97 | + | if !ok { | |
| 98 | + | return nil | |
| 99 | + | } | |
| 100 | + | var pb storagepb.CIRefTip | |
| 101 | + | if err := proto.Unmarshal(it.Value, &pb); err != nil { | |
| 102 | + | return fmt.Errorf("decode ref tip: %w", err) | |
| 103 | + | } | |
| 104 | + | if pb.GetSha() != sha { | |
| 105 | + | return nil // a newer push owns the record | |
| 106 | + | } | |
| 107 | + | var b kv.Batch | |
| 108 | + | b.RequireVersion(refTipKey(repo, ref), it.Version) | |
| 109 | + | b.Delete(refTipKey(repo, ref)) | |
| 110 | + | _, err := c.db.Apply(ctx, &b) | |
| 111 | + | if errors.Is(err, kv.ErrConflict) { | |
| 112 | + | continue | |
| 113 | + | } | |
| 114 | + | if err != nil { | |
| 115 | + | return fmt.Errorf("delete ref tip: %w", err) | |
| 116 | + | } | |
| 117 | + | return nil | |
| 118 | + | } | |
| 119 | + | return fmt.Errorf("delete ref tip: %w", domain.ErrConflict) | |
| 120 | + | } | |
A internal/adapters/kvstore/ci_tip_test.go +34 -0
| @@ -0,0 +1,34 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package kvstore_test | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "testing" | |
| 8 | + | ||
| 9 | + | "github.com/stretchr/testify/assert" | |
| 10 | + | "github.com/stretchr/testify/require" | |
| 11 | + | ||
| 12 | + | "bnema.dev/ermite/internal/domain" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | func TestDeleteRefTipIfOnlyRemovesTheMatchingRecord(t *testing.T) { | |
| 16 | + | ctx := context.Background() | |
| 17 | + | s := openStore(t, t.TempDir()) | |
| 18 | + | n := name(t, "proj") | |
| 19 | + | require.NoError(t, s.Create(ctx, domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, CreatedAt: created})) | |
| 20 | + | ci := s.CI() | |
| 21 | + | ref := "refs/heads/feat" | |
| 22 | + | ||
| 23 | + | require.NoError(t, ci.PutRefTip(ctx, domain.CIRefTip{Repo: n, Ref: ref, Sha: "aaa", Actor: "ada"})) | |
| 24 | + | require.NoError(t, ci.DeleteRefTipIf(ctx, n, ref, "bbb")) | |
| 25 | + | got, err := ci.GetRefTip(ctx, n, ref) | |
| 26 | + | require.NoError(t, err) | |
| 27 | + | assert.Equal(t, "aaa", got.Sha, "a record of another object stays") | |
| 28 | + | ||
| 29 | + | require.NoError(t, ci.DeleteRefTipIf(ctx, n, ref, "aaa")) | |
| 30 | + | _, err = ci.GetRefTip(ctx, n, ref) | |
| 31 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| 32 | + | ||
| 33 | + | require.NoError(t, ci.DeleteRefTipIf(ctx, n, ref, "aaa"), "a missing record is not an error") | |
| 34 | + | } | |
M internal/adapters/kvstore/mergerequests.go +150 -5
| @@ -17,6 +17,7 @@ import ( | |||
| 17 | 17 | kv "bnema.dev/ermite-kv" | |
| 18 | 18 | "github.com/bnema/zerowrap" | |
| 19 | 19 | "google.golang.org/protobuf/proto" | |
| 20 | + | "google.golang.org/protobuf/types/known/timestamppb" | |
| 20 | 21 | ||
| 21 | 22 | storagepb "bnema.dev/ermite/gen/ermite/storage" | |
| 22 | 23 | "bnema.dev/ermite/internal/domain" | |
| @@ -27,10 +28,11 @@ import ( | |||
| 27 | 28 | // ending in "/" never matches another repository; numbers and comment ids are | |
| 28 | 29 | // zero-padded so that keys sort numerically. | |
| 29 | 30 | const ( | |
| 30 | - | mrPrefix = "mr/" // mr/<repo>/<number:010d> | |
| 31 | - | commentPrefix = "mrc/" // mrc/<repo>/<number:010d>/<id:010d> | |
| 32 | - | readPrefix = "mrread/" // mrread/<repo>/<number:010d> | |
| 33 | - | seqPrefix = "mrseq/" // mrseq/<repo> | |
| 31 | + | mrPrefix = "mr/" // mr/<repo>/<number:010d> | |
| 32 | + | commentPrefix = "mrc/" // mrc/<repo>/<number:010d>/<id:010d> | |
| 33 | + | readPrefix = "mrread/" // mrread/<repo>/<number:010d> | |
| 34 | + | seqPrefix = "mrseq/" // mrseq/<repo> | |
| 35 | + | mergePrefix = "mrmerge/" // mrmerge/<repo>/<number:010d> | |
| 34 | 36 | // The indexes of the open merge requests by source branch and by target | |
| 35 | 37 | // branch, which a push looks up (the values are not read). They are written in | |
| 36 | 38 | // the same batch as the merge request, so they are never out of step with it, | |
| @@ -69,6 +71,9 @@ func commentKey(repo domain.RepoName, n, id int64) []byte { | |||
| 69 | 71 | func readKey(repo domain.RepoName, n int64) []byte { | |
| 70 | 72 | return []byte(readPrefix + repo.String() + "/" + fmt.Sprintf(numberFormat, n)) | |
| 71 | 73 | } | |
| 74 | + | func mergeKey(repo domain.RepoName, n int64) []byte { | |
| 75 | + | return []byte(mergePrefix + repo.String() + "/" + fmt.Sprintf(numberFormat, n)) | |
| 76 | + | } | |
| 72 | 77 | func seqKey(repo domain.RepoName) []byte { return []byte(seqPrefix + repo.String()) } | |
| 73 | 78 | ||
| 74 | 79 | // mrOpenRepoPrefix is where the open merge requests of a repository are indexed | |
| @@ -175,6 +180,7 @@ func mergeRequestKeys(ctx context.Context, snap *kv.Snapshot, repo domain.RepoNa | |||
| 175 | 180 | for _, p := range []string{ | |
| 176 | 181 | mrRepoPrefix(repo), commentPrefix + repo.String() + "/", readPrefix + repo.String() + "/", | |
| 177 | 182 | mrOpenRepoPrefix(repo), mrTargetPrefix + repo.String() + "/", | |
| 183 | + | mergePrefix + repo.String() + "/", | |
| 178 | 184 | } { | |
| 179 | 185 | for k := range snap.Prefix(ctx, []byte(p)) { | |
| 180 | 186 | keys = append(keys, slices.Clone(k)) | |
| @@ -469,6 +475,9 @@ func (m *MergeRequests) Update(ctx context.Context, mr domain.MergeRequest) erro | |||
| 469 | 475 | } else { | |
| 470 | 476 | indexOpen(&b, nil, mr) | |
| 471 | 477 | } | |
| 478 | + | if !mr.IsOpen() { | |
| 479 | + | b.Delete(mergeKey(mr.Repo, mr.Number)) | |
| 480 | + | } | |
| 472 | 481 | if _, err := m.db.Apply(ctx, &b); err != nil { | |
| 473 | 482 | if errors.Is(err, kv.ErrConflict) { | |
| 474 | 483 | return domain.ErrConflict | |
| @@ -579,6 +588,133 @@ func (m *MergeRequests) SetRead(ctx context.Context, repo domain.RepoName, numbe | |||
| 579 | 588 | return fmt.Errorf("record read position of %d in %q: %w", number, repo, domain.ErrConflict) | |
| 580 | 589 | } | |
| 581 | 590 | ||
| 591 | + | // GetMergeability returns the stored mergeability, or domain.ErrNotFound. | |
| 592 | + | func (m *MergeRequests) GetMergeability(ctx context.Context, repo domain.RepoName, number int64) (domain.Mergeability, error) { | |
| 593 | + | item, ok := m.db.Get(ctx, mergeKey(repo, number)) | |
| 594 | + | if !ok { | |
| 595 | + | return domain.Mergeability{}, domain.ErrNotFound | |
| 596 | + | } | |
| 597 | + | return decodeMergeability(repo, number, item) | |
| 598 | + | } | |
| 599 | + | ||
| 600 | + | // PutMergeability stores the mergeability in a batch that requires the merge | |
| 601 | + | // request to be unchanged since it was read here and open, and the record to | |
| 602 | + | // be absent (Version 0) or unchanged. | |
| 603 | + | // | |
| 604 | + | //hexcheck:ignore suspicious-business-logic-in-adapter version preconditions of a derived record: a storage concern, no policy | |
| 605 | + | func (m *MergeRequests) PutMergeability(ctx context.Context, mg domain.Mergeability) error { | |
| 606 | + | val, err := encodeMergeability(mg) | |
| 607 | + | if err != nil { | |
| 608 | + | return err | |
| 609 | + | } | |
| 610 | + | for range createTries { | |
| 611 | + | mrItem, ok := m.db.Get(ctx, mrKey(mg.Repo, mg.Number)) | |
| 612 | + | if !ok { | |
| 613 | + | return domain.ErrNotFound | |
| 614 | + | } | |
| 615 | + | mr, err := decodeMergeRequest(mg.Repo, mrItem) | |
| 616 | + | if err != nil { | |
| 617 | + | return err | |
| 618 | + | } | |
| 619 | + | if !mr.IsOpen() { | |
| 620 | + | return domain.ErrNotFound | |
| 621 | + | } | |
| 622 | + | key := mergeKey(mg.Repo, mg.Number) | |
| 623 | + | var b kv.Batch | |
| 624 | + | b.RequireVersion(mrKey(mg.Repo, mg.Number), mrItem.Version) | |
| 625 | + | if mg.Version == 0 { | |
| 626 | + | b.RequireAbsent(key) | |
| 627 | + | } else { | |
| 628 | + | b.RequireVersion(key, mg.Version) | |
| 629 | + | } | |
| 630 | + | b.Put(key, val) | |
| 631 | + | _, err = m.db.Apply(ctx, &b) | |
| 632 | + | if errors.Is(err, kv.ErrConflict) { | |
| 633 | + | // Either the record changed (the caller's conflict) or the merge | |
| 634 | + | // request did (retry against it). | |
| 635 | + | if cur, ok := m.db.Get(ctx, mrKey(mg.Repo, mg.Number)); ok && cur.Version != mrItem.Version { | |
| 636 | + | continue | |
| 637 | + | } | |
| 638 | + | return domain.ErrConflict | |
| 639 | + | } | |
| 640 | + | if err != nil { | |
| 641 | + | return fmt.Errorf("store mergeability of %d in %q: %w", mg.Number, mg.Repo, err) | |
| 642 | + | } | |
| 643 | + | return nil | |
| 644 | + | } | |
| 645 | + | return fmt.Errorf("store mergeability of %d in %q: %w", mg.Number, mg.Repo, domain.ErrConflict) | |
| 646 | + | } | |
| 647 | + | ||
| 648 | + | // DeleteMergeability removes the stored mergeability; absent is fine. | |
| 649 | + | func (m *MergeRequests) DeleteMergeability(ctx context.Context, repo domain.RepoName, number int64) error { | |
| 650 | + | var b kv.Batch | |
| 651 | + | b.Delete(mergeKey(repo, number)) | |
| 652 | + | if _, err := m.db.Apply(ctx, &b); err != nil { | |
| 653 | + | return fmt.Errorf("delete mergeability of %d in %q: %w", number, repo, err) | |
| 654 | + | } | |
| 655 | + | return nil | |
| 656 | + | } | |
| 657 | + | ||
| 658 | + | func encodeMergeability(m domain.Mergeability) ([]byte, error) { | |
| 659 | + | var state storagepb.MergeabilityState | |
| 660 | + | switch m.State { | |
| 661 | + | case domain.MergeabilityUpToDate: | |
| 662 | + | state = storagepb.MergeabilityState_MERGEABILITY_STATE_UP_TO_DATE | |
| 663 | + | case domain.MergeabilityBehind: | |
| 664 | + | state = storagepb.MergeabilityState_MERGEABILITY_STATE_BEHIND | |
| 665 | + | case domain.MergeabilityConflict: | |
| 666 | + | state = storagepb.MergeabilityState_MERGEABILITY_STATE_CONFLICT | |
| 667 | + | default: | |
| 668 | + | return nil, fmt.Errorf("%w: mergeability state %q is not stored", domain.ErrInvalid, m.State) | |
| 669 | + | } | |
| 670 | + | return proto.Marshal(&storagepb.Mergeability{ | |
| 671 | + | State: state, | |
| 672 | + | TargetId: m.TargetID.String(), | |
| 673 | + | HeadId: m.HeadID.String(), | |
| 674 | + | Behind: int32(m.Behind), //nolint:gosec // capped at MaxBehindCount | |
| 675 | + | BehindCapped: m.BehindCapped, | |
| 676 | + | ConflictPaths: m.ConflictPaths, | |
| 677 | + | ConflictPathsTruncated: m.ConflictPathsTruncated, | |
| 678 | + | CheckedAt: timestamppb.New(m.CheckedAt), | |
| 679 | + | }) | |
| 680 | + | } | |
| 681 | + | ||
| 682 | + | func decodeMergeability(repo domain.RepoName, number int64, item kv.Item) (domain.Mergeability, error) { | |
| 683 | + | var pb storagepb.Mergeability | |
| 684 | + | if err := proto.Unmarshal(item.Value, &pb); err != nil { | |
| 685 | + | return domain.Mergeability{}, fmt.Errorf("decode mergeability of %d in %q: %w", number, repo, err) | |
| 686 | + | } | |
| 687 | + | state, ok := mergeabilityStates[pb.GetState()] | |
| 688 | + | if !ok { | |
| 689 | + | return domain.Mergeability{}, fmt.Errorf("decode mergeability of %d in %q: unknown state %d: %w", number, repo, pb.GetState(), domain.ErrNotFound) | |
| 690 | + | } | |
| 691 | + | target, err := domain.ParseObjectID(pb.GetTargetId()) | |
| 692 | + | if err != nil { | |
| 693 | + | return domain.Mergeability{}, fmt.Errorf("decode mergeability of %d in %q: %w", number, repo, err) | |
| 694 | + | } | |
| 695 | + | head, err := domain.ParseObjectID(pb.GetHeadId()) | |
| 696 | + | if err != nil { | |
| 697 | + | return domain.Mergeability{}, fmt.Errorf("decode mergeability of %d in %q: %w", number, repo, err) | |
| 698 | + | } | |
| 699 | + | return domain.Mergeability{ | |
| 700 | + | Repo: repo, Number: number, State: state, | |
| 701 | + | TargetID: target, HeadID: head, | |
| 702 | + | Behind: int(pb.GetBehind()), BehindCapped: pb.GetBehindCapped(), | |
| 703 | + | ConflictPaths: pb.GetConflictPaths(), | |
| 704 | + | ConflictPathsTruncated: pb.GetConflictPathsTruncated(), | |
| 705 | + | CheckedAt: pb.GetCheckedAt().AsTime(), | |
| 706 | + | Version: item.Version, | |
| 707 | + | }, nil | |
| 708 | + | } | |
| 709 | + | ||
| 710 | + | // mergeabilityStates maps the stored states to the domain ones; CHECKING is | |
| 711 | + | // never stored. | |
| 712 | + | var mergeabilityStates = map[storagepb.MergeabilityState]domain.MergeabilityState{ | |
| 713 | + | storagepb.MergeabilityState_MERGEABILITY_STATE_UP_TO_DATE: domain.MergeabilityUpToDate, | |
| 714 | + | storagepb.MergeabilityState_MERGEABILITY_STATE_BEHIND: domain.MergeabilityBehind, | |
| 715 | + | storagepb.MergeabilityState_MERGEABILITY_STATE_CONFLICT: domain.MergeabilityConflict, | |
| 716 | + | } | |
| 717 | + | ||
| 582 | 718 | func encodeMergeRequest(mr domain.MergeRequest) ([]byte, error) { | |
| 583 | 719 | if mr.Repo.IsZero() { | |
| 584 | 720 | return nil, fmt.Errorf("%w: empty name", domain.ErrInvalidName) | |
| @@ -735,6 +871,8 @@ func encodeMode(m domain.MergeMode) (storagepb.MergeMode, error) { | |||
| 735 | 871 | return storagepb.MergeMode_MERGE_MODE_FAST_FORWARD, nil | |
| 736 | 872 | case domain.MergeModeSquash: | |
| 737 | 873 | return storagepb.MergeMode_MERGE_MODE_SQUASH, nil | |
| 874 | + | case domain.MergeModeMerge: | |
| 875 | + | return storagepb.MergeMode_MERGE_MODE_MERGE, nil | |
| 738 | 876 | } | |
| 739 | 877 | return 0, fmt.Errorf("%w: merge mode %q", domain.ErrInvalid, m) | |
| 740 | 878 | } | |
| @@ -746,6 +884,8 @@ func decodeMode(m storagepb.MergeMode) domain.MergeMode { | |||
| 746 | 884 | return domain.MergeModeFastForward | |
| 747 | 885 | case storagepb.MergeMode_MERGE_MODE_SQUASH: | |
| 748 | 886 | return domain.MergeModeSquash | |
| 887 | + | case storagepb.MergeMode_MERGE_MODE_MERGE: | |
| 888 | + | return domain.MergeModeMerge | |
| 749 | 889 | } | |
| 750 | 890 | return "" | |
| 751 | 891 | } | |
| @@ -773,7 +913,7 @@ func decodeSide(s storagepb.DiffSide) domain.DiffSide { | |||
| 773 | 913 | } | |
| 774 | 914 | ||
| 775 | 915 | func encodeSettings(s domain.RepoSettings) (*storagepb.RepoSettings, error) { | |
| 776 | - | pb := &storagepb.RepoSettings{FailingChecksBlock: s.FailingChecksBlock} | |
| 916 | + | pb := &storagepb.RepoSettings{FailingChecksBlock: s.FailingChecksBlock, MergeModesV2: true} | |
| 777 | 917 | for _, m := range s.AllowedMergeModes { | |
| 778 | 918 | mode, err := encodeMode(m) | |
| 779 | 919 | if err != nil || mode == storagepb.MergeMode_MERGE_MODE_UNSPECIFIED { | |
| @@ -810,5 +950,10 @@ func decodeSettings(pb *storagepb.RepoSettings) domain.RepoSettings { | |||
| 810 | 950 | if err != nil { | |
| 811 | 951 | return domain.DefaultRepoSettings() | |
| 812 | 952 | } | |
| 953 | + | // Settings stored before merge commits: exactly {fast-forward, squash} was | |
| 954 | + | // the old default, "everything allowed". | |
| 955 | + | if !pb.GetMergeModesV2() && slices.Equal(normalized, []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash}) { | |
| 956 | + | normalized = domain.MergeModes() | |
| 957 | + | } | |
| 813 | 958 | return domain.RepoSettings{AllowedMergeModes: normalized, FailingChecksBlock: pb.GetFailingChecksBlock(), Issues: decodeIssuesMode(pb.GetIssues())} | |
| 814 | 959 | } | |
M internal/adapters/kvstore/mergerequests_open_test.go +25 -0
| @@ -284,3 +284,28 @@ func TestOpenIndexIsBackfilledOnce(t *testing.T) { | |||
| 284 | 284 | assert.Equal(t, int64(5), five.Number) | |
| 285 | 285 | assert.Equal(t, []int64{1, 5}, bySource(t, m2, old, "feat/one")) | |
| 286 | 286 | } | |
| 287 | + | ||
| 288 | + | // Issues and merge requests share a sequence, but only merge requests belong | |
| 289 | + | // to the indexes used by branch-move checks and mergeability rescans. | |
| 290 | + | func TestMergeabilityIndexesExcludeIssueNumbers(t *testing.T) { | |
| 291 | + | ctx := context.Background() | |
| 292 | + | s, mrs, repo := repoWithMRs(t) | |
| 293 | + | issue, err := s.Issues().Create(ctx, newIssue(t, repo, "issue")) | |
| 294 | + | require.NoError(t, err) | |
| 295 | + | mr, err := mrs.Create(ctx, newMR(t, repo, "request")) | |
| 296 | + | require.NoError(t, err) | |
| 297 | + | require.Equal(t, issue.Number+1, mr.Number) | |
| 298 | + | ||
| 299 | + | source, truncated, err := mrs.OpenBySource(ctx, repo, []string{"feat/x"}, 10) | |
| 300 | + | require.NoError(t, err) | |
| 301 | + | assert.False(t, truncated) | |
| 302 | + | assert.Equal(t, []int64{mr.Number}, source) | |
| 303 | + | target, truncated, err := mrs.OpenByTarget(ctx, repo, []string{"main"}, 10) | |
| 304 | + | require.NoError(t, err) | |
| 305 | + | assert.False(t, truncated) | |
| 306 | + | assert.Equal(t, []int64{mr.Number}, target) | |
| 307 | + | open, err := mrs.ListOpen(ctx, repo) | |
| 308 | + | require.NoError(t, err) | |
| 309 | + | require.Len(t, open, 1) | |
| 310 | + | assert.Equal(t, mr.Number, open[0].Number) | |
| 311 | + | } | |
M internal/adapters/kvstore/mergerequests_test.go +117 -0
| @@ -572,3 +572,120 @@ func TestNoteFieldsRoundTrip(t *testing.T) { | |||
| 572 | 572 | assert.Equal(t, c, got) | |
| 573 | 573 | } | |
| 574 | 574 | } | |
| 575 | + | ||
| 576 | + | // TestStoredOldDefaultModesGainMerge: settings written before merge commits | |
| 577 | + | // (no merge_modes_v2) with exactly {fast-forward, squash} read with merge too; | |
| 578 | + | // the same set written by this build is an explicit choice and stays. | |
| 579 | + | func TestStoredOldDefaultModesGainMerge(t *testing.T) { | |
| 580 | + | ctx := context.Background() | |
| 581 | + | dir := t.TempDir() | |
| 582 | + | s, err := kvstore.Open(ctx, dir) | |
| 583 | + | require.NoError(t, err) | |
| 584 | + | require.NoError(t, s.Close(ctx)) | |
| 585 | + | ||
| 586 | + | put := func(db *kv.DB, repo string, modes ...storagepb.MergeMode) { | |
| 587 | + | val, err := proto.Marshal(&storagepb.Repo{Name: repo, Visibility: storagepb.Visibility_VISIBILITY_PUBLIC, | |
| 588 | + | Settings: &storagepb.RepoSettings{AllowedMergeModes: modes, FailingChecksBlock: true}}) | |
| 589 | + | require.NoError(t, err) | |
| 590 | + | var b kv.Batch | |
| 591 | + | b.Put([]byte("repo/"+repo), val) | |
| 592 | + | _, err = db.Apply(ctx, &b) | |
| 593 | + | require.NoError(t, err) | |
| 594 | + | } | |
| 595 | + | db, err := kv.Open(ctx, dir, kv.Options{}) | |
| 596 | + | require.NoError(t, err) | |
| 597 | + | put(db, "old", storagepb.MergeMode_MERGE_MODE_FAST_FORWARD, storagepb.MergeMode_MERGE_MODE_SQUASH) | |
| 598 | + | put(db, "single", storagepb.MergeMode_MERGE_MODE_SQUASH) | |
| 599 | + | require.NoError(t, db.Close(ctx)) | |
| 600 | + | ||
| 601 | + | s = openStore(t, dir) | |
| 602 | + | old, err := s.Get(ctx, name(t, "old")) | |
| 603 | + | require.NoError(t, err) | |
| 604 | + | assert.Equal(t, domain.MergeModes(), old.Settings.AllowedMergeModes) | |
| 605 | + | single, err := s.Get(ctx, name(t, "single")) | |
| 606 | + | require.NoError(t, err) | |
| 607 | + | assert.Equal(t, []domain.MergeMode{domain.MergeModeSquash}, single.Settings.AllowedMergeModes) | |
| 608 | + | ||
| 609 | + | // An explicit {ff, squash} written now is kept. | |
| 610 | + | old.Settings.AllowedMergeModes = []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash} | |
| 611 | + | require.NoError(t, s.Update(ctx, old)) | |
| 612 | + | again, err := s.Get(ctx, name(t, "old")) | |
| 613 | + | require.NoError(t, err) | |
| 614 | + | assert.Equal(t, []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash}, again.Settings.AllowedMergeModes) | |
| 615 | + | } | |
| 616 | + | ||
| 617 | + | func mergeability(mr domain.MergeRequest) domain.Mergeability { | |
| 618 | + | return domain.Mergeability{ | |
| 619 | + | Repo: mr.Repo, Number: mr.Number, State: domain.MergeabilityConflict, | |
| 620 | + | TargetID: oid('a'), HeadID: oid('b'), Behind: 3, BehindCapped: true, | |
| 621 | + | ConflictPaths: []string{"a.txt", "b/c.txt"}, ConflictPathsTruncated: true, CheckedAt: created, | |
| 622 | + | } | |
| 623 | + | } | |
| 624 | + | ||
| 625 | + | func TestPutMergeabilityCAS(t *testing.T) { | |
| 626 | + | ctx := context.Background() | |
| 627 | + | _, mrs, n := repoWithMRs(t) | |
| 628 | + | mr, err := mrs.Create(ctx, newMR(t, n, "t")) | |
| 629 | + | require.NoError(t, err) | |
| 630 | + | ||
| 631 | + | _, err = mrs.GetMergeability(ctx, n, mr.Number) | |
| 632 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| 633 | + | ||
| 634 | + | m := mergeability(mr) | |
| 635 | + | require.NoError(t, mrs.PutMergeability(ctx, m)) | |
| 636 | + | assert.ErrorIs(t, mrs.PutMergeability(ctx, m), domain.ErrConflict, "version 0 needs it absent") | |
| 637 | + | ||
| 638 | + | got, err := mrs.GetMergeability(ctx, n, mr.Number) | |
| 639 | + | require.NoError(t, err) | |
| 640 | + | assert.NotZero(t, got.Version) | |
| 641 | + | m.Version = got.Version | |
| 642 | + | assert.Equal(t, m, got) | |
| 643 | + | ||
| 644 | + | next := got | |
| 645 | + | next.State = domain.MergeabilityUpToDate | |
| 646 | + | require.NoError(t, mrs.PutMergeability(ctx, next)) | |
| 647 | + | assert.ErrorIs(t, mrs.PutMergeability(ctx, next), domain.ErrConflict, "stale version") | |
| 648 | + | ||
| 649 | + | require.NoError(t, mrs.DeleteMergeability(ctx, n, mr.Number)) | |
| 650 | + | require.NoError(t, mrs.DeleteMergeability(ctx, n, mr.Number), "absent is fine") | |
| 651 | + | _, err = mrs.GetMergeability(ctx, n, mr.Number) | |
| 652 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| 653 | + | ||
| 654 | + | missing := mergeability(mr) | |
| 655 | + | missing.Number = 99 | |
| 656 | + | assert.ErrorIs(t, mrs.PutMergeability(ctx, missing), domain.ErrNotFound) | |
| 657 | + | ||
| 658 | + | checking := mergeability(mr) | |
| 659 | + | checking.State = domain.MergeabilityChecking | |
| 660 | + | assert.ErrorIs(t, mrs.PutMergeability(ctx, checking), domain.ErrInvalid) | |
| 661 | + | } | |
| 662 | + | ||
| 663 | + | func TestMergeabilityRefusedAndDroppedWhenNotOpen(t *testing.T) { | |
| 664 | + | for _, state := range []domain.MRState{domain.MRStateMerged, domain.MRStateClosed} { | |
| 665 | + | ctx := context.Background() | |
| 666 | + | _, mrs, n := repoWithMRs(t) | |
| 667 | + | mr, err := mrs.Create(ctx, newMR(t, n, "t")) | |
| 668 | + | require.NoError(t, err) | |
| 669 | + | require.NoError(t, mrs.PutMergeability(ctx, mergeability(mr))) | |
| 670 | + | ||
| 671 | + | cur, err := mrs.Get(ctx, n, mr.Number) | |
| 672 | + | require.NoError(t, err) | |
| 673 | + | cur.State = state | |
| 674 | + | require.NoError(t, mrs.Update(ctx, cur)) | |
| 675 | + | _, err = mrs.GetMergeability(ctx, n, mr.Number) | |
| 676 | + | assert.ErrorIs(t, err, domain.ErrNotFound, state) | |
| 677 | + | ||
| 678 | + | assert.ErrorIs(t, mrs.PutMergeability(ctx, mergeability(mr)), domain.ErrNotFound, "a late worker never resurrects it: %s", state) | |
| 679 | + | } | |
| 680 | + | } | |
| 681 | + | ||
| 682 | + | func TestDeleteWipesMergeability(t *testing.T) { | |
| 683 | + | ctx := context.Background() | |
| 684 | + | s, mrs, n := repoWithMRs(t) | |
| 685 | + | mr, err := mrs.Create(ctx, newMR(t, n, "t")) | |
| 686 | + | require.NoError(t, err) | |
| 687 | + | require.NoError(t, mrs.PutMergeability(ctx, mergeability(mr))) | |
| 688 | + | require.NoError(t, s.Delete(ctx, n)) | |
| 689 | + | _, err = mrs.GetMergeability(ctx, n, mr.Number) | |
| 690 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| 691 | + | } | |
M internal/adapters/rpc/api.go +1 -1
| @@ -35,7 +35,7 @@ const RunnerMaxRequestBytes = MaxRequestBytes + 64<<10 | |||
| 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", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.cache", "ci.workflows"} | |
| 38 | + | var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.cache", "ci.workflows"} | |
| 39 | 39 | ||
| 40 | 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 +7 -0
| @@ -88,6 +88,8 @@ func mergeModeToPB(m domain.MergeMode) pb.MergeMode { | |||
| 88 | 88 | return pb.MergeMode_MERGE_MODE_FAST_FORWARD | |
| 89 | 89 | case domain.MergeModeSquash: | |
| 90 | 90 | return pb.MergeMode_MERGE_MODE_SQUASH | |
| 91 | + | case domain.MergeModeMerge: | |
| 92 | + | return pb.MergeMode_MERGE_MODE_MERGE_COMMIT | |
| 91 | 93 | } | |
| 92 | 94 | return pb.MergeMode_MERGE_MODE_UNSPECIFIED | |
| 93 | 95 | } | |
| @@ -102,6 +104,8 @@ func mergeModeFromPB(m pb.MergeMode) (domain.MergeMode, error) { | |||
| 102 | 104 | return domain.MergeModeFastForward, nil | |
| 103 | 105 | case pb.MergeMode_MERGE_MODE_SQUASH: | |
| 104 | 106 | return domain.MergeModeSquash, nil | |
| 107 | + | case pb.MergeMode_MERGE_MODE_MERGE_COMMIT: | |
| 108 | + | return domain.MergeModeMerge, nil | |
| 105 | 109 | } | |
| 106 | 110 | return "", fmt.Errorf("%w: merge mode %v", domain.ErrInvalid, m) | |
| 107 | 111 | } | |
| @@ -497,6 +501,9 @@ func watchEventToPB(we ports.WatchEvent) *pb.Event { | |||
| 497 | 501 | case domain.CommentResolved: | |
| 498 | 502 | out.Time = timestamp(ev.At) | |
| 499 | 503 | out.Kind = &pb.Event_CommentResolved{CommentResolved: &pb.CommentResolved{Repo: ev.Repo.String(), Number: ev.Number, Comment: commentToPB(ev.Comment)}} | |
| 504 | + | case domain.MergeRequestMergeability: | |
| 505 | + | out.Time = timestamp(ev.At) | |
| 506 | + | out.Kind = &pb.Event_MergeRequestMergeability{MergeRequestMergeability: &pb.MergeRequestMergeability{Repo: ev.Repo.String(), Number: ev.Number, Mergeability: mergeabilityToPB(ev.Mergeability)}} | |
| 500 | 507 | case domain.RunUpdated: | |
| 501 | 508 | out.Time = timestamp(ev.At) | |
| 502 | 509 | out.Kind = runEventToPB(ev) | |
M internal/adapters/rpc/convert_test.go +23 -1
| @@ -213,7 +213,7 @@ func TestRepoToPBAlwaysHasSettings(t *testing.T) { | |||
| 213 | 213 | t.Parallel() | |
| 214 | 214 | // A repository value without settings (never stored with them) reads as the defaults. | |
| 215 | 215 | r := repoToPB(ports.RepoView{Repo: domain.Repo{Name: mustName(t, "x")}}) | |
| 216 | - | assert.Equal(t, []pb.MergeMode{pb.MergeMode_MERGE_MODE_FAST_FORWARD, pb.MergeMode_MERGE_MODE_SQUASH}, r.GetSettings().GetAllowedMergeModes()) | |
| 216 | + | assert.Equal(t, []pb.MergeMode{pb.MergeMode_MERGE_MODE_FAST_FORWARD, pb.MergeMode_MERGE_MODE_SQUASH, pb.MergeMode_MERGE_MODE_MERGE_COMMIT}, r.GetSettings().GetAllowedMergeModes()) | |
| 217 | 217 | assert.True(t, r.GetSettings().GetFailingChecksBlock()) | |
| 218 | 218 | ||
| 219 | 219 | custom := domain.RepoSettings{AllowedMergeModes: []domain.MergeMode{domain.MergeModeSquash}} | |
| @@ -376,3 +376,25 @@ func TestIssueStateToPBNeverPending(t *testing.T) { | |||
| 376 | 376 | assert.Equal(t, pb.IssueState_ISSUE_STATE_UNSPECIFIED, issueStateToPB(domain.IssueState("pending"))) | |
| 377 | 377 | assert.Equal(t, pb.IssueState_ISSUE_STATE_OPEN, issueStateToPB(domain.IssueStateOpen)) | |
| 378 | 378 | } | |
| 379 | + | ||
| 380 | + | func TestMergeRequestToPBMergeabilityOnlyWhileOpen(t *testing.T) { | |
| 381 | + | t.Parallel() | |
| 382 | + | repo := mustName(t, "proj") | |
| 383 | + | for state, want := range map[domain.MRState]bool{domain.MRStateOpen: true, domain.MRStateMerged: false, domain.MRStateClosed: false} { | |
| 384 | + | mr := domain.MergeRequest{Repo: repo, Number: 4, State: state} | |
| 385 | + | got := mergeRequestToPB(mr).GetMergeability() | |
| 386 | + | if !want { | |
| 387 | + | assert.Nil(t, got, state) | |
| 388 | + | continue | |
| 389 | + | } | |
| 390 | + | require.NotNil(t, got) | |
| 391 | + | assert.Equal(t, pb.MergeabilityState_MERGEABILITY_STATE_CHECKING, got.GetState()) | |
| 392 | + | ev := watchEventToPB(ports.WatchEvent{ID: "e", Event: domain.MergeRequestOpened{MergeRequest: mr}}) | |
| 393 | + | assert.Equal(t, pb.MergeabilityState_MERGEABILITY_STATE_CHECKING, ev.GetMergeRequestOpened().GetMergeRequest().GetMergeability().GetState()) | |
| 394 | + | } | |
| 395 | + | for _, ev := range []domain.Event{domain.MergeRequestMerged{MergeRequest: domain.MergeRequest{Repo: repo, State: domain.MRStateMerged}}, domain.MergeRequestClosed{MergeRequest: domain.MergeRequest{Repo: repo, State: domain.MRStateClosed}}} { | |
| 396 | + | got := watchEventToPB(ports.WatchEvent{ID: "e", Event: ev}) | |
| 397 | + | assert.Nil(t, got.GetMergeRequestMerged().GetMergeRequest().GetMergeability()) | |
| 398 | + | assert.Nil(t, got.GetMergeRequestClosed().GetMergeRequest().GetMergeability()) | |
| 399 | + | } | |
| 400 | + | } | |
M internal/adapters/rpc/mergerequest.go +47 -3
| @@ -115,8 +115,11 @@ func diffSideFromPB(s pb.DiffSide) (domain.DiffSide, error) { | |||
| 115 | 115 | return "", fmt.Errorf("%w: diff side %v", domain.ErrInvalid, s) | |
| 116 | 116 | } | |
| 117 | 117 | ||
| 118 | + | // mergeRequestToPB maps a request. An open one carries the CHECKING | |
| 119 | + | // mergeability: the server has no result in hand here; Get and List override | |
| 120 | + | // it with the computed one. | |
| 118 | 121 | func mergeRequestToPB(mr domain.MergeRequest) *pb.MergeRequest { | |
| 119 | - | return &pb.MergeRequest{ | |
| 122 | + | out := &pb.MergeRequest{ | |
| 120 | 123 | Repo: mr.Repo.String(), | |
| 121 | 124 | Number: mr.Number, | |
| 122 | 125 | Title: mr.Title.String(), | |
| @@ -131,6 +134,47 @@ func mergeRequestToPB(mr domain.MergeRequest) *pb.MergeRequest { | |||
| 131 | 134 | MergedCommitId: mr.MergedCommitID.String(), | |
| 132 | 135 | MergeMode: mergeModeToPB(mr.MergeMode), | |
| 133 | 136 | } | |
| 137 | + | if mr.IsOpen() { | |
| 138 | + | out.Mergeability = &pb.Mergeability{State: pb.MergeabilityState_MERGEABILITY_STATE_CHECKING} | |
| 139 | + | } | |
| 140 | + | return out | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | // mergeRequestWithMergeabilityToPB is mergeRequestToPB with the mergeability of | |
| 144 | + | // an open request; m is ignored for a request that is not open. | |
| 145 | + | func mergeRequestWithMergeabilityToPB(mr domain.MergeRequest, m domain.Mergeability) *pb.MergeRequest { | |
| 146 | + | out := mergeRequestToPB(mr) | |
| 147 | + | if mr.IsOpen() && m.State != "" { | |
| 148 | + | out.Mergeability = mergeabilityToPB(m) | |
| 149 | + | } | |
| 150 | + | return out | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | func mergeabilityStateToPB(s domain.MergeabilityState) pb.MergeabilityState { | |
| 154 | + | switch s { | |
| 155 | + | case domain.MergeabilityChecking: | |
| 156 | + | return pb.MergeabilityState_MERGEABILITY_STATE_CHECKING | |
| 157 | + | case domain.MergeabilityUpToDate: | |
| 158 | + | return pb.MergeabilityState_MERGEABILITY_STATE_UP_TO_DATE | |
| 159 | + | case domain.MergeabilityBehind: | |
| 160 | + | return pb.MergeabilityState_MERGEABILITY_STATE_BEHIND | |
| 161 | + | case domain.MergeabilityConflict: | |
| 162 | + | return pb.MergeabilityState_MERGEABILITY_STATE_CONFLICT | |
| 163 | + | } | |
| 164 | + | return pb.MergeabilityState_MERGEABILITY_STATE_UNSPECIFIED | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | func mergeabilityToPB(m domain.Mergeability) *pb.Mergeability { | |
| 168 | + | return &pb.Mergeability{ | |
| 169 | + | State: mergeabilityStateToPB(m.State), | |
| 170 | + | TargetId: m.TargetID.String(), | |
| 171 | + | HeadId: m.HeadID.String(), | |
| 172 | + | Behind: int32(m.Behind), //nolint:gosec // capped at domain.MaxBehindCount | |
| 173 | + | BehindCapped: m.BehindCapped, | |
| 174 | + | ConflictPaths: m.ConflictPaths, | |
| 175 | + | ConflictPathsTruncated: m.ConflictPathsTruncated, | |
| 176 | + | CheckedAt: timestamp(m.CheckedAt), | |
| 177 | + | } | |
| 134 | 178 | } | |
| 135 | 179 | ||
| 136 | 180 | func commentToPB(c domain.Comment) *pb.Comment { | |
| @@ -214,7 +258,7 @@ func (s *mergeRequestService) ListMergeRequests(ctx context.Context, req *connec | |||
| 214 | 258 | } | |
| 215 | 259 | out := &pb.ListMergeRequestsResponse{NextPageToken: page.NextPageToken, MergeRequests: make([]*pb.MergeRequest, 0, len(page.MergeRequests))} | |
| 216 | 260 | for _, mr := range page.MergeRequests { | |
| 217 | - | out.MergeRequests = append(out.MergeRequests, mergeRequestToPB(mr)) | |
| 261 | + | out.MergeRequests = append(out.MergeRequests, mergeRequestWithMergeabilityToPB(mr, page.Mergeability[mr.Number])) | |
| 218 | 262 | } | |
| 219 | 263 | return connect.NewResponse(out), nil | |
| 220 | 264 | } | |
| @@ -229,7 +273,7 @@ func (s *mergeRequestService) GetMergeRequest(ctx context.Context, req *connect. | |||
| 229 | 273 | return nil, connectError(ctx, err) | |
| 230 | 274 | } | |
| 231 | 275 | out := &pb.GetMergeRequestResponse{ | |
| 232 | - | MergeRequest: mergeRequestToPB(d.MergeRequest), | |
| 276 | + | MergeRequest: mergeRequestWithMergeabilityToPB(d.MergeRequest, d.Mergeability), | |
| 233 | 277 | CommitsTruncated: d.CommitsTruncated, | |
| 234 | 278 | DiffBaseId: d.DiffBaseID.String(), | |
| 235 | 279 | Stats: diffStatsToPB(d.Stats), | |
M internal/adapters/rpc/mergerequest_test.go +1 -0
| @@ -51,6 +51,7 @@ func TestMergeRequestServiceCreate(t *testing.T) { | |||
| 51 | 51 | require.NoError(t, err) | |
| 52 | 52 | assert.EqualValues(t, 7, resp.Msg.GetMergeRequest().GetNumber()) | |
| 53 | 53 | assert.Equal(t, pb.MergeRequestState_MERGE_REQUEST_STATE_OPEN, resp.Msg.GetMergeRequest().GetState()) | |
| 54 | + | assert.Equal(t, pb.MergeabilityState_MERGEABILITY_STATE_CHECKING, resp.Msg.GetMergeRequest().GetMergeability().GetState()) | |
| 54 | 55 | }) | |
| 55 | 56 | } | |
| 56 | 57 | } | |
M internal/adapters/web/events.go +6 -2
| @@ -31,7 +31,7 @@ import ( | |||
| 31 | 31 | // GET /-/events?topic=repo:<name>&view=summary|refs | |
| 32 | 32 | // GET /-/events?topic=repo:<name>&view=commits&ref=<branch>&head=<sha> | |
| 33 | 33 | // GET /-/events?topic=repo:<name>&view=mrs | |
| 34 | - | // GET /-/events?topic=repo:<name>&view=mr&n=<number>&ref=<source branch> | |
| 34 | + | // GET /-/events?topic=repo:<name>&view=mr&n=<number>&ref=<source branch>&target=<target branch> | |
| 35 | 35 | // GET /-/events?topic=repo:<name>&view=issues | |
| 36 | 36 | // GET /-/events?topic=repo:<name>&view=issue&n=<number> | |
| 37 | 37 | // GET /-/events?topic=repo:<name>&view=actions | |
| @@ -54,6 +54,10 @@ import ( | |||
| 54 | 54 | // refs-list data: <tbody id="ref-rows">…</tbody>, and again with | |
| 55 | 55 | // <dl id="refs-kv">…</dl>, the facts of the rail | |
| 56 | 56 | // commits-head data: <div id="commits-head">N new commits — reload</div> | |
| 57 | + | // mr-mergeability data: <div id="mr-mergeability">…</div>, on the merge request pages: | |
| 58 | + | // the badge of the merge box (a slot like mr-notice), when the | |
| 59 | + | // mergeability of the request shown settles or a push moves its | |
| 60 | + | // source or target | |
| 57 | 61 | // mr-notice data: <div id="mr-notice">…</div>, on the merge request and issue pages: | |
| 58 | 62 | // something changed, with the link that reloads the page | |
| 59 | 63 | // ci-run data: <tr id="run-<id>">…</tr> (the list of runs), | |
| @@ -252,7 +256,7 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) { | |||
| 252 | 256 | return | |
| 253 | 257 | } | |
| 254 | 258 | ||
| 255 | - | st := &liveStream{s: s, repo: repo, view: q.Get("view"), ref: q.Get("ref"), head: q.Get("head"), n: q.Get("n"), job: q.Get("job"), sha: sha} | |
| 259 | + | st := &liveStream{s: s, repo: repo, view: q.Get("view"), ref: q.Get("ref"), head: q.Get("head"), n: q.Get("n"), target: q.Get("target"), job: q.Get("job"), sha: sha} | |
| 256 | 260 | if topic == ownerTopic { | |
| 257 | 261 | st.filter = q.Get("repo") | |
| 258 | 262 | } | |
M internal/adapters/web/fragments.go +58 -8
| @@ -28,6 +28,7 @@ type liveStream struct { | |||
| 28 | 28 | head string // commits: the commit listed first | |
| 29 | 29 | n string // mr: the number of the merge request shown; run: the run id | |
| 30 | 30 | job string // job: the id of the job shown | |
| 31 | + | target string // mr: the target branch of the merge request shown | |
| 31 | 32 | // sha is the commit of the run of the job shown, read once when the stream | |
| 32 | 33 | // is set up: the annotations link to files at it, and it never changes. | |
| 33 | 34 | sha string | |
| @@ -60,7 +61,7 @@ func liveEvent(we ports.WatchEvent) bool { | |||
| 60 | 61 | } | |
| 61 | 62 | switch we.Event.(type) { | |
| 62 | 63 | case domain.RepoCreated, domain.RepoUpdated, domain.RepoDeleted, domain.PushEvent, | |
| 63 | - | domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.MergeRequestReady, domain.CommentAdded, domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved, | |
| 64 | + | domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.MergeRequestReady, domain.MergeRequestMergeability, domain.CommentAdded, domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved, | |
| 64 | 65 | domain.RunUpdated, domain.JobUpdated, | |
| 65 | 66 | domain.IssueOpened, domain.IssueClosed, domain.IssueReopened, domain.IssueCommented, domain.IssueSubmitted, domain.IssuePendingRemoved: | |
| 66 | 67 | return true | |
| @@ -170,6 +171,11 @@ func (l *liveStream) repoPage(ctx context.Context, we ports.WatchEvent) ([]fragm | |||
| 170 | 171 | return l.mrNotice(ctx, ev.MergeRequest.Number, "this merge request was closed — reload") | |
| 171 | 172 | case domain.MergeRequestReady: | |
| 172 | 173 | return l.mrNotice(ctx, ev.MergeRequest.Number, "this merge request is ready for review — reload") | |
| 174 | + | case domain.MergeRequestMergeability: | |
| 175 | + | if l.view != "mr" || strconv.FormatInt(ev.Number, 10) != l.n { | |
| 176 | + | return nil, nil | |
| 177 | + | } | |
| 178 | + | return l.mrMergeability(ctx) | |
| 173 | 179 | case domain.CommentEdited: | |
| 174 | 180 | return l.mrNotice(ctx, ev.Number, "a note was edited — reload") | |
| 175 | 181 | case domain.CommentDeleted: | |
| @@ -436,21 +442,65 @@ func (l *liveStream) issueNotice(ctx context.Context, number int64, text string) | |||
| 436 | 442 | return nil, nil | |
| 437 | 443 | } | |
| 438 | 444 | ||
| 439 | - | // mrPushed announces a push to the source branch of the merge request shown. | |
| 445 | + | // mrPushed announces a push to the source branch of the merge request shown, | |
| 446 | + | // and has the badge follow a push to its source or its target. | |
| 440 | 447 | func (l *liveStream) mrPushed(ctx context.Context, p domain.PushEvent) ([]fragment, error) { | |
| 441 | - | if l.ref == "" { | |
| 448 | + | n, err := strconv.ParseInt(l.n, 10, 64) | |
| 449 | + | if err != nil { | |
| 442 | 450 | return nil, nil | |
| 443 | 451 | } | |
| 452 | + | var out []fragment | |
| 453 | + | moved := false | |
| 444 | 454 | for _, u := range p.Updates { | |
| 445 | - | if u.Ref == "refs/heads/"+l.ref { | |
| 446 | - | n, err := strconv.ParseInt(l.n, 10, 64) | |
| 455 | + | switch { | |
| 456 | + | case l.ref != "" && u.Ref == "refs/heads/"+l.ref: | |
| 457 | + | notice, err := l.notice(ctx, "the branch changed — reload", mrHref(l.repo, n)) | |
| 447 | 458 | if err != nil { | |
| 448 | - | return nil, nil | |
| 459 | + | return nil, err | |
| 449 | 460 | } | |
| 450 | - | return l.notice(ctx, "the branch changed — reload", mrHref(l.repo, n)) | |
| 461 | + | out = append(out, notice...) | |
| 462 | + | moved = true | |
| 463 | + | case l.target != "" && u.Ref == "refs/heads/"+l.target: | |
| 464 | + | moved = true | |
| 465 | + | } | |
| 466 | + | if moved && len(out) > 0 { | |
| 467 | + | break | |
| 451 | 468 | } | |
| 452 | 469 | } | |
| 453 | - | return nil, nil | |
| 470 | + | if !moved { | |
| 471 | + | return nil, nil | |
| 472 | + | } | |
| 473 | + | badge, err := l.mrMergeability(ctx) | |
| 474 | + | return append(out, badge...), err | |
| 475 | + | } | |
| 476 | + | ||
| 477 | + | // mrMergeability is the badge of the merge box, read again: the result for the | |
| 478 | + | // tips as they are now, "checking" until the server has one (it then sends the | |
| 479 | + | // MergeRequestMergeability that turns the badge). Nothing for a request that | |
| 480 | + | // is not open. | |
| 481 | + | func (l *liveStream) mrMergeability(ctx context.Context) ([]fragment, error) { | |
| 482 | + | if !SignedIn(ctx) || l.s.cfg.MergeRequests == nil { | |
| 483 | + | return nil, nil // the box is the owner's | |
| 484 | + | } | |
| 485 | + | name, err := domain.ParseRepoName(l.repo) | |
| 486 | + | if err != nil { | |
| 487 | + | return nil, nil | |
| 488 | + | } | |
| 489 | + | n, err := strconv.ParseInt(l.n, 10, 64) | |
| 490 | + | if err != nil { | |
| 491 | + | return nil, nil | |
| 492 | + | } | |
| 493 | + | m, err := l.s.cfg.MergeRequests.Mergeability(ctx, principal(ctx), name, n) | |
| 494 | + | switch { | |
| 495 | + | case errors.Is(err, domain.ErrNotFound): | |
| 496 | + | return gap, nil | |
| 497 | + | case err != nil: | |
| 498 | + | return nil, err | |
| 499 | + | case m.State == "": | |
| 500 | + | return nil, nil | |
| 501 | + | } | |
| 502 | + | f, err := renderFragment(ctx, "mr-mergeability", MRMergeabilityFragment(mergeabilityView(m, l.target))) | |
| 503 | + | return []fragment{f}, err | |
| 454 | 504 | } | |
| 455 | 505 | ||
| 456 | 506 | func (l *liveStream) notice(ctx context.Context, text, href string) ([]fragment, error) { | |
M internal/adapters/web/fragments.templ +1 -1
| @@ -102,7 +102,7 @@ templ Live(l Layout) { | |||
| 102 | 102 | htmx:sse:close, htmx:sse:error -> ermiteLive.lost(); | |
| 103 | 103 | htmx:sse:error -> ermiteLive.error(event.detail.status); | |
| 104 | 104 | htmx:sse:before:connection -> ermiteLive.gate(event); | |
| 105 | - | home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data); | |
| 105 | + | home-list, repo-last-push, refs-list, commits-head, mr-notice, mr-mergeability, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data); | |
| 106 | 106 | gap -> ermiteLive.reload()" | |
| 107 | 107 | ></span> | |
| 108 | 108 | } | |
M internal/adapters/web/fragments_templ.go +1 -1
| @@ -517,7 +517,7 @@ func Live(l Layout) templ.Component { | |||
| 517 | 517 | if templ_7745c5c3_Err != nil { | |
| 518 | 518 | return templ_7745c5c3_Err | |
| 519 | 519 | } | |
| 520 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "\" hx-swap=\"none\" :text=\"data.state === 'offline' ? 'live updates paused' : ''\" hx-on=\"htmx:sse:after:connection -> ermiteLive.back();\n htmx:sse:close, htmx:sse:error -> ermiteLive.lost();\n htmx:sse:error -> ermiteLive.error(event.detail.status);\n htmx:sse:before:connection -> ermiteLive.gate(event);\n home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data);\n gap -> ermiteLive.reload()\"></span>") | |
| 520 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "\" hx-swap=\"none\" :text=\"data.state === 'offline' ? 'live updates paused' : ''\" hx-on=\"htmx:sse:after:connection -> ermiteLive.back();\n htmx:sse:close, htmx:sse:error -> ermiteLive.lost();\n htmx:sse:error -> ermiteLive.error(event.detail.status);\n htmx:sse:before:connection -> ermiteLive.gate(event);\n home-list, repo-last-push, refs-list, commits-head, mr-notice, mr-mergeability, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data);\n gap -> ermiteLive.reload()\"></span>") | |
| 521 | 521 | if templ_7745c5c3_Err != nil { | |
| 522 | 522 | return templ_7745c5c3_Err | |
| 523 | 523 | } | |
M internal/adapters/web/fragments_test.go +1 -0
| @@ -33,6 +33,7 @@ func TestLiveSlotsAreStableStatusRegions(t *testing.T) { | |||
| 33 | 33 | }{ | |
| 34 | 34 | {"empty commits slot", LiveSlot("commits-head"), "commits-head", ""}, | |
| 35 | 35 | {"empty mr slot", LiveSlot("mr-notice"), "mr-notice", ""}, | |
| 36 | + | {"mergeability slot, as sent by the stream", MRMergeabilityFragment(MergeabilityView{State: "up_to_date", Label: "Ready to merge", FFOk: true, MergeOk: true}), "mr-mergeability", "Ready to merge"}, | |
| 36 | 37 | {"commits banner", CommitsBanner("2 new commits — reload", "/x/commits/main"), "commits-head", "2 new commits — reload"}, | |
| 37 | 38 | {"mr notice", MRNotice("a comment was added — reload", "/x/merge-requests/1"), "mr-notice", "a comment was added — reload"}, | |
| 38 | 39 | } { | |
M internal/adapters/web/mr.go +156 -18
| @@ -196,10 +196,38 @@ type MergeBox struct { | |||
| 196 | 196 | // CanDeleteBranch offers to delete the source after the merge: it is not | |
| 197 | 197 | // the default branch. The server checks again, with the open requests. | |
| 198 | 198 | CanDeleteBranch bool | |
| 199 | + | // Mergeability is the live badge of the box and what the modes depend on. | |
| 200 | + | Mergeability MergeabilityView | |
| 201 | + | // Signed is true when a mode that creates a commit (squash, merge commit) | |
| 202 | + | // is offered; Fingerprint is then the one of the key that signs it, empty | |
| 203 | + | // when the server does not tell. | |
| 204 | + | Signed bool | |
| 205 | + | Fingerprint string | |
| 206 | + | // Error is why the last merge was refused, shown in the box. | |
| 207 | + | Error string | |
| 199 | 208 | } | |
| 200 | 209 | ||
| 201 | - | // ModeOption is a merge mode the repository allows. | |
| 202 | - | type ModeOption struct{ Value, Label string } | |
| 210 | + | // ModeOption is a merge mode the repository allows. Disabled says that the | |
| 211 | + | // mode is not possible for the current state of the request, Reason why. | |
| 212 | + | type ModeOption struct { | |
| 213 | + | Value, Label string | |
| 214 | + | Disabled bool | |
| 215 | + | Reason string | |
| 216 | + | } | |
| 217 | + | ||
| 218 | + | // MergeabilityView is the mergeability of an open request, ready to show: the | |
| 219 | + | // badge, and what the merge modes depend on (the flags are what the controls | |
| 220 | + | // of the box read, live). | |
| 221 | + | type MergeabilityView struct { | |
| 222 | + | State, Label, Detail string | |
| 223 | + | Paths []string | |
| 224 | + | // PathsNote says that more paths conflict than are listed. | |
| 225 | + | PathsNote string | |
| 226 | + | // FFOk and MergeOk say whether fast-forward, squash and merge commit are | |
| 227 | + | // possible; the reasons say why not. | |
| 228 | + | FFOk, MergeOk bool | |
| 229 | + | FFReason, MergeReason string | |
| 230 | + | } | |
| 203 | 231 | ||
| 204 | 232 | // SinceNav switches between the whole diff and the diff since the last read. | |
| 205 | 233 | type SinceNav struct { | |
| @@ -233,6 +261,16 @@ type MRPage struct { | |||
| 233 | 261 | MergedShort string | |
| 234 | 262 | MergedHref string | |
| 235 | 263 | MergeMode string | |
| 264 | + | // Deleting is the source branch that a merge just asked to delete: the | |
| 265 | + | // deletion follows the answer. | |
| 266 | + | Deleting string | |
| 267 | + | // MergeError is why a merge was refused when the page has no merge box | |
| 268 | + | // to show it in (the request is not open any more). | |
| 269 | + | MergeError string | |
| 270 | + | // Canonical is the address of the request, set when the page answers a | |
| 271 | + | // refused action from the address of that action: the script puts it in | |
| 272 | + | // the address bar, so that a reload does not post again. | |
| 273 | + | Canonical string | |
| 236 | 274 | ||
| 237 | 275 | Since *SinceNav | |
| 238 | 276 | SinceNote string | |
| @@ -346,10 +384,59 @@ func modeName(m domain.MergeMode) string { | |||
| 346 | 384 | return "fast-forward" | |
| 347 | 385 | case domain.MergeModeSquash: | |
| 348 | 386 | return "squash" | |
| 387 | + | case domain.MergeModeMerge: | |
| 388 | + | return "merge commit" | |
| 349 | 389 | } | |
| 350 | 390 | return "" | |
| 351 | 391 | } | |
| 352 | 392 | ||
| 393 | + | // mergeabilityView converts the mergeability of a request into the badge and | |
| 394 | + | // the flags of the controls of the merge box. A request without result is | |
| 395 | + | // being checked. | |
| 396 | + | func mergeabilityView(m domain.Mergeability, target string) MergeabilityView { | |
| 397 | + | v := MergeabilityView{State: string(domain.MergeabilityChecking), Label: "Checking…"} | |
| 398 | + | v.FFOk, v.FFReason = m.Allows(domain.MergeModeFastForward) | |
| 399 | + | v.MergeOk, v.MergeReason = m.Allows(domain.MergeModeSquash) | |
| 400 | + | target = visibleText(target) | |
| 401 | + | switch m.State { | |
| 402 | + | case domain.MergeabilityUpToDate: | |
| 403 | + | v.State, v.Label = string(m.State), "Ready to merge" | |
| 404 | + | case domain.MergeabilityBehind: | |
| 405 | + | v.State, v.Label, v.Detail = string(m.State), "Behind "+target+" by "+behindText(m), "merges without conflicts" | |
| 406 | + | case domain.MergeabilityConflict: | |
| 407 | + | v.State, v.Label, v.Detail = string(m.State), "Conflicts with "+target, "rebase needed" | |
| 408 | + | v.Paths, v.PathsNote = conflictList(m) | |
| 409 | + | } | |
| 410 | + | return v | |
| 411 | + | } | |
| 412 | + | ||
| 413 | + | // behindText is "2 commits", "1 commit" or "1000+ commits". | |
| 414 | + | func behindText(m domain.Mergeability) string { | |
| 415 | + | if m.Behind == 1 && !m.BehindCapped { | |
| 416 | + | return "1 commit" | |
| 417 | + | } | |
| 418 | + | n := itoa(m.Behind) | |
| 419 | + | if m.BehindCapped { | |
| 420 | + | n += "+" | |
| 421 | + | } | |
| 422 | + | return n + " commits" | |
| 423 | + | } | |
| 424 | + | ||
| 425 | + | // conflictList is the conflicting paths to show, and the note that says what | |
| 426 | + | // the list lacks. | |
| 427 | + | func conflictList(m domain.Mergeability) (paths []string, note string) { | |
| 428 | + | for _, p := range m.ConflictPaths { | |
| 429 | + | paths = append(paths, visibleText(p)) | |
| 430 | + | } | |
| 431 | + | switch { | |
| 432 | + | case len(paths) == 0: | |
| 433 | + | note = "no common history." | |
| 434 | + | case m.ConflictPathsTruncated: | |
| 435 | + | note = "more files conflict than are listed." | |
| 436 | + | } | |
| 437 | + | return paths, note | |
| 438 | + | } | |
| 439 | + | ||
| 353 | 440 | // mrRow converts a merge request of a listing. | |
| 354 | 441 | func (s *Server) mrRow(repo string, m domain.MergeRequest) MRRow { | |
| 355 | 442 | row := MRRow{ | |
| @@ -540,6 +627,9 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ | |||
| 540 | 627 | p.MergedHref = repoHref(d.name(), "commit", m.MergedCommitID.String()) | |
| 541 | 628 | p.MergeMode = modeName(m.MergeMode) | |
| 542 | 629 | } | |
| 630 | + | if signed && m.State == domain.MRStateMerged && r.URL.Query().Get("deleting") == "1" && d.hasBranch(m.Source) { | |
| 631 | + | p.Deleting = visibleText(m.Source) | |
| 632 | + | } | |
| 543 | 633 | for _, mode := range d.repo.Settings.AllowedMergeModes { | |
| 544 | 634 | if name := modeName(mode); name != "" { | |
| 545 | 635 | p.Modes = append(p.Modes, name) | |
| @@ -624,11 +714,18 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ | |||
| 624 | 714 | p.Invisible = invisible | |
| 625 | 715 | p.DiffView = newDiffView(r, lines, withFrom(self+"?view=unified", from), withFrom(self+"?view=split", from)) | |
| 626 | 716 | if signed && open { | |
| 627 | - | p.Box = s.mergeBox(d, n, m.Source, head, lastRead) | |
| 717 | + | p.Box = s.mergeBox(d, n, m.Source, head, lastRead, mergeabilityView(res.Mergeability, m.Target)) | |
| 718 | + | p.Box.Error = mergeErrorOf(ctx) | |
| 628 | 719 | if m.IsDraft() { | |
| 629 | 720 | p.Box.ReadyAction = mrHref(d.name(), n, "ready") | |
| 630 | 721 | } | |
| 631 | 722 | } | |
| 723 | + | if mergeErrorOf(ctx) != "" { | |
| 724 | + | p.Canonical = self | |
| 725 | + | } | |
| 726 | + | if p.Box == nil { | |
| 727 | + | p.MergeError = mergeErrorOf(ctx) // the request is not open any more | |
| 728 | + | } | |
| 632 | 729 | if canWrite { | |
| 633 | 730 | p.GeneralForm = &CommentForm{ | |
| 634 | 731 | ID: "cf-general", Action: mrHref(d.name(), n, "comments"), Title: "comment on the merge request", | |
| @@ -652,7 +749,7 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ | |||
| 652 | 749 | l.Crumbs = append(l.Crumbs, Crumb{Label: "since", Current: true}) | |
| 653 | 750 | } | |
| 654 | 751 | p.Live = true | |
| 655 | - | l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=mr&n="+strconv.FormatInt(n, 10)+"&ref="+url.QueryEscape(m.Source)+"&head="+url.QueryEscape(head)) | |
| 752 | + | l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=mr&n="+strconv.FormatInt(n, 10)+"&ref="+url.QueryEscape(m.Source)+"&head="+url.QueryEscape(head)+"&target="+url.QueryEscape(m.Target)) | |
| 656 | 753 | l.HasRail = true | |
| 657 | 754 | l.MR = true | |
| 658 | 755 | return MergeRequest(l, p), http.StatusOK, nil | |
| @@ -669,37 +766,78 @@ func (d *repoData) hasBranch(name string) bool { | |||
| 669 | 766 | } | |
| 670 | 767 | ||
| 671 | 768 | // mergeBox is what the owner can do with an open request. | |
| 672 | - | func (s *Server) mergeBox(d *repoData, n int64, source, head, lastRead string) *MergeBox { | |
| 769 | + | func (s *Server) mergeBox(d *repoData, n int64, source, head, lastRead string, mg MergeabilityView) *MergeBox { | |
| 673 | 770 | b := &MergeBox{ | |
| 674 | 771 | MergeAction: mrHref(d.name(), n, "merge"), CloseAction: mrHref(d.name(), n, "close"), | |
| 675 | 772 | ReadAction: mrHref(d.name(), n, "read"), Head: head, HeadGone: head == "", | |
| 676 | 773 | CanMarkRead: head != "" && head != lastRead, | |
| 677 | 774 | CanDeleteBranch: source != d.repo.DefaultBranch, | |
| 775 | + | Mergeability: mg, | |
| 776 | + | } | |
| 777 | + | var lines []string | |
| 778 | + | cmd := "" | |
| 779 | + | if head != "" { | |
| 780 | + | cmd = "ermite mr merge " + shellWord(d.name()) + " " + strconv.FormatInt(n, 10) + " --head " + head | |
| 678 | 781 | } | |
| 679 | - | ff, sq := false, false | |
| 680 | 782 | for _, mode := range d.repo.Settings.AllowedMergeModes { | |
| 681 | 783 | switch mode { | |
| 682 | 784 | case domain.MergeModeFastForward: | |
| 683 | - | ff = true | |
| 684 | - | b.Modes = append(b.Modes, ModeOption{Value: "fast-forward", Label: "fast-forward"}) | |
| 785 | + | b.Modes = append(b.Modes, ModeOption{Value: "fast-forward", Label: "fast-forward", Disabled: !mg.FFOk, Reason: reasonIf(!mg.FFOk, mg.FFReason)}) | |
| 786 | + | lines = append(lines, cmd) | |
| 685 | 787 | case domain.MergeModeSquash: | |
| 686 | - | sq = true | |
| 687 | - | b.Modes = append(b.Modes, ModeOption{Value: "squash", Label: "squash (one signed commit)"}) | |
| 788 | + | b.Signed = true | |
| 789 | + | b.Modes = append(b.Modes, ModeOption{Value: "squash", Label: "squash (one signed commit)", Disabled: !mg.MergeOk, Reason: reasonIf(!mg.MergeOk, mg.MergeReason)}) | |
| 790 | + | lines = append(lines, cmd+" --mode squash") | |
| 791 | + | case domain.MergeModeMerge: | |
| 792 | + | b.Signed = true | |
| 793 | + | b.Modes = append(b.Modes, ModeOption{Value: "merge", Label: "merge commit (signed)", Disabled: !mg.MergeOk, Reason: reasonIf(!mg.MergeOk, mg.MergeReason)}) | |
| 794 | + | lines = append(lines, cmd+" --mode merge") | |
| 688 | 795 | } | |
| 689 | 796 | } | |
| 797 | + | if b.Signed && s.cfg.SigningKey != nil { | |
| 798 | + | b.Fingerprint = s.cfg.SigningKey.Info().Key.Fingerprint | |
| 799 | + | } | |
| 690 | 800 | if head != "" { | |
| 691 | - | cmd := "ermite mr merge " + shellWord(d.name()) + " " + strconv.FormatInt(n, 10) + " --head " + head | |
| 692 | - | b.CLI = Clone{ID: "cli-merge"} | |
| 693 | - | if ff { | |
| 694 | - | b.CLI.Lines = append(b.CLI.Lines, cmd) | |
| 695 | - | } | |
| 696 | - | if sq { | |
| 697 | - | b.CLI.Lines = append(b.CLI.Lines, cmd+" --squash") | |
| 698 | - | } | |
| 801 | + | b.CLI = Clone{ID: "cli-merge", Lines: lines} | |
| 699 | 802 | } | |
| 700 | 803 | return b | |
| 701 | 804 | } | |
| 702 | 805 | ||
| 806 | + | func reasonIf(cond bool, reason string) string { | |
| 807 | + | if cond { | |
| 808 | + | return reason | |
| 809 | + | } | |
| 810 | + | return "" | |
| 811 | + | } | |
| 812 | + | ||
| 813 | + | // firstModeOK is the index of the first mode that is possible, -1 for none. | |
| 814 | + | func (b *MergeBox) firstModeOK() int { | |
| 815 | + | for i, m := range b.Modes { | |
| 816 | + | if !m.Disabled { | |
| 817 | + | return i | |
| 818 | + | } | |
| 819 | + | } | |
| 820 | + | return -1 | |
| 821 | + | } | |
| 822 | + | ||
| 823 | + | // Checked is the mode the form has checked: the first that is possible, else | |
| 824 | + | // the first (it is disabled, and a live update may enable it). | |
| 825 | + | func (b *MergeBox) Checked() int { return max(b.firstModeOK(), 0) } | |
| 826 | + | ||
| 827 | + | // CanMerge tells whether some mode is possible: the button is enabled. | |
| 828 | + | func (b *MergeBox) CanMerge() bool { return b.firstModeOK() >= 0 } | |
| 829 | + | ||
| 830 | + | // MergeTitle is why the button is disabled, empty when it is not. | |
| 831 | + | func (b *MergeBox) MergeTitle() string { | |
| 832 | + | if b.CanMerge() { | |
| 833 | + | return "" | |
| 834 | + | } | |
| 835 | + | if len(b.Modes) == 0 { | |
| 836 | + | return "the repository allows no merge mode" | |
| 837 | + | } | |
| 838 | + | return b.Modes[0].Reason | |
| 839 | + | } | |
| 840 | + | ||
| 703 | 841 | // shellWord quotes s for a shell unless it is made of safe characters, which | |
| 704 | 842 | // a repository name always is. | |
| 705 | 843 | func shellWord(s string) string { | |
M internal/adapters/web/mr.templ +92 -4
| @@ -2,7 +2,10 @@ | |||
| 2 | 2 | ||
| 3 | 3 | package web | |
| 4 | 4 | ||
| 5 | - | import "strings" | |
| 5 | + | import ( | |
| 6 | + | "strconv" | |
| 7 | + | "strings" | |
| 8 | + | ) | |
| 6 | 9 | ||
| 7 | 10 | // MRList is the list of the merge requests of a repository: the open ones, | |
| 8 | 11 | // then the merged and closed ones. | |
| @@ -138,6 +141,12 @@ templ MergeRequest(l Layout, p MRPage) { | |||
| 138 | 141 | } | |
| 139 | 142 | </dl> | |
| 140 | 143 | </header> | |
| 144 | + | if p.Deleting != "" { | |
| 145 | + | <p class="note" role="status">merged. the branch { p.Deleting } is being deleted.</p> | |
| 146 | + | } | |
| 147 | + | if p.MergeError != "" { | |
| 148 | + | <p class="d-err" role="alert">{ p.MergeError }</p> | |
| 149 | + | } | |
| 141 | 150 | @LiveSlot("mr-notice") | |
| 142 | 151 | if p.Desc != "" { | |
| 143 | 152 | <article class="mr-desc prose" aria-label="description" hx-ignore> | |
| @@ -176,6 +185,9 @@ templ MergeRequest(l Layout, p MRPage) { | |||
| 176 | 185 | <div | |
| 177 | 186 | id="diffs" | |
| 178 | 187 | data-mr | |
| 188 | + | if p.Canonical != "" { | |
| 189 | + | data-canonical={ p.Canonical } | |
| 190 | + | } | |
| 179 | 191 | if p.Both { | |
| 180 | 192 | data-diff={ p.View } | |
| 181 | 193 | } else { | |
| @@ -446,8 +458,13 @@ templ mrBar(p MRPage) { | |||
| 446 | 458 | // mergeMenu is the merge of an open request, for the owner: the form (it | |
| 447 | 459 | // carries the head that was read), or "Ready for review" for a draft. The | |
| 448 | 460 | // mode and the branch deletion have keys (1-3, D) while it is open. | |
| 461 | + | // | |
| 462 | + | // The badge is a live slot (mr-mergeability); the modes and the button read | |
| 463 | + | // its data-* attributes (hx-live bindings), so that they follow the slot when | |
| 464 | + | // the stream replaces its content. The server renders the same state in the | |
| 465 | + | // attributes, for a page without scripts. | |
| 449 | 466 | templ mergeMenu(p MRPage) { | |
| 450 | - | <details class="menu mmenu" id="merge-menu" data-state="open"> | |
| 467 | + | <details class="menu mmenu" id="merge-menu" data-state="open" open?={ p.Box.Error != "" }> | |
| 451 | 468 | <summary class="btn primary" aria-keyshortcuts="Shift+M"> | |
| 452 | 469 | if p.Box.ReadyAction != "" { | |
| 453 | 470 | Draft | |
| @@ -469,19 +486,32 @@ templ mergeMenu(p MRPage) { | |||
| 469 | 486 | <dt>into</dt> | |
| 470 | 487 | <dd>{ p.Target }</dd> | |
| 471 | 488 | </dl> | |
| 489 | + | if p.Box.Error != "" { | |
| 490 | + | <p class="d-err" role="alert">{ p.Box.Error }</p> | |
| 491 | + | } | |
| 472 | 492 | if p.Box.ReadyAction != "" { | |
| 473 | 493 | <p class="d-draft">this is a draft: it cannot be merged until it is ready for review.</p> | |
| 474 | 494 | <form class="d-btns" method="post" action={ templ.SafeURL(p.Box.ReadyAction) }> | |
| 475 | 495 | <button type="submit" class="btn primary" data-default title="remove the draft prefix from the title">Ready for review</button> | |
| 476 | 496 | </form> | |
| 477 | 497 | } else if !p.Box.HeadGone { | |
| 498 | + | @mrMergeabilitySlot(p.Box.Mergeability) | |
| 478 | 499 | <form class="d-acts" method="post" action={ templ.SafeURL(p.Box.MergeAction) }> | |
| 479 | 500 | <input type="hidden" name="head_id" value={ p.Box.Head }/> | |
| 480 | 501 | <fieldset class="d-modes"> | |
| 481 | 502 | <legend class="sr">merge mode</legend> | |
| 482 | 503 | for i, m := range p.Box.Modes { | |
| 483 | 504 | <label> | |
| 484 | - | <input type="radio" name="mode" value={ m.Value } checked?={ i == 0 }/> | |
| 505 | + | <input | |
| 506 | + | type="radio" | |
| 507 | + | name="mode" | |
| 508 | + | value={ m.Value } | |
| 509 | + | checked?={ i == p.Box.Checked() } | |
| 510 | + | disabled?={ m.Disabled } | |
| 511 | + | title={ m.Reason } | |
| 512 | + | :disabled="this.value === 'fast-forward' ? q('[data-mergeability]').data.ffOk !== true : q('[data-mergeability]').data.mergeOk !== true" | |
| 513 | + | :title="this.value === 'fast-forward' ? q('[data-mergeability]').data.ffReason : q('[data-mergeability]').data.mergeReason" | |
| 514 | + | /> | |
| 485 | 515 | { m.Label } | |
| 486 | 516 | if i < 9 { | |
| 487 | 517 | <kbd aria-hidden="true">{ itoa(i + 1) }</kbd> | |
| @@ -489,11 +519,28 @@ templ mergeMenu(p MRPage) { | |||
| 489 | 519 | </label> | |
| 490 | 520 | } | |
| 491 | 521 | </fieldset> | |
| 522 | + | if p.Box.Signed { | |
| 523 | + | <p class="d-note" id="mr-signing"> | |
| 524 | + | squash and merge commits are signed with ermite's own key (generated on first start). | |
| 525 | + | if p.Box.Fingerprint != "" { | |
| 526 | + | fingerprint <code>{ p.Box.Fingerprint }</code> | |
| 527 | + | } | |
| 528 | + | <code>ermite signing-key show</code> prints it. | |
| 529 | + | </p> | |
| 530 | + | } | |
| 492 | 531 | if p.Box.CanDeleteBranch { | |
| 493 | 532 | <label class="d-check"><input type="checkbox" name="delete_branch" value="1"/> delete { p.Source } after the merge <kbd aria-hidden="true">D</kbd></label> | |
| 494 | 533 | } | |
| 495 | 534 | <div class="d-btns"> | |
| 496 | - | <button type="submit" class="btn primary" data-default>Merge into { p.Target }</button> | |
| 535 | + | <button | |
| 536 | + | type="submit" | |
| 537 | + | class="btn primary" | |
| 538 | + | data-default | |
| 539 | + | disabled?={ !p.Box.CanMerge() } | |
| 540 | + | title={ p.Box.MergeTitle() } | |
| 541 | + | :disabled="!this.form.querySelector('input[name=mode]:checked:not(:disabled)')" | |
| 542 | + | :title="(this.form.querySelector('input[name=mode]:checked') || {}).title || ''" | |
| 543 | + | >Merge into { p.Target }</button> | |
| 497 | 544 | </div> | |
| 498 | 545 | </form> | |
| 499 | 546 | } | |
| @@ -501,6 +548,47 @@ templ mergeMenu(p MRPage) { | |||
| 501 | 548 | </details> | |
| 502 | 549 | } | |
| 503 | 550 | ||
| 551 | + | // MRMergeability is the content of the mergeability slot: the badge, and the | |
| 552 | + | // state the controls of the merge box read. | |
| 553 | + | templ MRMergeability(v MergeabilityView) { | |
| 554 | + | <span | |
| 555 | + | data-mergeability | |
| 556 | + | data-state={ v.State } | |
| 557 | + | data-ff-ok={ strconv.FormatBool(v.FFOk) } | |
| 558 | + | data-merge-ok={ strconv.FormatBool(v.MergeOk) } | |
| 559 | + | data-ff-reason={ v.FFReason } | |
| 560 | + | data-merge-reason={ v.MergeReason } | |
| 561 | + | > | |
| 562 | + | <span class="badge" data-state={ v.State }>{ v.Label }</span> | |
| 563 | + | if v.Detail != "" { | |
| 564 | + | { " " }<span class="dim">{ v.Detail }</span> | |
| 565 | + | } | |
| 566 | + | </span> | |
| 567 | + | if len(v.Paths) > 0 { | |
| 568 | + | <ul class="mg-paths" aria-label="conflicting files"> | |
| 569 | + | for _, path := range v.Paths { | |
| 570 | + | <li><code>{ path }</code></li> | |
| 571 | + | } | |
| 572 | + | </ul> | |
| 573 | + | } | |
| 574 | + | if v.PathsNote != "" { | |
| 575 | + | <p class="dim">{ v.PathsNote }</p> | |
| 576 | + | } | |
| 577 | + | } | |
| 578 | + | ||
| 579 | + | // mrMergeabilitySlot is the status region of the badge, with its content: the | |
| 580 | + | // stream replaces the children, never the element (see LiveSlot). | |
| 581 | + | templ mrMergeabilitySlot(v MergeabilityView) { | |
| 582 | + | <div id="mr-mergeability" class="live-slot" role="status" data-slot> | |
| 583 | + | @MRMergeability(v) | |
| 584 | + | </div> | |
| 585 | + | } | |
| 586 | + | ||
| 587 | + | // MRMergeabilityFragment is the slot as the stream sends it. | |
| 588 | + | templ MRMergeabilityFragment(v MergeabilityView) { | |
| 589 | + | @mrMergeabilitySlot(v) | |
| 590 | + | } | |
| 591 | + | ||
| 504 | 592 | // moreMenu is what else the owner does with an open request: mark the head | |
| 505 | 593 | // read, close it, merge it from a terminal. | |
| 506 | 594 | templ moreMenu(p MRPage) { | |
M internal/adapters/web/mr_post.go +55 -3
| @@ -60,6 +60,10 @@ func (s *Server) mrAction(h func(ctx context.Context, r *http.Request) (to strin | |||
| 60 | 60 | return | |
| 61 | 61 | } | |
| 62 | 62 | to, back, err := h(ctx, r) | |
| 63 | + | var re *rerender | |
| 64 | + | if errors.As(err, &re) && s.rerenderMR(ctx, w, r, re) { | |
| 65 | + | return | |
| 66 | + | } | |
| 63 | 67 | if err != nil { | |
| 64 | 68 | s.actionFailed(ctx, w, r, err, back) | |
| 65 | 69 | return | |
| @@ -68,6 +72,43 @@ func (s *Server) mrAction(h func(ctx context.Context, r *http.Request) (to strin | |||
| 68 | 72 | } | |
| 69 | 73 | } | |
| 70 | 74 | ||
| 75 | + | // rerender is the error of a merge the use case refused for a reason the | |
| 76 | + | // owner reads on the page of the request: the page is shown again, with the | |
| 77 | + | // status, and the reason in its merge box. Any other error keeps actionFailed. | |
| 78 | + | type rerender struct { | |
| 79 | + | status int | |
| 80 | + | msg string | |
| 81 | + | err error | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | func (e *rerender) Error() string { return e.err.Error() } | |
| 85 | + | func (e *rerender) Unwrap() error { return e.err } | |
| 86 | + | ||
| 87 | + | type mergeErrorKey struct{} | |
| 88 | + | ||
| 89 | + | // withMergeError carries the reason of a refused merge to the page. | |
| 90 | + | func withMergeError(ctx context.Context, msg string) context.Context { | |
| 91 | + | return context.WithValue(ctx, mergeErrorKey{}, msg) | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | func mergeErrorOf(ctx context.Context) string { | |
| 95 | + | msg, _ := ctx.Value(mergeErrorKey{}).(string) | |
| 96 | + | return msg | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | // rerenderMR writes the page of the request with the reason of the refusal. | |
| 100 | + | // It reports false when the page cannot be built; the caller then answers as | |
| 101 | + | // for any refused action. | |
| 102 | + | func (s *Server) rerenderMR(ctx context.Context, w http.ResponseWriter, r *http.Request, re *rerender) bool { | |
| 103 | + | c, _, err := s.mrPage(withMergeError(ctx, re.msg), r, false) | |
| 104 | + | if err != nil { | |
| 105 | + | return false | |
| 106 | + | } | |
| 107 | + | w.Header().Set("Cache-Control", "no-store") | |
| 108 | + | s.write(ctx, w, r, re.status, c) | |
| 109 | + | return true | |
| 110 | + | } | |
| 111 | + | ||
| 71 | 112 | // actionFailed answers a refused action. | |
| 72 | 113 | func (s *Server) actionFailed(ctx context.Context, w http.ResponseWriter, r *http.Request, err error, back string) { | |
| 73 | 114 | // The words of the use case, for the rules the owner can fix. | |
| @@ -322,8 +363,11 @@ func (s *Server) mrMerge(ctx context.Context, r *http.Request) (string, string, | |||
| 322 | 363 | mode = domain.MergeModeFastForward | |
| 323 | 364 | case "squash": | |
| 324 | 365 | mode = domain.MergeModeSquash | |
| 366 | + | case "merge": | |
| 367 | + | mode = domain.MergeModeMerge | |
| 325 | 368 | default: | |
| 326 | - | return "", back, formError("mode must be fast-forward or squash") | |
| 369 | + | err := formError("mode must be fast-forward, squash or merge") | |
| 370 | + | return "", back, &rerender{status: http.StatusBadRequest, msg: err.Error(), err: err} | |
| 327 | 371 | } | |
| 328 | 372 | name, err := domain.ParseRepoName(repo) | |
| 329 | 373 | if err != nil { | |
| @@ -333,11 +377,19 @@ func (s *Server) mrMerge(ctx context.Context, r *http.Request) (string, string, | |||
| 333 | 377 | if err != nil { | |
| 334 | 378 | return "", back, err | |
| 335 | 379 | } | |
| 336 | - | if _, err := s.cfg.MergeRequests.Merge(ctx, principal(ctx), name, n, ports.MergeParams{ | |
| 380 | + | res, err := s.cfg.MergeRequests.Merge(ctx, principal(ctx), name, n, ports.MergeParams{ | |
| 337 | 381 | Mode: mode, HeadID: head, DeleteSource: r.PostFormValue("delete_branch") == "1", | |
| 338 | - | }); err != nil { | |
| 382 | + | }) | |
| 383 | + | switch { | |
| 384 | + | case errors.Is(err, domain.ErrPreconditionFailed), errors.Is(err, domain.ErrConflict): | |
| 385 | + | return "", back, &rerender{status: http.StatusConflict, msg: rule(err.Error(), "the merge request is not in a state that allows this."), err: err} | |
| 386 | + | case err != nil: | |
| 339 | 387 | return "", back, err | |
| 340 | 388 | } | |
| 389 | + | if res.SourceDeleted { | |
| 390 | + | // The deletion follows the answer: the page says it is being done. | |
| 391 | + | return back + "?deleting=1", back, nil | |
| 392 | + | } | |
| 341 | 393 | return back, back, nil | |
| 342 | 394 | } | |
| 343 | 395 | ||
M internal/adapters/web/mr_templ.go +1188 -740 generated file: collapsed
A internal/adapters/web/mr_test.go +130 -0
| @@ -0,0 +1,130 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package web | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "testing" | |
| 8 | + | ||
| 9 | + | "github.com/stretchr/testify/assert" | |
| 10 | + | "github.com/stretchr/testify/mock" | |
| 11 | + | "github.com/stretchr/testify/require" | |
| 12 | + | ||
| 13 | + | "bnema.dev/ermite/internal/domain" | |
| 14 | + | "bnema.dev/ermite/internal/ports" | |
| 15 | + | "bnema.dev/ermite/internal/ports/mocks" | |
| 16 | + | ) | |
| 17 | + | ||
| 18 | + | func repoWithModes(modes ...domain.MergeMode) ports.RepoView { | |
| 19 | + | var v ports.RepoView | |
| 20 | + | v.Settings.AllowedMergeModes = modes | |
| 21 | + | return v | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | func watchOf(ev domain.Event) ports.WatchEvent { return ports.WatchEvent{Event: ev} } | |
| 25 | + | ||
| 26 | + | func TestMergeabilityView(t *testing.T) { | |
| 27 | + | t.Parallel() | |
| 28 | + | for _, tt := range []struct { | |
| 29 | + | name string | |
| 30 | + | m domain.Mergeability | |
| 31 | + | label, detail string | |
| 32 | + | ffOK, mergeOK bool | |
| 33 | + | note string | |
| 34 | + | paths []string | |
| 35 | + | }{ | |
| 36 | + | {"up to date", domain.Mergeability{State: domain.MergeabilityUpToDate}, "Ready to merge", "", true, true, "", nil}, | |
| 37 | + | {"behind", domain.Mergeability{State: domain.MergeabilityBehind, Behind: 2}, "Behind main by 2 commits", "merges without conflicts", false, true, "", nil}, | |
| 38 | + | {"behind by one", domain.Mergeability{State: domain.MergeabilityBehind, Behind: 1}, "Behind main by 1 commit", "merges without conflicts", false, true, "", nil}, | |
| 39 | + | {"behind, capped", domain.Mergeability{State: domain.MergeabilityBehind, Behind: 1000, BehindCapped: true}, "Behind main by 1000+ commits", "merges without conflicts", false, true, "", nil}, | |
| 40 | + | {"conflict", domain.Mergeability{State: domain.MergeabilityConflict, ConflictPaths: []string{"a.go"}}, "Conflicts with main", "rebase needed", false, false, "", []string{"a.go"}}, | |
| 41 | + | {"conflict, truncated", domain.Mergeability{State: domain.MergeabilityConflict, ConflictPaths: []string{"a.go"}, ConflictPathsTruncated: true}, "Conflicts with main", "rebase needed", false, false, "more files conflict than are listed.", []string{"a.go"}}, | |
| 42 | + | {"no common history", domain.Mergeability{State: domain.MergeabilityConflict}, "Conflicts with main", "rebase needed", false, false, "no common history.", nil}, | |
| 43 | + | {"checking", domain.Mergeability{State: domain.MergeabilityChecking}, "Checking…", "", false, false, "", nil}, | |
| 44 | + | {"none", domain.Mergeability{}, "Checking…", "", false, false, "", nil}, | |
| 45 | + | } { | |
| 46 | + | t.Run(tt.name, func(t *testing.T) { | |
| 47 | + | t.Parallel() | |
| 48 | + | v := mergeabilityView(tt.m, "main") | |
| 49 | + | assert.Equal(t, tt.label, v.Label) | |
| 50 | + | assert.Equal(t, tt.detail, v.Detail) | |
| 51 | + | assert.Equal(t, tt.ffOK, v.FFOk) | |
| 52 | + | assert.Equal(t, tt.mergeOK, v.MergeOk) | |
| 53 | + | assert.Equal(t, tt.note, v.PathsNote) | |
| 54 | + | assert.Equal(t, tt.paths, v.Paths) | |
| 55 | + | assert.Equal(t, !tt.ffOK, v.FFReason != "", "a mode that is not possible says why") | |
| 56 | + | assert.Equal(t, !tt.mergeOK, v.MergeReason != "") | |
| 57 | + | }) | |
| 58 | + | } | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | func TestMergeBoxModes(t *testing.T) { | |
| 62 | + | t.Parallel() | |
| 63 | + | d := &repoData{repo: repoWithModes(domain.MergeModes()...)} | |
| 64 | + | s := &Server{} | |
| 65 | + | ||
| 66 | + | behind := s.mergeBox(d, 1, "feat", "abc", "", mergeabilityView(domain.Mergeability{State: domain.MergeabilityBehind, Behind: 1}, "main")) | |
| 67 | + | require.Len(t, behind.Modes, 3) | |
| 68 | + | assert.True(t, behind.Modes[0].Disabled, "fast-forward is not possible when behind") | |
| 69 | + | assert.NotEmpty(t, behind.Modes[0].Reason) | |
| 70 | + | assert.False(t, behind.Modes[1].Disabled) | |
| 71 | + | assert.False(t, behind.Modes[2].Disabled) | |
| 72 | + | assert.Equal(t, 1, behind.Checked(), "the first possible mode is checked") | |
| 73 | + | assert.True(t, behind.CanMerge()) | |
| 74 | + | assert.True(t, behind.Signed) | |
| 75 | + | ||
| 76 | + | conflict := s.mergeBox(d, 1, "feat", "abc", "", mergeabilityView(domain.Mergeability{State: domain.MergeabilityConflict}, "main")) | |
| 77 | + | assert.False(t, conflict.CanMerge()) | |
| 78 | + | assert.NotEmpty(t, conflict.MergeTitle(), "the button says why it is disabled") | |
| 79 | + | assert.Equal(t, 0, conflict.Checked()) | |
| 80 | + | assert.Contains(t, conflict.CLI.Lines[2], "--mode merge") | |
| 81 | + | assert.NotContains(t, conflict.CLI.Lines[1], "--squash") | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | func TestMergeBoxOnlyFastForwardIsNotSigned(t *testing.T) { | |
| 85 | + | t.Parallel() | |
| 86 | + | d := &repoData{repo: repoWithModes(domain.MergeModeFastForward)} | |
| 87 | + | b := (&Server{}).mergeBox(d, 1, "feat", "abc", "", mergeabilityView(domain.Mergeability{State: domain.MergeabilityUpToDate}, "main")) | |
| 88 | + | assert.False(t, b.Signed, "no commit is created, nothing to explain") | |
| 89 | + | assert.Len(t, b.Modes, 1) | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | func TestMergeBoxNamesTheSigningKey(t *testing.T) { | |
| 93 | + | t.Parallel() | |
| 94 | + | key := mocks.NewSigningKeyService(t) | |
| 95 | + | key.EXPECT().Info().Return(domain.SigningKeyInfo{Key: domain.SigningPublicKey{Fingerprint: "SHA256:abc"}}) | |
| 96 | + | d := &repoData{repo: repoWithModes(domain.MergeModes()...)} | |
| 97 | + | b := (&Server{cfg: Config{SigningKey: key}}).mergeBox(d, 1, "feat", "abc", "", mergeabilityView(domain.Mergeability{}, "main")) | |
| 98 | + | assert.Equal(t, "SHA256:abc", b.Fingerprint) | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | func TestMergeErrorTravelsInTheContext(t *testing.T) { | |
| 102 | + | t.Parallel() | |
| 103 | + | assert.Empty(t, mergeErrorOf(context.Background())) | |
| 104 | + | assert.Equal(t, "boom", mergeErrorOf(withMergeError(context.Background(), "boom"))) | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | func TestMergeabilityLiveEvents(t *testing.T) { | |
| 108 | + | t.Parallel() | |
| 109 | + | assert.True(t, liveEvent(watchOf(domain.MergeRequestMergeability{Number: 1}))) | |
| 110 | + | svc := mocks.NewMergeRequestService(t) | |
| 111 | + | svc.EXPECT().Mergeability(mock.Anything, mock.Anything, mock.Anything, int64(1)). | |
| 112 | + | Return(domain.Mergeability{State: domain.MergeabilityBehind, Behind: 3}, nil) | |
| 113 | + | l := liveStream{s: &Server{cfg: Config{MergeRequests: svc}}, repo: "demo", view: "mr", n: "1", target: "main"} | |
| 114 | + | ctx := context.WithValue(context.Background(), signedInKey{}, struct{}{}) | |
| 115 | + | ||
| 116 | + | fs, err := l.repoPage(ctx, watchOf(domain.MergeRequestMergeability{Number: 1})) | |
| 117 | + | require.NoError(t, err) | |
| 118 | + | require.Len(t, fs, 1) | |
| 119 | + | assert.Equal(t, "mr-mergeability", fs[0].event) | |
| 120 | + | assert.Contains(t, fs[0].html, "Behind main by 3 commits") | |
| 121 | + | ||
| 122 | + | // Another request, or a page that is not one: nothing. | |
| 123 | + | fs, err = l.repoPage(ctx, watchOf(domain.MergeRequestMergeability{Number: 2})) | |
| 124 | + | require.NoError(t, err) | |
| 125 | + | assert.Empty(t, fs) | |
| 126 | + | // The box is the owner's. | |
| 127 | + | fs, err = l.repoPage(context.Background(), watchOf(domain.MergeRequestMergeability{Number: 1})) | |
| 128 | + | require.NoError(t, err) | |
| 129 | + | assert.Empty(t, fs) | |
| 130 | + | } | |
M internal/adapters/web/static/css/60-mr.css +22 -1
| @@ -50,6 +50,12 @@ | |||
| 50 | 50 | & .kv { margin: 0; } | |
| 51 | 51 | } | |
| 52 | 52 | .mmenu { | |
| 53 | + | & .menu-pop { box-sizing: border-box; min-width: 0; overflow-wrap: anywhere; } | |
| 54 | + | & .menu-pop > *, & form, & .d-modes, & .d-btns { min-width: 0; max-width: 100%; box-sizing: border-box; } | |
| 55 | + | & .d-acts { display: grid; gap: var(--s3); } | |
| 56 | + | & .d-modes label, & .d-check { min-width: 0; } | |
| 57 | + | & code { overflow-wrap: anywhere; } | |
| 58 | + | & .d-btns .btn { min-width: 0; max-width: 100%; white-space: normal; } | |
| 53 | 59 | & .d-modes { display: grid; gap: var(--s1); border: 0; padding: 0; margin: 0; } | |
| 54 | 60 | & .d-modes label, & .d-check { | |
| 55 | 61 | display: flex; gap: var(--s2); align-items: center; padding: 4px var(--s2); margin: 0 calc(-1 * var(--s2)); | |
| @@ -63,7 +69,22 @@ | |||
| 63 | 69 | & .d-btns { display: flex; margin-top: var(--s1); } | |
| 64 | 70 | & .d-btns .btn { flex: 1; justify-content: center; } | |
| 65 | 71 | & .d-draft { color: var(--ink-dim); } | |
| 66 | - | } | |
| 72 | + | & .d-note { margin: 0; color: var(--ink-dim); } | |
| 73 | + | & .d-err { margin: 0; padding: var(--s2) var(--s3); color: var(--ink-hi); border: 1px solid var(--red); border-left-width: 3px; border-radius: var(--r-s); overflow-wrap: anywhere; } | |
| 74 | + | /* the badge is a live slot, but not a banner */ | |
| 75 | + | & #mr-mergeability:not(:empty) { padding: 0; font-weight: normal; background: none; border: 0; display: grid; gap: var(--s1); } | |
| 76 | + | & .mg-paths { margin: 0; padding-left: 2ch; max-height: 8em; overflow: auto; } | |
| 77 | + | & .mg-paths code { overflow-wrap: anywhere; } | |
| 78 | + | & .d-modes label:has(:disabled) { color: var(--ink-mute); cursor: not-allowed; } | |
| 79 | + | & .d-modes label:has(:disabled):hover { background: none; } | |
| 80 | + | & .btn:disabled { opacity: .5; cursor: not-allowed; } | |
| 81 | + | } | |
| 82 | + | .badge[data-state] { display: inline-block; padding: 0 var(--s2); border: 1px solid var(--border-strong); border-radius: var(--r-s); font-weight: 600; color: var(--ink-hi); } | |
| 83 | + | .badge[data-state="up_to_date"] { border-color: var(--teal); color: var(--teal); } | |
| 84 | + | .badge[data-state="behind"] { border-color: var(--teal-2); } | |
| 85 | + | .badge[data-state="conflict"] { border-color: var(--red); color: var(--red); } | |
| 86 | + | .badge[data-state="checking"] { color: var(--ink-dim); border-style: dashed; } | |
| 87 | + | .note[role="status"] { color: var(--ink-dim); } | |
| 67 | 88 | .omenu { | |
| 68 | 89 | & > summary { font-size: 18px; line-height: 1; } | |
| 69 | 90 | & .menu-pop { width: 280px; } | |
M internal/adapters/web/static/js/mr.js +10 -1
| @@ -219,7 +219,12 @@ window.ermiteMR = (() => { | |||
| 219 | 219 | const box = $('input[name="delete_branch"]', m); | |
| 220 | 220 | if (box) box.checked = !box.checked; | |
| 221 | 221 | } else { | |
| 222 | - | radios[act.mode].checked = true; | |
| 222 | + | const r = radios[act.mode]; | |
| 223 | + | // A change event lets the live bindings enable the Merge button again. | |
| 224 | + | if (!r.disabled && !r.checked) { | |
| 225 | + | r.checked = true; | |
| 226 | + | r.dispatchEvent(new Event('change', { bubbles: true })); | |
| 227 | + | } | |
| 223 | 228 | } | |
| 224 | 229 | $('[data-default]', m)?.focus(); | |
| 225 | 230 | return true; | |
| @@ -291,6 +296,10 @@ window.ermiteMR = (() => { | |||
| 291 | 296 | let folded = false; | |
| 292 | 297 | try { folded = localStorage.getItem('mr-files-folded') === '1'; } catch { /* no storage: open */ } | |
| 293 | 298 | if (folded) foldFiles(true); | |
| 299 | + | // The page answers a refused action from the address of the action: show | |
| 300 | + | // the address of the request, so that a reload does not post again. | |
| 301 | + | const canonical = $('#diffs[data-canonical]')?.dataset.canonical; | |
| 302 | + | if (canonical && location.pathname !== canonical) history.replaceState(history.state, '', canonical + location.hash); | |
| 294 | 303 | watch(); | |
| 295 | 304 | // A link to a file or a comment that is not loaded yet. | |
| 296 | 305 | const hash = () => { | |
M internal/adapters/web/web.go +4 -0
| @@ -76,6 +76,10 @@ type Config struct { | |||
| 76 | 76 | // leads to it. Without it there is neither. Leave it unset rather than | |
| 77 | 77 | // assigning a typed nil pointer. | |
| 78 | 78 | Inbox ports.InboxService | |
| 79 | + | // SigningKey tells the key that signs the squash and merge commits, shown | |
| 80 | + | // in the merge box. Without it the box does not name the key. Leave it | |
| 81 | + | // unset rather than assigning a typed nil pointer. | |
| 82 | + | SigningKey ports.SigningKeyService | |
| 79 | 83 | // Runs serves the CI pages (/<repo>/actions) and the checks shown next to | |
| 80 | 84 | // commits and merge requests. Without it there are no such pages. Leave it | |
| 81 | 85 | // unset rather than assigning a typed nil pointer. | |
M internal/app/api_helpers_test.go +14 -1
| @@ -167,8 +167,21 @@ func (h *harness) watch(c *clients, resume string, topics ...string) *stream { | |||
| 167 | 167 | return s | |
| 168 | 168 | } | |
| 169 | 169 | ||
| 170 | - | // next returns the next event or fails after d. | |
| 170 | + | // next returns the next event or fails after d. Mergeability events, which | |
| 171 | + | // arrive asynchronously after a push or an opening, are skipped: use | |
| 172 | + | // nextAny to see them. | |
| 171 | 173 | func (s *stream) next(t *testing.T, d time.Duration) *pb.Event { | |
| 174 | + | t.Helper() | |
| 175 | + | for { | |
| 176 | + | ev := s.nextAny(t, d) | |
| 177 | + | if ev.GetMergeRequestMergeability() == nil { | |
| 178 | + | return ev | |
| 179 | + | } | |
| 180 | + | } | |
| 181 | + | } | |
| 182 | + | ||
| 183 | + | // nextAny returns the next event, whatever its kind, or fails after d. | |
| 184 | + | func (s *stream) nextAny(t *testing.T, d time.Duration) *pb.Event { | |
| 172 | 185 | t.Helper() | |
| 173 | 186 | select { | |
| 174 | 187 | case ev, ok := <-s.events: | |
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", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures"}, resp.Msg.GetFeatures(), who) | |
| 199 | + | assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures"}, resp.Msg.GetFeatures(), who) | |
| 200 | 200 | assert.NotEmpty(t, resp.Msg.GetVersion()) | |
| 201 | 201 | } | |
| 202 | 202 | }) | |
M internal/app/ci_schedule_trust_test.go +6 -2
| @@ -3,6 +3,7 @@ | |||
| 3 | 3 | package app | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | + | "errors" | |
| 6 | 7 | "strings" | |
| 7 | 8 | "testing" | |
| 8 | 9 | "time" | |
| @@ -230,8 +231,11 @@ func TestMergeRecordsTheTargetAndDropsTheSourceRecord(t *testing.T) { | |||
| 230 | 231 | })) | |
| 231 | 232 | require.NoError(t, err) | |
| 232 | 233 | ||
| 233 | - | _, err = tips.GetRefTip(c.ctx, c.name("proj"), "refs/heads/feat") | |
| 234 | - | assert.ErrorIs(t, err, domain.ErrNotFound, "the deleted source branch has no record") | |
| 234 | + | // The deletion and the removal of the record run after the merge returned. | |
| 235 | + | c.waitFor("the deleted source branch has no record", func() bool { | |
| 236 | + | _, err := tips.GetRefTip(c.ctx, c.name("proj"), "refs/heads/feat") | |
| 237 | + | return errors.Is(err, domain.ErrNotFound) | |
| 238 | + | }) | |
| 235 | 239 | main, err := tips.GetRefTip(c.ctx, c.name("proj"), "refs/heads/main") | |
| 236 | 240 | require.NoError(t, err) | |
| 237 | 241 | assert.Equal(t, c.ref("refs/heads/main"), main.Sha, "the merge recorded the new tip") | |
M internal/app/cli_e2e_test.go +14 -2
| @@ -123,8 +123,20 @@ func (c *cliHarness) startWatch(t *testing.T, secret string, args ...string) *wa | |||
| 123 | 123 | return w | |
| 124 | 124 | } | |
| 125 | 125 | ||
| 126 | - | // next decodes the next event line or fails after d. | |
| 126 | + | // next returns the next event line or fails after d. Mergeability events, | |
| 127 | + | // which arrive asynchronously after a push or an opening, are skipped. | |
| 127 | 128 | func (w *watchProc) next(t *testing.T, d time.Duration) (raw string) { | |
| 129 | + | t.Helper() | |
| 130 | + | for { | |
| 131 | + | l := w.nextAny(t, d) | |
| 132 | + | if !strings.Contains(l, `"type":"`+eventMRMergeability+`"`) { | |
| 133 | + | return l | |
| 134 | + | } | |
| 135 | + | } | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | // nextAny returns the next event line, whatever its kind, or fails after d. | |
| 139 | + | func (w *watchProc) nextAny(t *testing.T, d time.Duration) (raw string) { | |
| 128 | 140 | t.Helper() | |
| 129 | 141 | select { | |
| 130 | 142 | case l, ok := <-w.lines: | |
| @@ -338,7 +350,7 @@ func TestAC6CLIEndToEnd(t *testing.T) { | |||
| 338 | 350 | ver := decode[versionOut](t, res.stdout) | |
| 339 | 351 | assert.Equal(t, c.url, ver.Server.URL) | |
| 340 | 352 | assert.NotEmpty(t, ver.Server.Version) | |
| 341 | - | assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures"}, ver.Server.Features) | |
| 353 | + | assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures"}, ver.Server.Features) | |
| 342 | 354 | assert.Equal(t, []string{"passkeys"}, ver.Server.MissingFeatures) | |
| 343 | 355 | stdout, stderr, code = c.bin(t, secret, "version") | |
| 344 | 356 | require.Equal(t, 0, code, stderr) | |
M internal/app/cli_mr.go +150 -24
| @@ -8,6 +8,7 @@ import ( | |||
| 8 | 8 | "fmt" | |
| 9 | 9 | "io" | |
| 10 | 10 | "net/url" | |
| 11 | + | "slices" | |
| 11 | 12 | "strconv" | |
| 12 | 13 | "strings" | |
| 13 | 14 | ||
| @@ -31,6 +32,7 @@ const ( | |||
| 31 | 32 | ||
| 32 | 33 | modeFastForward = "fast-forward" | |
| 33 | 34 | modeSquash = "squash" | |
| 35 | + | modeMerge = "merge" | |
| 34 | 36 | ) | |
| 35 | 37 | ||
| 36 | 38 | func stateName(s ermite.MergeRequestState) string { | |
| @@ -65,6 +67,8 @@ func modeName(m ermite.MergeMode) string { | |||
| 65 | 67 | return modeFastForward | |
| 66 | 68 | case ermite.MergeMode_MERGE_MODE_SQUASH: | |
| 67 | 69 | return modeSquash | |
| 70 | + | case ermite.MergeMode_MERGE_MODE_MERGE_COMMIT: | |
| 71 | + | return modeMerge | |
| 68 | 72 | } | |
| 69 | 73 | return "" | |
| 70 | 74 | } | |
| @@ -75,8 +79,10 @@ func parseMode(s string) (ermite.MergeMode, error) { | |||
| 75 | 79 | return ermite.MergeMode_MERGE_MODE_FAST_FORWARD, nil | |
| 76 | 80 | case modeSquash: | |
| 77 | 81 | return ermite.MergeMode_MERGE_MODE_SQUASH, nil | |
| 82 | + | case modeMerge: | |
| 83 | + | return ermite.MergeMode_MERGE_MODE_MERGE_COMMIT, nil | |
| 78 | 84 | } | |
| 79 | - | return 0, fmt.Errorf("invalid merge mode %q: use %s or %s", s, modeFastForward, modeSquash) | |
| 85 | + | return 0, fmt.Errorf("invalid merge mode %q: use %s, %s or %s", s, modeFastForward, modeSquash, modeMerge) | |
| 80 | 86 | } | |
| 81 | 87 | ||
| 82 | 88 | func sideName(s ermite.DiffSide) string { | |
| @@ -117,6 +123,71 @@ type jsonMergeRequest struct { | |||
| 117 | 123 | MergeMode string `json:"merge_mode"` | |
| 118 | 124 | } | |
| 119 | 125 | ||
| 126 | + | // jsonMergeability is the stable JSON shape of the mergeability of an open | |
| 127 | + | // merge request. | |
| 128 | + | type jsonMergeability struct { | |
| 129 | + | // State is "checking", "up_to_date", "behind" or "conflict". | |
| 130 | + | State string `json:"state"` | |
| 131 | + | // Behind counts the commits of the target the head lacks (behind only); | |
| 132 | + | // BehindCapped says it is a lower bound. | |
| 133 | + | Behind int32 `json:"behind"` | |
| 134 | + | BehindCapped bool `json:"behind_capped"` | |
| 135 | + | ConflictPaths []string `json:"conflict_paths"` | |
| 136 | + | // ConflictPathsTruncated says that more paths conflict than listed. | |
| 137 | + | ConflictPathsTruncated bool `json:"conflict_paths_truncated"` | |
| 138 | + | TargetID string `json:"target_id"` | |
| 139 | + | HeadID string `json:"head_id"` | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | func mergeabilityName(s ermite.MergeabilityState) string { | |
| 143 | + | switch s { | |
| 144 | + | case ermite.MergeabilityState_MERGEABILITY_STATE_CHECKING: | |
| 145 | + | return "checking" | |
| 146 | + | case ermite.MergeabilityState_MERGEABILITY_STATE_UP_TO_DATE: | |
| 147 | + | return "up_to_date" | |
| 148 | + | case ermite.MergeabilityState_MERGEABILITY_STATE_BEHIND: | |
| 149 | + | return "behind" | |
| 150 | + | case ermite.MergeabilityState_MERGEABILITY_STATE_CONFLICT: | |
| 151 | + | return "conflict" | |
| 152 | + | } | |
| 153 | + | return "" | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | // toJSONMergeability maps a mergeability; nil when the server sent none. | |
| 157 | + | func toJSONMergeability(m *ermite.Mergeability) *jsonMergeability { | |
| 158 | + | if m == nil { | |
| 159 | + | return nil | |
| 160 | + | } | |
| 161 | + | return &jsonMergeability{ | |
| 162 | + | State: mergeabilityName(m.GetState()), Behind: m.GetBehind(), BehindCapped: m.GetBehindCapped(), | |
| 163 | + | ConflictPaths: append([]string{}, m.GetConflictPaths()...), ConflictPathsTruncated: m.GetConflictPathsTruncated(), | |
| 164 | + | TargetID: m.GetTargetId(), HeadID: m.GetHeadId(), | |
| 165 | + | } | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | // mergeabilityText is the human form of a mergeability: "up to date", | |
| 169 | + | // "behind by 2", "conflict (a, b, ...)", "checking". | |
| 170 | + | func mergeabilityText(m *jsonMergeability) string { | |
| 171 | + | switch m.State { | |
| 172 | + | case "up_to_date": | |
| 173 | + | return "up to date with the target, can be fast-forwarded" | |
| 174 | + | case "behind": | |
| 175 | + | return fmt.Sprintf("behind by %d%s, merges without conflicts (squash or merge commit; fast-forward needs a rebase)", m.Behind, plus(m.BehindCapped)) | |
| 176 | + | case "conflict": | |
| 177 | + | if len(m.ConflictPaths) == 0 { | |
| 178 | + | return "conflict (no common history with the target), rebase needed" | |
| 179 | + | } | |
| 180 | + | more := "" | |
| 181 | + | if m.ConflictPathsTruncated { | |
| 182 | + | more = ", ..." | |
| 183 | + | } | |
| 184 | + | return "conflict (" + strings.Join(m.ConflictPaths, ", ") + more + "), rebase needed" | |
| 185 | + | case "checking": | |
| 186 | + | return "checking" | |
| 187 | + | } | |
| 188 | + | return m.State | |
| 189 | + | } | |
| 190 | + | ||
| 120 | 191 | // jsonMerged is the output of `mr merge --json`. | |
| 121 | 192 | type jsonMerged struct { | |
| 122 | 193 | jsonMergeRequest | |
| @@ -173,9 +244,15 @@ const jsonMRHelp = ` | |||
| 173 | 244 | JSON fields of a merge request (--json): repo, url (full web URL), number, title, description, source_branch, | |
| 174 | 245 | target_branch, state ("open", "merged" or "closed"), author (token label), created_at and | |
| 175 | 246 | updated_at (RFC 3339 UTC), head_id (the commit the source branch points to; empty when the | |
| 176 | - | branch is gone), merged_commit_id and merge_mode ("fast-forward" or "squash"; both empty | |
| 247 | + | branch is gone), merged_commit_id and merge_mode ("fast-forward", "squash" or "merge"; both empty | |
| 177 | 248 | unless merged).` | |
| 178 | 249 | ||
| 250 | + | const jsonMergeabilityHelp = ` | |
| 251 | + | mr show --json adds mergeability, null unless the request is open: state ("checking", "up_to_date", | |
| 252 | + | "behind" or "conflict"), behind and behind_capped (commits of the target the head lacks; behind | |
| 253 | + | only), conflict_paths and conflict_paths_truncated (conflict only), target_id and head_id (the | |
| 254 | + | commits the result is for).` | |
| 255 | + | ||
| 179 | 256 | const jsonCommentHelp = ` | |
| 180 | 257 | JSON fields of a comment: id, author, path, line and side ("old" or "new"; empty path, line 0 | |
| 181 | 258 | and empty side for a general comment), commit_id (the head when it was written), text, | |
| @@ -412,6 +489,8 @@ type jsonCommit struct { | |||
| 412 | 489 | // jsonMRDetail is the stable JSON shape of `mr show --json`. | |
| 413 | 490 | type jsonMRDetail struct { | |
| 414 | 491 | jsonMergeRequest | |
| 492 | + | // Mergeability is null unless the request is open and the server computes it. | |
| 493 | + | Mergeability *jsonMergeability `json:"mergeability"` | |
| 415 | 494 | Commits []jsonCommit `json:"commits"` | |
| 416 | 495 | CommitsTruncated bool `json:"commits_truncated"` | |
| 417 | 496 | DiffBaseID string `json:"diff_base_id"` | |
| @@ -444,6 +523,7 @@ func lineKindName(k ermite.LineKind) string { | |||
| 444 | 523 | func toJSONDetail(d *ermite.GetMergeRequestResponse, withDiff bool, baseURL string) jsonMRDetail { | |
| 445 | 524 | out := jsonMRDetail{ | |
| 446 | 525 | jsonMergeRequest: toJSONMergeRequest(d.GetMergeRequest(), baseURL), | |
| 526 | + | Mergeability: toJSONMergeability(d.GetMergeRequest().GetMergeability()), | |
| 447 | 527 | Commits: []jsonCommit{}, CommitsTruncated: d.GetCommitsTruncated(), | |
| 448 | 528 | DiffBaseID: d.GetDiffBaseId(), | |
| 449 | 529 | Stats: jsonStats{ | |
| @@ -496,7 +576,9 @@ func newMRShowCmd(env *cliEnv) *cobra.Command { | |||
| 496 | 576 | "--since-last-read shows the diff since the head recorded by --mark-read (or by the web) instead of\n" + | |
| 497 | 577 | "the whole change; after a force-push it still works when the recorded commit exists, else the\n" + | |
| 498 | 578 | "whole change is shown and the output says so. --mark-read records the head shown as read; it needs\n" + | |
| 499 | - | "the mr:write scope." + jsonMRHelp + jsonCommentHelp + "\n" + | |
| 579 | + | "the mr:write scope. The mergeability line says whether the branch merges with its target now: up to\n" + | |
| 580 | + | "date (can be fast-forwarded), behind by N commits (merges without conflicts, squash or merge commit),\n" + | |
| 581 | + | "or in conflict with the paths that conflict (rebase needed)." + jsonMRHelp + jsonMergeabilityHelp + jsonCommentHelp + "\n" + | |
| 500 | 582 | "--json adds to the merge request fields: commits (id, author, summary), commits_truncated,\n" + | |
| 501 | 583 | "diff_base_id (the commit the diff starts from), stats (files_changed, additions, deletions),\n" + | |
| 502 | 584 | "diff_truncated, files (old_path, new_path, status, additions, deletions, binary, truncated and,\n" + | |
| @@ -569,6 +651,9 @@ func printMRDetail(w io.Writer, d *ermite.GetMergeRequestResponse, withDiff, sin | |||
| 569 | 651 | if m.GetState() == ermite.MergeRequestState_MERGE_REQUEST_STATE_MERGED { | |
| 570 | 652 | pairs = append(pairs, [2]string{"merged", orDash(m.GetMergedCommitId()) + " (" + modeName(m.GetMergeMode()) + ")"}) | |
| 571 | 653 | } | |
| 654 | + | if mg := toJSONMergeability(m.GetMergeability()); mg != nil && m.GetState() == ermite.MergeRequestState_MERGE_REQUEST_STATE_OPEN { | |
| 655 | + | pairs = append(pairs, [2]string{"mergeability", mergeabilityText(mg)}) | |
| 656 | + | } | |
| 572 | 657 | if len(d.GetVerdicts()) > 0 { | |
| 573 | 658 | var verdicts []string | |
| 574 | 659 | for _, v := range d.GetVerdicts() { | |
| @@ -957,36 +1042,52 @@ func newMRResolveCmd(env *cliEnv) *cobra.Command { | |||
| 957 | 1042 | } | |
| 958 | 1043 | ||
| 959 | 1044 | func newMRMergeCmd(env *cliEnv) *cobra.Command { | |
| 960 | - | var squash, deleteBranch bool | |
| 1045 | + | var mode string | |
| 1046 | + | var deleteBranch bool | |
| 961 | 1047 | var head string | |
| 962 | 1048 | cmd := &cobra.Command{ | |
| 963 | 1049 | Use: "merge <repo> <number>", | |
| 964 | 1050 | Short: "Merge a merge request", | |
| 965 | - | Long: "Merge an open merge request. The default is a fast-forward: the target branch moves to the head\n" + | |
| 966 | - | "and no commit is created. --squash makes one new commit with the tree of the head and the target\n" + | |
| 967 | - | "as only parent, signed by the server's key (see `ermite signing-key show`). The target must be an\n" + | |
| 968 | - | "ancestor of the head, else rebase the branch and push it again; the repository settings may allow\n" + | |
| 969 | - | "only one of the modes.\n\n" + | |
| 1051 | + | Long: "Merge an open merge request with --mode (default fast-forward; the repository settings may allow\n" + | |
| 1052 | + | "only some modes, see `ermite repo edit --merge-modes`):\n" + | |
| 1053 | + | " fast-forward the target branch moves to the head and no commit is created. The target must be\n" + | |
| 1054 | + | " an ancestor of the head, else rebase the branch and push it again, or use another mode.\n" + | |
| 1055 | + | " squash one new commit with the tree of the merge of the head and the target, the target tip\n" + | |
| 1056 | + | " as only parent. The branch must merge without conflicts.\n" + | |
| 1057 | + | " merge a merge commit with two parents, the target tip and the head. The branch must merge\n" + | |
| 1058 | + | " without conflicts.\n" + | |
| 1059 | + | "A squash and a merge commit are signed with the server's own key, generated automatically when the\n" + | |
| 1060 | + | "server first starts; `ermite signing-key show` prints it. The server never rebases. `ermite mr show`\n" + | |
| 1061 | + | "prints the mergeability (up to date, behind, conflict); the server checks again when merging and\n" + | |
| 1062 | + | "refuses a merge that is not possible, saying why.\n\n" + | |
| 970 | 1063 | "The server merges only if the source branch still points to the head you reviewed, so --head is\n" + | |
| 971 | 1064 | "required: give the full id of the head that `ermite mr show <repo> <number>` printed when you\n" + | |
| 972 | 1065 | "reviewed the change. If the branch moved since, the merge is refused: review again. Needs the\n" + | |
| 973 | 1066 | "mr:merge scope.\n\n" + | |
| 974 | - | "--delete-branch deletes the source branch after the merge. The merge is refused when the branch\n" + | |
| 975 | - | "is the default branch or used by another open merge request. If the deletion fails after the\n" + | |
| 976 | - | "merge, the merge stands, the branch is kept and a warning goes to standard error." + jsonMRHelp + | |
| 977 | - | "\n--json adds source_branch_deleted (true when the branch was deleted).", | |
| 1067 | + | "The merge answers as soon as the target branch has moved. --delete-branch asks the server to delete\n" + | |
| 1068 | + | "the source branch right after, in the background. The merge is refused when the branch is the\n" + | |
| 1069 | + | "default branch or used by another open merge request. If the deletion later fails (the branch\n" + | |
| 1070 | + | "moved, say), the merge stands and the branch is kept." + jsonMRHelp + | |
| 1071 | + | "\n--json adds source_branch_deleted (true when the deletion was requested: the server deletes the branch right after the merge).", | |
| 978 | 1072 | Args: cobra.ExactArgs(2), | |
| 979 | 1073 | ValidArgsFunction: completeMRRepoThenNumber(env), | |
| 980 | 1074 | } | |
| 981 | - | cmd.Flags().BoolVar(&squash, "squash", false, "squash the branch into one signed commit instead of a fast-forward") | |
| 1075 | + | cmd.Flags().StringVar(&mode, "mode", modeFastForward, "merge mode: fast-forward, squash or merge (a merge commit)") | |
| 982 | 1076 | cmd.Flags().StringVar(&head, "head", "", "full id of the head you reviewed, from `ermite mr show` (required)") | |
| 983 | 1077 | cmd.Flags().BoolVar(&deleteBranch, "delete-branch", false, "delete the source branch after the merge") | |
| 1078 | + | _ = cmd.RegisterFlagCompletionFunc("mode", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { | |
| 1079 | + | return []string{modeFastForward, modeSquash, modeMerge}, cobra.ShellCompDirectiveNoFileComp | |
| 1080 | + | }) | |
| 984 | 1081 | asJSON := addJSONFlag(cmd) | |
| 985 | 1082 | cmd.RunE = func(cmd *cobra.Command, args []string) error { | |
| 986 | 1083 | n, err := parseNumber(args[1]) | |
| 987 | 1084 | if err != nil { | |
| 988 | 1085 | return err | |
| 989 | 1086 | } | |
| 1087 | + | pbMode, err := parseMode(mode) | |
| 1088 | + | if err != nil { | |
| 1089 | + | return err | |
| 1090 | + | } | |
| 990 | 1091 | head = strings.TrimSpace(head) | |
| 991 | 1092 | if head == "" { | |
| 992 | 1093 | return fmt.Errorf("--head is required: run `ermite mr show %s %d` and give the head id it prints, "+ | |
| @@ -997,35 +1098,60 @@ func newMRMergeCmd(env *cliEnv) *cobra.Command { | |||
| 997 | 1098 | return err | |
| 998 | 1099 | } | |
| 999 | 1100 | ctx := env.ctx(cmd) | |
| 1000 | - | mode := ermite.MergeMode_MERGE_MODE_FAST_FORWARD | |
| 1001 | - | if squash { | |
| 1002 | - | mode = ermite.MergeMode_MERGE_MODE_SQUASH | |
| 1101 | + | if pbMode == ermite.MergeMode_MERGE_MODE_MERGE_COMMIT { | |
| 1102 | + | if err := requireFeature(ctx, c, client.FeatureMergeability, "has no merge commit mode (--mode merge)"); err != nil { | |
| 1103 | + | return err | |
| 1104 | + | } | |
| 1003 | 1105 | } | |
| 1004 | 1106 | res, err := c.MergeRequests().MergeMergeRequest(ctx, connect.NewRequest(&ermite.MergeMergeRequestRequest{ | |
| 1005 | - | Repo: args[0], Number: n, Mode: mode, HeadId: head, DeleteSourceBranch: deleteBranch, | |
| 1107 | + | Repo: args[0], Number: n, Mode: pbMode, HeadId: head, DeleteSourceBranch: deleteBranch, | |
| 1006 | 1108 | })) | |
| 1007 | 1109 | if err != nil { | |
| 1008 | 1110 | return c.Describe(err) | |
| 1009 | 1111 | } | |
| 1010 | 1112 | m := res.Msg.GetMergeRequest() | |
| 1011 | 1113 | deleted := res.Msg.GetSourceBranchDeleted() | |
| 1012 | - | if deleteBranch && !deleted { | |
| 1013 | - | _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "ermite: warning: the branch %s was kept (it moved after the merge, or the server cannot delete it)\n", | |
| 1014 | - | clean(m.GetSourceBranch())) | |
| 1015 | - | } | |
| 1016 | 1114 | if *asJSON { | |
| 1017 | 1115 | return writeJSON(cmd.OutOrStdout(), jsonMerged{jsonMergeRequest: toJSONMergeRequest(m, c.URL()), SourceBranchDeleted: deleted}) | |
| 1018 | 1116 | } | |
| 1019 | - | _, err = fmt.Fprintf(cmd.OutOrStdout(), "merged %s!%d into %s (%s) at %s\n", clean(m.GetRepo()), m.GetNumber(), | |
| 1117 | + | out := cmd.OutOrStdout() | |
| 1118 | + | _, err = fmt.Fprintf(out, "merged %s!%d into %s (%s) at %s\n", clean(m.GetRepo()), m.GetNumber(), | |
| 1020 | 1119 | clean(m.GetTargetBranch()), modeName(m.GetMergeMode()), shortID(m.GetMergedCommitId())) | |
| 1120 | + | if err == nil && pbMode != ermite.MergeMode_MERGE_MODE_FAST_FORWARD { | |
| 1121 | + | _, err = fmt.Fprintln(out, signedNote(ctx, c)) | |
| 1122 | + | } | |
| 1021 | 1123 | if err == nil && deleted { | |
| 1022 | - | _, err = fmt.Fprintf(cmd.OutOrStdout(), "deleted branch %s\n", clean(m.GetSourceBranch())) | |
| 1124 | + | _, err = fmt.Fprintf(out, "deleting branch %s (done by the server after the merge)\n", clean(m.GetSourceBranch())) | |
| 1023 | 1125 | } | |
| 1024 | 1126 | return err | |
| 1025 | 1127 | } | |
| 1026 | 1128 | return cmd | |
| 1027 | 1129 | } | |
| 1028 | 1130 | ||
| 1131 | + | // signedNote says that a squash or merge commit is signed by the server's | |
| 1132 | + | // key, with its fingerprint when the server tells it. | |
| 1133 | + | func signedNote(ctx context.Context, c *client.Client) string { | |
| 1134 | + | const how = "; `ermite signing-key show` prints the key to verify it" | |
| 1135 | + | res, err := c.Server().GetSigningKey(ctx, connect.NewRequest(&ermite.GetSigningKeyRequest{})) | |
| 1136 | + | if err != nil || res.Msg.GetFingerprint() == "" { | |
| 1137 | + | return "the commit is signed by the server's key" + how | |
| 1138 | + | } | |
| 1139 | + | return "the commit is signed by the server's key " + clean(res.Msg.GetFingerprint()) + how | |
| 1140 | + | } | |
| 1141 | + | ||
| 1142 | + | // requireFeature refuses to go on with a server that does not list the | |
| 1143 | + | // feature. A server whose features cannot be read is refused too. | |
| 1144 | + | func requireFeature(ctx context.Context, c *client.Client, feature, what string) error { | |
| 1145 | + | res, err := c.Server().GetVersion(ctx, connect.NewRequest(&ermite.GetVersionRequest{})) | |
| 1146 | + | if err != nil { | |
| 1147 | + | return c.Describe(err) | |
| 1148 | + | } | |
| 1149 | + | if !slices.Contains(res.Msg.GetFeatures(), feature) { | |
| 1150 | + | return fmt.Errorf("the server does not support %s, so it %s (upgrade the server, see `ermite version`)", feature, what) | |
| 1151 | + | } | |
| 1152 | + | return nil | |
| 1153 | + | } | |
| 1154 | + | ||
| 1029 | 1155 | func newMRCloseCmd(env *cliEnv) *cobra.Command { | |
| 1030 | 1156 | cmd := &cobra.Command{ | |
| 1031 | 1157 | Use: "close <repo> <number>", | |
M internal/app/cli_mr_e2e_test.go +6 -6
| @@ -260,19 +260,19 @@ func TestMergeRequestsCLIEndToEnd(t *testing.T) { | |||
| 260 | 260 | require.Error(t, res.err) | |
| 261 | 261 | assert.Contains(t, res.err.Error(), "precondition failed") | |
| 262 | 262 | ||
| 263 | - | // Squash, with --squash. Without --head the merge is refused before it reaches the server. | |
| 263 | + | // Squash, with --mode squash. Without --head the merge is refused before it reaches the server. | |
| 264 | 264 | c.mustGit(wt, "checkout", "-q", "-b", "feat/two", head3) | |
| 265 | 265 | c.mustGit(wt, "-c", "user.name=Cy", "-c", "user.email=cy@example.org", "commit", "-q", "--allow-empty", "-m", "empty by Cy") | |
| 266 | 266 | c.writeFile(wt, "two.txt", []byte("two\n")) | |
| 267 | 267 | c.mustGit(wt, "push", "-q", url, "feat/two") | |
| 268 | 268 | c.cli(t, secret, "mr", "create", "proj", "--source", "feat/two", "--title", "Second").ok(t) | |
| 269 | 269 | w.nextOfType(t, "merge_request_opened") | |
| 270 | - | _, stderr, code = c.bin(t, secret, "mr", "merge", "proj", "2", "--squash") | |
| 270 | + | _, stderr, code = c.bin(t, secret, "mr", "merge", "proj", "2", "--mode", "squash") | |
| 271 | 271 | requireOneLineError(t, stderr, code, "--head is required", "ermite mr show proj 2") | |
| 272 | 272 | head4 := c.bareGit("proj", "rev-parse", "feat/two") | |
| 273 | - | res = c.cli(t, secret, "mr", "merge", "proj", "2", "--squash", "--head", head4) | |
| 273 | + | res = c.cli(t, secret, "mr", "merge", "proj", "2", "--mode", "squash", "--head", head4) | |
| 274 | 274 | require.NoError(t, res.err, res.stderr) | |
| 275 | - | assert.Regexp(t, `^merged proj!2 into main \(squash\) at [0-9a-f]{10}\n$`, res.stdout) | |
| 275 | + | assert.Regexp(t, `^merged proj!2 into main \(squash\) at [0-9a-f]{10}\nthe commit is signed by the server's key SHA256:\S+; .ermite signing-key show. prints the key to verify it\n$`, res.stdout) | |
| 276 | 276 | sq := c.bareGit("proj", "rev-parse", "main") | |
| 277 | 277 | assert.Equal(t, head3, c.bareGit("proj", "rev-parse", sq+"^")) | |
| 278 | 278 | assert.Equal(t, "Second (!2)", c.bareGit("proj", "log", "-1", "--format=%s", sq)) | |
| @@ -338,7 +338,7 @@ func TestSigningKeyShowVerifiesASquashCommit(t *testing.T) { | |||
| 338 | 338 | c.writeFile(wt, "x.txt", []byte("x\n")) | |
| 339 | 339 | c.mustGit(wt, "push", "-q", url, "main", "feat") | |
| 340 | 340 | c.cli(t, adminToken, "mr", "create", "proj", "--source", "feat", "--title", "Add x").ok(t) | |
| 341 | - | res := c.cli(t, adminToken, "mr", "merge", "proj", "1", "--squash", "--head", c.bareGit("proj", "rev-parse", "feat")) | |
| 341 | + | res := c.cli(t, adminToken, "mr", "merge", "proj", "1", "--mode", "squash", "--head", c.bareGit("proj", "rev-parse", "feat")) | |
| 342 | 342 | require.NoError(t, res.err, res.stderr) | |
| 343 | 343 | sq := c.bareGit("proj", "rev-parse", "main") | |
| 344 | 344 | ||
| @@ -381,7 +381,7 @@ func TestRepoEditMergeSettingsCLI(t *testing.T) { | |||
| 381 | 381 | MergeModes []string `json:"merge_modes"` | |
| 382 | 382 | FailingChecksBlock bool `json:"failing_checks_block"` | |
| 383 | 383 | }](t, res.stdout) | |
| 384 | - | assert.Equal(t, []string{"fast-forward", "squash"}, repo.MergeModes) | |
| 384 | + | assert.Equal(t, []string{"fast-forward", "squash", "merge"}, repo.MergeModes) | |
| 385 | 385 | assert.True(t, repo.FailingChecksBlock) | |
| 386 | 386 | ||
| 387 | 387 | res = c.cli(t, adminToken, "repo", "edit", "proj", "--merge-modes", "squash", "--json").ok(t) | |
M internal/app/cli_mr_test.go +83 -7
| @@ -3,6 +3,8 @@ | |||
| 3 | 3 | package app | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | + | "encoding/json" | |
| 7 | + | "regexp" | |
| 6 | 8 | "strings" | |
| 7 | 9 | "testing" | |
| 8 | 10 | ||
| @@ -174,6 +176,53 @@ func TestMRShow(t *testing.T) { | |||
| 174 | 176 | assert.Contains(t, res.stderr, "head you last read is gone") | |
| 175 | 177 | } | |
| 176 | 178 | ||
| 179 | + | // The mergeability of an open request is a line of `mr show` and a field of | |
| 180 | + | // its JSON. | |
| 181 | + | func TestMRShowMergeability(t *testing.T) { | |
| 182 | + | withState := func(mg *ermite.Mergeability) *ermite.GetMergeRequestResponse { | |
| 183 | + | m := proto.Clone(mrOne).(*ermite.MergeRequest) | |
| 184 | + | m.Mergeability = mg | |
| 185 | + | return &ermite.GetMergeRequestResponse{MergeRequest: m} | |
| 186 | + | } | |
| 187 | + | var cur *ermite.GetMergeRequestResponse | |
| 188 | + | f := &fakeAPI{getMR: func(*ermite.GetMergeRequestRequest) (*ermite.GetMergeRequestResponse, error) { return cur, nil }} | |
| 189 | + | url := f.serve(t) | |
| 190 | + | for _, tt := range []struct { | |
| 191 | + | mg *ermite.Mergeability | |
| 192 | + | line string | |
| 193 | + | }{ | |
| 194 | + | {&ermite.Mergeability{State: ermite.MergeabilityState_MERGEABILITY_STATE_UP_TO_DATE}, "up to date"}, | |
| 195 | + | {&ermite.Mergeability{State: ermite.MergeabilityState_MERGEABILITY_STATE_BEHIND, Behind: 1000, BehindCapped: true}, "behind by 1000+"}, | |
| 196 | + | {&ermite.Mergeability{State: ermite.MergeabilityState_MERGEABILITY_STATE_CONFLICT, ConflictPaths: []string{"a.txt", "b/c.go"}, ConflictPathsTruncated: true}, "conflict (a.txt, b/c.go, ...)"}, | |
| 197 | + | {&ermite.Mergeability{State: ermite.MergeabilityState_MERGEABILITY_STATE_CHECKING}, "checking"}, | |
| 198 | + | } { | |
| 199 | + | cur = withState(tt.mg) | |
| 200 | + | res := runCLI(t, envFor(url), "", "mr", "show", "alpha", "1").ok(t) | |
| 201 | + | assert.Regexp(t, `(?m)^mergeability\s+.*`+regexp.QuoteMeta(tt.line), res.stdout) | |
| 202 | + | } | |
| 203 | + | cur = withState(&ermite.Mergeability{State: ermite.MergeabilityState_MERGEABILITY_STATE_BEHIND, Behind: 2, TargetId: headA}) | |
| 204 | + | res := runCLI(t, envFor(url), "", "mr", "show", "alpha", "1", "--json").ok(t) | |
| 205 | + | var got struct { | |
| 206 | + | Mergeability *struct { | |
| 207 | + | State string `json:"state"` | |
| 208 | + | Behind int `json:"behind"` | |
| 209 | + | TargetID string `json:"target_id"` | |
| 210 | + | } `json:"mergeability"` | |
| 211 | + | } | |
| 212 | + | require.NoError(t, json.Unmarshal([]byte(res.stdout), &got)) | |
| 213 | + | require.NotNil(t, got.Mergeability) | |
| 214 | + | assert.Equal(t, "behind", got.Mergeability.State) | |
| 215 | + | assert.Equal(t, 2, got.Mergeability.Behind) | |
| 216 | + | assert.Equal(t, headA, got.Mergeability.TargetID) | |
| 217 | + | ||
| 218 | + | // No mergeability (a merged request, or an older server): no line, null. | |
| 219 | + | cur = withState(nil) | |
| 220 | + | res = runCLI(t, envFor(url), "", "mr", "show", "alpha", "1").ok(t) | |
| 221 | + | assert.NotContains(t, res.stdout, "mergeability") | |
| 222 | + | res = runCLI(t, envFor(url), "", "mr", "show", "alpha", "1", "--json").ok(t) | |
| 223 | + | assert.Regexp(t, `"mergeability":\s*null`, res.stdout) | |
| 224 | + | } | |
| 225 | + | ||
| 177 | 226 | func TestMRShowMarkRead(t *testing.T) { | |
| 178 | 227 | f := &fakeAPI{ | |
| 179 | 228 | getMR: func(*ermite.GetMergeRequestRequest) (*ermite.GetMergeRequestResponse, error) { | |
| @@ -252,6 +301,12 @@ func TestMRResolveMergeAndClose(t *testing.T) { | |||
| 252 | 301 | closeMR: func(*ermite.CloseMergeRequestRequest) (*ermite.CloseMergeRequestResponse, error) { | |
| 253 | 302 | return &ermite.CloseMergeRequestResponse{MergeRequest: closed}, nil | |
| 254 | 303 | }, | |
| 304 | + | getSigningKey: func(*ermite.GetSigningKeyRequest) (*ermite.GetSigningKeyResponse, error) { | |
| 305 | + | return &ermite.GetSigningKeyResponse{Fingerprint: "SHA256:abc"}, nil | |
| 306 | + | }, | |
| 307 | + | getVersion: func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) { | |
| 308 | + | return &ermite.GetVersionResponse{Version: "test", Features: []string{"merge_requests.mergeability"}}, nil | |
| 309 | + | }, | |
| 255 | 310 | } | |
| 256 | 311 | url := f.serve(t) | |
| 257 | 312 | ||
| @@ -265,16 +320,37 @@ func TestMRResolveMergeAndClose(t *testing.T) { | |||
| 265 | 320 | assert.False(t, reqs[1].(*ermite.ResolveCommentRequest).GetResolved()) | |
| 266 | 321 | ||
| 267 | 322 | // Merge with an explicit head and mode. | |
| 268 | - | res = runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--squash", "--head", headA).ok(t) | |
| 269 | - | assert.Equal(t, "merged alpha!1 into main (squash) at dddddddddd\n", res.stdout) | |
| 323 | + | res = runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--mode", "squash", "--head", headA).ok(t) | |
| 324 | + | assert.Equal(t, "merged alpha!1 into main (squash) at dddddddddd\n"+ | |
| 325 | + | "the commit is signed by the server's key SHA256:abc; `ermite signing-key show` prints the key to verify it\n", res.stdout) | |
| 270 | 326 | assert.Empty(t, res.stderr) | |
| 271 | 327 | calls, reqs := f.snapshot() | |
| 272 | - | assert.Equal(t, []string{"ResolveComment", "ResolveComment", "MergeMergeRequest"}, calls, "an explicit head needs no read first") | |
| 328 | + | assert.Equal(t, []string{"ResolveComment", "ResolveComment", "MergeMergeRequest", "GetSigningKey"}, calls, "an explicit head needs no read first; the signing key is for the note") | |
| 273 | 329 | m := reqs[2].(*ermite.MergeMergeRequestRequest) | |
| 274 | 330 | assert.Equal(t, ermite.MergeMode_MERGE_MODE_SQUASH, m.GetMode()) | |
| 275 | 331 | assert.Equal(t, headA, m.GetHeadId()) | |
| 276 | 332 | assert.EqualValues(t, 1, m.GetNumber()) | |
| 277 | 333 | ||
| 334 | + | // A merge commit, and a mode that does not exist. | |
| 335 | + | merged.MergeMode = ermite.MergeMode_MERGE_MODE_MERGE_COMMIT | |
| 336 | + | res = runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--mode", "merge", "--head", headA).ok(t) | |
| 337 | + | assert.Contains(t, res.stdout, "merged alpha!1 into main (merge) at dddddddddd\n") | |
| 338 | + | assert.Contains(t, res.stdout, "signed by the server's key SHA256:abc") | |
| 339 | + | _, reqs = f.snapshot() | |
| 340 | + | var lastMerge *ermite.MergeMergeRequestRequest | |
| 341 | + | for _, r := range reqs { | |
| 342 | + | if mr, ok := r.(*ermite.MergeMergeRequestRequest); ok { | |
| 343 | + | lastMerge = mr | |
| 344 | + | } | |
| 345 | + | } | |
| 346 | + | require.NotNil(t, lastMerge) | |
| 347 | + | assert.Equal(t, ermite.MergeMode_MERGE_MODE_MERGE_COMMIT, lastMerge.GetMode()) | |
| 348 | + | bad := runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--mode", "rebase", "--head", headA) | |
| 349 | + | require.Error(t, bad.err) | |
| 350 | + | assert.Contains(t, bad.err.Error(), "invalid merge mode") | |
| 351 | + | assert.Error(t, runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--squash", "--head", headA).err, "--squash is gone") | |
| 352 | + | merged.MergeMode = ermite.MergeMode_MERGE_MODE_SQUASH | |
| 353 | + | ||
| 278 | 354 | // The default mode is a fast-forward. | |
| 279 | 355 | res = runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--head", headA, "--json").ok(t) | |
| 280 | 356 | assert.Equal(t, "merged", decode[mrOut](t, res.stdout).State) | |
| @@ -299,7 +375,7 @@ func TestMRMergeNeedsTheHead(t *testing.T) { | |||
| 299 | 375 | url := f.serve(t) | |
| 300 | 376 | for _, args := range [][]string{ | |
| 301 | 377 | {"mr", "merge", "alpha", "1"}, | |
| 302 | - | {"mr", "merge", "alpha", "1", "--squash"}, | |
| 378 | + | {"mr", "merge", "alpha", "1", "--mode", "squash"}, | |
| 303 | 379 | {"mr", "merge", "alpha", "1", "--head", " "}, | |
| 304 | 380 | } { | |
| 305 | 381 | res := runCLI(t, envFor(url), "", args...) | |
| @@ -326,7 +402,7 @@ func TestMRMergeDeleteBranch(t *testing.T) { | |||
| 326 | 402 | url := f.serve(t) | |
| 327 | 403 | ||
| 328 | 404 | res := runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--head", headA, "--delete-branch").ok(t) | |
| 329 | - | assert.Equal(t, "merged alpha!1 into main (fast-forward) at "+headA[:10]+"\ndeleted branch feat/x\n", res.stdout) | |
| 405 | + | assert.Equal(t, "merged alpha!1 into main (fast-forward) at "+headA[:10]+"\ndeleting branch feat/x (done by the server after the merge)\n", res.stdout) | |
| 330 | 406 | assert.Empty(t, res.stderr) | |
| 331 | 407 | _, reqs := f.snapshot() | |
| 332 | 408 | assert.True(t, reqs[0].(*ermite.MergeMergeRequestRequest).GetDeleteSourceBranch()) | |
| @@ -334,11 +410,11 @@ func TestMRMergeDeleteBranch(t *testing.T) { | |||
| 334 | 410 | res = runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--head", headA, "--delete-branch", "--json").ok(t) | |
| 335 | 411 | assert.Contains(t, res.stdout, `"source_branch_deleted": true`) | |
| 336 | 412 | ||
| 337 | - | // The merge stands when the branch is kept: a warning, no failure. | |
| 413 | + | // Deletion is asynchronous: the CLI makes no claim when the server did not | |
| 414 | + | // request it. | |
| 338 | 415 | deleted = false | |
| 339 | 416 | res = runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--head", headA, "--delete-branch").ok(t) | |
| 340 | 417 | assert.Equal(t, "merged alpha!1 into main (fast-forward) at "+headA[:10]+"\n", res.stdout) | |
| 341 | - | assert.Contains(t, res.stderr, "ermite: warning: the branch feat/x was kept") | |
| 342 | 418 | ||
| 343 | 419 | // Without the flag nothing is asked and nothing is said. | |
| 344 | 420 | res = runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--head", headA).ok(t) | |
M internal/app/cli_repo.go +9 -7
| @@ -33,7 +33,7 @@ type jsonRepo struct { | |||
| 33 | 33 | DefaultBranch string `json:"default_branch"` | |
| 34 | 34 | CreatedAt *string `json:"created_at"` | |
| 35 | 35 | PushedAt *string `json:"pushed_at"` | |
| 36 | - | // MergeModes are the allowed merge modes ("fast-forward", "squash"). | |
| 36 | + | // MergeModes are the allowed merge modes ("fast-forward", "squash", "merge"). | |
| 37 | 37 | MergeModes []string `json:"merge_modes"` | |
| 38 | 38 | FailingChecksBlock bool `json:"failing_checks_block"` | |
| 39 | 39 | // Issues is the issue mode ("off", "owner" or "public"). | |
| @@ -114,8 +114,8 @@ func parseVisibility(s string) (ermite.Visibility, error) { | |||
| 114 | 114 | const jsonRepoHelp = ` | |
| 115 | 115 | JSON fields of a repository (--json): name, description, visibility ("public" or | |
| 116 | 116 | "private"), default_branch, created_at and pushed_at (RFC 3339 UTC; pushed_at is | |
| 117 | - | null until the first push), merge_modes (array of the allowed merge modes, "fast-forward" and/or | |
| 118 | - | "squash"), failing_checks_block (whether a failing check prevents a merge) and issues (the issue | |
| 117 | + | null until the first push), merge_modes (array of the allowed merge modes, "fast-forward", "squash" and/or | |
| 118 | + | "merge"), failing_checks_block (whether a failing check prevents a merge) and issues (the issue | |
| 119 | 119 | mode: "off", "owner" or "public").` | |
| 120 | 120 | ||
| 121 | 121 | func newRepoCmd(env *cliEnv) *cobra.Command { | |
| @@ -315,7 +315,9 @@ func newRepoEditCmd(env *cliEnv) *cobra.Command { | |||
| 315 | 315 | Short: "Change a repository's description, topics, visibility, default branch, merge settings or issues", | |
| 316 | 316 | Long: "Change the fields given as flags and leave the others alone; --description \"\" clears the\n" + | |
| 317 | 317 | "description and --topics \"\" clears the topics. The default branch must already exist. --merge-modes lists the modes a merge request\n" + | |
| 318 | - | "may be merged with (fast-forward, squash; default for a new repository: both), and\n" + | |
| 318 | + | "may be merged with (fast-forward, squash, merge; every mode is allowed by default, so to disable one,\n" + | |
| 319 | + | "list the others: --merge-modes fast-forward,squash disables the merge commit; squash and merge\n" + | |
| 320 | + | "commits are signed with the server's key, see `ermite signing-key show`), and\n" + | |
| 319 | 321 | "--failing-checks-block says whether a failing check prevents a merge (a failed or unfinished CI check of the head; see README, CI).\n" + | |
| 320 | 322 | "--issues sets who may use issues: off (default), owner (the owner and tokens with issue:write) or\n" + | |
| 321 | 323 | "public (visitors of a public repository may also propose issues through a moderated form).\nNeeds a server with the issues feature.\n" + | |
| @@ -327,7 +329,7 @@ func newRepoEditCmd(env *cliEnv) *cobra.Command { | |||
| 327 | 329 | cmd.Flags().StringSliceVar(&topics, "topics", nil, "new topics, comma-separated (empty clears them)") | |
| 328 | 330 | cmd.Flags().StringVar(&visibility, "visibility", "", "new visibility: public or private") | |
| 329 | 331 | cmd.Flags().StringVar(&defaultBranch, "default-branch", "", "short name of the new default branch") | |
| 330 | - | cmd.Flags().StringSliceVar(&mergeModes, "merge-modes", nil, "allowed merge modes, comma-separated: fast-forward, squash") | |
| 332 | + | cmd.Flags().StringSliceVar(&mergeModes, "merge-modes", nil, "allowed merge modes, comma-separated: fast-forward, squash, merge (all by default; list the ones to keep)") | |
| 331 | 333 | cmd.Flags().BoolVar(&failingChecksBlock, "failing-checks-block", true, "a failing check prevents a merge (--failing-checks-block=false to allow it)") | |
| 332 | 334 | cmd.Flags().StringVar(&issues, "issues", "", "issue mode: off, owner or public") | |
| 333 | 335 | _ = cmd.RegisterFlagCompletionFunc("issues", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { | |
| @@ -335,7 +337,7 @@ func newRepoEditCmd(env *cliEnv) *cobra.Command { | |||
| 335 | 337 | }) | |
| 336 | 338 | _ = cmd.RegisterFlagCompletionFunc("visibility", visibilityCompletion) | |
| 337 | 339 | _ = cmd.RegisterFlagCompletionFunc("merge-modes", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { | |
| 338 | - | return []string{modeFastForward, modeSquash}, cobra.ShellCompDirectiveNoFileComp | |
| 340 | + | return []string{modeFastForward, modeSquash, modeMerge}, cobra.ShellCompDirectiveNoFileComp | |
| 339 | 341 | }) | |
| 340 | 342 | asJSON := addJSONFlag(cmd) | |
| 341 | 343 | cmd.RunE = func(cmd *cobra.Command, args []string) error { | |
| @@ -400,7 +402,7 @@ func buildUpdateRepo(cmd *cobra.Command, name, description string, topics []stri | |||
| 400 | 402 | modes = append(modes, mode) | |
| 401 | 403 | } | |
| 402 | 404 | if len(modes) == 0 { | |
| 403 | - | return nil, errors.New("--merge-modes must name at least one mode: fast-forward, squash") | |
| 405 | + | return nil, errors.New("--merge-modes must name at least one mode: fast-forward, squash, merge") | |
| 404 | 406 | } | |
| 405 | 407 | req.Settings = &ermite.RepoSettings{AllowedMergeModes: modes} | |
| 406 | 408 | paths = append(paths, "settings.allowed_merge_modes") | |
M internal/app/cli_version.go +1 -0
| @@ -114,6 +114,7 @@ var featureUse = map[string]string{ | |||
| 114 | 114 | client.FeatureMergeRequests: "`ermite mr` needs: the commands will fail on this server", | |
| 115 | 115 | client.FeatureMergeDeleteBranch: "`ermite mr merge --delete-branch` needs: the merge works but the branch is kept", | |
| 116 | 116 | client.FeatureIssues: "`ermite issue` needs: the commands will fail on this server, and `repo edit --issues` and `token create --scope issue:write` are refused", | |
| 117 | + | client.FeatureMergeability: "`ermite mr show` uses to show whether the request merges: the line is missing", | |
| 117 | 118 | client.FeatureSigningKey: "`ermite signing-key show` needs: the command will fail on this server", | |
| 118 | 119 | client.FeatureSession: "`ermite web login` needs: the command will fail on this server", | |
| 119 | 120 | client.FeatureTokenBranchPrefixes: "`ermite token create --branch-prefix` needs: the command is refused, and `token list` does not show the limits", | |
M internal/app/cli_version_test.go +5 -5
| @@ -17,7 +17,7 @@ import ( | |||
| 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", "signing_key", "session", "token.branch_prefixes", "ci.runs", "ci.runners", "ci.secrets", "ci.dispatch", "ci.workflows", "ci.artifacts", "commit_signatures", "passkeys"} | |
| 20 | + | var allFeatures = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "merge_requests.mergeability", "signing_key", "session", "token.branch_prefixes", "ci.runs", "ci.runners", "ci.secrets", "ci.dispatch", "ci.workflows", "ci.artifacts", "commit_signatures", "passkeys"} | |
| 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, signing_key, session, token.branch_prefixes, ci.runs, ci.runners, ci.secrets, ci.dispatch, ci.workflows, ci.artifacts, commit_signatures, passkeys\n", res.stdout) | |
| 48 | + | assert.Equal(t, "client ermite v0.0.1\nserver v0.4.0 at "+url+"\nfeatures repo.update, events.resume, merge_requests, merge_requests.delete_branch, issues, merge_requests.mergeability, signing_key, session, token.branch_prefixes, ci.runs, ci.runners, ci.secrets, ci.dispatch, ci.workflows, ci.artifacts, commit_signatures, passkeys\n", res.stdout) | |
| 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","signing_key","session","token.branch_prefixes","ci.runs","ci.runners","ci.secrets","ci.dispatch","ci.workflows","ci.artifacts","commit_signatures","passkeys"],"missing_features":[]}}`, res.stdout) | |
| 52 | + | assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","events.resume","merge_requests","merge_requests.delete_branch","issues","merge_requests.mergeability","signing_key","session","token.branch_prefixes","ci.runs","ci.runners","ci.secrets","ci.dispatch","ci.workflows","ci.artifacts","commit_signatures","passkeys"],"missing_features":[]}}`, res.stdout) | |
| 53 | 53 | } | |
| 54 | 54 | ||
| 55 | 55 | func TestVersionWarnsAboutMissingFeatures(t *testing.T) { | |
| 56 | - | f := versionFake("repo.update", "merge_requests", "merge_requests.delete_branch", "issues", "signing_key", "session", "token.branch_prefixes", "ci.runs", "ci.runners", "ci.secrets", "ci.dispatch", "ci.workflows", "ci.artifacts", "commit_signatures", "passkeys") | |
| 56 | + | f := versionFake("repo.update", "merge_requests", "merge_requests.delete_branch", "issues", "merge_requests.mergeability", "signing_key", "session", "token.branch_prefixes", "ci.runs", "ci.runners", "ci.secrets", "ci.dispatch", "ci.workflows", "ci.artifacts", "commit_signatures", "passkeys") | |
| 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","signing_key","session","token.branch_prefixes","ci.runs","ci.runners","ci.secrets","ci.dispatch","ci.workflows","ci.artifacts","commit_signatures","passkeys"],"missing_features":["events.resume"]}}`, res.stdout) | |
| 64 | + | assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","merge_requests","merge_requests.delete_branch","issues","merge_requests.mergeability","signing_key","session","token.branch_prefixes","ci.runs","ci.runners","ci.secrets","ci.dispatch","ci.workflows","ci.artifacts","commit_signatures","passkeys"],"missing_features":["events.resume"]}}`, res.stdout) | |
| 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 +30 -0
| @@ -29,6 +29,8 @@ type jsonEvent struct { | |||
| 29 | 29 | MergeRequest *jsonMergeRequest `json:"merge_request,omitempty"` | |
| 30 | 30 | Number int64 `json:"number,omitempty"` | |
| 31 | 31 | Comment *jsonComment `json:"comment,omitempty"` | |
| 32 | + | // Mergeability is set for a merge_request_mergeability event. | |
| 33 | + | Mergeability *jsonMergeability `json:"mergeability,omitempty"` | |
| 32 | 34 | } | |
| 33 | 35 | ||
| 34 | 36 | type jsonRefUpdate struct { | |
| @@ -52,6 +54,7 @@ const ( | |||
| 52 | 54 | eventCommentEdited = "comment_edited" | |
| 53 | 55 | eventCommentDeleted = "comment_deleted" | |
| 54 | 56 | eventCommentResolved = "comment_resolved" | |
| 57 | + | eventMRMergeability = "merge_request_mergeability" | |
| 55 | 58 | ) | |
| 56 | 59 | ||
| 57 | 60 | // toJSONEvent maps an event; ok is false for kinds this client does not know, | |
| @@ -94,6 +97,9 @@ func toJSONEvent(ev *ermite.Event, baseURL string) (jsonEvent, bool) { | |||
| 94 | 97 | case *ermite.Event_CommentResolved: | |
| 95 | 98 | c := toJSONComment(k.CommentResolved.GetComment()) | |
| 96 | 99 | out.Type, out.Repo, out.Number, out.Comment = eventCommentResolved, k.CommentResolved.GetRepo(), k.CommentResolved.GetNumber(), &c | |
| 100 | + | case *ermite.Event_MergeRequestMergeability: | |
| 101 | + | out.Type, out.Repo, out.Number = eventMRMergeability, k.MergeRequestMergeability.GetRepo(), k.MergeRequestMergeability.GetNumber() | |
| 102 | + | out.Mergeability = toJSONMergeability(k.MergeRequestMergeability.GetMergeability()) | |
| 97 | 103 | case *ermite.Event_Gap: | |
| 98 | 104 | out.Type = eventGap | |
| 99 | 105 | default: | |
| @@ -126,12 +132,36 @@ func eventLine(e jsonEvent) string { | |||
| 126 | 132 | detail = fmt.Sprintf("%s!%d %s -> %s", e.Repo, e.MergeRequest.Number, e.MergeRequest.SourceBranch, e.MergeRequest.TargetBranch) | |
| 127 | 133 | case eventCommentAdded, eventCommentEdited, eventCommentDeleted, eventCommentResolved: | |
| 128 | 134 | detail = fmt.Sprintf("%s!%d by %s", e.Repo, e.Number, e.Comment.Author) | |
| 135 | + | case eventMRMergeability: | |
| 136 | + | detail = fmt.Sprintf("%s!%d mergeability: %s", e.Repo, e.Number, mergeabilityEventText(e.Mergeability)) | |
| 129 | 137 | case eventGap: | |
| 130 | 138 | detail = "events were lost, reload what you display" | |
| 131 | 139 | } | |
| 132 | 140 | return clean(ts + " " + e.Type + " " + detail) | |
| 133 | 141 | } | |
| 134 | 142 | ||
| 143 | + | // mergeabilityEventText is the short form of a mergeability for a watch line: | |
| 144 | + | // "up_to_date", "behind by 2", "conflict (a, b)", "checking". | |
| 145 | + | func mergeabilityEventText(m *jsonMergeability) string { | |
| 146 | + | if m == nil { | |
| 147 | + | return "unknown" | |
| 148 | + | } | |
| 149 | + | switch m.State { | |
| 150 | + | case "behind": | |
| 151 | + | return fmt.Sprintf("behind by %d%s", m.Behind, plus(m.BehindCapped)) | |
| 152 | + | case "conflict": | |
| 153 | + | if len(m.ConflictPaths) == 0 { | |
| 154 | + | return "conflict (no common history)" | |
| 155 | + | } | |
| 156 | + | more := "" | |
| 157 | + | if m.ConflictPathsTruncated { | |
| 158 | + | more = ", ..." | |
| 159 | + | } | |
| 160 | + | return "conflict (" + strings.Join(m.ConflictPaths, ", ") + more + ")" | |
| 161 | + | } | |
| 162 | + | return m.State | |
| 163 | + | } | |
| 164 | + | ||
| 135 | 165 | func refUpdateText(u jsonRefUpdate) string { | |
| 136 | 166 | short := func(id string) string { | |
| 137 | 167 | if len(id) > 8 { | |
M internal/app/cli_watch_test.go +43 -0
| @@ -200,6 +200,49 @@ func TestWatchMergeRequestEvents(t *testing.T) { | |||
| 200 | 200 | assert.Equal(t, "merge_request_closed", decode[eventOut](t, lines[3]).Type) | |
| 201 | 201 | } | |
| 202 | 202 | ||
| 203 | + | // A mergeability event shows the state, with how far behind or the paths. | |
| 204 | + | func TestWatchMergeabilityEvents(t *testing.T) { | |
| 205 | + | mg := func(m *ermite.Mergeability) func(*ermite.Event) { | |
| 206 | + | return func(e *ermite.Event) { | |
| 207 | + | e.Kind = &ermite.Event_MergeRequestMergeability{MergeRequestMergeability: &ermite.MergeRequestMergeability{Repo: "alpha", Number: 1, Mergeability: m}} | |
| 208 | + | } | |
| 209 | + | } | |
| 210 | + | events := []*ermite.WatchResponse{ | |
| 211 | + | evt("1", mg(&ermite.Mergeability{State: ermite.MergeabilityState_MERGEABILITY_STATE_UP_TO_DATE})), | |
| 212 | + | evt("2", mg(&ermite.Mergeability{State: ermite.MergeabilityState_MERGEABILITY_STATE_BEHIND, Behind: 2})), | |
| 213 | + | evt("3", mg(&ermite.Mergeability{State: ermite.MergeabilityState_MERGEABILITY_STATE_CONFLICT, ConflictPaths: []string{"a.txt", "b.txt"}})), | |
| 214 | + | } | |
| 215 | + | f := &fakeAPI{watch: finishing(events)} | |
| 216 | + | url := f.serve(t) | |
| 217 | + | ||
| 218 | + | res := runCLI(t, envFor(url), "", "watch") | |
| 219 | + | require.Error(t, res.err) | |
| 220 | + | assert.Equal(t, ""+ | |
| 221 | + | "2026-10-10T12:00:00Z merge_request_mergeability alpha!1 mergeability: up_to_date\n"+ | |
| 222 | + | "2026-10-10T12:00:00Z merge_request_mergeability alpha!1 mergeability: behind by 2\n"+ | |
| 223 | + | "2026-10-10T12:00:00Z merge_request_mergeability alpha!1 mergeability: conflict (a.txt, b.txt)\n", res.stdout) | |
| 224 | + | ||
| 225 | + | res = runCLI(t, envFor(url), "", "watch", "--json") | |
| 226 | + | lines := strings.Split(strings.TrimSpace(res.stdout), "\n") | |
| 227 | + | require.Len(t, lines, 3) | |
| 228 | + | var ev struct { | |
| 229 | + | Type string `json:"type"` | |
| 230 | + | Number int64 `json:"number"` | |
| 231 | + | Mergeability struct { | |
| 232 | + | State string `json:"state"` | |
| 233 | + | Behind int `json:"behind"` | |
| 234 | + | ConflictPaths []string `json:"conflict_paths"` | |
| 235 | + | } `json:"mergeability"` | |
| 236 | + | } | |
| 237 | + | require.NoError(t, json.Unmarshal([]byte(lines[1]), &ev)) | |
| 238 | + | assert.Equal(t, "merge_request_mergeability", ev.Type) | |
| 239 | + | assert.EqualValues(t, 1, ev.Number) | |
| 240 | + | assert.Equal(t, "behind", ev.Mergeability.State) | |
| 241 | + | assert.Equal(t, 2, ev.Mergeability.Behind) | |
| 242 | + | require.NoError(t, json.Unmarshal([]byte(lines[2]), &ev)) | |
| 243 | + | assert.Equal(t, []string{"a.txt", "b.txt"}, ev.Mergeability.ConflictPaths) | |
| 244 | + | } | |
| 245 | + | ||
| 203 | 246 | func TestNoteWatchEvents(t *testing.T) { | |
| 204 | 247 | note := &ermite.Comment{Id: "4", ReplyTo: "2", StartLine: 10, Line: 24, Verdict: ermite.Verdict_VERDICT_APPROVE, EditedAt: ts("2026-10-10T12:00:00Z"), Deleted: true, Author: "owner"} | |
| 205 | 248 | events := []*ermite.Event{ | |
M internal/app/forge.go +14 -1
| @@ -137,6 +137,8 @@ type forge struct { | |||
| 137 | 137 | // Inbox serves the owner's inbox of the web. | |
| 138 | 138 | Inbox *usecase.Inbox | |
| 139 | 139 | SigningKey *usecase.SigningKey | |
| 140 | + | // Mergeability keeps the mergeability of the open merge requests up to date. | |
| 141 | + | Mergeability *usecase.Mergeability | |
| 140 | 142 | // Signatures are the trusted keys and the commit signature checks | |
| 141 | 143 | // (ADR 0030). | |
| 142 | 144 | Signatures *usecase.Signatures | |
| @@ -331,11 +333,16 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e | |||
| 331 | 333 | if err := f.signatures(store, reader, signer.PublicKey(), committer, clock); err != nil { | |
| 332 | 334 | return nil, err | |
| 333 | 335 | } | |
| 336 | + | f.Mergeability = usecase.NewMergeability(ctx, usecase.MergeabilityConfig{ | |
| 337 | + | Store: store.MergeRequests(), Meta: store, Git: git, Bus: f.Bus, Clock: clock, | |
| 338 | + | }) | |
| 339 | + | f.Pushes.WithMergeability(f.Mergeability) | |
| 334 | 340 | f.MergeRequests = usecase.NewMergeRequests(usecase.MergeRequestsConfig{ | |
| 335 | 341 | Meta: store, Store: store.MergeRequests(), Repos: git, Git: git, Signer: signer, | |
| 336 | 342 | Committer: committer, Bus: f.Bus, Clock: clock, Maint: f.maint, | |
| 337 | - | CI: f.CI, Checks: f.CI, PullRequests: f.CI, RefTips: store.CI(), | |
| 343 | + | CI: f.CI, Checks: f.CI, PullRequests: f.CI, RefTips: store.CI(), Mergeability: f.Mergeability, | |
| 338 | 344 | }) | |
| 345 | + | f.Mergeability.Start() | |
| 339 | 346 | f.MergeRequests.WithVerifier(f.Signatures) | |
| 340 | 347 | f.Issues = usecase.NewIssues(usecase.IssuesConfig{ | |
| 341 | 348 | Meta: store, Store: store.Issues(), MergeRequests: store.MergeRequests(), Bus: f.Bus, Clock: clock, | |
| @@ -512,9 +519,15 @@ func (f *forge) Close(ctx context.Context) { | |||
| 512 | 519 | if f.hooks != nil { | |
| 513 | 520 | f.hooks.Close(ctx) | |
| 514 | 521 | } | |
| 522 | + | if f.MergeRequests != nil { | |
| 523 | + | f.MergeRequests.Wait(ctx) // its side effects use CI and Mergeability | |
| 524 | + | } | |
| 515 | 525 | if f.CI != nil { | |
| 516 | 526 | f.CI.Close(ctx) | |
| 517 | 527 | } | |
| 528 | + | if f.Mergeability != nil { | |
| 529 | + | f.Mergeability.Close(ctx) | |
| 530 | + | } | |
| 518 | 531 | if f.EventLog != nil { | |
| 519 | 532 | f.EventLog.Close(ctx) | |
| 520 | 533 | } | |
M internal/app/mr_api_test.go +3 -2
| @@ -298,7 +298,7 @@ func TestUpdateRepoSettingsOverTheAPI(t *testing.T) { | |||
| 298 | 298 | ||
| 299 | 299 | got, err := h.api("").repo.GetRepo(ctx, connect.NewRequest(&pb.GetRepoRequest{Repo: "proj"})) | |
| 300 | 300 | require.NoError(t, err) | |
| 301 | - | assert.Equal(t, []pb.MergeMode{pb.MergeMode_MERGE_MODE_FAST_FORWARD, pb.MergeMode_MERGE_MODE_SQUASH}, got.Msg.GetRepo().GetSettings().GetAllowedMergeModes()) | |
| 301 | + | assert.Equal(t, []pb.MergeMode{pb.MergeMode_MERGE_MODE_FAST_FORWARD, pb.MergeMode_MERGE_MODE_SQUASH, pb.MergeMode_MERGE_MODE_MERGE_COMMIT}, got.Msg.GetRepo().GetSettings().GetAllowedMergeModes()) | |
| 302 | 302 | assert.True(t, got.Msg.GetRepo().GetSettings().GetFailingChecksBlock()) | |
| 303 | 303 | ||
| 304 | 304 | update := func(c *clients, paths []string, s *pb.RepoSettings) (*pb.Repo, error) { | |
| @@ -375,7 +375,8 @@ func TestMergeDeletesTheSourceOverTheAPI(t *testing.T) { | |||
| 375 | 375 | Repo: "proj", Number: 1, HeadId: r.head, DeleteSourceBranch: true, | |
| 376 | 376 | })) | |
| 377 | 377 | require.NoError(t, err) | |
| 378 | - | assert.True(t, res.Msg.GetSourceBranchDeleted()) | |
| 378 | + | assert.True(t, res.Msg.GetSourceBranchDeleted(), "requested") | |
| 379 | + | r.forge.MergeRequests.Wait(r.ctx) // the deletion follows the response | |
| 379 | 380 | assert.Empty(t, r.bareGit("proj", "for-each-ref", "refs/heads/feat")) | |
| 380 | 381 | ||
| 381 | 382 | // The default branch as a source: refused before anything changes. | |
M internal/app/testdata/web/commits-root.html +1 -1
| @@ -972,7 +972,7 @@ | |||
| 972 | 972 | htmx:sse:close, htmx:sse:error -> ermiteLive.lost(); | |
| 973 | 973 | htmx:sse:error -> ermiteLive.error(event.detail.status); | |
| 974 | 974 | htmx:sse:before:connection -> ermiteLive.gate(event); | |
| 975 | - | home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data); | |
| 975 | + | home-list, repo-last-push, refs-list, commits-head, mr-notice, mr-mergeability, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data); | |
| 976 | 976 | gap -> ermiteLive.reload()"> | |
| 977 | 977 | </span> | |
| 978 | 978 | </div> | |
M internal/app/testdata/web/commits-small.html +1 -1
| @@ -317,7 +317,7 @@ | |||
| 317 | 317 | htmx:sse:close, htmx:sse:error -> ermiteLive.lost(); | |
| 318 | 318 | htmx:sse:error -> ermiteLive.error(event.detail.status); | |
| 319 | 319 | htmx:sse:before:connection -> ermiteLive.gate(event); | |
| 320 | - | home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data); | |
| 320 | + | home-list, repo-last-push, refs-list, commits-head, mr-notice, mr-mergeability, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data); | |
| 321 | 321 | gap -> ermiteLive.reload()"> | |
| 322 | 322 | </span> | |
| 323 | 323 | </div> | |
M internal/app/testdata/web/home.html +1 -1
| @@ -191,7 +191,7 @@ | |||
| 191 | 191 | htmx:sse:close, htmx:sse:error -> ermiteLive.lost(); | |
| 192 | 192 | htmx:sse:error -> ermiteLive.error(event.detail.status); | |
| 193 | 193 | htmx:sse:before:connection -> ermiteLive.gate(event); | |
| 194 | - | home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data); | |
| 194 | + | home-list, repo-last-push, refs-list, commits-head, mr-notice, mr-mergeability, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data); | |
| 195 | 195 | gap -> ermiteLive.reload()"> | |
| 196 | 196 | </span> | |
| 197 | 197 | </div> | |
M internal/app/testdata/web/refs.html +1 -1
| @@ -199,7 +199,7 @@ | |||
| 199 | 199 | htmx:sse:close, htmx:sse:error -> ermiteLive.lost(); | |
| 200 | 200 | htmx:sse:error -> ermiteLive.error(event.detail.status); | |
| 201 | 201 | htmx:sse:before:connection -> ermiteLive.gate(event); | |
| 202 | - | home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data); | |
| 202 | + | home-list, repo-last-push, refs-list, commits-head, mr-notice, mr-mergeability, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data); | |
| 203 | 203 | gap -> ermiteLive.reload()"> | |
| 204 | 204 | </span> | |
| 205 | 205 | </div> | |
M internal/app/testdata/web/summary.html +1 -1
| @@ -225,7 +225,7 @@ | |||
| 225 | 225 | htmx:sse:close, htmx:sse:error -> ermiteLive.lost(); | |
| 226 | 226 | htmx:sse:error -> ermiteLive.error(event.detail.status); | |
| 227 | 227 | htmx:sse:before:connection -> ermiteLive.gate(event); | |
| 228 | - | home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data); | |
| 228 | + | home-list, repo-last-push, refs-list, commits-head, mr-notice, mr-mergeability, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data); | |
| 229 | 229 | gap -> ermiteLive.reload()"> | |
| 230 | 230 | </span> | |
| 231 | 231 | </div> | |
M internal/app/web.go +5 -0
| @@ -35,6 +35,10 @@ func newWeb(cfg config.Config, f *forge, limiter *clientlimit.Limiter) (http.Han | |||
| 35 | 35 | if f.Passkeys != nil { | |
| 36 | 36 | passkeys = webPasskeys{f.Passkeys} | |
| 37 | 37 | } | |
| 38 | + | var signing ports.SigningKeyService | |
| 39 | + | if f.SigningKey != nil { | |
| 40 | + | signing = f.SigningKey | |
| 41 | + | } | |
| 38 | 42 | // Without artifacts (ERMITE_CI_ARTIFACTS=0) the run page has no section | |
| 39 | 43 | // for them and no download route. | |
| 40 | 44 | var artifacts ports.ArtifactService | |
| @@ -50,6 +54,7 @@ func newWeb(cfg config.Config, f *forge, limiter *clientlimit.Limiter) (http.Han | |||
| 50 | 54 | Inbox: f.Inbox, | |
| 51 | 55 | IssueForm: f.IssueSubmissions, | |
| 52 | 56 | Moderation: f.IssueModeration, | |
| 57 | + | SigningKey: signing, | |
| 53 | 58 | Runs: f.CI, | |
| 54 | 59 | Artifacts: artifacts, | |
| 55 | 60 | Events: f.EventLog, | |
M internal/app/web_events_test.go +9 -0
| @@ -37,6 +37,12 @@ type sseStream struct { | |||
| 37 | 37 | // background. It returns once the server answered, which it does when the | |
| 38 | 38 | // watch is registered: what happens after is not missed. | |
| 39 | 39 | func (h *harness) sse(query, lastID string) *sseStream { | |
| 40 | + | h.t.Helper() | |
| 41 | + | return h.sseAs(query, lastID, nil) | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | // sseAs opens the stream with the headers given (the owner's cookie). | |
| 45 | + | func (h *harness) sseAs(query, lastID string, headers map[string]string) *sseStream { | |
| 40 | 46 | h.t.Helper() | |
| 41 | 47 | ctx, cancel := context.WithCancel(h.ctx) | |
| 42 | 48 | h.t.Cleanup(cancel) | |
| @@ -45,6 +51,9 @@ func (h *harness) sse(query, lastID string) *sseStream { | |||
| 45 | 51 | if lastID != "" { | |
| 46 | 52 | req.Header.Set("Last-Event-ID", lastID) | |
| 47 | 53 | } | |
| 54 | + | for k, v := range headers { | |
| 55 | + | req.Header.Set(k, v) | |
| 56 | + | } | |
| 48 | 57 | resp, err := http.DefaultClient.Do(req) | |
| 49 | 58 | require.NoError(h.t, err) | |
| 50 | 59 | t := h.t | |
M internal/app/web_mr_test.go +202 -10
| @@ -10,6 +10,7 @@ import ( | |||
| 10 | 10 | "regexp" | |
| 11 | 11 | "strings" | |
| 12 | 12 | "testing" | |
| 13 | + | "time" | |
| 13 | 14 | ||
| 14 | 15 | "github.com/stretchr/testify/assert" | |
| 15 | 16 | "github.com/stretchr/testify/require" | |
| @@ -165,12 +166,15 @@ func TestWebMergeRequestPagesRender(t *testing.T) { | |||
| 165 | 166 | assert.Equal(t, head, formHead(t, own.body)) | |
| 166 | 167 | assert.Contains(t, own.body, `action="/proj/merge-requests/1/merge"`) | |
| 167 | 168 | assert.Contains(t, own.body, `id="merge-menu"`, "the merge is a menu of the bar above the diff") | |
| 168 | - | assert.Contains(t, own.body, `<input type="radio" name="mode" value="fast-forward" checked>`, "the first mode is the default") | |
| 169 | - | assert.Contains(t, own.body, `<input type="radio" name="mode" value="squash">`) | |
| 169 | + | assert.Contains(t, own.body, `name="mode" value="fast-forward" checked`, "the first possible mode is the default") | |
| 170 | + | assert.Contains(t, own.body, `name="mode" value="squash"`) | |
| 171 | + | assert.Contains(t, own.body, `name="mode" value="merge"`, "every mode is allowed by default") | |
| 170 | 172 | assert.Contains(t, own.body, "ermite mr merge proj 1 --head "+head) | |
| 171 | 173 | assert.Contains(t, own.body, `name="delete_branch" value="1"> delete feat/search after the merge <kbd aria-hidden="true">D</kbd>`) | |
| 172 | 174 | assert.NotContains(t, own.body, `name="delete_branch" value="1" checked>`, "unchecked by default") | |
| 173 | - | assert.Contains(t, own.body, "ermite mr merge proj 1 --head "+head+" --squash") | |
| 175 | + | assert.Contains(t, own.body, "ermite mr merge proj 1 --head "+head+" --mode squash") | |
| 176 | + | assert.Contains(t, own.body, "ermite mr merge proj 1 --head "+head+" --mode merge") | |
| 177 | + | assert.NotContains(t, own.body, "--squash") | |
| 174 | 178 | assert.Contains(t, own.body, `action="/proj/merge-requests/1/comments"`) | |
| 175 | 179 | assert.Contains(t, own.body, `action="/proj/merge-requests/1/close"`) | |
| 176 | 180 | assert.Contains(t, own.body, `href="/proj/merge-requests/1?on=new:1:search.go#f`, "line numbers link to their form") | |
| @@ -392,6 +396,7 @@ func TestWebMergeRequestSquashCloseAndList(t *testing.T) { | |||
| 392 | 396 | assert.Contains(t, b.get("/proj/merge-requests/1", as(token)).body, `name="delete_branch" value="1"`, "the box offers to delete the branch") | |
| 393 | 397 | res = b.form("/proj/merge-requests/1/merge", token, url.Values{"mode": {"squash"}, "head_id": {head}, "delete_branch": {"1"}}) | |
| 394 | 398 | require.Equal(t, http.StatusSeeOther, res.status, res.body) | |
| 399 | + | r.forge.MergeRequests.Wait(r.ctx) // the deletion follows the redirect | |
| 395 | 400 | assert.Empty(t, r.bareGit("proj", "for-each-ref", "refs/heads/feat/search"), "the source branch is deleted") | |
| 396 | 401 | merged := r.headOf("main") | |
| 397 | 402 | assert.NotEqual(t, head, merged, "squash makes a new commit") | |
| @@ -461,6 +466,9 @@ func TestWebMergeRequestDraft(t *testing.T) { | |||
| 461 | 466 | ||
| 462 | 467 | res := b.form("/proj/merge-requests/1/merge", token, url.Values{"mode": {"fast-forward"}, "head_id": {head}}) | |
| 463 | 468 | assert.Equal(t, http.StatusConflict, res.status, "a draft is not merged") | |
| 469 | + | assert.Contains(t, res.body, `data-canonical="/proj/merge-requests/1"`, "the refused page names its own address") | |
| 470 | + | assert.Contains(t, res.body, `class="d-err"`, "the reason stays inline") | |
| 471 | + | assert.NotContains(t, b.get("/proj/merge-requests/1", as(token)).body, "data-canonical", "the plain page has no marker") | |
| 464 | 472 | ||
| 465 | 473 | res = b.form("/proj/merge-requests/1/ready", token, nil) | |
| 466 | 474 | require.Equal(t, http.StatusSeeOther, res.status, res.body) | |
| @@ -511,6 +519,7 @@ func TestWebMergeDeleteBranchBox(t *testing.T) { | |||
| 511 | 519 | one := b.get("/proj/merge-requests/1", as(token)) | |
| 512 | 520 | res := b.form("/proj/merge-requests/1/merge", token, url.Values{"mode": {"fast-forward"}, "head_id": {formHead(t, one.body)}, "delete_branch": {"1"}}) | |
| 513 | 521 | require.Equal(t, http.StatusSeeOther, res.status, res.body) | |
| 522 | + | r.forge.MergeRequests.Wait(r.ctx) // the deletion follows the redirect | |
| 514 | 523 | assert.Empty(t, r.bareGit("proj", "for-each-ref", "refs/heads/feat/search")) | |
| 515 | 524 | done := b.get("/proj/merge-requests/1", nil) | |
| 516 | 525 | assert.Equal(t, 200, done.status, "the merged page reads without its branch") | |
| @@ -569,8 +578,7 @@ func TestWebMergeRequestEvents(t *testing.T) { | |||
| 569 | 578 | r := newMRRepo(t, domain.VisibilityPublic) | |
| 570 | 579 | list := r.sse("topic=repo:proj&view=mrs", "") | |
| 571 | 580 | r.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/search", "--title", "Secret <title>").ok(t) | |
| 572 | - | ev := list.next(t, watchWait) | |
| 573 | - | assert.Equal(t, "mr-notice", ev.name) | |
| 581 | + | ev := nextNamed(t, list, "mr-notice") | |
| 574 | 582 | assert.Contains(t, ev.data, `<div id="mr-notice" class="live-slot" role="status" data-slot>`) | |
| 575 | 583 | assert.Contains(t, ev.data, `href="/proj/merge-requests"`) | |
| 576 | 584 | assert.NotContains(t, ev.data, "Secret") | |
| @@ -578,19 +586,18 @@ func TestWebMergeRequestEvents(t *testing.T) { | |||
| 578 | 586 | one := r.sse("topic=repo:proj&view=mr&n=1&ref=feat/search", "") | |
| 579 | 587 | other := r.sse("topic=repo:proj&view=mr&n=2&ref=feat/other", "") | |
| 580 | 588 | r.cli(t, adminToken, "mr", "comment", "proj", "1", "-m", "private words").ok(t) | |
| 581 | - | ev = one.next(t, watchWait) | |
| 582 | - | assert.Equal(t, "mr-notice", ev.name) | |
| 589 | + | ev = nextNamed(t, one, "mr-notice") | |
| 583 | 590 | assert.Contains(t, ev.data, "a comment was added") | |
| 584 | 591 | assert.Contains(t, ev.data, `href="/proj/merge-requests/1"`) | |
| 585 | 592 | assert.NotContains(t, ev.data, "private words") | |
| 586 | 593 | ||
| 587 | 594 | r.writeFile(r.wt, "more.txt", []byte("more\n")) | |
| 588 | 595 | r.mustGit(r.wt, "push", "-q", r.url, "feat/search") | |
| 589 | - | ev = one.next(t, watchWait) | |
| 596 | + | ev = nextNamed(t, one, "mr-notice") | |
| 590 | 597 | assert.Contains(t, ev.data, "the branch changed") | |
| 591 | 598 | ||
| 592 | 599 | r.cli(t, adminToken, "mr", "close", "proj", "1").ok(t) | |
| 593 | - | assert.Contains(t, one.next(t, watchWait).data, "was closed") | |
| 600 | + | assert.Contains(t, nextNamed(t, one, "mr-notice").data, "was closed") | |
| 594 | 601 | ||
| 595 | 602 | // A page of another request hears nothing of this one. | |
| 596 | 603 | for drained := false; !drained; { | |
| @@ -603,7 +610,20 @@ func TestWebMergeRequestEvents(t *testing.T) { | |||
| 603 | 610 | drained = true | |
| 604 | 611 | } | |
| 605 | 612 | } | |
| 606 | - | assert.Contains(t, r.get1("/proj/merge-requests/1"), `hx-sse:connect="/-/events?topic=repo%3Aproj&view=mr&n=1&ref=feat%2Fsearch&head=`+r.headOf("feat/search")+`"`) | |
| 613 | + | assert.Contains(t, r.get1("/proj/merge-requests/1"), `hx-sse:connect="/-/events?topic=repo%3Aproj&view=mr&n=1&ref=feat%2Fsearch&head=`+r.headOf("feat/search")+`&target=main"`) | |
| 614 | + | } | |
| 615 | + | ||
| 616 | + | // nextNamed returns the next event that carries the fragment event: the | |
| 617 | + | // events that change nothing on the page (an id alone) are skipped. | |
| 618 | + | func nextNamed(t *testing.T, s *sseStream, name string) sseEvent { | |
| 619 | + | t.Helper() | |
| 620 | + | for range 10 { | |
| 621 | + | if ev := s.next(t, watchWait); ev.name == name { | |
| 622 | + | return ev | |
| 623 | + | } | |
| 624 | + | } | |
| 625 | + | t.Fatalf("no %s event", name) | |
| 626 | + | return sseEvent{} | |
| 607 | 627 | } | |
| 608 | 628 | ||
| 609 | 629 | func (r *mrRepo) get1(path string) string { | |
| @@ -722,3 +742,175 @@ func TestWebMergeRequestFileWindows(t *testing.T) { | |||
| 722 | 742 | } | |
| 723 | 743 | ||
| 724 | 744 | var reWindowSrc = regexp.MustCompile(`data-src="([^"]*from=25[^"]*)"`) | |
| 745 | + | ||
| 746 | + | // advanceMain pushes a commit to main that the feature branch lacks. file is | |
| 747 | + | // the file it adds (give search.go to conflict with the feature branch). | |
| 748 | + | func (r *mrRepo) advanceMain(file, content string) { | |
| 749 | + | r.t.Helper() | |
| 750 | + | r.mustGit(r.wt, "checkout", "-q", "main") | |
| 751 | + | r.writeFile(r.wt, file, []byte(content)) | |
| 752 | + | r.mustGit(r.wt, "push", "-q", r.url, "main") | |
| 753 | + | r.mustGit(r.wt, "checkout", "-q", "feat/search") | |
| 754 | + | } | |
| 755 | + | ||
| 756 | + | // mrPageWith polls the owner's page of !1 until it contains want. | |
| 757 | + | func (r *mrRepo) mrPageWith(token, want string) string { | |
| 758 | + | r.t.Helper() | |
| 759 | + | var body string | |
| 760 | + | require.Eventually(r.t, func() bool { | |
| 761 | + | body = browser{r.harness}.get("/proj/merge-requests/1", as(token)).body | |
| 762 | + | return strings.Contains(body, want) | |
| 763 | + | }, 10*time.Second, 25*time.Millisecond, "the page never showed %q", want) | |
| 764 | + | return body | |
| 765 | + | } | |
| 766 | + | ||
| 767 | + | // The merge box shows the mergeability of the request: ready, then behind | |
| 768 | + | // (fast-forward is disabled with its reason, the other modes are not), then | |
| 769 | + | // in conflict (no mode is possible and the button says why). | |
| 770 | + | func TestWebMergeRequestMergeabilityBadge(t *testing.T) { | |
| 771 | + | r := newMRRepo(t, domain.VisibilityPublic) | |
| 772 | + | token := r.signIn(t) | |
| 773 | + | r.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/search", "--title", "Add search").ok(t) | |
| 774 | + | ||
| 775 | + | body := r.mrPageWith(token, "Ready to merge") | |
| 776 | + | assert.Contains(t, body, `<div id="mr-mergeability" class="live-slot" role="status" data-slot>`) | |
| 777 | + | assert.Contains(t, body, `data-state="up_to_date"`) | |
| 778 | + | assert.Contains(t, body, `name="mode" value="fast-forward" checked`) | |
| 779 | + | assert.NotContains(t, body, `value="fast-forward" checked disabled`) | |
| 780 | + | assert.Contains(t, body, `id="mr-signing"`, "squash and merge commits say who signs them") | |
| 781 | + | assert.Contains(t, body, "signed with ermite's own key (generated on first start)") | |
| 782 | + | assert.Contains(t, body, "<code>ermite signing-key show</code>") | |
| 783 | + | assert.Contains(t, body, "SHA256:", "the fingerprint of the signing key") | |
| 784 | + | assert.Contains(t, body, `:disabled="`, "the controls follow the badge") | |
| 785 | + | assertNoInline(t, body) | |
| 786 | + | ||
| 787 | + | // Main moves on: the branch is behind by one commit and merges cleanly. | |
| 788 | + | r.advanceMain("other.txt", "other\n") | |
| 789 | + | body = r.mrPageWith(token, "Behind main by 1 commit") | |
| 790 | + | assert.Contains(t, body, "merges without conflicts") | |
| 791 | + | assert.Contains(t, body, `data-ff-ok="false"`) | |
| 792 | + | assert.Contains(t, body, `data-merge-ok="true"`) | |
| 793 | + | assert.Regexp(t, `<input type="radio" name="mode" value="fast-forward"[^>]* disabled`, body) | |
| 794 | + | assert.Contains(t, body, `title="behind the target: rebase or use squash/merge"`) | |
| 795 | + | assert.Regexp(t, `name="mode" value="squash" checked`, body, "the first possible mode is checked") | |
| 796 | + | assert.NotRegexp(t, `name="mode" value="squash"[^>]* disabled`, body) | |
| 797 | + | ||
| 798 | + | // A conflicting change to main: every mode is disabled and the files are listed. | |
| 799 | + | r.mustGit(r.wt, "checkout", "-q", "main") | |
| 800 | + | r.writeFile(r.wt, "search.go", []byte("package other\n")) | |
| 801 | + | r.mustGit(r.wt, "push", "-q", r.url, "main") | |
| 802 | + | body = r.mrPageWith(token, "Conflicts with main") | |
| 803 | + | assert.Contains(t, body, "rebase needed") | |
| 804 | + | assert.Contains(t, body, `<ul class="mg-paths" aria-label="conflicting files"><li><code>search.go</code></li></ul>`) | |
| 805 | + | assert.Regexp(t, `<button type="submit" class="btn primary" data-default disabled title="conflicts with the target"`, body) | |
| 806 | + | assert.NotContains(t, body, `name="mode" value="squash" checked`) | |
| 807 | + | ||
| 808 | + | // The server refuses it too, and says so in the box of the page. | |
| 809 | + | res := browser{r.harness}.form("/proj/merge-requests/1/merge", token, url.Values{"mode": {"squash"}, "head_id": {formHead(t, body)}}) | |
| 810 | + | assert.Equal(t, http.StatusConflict, res.status) | |
| 811 | + | assert.Contains(t, res.body, `<p class="d-err" role="alert">`) | |
| 812 | + | assert.Contains(t, res.body, "conflicts with main") | |
| 813 | + | } | |
| 814 | + | ||
| 815 | + | // A merge the server refuses (the target moved after the page was read) shows | |
| 816 | + | // the page of the request again, 409, with the reason in its merge box. | |
| 817 | + | func TestWebMergeRequestInlineMergeError(t *testing.T) { | |
| 818 | + | r := newMRRepo(t, domain.VisibilityPublic) | |
| 819 | + | b := browser{r.harness} | |
| 820 | + | token := r.signIn(t) | |
| 821 | + | r.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/search", "--title", "Add search").ok(t) | |
| 822 | + | body := r.mrPageWith(token, "Ready to merge") | |
| 823 | + | head := formHead(t, body) | |
| 824 | + | ||
| 825 | + | r.advanceMain("other.txt", "other\n") // raced: the form below still says fast-forward | |
| 826 | + | res := b.form("/proj/merge-requests/1/merge", token, url.Values{"mode": {"fast-forward"}, "head_id": {head}}) | |
| 827 | + | assert.Equal(t, http.StatusConflict, res.status) | |
| 828 | + | assert.Equal(t, "no-store", res.hdr.Get("Cache-Control")) | |
| 829 | + | assert.Contains(t, res.body, `id="merge-menu"`, "the page of the request, not a state page") | |
| 830 | + | assert.Contains(t, res.body, `<p class="d-err" role="alert">`) | |
| 831 | + | assert.Contains(t, res.body, "the target has moved") | |
| 832 | + | assert.Contains(t, res.body, `<details class="menu mmenu" id="merge-menu" data-state="open" open>`, "the box is open on the error") | |
| 833 | + | assert.Equal(t, head, r.headOf("feat/search")) | |
| 834 | + | assert.NotContains(t, b.get("/proj/merge-requests/1", as(token)).body, "d-err", "a reload shows no error") | |
| 835 | + | ||
| 836 | + | // A head that moved is refused the same way. | |
| 837 | + | r.writeFile(r.wt, "more.txt", []byte("more\n")) | |
| 838 | + | r.mustGit(r.wt, "push", "-q", r.url, "feat/search") | |
| 839 | + | res = b.form("/proj/merge-requests/1/merge", token, url.Values{"mode": {"squash"}, "head_id": {head}}) | |
| 840 | + | assert.Equal(t, http.StatusConflict, res.status) | |
| 841 | + | assert.Contains(t, res.body, `<p class="d-err" role="alert">`) | |
| 842 | + | assert.Contains(t, res.body, "branch moved") | |
| 843 | + | ||
| 844 | + | // Other refusals keep the state page; a mode that does not exist is a 400. | |
| 845 | + | assert.Equal(t, http.StatusBadRequest, b.form("/proj/merge-requests/1/merge", token, url.Values{"mode": {"rebase"}, "head_id": {head}}).status) | |
| 846 | + | } | |
| 847 | + | ||
| 848 | + | // A merge commit through the page; with the branch deletion requested the | |
| 849 | + | // merged page says the branch is being deleted, not that it is gone. | |
| 850 | + | func TestWebMergeRequestMergeCommitAndDeletingNote(t *testing.T) { | |
| 851 | + | r := newMRRepo(t, domain.VisibilityPublic) | |
| 852 | + | b := browser{r.harness} | |
| 853 | + | token := r.signIn(t) | |
| 854 | + | r.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/search", "--title", "Add search").ok(t) | |
| 855 | + | r.advanceMain("other.txt", "other\n") | |
| 856 | + | body := r.mrPageWith(token, "Behind main by 1 commit") | |
| 857 | + | head := formHead(t, body) | |
| 858 | + | main0 := r.headOf("main") | |
| 859 | + | ||
| 860 | + | res := b.form("/proj/merge-requests/1/merge", token, url.Values{"mode": {"merge"}, "head_id": {head}, "delete_branch": {"1"}}) | |
| 861 | + | require.Equal(t, http.StatusSeeOther, res.status, res.body) | |
| 862 | + | assert.Equal(t, "/proj/merge-requests/1?deleting=1", res.hdr.Get("Location")) | |
| 863 | + | merged := r.headOf("main") | |
| 864 | + | assert.Equal(t, main0, r.bareGit("proj", "rev-parse", merged+"^1")) | |
| 865 | + | assert.Equal(t, head, r.bareGit("proj", "rev-parse", merged+"^2")) | |
| 866 | + | assert.Contains(t, r.bareGit("proj", "cat-file", "-p", merged), "gpgsig") | |
| 867 | + | page := b.get("/proj/merge-requests/1?deleting=1", as(token)) | |
| 868 | + | assert.Contains(t, page.body, "merge commit") | |
| 869 | + | if strings.Contains(page.body, "is being deleted") { | |
| 870 | + | assert.Contains(t, page.body, "feat/search is being deleted") | |
| 871 | + | } | |
| 872 | + | assert.NotContains(t, page.body, "is deleted") | |
| 873 | + | r.forge.MergeRequests.Wait(r.ctx) | |
| 874 | + | assert.Empty(t, r.bareGit("proj", "for-each-ref", "refs/heads/feat/search")) | |
| 875 | + | assert.NotContains(t, b.get("/proj/merge-requests/1?deleting=1", as(token)).body, "is being deleted", "once gone, nothing is being deleted") | |
| 876 | + | } | |
| 877 | + | ||
| 878 | + | // The badge follows the request live: a push to the target turns the fragment | |
| 879 | + | // of the page to "checking" or "behind", and the settled result follows. | |
| 880 | + | func TestWebMergeRequestMergeabilityLive(t *testing.T) { | |
| 881 | + | r := newMRRepo(t, domain.VisibilityPublic) | |
| 882 | + | token := r.signIn(t) | |
| 883 | + | r.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/search", "--title", "Add search").ok(t) | |
| 884 | + | r.mrPageWith(token, "Ready to merge") | |
| 885 | + | ||
| 886 | + | one := r.sseAs("topic=repo:proj&view=mr&n=1&ref=feat/search&target=main", "", as(token)) | |
| 887 | + | anon := r.sse("topic=repo:proj&view=mr&n=1&ref=feat/search&target=main", "") | |
| 888 | + | r.advanceMain("other.txt", "other\n") | |
| 889 | + | deadline := time.After(watchWait) | |
| 890 | + | for done := false; !done; { | |
| 891 | + | ev := nextNamed(t, one, "mr-mergeability") | |
| 892 | + | select { | |
| 893 | + | case <-deadline: | |
| 894 | + | t.Fatal("the badge never showed the result") | |
| 895 | + | default: | |
| 896 | + | } | |
| 897 | + | if strings.Contains(ev.data, "Behind main by 1 commit") { | |
| 898 | + | assert.Contains(t, ev.data, `<div id="mr-mergeability" class="live-slot" role="status" data-slot>`) | |
| 899 | + | assert.Contains(t, ev.data, `data-ff-ok="false"`) | |
| 900 | + | assert.Contains(t, ev.data, `data-merge-ok="true"`) | |
| 901 | + | done = true | |
| 902 | + | } else { | |
| 903 | + | assert.Contains(t, ev.data, "Checking…", "before the result, the badge says it checks") | |
| 904 | + | } | |
| 905 | + | } | |
| 906 | + | // The box is the owner's: a visitor's stream carries no badge. | |
| 907 | + | for { | |
| 908 | + | select { | |
| 909 | + | case ev := <-anon.events: | |
| 910 | + | assert.NotEqual(t, "mr-mergeability", ev.name) | |
| 911 | + | continue | |
| 912 | + | default: | |
| 913 | + | } | |
| 914 | + | break | |
| 915 | + | } | |
| 916 | + | } | |
M internal/domain/event.go +14 -0
| @@ -284,6 +284,20 @@ type CommentResolved struct { | |||
| 284 | 284 | // EventKind implements Event. | |
| 285 | 285 | func (CommentResolved) EventKind() string { return "mr.comment_resolved" } | |
| 286 | 286 | ||
| 287 | + | // MergeRequestMergeability is published when the mergeability of an open merge | |
| 288 | + | // request changed. | |
| 289 | + | type MergeRequestMergeability struct { | |
| 290 | + | Repo RepoName | |
| 291 | + | Number int64 | |
| 292 | + | Mergeability Mergeability | |
| 293 | + | At time.Time | |
| 294 | + | Visibility Visibility | |
| 295 | + | CreatedAt time.Time | |
| 296 | + | } | |
| 297 | + | ||
| 298 | + | // EventKind implements Event. | |
| 299 | + | func (MergeRequestMergeability) EventKind() string { return "mr.mergeability" } | |
| 300 | + | ||
| 287 | 301 | // EventGap tells a subscriber that it missed events because it was too slow; | |
| 288 | 302 | // it must refresh its whole view. | |
| 289 | 303 | type EventGap struct{} | |
M internal/domain/mergerequest.go +155 -16
| @@ -18,18 +18,23 @@ const ( | |||
| 18 | 18 | MergeModeFastForward MergeMode = "fast-forward" | |
| 19 | 19 | // MergeModeSquash makes one new signed commit with the tree of the head. | |
| 20 | 20 | MergeModeSquash MergeMode = "squash" | |
| 21 | + | // MergeModeMerge makes one new signed commit with two parents: the target | |
| 22 | + | // tip and the head. | |
| 23 | + | MergeModeMerge MergeMode = "merge" | |
| 21 | 24 | ) | |
| 22 | 25 | ||
| 23 | - | // MergeModes returns the two modes in their canonical order. | |
| 24 | - | func MergeModes() []MergeMode { return []MergeMode{MergeModeFastForward, MergeModeSquash} } | |
| 26 | + | // MergeModes returns the modes in their canonical order. | |
| 27 | + | func MergeModes() []MergeMode { | |
| 28 | + | return []MergeMode{MergeModeFastForward, MergeModeSquash, MergeModeMerge} | |
| 29 | + | } | |
| 25 | 30 | ||
| 26 | - | // ParseMergeMode parses "fast-forward" or "squash". | |
| 31 | + | // ParseMergeMode parses "fast-forward", "squash" or "merge". | |
| 27 | 32 | func ParseMergeMode(s string) (MergeMode, error) { | |
| 28 | 33 | m := MergeMode(s) | |
| 29 | 34 | if slices.Contains(MergeModes(), m) { | |
| 30 | 35 | return m, nil | |
| 31 | 36 | } | |
| 32 | - | return "", fmt.Errorf("%w: merge mode %q (want fast-forward or squash)", ErrInvalid, s) | |
| 37 | + | return "", fmt.Errorf("%w: merge mode %q (want fast-forward, squash or merge)", ErrInvalid, s) | |
| 33 | 38 | } | |
| 34 | 39 | ||
| 35 | 40 | // String returns the mode as text. | |
| @@ -47,7 +52,7 @@ type RepoSettings struct { | |||
| 47 | 52 | Issues IssuesMode | |
| 48 | 53 | } | |
| 49 | 54 | ||
| 50 | - | // DefaultRepoSettings are the settings of a new repository: both modes, and | |
| 55 | + | // DefaultRepoSettings are the settings of a new repository: every mode, and | |
| 51 | 56 | // failing checks block the merge. | |
| 52 | 57 | func DefaultRepoSettings() RepoSettings { | |
| 53 | 58 | return RepoSettings{AllowedMergeModes: MergeModes(), FailingChecksBlock: true, Issues: IssuesOff} | |
| @@ -249,10 +254,10 @@ type Identity struct { | |||
| 249 | 254 | Name, Email string | |
| 250 | 255 | } | |
| 251 | 256 | ||
| 252 | - | // SquashCommit is the commit a squash merge creates: the tree of the head on | |
| 253 | - | // top of the target. | |
| 254 | - | type SquashCommit struct { | |
| 255 | - | Tree, Parent ObjectID | |
| 257 | + | // ServerCommit is a commit the server creates: a squash or a merge commit. | |
| 258 | + | type ServerCommit struct { | |
| 259 | + | Tree ObjectID | |
| 260 | + | Parents []ObjectID | |
| 256 | 261 | Author, Committer Signature | |
| 257 | 262 | Message string | |
| 258 | 263 | } | |
| @@ -267,7 +272,7 @@ const MaxSquashCommits = 1000 | |||
| 267 | 272 | // trailers, in order of appearance. The message is the title, " (!<number>)", | |
| 268 | 273 | // a blank line, the summaries of the commits oldest first as a list, and the | |
| 269 | 274 | // trailers. | |
| 270 | - | func NewSquashCommit(mr MergeRequest, tree, parent ObjectID, commits []Commit, committer Identity, at time.Time) SquashCommit { | |
| 275 | + | func NewSquashCommit(mr MergeRequest, tree, parent ObjectID, commits []Commit, committer Identity, at time.Time) ServerCommit { | |
| 271 | 276 | oldest := slices.Clone(commits) | |
| 272 | 277 | slices.Reverse(oldest) | |
| 273 | 278 | var first Signature | |
| @@ -293,26 +298,41 @@ func NewSquashCommit(mr MergeRequest, tree, parent ObjectID, commits []Commit, c | |||
| 293 | 298 | if len(trailers) > 0 { | |
| 294 | 299 | msg.WriteString("\n" + strings.Join(trailers, "\n") + "\n") | |
| 295 | 300 | } | |
| 296 | - | return SquashCommit{ | |
| 297 | - | Tree: tree, Parent: parent, | |
| 301 | + | return ServerCommit{ | |
| 302 | + | Tree: tree, Parents: []ObjectID{parent}, | |
| 298 | 303 | Author: first, | |
| 299 | 304 | Committer: Signature{Name: committer.Name, Email: committer.Email, Time: at.UTC()}, | |
| 300 | 305 | Message: msg.String(), | |
| 301 | 306 | } | |
| 302 | 307 | } | |
| 303 | 308 | ||
| 309 | + | // NewMergeCommit builds the merge commit of a merge request: the tree of the | |
| 310 | + | // merge, the target tip and the head as parents, and the server identity as | |
| 311 | + | // author and committer. | |
| 312 | + | func NewMergeCommit(mr MergeRequest, tree, target, head ObjectID, committer Identity, at time.Time) ServerCommit { | |
| 313 | + | sig := Signature{Name: committer.Name, Email: committer.Email, Time: at.UTC()} | |
| 314 | + | return ServerCommit{ | |
| 315 | + | Tree: tree, Parents: []ObjectID{target, head}, | |
| 316 | + | Author: sig, Committer: sig, | |
| 317 | + | Message: fmt.Sprintf("Merge branch '%s' into %s (!%d)\n\n%s\n", | |
| 318 | + | singleLine(mr.Source), singleLine(mr.Target), mr.Number, singleLine(mr.Title.String())), | |
| 319 | + | } | |
| 320 | + | } | |
| 321 | + | ||
| 304 | 322 | // Payload returns the text of the commit object without a signature, which is | |
| 305 | 323 | // what the signature covers. | |
| 306 | - | func (c SquashCommit) Payload() []byte { return c.encode("") } | |
| 324 | + | func (c ServerCommit) Payload() []byte { return c.encode("") } | |
| 307 | 325 | ||
| 308 | 326 | // Signed returns the text of the commit object with the armored SSH signature | |
| 309 | 327 | // in its gpgsig header. | |
| 310 | - | func (c SquashCommit) Signed(armored string) []byte { return c.encode(armored) } | |
| 328 | + | func (c ServerCommit) Signed(armored string) []byte { return c.encode(armored) } | |
| 311 | 329 | ||
| 312 | - | func (c SquashCommit) encode(armored string) []byte { | |
| 330 | + | func (c ServerCommit) encode(armored string) []byte { | |
| 313 | 331 | var b strings.Builder | |
| 314 | 332 | b.WriteString("tree " + c.Tree.String() + "\n") | |
| 315 | - | b.WriteString("parent " + c.Parent.String() + "\n") | |
| 333 | + | for _, p := range c.Parents { | |
| 334 | + | b.WriteString("parent " + p.String() + "\n") | |
| 335 | + | } | |
| 316 | 336 | b.WriteString("author " + signatureLine(c.Author) + "\n") | |
| 317 | 337 | b.WriteString("committer " + signatureLine(c.Committer) + "\n") | |
| 318 | 338 | if armored != "" { | |
| @@ -430,3 +450,122 @@ func CurrentVerdicts(comments []Comment, head ObjectID) []AuthorVerdict { | |||
| 430 | 450 | }) | |
| 431 | 451 | return out | |
| 432 | 452 | } | |
| 453 | + | ||
| 454 | + | // Limits of a stored Mergeability. | |
| 455 | + | const ( | |
| 456 | + | // MaxBehindCount caps the commits counted as behind. | |
| 457 | + | MaxBehindCount = 1000 | |
| 458 | + | // MaxConflictPaths caps the conflicted paths listed. | |
| 459 | + | MaxConflictPaths = 20 | |
| 460 | + | ) | |
| 461 | + | ||
| 462 | + | // MergeabilityState is the outcome of a mergeability check. | |
| 463 | + | type MergeabilityState string | |
| 464 | + | ||
| 465 | + | // Mergeability states. | |
| 466 | + | const ( | |
| 467 | + | // MergeabilityChecking means there is no up-to-date result yet; it is never | |
| 468 | + | // stored. | |
| 469 | + | MergeabilityChecking MergeabilityState = "checking" | |
| 470 | + | // MergeabilityUpToDate means the target is an ancestor of the head. | |
| 471 | + | MergeabilityUpToDate MergeabilityState = "up_to_date" | |
| 472 | + | // MergeabilityBehind means the head lacks commits of the target but merges | |
| 473 | + | // cleanly. | |
| 474 | + | MergeabilityBehind MergeabilityState = "behind" | |
| 475 | + | // MergeabilityConflict means the head does not merge cleanly. | |
| 476 | + | MergeabilityConflict MergeabilityState = "conflict" | |
| 477 | + | ) | |
| 478 | + | ||
| 479 | + | // Mergeability is whether an open merge request merges with its target, for | |
| 480 | + | // given tips. | |
| 481 | + | type Mergeability struct { | |
| 482 | + | Repo RepoName | |
| 483 | + | Number int64 | |
| 484 | + | State MergeabilityState | |
| 485 | + | // TargetID and HeadID are the tips the result is for; zero for a derived | |
| 486 | + | // "checking". | |
| 487 | + | TargetID, HeadID ObjectID | |
| 488 | + | // Behind counts the commits of the target not in the head (behind only), | |
| 489 | + | // capped at MaxBehindCount. | |
| 490 | + | Behind int | |
| 491 | + | BehindCapped bool | |
| 492 | + | // ConflictPaths holds at most MaxConflictPaths paths (conflict only). | |
| 493 | + | ConflictPaths []string | |
| 494 | + | ConflictPathsTruncated bool | |
| 495 | + | CheckedAt time.Time | |
| 496 | + | // Version is set by the store on read, like MergeRequest.Version. | |
| 497 | + | Version uint64 | |
| 498 | + | } | |
| 499 | + | ||
| 500 | + | // Allows reports whether the mode may be used given the state, and if not, | |
| 501 | + | // why. | |
| 502 | + | func (m Mergeability) Allows(mode MergeMode) (bool, string) { | |
| 503 | + | switch mode { | |
| 504 | + | case MergeModeFastForward: | |
| 505 | + | if m.State == MergeabilityUpToDate { | |
| 506 | + | return true, "" | |
| 507 | + | } | |
| 508 | + | if m.State == MergeabilityBehind { | |
| 509 | + | return false, "behind the target: rebase or use squash/merge" | |
| 510 | + | } | |
| 511 | + | case MergeModeSquash, MergeModeMerge: | |
| 512 | + | if m.State != MergeabilityConflict && m.State != MergeabilityChecking && m.State != "" { | |
| 513 | + | return true, "" | |
| 514 | + | } | |
| 515 | + | } | |
| 516 | + | switch m.State { | |
| 517 | + | case MergeabilityConflict: | |
| 518 | + | return false, "conflicts with the target" | |
| 519 | + | case MergeabilityChecking, "": | |
| 520 | + | return false, "mergeability is being checked" | |
| 521 | + | } | |
| 522 | + | return false, "mode not allowed" | |
| 523 | + | } | |
| 524 | + | ||
| 525 | + | // IsFor reports whether the result is for these tips, that is not stale. | |
| 526 | + | func (m Mergeability) IsFor(target, head ObjectID) bool { | |
| 527 | + | return m.State != "" && m.State != MergeabilityChecking && m.TargetID == target && m.HeadID == head | |
| 528 | + | } | |
| 529 | + | ||
| 530 | + | // MergeTreeResult is the outcome of merging two commits without a commit. | |
| 531 | + | type MergeTreeResult struct { | |
| 532 | + | // Tree is the merged tree; zero when not clean. | |
| 533 | + | Tree ObjectID | |
| 534 | + | Clean bool | |
| 535 | + | ConflictPaths []string | |
| 536 | + | Truncated bool | |
| 537 | + | } | |
| 538 | + | ||
| 539 | + | // Bounded returns r with at most MaxConflictPaths conflicted paths, Truncated | |
| 540 | + | // set when some were dropped. Adapters return the raw result; the bound is | |
| 541 | + | // the domain's. | |
| 542 | + | func (r MergeTreeResult) Bounded() MergeTreeResult { | |
| 543 | + | kept := r.ConflictPaths | |
| 544 | + | if len(kept) > MaxConflictPaths { | |
| 545 | + | kept = kept[:MaxConflictPaths] | |
| 546 | + | r.Truncated = true | |
| 547 | + | } | |
| 548 | + | if kept != nil { | |
| 549 | + | // Exact-sized copies: neither the backing array nor the git output the | |
| 550 | + | // paths were cut from stays referenced. | |
| 551 | + | out := make([]string, len(kept)) | |
| 552 | + | for i, p := range kept { | |
| 553 | + | out[i] = strings.Clone(p) | |
| 554 | + | } | |
| 555 | + | r.ConflictPaths = out | |
| 556 | + | } | |
| 557 | + | return r | |
| 558 | + | } | |
| 559 | + | ||
| 560 | + | // BehindLimit is the limit to give a count of commits behind so that | |
| 561 | + | // BoundBehind can tell whether MaxBehindCount was exceeded. | |
| 562 | + | const BehindLimit = MaxBehindCount + 1 | |
| 563 | + | ||
| 564 | + | // BoundBehind caps a raw count of commits behind (counted up to BehindLimit) | |
| 565 | + | // at MaxBehindCount; capped is true when the count was over it. | |
| 566 | + | func BoundBehind(n int) (count int, capped bool) { | |
| 567 | + | if n > MaxBehindCount { | |
| 568 | + | return MaxBehindCount, true | |
| 569 | + | } | |
| 570 | + | return n, false | |
| 571 | + | } | |
M internal/domain/mergerequest_test.go +50 -3
| @@ -3,6 +3,7 @@ | |||
| 3 | 3 | package domain_test | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | + | "strconv" | |
| 6 | 7 | "strings" | |
| 7 | 8 | "testing" | |
| 8 | 9 | "time" | |
| @@ -23,7 +24,7 @@ func oid(t *testing.T, c byte) domain.ObjectID { | |||
| 23 | 24 | func TestDefaultRepoSettings(t *testing.T) { | |
| 24 | 25 | t.Parallel() | |
| 25 | 26 | s := domain.DefaultRepoSettings() | |
| 26 | - | assert.Equal(t, []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash}, s.AllowedMergeModes) | |
| 27 | + | assert.Equal(t, []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash, domain.MergeModeMerge}, s.AllowedMergeModes) | |
| 27 | 28 | assert.True(t, s.FailingChecksBlock) | |
| 28 | 29 | assert.True(t, s.Allows(domain.MergeModeSquash)) | |
| 29 | 30 | assert.False(t, domain.RepoSettings{}.Allows(domain.MergeModeSquash)) | |
| @@ -39,7 +40,7 @@ func TestNormalizeMergeModes(t *testing.T) { | |||
| 39 | 40 | t.Parallel() | |
| 40 | 41 | got, err := domain.NormalizeMergeModes([]domain.MergeMode{domain.MergeModeSquash, domain.MergeModeFastForward, domain.MergeModeSquash}) | |
| 41 | 42 | require.NoError(t, err) | |
| 42 | - | assert.Equal(t, domain.MergeModes(), got) | |
| 43 | + | assert.Equal(t, []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash}, got) | |
| 43 | 44 | ||
| 44 | 45 | got, err = domain.NormalizeMergeModes([]domain.MergeMode{domain.MergeModeSquash}) | |
| 45 | 46 | require.NoError(t, err) | |
| @@ -56,7 +57,10 @@ func TestParseMergeMode(t *testing.T) { | |||
| 56 | 57 | m, err := domain.ParseMergeMode("fast-forward") | |
| 57 | 58 | require.NoError(t, err) | |
| 58 | 59 | assert.Equal(t, domain.MergeModeFastForward, m) | |
| 59 | - | _, err = domain.ParseMergeMode("merge") | |
| 60 | + | m, err = domain.ParseMergeMode("merge") | |
| 61 | + | require.NoError(t, err) | |
| 62 | + | assert.Equal(t, domain.MergeModeMerge, m) | |
| 63 | + | _, err = domain.ParseMergeMode("rebase") | |
| 60 | 64 | assert.ErrorIs(t, err, domain.ErrInvalid) | |
| 61 | 65 | } | |
| 62 | 66 | ||
| @@ -283,3 +287,46 @@ func TestReviewScopeOwnerRights(t *testing.T) { | |||
| 283 | 287 | assert.Equal(t, tc.want, domain.AuthorName(tc.p)) | |
| 284 | 288 | } | |
| 285 | 289 | } | |
| 290 | + | ||
| 291 | + | func TestNewMergeCommit(t *testing.T) { | |
| 292 | + | t.Parallel() | |
| 293 | + | title, err := domain.ParseMRTitle("Add things") | |
| 294 | + | require.NoError(t, err) | |
| 295 | + | mr := domain.MergeRequest{Number: 7, Title: title, Source: "feat/x", Target: "main"} | |
| 296 | + | at := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) | |
| 297 | + | c := domain.NewMergeCommit(mr, oid(t, 'a'), oid(t, 'b'), oid(t, 'c'), domain.Identity{Name: "ermite", Email: "e@x"}, at) | |
| 298 | + | assert.Equal(t, []domain.ObjectID{oid(t, 'b'), oid(t, 'c')}, c.Parents) | |
| 299 | + | assert.Equal(t, c.Author, c.Committer) | |
| 300 | + | want := "tree " + strings.Repeat("a", 40) + "\nparent " + strings.Repeat("b", 40) + "\nparent " + strings.Repeat("c", 40) + "\n" + | |
| 301 | + | "author ermite <e@x> " + strconv.FormatInt(at.Unix(), 10) + " +0000\ncommitter ermite <e@x> " + strconv.FormatInt(at.Unix(), 10) + " +0000\n\n" + | |
| 302 | + | "Merge branch 'feat/x' into main (!7)\n\nAdd things\n" | |
| 303 | + | assert.Equal(t, want, string(c.Payload())) | |
| 304 | + | assert.Contains(t, string(c.Signed("-----BEGIN-----\nabc\n-----END-----\n")), "gpgsig -----BEGIN-----\n abc\n -----END-----\n") | |
| 305 | + | } | |
| 306 | + | ||
| 307 | + | func TestMergeabilityAllowsAndIsFor(t *testing.T) { | |
| 308 | + | t.Parallel() | |
| 309 | + | for _, tc := range []struct { | |
| 310 | + | state domain.MergeabilityState | |
| 311 | + | ff, squash, mg bool | |
| 312 | + | }{ | |
| 313 | + | {domain.MergeabilityUpToDate, true, true, true}, | |
| 314 | + | {domain.MergeabilityBehind, false, true, true}, | |
| 315 | + | {domain.MergeabilityConflict, false, false, false}, | |
| 316 | + | {domain.MergeabilityChecking, false, false, false}, | |
| 317 | + | } { | |
| 318 | + | m := domain.Mergeability{State: tc.state} | |
| 319 | + | ok, reason := m.Allows(domain.MergeModeFastForward) | |
| 320 | + | assert.Equal(t, tc.ff, ok, tc.state) | |
| 321 | + | assert.Equal(t, !ok, reason != "") | |
| 322 | + | ok, _ = m.Allows(domain.MergeModeSquash) | |
| 323 | + | assert.Equal(t, tc.squash, ok, tc.state) | |
| 324 | + | ok, _ = m.Allows(domain.MergeModeMerge) | |
| 325 | + | assert.Equal(t, tc.mg, ok, tc.state) | |
| 326 | + | } | |
| 327 | + | m := domain.Mergeability{State: domain.MergeabilityBehind, TargetID: oid(t, 'a'), HeadID: oid(t, 'b')} | |
| 328 | + | assert.True(t, m.IsFor(oid(t, 'a'), oid(t, 'b'))) | |
| 329 | + | assert.False(t, m.IsFor(oid(t, 'c'), oid(t, 'b'))) | |
| 330 | + | assert.False(t, m.IsFor(oid(t, 'a'), oid(t, 'c'))) | |
| 331 | + | assert.False(t, domain.Mergeability{}.IsFor(domain.ObjectID{}, domain.ObjectID{})) | |
| 332 | + | } | |
A internal/domain/mergetree_bound_test.go +53 -0
| @@ -0,0 +1,53 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package domain_test | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "strconv" | |
| 7 | + | "testing" | |
| 8 | + | ||
| 9 | + | "github.com/stretchr/testify/assert" | |
| 10 | + | "github.com/stretchr/testify/require" | |
| 11 | + | ||
| 12 | + | "bnema.dev/ermite/internal/domain" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | func TestMergeTreeResultBounded(t *testing.T) { | |
| 16 | + | var paths []string | |
| 17 | + | for i := 0; i < domain.MaxConflictPaths+2; i++ { | |
| 18 | + | paths = append(paths, strconv.Itoa(i)) | |
| 19 | + | } | |
| 20 | + | got := domain.MergeTreeResult{ConflictPaths: paths}.Bounded() | |
| 21 | + | assert.Len(t, got.ConflictPaths, domain.MaxConflictPaths) | |
| 22 | + | assert.True(t, got.Truncated) | |
| 23 | + | ||
| 24 | + | got = domain.MergeTreeResult{ConflictPaths: paths[:domain.MaxConflictPaths]}.Bounded() | |
| 25 | + | assert.Len(t, got.ConflictPaths, domain.MaxConflictPaths) | |
| 26 | + | assert.False(t, got.Truncated) | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | func TestBoundBehind(t *testing.T) { | |
| 30 | + | n, capped := domain.BoundBehind(domain.MaxBehindCount) | |
| 31 | + | assert.Equal(t, domain.MaxBehindCount, n) | |
| 32 | + | assert.False(t, capped) | |
| 33 | + | n, capped = domain.BoundBehind(domain.BehindLimit) | |
| 34 | + | assert.Equal(t, domain.MaxBehindCount, n) | |
| 35 | + | assert.True(t, capped) | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | func TestMergeTreeResultBoundedCopiesThePaths(t *testing.T) { | |
| 39 | + | var paths []string | |
| 40 | + | for i := 0; i < domain.MaxConflictPaths+2; i++ { | |
| 41 | + | paths = append(paths, strconv.Itoa(i)) | |
| 42 | + | } | |
| 43 | + | got := domain.MergeTreeResult{ConflictPaths: paths}.Bounded() | |
| 44 | + | require.Len(t, got.ConflictPaths, domain.MaxConflictPaths) | |
| 45 | + | assert.Equal(t, len(got.ConflictPaths), cap(got.ConflictPaths)) | |
| 46 | + | assert.NotSame(t, &paths[0], &got.ConflictPaths[0], "a new array, not the git output's") | |
| 47 | + | assert.Equal(t, paths[:domain.MaxConflictPaths], got.ConflictPaths) | |
| 48 | + | ||
| 49 | + | got = domain.MergeTreeResult{ConflictPaths: paths[:3:10]}.Bounded() | |
| 50 | + | assert.Equal(t, 3, cap(got.ConflictPaths)) | |
| 51 | + | assert.False(t, got.Truncated) | |
| 52 | + | assert.Nil(t, domain.MergeTreeResult{}.Bounded().ConflictPaths) | |
| 53 | + | } | |
M internal/ports/ci.go +3 -0
| @@ -93,6 +93,9 @@ type CIRefTipStore interface { | |||
| 93 | 93 | GetRefTip(ctx context.Context, repo domain.RepoName, ref string) (domain.CIRefTip, error) | |
| 94 | 94 | // DeleteRefTip removes the record of a ref; a missing one is not an error. | |
| 95 | 95 | DeleteRefTip(ctx context.Context, repo domain.RepoName, ref string) error | |
| 96 | + | // DeleteRefTipIf removes the record of a ref only when it still names | |
| 97 | + | // sha; a missing or different one is left alone and is not an error. | |
| 98 | + | DeleteRefTipIf(ctx context.Context, repo domain.RepoName, ref, sha string) error | |
| 96 | 99 | } | |
| 97 | 100 | ||
| 98 | 101 | // CIWorkflowStore keeps, per repository, the workflow files that are disabled | |
M internal/ports/dto.go +8 -2
| @@ -198,6 +198,9 @@ type MergeRequestPage struct { | |||
| 198 | 198 | MergeRequests []domain.MergeRequest | |
| 199 | 199 | // NextPageToken is empty on the last page. | |
| 200 | 200 | NextPageToken string | |
| 201 | + | // Mergeability holds, by number, the mergeability of the open requests of | |
| 202 | + | // the page (nil when the server does not compute it). | |
| 203 | + | Mergeability map[int64]domain.Mergeability | |
| 201 | 204 | } | |
| 202 | 205 | ||
| 203 | 206 | // ListIssuesParams selects a page of issues from an IssueStore. | |
| @@ -262,6 +265,8 @@ type MergeRequestDetail struct { | |||
| 262 | 265 | Comments []domain.Comment | |
| 263 | 266 | // Verdicts are the latest verdict per author in note ID order. | |
| 264 | 267 | Verdicts []domain.AuthorVerdict | |
| 268 | + | // Mergeability is zero unless the request is open. | |
| 269 | + | Mergeability domain.Mergeability | |
| 265 | 270 | // LastReadID is the head recorded by MarkRead; zero when none. | |
| 266 | 271 | LastReadID domain.ObjectID | |
| 267 | 272 | // LastReadLost tells that since-last-read was asked for, a head was | |
| @@ -319,8 +324,9 @@ type MergeParams struct { | |||
| 319 | 324 | // MergeResult is a merged request and what happened to its source branch. | |
| 320 | 325 | type MergeResult struct { | |
| 321 | 326 | domain.MergeRequest | |
| 322 | - | // SourceDeleted is true when DeleteSource was asked and the branch is | |
| 323 | - | // gone. | |
| 327 | + | // SourceDeleted is true when DeleteSource was asked: the deletion is | |
| 328 | + | // requested, and done asynchronously after the merge returned (it is | |
| 329 | + | // skipped if the branch moved meanwhile). | |
| 324 | 330 | SourceDeleted bool | |
| 325 | 331 | } | |
| 326 | 332 | ||
M internal/ports/mocks/CIRefTipStore.go +69 -0
| @@ -110,6 +110,75 @@ func (_c *CIRefTipStore_DeleteRefTip_Call) RunAndReturn(run func(ctx context.Con | |||
| 110 | 110 | return _c | |
| 111 | 111 | } | |
| 112 | 112 | ||
| 113 | + | // DeleteRefTipIf provides a mock function for the type CIRefTipStore | |
| 114 | + | func (_mock *CIRefTipStore) DeleteRefTipIf(ctx context.Context, repo domain.RepoName, ref string, sha string) error { | |
| 115 | + | ret := _mock.Called(ctx, repo, ref, sha) | |
| 116 | + | ||
| 117 | + | if len(ret) == 0 { | |
| 118 | + | panic("no return value specified for DeleteRefTipIf") | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | var r0 error | |
| 122 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, string, string) error); ok { | |
| 123 | + | r0 = returnFunc(ctx, repo, ref, sha) | |
| 124 | + | } else { | |
| 125 | + | r0 = ret.Error(0) | |
| 126 | + | } | |
| 127 | + | return r0 | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | // CIRefTipStore_DeleteRefTipIf_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteRefTipIf' | |
| 131 | + | type CIRefTipStore_DeleteRefTipIf_Call struct { | |
| 132 | + | *mock.Call | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | // DeleteRefTipIf is a helper method to define mock.On call | |
| 136 | + | // - ctx context.Context | |
| 137 | + | // - repo domain.RepoName | |
| 138 | + | // - ref string | |
| 139 | + | // - sha string | |
| 140 | + | func (_e *CIRefTipStore_Expecter) DeleteRefTipIf(ctx any, repo any, ref any, sha any) *CIRefTipStore_DeleteRefTipIf_Call { | |
| 141 | + | return &CIRefTipStore_DeleteRefTipIf_Call{Call: _e.mock.On("DeleteRefTipIf", ctx, repo, ref, sha)} | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | func (_c *CIRefTipStore_DeleteRefTipIf_Call) Run(run func(ctx context.Context, repo domain.RepoName, ref string, sha string)) *CIRefTipStore_DeleteRefTipIf_Call { | |
| 145 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 146 | + | var arg0 context.Context | |
| 147 | + | if args[0] != nil { | |
| 148 | + | arg0 = args[0].(context.Context) | |
| 149 | + | } | |
| 150 | + | var arg1 domain.RepoName | |
| 151 | + | if args[1] != nil { | |
| 152 | + | arg1 = args[1].(domain.RepoName) | |
| 153 | + | } | |
| 154 | + | var arg2 string | |
| 155 | + | if args[2] != nil { | |
| 156 | + | arg2 = args[2].(string) | |
| 157 | + | } | |
| 158 | + | var arg3 string | |
| 159 | + | if args[3] != nil { | |
| 160 | + | arg3 = args[3].(string) | |
| 161 | + | } | |
| 162 | + | run( | |
| 163 | + | arg0, | |
| 164 | + | arg1, | |
| 165 | + | arg2, | |
| 166 | + | arg3, | |
| 167 | + | ) | |
| 168 | + | }) | |
| 169 | + | return _c | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | func (_c *CIRefTipStore_DeleteRefTipIf_Call) Return(err error) *CIRefTipStore_DeleteRefTipIf_Call { | |
| 173 | + | _c.Call.Return(err) | |
| 174 | + | return _c | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | func (_c *CIRefTipStore_DeleteRefTipIf_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, ref string, sha string) error) *CIRefTipStore_DeleteRefTipIf_Call { | |
| 178 | + | _c.Call.Return(run) | |
| 179 | + | return _c | |
| 180 | + | } | |
| 181 | + | ||
| 113 | 182 | // GetRefTip provides a mock function for the type CIRefTipStore | |
| 114 | 183 | func (_mock *CIRefTipStore) GetRefTip(ctx context.Context, repo domain.RepoName, ref string) (domain.CIRefTip, error) { | |
| 115 | 184 | ret := _mock.Called(ctx, repo, ref) | |
M internal/ports/mocks/MergeGit.go +162 -0
| @@ -191,6 +191,90 @@ func (_c *MergeGit_CommitTree_Call) RunAndReturn(run func(ctx context.Context, r | |||
| 191 | 191 | return _c | |
| 192 | 192 | } | |
| 193 | 193 | ||
| 194 | + | // CountBehind provides a mock function for the type MergeGit | |
| 195 | + | func (_mock *MergeGit) CountBehind(ctx context.Context, repo domain.RepoName, head domain.ObjectID, target domain.ObjectID, limit int) (int, error) { | |
| 196 | + | ret := _mock.Called(ctx, repo, head, target, limit) | |
| 197 | + | ||
| 198 | + | if len(ret) == 0 { | |
| 199 | + | panic("no return value specified for CountBehind") | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | var r0 int | |
| 203 | + | var r1 error | |
| 204 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, domain.ObjectID, domain.ObjectID, int) (int, error)); ok { | |
| 205 | + | return returnFunc(ctx, repo, head, target, limit) | |
| 206 | + | } | |
| 207 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, domain.ObjectID, domain.ObjectID, int) int); ok { | |
| 208 | + | r0 = returnFunc(ctx, repo, head, target, limit) | |
| 209 | + | } else { | |
| 210 | + | r0 = ret.Get(0).(int) | |
| 211 | + | } | |
| 212 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, domain.ObjectID, domain.ObjectID, int) error); ok { | |
| 213 | + | r1 = returnFunc(ctx, repo, head, target, limit) | |
| 214 | + | } else { | |
| 215 | + | r1 = ret.Error(1) | |
| 216 | + | } | |
| 217 | + | return r0, r1 | |
| 218 | + | } | |
| 219 | + | ||
| 220 | + | // MergeGit_CountBehind_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CountBehind' | |
| 221 | + | type MergeGit_CountBehind_Call struct { | |
| 222 | + | *mock.Call | |
| 223 | + | } | |
| 224 | + | ||
| 225 | + | // CountBehind is a helper method to define mock.On call | |
| 226 | + | // - ctx context.Context | |
| 227 | + | // - repo domain.RepoName | |
| 228 | + | // - head domain.ObjectID | |
| 229 | + | // - target domain.ObjectID | |
| 230 | + | // - limit int | |
| 231 | + | func (_e *MergeGit_Expecter) CountBehind(ctx any, repo any, head any, target any, limit any) *MergeGit_CountBehind_Call { | |
| 232 | + | return &MergeGit_CountBehind_Call{Call: _e.mock.On("CountBehind", ctx, repo, head, target, limit)} | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | func (_c *MergeGit_CountBehind_Call) Run(run func(ctx context.Context, repo domain.RepoName, head domain.ObjectID, target domain.ObjectID, limit int)) *MergeGit_CountBehind_Call { | |
| 236 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 237 | + | var arg0 context.Context | |
| 238 | + | if args[0] != nil { | |
| 239 | + | arg0 = args[0].(context.Context) | |
| 240 | + | } | |
| 241 | + | var arg1 domain.RepoName | |
| 242 | + | if args[1] != nil { | |
| 243 | + | arg1 = args[1].(domain.RepoName) | |
| 244 | + | } | |
| 245 | + | var arg2 domain.ObjectID | |
| 246 | + | if args[2] != nil { | |
| 247 | + | arg2 = args[2].(domain.ObjectID) | |
| 248 | + | } | |
| 249 | + | var arg3 domain.ObjectID | |
| 250 | + | if args[3] != nil { | |
| 251 | + | arg3 = args[3].(domain.ObjectID) | |
| 252 | + | } | |
| 253 | + | var arg4 int | |
| 254 | + | if args[4] != nil { | |
| 255 | + | arg4 = args[4].(int) | |
| 256 | + | } | |
| 257 | + | run( | |
| 258 | + | arg0, | |
| 259 | + | arg1, | |
| 260 | + | arg2, | |
| 261 | + | arg3, | |
| 262 | + | arg4, | |
| 263 | + | ) | |
| 264 | + | }) | |
| 265 | + | return _c | |
| 266 | + | } | |
| 267 | + | ||
| 268 | + | func (_c *MergeGit_CountBehind_Call) Return(n int, err error) *MergeGit_CountBehind_Call { | |
| 269 | + | _c.Call.Return(n, err) | |
| 270 | + | return _c | |
| 271 | + | } | |
| 272 | + | ||
| 273 | + | func (_c *MergeGit_CountBehind_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, head domain.ObjectID, target domain.ObjectID, limit int) (int, error)) *MergeGit_CountBehind_Call { | |
| 274 | + | _c.Call.Return(run) | |
| 275 | + | return _c | |
| 276 | + | } | |
| 277 | + | ||
| 194 | 278 | // DeleteBranch provides a mock function for the type MergeGit | |
| 195 | 279 | func (_mock *MergeGit) DeleteBranch(ctx context.Context, repo domain.RepoName, branch string, old domain.ObjectID) error { | |
| 196 | 280 | ret := _mock.Called(ctx, repo, branch, old) | |
| @@ -670,6 +754,84 @@ func (_c *MergeGit_MergeBase_Call) RunAndReturn(run func(ctx context.Context, re | |||
| 670 | 754 | return _c | |
| 671 | 755 | } | |
| 672 | 756 | ||
| 757 | + | // MergeTree provides a mock function for the type MergeGit | |
| 758 | + | func (_mock *MergeGit) MergeTree(ctx context.Context, repo domain.RepoName, base domain.ObjectID, head domain.ObjectID) (domain.MergeTreeResult, error) { | |
| 759 | + | ret := _mock.Called(ctx, repo, base, head) | |
| 760 | + | ||
| 761 | + | if len(ret) == 0 { | |
| 762 | + | panic("no return value specified for MergeTree") | |
| 763 | + | } | |
| 764 | + | ||
| 765 | + | var r0 domain.MergeTreeResult | |
| 766 | + | var r1 error | |
| 767 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, domain.ObjectID, domain.ObjectID) (domain.MergeTreeResult, error)); ok { | |
| 768 | + | return returnFunc(ctx, repo, base, head) | |
| 769 | + | } | |
| 770 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, domain.ObjectID, domain.ObjectID) domain.MergeTreeResult); ok { | |
| 771 | + | r0 = returnFunc(ctx, repo, base, head) | |
| 772 | + | } else { | |
| 773 | + | r0 = ret.Get(0).(domain.MergeTreeResult) | |
| 774 | + | } | |
| 775 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, domain.ObjectID, domain.ObjectID) error); ok { | |
| 776 | + | r1 = returnFunc(ctx, repo, base, head) | |
| 777 | + | } else { | |
| 778 | + | r1 = ret.Error(1) | |
| 779 | + | } | |
| 780 | + | return r0, r1 | |
| 781 | + | } | |
| 782 | + | ||
| 783 | + | // MergeGit_MergeTree_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'MergeTree' | |
| 784 | + | type MergeGit_MergeTree_Call struct { | |
| 785 | + | *mock.Call | |
| 786 | + | } | |
| 787 | + | ||
| 788 | + | // MergeTree is a helper method to define mock.On call | |
| 789 | + | // - ctx context.Context | |
| 790 | + | // - repo domain.RepoName | |
| 791 | + | // - base domain.ObjectID | |
| 792 | + | // - head domain.ObjectID | |
| 793 | + | func (_e *MergeGit_Expecter) MergeTree(ctx any, repo any, base any, head any) *MergeGit_MergeTree_Call { | |
| 794 | + | return &MergeGit_MergeTree_Call{Call: _e.mock.On("MergeTree", ctx, repo, base, head)} | |
| 795 | + | } | |
| 796 | + | ||
| 797 | + | func (_c *MergeGit_MergeTree_Call) Run(run func(ctx context.Context, repo domain.RepoName, base domain.ObjectID, head domain.ObjectID)) *MergeGit_MergeTree_Call { | |
| 798 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 799 | + | var arg0 context.Context | |
| 800 | + | if args[0] != nil { | |
| 801 | + | arg0 = args[0].(context.Context) | |
| 802 | + | } | |
| 803 | + | var arg1 domain.RepoName | |
| 804 | + | if args[1] != nil { | |
| 805 | + | arg1 = args[1].(domain.RepoName) | |
| 806 | + | } | |
| 807 | + | var arg2 domain.ObjectID | |
| 808 | + | if args[2] != nil { | |
| 809 | + | arg2 = args[2].(domain.ObjectID) | |
| 810 | + | } | |
| 811 | + | var arg3 domain.ObjectID | |
| 812 | + | if args[3] != nil { | |
| 813 | + | arg3 = args[3].(domain.ObjectID) | |
| 814 | + | } | |
| 815 | + | run( | |
| 816 | + | arg0, | |
| 817 | + | arg1, | |
| 818 | + | arg2, | |
| 819 | + | arg3, | |
| 820 | + | ) | |
| 821 | + | }) | |
| 822 | + | return _c | |
| 823 | + | } | |
| 824 | + | ||
| 825 | + | func (_c *MergeGit_MergeTree_Call) Return(mergeTreeResult domain.MergeTreeResult, err error) *MergeGit_MergeTree_Call { | |
| 826 | + | _c.Call.Return(mergeTreeResult, err) | |
| 827 | + | return _c | |
| 828 | + | } | |
| 829 | + | ||
| 830 | + | func (_c *MergeGit_MergeTree_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, base domain.ObjectID, head domain.ObjectID) (domain.MergeTreeResult, error)) *MergeGit_MergeTree_Call { | |
| 831 | + | _c.Call.Return(run) | |
| 832 | + | return _c | |
| 833 | + | } | |
| 834 | + | ||
| 673 | 835 | // UpdateBranch provides a mock function for the type MergeGit | |
| 674 | 836 | func (_mock *MergeGit) UpdateBranch(ctx context.Context, repo domain.RepoName, branch string, old domain.ObjectID, new domain.ObjectID) error { | |
| 675 | 837 | ret := _mock.Called(ctx, repo, branch, old, new) | |
M internal/ports/mocks/MergeRequestService.go +78 -0
| @@ -942,6 +942,84 @@ func (_c *MergeRequestService_Merge_Call) RunAndReturn(run func(ctx context.Cont | |||
| 942 | 942 | return _c | |
| 943 | 943 | } | |
| 944 | 944 | ||
| 945 | + | // Mergeability provides a mock function for the type MergeRequestService | |
| 946 | + | func (_mock *MergeRequestService) Mergeability(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.Mergeability, error) { | |
| 947 | + | ret := _mock.Called(ctx, p, name, number) | |
| 948 | + | ||
| 949 | + | if len(ret) == 0 { | |
| 950 | + | panic("no return value specified for Mergeability") | |
| 951 | + | } | |
| 952 | + | ||
| 953 | + | var r0 domain.Mergeability | |
| 954 | + | var r1 error | |
| 955 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64) (domain.Mergeability, error)); ok { | |
| 956 | + | return returnFunc(ctx, p, name, number) | |
| 957 | + | } | |
| 958 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64) domain.Mergeability); ok { | |
| 959 | + | r0 = returnFunc(ctx, p, name, number) | |
| 960 | + | } else { | |
| 961 | + | r0 = ret.Get(0).(domain.Mergeability) | |
| 962 | + | } | |
| 963 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, int64) error); ok { | |
| 964 | + | r1 = returnFunc(ctx, p, name, number) | |
| 965 | + | } else { | |
| 966 | + | r1 = ret.Error(1) | |
| 967 | + | } | |
| 968 | + | return r0, r1 | |
| 969 | + | } | |
| 970 | + | ||
| 971 | + | // MergeRequestService_Mergeability_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Mergeability' | |
| 972 | + | type MergeRequestService_Mergeability_Call struct { | |
| 973 | + | *mock.Call | |
| 974 | + | } | |
| 975 | + | ||
| 976 | + | // Mergeability is a helper method to define mock.On call | |
| 977 | + | // - ctx context.Context | |
| 978 | + | // - p domain.Principal | |
| 979 | + | // - name domain.RepoName | |
| 980 | + | // - number int64 | |
| 981 | + | func (_e *MergeRequestService_Expecter) Mergeability(ctx any, p any, name any, number any) *MergeRequestService_Mergeability_Call { | |
| 982 | + | return &MergeRequestService_Mergeability_Call{Call: _e.mock.On("Mergeability", ctx, p, name, number)} | |
| 983 | + | } | |
| 984 | + | ||
| 985 | + | func (_c *MergeRequestService_Mergeability_Call) Run(run func(ctx context.Context, p domain.Principal, name domain.RepoName, number int64)) *MergeRequestService_Mergeability_Call { | |
| 986 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 987 | + | var arg0 context.Context | |
| 988 | + | if args[0] != nil { | |
| 989 | + | arg0 = args[0].(context.Context) | |
| 990 | + | } | |
| 991 | + | var arg1 domain.Principal | |
| 992 | + | if args[1] != nil { | |
| 993 | + | arg1 = args[1].(domain.Principal) | |
| 994 | + | } | |
| 995 | + | var arg2 domain.RepoName | |
| 996 | + | if args[2] != nil { | |
| 997 | + | arg2 = args[2].(domain.RepoName) | |
| 998 | + | } | |
| 999 | + | var arg3 int64 | |
| 1000 | + | if args[3] != nil { | |
| 1001 | + | arg3 = args[3].(int64) | |
| 1002 | + | } | |
| 1003 | + | run( | |
| 1004 | + | arg0, | |
| 1005 | + | arg1, | |
| 1006 | + | arg2, | |
| 1007 | + | arg3, | |
| 1008 | + | ) | |
| 1009 | + | }) | |
| 1010 | + | return _c | |
| 1011 | + | } | |
| 1012 | + | ||
| 1013 | + | func (_c *MergeRequestService_Mergeability_Call) Return(mergeability domain.Mergeability, err error) *MergeRequestService_Mergeability_Call { | |
| 1014 | + | _c.Call.Return(mergeability, err) | |
| 1015 | + | return _c | |
| 1016 | + | } | |
| 1017 | + | ||
| 1018 | + | func (_c *MergeRequestService_Mergeability_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.Mergeability, error)) *MergeRequestService_Mergeability_Call { | |
| 1019 | + | _c.Call.Return(run) | |
| 1020 | + | return _c | |
| 1021 | + | } | |
| 1022 | + | ||
| 945 | 1023 | // ResolveComment provides a mock function for the type MergeRequestService | |
| 946 | 1024 | func (_mock *MergeRequestService) ResolveComment(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, commentID int64, resolved bool) (domain.Comment, error) { | |
| 947 | 1025 | ret := _mock.Called(ctx, p, name, number, commentID, resolved) | |
M internal/ports/mocks/MergeRequestStore.go +192 -0
| @@ -179,6 +179,69 @@ func (_c *MergeRequestStore_Create_Call) RunAndReturn(run func(ctx context.Conte | |||
| 179 | 179 | return _c | |
| 180 | 180 | } | |
| 181 | 181 | ||
| 182 | + | // DeleteMergeability provides a mock function for the type MergeRequestStore | |
| 183 | + | func (_mock *MergeRequestStore) DeleteMergeability(ctx context.Context, repo domain.RepoName, number int64) error { | |
| 184 | + | ret := _mock.Called(ctx, repo, number) | |
| 185 | + | ||
| 186 | + | if len(ret) == 0 { | |
| 187 | + | panic("no return value specified for DeleteMergeability") | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | var r0 error | |
| 191 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, int64) error); ok { | |
| 192 | + | r0 = returnFunc(ctx, repo, number) | |
| 193 | + | } else { | |
| 194 | + | r0 = ret.Error(0) | |
| 195 | + | } | |
| 196 | + | return r0 | |
| 197 | + | } | |
| 198 | + | ||
| 199 | + | // MergeRequestStore_DeleteMergeability_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteMergeability' | |
| 200 | + | type MergeRequestStore_DeleteMergeability_Call struct { | |
| 201 | + | *mock.Call | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | // DeleteMergeability is a helper method to define mock.On call | |
| 205 | + | // - ctx context.Context | |
| 206 | + | // - repo domain.RepoName | |
| 207 | + | // - number int64 | |
| 208 | + | func (_e *MergeRequestStore_Expecter) DeleteMergeability(ctx any, repo any, number any) *MergeRequestStore_DeleteMergeability_Call { | |
| 209 | + | return &MergeRequestStore_DeleteMergeability_Call{Call: _e.mock.On("DeleteMergeability", ctx, repo, number)} | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | func (_c *MergeRequestStore_DeleteMergeability_Call) Run(run func(ctx context.Context, repo domain.RepoName, number int64)) *MergeRequestStore_DeleteMergeability_Call { | |
| 213 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 214 | + | var arg0 context.Context | |
| 215 | + | if args[0] != nil { | |
| 216 | + | arg0 = args[0].(context.Context) | |
| 217 | + | } | |
| 218 | + | var arg1 domain.RepoName | |
| 219 | + | if args[1] != nil { | |
| 220 | + | arg1 = args[1].(domain.RepoName) | |
| 221 | + | } | |
| 222 | + | var arg2 int64 | |
| 223 | + | if args[2] != nil { | |
| 224 | + | arg2 = args[2].(int64) | |
| 225 | + | } | |
| 226 | + | run( | |
| 227 | + | arg0, | |
| 228 | + | arg1, | |
| 229 | + | arg2, | |
| 230 | + | ) | |
| 231 | + | }) | |
| 232 | + | return _c | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | func (_c *MergeRequestStore_DeleteMergeability_Call) Return(err error) *MergeRequestStore_DeleteMergeability_Call { | |
| 236 | + | _c.Call.Return(err) | |
| 237 | + | return _c | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | func (_c *MergeRequestStore_DeleteMergeability_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, number int64) error) *MergeRequestStore_DeleteMergeability_Call { | |
| 241 | + | _c.Call.Return(run) | |
| 242 | + | return _c | |
| 243 | + | } | |
| 244 | + | ||
| 182 | 245 | // Get provides a mock function for the type MergeRequestStore | |
| 183 | 246 | func (_mock *MergeRequestStore) Get(ctx context.Context, repo domain.RepoName, number int64) (domain.MergeRequest, error) { | |
| 184 | 247 | ret := _mock.Called(ctx, repo, number) | |
| @@ -329,6 +392,78 @@ func (_c *MergeRequestStore_GetComment_Call) RunAndReturn(run func(ctx context.C | |||
| 329 | 392 | return _c | |
| 330 | 393 | } | |
| 331 | 394 | ||
| 395 | + | // GetMergeability provides a mock function for the type MergeRequestStore | |
| 396 | + | func (_mock *MergeRequestStore) GetMergeability(ctx context.Context, repo domain.RepoName, number int64) (domain.Mergeability, error) { | |
| 397 | + | ret := _mock.Called(ctx, repo, number) | |
| 398 | + | ||
| 399 | + | if len(ret) == 0 { | |
| 400 | + | panic("no return value specified for GetMergeability") | |
| 401 | + | } | |
| 402 | + | ||
| 403 | + | var r0 domain.Mergeability | |
| 404 | + | var r1 error | |
| 405 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, int64) (domain.Mergeability, error)); ok { | |
| 406 | + | return returnFunc(ctx, repo, number) | |
| 407 | + | } | |
| 408 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, int64) domain.Mergeability); ok { | |
| 409 | + | r0 = returnFunc(ctx, repo, number) | |
| 410 | + | } else { | |
| 411 | + | r0 = ret.Get(0).(domain.Mergeability) | |
| 412 | + | } | |
| 413 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, int64) error); ok { | |
| 414 | + | r1 = returnFunc(ctx, repo, number) | |
| 415 | + | } else { | |
| 416 | + | r1 = ret.Error(1) | |
| 417 | + | } | |
| 418 | + | return r0, r1 | |
| 419 | + | } | |
| 420 | + | ||
| 421 | + | // MergeRequestStore_GetMergeability_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetMergeability' | |
| 422 | + | type MergeRequestStore_GetMergeability_Call struct { | |
| 423 | + | *mock.Call | |
| 424 | + | } | |
| 425 | + | ||
| 426 | + | // GetMergeability is a helper method to define mock.On call | |
| 427 | + | // - ctx context.Context | |
| 428 | + | // - repo domain.RepoName | |
| 429 | + | // - number int64 | |
| 430 | + | func (_e *MergeRequestStore_Expecter) GetMergeability(ctx any, repo any, number any) *MergeRequestStore_GetMergeability_Call { | |
| 431 | + | return &MergeRequestStore_GetMergeability_Call{Call: _e.mock.On("GetMergeability", ctx, repo, number)} | |
| 432 | + | } | |
| 433 | + | ||
| 434 | + | func (_c *MergeRequestStore_GetMergeability_Call) Run(run func(ctx context.Context, repo domain.RepoName, number int64)) *MergeRequestStore_GetMergeability_Call { | |
| 435 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 436 | + | var arg0 context.Context | |
| 437 | + | if args[0] != nil { | |
| 438 | + | arg0 = args[0].(context.Context) | |
| 439 | + | } | |
| 440 | + | var arg1 domain.RepoName | |
| 441 | + | if args[1] != nil { | |
| 442 | + | arg1 = args[1].(domain.RepoName) | |
| 443 | + | } | |
| 444 | + | var arg2 int64 | |
| 445 | + | if args[2] != nil { | |
| 446 | + | arg2 = args[2].(int64) | |
| 447 | + | } | |
| 448 | + | run( | |
| 449 | + | arg0, | |
| 450 | + | arg1, | |
| 451 | + | arg2, | |
| 452 | + | ) | |
| 453 | + | }) | |
| 454 | + | return _c | |
| 455 | + | } | |
| 456 | + | ||
| 457 | + | func (_c *MergeRequestStore_GetMergeability_Call) Return(mergeability domain.Mergeability, err error) *MergeRequestStore_GetMergeability_Call { | |
| 458 | + | _c.Call.Return(mergeability, err) | |
| 459 | + | return _c | |
| 460 | + | } | |
| 461 | + | ||
| 462 | + | func (_c *MergeRequestStore_GetMergeability_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, number int64) (domain.Mergeability, error)) *MergeRequestStore_GetMergeability_Call { | |
| 463 | + | _c.Call.Return(run) | |
| 464 | + | return _c | |
| 465 | + | } | |
| 466 | + | ||
| 332 | 467 | // GetRead provides a mock function for the type MergeRequestStore | |
| 333 | 468 | func (_mock *MergeRequestStore) GetRead(ctx context.Context, repo domain.RepoName, number int64) (domain.ObjectID, error) { | |
| 334 | 469 | ret := _mock.Called(ctx, repo, number) | |
| @@ -845,6 +980,63 @@ func (_c *MergeRequestStore_OpenByTarget_Call) RunAndReturn(run func(ctx context | |||
| 845 | 980 | return _c | |
| 846 | 981 | } | |
| 847 | 982 | ||
| 983 | + | // PutMergeability provides a mock function for the type MergeRequestStore | |
| 984 | + | func (_mock *MergeRequestStore) PutMergeability(ctx context.Context, m domain.Mergeability) error { | |
| 985 | + | ret := _mock.Called(ctx, m) | |
| 986 | + | ||
| 987 | + | if len(ret) == 0 { | |
| 988 | + | panic("no return value specified for PutMergeability") | |
| 989 | + | } | |
| 990 | + | ||
| 991 | + | var r0 error | |
| 992 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Mergeability) error); ok { | |
| 993 | + | r0 = returnFunc(ctx, m) | |
| 994 | + | } else { | |
| 995 | + | r0 = ret.Error(0) | |
| 996 | + | } | |
| 997 | + | return r0 | |
| 998 | + | } | |
| 999 | + | ||
| 1000 | + | // MergeRequestStore_PutMergeability_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PutMergeability' | |
| 1001 | + | type MergeRequestStore_PutMergeability_Call struct { | |
| 1002 | + | *mock.Call | |
| 1003 | + | } | |
| 1004 | + | ||
| 1005 | + | // PutMergeability is a helper method to define mock.On call | |
| 1006 | + | // - ctx context.Context | |
| 1007 | + | // - m domain.Mergeability | |
| 1008 | + | func (_e *MergeRequestStore_Expecter) PutMergeability(ctx any, m any) *MergeRequestStore_PutMergeability_Call { | |
| 1009 | + | return &MergeRequestStore_PutMergeability_Call{Call: _e.mock.On("PutMergeability", ctx, m)} | |
| 1010 | + | } | |
| 1011 | + | ||
| 1012 | + | func (_c *MergeRequestStore_PutMergeability_Call) Run(run func(ctx context.Context, m domain.Mergeability)) *MergeRequestStore_PutMergeability_Call { | |
| 1013 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 1014 | + | var arg0 context.Context | |
| 1015 | + | if args[0] != nil { | |
| 1016 | + | arg0 = args[0].(context.Context) | |
| 1017 | + | } | |
| 1018 | + | var arg1 domain.Mergeability | |
| 1019 | + | if args[1] != nil { | |
| 1020 | + | arg1 = args[1].(domain.Mergeability) | |
| 1021 | + | } | |
| 1022 | + | run( | |
| 1023 | + | arg0, | |
| 1024 | + | arg1, | |
| 1025 | + | ) | |
| 1026 | + | }) | |
| 1027 | + | return _c | |
| 1028 | + | } | |
| 1029 | + | ||
| 1030 | + | func (_c *MergeRequestStore_PutMergeability_Call) Return(err error) *MergeRequestStore_PutMergeability_Call { | |
| 1031 | + | _c.Call.Return(err) | |
| 1032 | + | return _c | |
| 1033 | + | } | |
| 1034 | + | ||
| 1035 | + | func (_c *MergeRequestStore_PutMergeability_Call) RunAndReturn(run func(ctx context.Context, m domain.Mergeability) error) *MergeRequestStore_PutMergeability_Call { | |
| 1036 | + | _c.Call.Return(run) | |
| 1037 | + | return _c | |
| 1038 | + | } | |
| 1039 | + | ||
| 848 | 1040 | // SaveComment provides a mock function for the type MergeRequestStore | |
| 849 | 1041 | func (_mock *MergeRequestStore) SaveComment(ctx context.Context, mr domain.MergeRequest, c domain.Comment) error { | |
| 850 | 1042 | ret := _mock.Called(ctx, mr, c) | |
A internal/ports/mocks/MergeabilityReader.go +111 -0
| @@ -0,0 +1,111 @@ | |||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |
| 2 | + | // github.com/vektra/mockery | |
| 3 | + | // template: testify | |
| 4 | + | ||
| 5 | + | package mocks | |
| 6 | + | ||
| 7 | + | import ( | |
| 8 | + | "context" | |
| 9 | + | ||
| 10 | + | "bnema.dev/ermite/internal/domain" | |
| 11 | + | mock "github.com/stretchr/testify/mock" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // NewMergeabilityReader creates a new instance of MergeabilityReader. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 15 | + | // The first argument is typically a *testing.T value. | |
| 16 | + | func NewMergeabilityReader(t interface { | |
| 17 | + | mock.TestingT | |
| 18 | + | Cleanup(func()) | |
| 19 | + | }) *MergeabilityReader { | |
| 20 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 21 | + | helper.Helper() | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | mock := &MergeabilityReader{} | |
| 25 | + | mock.Mock.Test(t) | |
| 26 | + | ||
| 27 | + | t.Cleanup(func() { | |
| 28 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 29 | + | helper.Helper() | |
| 30 | + | } | |
| 31 | + | mock.AssertExpectations(t) | |
| 32 | + | }) | |
| 33 | + | ||
| 34 | + | return mock | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | // MergeabilityReader is an autogenerated mock type for the MergeabilityReader type | |
| 38 | + | type MergeabilityReader struct { | |
| 39 | + | mock.Mock | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | type MergeabilityReader_Expecter struct { | |
| 43 | + | mock *mock.Mock | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | func (_m *MergeabilityReader) EXPECT() *MergeabilityReader_Expecter { | |
| 47 | + | return &MergeabilityReader_Expecter{mock: &_m.Mock} | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | // Read provides a mock function for the type MergeabilityReader | |
| 51 | + | func (_mock *MergeabilityReader) Read(ctx context.Context, mr domain.MergeRequest, targetTip domain.ObjectID) domain.Mergeability { | |
| 52 | + | ret := _mock.Called(ctx, mr, targetTip) | |
| 53 | + | ||
| 54 | + | if len(ret) == 0 { | |
| 55 | + | panic("no return value specified for Read") | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | var r0 domain.Mergeability | |
| 59 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.MergeRequest, domain.ObjectID) domain.Mergeability); ok { | |
| 60 | + | r0 = returnFunc(ctx, mr, targetTip) | |
| 61 | + | } else { | |
| 62 | + | r0 = ret.Get(0).(domain.Mergeability) | |
| 63 | + | } | |
| 64 | + | return r0 | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | // MergeabilityReader_Read_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Read' | |
| 68 | + | type MergeabilityReader_Read_Call struct { | |
| 69 | + | *mock.Call | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | // Read is a helper method to define mock.On call | |
| 73 | + | // - ctx context.Context | |
| 74 | + | // - mr domain.MergeRequest | |
| 75 | + | // - targetTip domain.ObjectID | |
| 76 | + | func (_e *MergeabilityReader_Expecter) Read(ctx any, mr any, targetTip any) *MergeabilityReader_Read_Call { | |
| 77 | + | return &MergeabilityReader_Read_Call{Call: _e.mock.On("Read", ctx, mr, targetTip)} | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | func (_c *MergeabilityReader_Read_Call) Run(run func(ctx context.Context, mr domain.MergeRequest, targetTip domain.ObjectID)) *MergeabilityReader_Read_Call { | |
| 81 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 82 | + | var arg0 context.Context | |
| 83 | + | if args[0] != nil { | |
| 84 | + | arg0 = args[0].(context.Context) | |
| 85 | + | } | |
| 86 | + | var arg1 domain.MergeRequest | |
| 87 | + | if args[1] != nil { | |
| 88 | + | arg1 = args[1].(domain.MergeRequest) | |
| 89 | + | } | |
| 90 | + | var arg2 domain.ObjectID | |
| 91 | + | if args[2] != nil { | |
| 92 | + | arg2 = args[2].(domain.ObjectID) | |
| 93 | + | } | |
| 94 | + | run( | |
| 95 | + | arg0, | |
| 96 | + | arg1, | |
| 97 | + | arg2, | |
| 98 | + | ) | |
| 99 | + | }) | |
| 100 | + | return _c | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | func (_c *MergeabilityReader_Read_Call) Return(mergeability domain.Mergeability) *MergeabilityReader_Read_Call { | |
| 104 | + | _c.Call.Return(mergeability) | |
| 105 | + | return _c | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | func (_c *MergeabilityReader_Read_Call) RunAndReturn(run func(ctx context.Context, mr domain.MergeRequest, targetTip domain.ObjectID) domain.Mergeability) *MergeabilityReader_Read_Call { | |
| 109 | + | _c.Call.Return(run) | |
| 110 | + | return _c | |
| 111 | + | } | |
A internal/ports/mocks/MergeabilityTrigger.go +146 -0
| @@ -0,0 +1,146 @@ | |||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |
| 2 | + | // github.com/vektra/mockery | |
| 3 | + | // template: testify | |
| 4 | + | ||
| 5 | + | package mocks | |
| 6 | + | ||
| 7 | + | import ( | |
| 8 | + | "context" | |
| 9 | + | ||
| 10 | + | "bnema.dev/ermite/internal/domain" | |
| 11 | + | mock "github.com/stretchr/testify/mock" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // NewMergeabilityTrigger creates a new instance of MergeabilityTrigger. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 15 | + | // The first argument is typically a *testing.T value. | |
| 16 | + | func NewMergeabilityTrigger(t interface { | |
| 17 | + | mock.TestingT | |
| 18 | + | Cleanup(func()) | |
| 19 | + | }) *MergeabilityTrigger { | |
| 20 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 21 | + | helper.Helper() | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | mock := &MergeabilityTrigger{} | |
| 25 | + | mock.Mock.Test(t) | |
| 26 | + | ||
| 27 | + | t.Cleanup(func() { | |
| 28 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 29 | + | helper.Helper() | |
| 30 | + | } | |
| 31 | + | mock.AssertExpectations(t) | |
| 32 | + | }) | |
| 33 | + | ||
| 34 | + | return mock | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | // MergeabilityTrigger is an autogenerated mock type for the MergeabilityTrigger type | |
| 38 | + | type MergeabilityTrigger struct { | |
| 39 | + | mock.Mock | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | type MergeabilityTrigger_Expecter struct { | |
| 43 | + | mock *mock.Mock | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | func (_m *MergeabilityTrigger) EXPECT() *MergeabilityTrigger_Expecter { | |
| 47 | + | return &MergeabilityTrigger_Expecter{mock: &_m.Mock} | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | // OnBranchesMoved provides a mock function for the type MergeabilityTrigger | |
| 51 | + | func (_mock *MergeabilityTrigger) OnBranchesMoved(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate) { | |
| 52 | + | _mock.Called(ctx, repo, updates) | |
| 53 | + | return | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | // MergeabilityTrigger_OnBranchesMoved_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OnBranchesMoved' | |
| 57 | + | type MergeabilityTrigger_OnBranchesMoved_Call struct { | |
| 58 | + | *mock.Call | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | // OnBranchesMoved is a helper method to define mock.On call | |
| 62 | + | // - ctx context.Context | |
| 63 | + | // - repo domain.RepoName | |
| 64 | + | // - updates []domain.RefUpdate | |
| 65 | + | func (_e *MergeabilityTrigger_Expecter) OnBranchesMoved(ctx any, repo any, updates any) *MergeabilityTrigger_OnBranchesMoved_Call { | |
| 66 | + | return &MergeabilityTrigger_OnBranchesMoved_Call{Call: _e.mock.On("OnBranchesMoved", ctx, repo, updates)} | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | func (_c *MergeabilityTrigger_OnBranchesMoved_Call) Run(run func(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate)) *MergeabilityTrigger_OnBranchesMoved_Call { | |
| 70 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 71 | + | var arg0 context.Context | |
| 72 | + | if args[0] != nil { | |
| 73 | + | arg0 = args[0].(context.Context) | |
| 74 | + | } | |
| 75 | + | var arg1 domain.RepoName | |
| 76 | + | if args[1] != nil { | |
| 77 | + | arg1 = args[1].(domain.RepoName) | |
| 78 | + | } | |
| 79 | + | var arg2 []domain.RefUpdate | |
| 80 | + | if args[2] != nil { | |
| 81 | + | arg2 = args[2].([]domain.RefUpdate) | |
| 82 | + | } | |
| 83 | + | run( | |
| 84 | + | arg0, | |
| 85 | + | arg1, | |
| 86 | + | arg2, | |
| 87 | + | ) | |
| 88 | + | }) | |
| 89 | + | return _c | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | func (_c *MergeabilityTrigger_OnBranchesMoved_Call) Return() *MergeabilityTrigger_OnBranchesMoved_Call { | |
| 93 | + | _c.Call.Return() | |
| 94 | + | return _c | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | func (_c *MergeabilityTrigger_OnBranchesMoved_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate)) *MergeabilityTrigger_OnBranchesMoved_Call { | |
| 98 | + | _c.Run(run) | |
| 99 | + | return _c | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | // OnOpened provides a mock function for the type MergeabilityTrigger | |
| 103 | + | func (_mock *MergeabilityTrigger) OnOpened(ctx context.Context, mr domain.MergeRequest) { | |
| 104 | + | _mock.Called(ctx, mr) | |
| 105 | + | return | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | // MergeabilityTrigger_OnOpened_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OnOpened' | |
| 109 | + | type MergeabilityTrigger_OnOpened_Call struct { | |
| 110 | + | *mock.Call | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | // OnOpened is a helper method to define mock.On call | |
| 114 | + | // - ctx context.Context | |
| 115 | + | // - mr domain.MergeRequest | |
| 116 | + | func (_e *MergeabilityTrigger_Expecter) OnOpened(ctx any, mr any) *MergeabilityTrigger_OnOpened_Call { | |
| 117 | + | return &MergeabilityTrigger_OnOpened_Call{Call: _e.mock.On("OnOpened", ctx, mr)} | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | func (_c *MergeabilityTrigger_OnOpened_Call) Run(run func(ctx context.Context, mr domain.MergeRequest)) *MergeabilityTrigger_OnOpened_Call { | |
| 121 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 122 | + | var arg0 context.Context | |
| 123 | + | if args[0] != nil { | |
| 124 | + | arg0 = args[0].(context.Context) | |
| 125 | + | } | |
| 126 | + | var arg1 domain.MergeRequest | |
| 127 | + | if args[1] != nil { | |
| 128 | + | arg1 = args[1].(domain.MergeRequest) | |
| 129 | + | } | |
| 130 | + | run( | |
| 131 | + | arg0, | |
| 132 | + | arg1, | |
| 133 | + | ) | |
| 134 | + | }) | |
| 135 | + | return _c | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | func (_c *MergeabilityTrigger_OnOpened_Call) Return() *MergeabilityTrigger_OnOpened_Call { | |
| 139 | + | _c.Call.Return() | |
| 140 | + | return _c | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | func (_c *MergeabilityTrigger_OnOpened_Call) RunAndReturn(run func(ctx context.Context, mr domain.MergeRequest)) *MergeabilityTrigger_OnOpened_Call { | |
| 144 | + | _c.Run(run) | |
| 145 | + | return _c | |
| 146 | + | } | |
M internal/ports/ports.go +37 -0
| @@ -377,6 +377,17 @@ type MergeRequestStore interface { | |||
| 377 | 377 | // domain.ErrConflict when concurrent writes to the repository keep it | |
| 378 | 378 | // from landing. | |
| 379 | 379 | SetRead(ctx context.Context, repo domain.RepoName, number int64, head domain.ObjectID) error | |
| 380 | + | // GetMergeability returns the stored mergeability of an open merge request, | |
| 381 | + | // or domain.ErrNotFound when there is none. | |
| 382 | + | GetMergeability(ctx context.Context, repo domain.RepoName, number int64) (domain.Mergeability, error) | |
| 383 | + | // PutMergeability stores the mergeability. A Version of 0 requires that | |
| 384 | + | // none is stored, otherwise the stored one must be unchanged since it was | |
| 385 | + | // read (domain.ErrConflict). It returns domain.ErrNotFound when the merge | |
| 386 | + | // request is not stored or not open, so that a late write never resurrects | |
| 387 | + | // a record. | |
| 388 | + | PutMergeability(ctx context.Context, m domain.Mergeability) error | |
| 389 | + | // DeleteMergeability removes the stored mergeability; no error when absent. | |
| 390 | + | DeleteMergeability(ctx context.Context, repo domain.RepoName, number int64) error | |
| 380 | 391 | } | |
| 381 | 392 | ||
| 382 | 393 | // MergeGit is what merge requests need from the Git repositories beyond | |
| @@ -412,6 +423,32 @@ type MergeGit interface { | |||
| 412 | 423 | // DeleteBranch deletes refs/heads/<branch> if it still points to old. It | |
| 413 | 424 | // returns domain.ErrConflict when the branch moved or is gone. | |
| 414 | 425 | DeleteBranch(ctx context.Context, repo domain.RepoName, branch string, old domain.ObjectID) error | |
| 426 | + | // MergeTree merges head into base without creating a commit. When the | |
| 427 | + | // merge is not clean (or the commits share no history) Clean is false and | |
| 428 | + | // ConflictPaths lists every conflicted path, unbounded: the caller bounds | |
| 429 | + | // them (MergeTreeResult.Bounded). | |
| 430 | + | MergeTree(ctx context.Context, repo domain.RepoName, base, head domain.ObjectID) (domain.MergeTreeResult, error) | |
| 431 | + | // CountBehind counts the commits of target that head lacks, stopping at | |
| 432 | + | // limit: the raw count, at most limit. The caller interprets it | |
| 433 | + | // (domain.BoundBehind). | |
| 434 | + | CountBehind(ctx context.Context, repo domain.RepoName, head, target domain.ObjectID, limit int) (n int, err error) | |
| 435 | + | } | |
| 436 | + | ||
| 437 | + | // MergeabilityTrigger queues mergeability checks. It only queues the work and | |
| 438 | + | // returns. | |
| 439 | + | type MergeabilityTrigger interface { | |
| 440 | + | // OnBranchesMoved queues the open merge requests whose source or target | |
| 441 | + | // branch was moved or deleted by updates. | |
| 442 | + | OnBranchesMoved(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate) | |
| 443 | + | // OnOpened queues a newly opened merge request. | |
| 444 | + | OnOpened(ctx context.Context, mr domain.MergeRequest) | |
| 445 | + | } | |
| 446 | + | ||
| 447 | + | // MergeabilityReader reads the mergeability of open merge requests. | |
| 448 | + | type MergeabilityReader interface { | |
| 449 | + | // Read returns the stored mergeability for mr and targetTip, or a | |
| 450 | + | // "checking" one (and queues a check) when there is none or it is stale. | |
| 451 | + | Read(ctx context.Context, mr domain.MergeRequest, targetTip domain.ObjectID) domain.Mergeability | |
| 415 | 452 | } | |
| 416 | 453 | ||
| 417 | 454 | // CommitSigner signs the commits ermite creates (ADR 0022). The private key | |
M internal/ports/services.go +4 -0
| @@ -124,6 +124,10 @@ type MergeRequestService interface { | |||
| 124 | 124 | // CountOpen is the number of open merge requests of a repository the | |
| 125 | 125 | // principal can read. It reads no Git: the tab of every page shows it. | |
| 126 | 126 | CountOpen(ctx context.Context, p domain.Principal, name domain.RepoName) (int, error) | |
| 127 | + | // Mergeability is the mergeability of an open merge request, "checking" | |
| 128 | + | // until the server has a result for the current tips; zero for a request | |
| 129 | + | // that is not open. It reads no diff: the live pages call it on each change. | |
| 130 | + | Mergeability(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.Mergeability, error) | |
| 127 | 131 | } | |
| 128 | 132 | ||
| 129 | 133 | // IssueService is the issue use cases. *usecase.Issues implements it. | |
M internal/usecase/ci_unit_test.go +1 -0
| @@ -54,6 +54,7 @@ func newMergeUnit(t *testing.T, block bool) *mergeUnit { | |||
| 54 | 54 | u.git.EXPECT().BranchHead(mock.Anything, u.repo.Name, "feat").Return(m.head, nil) | |
| 55 | 55 | u.git.EXPECT().BranchHead(mock.Anything, u.repo.Name, "main").Return(tip, nil) | |
| 56 | 56 | u.git.EXPECT().IsAncestor(mock.Anything, u.repo.Name, tip, m.head).Return(true, nil) | |
| 57 | + | u.git.EXPECT().IsAncestor(mock.Anything, u.repo.Name, m.head, tip).Return(false, nil) | |
| 57 | 58 | return m | |
| 58 | 59 | } | |
| 59 | 60 | ||
M internal/usecase/events.go +5 -1
| @@ -198,6 +198,8 @@ func (e *Events) snapshot(ctx context.Context, ev domain.Event) bufferedEvent { | |||
| 198 | 198 | return repoSnapshot(ev.Visibility, ev.CreatedAt) | |
| 199 | 199 | case domain.CommentResolved: | |
| 200 | 200 | return repoSnapshot(ev.Visibility, ev.CreatedAt) | |
| 201 | + | case domain.MergeRequestMergeability: | |
| 202 | + | return repoSnapshot(ev.Visibility, ev.CreatedAt) | |
| 201 | 203 | case domain.RunUpdated: | |
| 202 | 204 | return repoSnapshot(ev.Visibility, ev.CreatedAt) | |
| 203 | 205 | case domain.JobUpdated: | |
| @@ -541,6 +543,8 @@ func (e *Events) deliver(ctx context.Context, w *watch, be bufferedEvent, send f | |||
| 541 | 543 | return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send) | |
| 542 | 544 | case domain.CommentResolved: | |
| 543 | 545 | return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send) | |
| 546 | + | case domain.MergeRequestMergeability: | |
| 547 | + | return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send) | |
| 544 | 548 | case domain.RunUpdated: | |
| 545 | 549 | // Home shows the CI state of each repository. | |
| 546 | 550 | return e.deliverRepo(ctx, w, ev.Run.Repo, false, be, out, send) | |
| @@ -645,7 +649,7 @@ func isRepoLifecycleEvent(ev domain.Event) bool { | |||
| 645 | 649 | func isItemEvent(ev domain.Event) bool { | |
| 646 | 650 | switch ev.(type) { | |
| 647 | 651 | case domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, | |
| 648 | - | domain.MergeRequestReady, domain.CommentAdded, | |
| 652 | + | domain.MergeRequestReady, domain.MergeRequestMergeability, domain.CommentAdded, | |
| 649 | 653 | domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved: | |
| 650 | 654 | return true | |
| 651 | 655 | } | |
M internal/usecase/events_test.go +13 -0
| @@ -997,3 +997,16 @@ func TestNoteMutationEventVisibility(t *testing.T) { | |||
| 997 | 997 | } | |
| 998 | 998 | }) | |
| 999 | 999 | } | |
| 1000 | + | ||
| 1001 | + | func TestMergeabilityEventReachesOwnerTopic(t *testing.T) { | |
| 1002 | + | synctest.Test(t, func(t *testing.T) { | |
| 1003 | + | r := newEventRig(t, 0) | |
| 1004 | + | priv := r.repo("priv", domain.VisibilityPrivate) | |
| 1005 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 1006 | + | defer cancel() | |
| 1007 | + | owner := r.watch(ctx, domain.Root(), ports.WatchParams{Topics: []string{"repo:priv"}}) | |
| 1008 | + | synctest.Wait() | |
| 1009 | + | r.feed <- domain.MergeRequestMergeability{Repo: priv, Number: 1, Visibility: domain.VisibilityPrivate, CreatedAt: now, At: now} | |
| 1010 | + | assert.Equal(t, priv, owner.next(t).Event.(domain.MergeRequestMergeability).Repo) | |
| 1011 | + | }) | |
| 1012 | + | } | |
M internal/usecase/export_test.go +46 -0
| @@ -161,3 +161,49 @@ func (s *IssueSubmissions) FillSpent(n int, exp time.Time) { | |||
| 161 | 161 | ||
| 162 | 162 | // MaxSpent is the bound of the spent set. | |
| 163 | 163 | const MaxSpent = maxSpent | |
| 164 | + | ||
| 165 | + | // SetAfterMergeGate makes the post-merge goroutine of m run f before anything | |
| 166 | + | // else, to place a test between a merge and its side effects. | |
| 167 | + | func (m *MergeRequests) SetAfterMergeGate(f func()) { m.afterGate = f } | |
| 168 | + | ||
| 169 | + | // ScheduledRetries gives the number of retries waiting in the scheduler. | |
| 170 | + | func (s *Mergeability) ScheduledRetries() int { | |
| 171 | + | s.retryMu.Lock() | |
| 172 | + | defer s.retryMu.Unlock() | |
| 173 | + | return len(s.heap) | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | // RetryAttempts gives the number of repositories whose | |
| 177 | + | // failed attempts are counted. | |
| 178 | + | func (s *Mergeability) RetryAttempts() int { | |
| 179 | + | s.retryMu.Lock() | |
| 180 | + | defer s.retryMu.Unlock() | |
| 181 | + | return len(s.retries) | |
| 182 | + | } | |
| 183 | + | ||
| 184 | + | // ReconcileForTest runs a repository pass synchronously without workers. | |
| 185 | + | func (s *Mergeability) ReconcileForTest(repo domain.RepoName) bool { return s.reconcile(repo) } | |
| 186 | + | ||
| 187 | + | // RetryForTest schedules a retry of repo as a failed pass does. | |
| 188 | + | func (s *Mergeability) RetryForTest(repo domain.RepoName) { s.retry(repo) } | |
| 189 | + | ||
| 190 | + | // RetryDoneForTest forgets the retries of repo as a successful pass does. | |
| 191 | + | func (s *Mergeability) RetryDoneForTest(repo domain.RepoName) { s.retryDone(repo) } | |
| 192 | + | ||
| 193 | + | // RetryDueForTest gives the deadline of the retry scheduled for repo. | |
| 194 | + | func (s *Mergeability) RetryDueForTest(repo domain.RepoName) (time.Time, bool) { | |
| 195 | + | s.retryMu.Lock() | |
| 196 | + | defer s.retryMu.Unlock() | |
| 197 | + | for _, it := range s.heap { | |
| 198 | + | if it.key == repo { | |
| 199 | + | return it.due, true | |
| 200 | + | } | |
| 201 | + | } | |
| 202 | + | return time.Time{}, false | |
| 203 | + | } | |
| 204 | + | ||
| 205 | + | // PopDueForTest is the scheduler step taken when the timer armed for the | |
| 206 | + | // retry (repo, due) fires. It gives the repositories it released. | |
| 207 | + | func (s *Mergeability) PopDueForTest(repo domain.RepoName, due time.Time) []domain.RepoName { | |
| 208 | + | return s.popDue(retryItem{key: repo, due: due}) | |
| 209 | + | } | |
A internal/usecase/mergeability.go +467 -0
| @@ -0,0 +1,467 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package usecase | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "errors" | |
| 8 | + | "slices" | |
| 9 | + | "strings" | |
| 10 | + | "sync" | |
| 11 | + | "time" | |
| 12 | + | ||
| 13 | + | "bnema.dev/ermite/internal/domain" | |
| 14 | + | "bnema.dev/ermite/internal/ports" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | const ( | |
| 18 | + | defaultMergeabilityWorkers = 2 | |
| 19 | + | mergeabilityRetries = 5 | |
| 20 | + | mergeabilityRetryInitial = time.Second | |
| 21 | + | mergeabilityRetryMax = time.Minute | |
| 22 | + | ) | |
| 23 | + | ||
| 24 | + | // mergeBackoff is the delay before retry number attempt (1-based). | |
| 25 | + | func mergeBackoff(attempt int) time.Duration { | |
| 26 | + | d := mergeabilityRetryInitial | |
| 27 | + | for i := 1; i < attempt; i++ { | |
| 28 | + | d *= 2 | |
| 29 | + | if d >= mergeabilityRetryMax { | |
| 30 | + | return mergeabilityRetryMax | |
| 31 | + | } | |
| 32 | + | } | |
| 33 | + | return d | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | // MergeabilityConfig holds the dependencies of the repository reconciler. | |
| 37 | + | type MergeabilityConfig struct { | |
| 38 | + | Store ports.MergeRequestStore | |
| 39 | + | Meta ports.RepoMetaStore | |
| 40 | + | Git ports.MergeGit | |
| 41 | + | Bus ports.EventBus | |
| 42 | + | Clock ports.Clock | |
| 43 | + | Workers int // defaults to 2 | |
| 44 | + | // After is injectable to drive backoff without wall-clock waits in tests. | |
| 45 | + | After func(time.Duration) <-chan time.Time | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | // Mergeability reconciles derived records, with one writer per repository. | |
| 49 | + | // Its work and retry state are bounded by the number of repositories. | |
| 50 | + | type Mergeability struct { | |
| 51 | + | cfg MergeabilityConfig | |
| 52 | + | ctx context.Context | |
| 53 | + | cancel context.CancelFunc | |
| 54 | + | wg sync.WaitGroup | |
| 55 | + | mu sync.Mutex | |
| 56 | + | dirty map[domain.RepoName]bool | |
| 57 | + | order []domain.RepoName | |
| 58 | + | running, again map[domain.RepoName]bool | |
| 59 | + | wake chan struct{} | |
| 60 | + | retryMu sync.Mutex | |
| 61 | + | retries map[domain.RepoName]int | |
| 62 | + | scheduled map[domain.RepoName]bool | |
| 63 | + | heap []retryItem | |
| 64 | + | retryKick chan struct{} | |
| 65 | + | closed bool | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | // MergeabilityHook is the unchanged trigger and reader contract. | |
| 69 | + | type MergeabilityHook interface { | |
| 70 | + | ports.MergeabilityTrigger | |
| 71 | + | ports.MergeabilityReader | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | var _ MergeabilityHook = (*Mergeability)(nil) | |
| 75 | + | ||
| 76 | + | // NewMergeability builds a reconciler whose lifetime is controlled by Close. | |
| 77 | + | func NewMergeability(ctx context.Context, cfg MergeabilityConfig) *Mergeability { | |
| 78 | + | if cfg.Workers <= 0 { | |
| 79 | + | cfg.Workers = defaultMergeabilityWorkers | |
| 80 | + | } | |
| 81 | + | if cfg.After == nil { | |
| 82 | + | cfg.After = time.After | |
| 83 | + | } | |
| 84 | + | ctx, cancel := context.WithCancel(context.WithoutCancel(ctx)) | |
| 85 | + | return &Mergeability{cfg: cfg, ctx: ctx, cancel: cancel, | |
| 86 | + | dirty: map[domain.RepoName]bool{}, running: map[domain.RepoName]bool{}, again: map[domain.RepoName]bool{}, wake: make(chan struct{}, 1), | |
| 87 | + | retries: map[domain.RepoName]int{}, scheduled: map[domain.RepoName]bool{}, retryKick: make(chan struct{}, 1)} | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | // Start starts workers and discovers repositories, retrying failed discovery. | |
| 91 | + | func (s *Mergeability) Start() { | |
| 92 | + | s.wg.Add(2 + s.cfg.Workers) | |
| 93 | + | go s.scheduler() | |
| 94 | + | go func() { | |
| 95 | + | defer s.wg.Done() | |
| 96 | + | for attempt := 0; attempt <= mergeabilityRetries; attempt++ { | |
| 97 | + | repos, err := s.cfg.Meta.List(s.ctx) | |
| 98 | + | if err != nil && s.ctx.Err() == nil { | |
| 99 | + | ctxLog(s.ctx).Error().Err(err).Int("attempt", attempt+1).Msg("mergeability: list the repositories at startup") | |
| 100 | + | } | |
| 101 | + | if err == nil { | |
| 102 | + | for _, repo := range repos { | |
| 103 | + | s.markDirty(repo.Name) | |
| 104 | + | } | |
| 105 | + | return | |
| 106 | + | } | |
| 107 | + | if attempt == mergeabilityRetries { | |
| 108 | + | return | |
| 109 | + | } | |
| 110 | + | select { | |
| 111 | + | case <-s.ctx.Done(): | |
| 112 | + | return | |
| 113 | + | case <-s.cfg.After(mergeBackoff(attempt + 1)): | |
| 114 | + | } | |
| 115 | + | } | |
| 116 | + | }() | |
| 117 | + | for range s.cfg.Workers { | |
| 118 | + | go s.loop() | |
| 119 | + | } | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | // Close cancels workers and pending retry waits, then waits for them. | |
| 123 | + | func (s *Mergeability) Close(_ context.Context) { | |
| 124 | + | s.retryMu.Lock() | |
| 125 | + | s.closed = true | |
| 126 | + | s.retryMu.Unlock() | |
| 127 | + | s.cancel() | |
| 128 | + | s.wg.Wait() | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | func (s *Mergeability) signal() { | |
| 132 | + | select { | |
| 133 | + | case s.wake <- struct{}{}: | |
| 134 | + | default: | |
| 135 | + | } | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | func (s *Mergeability) markDirty(repo domain.RepoName) { | |
| 139 | + | s.mu.Lock() | |
| 140 | + | if s.running[repo] { | |
| 141 | + | s.again[repo] = true | |
| 142 | + | } else if !s.dirty[repo] { | |
| 143 | + | s.dirty[repo] = true | |
| 144 | + | s.order = append(s.order, repo) | |
| 145 | + | } | |
| 146 | + | s.mu.Unlock() | |
| 147 | + | s.signal() | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | func (s *Mergeability) next() (domain.RepoName, bool) { | |
| 151 | + | s.mu.Lock() | |
| 152 | + | defer s.mu.Unlock() | |
| 153 | + | if len(s.order) == 0 { | |
| 154 | + | return domain.RepoName{}, false | |
| 155 | + | } | |
| 156 | + | repo := s.order[0] | |
| 157 | + | s.order = s.order[1:] | |
| 158 | + | delete(s.dirty, repo) | |
| 159 | + | s.running[repo] = true | |
| 160 | + | if len(s.order) > 0 { | |
| 161 | + | s.signal() | |
| 162 | + | } | |
| 163 | + | return repo, true | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | func (s *Mergeability) loop() { | |
| 167 | + | defer s.wg.Done() | |
| 168 | + | for s.ctx.Err() == nil { | |
| 169 | + | repo, found := s.next() | |
| 170 | + | if !found { | |
| 171 | + | select { | |
| 172 | + | case <-s.ctx.Done(): | |
| 173 | + | return | |
| 174 | + | case <-s.wake: | |
| 175 | + | } | |
| 176 | + | continue | |
| 177 | + | } | |
| 178 | + | if s.reconcile(repo) { | |
| 179 | + | s.retryDone(repo) | |
| 180 | + | } else { | |
| 181 | + | s.retry(repo) | |
| 182 | + | } | |
| 183 | + | s.mu.Lock() | |
| 184 | + | delete(s.running, repo) | |
| 185 | + | if s.again[repo] { | |
| 186 | + | delete(s.again, repo) | |
| 187 | + | s.dirty[repo] = true | |
| 188 | + | s.order = append(s.order, repo) | |
| 189 | + | s.signal() | |
| 190 | + | } | |
| 191 | + | s.mu.Unlock() | |
| 192 | + | } | |
| 193 | + | } | |
| 194 | + | ||
| 195 | + | // OnOpened implements ports.MergeabilityTrigger. | |
| 196 | + | func (s *Mergeability) OnOpened(_ context.Context, mr domain.MergeRequest) { s.markDirty(mr.Repo) } | |
| 197 | + | ||
| 198 | + | // OnBranchesMoved dirties the repository for any branch update. | |
| 199 | + | func (s *Mergeability) OnBranchesMoved(_ context.Context, repo domain.RepoName, updates []domain.RefUpdate) { | |
| 200 | + | for _, update := range updates { | |
| 201 | + | if strings.HasPrefix(update.Ref, "refs/heads/") { | |
| 202 | + | s.markDirty(repo) | |
| 203 | + | return | |
| 204 | + | } | |
| 205 | + | } | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | // Read returns a current record, or checking and requests reconciliation. | |
| 209 | + | func (s *Mergeability) Read(ctx context.Context, mr domain.MergeRequest, targetTip domain.ObjectID) domain.Mergeability { | |
| 210 | + | if !mr.HeadID.IsZero() && !targetTip.IsZero() { | |
| 211 | + | rec, err := s.cfg.Store.GetMergeability(ctx, mr.Repo, mr.Number) | |
| 212 | + | if err == nil && rec.IsFor(targetTip, mr.HeadID) { | |
| 213 | + | return rec | |
| 214 | + | } | |
| 215 | + | } | |
| 216 | + | s.markDirty(mr.Repo) | |
| 217 | + | return domain.Mergeability{Repo: mr.Repo, Number: mr.Number, State: domain.MergeabilityChecking} | |
| 218 | + | } | |
| 219 | + | ||
| 220 | + | // reconcile settles each open request for the tips read in this pass. A push | |
| 221 | + | // during computation requests another pass; old-tip records are harmless. | |
| 222 | + | func (s *Mergeability) reconcile(repo domain.RepoName) bool { | |
| 223 | + | ctx := s.ctx | |
| 224 | + | meta, err := s.cfg.Meta.Get(ctx, repo) | |
| 225 | + | if errors.Is(err, domain.ErrNotFound) { | |
| 226 | + | return true | |
| 227 | + | } | |
| 228 | + | if err != nil { | |
| 229 | + | s.logFailure(ctx, err, "get the repository", repo, 0) | |
| 230 | + | return false | |
| 231 | + | } | |
| 232 | + | mrs, err := s.cfg.Store.ListOpen(ctx, repo) | |
| 233 | + | if err != nil { | |
| 234 | + | s.logFailure(ctx, err, "list the open merge requests", repo, 0) | |
| 235 | + | return false | |
| 236 | + | } | |
| 237 | + | ok := true | |
| 238 | + | for _, mr := range mrs { | |
| 239 | + | if ctx.Err() != nil { | |
| 240 | + | return true | |
| 241 | + | } | |
| 242 | + | head, tip, found, transient := s.tips(ctx, mr) | |
| 243 | + | if !found { | |
| 244 | + | ok = ok && !transient | |
| 245 | + | continue | |
| 246 | + | } | |
| 247 | + | prev, err := s.cfg.Store.GetMergeability(ctx, repo, mr.Number) | |
| 248 | + | if err != nil && !errors.Is(err, domain.ErrNotFound) { | |
| 249 | + | s.logFailure(ctx, err, "get the mergeability", repo, mr.Number) | |
| 250 | + | ok = false | |
| 251 | + | continue | |
| 252 | + | } | |
| 253 | + | if prev.IsFor(tip, head) { | |
| 254 | + | continue | |
| 255 | + | } | |
| 256 | + | res, err := s.compute(ctx, repo, mr.Number, tip, head) | |
| 257 | + | if err != nil { | |
| 258 | + | s.logFailure(ctx, err, "compute the mergeability", repo, mr.Number) | |
| 259 | + | ok = false | |
| 260 | + | continue | |
| 261 | + | } | |
| 262 | + | res.Version = prev.Version | |
| 263 | + | err = s.cfg.Store.PutMergeability(ctx, res) | |
| 264 | + | if errors.Is(err, domain.ErrNotFound) { | |
| 265 | + | continue | |
| 266 | + | } | |
| 267 | + | if err != nil { | |
| 268 | + | if !errors.Is(err, domain.ErrConflict) { // retried by the next pass | |
| 269 | + | s.logFailure(ctx, err, "store the mergeability", repo, mr.Number) | |
| 270 | + | } | |
| 271 | + | ok = false | |
| 272 | + | continue | |
| 273 | + | } | |
| 274 | + | s.cfg.Bus.Publish(ctx, domain.MergeRequestMergeability{ | |
| 275 | + | Repo: repo, Number: mr.Number, Mergeability: res, At: res.CheckedAt, | |
| 276 | + | Visibility: meta.Visibility, CreatedAt: meta.CreatedAt, | |
| 277 | + | }) | |
| 278 | + | } | |
| 279 | + | return ok | |
| 280 | + | } | |
| 281 | + | ||
| 282 | + | // logFailure logs an unexpected failure; cancellation is expected on Close. | |
| 283 | + | func (s *Mergeability) logFailure(ctx context.Context, err error, op string, repo domain.RepoName, number int64) { | |
| 284 | + | if ctx.Err() != nil || errors.Is(err, context.Canceled) || errors.Is(err, domain.ErrNotFound) { | |
| 285 | + | return | |
| 286 | + | } | |
| 287 | + | e := ctxLog(ctx).Error().Err(err).Str("repo", repo.String()) | |
| 288 | + | if number != 0 { | |
| 289 | + | e = e.Int64("number", number) | |
| 290 | + | } | |
| 291 | + | e.Msg("mergeability: " + op) | |
| 292 | + | } | |
| 293 | + | ||
| 294 | + | // retryItem is one scheduled retry. | |
| 295 | + | type retryItem struct { | |
| 296 | + | due time.Time | |
| 297 | + | key domain.RepoName | |
| 298 | + | } | |
| 299 | + | ||
| 300 | + | // retry schedules a retry of key after a bounded exponential backoff. It is | |
| 301 | + | // deduplicated per key, gives up after mergeabilityRetries attempts and stops | |
| 302 | + | // on Close. All retries share one scheduler goroutine. | |
| 303 | + | func (s *Mergeability) retry(key domain.RepoName) { | |
| 304 | + | s.retryMu.Lock() | |
| 305 | + | defer s.retryMu.Unlock() | |
| 306 | + | if s.closed || s.scheduled[key] { | |
| 307 | + | return | |
| 308 | + | } | |
| 309 | + | s.retries[key]++ | |
| 310 | + | n := s.retries[key] | |
| 311 | + | if n > mergeabilityRetries { | |
| 312 | + | delete(s.retries, key) | |
| 313 | + | return | |
| 314 | + | } | |
| 315 | + | s.scheduled[key] = true | |
| 316 | + | it := retryItem{due: s.cfg.Clock.Now().Add(mergeBackoff(n)), key: key} | |
| 317 | + | i, _ := slices.BinarySearchFunc(s.heap, it, func(a, b retryItem) int { | |
| 318 | + | if a.due.After(b.due) { | |
| 319 | + | return 1 | |
| 320 | + | } | |
| 321 | + | return -1 // equal dues keep their insertion order | |
| 322 | + | }) | |
| 323 | + | s.heap = slices.Insert(s.heap, i, it) | |
| 324 | + | if i == 0 { | |
| 325 | + | select { | |
| 326 | + | case s.retryKick <- struct{}{}: | |
| 327 | + | default: | |
| 328 | + | } | |
| 329 | + | } | |
| 330 | + | } | |
| 331 | + | ||
| 332 | + | // scheduler is the only goroutine that waits for retries: it sleeps until the | |
| 333 | + | // earliest is due and puts it back in the normal queue. | |
| 334 | + | func (s *Mergeability) scheduler() { | |
| 335 | + | defer s.wg.Done() | |
| 336 | + | // The timer armed for waited; a kick re-arms it only when the earliest | |
| 337 | + | // retry changed, so a stale kick never starts a second timer. | |
| 338 | + | var ( | |
| 339 | + | after <-chan time.Time | |
| 340 | + | waited retryItem | |
| 341 | + | ) | |
| 342 | + | for { | |
| 343 | + | s.retryMu.Lock() | |
| 344 | + | var head retryItem | |
| 345 | + | has := len(s.heap) > 0 | |
| 346 | + | if has { | |
| 347 | + | head = s.heap[0] | |
| 348 | + | } | |
| 349 | + | s.retryMu.Unlock() | |
| 350 | + | if !has { | |
| 351 | + | after = nil | |
| 352 | + | select { | |
| 353 | + | case <-s.ctx.Done(): | |
| 354 | + | return | |
| 355 | + | case <-s.retryKick: | |
| 356 | + | continue | |
| 357 | + | } | |
| 358 | + | } | |
| 359 | + | if after == nil || head.key != waited.key || !head.due.Equal(waited.due) { | |
| 360 | + | after, waited = s.cfg.After(max(0, head.due.Sub(s.cfg.Clock.Now()))), head | |
| 361 | + | } | |
| 362 | + | select { | |
| 363 | + | case <-s.ctx.Done(): | |
| 364 | + | return | |
| 365 | + | case <-s.retryKick: | |
| 366 | + | continue // an earlier retry may have arrived | |
| 367 | + | case <-after: | |
| 368 | + | after = nil | |
| 369 | + | } | |
| 370 | + | for _, k := range s.popDue(waited) { | |
| 371 | + | if s.ctx.Err() != nil { | |
| 372 | + | return | |
| 373 | + | } | |
| 374 | + | s.markDirty(k) | |
| 375 | + | } | |
| 376 | + | } | |
| 377 | + | } | |
| 378 | + | ||
| 379 | + | // popDue removes the waited item and the ones due as well. The waited item is | |
| 380 | + | // removed regardless of the clock only when both its key and its deadline | |
| 381 | + | // match: a replacement retry of the same repository is kept until it is due. | |
| 382 | + | func (s *Mergeability) popDue(waited retryItem) []domain.RepoName { | |
| 383 | + | s.retryMu.Lock() | |
| 384 | + | defer s.retryMu.Unlock() | |
| 385 | + | now := s.cfg.Clock.Now() | |
| 386 | + | var out []domain.RepoName | |
| 387 | + | s.heap = slices.DeleteFunc(s.heap, func(it retryItem) bool { | |
| 388 | + | if !(it.key == waited.key && it.due.Equal(waited.due)) && it.due.After(now) { | |
| 389 | + | return false | |
| 390 | + | } | |
| 391 | + | out = append(out, it.key) | |
| 392 | + | delete(s.scheduled, it.key) | |
| 393 | + | return true | |
| 394 | + | }) | |
| 395 | + | return out | |
| 396 | + | } | |
| 397 | + | ||
| 398 | + | // retryDone forgets the failures of key after a success. | |
| 399 | + | func (s *Mergeability) retryDone(key domain.RepoName) { | |
| 400 | + | s.retryMu.Lock() | |
| 401 | + | delete(s.retries, key) | |
| 402 | + | delete(s.scheduled, key) | |
| 403 | + | s.heap = slices.DeleteFunc(s.heap, func(it retryItem) bool { return it.key == key }) | |
| 404 | + | select { | |
| 405 | + | case s.retryKick <- struct{}{}: | |
| 406 | + | default: | |
| 407 | + | } | |
| 408 | + | s.retryMu.Unlock() | |
| 409 | + | } | |
| 410 | + | ||
| 411 | + | // tips reads the heads of the source and the target of mr. ok is false when | |
| 412 | + | // the check is over: a branch is gone (the record is deleted) or Git failed, | |
| 413 | + | // which is transient. | |
| 414 | + | func (s *Mergeability) tips(ctx context.Context, mr domain.MergeRequest) (head, tip domain.ObjectID, ok, transient bool) { | |
| 415 | + | head, herr := s.cfg.Git.BranchHead(ctx, mr.Repo, mr.Source) | |
| 416 | + | tip, terr := s.cfg.Git.BranchHead(ctx, mr.Repo, mr.Target) | |
| 417 | + | switch { | |
| 418 | + | case errors.Is(herr, domain.ErrNotFound) || errors.Is(terr, domain.ErrNotFound): | |
| 419 | + | if err := s.cfg.Store.DeleteMergeability(ctx, mr.Repo, mr.Number); err != nil { | |
| 420 | + | if ctx.Err() == nil { | |
| 421 | + | ctxLog(ctx).Error().Err(err).Str("repo", mr.Repo.String()).Int64("number", mr.Number).Msg("mergeability: delete the record of a merge request without a branch") | |
| 422 | + | } | |
| 423 | + | transient = ctx.Err() == nil | |
| 424 | + | } | |
| 425 | + | return domain.ObjectID{}, domain.ObjectID{}, false, transient | |
| 426 | + | case herr != nil || terr != nil: | |
| 427 | + | if ctx.Err() != nil { | |
| 428 | + | return domain.ObjectID{}, domain.ObjectID{}, false, false | |
| 429 | + | } | |
| 430 | + | ctxLog(ctx).Error().Err(errors.Join(herr, terr)).Str("repo", mr.Repo.String()).Int64("number", mr.Number).Msg("mergeability: read the branches") | |
| 431 | + | return domain.ObjectID{}, domain.ObjectID{}, false, true | |
| 432 | + | } | |
| 433 | + | return head, tip, true, false | |
| 434 | + | } | |
| 435 | + | ||
| 436 | + | // compute runs the Git reads that decide the state of head against tip. | |
| 437 | + | func (s *Mergeability) compute(ctx context.Context, repo domain.RepoName, number int64, tip, head domain.ObjectID) (domain.Mergeability, error) { | |
| 438 | + | m := domain.Mergeability{Repo: repo, Number: number, TargetID: tip, HeadID: head, CheckedAt: s.cfg.Clock.Now().UTC()} | |
| 439 | + | ancestor := tip == head | |
| 440 | + | if !ancestor { | |
| 441 | + | var err error | |
| 442 | + | if ancestor, err = s.cfg.Git.IsAncestor(ctx, repo, tip, head); err != nil { | |
| 443 | + | return m, err | |
| 444 | + | } | |
| 445 | + | } | |
| 446 | + | if ancestor { | |
| 447 | + | m.State = domain.MergeabilityUpToDate | |
| 448 | + | return m, nil | |
| 449 | + | } | |
| 450 | + | tree, err := s.cfg.Git.MergeTree(ctx, repo, tip, head) | |
| 451 | + | if err != nil { | |
| 452 | + | return m, err | |
| 453 | + | } | |
| 454 | + | if !tree.Clean { | |
| 455 | + | m.State = domain.MergeabilityConflict | |
| 456 | + | tree = tree.Bounded() | |
| 457 | + | m.ConflictPaths, m.ConflictPathsTruncated = tree.ConflictPaths, tree.Truncated | |
| 458 | + | return m, nil | |
| 459 | + | } | |
| 460 | + | m.State = domain.MergeabilityBehind | |
| 461 | + | n, err := s.cfg.Git.CountBehind(ctx, repo, head, tip, domain.BehindLimit) | |
| 462 | + | if err != nil { | |
| 463 | + | return m, err | |
| 464 | + | } | |
| 465 | + | m.Behind, m.BehindCapped = domain.BoundBehind(n) | |
| 466 | + | return m, nil | |
| 467 | + | } | |
A internal/usecase/mergeability_reconcile_test.go +91 -0
| @@ -0,0 +1,91 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package usecase | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "sync/atomic" | |
| 8 | + | "testing" | |
| 9 | + | "time" | |
| 10 | + | ||
| 11 | + | "github.com/stretchr/testify/assert" | |
| 12 | + | "github.com/stretchr/testify/mock" | |
| 13 | + | "github.com/stretchr/testify/require" | |
| 14 | + | ||
| 15 | + | "bnema.dev/ermite/internal/domain" | |
| 16 | + | "bnema.dev/ermite/internal/ports/mocks" | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | func TestMergeabilityBackoffIsBounded(t *testing.T) { | |
| 20 | + | assert.Equal(t, time.Second, mergeBackoff(1)) | |
| 21 | + | assert.Equal(t, 2*time.Second, mergeBackoff(2)) | |
| 22 | + | assert.Equal(t, 4*time.Second, mergeBackoff(3)) | |
| 23 | + | assert.Equal(t, time.Minute, mergeBackoff(20)) | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | func TestMergeabilityReadMarksTheRepoDirty(t *testing.T) { | |
| 27 | + | repo, err := domain.ParseRepoName("proj") | |
| 28 | + | require.NoError(t, err) | |
| 29 | + | oid, err := domain.ParseObjectID("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") | |
| 30 | + | require.NoError(t, err) | |
| 31 | + | store := mocks.NewMergeRequestStore(t) | |
| 32 | + | store.EXPECT().GetMergeability(mock.Anything, repo, int64(7)).Return(domain.Mergeability{}, domain.ErrNotFound) | |
| 33 | + | svc := NewMergeability(context.Background(), MergeabilityConfig{Store: store}) | |
| 34 | + | defer svc.Close(context.Background()) | |
| 35 | + | mr := domain.MergeRequest{Repo: repo, Number: 7, HeadID: oid} | |
| 36 | + | assert.Equal(t, domain.MergeabilityChecking, svc.Read(context.Background(), mr, oid).State) | |
| 37 | + | // Another MR in the same repository is the same unit of work. | |
| 38 | + | mr.Number = 8 | |
| 39 | + | mr.HeadID = domain.ObjectID{} | |
| 40 | + | svc.Read(context.Background(), mr, oid) | |
| 41 | + | got, found := svc.next() | |
| 42 | + | require.True(t, found) | |
| 43 | + | assert.Equal(t, repo, got) | |
| 44 | + | _, found = svc.next() | |
| 45 | + | assert.False(t, found) | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | func TestMergeabilityOnePassPerRepoAtATimeAndAgain(t *testing.T) { | |
| 49 | + | repo, err := domain.ParseRepoName("proj") | |
| 50 | + | require.NoError(t, err) | |
| 51 | + | other, err := domain.ParseRepoName("other") | |
| 52 | + | require.NoError(t, err) | |
| 53 | + | meta, store := mocks.NewRepoMetaStore(t), mocks.NewMergeRequestStore(t) | |
| 54 | + | meta.EXPECT().List(mock.Anything).Return(nil, nil) | |
| 55 | + | meta.EXPECT().Get(mock.Anything, repo).Return(domain.Repo{Name: repo}, nil) | |
| 56 | + | meta.EXPECT().Get(mock.Anything, other).Return(domain.Repo{Name: other}, nil) | |
| 57 | + | entered, release, otherPassed := make(chan struct{}, 4), make(chan struct{}), make(chan struct{}) | |
| 58 | + | var passes, inflight, peak atomic.Int32 | |
| 59 | + | store.EXPECT().ListOpen(mock.Anything, repo).RunAndReturn(func(context.Context, domain.RepoName) ([]domain.MergeRequest, error) { | |
| 60 | + | n := inflight.Add(1) | |
| 61 | + | if n > peak.Load() { | |
| 62 | + | peak.Store(n) | |
| 63 | + | } | |
| 64 | + | pass := passes.Add(1) | |
| 65 | + | entered <- struct{}{} | |
| 66 | + | if pass == 1 { | |
| 67 | + | <-release | |
| 68 | + | } | |
| 69 | + | inflight.Add(-1) | |
| 70 | + | return nil, nil | |
| 71 | + | }) | |
| 72 | + | store.EXPECT().ListOpen(mock.Anything, other).RunAndReturn(func(context.Context, domain.RepoName) ([]domain.MergeRequest, error) { | |
| 73 | + | close(otherPassed) | |
| 74 | + | return nil, nil | |
| 75 | + | }) | |
| 76 | + | svc := NewMergeability(context.Background(), MergeabilityConfig{Store: store, Meta: meta, Workers: 2}) | |
| 77 | + | svc.Start() | |
| 78 | + | svc.OnOpened(context.Background(), domain.MergeRequest{Repo: repo}) | |
| 79 | + | <-entered | |
| 80 | + | for range 50 { | |
| 81 | + | svc.OnOpened(context.Background(), domain.MergeRequest{Repo: repo}) | |
| 82 | + | } | |
| 83 | + | svc.OnOpened(context.Background(), domain.MergeRequest{Repo: other}) | |
| 84 | + | <-otherPassed // the other worker has serviced all work eligible meanwhile | |
| 85 | + | assert.EqualValues(t, 1, passes.Load()) | |
| 86 | + | close(release) | |
| 87 | + | <-entered | |
| 88 | + | svc.Close(context.Background()) | |
| 89 | + | assert.EqualValues(t, 2, passes.Load(), "all triggers during a pass coalesce into exactly one more pass") | |
| 90 | + | assert.EqualValues(t, 1, peak.Load()) | |
| 91 | + | } | |
A internal/usecase/mergeability_test.go +673 -0
| @@ -0,0 +1,673 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package usecase_test | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "errors" | |
| 8 | + | "strconv" | |
| 9 | + | "strings" | |
| 10 | + | "sync/atomic" | |
| 11 | + | "testing" | |
| 12 | + | "time" | |
| 13 | + | ||
| 14 | + | "github.com/stretchr/testify/assert" | |
| 15 | + | "github.com/stretchr/testify/mock" | |
| 16 | + | "github.com/stretchr/testify/require" | |
| 17 | + | ||
| 18 | + | "bnema.dev/ermite/internal/domain" | |
| 19 | + | "bnema.dev/ermite/internal/ports" | |
| 20 | + | "bnema.dev/ermite/internal/ports/mocks" | |
| 21 | + | "bnema.dev/ermite/internal/usecase" | |
| 22 | + | ) | |
| 23 | + | ||
| 24 | + | func fakeOID(t *testing.T, c string) domain.ObjectID { | |
| 25 | + | t.Helper() | |
| 26 | + | return oidFrom(t, strings.Repeat(c, 40)) | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | // mergeRig runs the mergeability service over the real adapters of mrRig. | |
| 30 | + | type mergeRig struct { | |
| 31 | + | *mrRig | |
| 32 | + | svc *usecase.Mergeability | |
| 33 | + | mev <-chan domain.Event | |
| 34 | + | history []domain.MergeRequestMergeability | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | func newMergeRig(t *testing.T) *mergeRig { | |
| 38 | + | t.Helper() | |
| 39 | + | r := newMRRig(t) | |
| 40 | + | svc := usecase.NewMergeability(r.ctx, usecase.MergeabilityConfig{ | |
| 41 | + | Store: r.store.MergeRequests(), Meta: r.store, Git: r.git, Bus: r.bus, Clock: fixedClock{}, Workers: 2, | |
| 42 | + | }) | |
| 43 | + | r.mrs = usecase.NewMergeRequests(usecase.MergeRequestsConfig{ | |
| 44 | + | Meta: r.store, Store: r.store.MergeRequests(), Repos: r.git, Git: r.git, Signer: nil, | |
| 45 | + | Committer: domain.Identity{Name: "ermite", Email: "ermite@git.example.org"}, Bus: r.bus, Clock: fixedClock{}, Maint: r.sched, | |
| 46 | + | Mergeability: svc, | |
| 47 | + | }) | |
| 48 | + | t.Cleanup(func() { svc.Close(context.Background()) }) | |
| 49 | + | return &mergeRig{mrRig: r, svc: svc, mev: r.bus.Subscribe(r.ctx)} | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | func (r *mergeRig) record(n int64) (domain.Mergeability, error) { | |
| 53 | + | return r.store.MergeRequests().GetMergeability(r.ctx, r.repo, n) | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | func (r *mergeRig) waitState(n int64, want domain.MergeabilityState, target, head domain.ObjectID) domain.Mergeability { | |
| 57 | + | r.t.Helper() | |
| 58 | + | var m domain.Mergeability | |
| 59 | + | deadline := time.After(10 * time.Second) | |
| 60 | + | for { | |
| 61 | + | var err error | |
| 62 | + | m, err = r.record(n) | |
| 63 | + | if err == nil && m.State == want && m.IsFor(target, head) { | |
| 64 | + | break | |
| 65 | + | } | |
| 66 | + | select { | |
| 67 | + | case ev := <-r.mev: | |
| 68 | + | if e, ok := ev.(domain.MergeRequestMergeability); ok { | |
| 69 | + | r.history = append(r.history, e) | |
| 70 | + | } | |
| 71 | + | case <-deadline: | |
| 72 | + | r.t.Fatalf("mergeability of !%d did not become %s", n, want) | |
| 73 | + | } | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | return m | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | // events returns the mergeability events published so far. | |
| 80 | + | func (r *mergeRig) events() []domain.MergeRequestMergeability { | |
| 81 | + | out := r.history | |
| 82 | + | r.history = nil | |
| 83 | + | for { | |
| 84 | + | select { | |
| 85 | + | case ev := <-r.mev: | |
| 86 | + | if m, ok := ev.(domain.MergeRequestMergeability); ok { | |
| 87 | + | out = append(out, m) | |
| 88 | + | } | |
| 89 | + | default: | |
| 90 | + | return out | |
| 91 | + | } | |
| 92 | + | } | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | // awaitEvents receives mergeability events until n arrived, or fails after a | |
| 96 | + | // bound: the workers publish after they stored the record the tests wait for. | |
| 97 | + | func (r *mergeRig) awaitEvents(n int) []domain.MergeRequestMergeability { | |
| 98 | + | r.t.Helper() | |
| 99 | + | out := r.history | |
| 100 | + | r.history = nil | |
| 101 | + | deadline := time.After(10 * time.Second) | |
| 102 | + | for len(out) < n { | |
| 103 | + | select { | |
| 104 | + | case ev := <-r.mev: | |
| 105 | + | if m, ok := ev.(domain.MergeRequestMergeability); ok { | |
| 106 | + | out = append(out, m) | |
| 107 | + | } | |
| 108 | + | case <-deadline: | |
| 109 | + | r.t.Fatalf("got %d mergeability events, want %d", len(out), n) | |
| 110 | + | } | |
| 111 | + | } | |
| 112 | + | return out | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | func TestMergeabilityStatesEndToEnd(t *testing.T) { | |
| 116 | + | r := newMergeRig(t) | |
| 117 | + | r.svc.Start() | |
| 118 | + | main := r.seed() | |
| 119 | + | head := r.feature("feat") | |
| 120 | + | mr := r.open("feat") | |
| 121 | + | ||
| 122 | + | // up to date: the target is an ancestor of the head. | |
| 123 | + | m := r.waitState(mr.Number, domain.MergeabilityUpToDate, main, head) | |
| 124 | + | assert.False(t, m.CheckedAt.IsZero()) | |
| 125 | + | ||
| 126 | + | // behind: the target moved with a change that merges cleanly. | |
| 127 | + | r.sh("checkout", "-q", "main") | |
| 128 | + | main2 := r.commit(author, "d.txt", "dee\n", "add d") | |
| 129 | + | r.push("main") | |
| 130 | + | r.svc.OnBranchesMoved(r.ctx, r.repo, []domain.RefUpdate{{Old: main.String(), New: main2.String(), Ref: "refs/heads/main"}}) | |
| 131 | + | m = r.waitState(mr.Number, domain.MergeabilityBehind, main2, head) | |
| 132 | + | assert.Equal(t, 1, m.Behind) | |
| 133 | + | assert.False(t, m.BehindCapped) | |
| 134 | + | ||
| 135 | + | // conflict: the target changes a line the head changed. | |
| 136 | + | main3 := r.commit(author, "a.txt", "one\nTWO\nthree\n", "conflict a") | |
| 137 | + | r.push("main") | |
| 138 | + | r.svc.OnBranchesMoved(r.ctx, r.repo, []domain.RefUpdate{{Old: main2.String(), New: main3.String(), Ref: "refs/heads/main"}}) | |
| 139 | + | m = r.waitState(mr.Number, domain.MergeabilityConflict, main3, head) | |
| 140 | + | assert.Equal(t, []string{"a.txt"}, m.ConflictPaths) | |
| 141 | + | ||
| 142 | + | got := r.awaitEvents(3) | |
| 143 | + | for i, s := range []domain.MergeabilityState{domain.MergeabilityUpToDate, domain.MergeabilityBehind, domain.MergeabilityConflict} { | |
| 144 | + | assert.Equal(t, s, got[i].Mergeability.State) | |
| 145 | + | assert.Equal(t, mr.Number, got[i].Number) | |
| 146 | + | } | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | func TestMergeabilityReaderIsLazy(t *testing.T) { | |
| 150 | + | r := newMergeRig(t) | |
| 151 | + | main := r.seed() | |
| 152 | + | head := r.feature("feat") | |
| 153 | + | mr := r.open("feat") // queued, but the workers are not started | |
| 154 | + | ||
| 155 | + | d, err := r.mrs.Get(r.ctx, root, r.repo, mr.Number, ports.GetMergeRequestOptions{}) | |
| 156 | + | require.NoError(t, err) | |
| 157 | + | assert.Equal(t, domain.MergeabilityChecking, d.Mergeability.State) | |
| 158 | + | ||
| 159 | + | r.svc.Start() | |
| 160 | + | r.waitState(mr.Number, domain.MergeabilityUpToDate, main, head) | |
| 161 | + | d, err = r.mrs.Get(r.ctx, root, r.repo, mr.Number, ports.GetMergeRequestOptions{}) | |
| 162 | + | require.NoError(t, err) | |
| 163 | + | assert.Equal(t, domain.MergeabilityUpToDate, d.Mergeability.State) | |
| 164 | + | ||
| 165 | + | page, err := r.mrs.List(r.ctx, root, r.repo, ports.ListMergeRequestsParams{}) | |
| 166 | + | require.NoError(t, err) | |
| 167 | + | assert.Equal(t, domain.MergeabilityUpToDate, page.Mergeability[mr.Number].State) | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | func TestMergeabilityPublishesEachSettledResultOnce(t *testing.T) { | |
| 171 | + | r := newMergeRig(t) | |
| 172 | + | r.seed() | |
| 173 | + | r.feature("feat") | |
| 174 | + | mr := r.open("feat") | |
| 175 | + | r.svc.ReconcileForTest(r.repo) | |
| 176 | + | require.Len(t, r.events(), 1) | |
| 177 | + | ||
| 178 | + | // Same tips: nothing is computed, nothing is published. | |
| 179 | + | r.svc.ReconcileForTest(r.repo) | |
| 180 | + | assert.Empty(t, r.events()) | |
| 181 | + | ||
| 182 | + | // New tips publish even when the state and counts stay the same. | |
| 183 | + | r.sh("checkout", "-q", "feat") | |
| 184 | + | r.commit(author, "e.txt", "e\n", "more") | |
| 185 | + | r.push("feat") | |
| 186 | + | r.svc.ReconcileForTest(r.repo) | |
| 187 | + | assert.Len(t, r.events(), 1) | |
| 188 | + | m, err := r.record(mr.Number) | |
| 189 | + | require.NoError(t, err) | |
| 190 | + | assert.Equal(t, domain.MergeabilityUpToDate, m.State) | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | func TestMergeabilityMergeReevaluatesOtherRequests(t *testing.T) { | |
| 194 | + | r := newMergeRig(t) | |
| 195 | + | r.svc.Start() | |
| 196 | + | main := r.seed() | |
| 197 | + | r.sh("checkout", "-q", "-B", "one", "main") | |
| 198 | + | oneHead := r.commit(author, "one.txt", "1\n", "one") | |
| 199 | + | r.push("one") | |
| 200 | + | r.sh("checkout", "-q", "-B", "two", "main") | |
| 201 | + | twoHead := r.commit(author, "two.txt", "2\n", "two") | |
| 202 | + | r.push("two") | |
| 203 | + | one, two := r.open("one"), r.open("two") | |
| 204 | + | r.waitState(one.Number, domain.MergeabilityUpToDate, main, oneHead) | |
| 205 | + | r.waitState(two.Number, domain.MergeabilityUpToDate, main, twoHead) | |
| 206 | + | ||
| 207 | + | _, err := r.mrs.Merge(r.ctx, mrM, r.repo, one.Number, ports.MergeParams{HeadID: oneHead}) | |
| 208 | + | require.NoError(t, err) | |
| 209 | + | m := r.waitState(two.Number, domain.MergeabilityBehind, oneHead, twoHead) | |
| 210 | + | assert.Equal(t, 1, m.Behind) | |
| 211 | + | _, err = r.record(one.Number) | |
| 212 | + | assert.ErrorIs(t, err, domain.ErrNotFound, "the merged request keeps no record") | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | func TestMergeabilityClosedRequestRecordDeleted(t *testing.T) { | |
| 216 | + | r := newMergeRig(t) | |
| 217 | + | r.seed() | |
| 218 | + | r.feature("feat") | |
| 219 | + | mr := r.open("feat") | |
| 220 | + | r.svc.ReconcileForTest(r.repo) | |
| 221 | + | _, err := r.record(mr.Number) | |
| 222 | + | require.NoError(t, err) | |
| 223 | + | ||
| 224 | + | _, err = r.mrs.Close(r.ctx, mrW, r.repo, mr.Number) | |
| 225 | + | require.NoError(t, err) | |
| 226 | + | _, err = r.record(mr.Number) | |
| 227 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| 228 | + | r.svc.ReconcileForTest(r.repo) // a late check writes nothing | |
| 229 | + | _, err = r.record(mr.Number) | |
| 230 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| 231 | + | } | |
| 232 | + | ||
| 233 | + | func TestMergeabilityStartupRescanComputesMissingRecords(t *testing.T) { | |
| 234 | + | r := newMergeRig(t) | |
| 235 | + | main := r.seed() | |
| 236 | + | head := r.feature("feat") | |
| 237 | + | mr := r.open("feat") | |
| 238 | + | _, err := r.record(mr.Number) | |
| 239 | + | require.ErrorIs(t, err, domain.ErrNotFound) | |
| 240 | + | ||
| 241 | + | // A fresh service has received no OnOpened or Read trigger. | |
| 242 | + | svc := usecase.NewMergeability(r.ctx, usecase.MergeabilityConfig{ | |
| 243 | + | Store: r.store.MergeRequests(), Meta: r.store, Git: r.git, Bus: r.bus, Clock: fixedClock{}, | |
| 244 | + | }) | |
| 245 | + | defer svc.Close(context.Background()) | |
| 246 | + | svc.Start() | |
| 247 | + | r.waitState(mr.Number, domain.MergeabilityUpToDate, main, head) | |
| 248 | + | } | |
| 249 | + | ||
| 250 | + | func TestMergeabilityNeedsNoCI(t *testing.T) { | |
| 251 | + | // The service is built without any CI: a target move still updates the | |
| 252 | + | // record, and the repository gets no pull ref (the service has no CI to create a run with). | |
| 253 | + | r := newMergeRig(t) | |
| 254 | + | r.svc.Start() | |
| 255 | + | main := r.seed() | |
| 256 | + | head := r.feature("feat") | |
| 257 | + | mr := r.open("feat") | |
| 258 | + | r.waitState(mr.Number, domain.MergeabilityUpToDate, main, head) | |
| 259 | + | r.sh("checkout", "-q", "main") | |
| 260 | + | main2 := r.commit(author, "d.txt", "dee\n", "add d") | |
| 261 | + | r.push("main") | |
| 262 | + | r.svc.OnBranchesMoved(r.ctx, r.repo, []domain.RefUpdate{{Old: main.String(), New: main2.String(), Ref: "refs/heads/main"}}) | |
| 263 | + | r.waitState(mr.Number, domain.MergeabilityBehind, main2, head) | |
| 264 | + | ||
| 265 | + | assert.Empty(t, r.run(r.bare(), r.env(author), "for-each-ref", "refs/pull")) | |
| 266 | + | } | |
| 267 | + | ||
| 268 | + | // --- unit tests over mocks --- | |
| 269 | + | ||
| 270 | + | type mockRig struct { | |
| 271 | + | store *mocks.MergeRequestStore | |
| 272 | + | meta *mocks.RepoMetaStore | |
| 273 | + | git *mocks.MergeGit | |
| 274 | + | bus *mocks.EventBus | |
| 275 | + | svc *usecase.Mergeability | |
| 276 | + | mr domain.MergeRequest | |
| 277 | + | } | |
| 278 | + | ||
| 279 | + | func newMockRig(t *testing.T) *mockRig { | |
| 280 | + | t.Helper() | |
| 281 | + | repo := name(t, "proj") | |
| 282 | + | r := &mockRig{ | |
| 283 | + | store: mocks.NewMergeRequestStore(t), meta: mocks.NewRepoMetaStore(t), git: mocks.NewMergeGit(t), bus: mocks.NewEventBus(t), | |
| 284 | + | mr: domain.MergeRequest{Repo: repo, Number: 7, Source: "feat", Target: "main", State: domain.MRStateOpen}, | |
| 285 | + | } | |
| 286 | + | r.store.EXPECT().ListOpen(mock.Anything, repo).Return([]domain.MergeRequest{r.mr}, nil).Maybe() | |
| 287 | + | r.store.EXPECT().GetMergeability(mock.Anything, repo, int64(7)).Return(domain.Mergeability{}, domain.ErrNotFound).Maybe() | |
| 288 | + | r.meta.EXPECT().List(mock.Anything).Return(nil, nil).Maybe() | |
| 289 | + | r.meta.EXPECT().Get(mock.Anything, repo).Return(domain.Repo{Name: repo, CreatedAt: now}, nil).Maybe() | |
| 290 | + | r.svc = usecase.NewMergeability(context.Background(), usecase.MergeabilityConfig{ | |
| 291 | + | Store: r.store, Meta: r.meta, Git: r.git, Bus: r.bus, Clock: fixedClock{}, | |
| 292 | + | }) | |
| 293 | + | t.Cleanup(func() { r.svc.Close(context.Background()) }) | |
| 294 | + | return r | |
| 295 | + | } | |
| 296 | + | ||
| 297 | + | func TestMergeabilityDedupsTriggers(t *testing.T) { | |
| 298 | + | r := newMockRig(t) | |
| 299 | + | repo := r.mr.Repo | |
| 300 | + | head, tip := fakeOID(t, "a"), fakeOID(t, "b") | |
| 301 | + | r.git.EXPECT().BranchHead(mock.Anything, repo, "feat").Return(head, nil) | |
| 302 | + | r.git.EXPECT().BranchHead(mock.Anything, repo, "main").Return(tip, nil) | |
| 303 | + | computed := make(chan struct{}, 1) | |
| 304 | + | var computes atomic.Int32 | |
| 305 | + | r.git.EXPECT().IsAncestor(mock.Anything, repo, tip, head).RunAndReturn( | |
| 306 | + | func(context.Context, domain.RepoName, domain.ObjectID, domain.ObjectID) (bool, error) { | |
| 307 | + | computes.Add(1) | |
| 308 | + | computed <- struct{}{} | |
| 309 | + | return true, nil | |
| 310 | + | }) | |
| 311 | + | r.store.EXPECT().PutMergeability(mock.Anything, mock.Anything).Return(nil) | |
| 312 | + | r.bus.EXPECT().Publish(mock.Anything, mock.Anything).Return() | |
| 313 | + | ||
| 314 | + | for range 50 { | |
| 315 | + | r.svc.OnOpened(context.Background(), r.mr) // queued before any worker runs | |
| 316 | + | } | |
| 317 | + | r.svc.Start() | |
| 318 | + | <-computed | |
| 319 | + | r.svc.Close(context.Background()) // the workers have each looked at the queue once more | |
| 320 | + | assert.EqualValues(t, 1, computes.Load()) | |
| 321 | + | } | |
| 322 | + | ||
| 323 | + | func TestMergeabilityNoWriteForClosedRequest(t *testing.T) { | |
| 324 | + | r := newMockRig(t) | |
| 325 | + | repo := r.mr.Repo | |
| 326 | + | head, tip := fakeOID(t, "a"), fakeOID(t, "b") | |
| 327 | + | r.git.EXPECT().BranchHead(mock.Anything, repo, "feat").Return(head, nil) | |
| 328 | + | r.git.EXPECT().BranchHead(mock.Anything, repo, "main").Return(tip, nil) | |
| 329 | + | r.git.EXPECT().IsAncestor(mock.Anything, repo, tip, head).Return(true, nil) | |
| 330 | + | r.store.EXPECT().PutMergeability(mock.Anything, mock.Anything).Return(domain.ErrNotFound).Once() // closed meanwhile | |
| 331 | + | r.svc.ReconcileForTest(repo) // no event, no further write | |
| 332 | + | } | |
| 333 | + | ||
| 334 | + | func TestMergeabilityBoundsConflictPathsAndBehind(t *testing.T) { | |
| 335 | + | t.Run("conflict paths", func(t *testing.T) { | |
| 336 | + | r := newMockRig(t) | |
| 337 | + | repo := r.mr.Repo | |
| 338 | + | head, tip := fakeOID(t, "a"), fakeOID(t, "b") | |
| 339 | + | r.git.EXPECT().BranchHead(mock.Anything, repo, "feat").Return(head, nil) | |
| 340 | + | r.git.EXPECT().BranchHead(mock.Anything, repo, "main").Return(tip, nil) | |
| 341 | + | r.git.EXPECT().IsAncestor(mock.Anything, repo, tip, head).Return(false, nil) | |
| 342 | + | var paths []string | |
| 343 | + | for i := 0; i < domain.MaxConflictPaths+3; i++ { | |
| 344 | + | paths = append(paths, strings.Repeat("p", i+1)) | |
| 345 | + | } | |
| 346 | + | r.git.EXPECT().MergeTree(mock.Anything, repo, tip, head).Return(domain.MergeTreeResult{ConflictPaths: paths}, nil) | |
| 347 | + | var put domain.Mergeability | |
| 348 | + | r.store.EXPECT().PutMergeability(mock.Anything, mock.Anything).RunAndReturn( | |
| 349 | + | func(_ context.Context, m domain.Mergeability) error { put = m; return nil }) | |
| 350 | + | r.bus.EXPECT().Publish(mock.Anything, mock.Anything).Return() | |
| 351 | + | r.svc.ReconcileForTest(repo) | |
| 352 | + | assert.Equal(t, domain.MergeabilityConflict, put.State) | |
| 353 | + | assert.Len(t, put.ConflictPaths, domain.MaxConflictPaths) | |
| 354 | + | assert.True(t, put.ConflictPathsTruncated) | |
| 355 | + | }) | |
| 356 | + | t.Run("behind", func(t *testing.T) { | |
| 357 | + | r := newMockRig(t) | |
| 358 | + | repo := r.mr.Repo | |
| 359 | + | head, tip := fakeOID(t, "a"), fakeOID(t, "b") | |
| 360 | + | r.git.EXPECT().BranchHead(mock.Anything, repo, "feat").Return(head, nil) | |
| 361 | + | r.git.EXPECT().BranchHead(mock.Anything, repo, "main").Return(tip, nil) | |
| 362 | + | r.git.EXPECT().IsAncestor(mock.Anything, repo, tip, head).Return(false, nil) | |
| 363 | + | r.git.EXPECT().MergeTree(mock.Anything, repo, tip, head).Return(domain.MergeTreeResult{Clean: true}, nil) | |
| 364 | + | r.git.EXPECT().CountBehind(mock.Anything, repo, head, tip, domain.MaxBehindCount+1).Return(domain.MaxBehindCount+1, nil) | |
| 365 | + | var put domain.Mergeability | |
| 366 | + | r.store.EXPECT().PutMergeability(mock.Anything, mock.Anything).RunAndReturn( | |
| 367 | + | func(_ context.Context, m domain.Mergeability) error { put = m; return nil }) | |
| 368 | + | r.bus.EXPECT().Publish(mock.Anything, mock.Anything).Return() | |
| 369 | + | r.svc.ReconcileForTest(repo) | |
| 370 | + | assert.Equal(t, domain.MaxBehindCount, put.Behind) | |
| 371 | + | assert.True(t, put.BehindCapped) | |
| 372 | + | }) | |
| 373 | + | } | |
| 374 | + | ||
| 375 | + | // A transient Git failure is retried once after the backoff, by rescanning | |
| 376 | + | // the repository through the queue. | |
| 377 | + | func TestMergeabilityRetriesATransientFailureWithBackoff(t *testing.T) { | |
| 378 | + | repo := name(t, "proj") | |
| 379 | + | head, tip := fakeOID(t, "a"), fakeOID(t, "b") | |
| 380 | + | mr := domain.MergeRequest{Repo: repo, Number: 7, Source: "feat", Target: "main", State: domain.MRStateOpen} | |
| 381 | + | store, meta, git, bus := mocks.NewMergeRequestStore(t), mocks.NewRepoMetaStore(t), mocks.NewMergeGit(t), mocks.NewEventBus(t) | |
| 382 | + | store.EXPECT().ListOpen(mock.Anything, repo).Return([]domain.MergeRequest{mr}, nil) | |
| 383 | + | store.EXPECT().GetMergeability(mock.Anything, repo, int64(7)).Return(domain.Mergeability{}, domain.ErrNotFound) | |
| 384 | + | store.EXPECT().PutMergeability(mock.Anything, mock.Anything).Return(nil) | |
| 385 | + | meta.EXPECT().List(mock.Anything).Return(nil, nil) | |
| 386 | + | meta.EXPECT().Get(mock.Anything, repo).Return(domain.Repo{Name: repo, CreatedAt: now}, nil) | |
| 387 | + | var fails atomic.Int32 | |
| 388 | + | git.EXPECT().BranchHead(mock.Anything, repo, "feat").RunAndReturn( | |
| 389 | + | func(context.Context, domain.RepoName, string) (domain.ObjectID, error) { | |
| 390 | + | if fails.Add(1) == 1 { | |
| 391 | + | return domain.ObjectID{}, errors.New("transient") | |
| 392 | + | } | |
| 393 | + | return head, nil | |
| 394 | + | }) | |
| 395 | + | git.EXPECT().BranchHead(mock.Anything, repo, "main").Return(tip, nil) | |
| 396 | + | git.EXPECT().IsAncestor(mock.Anything, repo, tip, head).Return(true, nil) | |
| 397 | + | published := make(chan struct{}) | |
| 398 | + | bus.EXPECT().Publish(mock.Anything, mock.Anything).Run(func(context.Context, domain.Event) { close(published) }).Return().Once() | |
| 399 | + | ||
| 400 | + | asked, fire := make(chan time.Duration, 4), make(chan time.Time) | |
| 401 | + | svc := usecase.NewMergeability(context.Background(), usecase.MergeabilityConfig{ | |
| 402 | + | Store: store, Meta: meta, Git: git, Bus: bus, Clock: fixedClock{}, | |
| 403 | + | After: func(d time.Duration) <-chan time.Time { asked <- d; return fire }, | |
| 404 | + | }) | |
| 405 | + | t.Cleanup(func() { svc.Close(context.Background()) }) | |
| 406 | + | svc.Start() | |
| 407 | + | svc.OnOpened(context.Background(), mr) | |
| 408 | + | ||
| 409 | + | assert.Equal(t, time.Second, <-asked) | |
| 410 | + | select { | |
| 411 | + | case <-published: | |
| 412 | + | t.Fatal("published before the backoff elapsed") | |
| 413 | + | default: | |
| 414 | + | } | |
| 415 | + | fire <- time.Time{} | |
| 416 | + | <-published | |
| 417 | + | assert.Empty(t, asked, "one retry was enough and only one was scheduled") | |
| 418 | + | } | |
| 419 | + | ||
| 420 | + | // Close stops a pending retry timer's goroutine. | |
| 421 | + | func TestMergeabilityCloseStopsPendingRetries(t *testing.T) { | |
| 422 | + | repo := name(t, "proj") | |
| 423 | + | mr := domain.MergeRequest{Repo: repo, Number: 7, Source: "feat", Target: "main", State: domain.MRStateOpen} | |
| 424 | + | store, meta, git, bus := mocks.NewMergeRequestStore(t), mocks.NewRepoMetaStore(t), mocks.NewMergeGit(t), mocks.NewEventBus(t) | |
| 425 | + | store.EXPECT().ListOpen(mock.Anything, repo).Return(nil, errors.New("db down")) | |
| 426 | + | meta.EXPECT().Get(mock.Anything, repo).Return(domain.Repo{Name: repo}, nil) | |
| 427 | + | meta.EXPECT().List(mock.Anything).Return(nil, nil) | |
| 428 | + | asked := make(chan time.Duration, 4) | |
| 429 | + | svc := usecase.NewMergeability(context.Background(), usecase.MergeabilityConfig{ | |
| 430 | + | Store: store, Meta: meta, Git: git, Bus: bus, Clock: fixedClock{}, | |
| 431 | + | After: func(d time.Duration) <-chan time.Time { asked <- d; return make(chan time.Time) }, | |
| 432 | + | }) | |
| 433 | + | svc.Start() | |
| 434 | + | svc.OnOpened(context.Background(), mr) | |
| 435 | + | <-asked | |
| 436 | + | done := make(chan struct{}) | |
| 437 | + | go func() { svc.Close(context.Background()); close(done) }() | |
| 438 | + | <-done // Close waits for the timer goroutine: it returns only if it stopped | |
| 439 | + | } | |
| 440 | + | ||
| 441 | + | // Workers are not stopped by the cancellation of the context given to the | |
| 442 | + | // constructor; Close stops them. | |
| 443 | + | func TestMergeabilityWorkersOutliveTheParentContext(t *testing.T) { | |
| 444 | + | repo := name(t, "proj") | |
| 445 | + | head, tip := fakeOID(t, "a"), fakeOID(t, "b") | |
| 446 | + | mr := domain.MergeRequest{Repo: repo, Number: 7, Source: "feat", Target: "main", State: domain.MRStateOpen} | |
| 447 | + | store, meta, git, bus := mocks.NewMergeRequestStore(t), mocks.NewRepoMetaStore(t), mocks.NewMergeGit(t), mocks.NewEventBus(t) | |
| 448 | + | store.EXPECT().ListOpen(mock.Anything, repo).Return([]domain.MergeRequest{mr}, nil) | |
| 449 | + | store.EXPECT().GetMergeability(mock.Anything, repo, int64(7)).Return(domain.Mergeability{}, domain.ErrNotFound) | |
| 450 | + | store.EXPECT().PutMergeability(mock.Anything, mock.Anything).Return(nil) | |
| 451 | + | meta.EXPECT().List(mock.Anything).Return(nil, nil) | |
| 452 | + | meta.EXPECT().Get(mock.Anything, repo).Return(domain.Repo{Name: repo, CreatedAt: now}, nil) | |
| 453 | + | git.EXPECT().BranchHead(mock.Anything, repo, "feat").Return(head, nil) | |
| 454 | + | git.EXPECT().BranchHead(mock.Anything, repo, "main").Return(tip, nil) | |
| 455 | + | git.EXPECT().IsAncestor(mock.Anything, repo, tip, head).Return(true, nil) | |
| 456 | + | published := make(chan struct{}) | |
| 457 | + | bus.EXPECT().Publish(mock.Anything, mock.Anything).Run(func(context.Context, domain.Event) { close(published) }).Return().Once() | |
| 458 | + | ||
| 459 | + | parent, cancel := context.WithCancel(context.Background()) | |
| 460 | + | svc := usecase.NewMergeability(parent, usecase.MergeabilityConfig{Store: store, Meta: meta, Git: git, Bus: bus, Clock: fixedClock{}}) | |
| 461 | + | svc.Start() | |
| 462 | + | cancel() | |
| 463 | + | svc.OnOpened(context.Background(), mr) | |
| 464 | + | <-published // computed although the parent is cancelled | |
| 465 | + | svc.Close(context.Background()) | |
| 466 | + | } | |
| 467 | + | ||
| 468 | + | // Failures of many requests of 3 repositories schedule at most one retry per | |
| 469 | + | // repository on a single scheduler goroutine. | |
| 470 | + | func TestMergeabilityRetriesAreBounded(t *testing.T) { | |
| 471 | + | const repos = 3 | |
| 472 | + | store, meta := mocks.NewMergeRequestStore(t), mocks.NewRepoMetaStore(t) | |
| 473 | + | barrier := name(t, "barrier") | |
| 474 | + | done := make(chan struct{}) | |
| 475 | + | store.EXPECT().ListOpen(mock.Anything, barrier).RunAndReturn(func(context.Context, domain.RepoName) ([]domain.MergeRequest, error) { close(done); return nil, nil }).Once() | |
| 476 | + | store.EXPECT().ListOpen(mock.Anything, mock.Anything).Return(nil, errors.New("db down")) | |
| 477 | + | meta.EXPECT().Get(mock.Anything, mock.Anything).Return(domain.Repo{}, nil) | |
| 478 | + | meta.EXPECT().List(mock.Anything).Return(nil, nil) | |
| 479 | + | asked := make(chan time.Duration, repos) | |
| 480 | + | svc := usecase.NewMergeability(context.Background(), usecase.MergeabilityConfig{ | |
| 481 | + | Store: store, Meta: meta, Clock: fixedClock{}, Workers: 1, | |
| 482 | + | After: func(d time.Duration) <-chan time.Time { asked <- d; return make(chan time.Time) }, | |
| 483 | + | }) | |
| 484 | + | svc.Start() | |
| 485 | + | for r := range repos { | |
| 486 | + | repo := name(t, "proj"+strconv.Itoa(r)) | |
| 487 | + | for range 20 { | |
| 488 | + | svc.OnOpened(context.Background(), domain.MergeRequest{Repo: repo}) | |
| 489 | + | } | |
| 490 | + | } | |
| 491 | + | svc.OnOpened(context.Background(), domain.MergeRequest{Repo: barrier}) | |
| 492 | + | <-done | |
| 493 | + | // The shared scheduler only arms the earliest pending retry. | |
| 494 | + | <-asked | |
| 495 | + | svc.Close(context.Background()) | |
| 496 | + | assert.Equal(t, repos, svc.ScheduledRetries()) | |
| 497 | + | assert.Equal(t, repos, svc.RetryAttempts()) | |
| 498 | + | } | |
| 499 | + | ||
| 500 | + | // retryRig is a service over mocks whose only repository holds request !7, | |
| 501 | + | // whose read fails for its first failFor calls. Each repository rescan signals passes. | |
| 502 | + | type retryRig struct { | |
| 503 | + | svc *usecase.Mergeability | |
| 504 | + | repo domain.RepoName | |
| 505 | + | reads atomic.Int32 | |
| 506 | + | failFor atomic.Int32 | |
| 507 | + | passes chan struct{} | |
| 508 | + | afters chan chan time.Time | |
| 509 | + | barrier chan struct{} | |
| 510 | + | } | |
| 511 | + | ||
| 512 | + | func newRetryRig(t *testing.T) *retryRig { | |
| 513 | + | t.Helper() | |
| 514 | + | r := &retryRig{repo: name(t, "proj"), passes: make(chan struct{}, 32), afters: make(chan chan time.Time, 32), barrier: make(chan struct{})} | |
| 515 | + | store, meta, git, bus := mocks.NewMergeRequestStore(t), mocks.NewRepoMetaStore(t), mocks.NewMergeGit(t), mocks.NewEventBus(t) | |
| 516 | + | r.failFor.Store(1 << 30) | |
| 517 | + | meta.EXPECT().Get(mock.Anything, r.repo).Return(domain.Repo{Name: r.repo}, nil) | |
| 518 | + | store.EXPECT().ListOpen(mock.Anything, r.repo).RunAndReturn( | |
| 519 | + | func(context.Context, domain.RepoName) ([]domain.MergeRequest, error) { | |
| 520 | + | defer func() { r.passes <- struct{}{} }() | |
| 521 | + | if r.reads.Add(1) <= r.failFor.Load() { | |
| 522 | + | return nil, errors.New("db down") | |
| 523 | + | } | |
| 524 | + | return nil, nil | |
| 525 | + | }) | |
| 526 | + | barrier := name(t, "barrier") | |
| 527 | + | meta.EXPECT().Get(mock.Anything, barrier).RunAndReturn(func(context.Context, domain.RepoName) (domain.Repo, error) { | |
| 528 | + | r.barrier <- struct{}{} | |
| 529 | + | return domain.Repo{}, domain.ErrNotFound | |
| 530 | + | }) | |
| 531 | + | meta.EXPECT().List(mock.Anything).Return([]domain.Repo{{Name: r.repo}}, nil) | |
| 532 | + | r.svc = usecase.NewMergeability(context.Background(), usecase.MergeabilityConfig{ | |
| 533 | + | Store: store, Meta: meta, Git: git, Bus: bus, Clock: fixedClock{}, Workers: 1, | |
| 534 | + | After: func(time.Duration) <-chan time.Time { ch := make(chan time.Time, 1); r.afters <- ch; return ch }, | |
| 535 | + | }) | |
| 536 | + | t.Cleanup(func() { r.svc.Close(context.Background()) }) | |
| 537 | + | return r | |
| 538 | + | } | |
| 539 | + | ||
| 540 | + | // next fires the pending retry and waits for the rescan it queues. | |
| 541 | + | func (r *retryRig) next() { | |
| 542 | + | ch := <-r.afters | |
| 543 | + | ch <- time.Time{} | |
| 544 | + | <-r.passes | |
| 545 | + | } | |
| 546 | + | ||
| 547 | + | func (r *retryRig) settled(t *testing.T) { | |
| 548 | + | t.Helper() | |
| 549 | + | r.svc.OnOpened(context.Background(), domain.MergeRequest{Repo: name(t, "barrier")}) | |
| 550 | + | <-r.barrier | |
| 551 | + | require.Zero(t, r.svc.RetryAttempts()) | |
| 552 | + | require.Zero(t, r.svc.ScheduledRetries()) | |
| 553 | + | } | |
| 554 | + | ||
| 555 | + | // A persistent failure is retried 5 times, then given up with no state kept. | |
| 556 | + | func TestMergeabilityGivesUpAfterFiveRetries(t *testing.T) { | |
| 557 | + | r := newRetryRig(t) | |
| 558 | + | r.svc.Start() | |
| 559 | + | <-r.passes // the startup rescan fails | |
| 560 | + | for range 5 { | |
| 561 | + | r.next() | |
| 562 | + | } | |
| 563 | + | r.settled(t) | |
| 564 | + | assert.Empty(t, r.afters, "no sixth retry") | |
| 565 | + | assert.Empty(t, r.passes) | |
| 566 | + | } | |
| 567 | + | ||
| 568 | + | // A successful full rescan resets the budget: a later failure gets 5 again. | |
| 569 | + | func TestMergeabilitySuccessfulPassForgetsTheRetryBudget(t *testing.T) { | |
| 570 | + | r := newRetryRig(t) | |
| 571 | + | r.failFor.Store(2) // the first two reads fail, the third succeeds | |
| 572 | + | r.svc.Start() | |
| 573 | + | <-r.passes | |
| 574 | + | r.next() // second failing pass | |
| 575 | + | r.next() // success | |
| 576 | + | r.settled(t) | |
| 577 | + | r.failFor.Store(1 << 30) | |
| 578 | + | r.svc.OnOpened(context.Background(), domain.MergeRequest{Repo: r.repo}) | |
| 579 | + | <-r.passes | |
| 580 | + | for range 5 { | |
| 581 | + | r.next() | |
| 582 | + | } | |
| 583 | + | r.settled(t) | |
| 584 | + | assert.Empty(t, r.afters) | |
| 585 | + | assert.Empty(t, r.passes) | |
| 586 | + | } | |
| 587 | + | ||
| 588 | + | // stepClock is a clock the test moves. | |
| 589 | + | type stepClock struct{ at atomic.Int64 } | |
| 590 | + | ||
| 591 | + | func (c *stepClock) Now() time.Time { return time.Unix(0, c.at.Load()).UTC() } | |
| 592 | + | func (c *stepClock) advance(d time.Duration) { c.at.Add(int64(d)) } | |
| 593 | + | ||
| 594 | + | // An expired timer must not consume a replacement retry of its repository. | |
| 595 | + | func TestMergeabilityStaleTimerKeepsReplacementRetry(t *testing.T) { | |
| 596 | + | repo := name(t, "proj") | |
| 597 | + | meta := mocks.NewRepoMetaStore(t) | |
| 598 | + | meta.EXPECT().List(mock.Anything).Return(nil, nil) | |
| 599 | + | clock := &stepClock{} | |
| 600 | + | clock.advance(time.Hour) | |
| 601 | + | armed := make(chan time.Duration, 8) | |
| 602 | + | svc := usecase.NewMergeability(context.Background(), usecase.MergeabilityConfig{ | |
| 603 | + | Meta: meta, Clock: clock, Workers: 1, | |
| 604 | + | After: func(d time.Duration) <-chan time.Time { armed <- d; return make(chan time.Time) }, | |
| 605 | + | }) | |
| 606 | + | t.Cleanup(func() { svc.Close(context.Background()) }) | |
| 607 | + | svc.Start() | |
| 608 | + | ||
| 609 | + | svc.RetryForTest(repo) | |
| 610 | + | assert.Equal(t, time.Second, <-armed, "the scheduler armed the timer of retry A") | |
| 611 | + | oldDue, ok := svc.RetryDueForTest(repo) | |
| 612 | + | require.True(t, ok) | |
| 613 | + | ||
| 614 | + | svc.RetryDoneForTest(repo) | |
| 615 | + | clock.advance(10 * time.Second) | |
| 616 | + | svc.RetryForTest(repo) | |
| 617 | + | newDue, ok := svc.RetryDueForTest(repo) | |
| 618 | + | require.True(t, ok) | |
| 619 | + | require.True(t, newDue.After(oldDue)) | |
| 620 | + | ||
| 621 | + | // The old timer fires while the replacement is not yet due. | |
| 622 | + | assert.Empty(t, svc.PopDueForTest(repo, oldDue)) | |
| 623 | + | assert.Equal(t, 1, svc.ScheduledRetries()) | |
| 624 | + | // The timer armed for the replacement itself releases it. | |
| 625 | + | assert.Equal(t, []domain.RepoName{repo}, svc.PopDueForTest(repo, newDue)) | |
| 626 | + | assert.Zero(t, svc.ScheduledRetries()) | |
| 627 | + | } | |
| 628 | + | ||
| 629 | + | func reconcileMocks(t *testing.T, repo domain.RepoName) (*mocks.MergeRequestStore, *mocks.RepoMetaStore, *mocks.MergeGit, domain.MergeRequest) { | |
| 630 | + | t.Helper() | |
| 631 | + | mr := domain.MergeRequest{Repo: repo, Number: 7, Source: "feat", Target: "main", State: domain.MRStateOpen} | |
| 632 | + | store, meta, git := mocks.NewMergeRequestStore(t), mocks.NewRepoMetaStore(t), mocks.NewMergeGit(t) | |
| 633 | + | meta.EXPECT().Get(mock.Anything, repo).Return(domain.Repo{Name: repo, CreatedAt: now}, nil) | |
| 634 | + | store.EXPECT().ListOpen(mock.Anything, repo).Return([]domain.MergeRequest{mr}, nil) | |
| 635 | + | return store, meta, git, mr | |
| 636 | + | } | |
| 637 | + | ||
| 638 | + | func TestMergeabilityDisappearedBranchDeletesTheRecord(t *testing.T) { | |
| 639 | + | repo := name(t, "proj") | |
| 640 | + | store, meta, git, _ := reconcileMocks(t, repo) | |
| 641 | + | git.EXPECT().BranchHead(mock.Anything, repo, "feat").Return(domain.ObjectID{}, domain.ErrNotFound) | |
| 642 | + | git.EXPECT().BranchHead(mock.Anything, repo, "main").Return(fakeOID(t, "b"), nil) | |
| 643 | + | store.EXPECT().DeleteMergeability(mock.Anything, repo, int64(7)).Return(nil).Once() | |
| 644 | + | svc := usecase.NewMergeability(context.Background(), usecase.MergeabilityConfig{ | |
| 645 | + | Store: store, Meta: meta, Git: git, Clock: fixedClock{}, | |
| 646 | + | }) | |
| 647 | + | t.Cleanup(func() { svc.Close(context.Background()) }) | |
| 648 | + | assert.True(t, svc.ReconcileForTest(repo)) | |
| 649 | + | } | |
| 650 | + | ||
| 651 | + | func TestMergeabilityConflictMakesThePassFailAndRetries(t *testing.T) { | |
| 652 | + | repo := name(t, "proj") | |
| 653 | + | head, tip := fakeOID(t, "a"), fakeOID(t, "b") | |
| 654 | + | store, meta, git, mr := reconcileMocks(t, repo) | |
| 655 | + | meta.EXPECT().List(mock.Anything).Return(nil, nil) | |
| 656 | + | git.EXPECT().BranchHead(mock.Anything, repo, "feat").Return(head, nil) | |
| 657 | + | git.EXPECT().BranchHead(mock.Anything, repo, "main").Return(tip, nil) | |
| 658 | + | git.EXPECT().IsAncestor(mock.Anything, repo, tip, head).Return(true, nil) | |
| 659 | + | store.EXPECT().GetMergeability(mock.Anything, repo, int64(7)).Return(domain.Mergeability{}, domain.ErrNotFound) | |
| 660 | + | store.EXPECT().PutMergeability(mock.Anything, mock.Anything).Return(domain.ErrConflict) | |
| 661 | + | ||
| 662 | + | asked := make(chan time.Duration, 4) | |
| 663 | + | svc := usecase.NewMergeability(context.Background(), usecase.MergeabilityConfig{ | |
| 664 | + | Store: store, Meta: meta, Git: git, Clock: fixedClock{}, Workers: 1, | |
| 665 | + | After: func(d time.Duration) <-chan time.Time { asked <- d; return make(chan time.Time) }, | |
| 666 | + | }) | |
| 667 | + | t.Cleanup(func() { svc.Close(context.Background()) }) | |
| 668 | + | assert.False(t, svc.ReconcileForTest(repo)) | |
| 669 | + | ||
| 670 | + | svc.Start() | |
| 671 | + | svc.OnOpened(context.Background(), mr) | |
| 672 | + | assert.Equal(t, time.Second, <-asked, "the conflict scheduled a retry") | |
| 673 | + | } | |
M internal/usecase/mergerequests.go +246 -70
| @@ -8,6 +8,7 @@ import ( | |||
| 8 | 8 | "errors" | |
| 9 | 9 | "fmt" | |
| 10 | 10 | "math" | |
| 11 | + | "runtime/debug" | |
| 11 | 12 | "slices" | |
| 12 | 13 | "strconv" | |
| 13 | 14 | "strings" | |
| @@ -56,9 +57,15 @@ type MergeRequests struct { | |||
| 56 | 57 | pulls ports.PullRequestTrigger // nil: no pull_request workflows | |
| 57 | 58 | tips ports.CIRefTipStore // nil: who merges is not recorded | |
| 58 | 59 | verifier ports.CommitVerifier // nil: commits are not checked (ADR 0030) | |
| 60 | + | mergeab MergeabilityHook // nil: no mergeability | |
| 59 | 61 | ||
| 60 | 62 | mu sync.Mutex | |
| 61 | 63 | locks map[domain.RepoName]chan struct{} | |
| 64 | + | ||
| 65 | + | // after tracks the post-merge side effects running off the request path. | |
| 66 | + | after sync.WaitGroup | |
| 67 | + | // afterGate, when set by a test, runs first in the post-merge goroutine. | |
| 68 | + | afterGate func() | |
| 62 | 69 | } | |
| 63 | 70 | ||
| 64 | 71 | var ( | |
| @@ -95,13 +102,16 @@ type MergeRequestsConfig struct { | |||
| 95 | 102 | // deleted source branch) before the merge does, as a push does | |
| 96 | 103 | // (domain.CIRefTip). Optional. | |
| 97 | 104 | RefTips ports.CIRefTipStore | |
| 105 | + | // Mergeability is queued when a request is opened, closed or merged and | |
| 106 | + | // read by Get and List. Optional: without it Mergeability stays zero. | |
| 107 | + | Mergeability MergeabilityHook | |
| 98 | 108 | } | |
| 99 | 109 | ||
| 100 | 110 | // NewMergeRequests builds the merge request use cases. | |
| 101 | 111 | func NewMergeRequests(cfg MergeRequestsConfig) *MergeRequests { | |
| 102 | 112 | return &MergeRequests{ | |
| 103 | 113 | meta: cfg.Meta, store: cfg.Store, repos: cfg.Repos, git: cfg.Git, signer: cfg.Signer, | |
| 104 | - | committer: cfg.Committer, bus: cfg.Bus, clock: cfg.Clock, maint: cfg.Maint, ci: cfg.CI, checks: cfg.Checks, pulls: cfg.PullRequests, tips: cfg.RefTips, | |
| 114 | + | committer: cfg.Committer, bus: cfg.Bus, clock: cfg.Clock, maint: cfg.Maint, ci: cfg.CI, checks: cfg.Checks, pulls: cfg.PullRequests, tips: cfg.RefTips, mergeab: cfg.Mergeability, | |
| 105 | 115 | locks: make(map[domain.RepoName]chan struct{}), | |
| 106 | 116 | } | |
| 107 | 117 | } | |
| @@ -113,6 +123,21 @@ func (m *MergeRequests) WithVerifier(v ports.CommitVerifier) *MergeRequests { | |||
| 113 | 123 | return m | |
| 114 | 124 | } | |
| 115 | 125 | ||
| 126 | + | // Wait waits for the post-merge side effects still running (source branch | |
| 127 | + | // deletion, events, CI and mergeability hooks), or for ctx to end. Call it | |
| 128 | + | // before closing what they use. | |
| 129 | + | func (m *MergeRequests) Wait(ctx context.Context) { | |
| 130 | + | done := make(chan struct{}) | |
| 131 | + | go func() { | |
| 132 | + | m.after.Wait() | |
| 133 | + | close(done) | |
| 134 | + | }() | |
| 135 | + | select { | |
| 136 | + | case <-done: | |
| 137 | + | case <-ctx.Done(): | |
| 138 | + | } | |
| 139 | + | } | |
| 140 | + | ||
| 116 | 141 | // lockRepo takes the merge lock of a repository, waiting until ctx ends. The | |
| 117 | 142 | // merges of one repository, and the creation of its merge requests, run one at | |
| 118 | 143 | // a time. The returned function releases the lock. | |
| @@ -255,6 +280,9 @@ func (m *MergeRequests) Create(ctx context.Context, p domain.Principal, name dom | |||
| 255 | 280 | return domain.MergeRequest{}, err | |
| 256 | 281 | } | |
| 257 | 282 | m.bus.Publish(ctx, domain.MergeRequestOpened{MergeRequest: mr, At: now, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt}) | |
| 283 | + | if m.mergeab != nil { | |
| 284 | + | m.mergeab.OnOpened(ctx, mr) | |
| 285 | + | } | |
| 258 | 286 | if m.pulls != nil { | |
| 259 | 287 | // The commits the opening is for are the ones of now. | |
| 260 | 288 | base, _ := m.branchOrZero(ctx, name, target) | |
| @@ -335,10 +363,25 @@ func (m *MergeRequests) List(ctx context.Context, p domain.Principal, name domai | |||
| 335 | 363 | return (params.State == "" || mr.State == params.State) && (after == 0 || mr.Number < after) | |
| 336 | 364 | }, | |
| 337 | 365 | encodeMRToken) | |
| 366 | + | tips := map[string]domain.ObjectID{} // one BranchHead per distinct target | |
| 338 | 367 | for i, mr := range page.MergeRequests { | |
| 339 | 368 | if page.MergeRequests[i], err = m.withHead(ctx, mr); err != nil { | |
| 340 | 369 | return ports.MergeRequestPage{}, err | |
| 341 | 370 | } | |
| 371 | + | if m.mergeab == nil || !mr.IsOpen() { | |
| 372 | + | continue | |
| 373 | + | } | |
| 374 | + | tip, ok := tips[mr.Target] | |
| 375 | + | if !ok { | |
| 376 | + | if tip, err = m.branchOrZero(ctx, name, mr.Target); err != nil { | |
| 377 | + | return ports.MergeRequestPage{}, err | |
| 378 | + | } | |
| 379 | + | tips[mr.Target] = tip | |
| 380 | + | } | |
| 381 | + | if page.Mergeability == nil { | |
| 382 | + | page.Mergeability = map[int64]domain.Mergeability{} | |
| 383 | + | } | |
| 384 | + | page.Mergeability[mr.Number] = m.mergeab.Read(ctx, page.MergeRequests[i], tip) | |
| 342 | 385 | } | |
| 343 | 386 | return page, nil | |
| 344 | 387 | } | |
| @@ -395,6 +438,28 @@ func (m *MergeRequests) CountOpen(ctx context.Context, p domain.Principal, name | |||
| 395 | 438 | return n, nil | |
| 396 | 439 | } | |
| 397 | 440 | ||
| 441 | + | // Mergeability implements ports.MergeRequestService: the mergeability of an | |
| 442 | + | // open merge request for the current tips, queued when there is no up-to-date | |
| 443 | + | // result. It is the zero value for a request that is not open, or without a | |
| 444 | + | // mergeability service. | |
| 445 | + | func (m *MergeRequests) Mergeability(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.Mergeability, error) { | |
| 446 | + | _, mr, err := m.get(ctx, p, name, number) | |
| 447 | + | if err != nil { | |
| 448 | + | return domain.Mergeability{}, err | |
| 449 | + | } | |
| 450 | + | if m.mergeab == nil || !mr.IsOpen() { | |
| 451 | + | return domain.Mergeability{}, nil | |
| 452 | + | } | |
| 453 | + | if mr, err = m.withHead(ctx, mr); err != nil { | |
| 454 | + | return domain.Mergeability{}, err | |
| 455 | + | } | |
| 456 | + | tip, err := m.branchOrZero(ctx, name, mr.Target) | |
| 457 | + | if err != nil { | |
| 458 | + | return domain.Mergeability{}, err | |
| 459 | + | } | |
| 460 | + | return m.mergeab.Read(ctx, mr, tip), nil | |
| 461 | + | } | |
| 462 | + | ||
| 398 | 463 | func encodeMRToken(last domain.MergeRequest) string { | |
| 399 | 464 | return base64.RawURLEncoding.EncodeToString([]byte(mrTokenPrefix + strconv.FormatInt(last.Number, 10))) | |
| 400 | 465 | } | |
| @@ -465,6 +530,13 @@ func (m *MergeRequests) Get(ctx context.Context, p domain.Principal, name domain | |||
| 465 | 530 | return nil | |
| 466 | 531 | } | |
| 467 | 532 | d := ports.MergeRequestDetail{MergeRequest: mr} | |
| 533 | + | if m.mergeab != nil && mr.IsOpen() { | |
| 534 | + | tip, err := m.branchOrZero(ctx, name, mr.Target) | |
| 535 | + | if err != nil { | |
| 536 | + | return ports.MergeRequestDetail{}, err | |
| 537 | + | } | |
| 538 | + | d.Mergeability = m.mergeab.Read(ctx, mr, tip) | |
| 539 | + | } | |
| 468 | 540 | if d.Comments, err = m.store.ListComments(ctx, name, number); err != nil { | |
| 469 | 541 | return ports.MergeRequestDetail{}, fmt.Errorf("list comments of !%d in %q: %w", number, name, err) | |
| 470 | 542 | } | |
| @@ -849,30 +921,77 @@ func (m *MergeRequests) MarkRead(ctx context.Context, p domain.Principal, name d | |||
| 849 | 921 | // domain.ErrPreconditionFailed when the request is not open, the mode is not | |
| 850 | 922 | // allowed by the settings of the repository, the source branch no longer | |
| 851 | 923 | // points to the head ("branch moved, read again") or is gone, the target is | |
| 852 | - | // gone or is not an ancestor of the head (the author rebases and pushes | |
| 853 | - | // again), or the branch brings nothing. Checks come with CI (CI-1): there are | |
| 854 | - | // none yet, so the setting that lets failing checks block the merge has | |
| 855 | - | // nothing to act on. | |
| 924 | + | // gone, the mode's own condition does not hold (see below), or the branch | |
| 925 | + | // brings nothing. Checks come with CI (CI-1): there are none yet, so the | |
| 926 | + | // setting that lets failing checks block the merge has nothing to act on. | |
| 856 | 927 | // | |
| 857 | - | // A fast-forward moves the target to the head, with the old value as a | |
| 858 | - | // precondition. A squash writes one commit with the tree of the head, the | |
| 859 | - | // target as parent and the author and message of domain.NewSquashCommit, | |
| 860 | - | // signed by the instance key, and moves the target to it the same way. | |
| 928 | + | // The conditions are always checked with Git, under the lock, never from the | |
| 929 | + | // stored mergeability. A fast-forward needs the target to be an ancestor of | |
| 930 | + | // the head (the author rebases and pushes again) and moves the target to the | |
| 931 | + | // head, with the old value as a precondition. A squash and a merge commit need | |
| 932 | + | // the head to merge cleanly into the target (a behind branch is fine). A squash | |
| 933 | + | // writes one commit with the merged tree, the target as parent and the author | |
| 934 | + | // and message of domain.NewSquashCommit; a merge commit has two parents (the | |
| 935 | + | // target, then the head) and the message of domain.NewMergeCommit. Both are | |
| 936 | + | // signed by the instance key and move the target the same way. | |
| 861 | 937 | // | |
| 862 | 938 | // With DeleteSource the merge is also refused, before anything changes, when | |
| 863 | 939 | // the source is the default branch or the source or target of another open | |
| 864 | - | // request. After the merge the source is deleted if it still points to the | |
| 865 | - | // head; the PushEvent then carries the deletion too. A failed deletion is | |
| 866 | - | // logged and leaves the branch: the merge stands and SourceDeleted is false. | |
| 940 | + | // request. | |
| 941 | + | // | |
| 942 | + | // Only the merge itself runs before Merge returns, under the lock. Afterwards, | |
| 943 | + | // off the request path and without the lock: the deletion of the source branch | |
| 944 | + | // (if it still points to the head; a failure is logged and leaves the branch), | |
| 945 | + | // MergeRequestMerged then a PushEvent (which carries the deletion too), and | |
| 946 | + | // the CI and mergeability hooks. Wait waits for them. SourceDeleted therefore | |
| 947 | + | // only says that the deletion was requested. | |
| 867 | 948 | func (m *MergeRequests) Merge(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params ports.MergeParams) (ports.MergeResult, error) { | |
| 868 | - | mr, deleted, err := m.merge(ctx, p, name, number, params) | |
| 869 | - | return ports.MergeResult{MergeRequest: mr, SourceDeleted: deleted}, err | |
| 949 | + | mr, err := m.merge(ctx, p, name, number, params) | |
| 950 | + | return ports.MergeResult{MergeRequest: mr, SourceDeleted: err == nil && params.DeleteSource}, err | |
| 870 | 951 | } | |
| 871 | 952 | ||
| 872 | - | func (m *MergeRequests) merge(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params ports.MergeParams) (domain.MergeRequest, bool, error) { | |
| 953 | + | // maxConflictPathsShown is how many conflicting paths a refusal lists. | |
| 954 | + | const maxConflictPathsShown = 5 | |
| 955 | + | ||
| 956 | + | // checkMode is the authoritative per-mode check of a merge, done with Git. It | |
| 957 | + | // returns the tree a squash or merge commit gets (zero for a fast-forward). | |
| 958 | + | func (m *MergeRequests) checkMode(ctx context.Context, mr domain.MergeRequest, mode domain.MergeMode, tip, head domain.ObjectID) (domain.ObjectID, error) { | |
| 959 | + | if mode == domain.MergeModeFastForward { | |
| 960 | + | ok, err := m.git.IsAncestor(ctx, mr.Repo, tip, head) | |
| 961 | + | if err != nil { | |
| 962 | + | return domain.ObjectID{}, err | |
| 963 | + | } | |
| 964 | + | if !ok { | |
| 965 | + | return domain.ObjectID{}, fmt.Errorf("%w: the target has moved: rebase and push again, or merge with squash or a merge commit", domain.ErrPreconditionFailed) | |
| 966 | + | } | |
| 967 | + | return domain.ObjectID{}, nil | |
| 968 | + | } | |
| 969 | + | res, err := m.git.MergeTree(ctx, mr.Repo, tip, head) | |
| 970 | + | if err != nil { | |
| 971 | + | return domain.ObjectID{}, err | |
| 972 | + | } | |
| 973 | + | res = res.Bounded() | |
| 974 | + | if !res.Clean { | |
| 975 | + | what := "no common history" | |
| 976 | + | if len(res.ConflictPaths) > 0 { | |
| 977 | + | shown := res.ConflictPaths | |
| 978 | + | if len(shown) > maxConflictPathsShown { | |
| 979 | + | shown = shown[:maxConflictPathsShown] | |
| 980 | + | } | |
| 981 | + | what = strings.Join(shown, ", ") | |
| 982 | + | if len(shown) < len(res.ConflictPaths) || res.Truncated { | |
| 983 | + | what += ", ..." | |
| 984 | + | } | |
| 985 | + | } | |
| 986 | + | return domain.ObjectID{}, fmt.Errorf("%w: conflicts with %s: %s: rebase and push again", domain.ErrPreconditionFailed, mr.Target, what) | |
| 987 | + | } | |
| 988 | + | return res.Tree, nil | |
| 989 | + | } | |
| 990 | + | ||
| 991 | + | func (m *MergeRequests) merge(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params ports.MergeParams) (domain.MergeRequest, error) { | |
| 873 | 992 | repo, err := m.writable(ctx, p, name, domain.ScopeMRMerge) | |
| 874 | 993 | if err != nil { | |
| 875 | - | return domain.MergeRequest{}, false, err | |
| 994 | + | return domain.MergeRequest{}, err | |
| 876 | 995 | } | |
| 877 | 996 | mode := params.Mode | |
| 878 | 997 | if mode == "" { | |
| @@ -886,116 +1005,134 @@ func (m *MergeRequests) merge(ctx context.Context, p domain.Principal, name doma | |||
| 886 | 1005 | v.Add("head_id", "is required") | |
| 887 | 1006 | } | |
| 888 | 1007 | if err := v.Err(); err != nil { | |
| 889 | - | return domain.MergeRequest{}, false, err | |
| 1008 | + | return domain.MergeRequest{}, err | |
| 890 | 1009 | } | |
| 891 | 1010 | ||
| 892 | 1011 | unlock, err := m.lockRepo(ctx, name) | |
| 893 | 1012 | if err != nil { | |
| 894 | - | return domain.MergeRequest{}, false, err | |
| 1013 | + | return domain.MergeRequest{}, err | |
| 895 | 1014 | } | |
| 896 | - | defer unlock() | |
| 1015 | + | var once sync.Once | |
| 1016 | + | release := func() { once.Do(unlock) } | |
| 1017 | + | defer release() | |
| 897 | 1018 | ||
| 898 | 1019 | mr, err := m.store.Get(ctx, name, number) | |
| 899 | 1020 | if err != nil { | |
| 900 | - | return domain.MergeRequest{}, false, err | |
| 1021 | + | return domain.MergeRequest{}, err | |
| 901 | 1022 | } | |
| 902 | 1023 | // A merge moves the target and may delete the source: a token limited to | |
| 903 | 1024 | // branch prefixes may do so only within them. | |
| 904 | 1025 | if !p.AllowsRef("refs/heads/" + mr.Target) { | |
| 905 | - | return domain.MergeRequest{}, false, fmt.Errorf("%w: this token may not write %s", domain.ErrForbidden, mr.Target) | |
| 1026 | + | return domain.MergeRequest{}, fmt.Errorf("%w: this token may not write %s", domain.ErrForbidden, mr.Target) | |
| 906 | 1027 | } | |
| 907 | 1028 | if params.DeleteSource && !p.AllowsRef("refs/heads/"+mr.Source) { | |
| 908 | - | return domain.MergeRequest{}, false, fmt.Errorf("%w: this token may not write %s", domain.ErrForbidden, mr.Source) | |
| 1029 | + | return domain.MergeRequest{}, fmt.Errorf("%w: this token may not write %s", domain.ErrForbidden, mr.Source) | |
| 909 | 1030 | } | |
| 910 | 1031 | if !mr.IsOpen() { | |
| 911 | - | return domain.MergeRequest{}, false, fmt.Errorf("%w: !%d is %s", domain.ErrPreconditionFailed, number, mr.State) | |
| 1032 | + | return domain.MergeRequest{}, fmt.Errorf("%w: !%d is %s", domain.ErrPreconditionFailed, number, mr.State) | |
| 912 | 1033 | } | |
| 913 | 1034 | if mr.IsDraft() { | |
| 914 | - | return domain.MergeRequest{}, false, fmt.Errorf("%w: !%d is a draft: mark it ready for review first", domain.ErrPreconditionFailed, number) | |
| 1035 | + | return domain.MergeRequest{}, fmt.Errorf("%w: !%d is a draft: mark it ready for review first", domain.ErrPreconditionFailed, number) | |
| 915 | 1036 | } | |
| 916 | 1037 | // The settings are read again under the lock: a change applies at once. | |
| 917 | 1038 | repo, err = m.meta.Get(ctx, name) | |
| 918 | 1039 | if err != nil { | |
| 919 | 1040 | if errors.Is(err, domain.ErrNotFound) { | |
| 920 | - | return domain.MergeRequest{}, false, err // deleted meanwhile | |
| 1041 | + | return domain.MergeRequest{}, err // deleted meanwhile | |
| 921 | 1042 | } | |
| 922 | - | return domain.MergeRequest{}, false, fmt.Errorf("get repository %q: %w", name, err) | |
| 1043 | + | return domain.MergeRequest{}, fmt.Errorf("get repository %q: %w", name, err) | |
| 923 | 1044 | } | |
| 924 | 1045 | settings := repo.Settings | |
| 925 | 1046 | if settings.IsZero() { | |
| 926 | 1047 | settings = domain.DefaultRepoSettings() | |
| 927 | 1048 | } | |
| 928 | 1049 | if !settings.Allows(mode) { | |
| 929 | - | return domain.MergeRequest{}, false, fmt.Errorf("%w: the %s merge mode is not allowed in %s", domain.ErrPreconditionFailed, mode, name) | |
| 1050 | + | return domain.MergeRequest{}, fmt.Errorf("%w: the %s merge mode is not allowed in %s", domain.ErrPreconditionFailed, mode, name) | |
| 930 | 1051 | } | |
| 931 | 1052 | ||
| 932 | 1053 | head, err := m.branchOrZero(ctx, name, mr.Source) | |
| 933 | 1054 | switch { | |
| 934 | 1055 | case err != nil: | |
| 935 | - | return domain.MergeRequest{}, false, err | |
| 1056 | + | return domain.MergeRequest{}, err | |
| 936 | 1057 | case head.IsZero(): | |
| 937 | - | return domain.MergeRequest{}, false, fmt.Errorf("%w: the source branch %q no longer exists", domain.ErrPreconditionFailed, mr.Source) | |
| 1058 | + | return domain.MergeRequest{}, fmt.Errorf("%w: the source branch %q no longer exists", domain.ErrPreconditionFailed, mr.Source) | |
| 938 | 1059 | case head != params.HeadID: | |
| 939 | - | return domain.MergeRequest{}, false, fmt.Errorf("%w: branch moved, read again: %q is at %s, not %s", domain.ErrPreconditionFailed, mr.Source, head, params.HeadID) | |
| 1060 | + | return domain.MergeRequest{}, fmt.Errorf("%w: branch moved, read again: %q is at %s, not %s", domain.ErrPreconditionFailed, mr.Source, head, params.HeadID) | |
| 940 | 1061 | } | |
| 941 | 1062 | tip, err := m.branchOrZero(ctx, name, mr.Target) | |
| 942 | 1063 | switch { | |
| 943 | 1064 | case err != nil: | |
| 944 | - | return domain.MergeRequest{}, false, err | |
| 1065 | + | return domain.MergeRequest{}, err | |
| 945 | 1066 | case tip.IsZero(): | |
| 946 | - | return domain.MergeRequest{}, false, fmt.Errorf("%w: the target branch %q no longer exists", domain.ErrPreconditionFailed, mr.Target) | |
| 1067 | + | return domain.MergeRequest{}, fmt.Errorf("%w: the target branch %q no longer exists", domain.ErrPreconditionFailed, mr.Target) | |
| 947 | 1068 | case tip == head: | |
| 948 | - | return domain.MergeRequest{}, false, fmt.Errorf("%w: nothing to merge, %q already is at %s", domain.ErrPreconditionFailed, mr.Target, head) | |
| 1069 | + | return domain.MergeRequest{}, fmt.Errorf("%w: nothing to merge, %q already is at %s", domain.ErrPreconditionFailed, mr.Target, head) | |
| 949 | 1070 | } | |
| 950 | - | switch ok, err := m.git.IsAncestor(ctx, name, tip, head); { | |
| 951 | - | case err != nil: | |
| 952 | - | return domain.MergeRequest{}, false, err | |
| 953 | - | case !ok: | |
| 954 | - | return domain.MergeRequest{}, false, fmt.Errorf("%w: %q is not an ancestor of %q: rebase it on %q and push again", domain.ErrPreconditionFailed, mr.Target, mr.Source, mr.Target) | |
| 1071 | + | // A head behind the target (or at it) has nothing to merge, in any mode. | |
| 1072 | + | if behind, err := m.git.IsAncestor(ctx, name, head, tip); err != nil { | |
| 1073 | + | return domain.MergeRequest{}, err | |
| 1074 | + | } else if behind { | |
| 1075 | + | return domain.MergeRequest{}, fmt.Errorf("%w: nothing to merge, %q already contains %s", domain.ErrPreconditionFailed, mr.Target, head) | |
| 1076 | + | } | |
| 1077 | + | tree, err := m.checkMode(ctx, mr, mode, tip, head) | |
| 1078 | + | if err != nil { | |
| 1079 | + | return domain.MergeRequest{}, err | |
| 955 | 1080 | } | |
| 956 | 1081 | if settings.FailingChecksBlock { | |
| 957 | 1082 | if err := m.checksAllow(ctx, name, head); err != nil { | |
| 958 | - | return domain.MergeRequest{}, false, err | |
| 1083 | + | return domain.MergeRequest{}, err | |
| 959 | 1084 | } | |
| 960 | 1085 | } | |
| 961 | 1086 | if params.DeleteSource { | |
| 962 | 1087 | if err := m.checkDeletable(ctx, name, mr); err != nil { | |
| 963 | - | return domain.MergeRequest{}, false, err | |
| 1088 | + | return domain.MergeRequest{}, err | |
| 964 | 1089 | } | |
| 965 | 1090 | } | |
| 966 | 1091 | ||
| 967 | - | merged, err := m.moveTarget(ctx, mr, mode, tip, head, p) | |
| 1092 | + | merged, err := m.moveTarget(ctx, mr, mode, tip, head, tree, p) | |
| 968 | 1093 | if err != nil { | |
| 969 | - | return domain.MergeRequest{}, false, err | |
| 1094 | + | return domain.MergeRequest{}, err | |
| 970 | 1095 | } | |
| 971 | 1096 | m.recordPush(ctx, name) // the target moved, whatever happens next | |
| 972 | 1097 | mr, err = m.markMerged(ctx, name, number, mode, tip, head, merged) | |
| 973 | 1098 | if err != nil { | |
| 974 | - | return domain.MergeRequest{}, false, err | |
| 1099 | + | return domain.MergeRequest{}, err | |
| 975 | 1100 | } | |
| 976 | 1101 | log := zerowrap.FromCtx(ctx) | |
| 977 | 1102 | log.Info().Str("repo", name.String()).Int64("number", number).Str("mode", mode.String()). | |
| 978 | 1103 | Str("target", mr.Target).Str("commit", merged.String()). | |
| 979 | 1104 | Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("merge request merged") | |
| 980 | - | updates := []domain.RefUpdate{{Old: tip.String(), New: merged.String(), Ref: "refs/heads/" + mr.Target}} | |
| 981 | - | deleted := false | |
| 982 | - | if params.DeleteSource { | |
| 983 | - | // The merge is done and recorded: a failure here keeps the branch. Who | |
| 984 | - | // pushed the source goes first (the branch is gone either way for a | |
| 985 | - | // schedule; a kept branch merely has no record: not trusted). | |
| 986 | - | if err := recordRefTips(ctx, m.tips, name, []domain.RefUpdate{{Old: head.String(), New: domain.ZeroOID, Ref: "refs/heads/" + mr.Source}}, "", domain.CIPusher{}, false); err != nil { | |
| 987 | - | log.Warn().Err(err).Str("repo", name.String()).Str("branch", mr.Source).Msg("the record of the source branch was kept") | |
| 988 | - | } | |
| 989 | - | switch err := m.git.DeleteBranch(context.WithoutCancel(ctx), name, mr.Source, head); { | |
| 990 | - | case err != nil: | |
| 991 | - | log.Warn().Err(err).Str("repo", name.String()).Int64("number", number).Str("branch", mr.Source). | |
| 992 | - | Msg("source branch kept after the merge") | |
| 993 | - | default: | |
| 994 | - | deleted = true | |
| 995 | - | updates = append(updates, domain.RefUpdate{Old: head.String(), New: domain.ZeroOID, Ref: "refs/heads/" + mr.Source}) | |
| 996 | - | log.Info().Str("repo", name.String()).Int64("number", number).Str("branch", mr.Source). | |
| 997 | - | Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("source branch deleted after the merge") | |
| 1105 | + | ||
| 1106 | + | // The merge is done and recorded. The rest runs without the lock and after | |
| 1107 | + | // the caller got its answer. | |
| 1108 | + | release() | |
| 1109 | + | bg := context.WithoutCancel(ctx) | |
| 1110 | + | m.after.Add(1) | |
| 1111 | + | go func() { | |
| 1112 | + | defer m.after.Done() | |
| 1113 | + | defer func() { | |
| 1114 | + | if r := recover(); r != nil { | |
| 1115 | + | log := zerowrap.FromCtx(bg) | |
| 1116 | + | log.Error().Str("repo", name.String()).Int64("number", number). | |
| 1117 | + | Str("panic", fmt.Sprint(r)).Bytes("stack", debug.Stack()).Msg("post-merge side effects panicked") | |
| 1118 | + | } | |
| 1119 | + | }() | |
| 1120 | + | if m.afterGate != nil { | |
| 1121 | + | m.afterGate() | |
| 998 | 1122 | } | |
| 1123 | + | m.afterMerge(bg, p, repo, mr, head, tip, merged, params.DeleteSource) | |
| 1124 | + | }() | |
| 1125 | + | return mr, nil | |
| 1126 | + | } | |
| 1127 | + | ||
| 1128 | + | // afterMerge is what follows a merge off the request path: errors are logged, | |
| 1129 | + | // never returned. mr is the merged request, tip the old target and merged the | |
| 1130 | + | // new one. | |
| 1131 | + | func (m *MergeRequests) afterMerge(ctx context.Context, p domain.Principal, repo domain.Repo, mr domain.MergeRequest, head, tip, merged domain.ObjectID, deleteSource bool) { | |
| 1132 | + | name := repo.Name | |
| 1133 | + | updates := []domain.RefUpdate{{Old: tip.String(), New: merged.String(), Ref: "refs/heads/" + mr.Target}} | |
| 1134 | + | if deleteSource && m.deleteSource(ctx, p, name, mr, head) { | |
| 1135 | + | updates = append(updates, domain.RefUpdate{Old: head.String(), New: domain.ZeroOID, Ref: "refs/heads/" + mr.Source}) | |
| 999 | 1136 | } | |
| 1000 | 1137 | at := mr.UpdatedAt | |
| 1001 | 1138 | m.bus.Publish(ctx, domain.MergeRequestMerged{MergeRequest: mr, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt}) | |
| @@ -1003,14 +1140,47 @@ func (m *MergeRequests) merge(ctx context.Context, p domain.Principal, name doma | |||
| 1003 | 1140 | Repo: name, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt, Updates: updates, | |
| 1004 | 1141 | }) | |
| 1005 | 1142 | if m.pulls != nil { | |
| 1006 | - | m.pulls.OnPullRequest(ctx, name, ports.PullRequestEvent{Number: number, Action: domain.PullClosed, Head: mr.HeadID, Base: mr.BaseID, Merged: true}, p) | |
| 1143 | + | m.pulls.OnPullRequest(ctx, name, ports.PullRequestEvent{Number: mr.Number, Action: domain.PullClosed, Head: mr.HeadID, Base: mr.BaseID, Merged: true}, p) | |
| 1007 | 1144 | } | |
| 1008 | 1145 | if m.ci != nil { | |
| 1009 | 1146 | // GitHub runs the "push" workflows of a merge, with the owner as actor; | |
| 1010 | 1147 | // the run is trusted with what the merging principal may read. | |
| 1011 | 1148 | m.ci.OnMerge(ctx, name, updates, p) | |
| 1012 | 1149 | } | |
| 1013 | - | return mr, deleted, nil | |
| 1150 | + | if m.mergeab != nil { | |
| 1151 | + | m.mergeab.OnBranchesMoved(ctx, name, updates) // the other requests of the moved target | |
| 1152 | + | } | |
| 1153 | + | } | |
| 1154 | + | ||
| 1155 | + | // deleteSource deletes the source branch of a merged request if it is still | |
| 1156 | + | // head and still unused, and reports whether it did. The eligibility is checked | |
| 1157 | + | // again and the branch deleted in one critical section of the merge lock: a | |
| 1158 | + | // request opened on the branch since the merge, or a push to it, keeps it. The | |
| 1159 | + | // record of who pushed the branch goes only after a deletion, and only if it | |
| 1160 | + | // still names head. | |
| 1161 | + | func (m *MergeRequests) deleteSource(ctx context.Context, p domain.Principal, name domain.RepoName, mr domain.MergeRequest, head domain.ObjectID) bool { | |
| 1162 | + | log := zerowrap.FromCtx(ctx).With().Str("repo", name.String()).Int64("number", mr.Number).Str("branch", mr.Source).Logger() | |
| 1163 | + | unlock, err := m.lockRepo(ctx, name) | |
| 1164 | + | if err != nil { | |
| 1165 | + | log.Warn().Err(err).Msg("source branch kept after the merge") | |
| 1166 | + | return false | |
| 1167 | + | } | |
| 1168 | + | defer unlock() | |
| 1169 | + | if err := m.checkDeletable(ctx, name, mr); err != nil { | |
| 1170 | + | log.Warn().Err(err).Msg("source branch kept after the merge") | |
| 1171 | + | return false | |
| 1172 | + | } | |
| 1173 | + | if err := m.git.DeleteBranch(ctx, name, mr.Source, head); err != nil { | |
| 1174 | + | log.Warn().Err(err).Msg("source branch kept after the merge") | |
| 1175 | + | return false | |
| 1176 | + | } | |
| 1177 | + | if m.tips != nil { | |
| 1178 | + | if err := m.tips.DeleteRefTipIf(ctx, name, "refs/heads/"+mr.Source, head.String()); err != nil && !errors.Is(err, domain.ErrNotFound) { | |
| 1179 | + | log.Warn().Err(err).Msg("the record of the source branch was kept") | |
| 1180 | + | } | |
| 1181 | + | } | |
| 1182 | + | log.Info().Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("source branch deleted after the merge") | |
| 1183 | + | return true | |
| 1014 | 1184 | } | |
| 1015 | 1185 | ||
| 1016 | 1186 | // WhileOpen implements ports.PullGate: fn runs under the merge lock, so a | |
| @@ -1103,10 +1273,20 @@ func (m *MergeRequests) recordPush(ctx context.Context, name domain.RepoName) { | |||
| 1103 | 1273 | } | |
| 1104 | 1274 | ||
| 1105 | 1275 | // moveTarget moves the target branch from tip to the head (fast-forward) or to | |
| 1106 | - | // a new signed squash commit, and returns the new tip. | |
| 1107 | - | func (m *MergeRequests) moveTarget(ctx context.Context, mr domain.MergeRequest, mode domain.MergeMode, tip, head domain.ObjectID, p domain.Principal) (domain.ObjectID, error) { | |
| 1276 | + | // a new signed squash or merge commit with the given tree, and returns the new tip. | |
| 1277 | + | func (m *MergeRequests) moveTarget(ctx context.Context, mr domain.MergeRequest, mode domain.MergeMode, tip, head, tree domain.ObjectID, p domain.Principal) (domain.ObjectID, error) { | |
| 1108 | 1278 | newTip := head | |
| 1109 | - | if mode == domain.MergeModeSquash { | |
| 1279 | + | switch mode { | |
| 1280 | + | case domain.MergeModeMerge: | |
| 1281 | + | commit := domain.NewMergeCommit(mr, tree, tip, head, m.committer, m.clock.Now()) | |
| 1282 | + | armored, err := m.signer.Sign(commit.Payload()) | |
| 1283 | + | if err != nil { | |
| 1284 | + | return domain.ObjectID{}, fmt.Errorf("sign the merge commit of !%d in %q: %w", mr.Number, mr.Repo, err) | |
| 1285 | + | } | |
| 1286 | + | if newTip, err = m.git.WriteCommit(ctx, mr.Repo, commit.Signed(armored)); err != nil { | |
| 1287 | + | return domain.ObjectID{}, err | |
| 1288 | + | } | |
| 1289 | + | case domain.MergeModeSquash: | |
| 1110 | 1290 | commits, more, err := m.git.ListRange(ctx, mr.Repo, tip, head, domain.MaxSquashCommits) | |
| 1111 | 1291 | if err != nil { | |
| 1112 | 1292 | return domain.ObjectID{}, err | |
| @@ -1114,10 +1294,6 @@ func (m *MergeRequests) moveTarget(ctx context.Context, mr domain.MergeRequest, | |||
| 1114 | 1294 | if more { | |
| 1115 | 1295 | return domain.ObjectID{}, fmt.Errorf("%w: the branch has more than %d commits to squash", domain.ErrPreconditionFailed, domain.MaxSquashCommits) | |
| 1116 | 1296 | } | |
| 1117 | - | tree, err := m.git.CommitTree(ctx, mr.Repo, head) | |
| 1118 | - | if err != nil { | |
| 1119 | - | return domain.ObjectID{}, err | |
| 1120 | - | } | |
| 1121 | 1297 | commit := domain.NewSquashCommit(mr, tree, tip, commits, m.committer, m.clock.Now()) | |
| 1122 | 1298 | armored, err := m.signer.Sign(commit.Payload()) | |
| 1123 | 1299 | if err != nil { | |
M internal/usecase/mergerequests_branches_test.go +1 -0
| @@ -67,6 +67,7 @@ func TestMergeWithBranchLimitedToken(t *testing.T) { | |||
| 67 | 67 | res, err := r.mrs.Merge(r.ctx, branchMerger, r.repo, mr.Number, ports.MergeParams{HeadID: head, DeleteSource: true}) | |
| 68 | 68 | require.NoError(t, err) | |
| 69 | 69 | assert.True(t, res.SourceDeleted) | |
| 70 | + | r.mrs.Wait(r.ctx) // the deletion runs after Merge returned | |
| 70 | 71 | assert.Equal(t, head, r.branchHead("agent/claude/base")) | |
| 71 | 72 | _, err = r.git.BranchHead(r.ctx, r.repo, "agent/claude/feat") | |
| 72 | 73 | assert.ErrorIs(t, err, domain.ErrNotFound) | |
M internal/usecase/mergerequests_delete_test.go +149 -1
| @@ -3,8 +3,13 @@ | |||
| 3 | 3 | package usecase_test | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | + | "bytes" | |
| 6 | 7 | "context" | |
| 8 | + | "sync" | |
| 7 | 9 | "testing" | |
| 10 | + | "time" | |
| 11 | + | ||
| 12 | + | "github.com/bnema/zerowrap" | |
| 8 | 13 | ||
| 9 | 14 | "github.com/stretchr/testify/assert" | |
| 10 | 15 | "github.com/stretchr/testify/require" | |
| @@ -31,6 +36,7 @@ func TestMergeDeletesTheSourceBranch(t *testing.T) { | |||
| 31 | 36 | require.NoError(t, err) | |
| 32 | 37 | assert.True(t, res.SourceDeleted) | |
| 33 | 38 | assert.Equal(t, domain.MRStateMerged, res.State) | |
| 39 | + | r.mrs.Wait(r.ctx) // the deletion happens after Merge returned | |
| 34 | 40 | _, err = r.git.BranchHead(r.ctx, r.repo, "feat") | |
| 35 | 41 | assert.ErrorIs(t, err, domain.ErrNotFound, "the branch is gone") | |
| 36 | 42 | ||
| @@ -157,6 +163,7 @@ func TestCreateAfterAMergeDeletedTheBranch(t *testing.T) { | |||
| 157 | 163 | mr := r.open("feat") | |
| 158 | 164 | _, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, DeleteSource: true}) | |
| 159 | 165 | require.NoError(t, err) | |
| 166 | + | r.mrs.Wait(r.ctx) // the deletion runs after Merge returned | |
| 160 | 167 | _, err = r.mrs.Create(r.ctx, root, r.repo, ports.CreateMergeRequestParams{Title: "late", Source: "feat"}) | |
| 161 | 168 | require.ErrorIs(t, err, domain.ErrInvalid) | |
| 162 | 169 | assert.Contains(t, fieldsOf(t, err), "source_branch") | |
| @@ -192,11 +199,152 @@ func TestMergeKeepsASourceThatMovedAfterTheMerge(t *testing.T) { | |||
| 192 | 199 | ||
| 193 | 200 | res, err := mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, DeleteSource: true}) | |
| 194 | 201 | require.NoError(t, err, "the merge stands") | |
| 195 | - | assert.False(t, res.SourceDeleted) | |
| 202 | + | assert.True(t, res.SourceDeleted, "the deletion was requested") | |
| 196 | 203 | assert.Equal(t, domain.MRStateMerged, res.State) | |
| 204 | + | mrs.Wait(r.ctx) | |
| 197 | 205 | assert.Equal(t, head, r.branchHead("main")) | |
| 198 | 206 | assert.Equal(t, moved, r.branchHead("feat"), "the late commit is kept") | |
| 199 | 207 | evs := r.drain() | |
| 200 | 208 | require.Len(t, evs, 2) | |
| 201 | 209 | assert.Len(t, evs[1].(domain.PushEvent).Updates, 1, "only the target moved") | |
| 202 | 210 | } | |
| 211 | + | ||
| 212 | + | // afterMergeRig builds merge request use cases over the rig with ref-tip | |
| 213 | + | // records, whose post-merge goroutine waits for the returned release function. | |
| 214 | + | func (r *mrRig) afterMergeRig(extra func(*usecase.MergeRequestsConfig)) (mrs *usecase.MergeRequests, release func()) { | |
| 215 | + | gate := make(chan struct{}) | |
| 216 | + | cfg := usecase.MergeRequestsConfig{ | |
| 217 | + | Meta: r.store, Store: r.store.MergeRequests(), Repos: r.git, Git: r.git, Signer: nil, | |
| 218 | + | Committer: domain.Identity{Name: "ermite", Email: "ermite@git.example.org"}, | |
| 219 | + | Bus: r.bus, Clock: fixedClock{}, Maint: r.sched, RefTips: r.store.CI(), | |
| 220 | + | } | |
| 221 | + | if extra != nil { | |
| 222 | + | extra(&cfg) | |
| 223 | + | } | |
| 224 | + | mrs = usecase.NewMergeRequests(cfg) | |
| 225 | + | mrs.SetAfterMergeGate(func() { <-gate }) | |
| 226 | + | var once sync.Once | |
| 227 | + | return mrs, func() { once.Do(func() { close(gate) }) } | |
| 228 | + | } | |
| 229 | + | ||
| 230 | + | // A request opened on the source after the merge, before the deletion runs, | |
| 231 | + | // keeps the branch (GO-002). | |
| 232 | + | func TestMergeKeepsTheSourceWhenAnotherRequestOpensOnItMeanwhile(t *testing.T) { | |
| 233 | + | t.Parallel() | |
| 234 | + | r := newMRRig(t) | |
| 235 | + | r.seed() | |
| 236 | + | head := r.feature("feat") | |
| 237 | + | mr := r.open("feat") | |
| 238 | + | r.sh("checkout", "-q", "-B", "release", "main") | |
| 239 | + | r.push("release") | |
| 240 | + | mrs, release := r.afterMergeRig(nil) | |
| 241 | + | defer release() | |
| 242 | + | ||
| 243 | + | _, err := mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, DeleteSource: true}) | |
| 244 | + | require.NoError(t, err) | |
| 245 | + | _, err = mrs.Create(r.ctx, root, r.repo, ports.CreateMergeRequestParams{Title: "to release", Source: "feat", Target: "release"}) | |
| 246 | + | require.NoError(t, err, "the merge released the lock; the deletion has not run") | |
| 247 | + | release() | |
| 248 | + | mrs.Wait(r.ctx) | |
| 249 | + | assert.Equal(t, head, r.branchHead("feat"), "the branch is kept") | |
| 250 | + | } | |
| 251 | + | ||
| 252 | + | // A push that moves the source after the merge keeps both the branch and the | |
| 253 | + | // record of who pushed it (GO-003). | |
| 254 | + | func TestMergeKeepsTheSourceAndItsRecordWhenAPushMovesItMeanwhile(t *testing.T) { | |
| 255 | + | t.Parallel() | |
| 256 | + | r := newMRRig(t) | |
| 257 | + | r.seed() | |
| 258 | + | head := r.feature("feat") | |
| 259 | + | mr := r.open("feat") | |
| 260 | + | mrs, release := r.afterMergeRig(nil) | |
| 261 | + | defer release() | |
| 262 | + | ||
| 263 | + | _, err := mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, DeleteSource: true}) | |
| 264 | + | require.NoError(t, err) | |
| 265 | + | r.sh("checkout", "-q", "feat") | |
| 266 | + | moved := r.commit(author, "later.txt", "later\n", "pushed meanwhile") | |
| 267 | + | r.push("feat") | |
| 268 | + | require.NoError(t, r.store.CI().PutRefTip(r.ctx, domain.CIRefTip{Repo: r.repo, Ref: "refs/heads/feat", Sha: moved.String(), Actor: "ada"})) | |
| 269 | + | release() | |
| 270 | + | mrs.Wait(r.ctx) | |
| 271 | + | ||
| 272 | + | assert.Equal(t, moved, r.branchHead("feat"), "the branch is kept") | |
| 273 | + | tip, err := r.store.CI().GetRefTip(r.ctx, r.repo, "refs/heads/feat") | |
| 274 | + | require.NoError(t, err, "the record of the new push is kept") | |
| 275 | + | assert.Equal(t, moved.String(), tip.Sha) | |
| 276 | + | } | |
| 277 | + | ||
| 278 | + | // The record of the source goes with the branch, once it is deleted. | |
| 279 | + | func TestMergeRemovesTheSourceRecordAfterDeletingIt(t *testing.T) { | |
| 280 | + | t.Parallel() | |
| 281 | + | r := newMRRig(t) | |
| 282 | + | r.seed() | |
| 283 | + | head := r.feature("feat") | |
| 284 | + | mr := r.open("feat") | |
| 285 | + | mrs, release := r.afterMergeRig(nil) | |
| 286 | + | release() | |
| 287 | + | require.NoError(t, r.store.CI().PutRefTip(r.ctx, domain.CIRefTip{Repo: r.repo, Ref: "refs/heads/feat", Sha: head.String(), Actor: "ada"})) | |
| 288 | + | ||
| 289 | + | _, err := mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, DeleteSource: true}) | |
| 290 | + | require.NoError(t, err) | |
| 291 | + | mrs.Wait(r.ctx) | |
| 292 | + | _, err = r.git.BranchHead(r.ctx, r.repo, "feat") | |
| 293 | + | require.ErrorIs(t, err, domain.ErrNotFound) | |
| 294 | + | _, err = r.store.CI().GetRefTip(r.ctx, r.repo, "refs/heads/feat") | |
| 295 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| 296 | + | } | |
| 297 | + | ||
| 298 | + | // panicHooks is a CI trigger whose merge hook panics. | |
| 299 | + | type panicHooks struct{} | |
| 300 | + | ||
| 301 | + | func (panicHooks) OnPush(context.Context, domain.RepoName, []domain.RefUpdate, domain.Principal) {} | |
| 302 | + | func (panicHooks) OnMerge(context.Context, domain.RepoName, []domain.RefUpdate, domain.Principal) { | |
| 303 | + | panic("boom from ci") | |
| 304 | + | } | |
| 305 | + | ||
| 306 | + | func TestMergeLogsAPanickingSideEffectAndWaitReturns(t *testing.T) { | |
| 307 | + | t.Parallel() | |
| 308 | + | r := newMRRig(t) | |
| 309 | + | r.seed() | |
| 310 | + | head := r.feature("feat") | |
| 311 | + | mr := r.open("feat") | |
| 312 | + | mrs, release := r.afterMergeRig(func(c *usecase.MergeRequestsConfig) { c.CI = panicHooks{} }) | |
| 313 | + | release() | |
| 314 | + | var logs bytes.Buffer | |
| 315 | + | ctx := zerowrap.WithCtx(r.ctx, zerowrap.New(zerowrap.Config{Level: "info", Format: "json", Output: &logs})) | |
| 316 | + | ||
| 317 | + | _, err := mrs.Merge(ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head}) | |
| 318 | + | require.NoError(t, err) | |
| 319 | + | waited := make(chan struct{}) | |
| 320 | + | go func() { mrs.Wait(context.Background()); close(waited) }() | |
| 321 | + | select { | |
| 322 | + | case <-waited: | |
| 323 | + | case <-time.After(10 * time.Second): | |
| 324 | + | t.Fatal("Wait did not return after a panicking side effect") | |
| 325 | + | } | |
| 326 | + | assert.Contains(t, logs.String(), "post-merge side effects panicked") | |
| 327 | + | assert.Contains(t, logs.String(), "boom from ci") | |
| 328 | + | } | |
| 329 | + | ||
| 330 | + | // A head behind the target has nothing to merge, whatever the mode (GO-007). | |
| 331 | + | func TestMergeRefusesAHeadBehindTheTarget(t *testing.T) { | |
| 332 | + | t.Parallel() | |
| 333 | + | for _, mode := range []domain.MergeMode{domain.MergeModeSquash, domain.MergeModeMerge, domain.MergeModeFastForward} { | |
| 334 | + | t.Run(string(mode), func(t *testing.T) { | |
| 335 | + | t.Parallel() | |
| 336 | + | r := newMRRig(t) | |
| 337 | + | main := r.seed() | |
| 338 | + | r.sh("checkout", "-q", "-B", "old", "main~1") | |
| 339 | + | r.push("old") | |
| 340 | + | old := r.branchHead("old") | |
| 341 | + | require.NotEqual(t, main, old) | |
| 342 | + | mr := r.open("old") | |
| 343 | + | ||
| 344 | + | _, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{Mode: mode, HeadID: old}) | |
| 345 | + | require.ErrorIs(t, err, domain.ErrPreconditionFailed) | |
| 346 | + | assert.Contains(t, err.Error(), "nothing to merge") | |
| 347 | + | assert.Equal(t, main, r.branchHead("main"), "no commit was made") | |
| 348 | + | }) | |
| 349 | + | } | |
| 350 | + | } | |
M internal/usecase/mergerequests_integration_test.go +11 -6
| @@ -207,6 +207,7 @@ func (r *mrRig) open(branch string) domain.MergeRequest { | |||
| 207 | 207 | // subscriber before Publish returns: whatever a returned call published is | |
| 208 | 208 | // already in the channel. | |
| 209 | 209 | func (r *mrRig) drain() []domain.Event { | |
| 210 | + | r.mrs.Wait(r.ctx) // the events of a merge are published after it returned | |
| 210 | 211 | var out []domain.Event | |
| 211 | 212 | for { | |
| 212 | 213 | select { | |
| @@ -959,11 +960,10 @@ func TestMergeRefusals(t *testing.T) { | |||
| 959 | 960 | r.push("main") | |
| 960 | 961 | tip := r.branchHead("main") | |
| 961 | 962 | ||
| 962 | - | for _, mode := range []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash} { | |
| 963 | - | _, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, Mode: mode}) | |
| 964 | - | require.ErrorIs(t, err, domain.ErrPreconditionFailed, mode) | |
| 965 | - | assert.Contains(t, err.Error(), "rebase", mode) | |
| 966 | - | } | |
| 963 | + | // Only a fast-forward needs the target to be an ancestor. | |
| 964 | + | _, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, Mode: domain.MergeModeFastForward}) | |
| 965 | + | require.ErrorIs(t, err, domain.ErrPreconditionFailed) | |
| 966 | + | assert.Contains(t, err.Error(), "the target has moved: rebase and push again") | |
| 967 | 967 | assert.Equal(t, tip, r.branchHead("main")) | |
| 968 | 968 | d, err := r.mrs.Get(r.ctx, mrM, r.repo, mr.Number, ports.GetMergeRequestOptions{}) | |
| 969 | 969 | require.NoError(t, err) | |
| @@ -1047,8 +1047,13 @@ func TestConcurrentMergesSerialize(t *testing.T) { | |||
| 1047 | 1047 | const n = 6 | |
| 1048 | 1048 | heads := map[int64]domain.ObjectID{} | |
| 1049 | 1049 | for i := range n { | |
| 1050 | + | // Every branch writes the same file differently: once one is merged the | |
| 1051 | + | // others neither fast-forward nor merge cleanly. | |
| 1050 | 1052 | b := fmt.Sprintf("f%d", i) | |
| 1051 | - | heads[r.openFeature(b).Number] = r.branchHead(b) | |
| 1053 | + | r.sh("checkout", "-q", "-B", b, "main") | |
| 1054 | + | r.commit(author, "same.txt", b+"\n", "add "+b) | |
| 1055 | + | r.push(b) | |
| 1056 | + | heads[r.open(b).Number] = r.branchHead(b) | |
| 1052 | 1057 | } | |
| 1053 | 1058 | ||
| 1054 | 1059 | type outcome struct { | |
A internal/usecase/mergerequests_merge_test.go +293 -0
| @@ -0,0 +1,293 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package usecase_test | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "bytes" | |
| 7 | + | "context" | |
| 8 | + | "crypto/ed25519" | |
| 9 | + | "strings" | |
| 10 | + | "sync" | |
| 11 | + | "testing" | |
| 12 | + | "time" | |
| 13 | + | ||
| 14 | + | "github.com/stretchr/testify/assert" | |
| 15 | + | "github.com/stretchr/testify/require" | |
| 16 | + | ||
| 17 | + | "bnema.dev/ermite/internal/adapters/sshsig" | |
| 18 | + | "bnema.dev/ermite/internal/domain" | |
| 19 | + | "bnema.dev/ermite/internal/ports" | |
| 20 | + | "bnema.dev/ermite/internal/usecase" | |
| 21 | + | ) | |
| 22 | + | ||
| 23 | + | // gateHooks is a CI and a pull_request trigger that block on a channel, to | |
| 24 | + | // tell what runs before Merge returns. | |
| 25 | + | type gateHooks struct { | |
| 26 | + | release chan struct{} | |
| 27 | + | mu sync.Mutex | |
| 28 | + | calls []string | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | func newGateHooks() *gateHooks { return &gateHooks{release: make(chan struct{})} } | |
| 32 | + | ||
| 33 | + | func (g *gateHooks) note(s string) { | |
| 34 | + | <-g.release | |
| 35 | + | g.mu.Lock() | |
| 36 | + | defer g.mu.Unlock() | |
| 37 | + | g.calls = append(g.calls, s) | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | func (g *gateHooks) ran() []string { | |
| 41 | + | g.mu.Lock() | |
| 42 | + | defer g.mu.Unlock() | |
| 43 | + | return append([]string(nil), g.calls...) | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | func (g *gateHooks) OnPush(context.Context, domain.RepoName, []domain.RefUpdate, domain.Principal) {} | |
| 47 | + | ||
| 48 | + | func (g *gateHooks) OnMerge(context.Context, domain.RepoName, []domain.RefUpdate, domain.Principal) { | |
| 49 | + | g.note("ci") | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | // OnPullRequest blocks for the closing of a request only: Create reports the | |
| 53 | + | // opening on the request path. | |
| 54 | + | func (g *gateHooks) OnPullRequest(_ context.Context, _ domain.RepoName, ev ports.PullRequestEvent, _ domain.Principal) { | |
| 55 | + | if ev.Action == domain.PullClosed { | |
| 56 | + | g.note("pulls") | |
| 57 | + | } | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | // gated builds merge request use cases over the rig whose CI and pull hooks | |
| 61 | + | // block until g.release is closed. | |
| 62 | + | func (r *mrRig) gated(g *gateHooks) *usecase.MergeRequests { | |
| 63 | + | signer, err := sshsig.New(bytes.Repeat([]byte{9}, ed25519.SeedSize)) | |
| 64 | + | require.NoError(r.t, err) | |
| 65 | + | return usecase.NewMergeRequests(usecase.MergeRequestsConfig{ | |
| 66 | + | Meta: r.store, Store: r.store.MergeRequests(), Repos: r.git, Git: r.git, Signer: signer, | |
| 67 | + | Committer: domain.Identity{Name: "ermite", Email: "ermite@git.example.org"}, | |
| 68 | + | Bus: r.bus, Clock: fixedClock{}, Maint: r.sched, CI: g, PullRequests: g, | |
| 69 | + | }) | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | // behind moves main on with a commit of its own (a file the feature does not | |
| 73 | + | // touch), and returns the new tip. | |
| 74 | + | func (r *mrRig) behind() domain.ObjectID { | |
| 75 | + | r.sh("checkout", "-q", "main") | |
| 76 | + | r.commit(author, "news.txt", "n\n", "main moved") | |
| 77 | + | r.push("main") | |
| 78 | + | return r.branchHead("main") | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | func TestMergeModesWhenBehind(t *testing.T) { | |
| 82 | + | t.Parallel() | |
| 83 | + | t.Run("fast-forward is refused", func(t *testing.T) { | |
| 84 | + | t.Parallel() | |
| 85 | + | r := newMRRig(t) | |
| 86 | + | r.seed() | |
| 87 | + | head := r.feature("feat") | |
| 88 | + | mr := r.open("feat") | |
| 89 | + | tip := r.behind() | |
| 90 | + | _, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head}) | |
| 91 | + | require.ErrorIs(t, err, domain.ErrPreconditionFailed) | |
| 92 | + | assert.Contains(t, err.Error(), "the target has moved: rebase and push again, or merge with squash or a merge commit") | |
| 93 | + | assert.Equal(t, tip, r.branchHead("main")) | |
| 94 | + | }) | |
| 95 | + | ||
| 96 | + | t.Run("squash merges the merged tree", func(t *testing.T) { | |
| 97 | + | t.Parallel() | |
| 98 | + | r := newMRRig(t) | |
| 99 | + | r.seed() | |
| 100 | + | head := r.feature("feat") | |
| 101 | + | mr := r.open("feat") | |
| 102 | + | tip := r.behind() | |
| 103 | + | res, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, Mode: domain.MergeModeSquash}) | |
| 104 | + | require.NoError(t, err) | |
| 105 | + | sq := res.MergedCommitID.String() | |
| 106 | + | g := func(args ...string) string { return r.run(r.bare(), r.env(author), args...) } | |
| 107 | + | assert.Equal(t, tip.String(), g("rev-parse", sq+"^"), "the current tip is the parent") | |
| 108 | + | files := g("ls-tree", "-r", "--name-only", sq) | |
| 109 | + | assert.Contains(t, files, "news.txt", "the target's own change is kept") | |
| 110 | + | assert.Contains(t, files, "c.txt", "the branch's change is there") | |
| 111 | + | r.verifyCommit(sq) | |
| 112 | + | }) | |
| 113 | + | ||
| 114 | + | t.Run("merge commit has two parents, the merge tree and a good signature", func(t *testing.T) { | |
| 115 | + | t.Parallel() | |
| 116 | + | r := newMRRig(t) | |
| 117 | + | r.seed() | |
| 118 | + | head := r.feature("feat") | |
| 119 | + | mr := r.open("feat") | |
| 120 | + | tip := r.behind() | |
| 121 | + | want := r.run(r.bare(), r.env(author), "merge-tree", "--write-tree", tip.String(), head.String()) | |
| 122 | + | res, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, Mode: domain.MergeModeMerge}) | |
| 123 | + | require.NoError(t, err) | |
| 124 | + | assert.Equal(t, domain.MergeModeMerge, res.MergeMode) | |
| 125 | + | assert.Equal(t, tip, res.BaseID) | |
| 126 | + | id := res.MergedCommitID.String() | |
| 127 | + | assert.Equal(t, id, r.branchHead("main").String()) | |
| 128 | + | g := func(args ...string) string { return r.run(r.bare(), r.env(author), args...) } | |
| 129 | + | assert.Equal(t, tip.String()+" "+head.String(), g("log", "-1", "--format=%P", id), "parents: target, head") | |
| 130 | + | assert.Equal(t, strings.TrimSpace(want), g("rev-parse", id+"^{tree}")) | |
| 131 | + | assert.Equal(t, "Merge branch 'feat' into main (!1)\n\nAdd feat", g("log", "-1", "--format=%B", id)) | |
| 132 | + | assert.Equal(t, "ermite <ermite@git.example.org>", g("log", "-1", "--format=%an <%ae>", id)) | |
| 133 | + | assert.Equal(t, "ermite <ermite@git.example.org>", g("log", "-1", "--format=%cn <%ce>", id)) | |
| 134 | + | r.verifyCommit(id) | |
| 135 | + | }) | |
| 136 | + | ||
| 137 | + | t.Run("merge commit when up to date", func(t *testing.T) { | |
| 138 | + | t.Parallel() | |
| 139 | + | r := newMRRig(t) | |
| 140 | + | main := r.seed() | |
| 141 | + | head := r.feature("feat") | |
| 142 | + | mr := r.open("feat") | |
| 143 | + | res, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, Mode: domain.MergeModeMerge}) | |
| 144 | + | require.NoError(t, err) | |
| 145 | + | id := res.MergedCommitID.String() | |
| 146 | + | g := func(args ...string) string { return r.run(r.bare(), r.env(author), args...) } | |
| 147 | + | assert.Equal(t, main.String()+" "+head.String(), g("log", "-1", "--format=%P", id)) | |
| 148 | + | assert.Equal(t, g("rev-parse", head.String()+"^{tree}"), g("rev-parse", id+"^{tree}")) | |
| 149 | + | }) | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | func TestMergeRefusedOnConflictAndNoCommonHistory(t *testing.T) { | |
| 153 | + | t.Parallel() | |
| 154 | + | conflict := func(t *testing.T) (*mrRig, domain.MergeRequest, domain.ObjectID, domain.ObjectID) { | |
| 155 | + | r := newMRRig(t) | |
| 156 | + | r.seed() | |
| 157 | + | head := r.feature("feat") // changes line 2 of a.txt | |
| 158 | + | mr := r.open("feat") | |
| 159 | + | r.sh("checkout", "-q", "main") | |
| 160 | + | r.commit(author, "a.txt", "one\nTWO\nthree\n", "main changes a too") | |
| 161 | + | r.push("main") | |
| 162 | + | return r, mr, head, r.branchHead("main") | |
| 163 | + | } | |
| 164 | + | for _, mode := range []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash, domain.MergeModeMerge} { | |
| 165 | + | t.Run("conflict "+string(mode), func(t *testing.T) { | |
| 166 | + | t.Parallel() | |
| 167 | + | r, mr, head, tip := conflict(t) | |
| 168 | + | _, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, Mode: mode}) | |
| 169 | + | require.ErrorIs(t, err, domain.ErrPreconditionFailed) | |
| 170 | + | if mode != domain.MergeModeFastForward { | |
| 171 | + | assert.Contains(t, err.Error(), "conflicts with main: a.txt: rebase and push again") | |
| 172 | + | } | |
| 173 | + | assert.Equal(t, tip, r.branchHead("main")) | |
| 174 | + | d, err := r.mrs.Get(r.ctx, mrM, r.repo, mr.Number, ports.GetMergeRequestOptions{}) | |
| 175 | + | require.NoError(t, err) | |
| 176 | + | assert.Equal(t, domain.MRStateOpen, d.MergeRequest.State) | |
| 177 | + | }) | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | for _, mode := range []domain.MergeMode{domain.MergeModeSquash, domain.MergeModeMerge} { | |
| 181 | + | t.Run("no common history "+string(mode), func(t *testing.T) { | |
| 182 | + | t.Parallel() | |
| 183 | + | r := newMRRig(t) | |
| 184 | + | r.seed() | |
| 185 | + | r.sh("checkout", "-q", "--orphan", "island") | |
| 186 | + | r.commit(author, "island.txt", "i\n", "an unrelated root") | |
| 187 | + | r.push("island") | |
| 188 | + | head := r.branchHead("island") | |
| 189 | + | mr, err := r.mrs.Create(r.ctx, root, r.repo, ports.CreateMergeRequestParams{Title: "island", Source: "island"}) | |
| 190 | + | if err != nil { | |
| 191 | + | t.Skipf("a request without common history cannot be opened: %v", err) | |
| 192 | + | } | |
| 193 | + | tip := r.branchHead("main") | |
| 194 | + | _, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, Mode: mode}) | |
| 195 | + | require.ErrorIs(t, err, domain.ErrPreconditionFailed) | |
| 196 | + | assert.Contains(t, err.Error(), "no common history") | |
| 197 | + | assert.Equal(t, tip, r.branchHead("main")) | |
| 198 | + | }) | |
| 199 | + | } | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | func TestMergeReturnsBeforeTheSideEffects(t *testing.T) { | |
| 203 | + | t.Parallel() | |
| 204 | + | r := newMRRig(t) | |
| 205 | + | r.seed() | |
| 206 | + | head := r.feature("feat") | |
| 207 | + | mr := r.open("feat") | |
| 208 | + | r.drain() | |
| 209 | + | g := newGateHooks() | |
| 210 | + | mrs := r.gated(g) | |
| 211 | + | ||
| 212 | + | done := make(chan error, 1) | |
| 213 | + | go func() { | |
| 214 | + | _, err := mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, DeleteSource: true}) | |
| 215 | + | done <- err | |
| 216 | + | }() | |
| 217 | + | select { | |
| 218 | + | case err := <-done: | |
| 219 | + | require.NoError(t, err, "Merge does not wait for the blocked hooks") | |
| 220 | + | case <-time.After(10 * time.Second): | |
| 221 | + | t.Fatal("Merge waited for the side effects") | |
| 222 | + | } | |
| 223 | + | // The merge is complete and recorded though the hooks have not run. | |
| 224 | + | d, err := mrs.Get(r.ctx, mrM, r.repo, mr.Number, ports.GetMergeRequestOptions{}) | |
| 225 | + | require.NoError(t, err) | |
| 226 | + | assert.Equal(t, domain.MRStateMerged, d.MergeRequest.State) | |
| 227 | + | assert.Equal(t, d.MergeRequest.MergedCommitID, r.branchHead("main")) | |
| 228 | + | assert.Empty(t, g.ran()) | |
| 229 | + | ||
| 230 | + | // Wait waits for them: it times out while they are blocked, then returns. | |
| 231 | + | ctx, cancel := context.WithTimeout(r.ctx, 50*time.Millisecond) | |
| 232 | + | start := time.Now() | |
| 233 | + | mrs.Wait(ctx) | |
| 234 | + | cancel() | |
| 235 | + | assert.GreaterOrEqual(t, time.Since(start), 40*time.Millisecond, "Wait waits for the pending side effects") | |
| 236 | + | ||
| 237 | + | waited := make(chan struct{}) | |
| 238 | + | go func() { mrs.Wait(context.Background()); close(waited) }() | |
| 239 | + | select { | |
| 240 | + | case <-waited: | |
| 241 | + | t.Fatal("Wait returned with side effects pending") | |
| 242 | + | case <-time.After(50 * time.Millisecond): | |
| 243 | + | } | |
| 244 | + | close(g.release) | |
| 245 | + | select { | |
| 246 | + | case <-waited: | |
| 247 | + | case <-time.After(10 * time.Second): | |
| 248 | + | t.Fatal("Wait did not return after the side effects ended") | |
| 249 | + | } | |
| 250 | + | assert.ElementsMatch(t, []string{"ci", "pulls"}, g.ran()) | |
| 251 | + | _, err = r.git.BranchHead(r.ctx, r.repo, "feat") | |
| 252 | + | assert.ErrorIs(t, err, domain.ErrNotFound, "the source was deleted") | |
| 253 | + | ||
| 254 | + | // Merged, then pushed, with the deletion in the push. | |
| 255 | + | evs := r.drain() | |
| 256 | + | require.Len(t, evs, 2) | |
| 257 | + | assert.IsType(t, domain.MergeRequestMerged{}, evs[0]) | |
| 258 | + | push, ok := evs[1].(domain.PushEvent) | |
| 259 | + | require.True(t, ok) | |
| 260 | + | assert.Len(t, push.Updates, 2) | |
| 261 | + | } | |
| 262 | + | ||
| 263 | + | func TestMergeReleasesTheLockBeforeTheSideEffects(t *testing.T) { | |
| 264 | + | t.Parallel() | |
| 265 | + | r := newMRRig(t) | |
| 266 | + | r.seed() | |
| 267 | + | head := r.feature("feat") | |
| 268 | + | mr := r.open("feat") | |
| 269 | + | r.sh("checkout", "-q", "main") | |
| 270 | + | other := r.openFeature("other") | |
| 271 | + | g := newGateHooks() | |
| 272 | + | defer func() { | |
| 273 | + | close(g.release) | |
| 274 | + | }() | |
| 275 | + | mrs := r.gated(g) | |
| 276 | + | ||
| 277 | + | _, err := mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head}) | |
| 278 | + | require.NoError(t, err) | |
| 279 | + | t.Cleanup(func() { mrs.Wait(context.Background()) }) | |
| 280 | + | require.Empty(t, g.ran(), "the side effects are still blocked") | |
| 281 | + | ||
| 282 | + | // Create and another Merge need the repository lock: they must finish. | |
| 283 | + | ctx, cancel := context.WithTimeout(r.ctx, 10*time.Second) | |
| 284 | + | defer cancel() | |
| 285 | + | r.sh("checkout", "-q", "-B", "third", "main") | |
| 286 | + | r.commit(author, "third.txt", "t\n", "third") | |
| 287 | + | r.push("third") | |
| 288 | + | _, err = mrs.Create(ctx, root, r.repo, ports.CreateMergeRequestParams{Title: "third", Source: "third"}) | |
| 289 | + | require.NoError(t, err, "Create is not blocked by the side effects of a merge") | |
| 290 | + | _, err = mrs.Merge(ctx, mrM, r.repo, other.Number, ports.MergeParams{HeadID: r.branchHead("other"), Mode: domain.MergeModeMerge}) | |
| 291 | + | require.NoError(t, err, "a second merge is not blocked either") | |
| 292 | + | assert.Empty(t, g.ran()) | |
| 293 | + | } | |
M internal/usecase/pushes.go +23 -11
| @@ -20,17 +20,18 @@ const pushRetries = 5 | |||
| 20 | 20 | ||
| 21 | 21 | // Pushes are the use cases around a Git push. | |
| 22 | 22 | type Pushes struct { | |
| 23 | - | meta ports.RepoMetaStore | |
| 24 | - | repos *Repos | |
| 25 | - | git ports.GitRepos | |
| 26 | - | bus ports.EventBus | |
| 27 | - | clock ports.Clock | |
| 28 | - | maint ports.MaintenanceScheduler | |
| 29 | - | auth ports.Revalidator | |
| 30 | - | fresh ports.RepoFreshener // nil: the reader needs no telling | |
| 31 | - | ci ports.WorkflowTrigger // nil: no CI | |
| 32 | - | wf ports.WorkflowGuard // nil: pushes by job tokens are refused | |
| 33 | - | tips ports.CIRefTipStore // nil: who pushes a ref is not recorded | |
| 23 | + | meta ports.RepoMetaStore | |
| 24 | + | repos *Repos | |
| 25 | + | git ports.GitRepos | |
| 26 | + | bus ports.EventBus | |
| 27 | + | clock ports.Clock | |
| 28 | + | maint ports.MaintenanceScheduler | |
| 29 | + | auth ports.Revalidator | |
| 30 | + | fresh ports.RepoFreshener // nil: the reader needs no telling | |
| 31 | + | ci ports.WorkflowTrigger // nil: no CI | |
| 32 | + | wf ports.WorkflowGuard // nil: pushes by job tokens are refused | |
| 33 | + | tips ports.CIRefTipStore // nil: who pushes a ref is not recorded | |
| 34 | + | mergeab ports.MergeabilityTrigger // nil: mergeability is not tracked | |
| 34 | 35 | } | |
| 35 | 36 | ||
| 36 | 37 | var ( | |
| @@ -59,6 +60,14 @@ func (u *Pushes) WithWorkflowTrigger(t ports.WorkflowTrigger) *Pushes { | |||
| 59 | 60 | return u | |
| 60 | 61 | } | |
| 61 | 62 | ||
| 63 | + | // WithMergeability makes Record queue the mergeability checks of the merge | |
| 64 | + | // requests whose branches the push moved. Call it before the first push; it | |
| 65 | + | // returns u. | |
| 66 | + | func (u *Pushes) WithMergeability(t ports.MergeabilityTrigger) *Pushes { | |
| 67 | + | u.mergeab = t | |
| 68 | + | return u | |
| 69 | + | } | |
| 70 | + | ||
| 62 | 71 | // WithRefTips makes Check record who is about to move each branch and tag | |
| 63 | 72 | // (domain.CIRefTip), before Git moves it. Call it before the first push; it | |
| 64 | 73 | // returns u. | |
| @@ -233,6 +242,9 @@ func (u *Pushes) Record(ctx context.Context, p domain.Principal, name domain.Rep | |||
| 233 | 242 | if u.ci != nil { | |
| 234 | 243 | u.ci.OnPush(ctx, name, updates, p) // queues only: never slows or fails the push | |
| 235 | 244 | } | |
| 245 | + | if u.mergeab != nil { | |
| 246 | + | u.mergeab.OnBranchesMoved(ctx, name, updates) // queues only | |
| 247 | + | } | |
| 236 | 248 | u.bus.Publish(ctx, domain.PushEvent{ | |
| 237 | 249 | Repo: name, Updates: append([]domain.RefUpdate(nil), updates...), At: now, | |
| 238 | 250 | Visibility: repo.Visibility, CreatedAt: repo.CreatedAt, | |
M proto/ermite/storage/merge_request.proto +28 -0
| @@ -15,6 +15,7 @@ enum MergeMode { | |||
| 15 | 15 | MERGE_MODE_UNSPECIFIED = 0; | |
| 16 | 16 | MERGE_MODE_FAST_FORWARD = 1; | |
| 17 | 17 | MERGE_MODE_SQUASH = 2; | |
| 18 | + | MERGE_MODE_MERGE = 3; | |
| 18 | 19 | } | |
| 19 | 20 | ||
| 20 | 21 | // RepoSettings are the merge settings of a repository, stored inside Repo. | |
| @@ -24,6 +25,11 @@ message RepoSettings { | |||
| 24 | 25 | bool failing_checks_block = 2; | |
| 25 | 26 | // Who may write issues; unspecified reads as off. | |
| 26 | 27 | IssuesMode issues = 3; | |
| 28 | + | // Set on every write since merge commits exist: the modes are the explicit | |
| 29 | + | // choice of the owner. When false (settings stored before), exactly | |
| 30 | + | // {fast-forward, squash} was the old default and gains MERGE_MODE_MERGE on | |
| 31 | + | // read. | |
| 32 | + | bool merge_modes_v2 = 4; | |
| 27 | 33 | } | |
| 28 | 34 | ||
| 29 | 35 | // IssuesMode says who may write issues in a repository. | |
| @@ -104,3 +110,25 @@ message Comment { | |||
| 104 | 110 | message Sequence { | |
| 105 | 111 | int64 last = 1; | |
| 106 | 112 | } | |
| 113 | + | ||
| 114 | + | // MergeabilityState is the outcome of a mergeability check. "Checking" is | |
| 115 | + | // never stored. | |
| 116 | + | enum MergeabilityState { | |
| 117 | + | MERGEABILITY_STATE_UNSPECIFIED = 0; | |
| 118 | + | MERGEABILITY_STATE_UP_TO_DATE = 1; | |
| 119 | + | MERGEABILITY_STATE_BEHIND = 2; | |
| 120 | + | MERGEABILITY_STATE_CONFLICT = 3; | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | // Mergeability is stored under the key "mrmerge/<repo>/<number:010d>": a | |
| 124 | + | // derived, discardable record of an open merge request. | |
| 125 | + | message Mergeability { | |
| 126 | + | MergeabilityState state = 1; | |
| 127 | + | string target_id = 2; | |
| 128 | + | string head_id = 3; | |
| 129 | + | int32 behind = 4; | |
| 130 | + | bool behind_capped = 5; | |
| 131 | + | repeated string conflict_paths = 6; | |
| 132 | + | bool conflict_paths_truncated = 7; | |
| 133 | + | google.protobuf.Timestamp checked_at = 8; | |
| 134 | + | } | |
M skills/ermite/CLI.md +5 -4
| @@ -11,7 +11,7 @@ Errors are one line on standard error (`ermite: ...`) with exit status 1. `--jso | |||
| 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] [--failing-checks-block=false]` | `repo:admin` | only the given flags change; `--description ""` clears it; the branch must exist | | |
| 14 | + | | `repo edit <name> [--description TEXT] [--visibility V] [--default-branch B] [--merge-modes fast-forward,squash,merge] [--failing-checks-block=false]` | `repo:admin` | only the given flags change; every merge mode is allowed by default, list the ones to keep; `--description ""` clears it; the branch must exist | | |
| 15 | 15 | | `repo delete <name> [--yes]` | `repo:admin` | | | |
| 16 | 16 | ||
| 17 | 17 | Repository JSON: `name`, `description`, `visibility`, `default_branch`, `created_at`, `pushed_at` (null before the first push), `merge_modes`, `failing_checks_block`. | |
| @@ -56,12 +56,12 @@ A token can never grant more than the token that creates it. `--repo` (repeatabl | |||
| 56 | 56 | |---|---|---| | |
| 57 | 57 | | `mr create <repo> --source B --title T [--target B] [--description TEXT\|-]` | `mr:write` | target defaults to the default branch; title of 120 characters max; `-` reads standard input | | |
| 58 | 58 | | `mr list <repo> [--state open\|merged\|closed\|all] [--json]` | none | default `open` | | |
| 59 | - | | `mr show <repo> <n> [--diff] [--since-last-read] [--mark-read] [--json]` | `mr:write` for `--mark-read` | commits, changed files and comments; `--diff` adds hunks with line numbers | | |
| 59 | + | | `mr show <repo> <n> [--diff] [--since-last-read] [--mark-read] [--json]` | `mr:write` for `--mark-read` | commits, changed files and comments; `--diff` adds hunks with line numbers; the `mergeability` line (JSON `mergeability`, null unless open) is `up_to_date`, `behind` (N commits, merges cleanly), `conflict` (paths) or `checking` | | |
| 60 | 60 | | `mr comment <repo> <n> -m TEXT\|- [--path P --line N\|A-B --side old\|new] [--reply ID] [--approve\|--request-changes]` | `mr:write`; verdicts `mr:review` | Markdown note (8000 characters max); the lines must be in the current diff on one side; a deleted file uses its old path; `--reply` takes a root note (`12`, `#12` or `^12`), inherits its anchor and cannot carry a verdict; a verdict note may have no message | | |
| 61 | 61 | | `mr edit-comment <repo> <n> <comment-id> -m TEXT\|-` | `mr:write` | author only, open merge request; the verdict is unchanged | | |
| 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 | - | | `mr merge <repo> <n> --head <full-id> [--squash] [--delete-branch]` | `mr:merge` | refused if the branch moved or the target is not an ancestor of the head; `--delete-branch` deletes the source after the merge (refused for the default branch or a branch another open request uses; JSON adds `source_branch_deleted`) | | |
| 64 | + | | `mr merge <repo> <n> --head <full-id> [--mode fast-forward\|squash\|merge] [--delete-branch]` | `mr:merge` | refused if the branch moved, if `fast-forward` and the target is not an ancestor of the head (behind: use `squash` or `merge`), or if `squash`/`merge` conflict; squash and merge commits are signed by the server key (`signing-key show`); answers once the target moved; `--delete-branch` deletes the source after the merge (refused for the default branch or a branch another open request uses; JSON adds `source_branch_deleted`) | | |
| 65 | 65 | | `mr close <repo> <n>` | `mr:write` | a closed merge request cannot be reopened | | |
| 66 | 66 | ||
| 67 | 67 | `ermite mr create` and `ermite mr close` print a confirmation followed by the full | |
| @@ -106,6 +106,7 @@ Workflow JSON: `repo`, `file` (always `.github/workflows/<file>`), `name` (the ` | |||
| 106 | 106 | | `repo_created`, `repo_updated`, `repo_deleted` | `repo`, `repository` | | |
| 107 | 107 | | `pushed` | `repo`, `ref_updates` (`ref`, `old_id`, `new_id`; an empty id means created or deleted) | | |
| 108 | 108 | | `merge_request_opened`, `merge_request_merged`, `merge_request_closed` | `repo`, `merge_request` | | |
| 109 | + | | `merge_request_mergeability` | `repo`, `number`, `mergeability` (`state`: `checking`, `up_to_date`, `behind`, `conflict`; `behind`, `behind_capped`, `conflict_paths`, `conflict_paths_truncated`); sent when the state changes | | |
| 109 | 110 | | `comment_added`, `comment_edited`, `comment_deleted`, `comment_resolved` | `repo`, `number`, `comment` (a verdict note has `verdict` set) | | |
| 110 | 111 | | `gap` | none: events were lost, reload the state | | |
| 111 | 112 | ||
| @@ -116,7 +117,7 @@ Workflow JSON: `repo`, `file` (always `.github/workflows/<file>`), `name` (the ` | |||
| 116 | 117 | | `serve` | configured by `ERMITE_*` environment variables only (see the project README) | | |
| 117 | 118 | | `version [--json]` | client version; with a server URL (`ERMITE_URL` or the config file), the server version and features | | |
| 118 | 119 | | `config show [--json]` / `config set <url\|token.cmd> <value>` / `config unset <key>` | the CLI config file; `token.cmd` is a command that prints the token (any secret manager), never the token itself; `show` never runs it | | |
| 119 | - | | `signing-key show` | public key and `allowed_signers` line to check squash commits with `git verify-commit` | | |
| 120 | + | | `signing-key show` | public key and `allowed_signers` line to check squash and merge commits with `git verify-commit` | | |
| 120 | 121 | | `signing-key trust list [--json]` | keys commit signatures are checked against (the server key first) | | |
| 121 | 122 | | `signing-key trust add --format ssh\|gpg --title <t> --email <e>... <file\|->` / `trust remove <id>` | owner only; a commit shows verified when its committer email is listed for the key | | |
| 122 | 123 | | `web login [--json]` | one-time sign-in link (about 60 s); needs the admin token or a token with every scope, no repository list and no branch prefix; the link is a secret | | |
M skills/ermite/SKILL.md +2 -2
| @@ -37,9 +37,9 @@ git push https://git.example.com/<repo>.git main # password = token, | |||
| 37 | 37 | 2. Read: `ermite mr show <repo> <n> --diff --json`. Keep `head_id`; take line numbers from the hunks. | |
| 38 | 38 | 3. Comment on a line: `ermite mr comment <repo> <n> -m "text" --path <file> --line <n> --side new`; `--line 10-14` covers a range on one side. Without `--path` the comment is general. The line must appear in the diff. Notes are Markdown; `^N` refers to note N. | |
| 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 | - | 5. Merge only when asked: `ermite mr merge <repo> <n> --head <head_id>`, with `--squash` for one commit signed by the server and `--delete-branch` to delete the source branch afterwards. | |
| 40 | + | 5. Merge only when asked: `ermite mr merge <repo> <n> --head <head_id>`, with `--mode squash` for one commit or `--mode merge` for a merge commit (both signed by the server's key, see `ermite signing-key show`) and `--delete-branch` to delete the source branch afterwards. | |
| 41 | 41 | ||
| 42 | - | The merge is refused when the branch moved since `head_id` (review again) or when the target is not an ancestor of the head (rebase and push again). The repository may allow only one merge mode. | |
| 42 | + | The merge is refused when the branch moved since `head_id` (review again) or, for `fast-forward`, when the target is not an ancestor of the head (rebase and push again, or use `squash`/`merge`), or when `squash`/`merge` conflict. `ermite mr show` prints the `mergeability` line (up to date, behind by N, conflict with paths). The repository may allow only some merge modes. | |
| 43 | 43 | ||
| 44 | 44 | ### Act on a review | |
| 45 | 45 | ||