Merge a1c67992913ad1300cfbc03a30679707ce836f51 into d8e7d5b03a01f5f16f8d49f12a7e132355b40f06
88 files · +6357 -1026
M README.md +1 -1
| @@ -122,7 +122,7 @@ ermite mr show myrepo 1 --since-last-read --mark-read | |||||
| 122 | **5. You merge** (or click Merge in the web interface): | 122 | **5. You merge** (or click Merge in the web interface): | ||
| 123 | 123 | ||||
| 124 | ```sh | 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 | 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). | 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 | ermite token create <label> [--preset cli|agent|ci|runner] [--scope …] [--repo …] [--branch-prefix …] [--expires 30d] | 54 | ermite token create <label> [--preset cli|agent|ci|runner] [--scope …] [--repo …] [--branch-prefix …] [--expires 30d] | ||
| 55 | ermite token list | revoke <id> | 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 | ermite mr comment <repo> <n> -m … [--path … --line N|A-B --side old|new] [--reply <id>] [--approve|--request-changes] | 58 | ermite mr comment <repo> <n> -m … [--path … --line N|A-B --side old|new] [--reply <id>] [--approve|--request-changes] | ||
| 59 | ermite mr resolve <repo> <n> <comment> | edit-comment <repo> <n> <comment> -m … | delete-comment <repo> <n> <comment> | 59 | ermite mr resolve <repo> <n> <comment> | edit-comment <repo> <n> <comment> -m … | delete-comment <repo> <n> <comment> | ||
| 60 | - | ermite mr merge <repo> <n> --head <id> [--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 | ermite issue create <repo> --title … [--body … | --body-file …] | list <repo> [--state open|closed|all] | 62 | ermite issue create <repo> --title … [--body … | --body-file …] | list <repo> [--state open|closed|all] | ||
| 63 | ermite issue show <repo> <n> | comment <repo> <n> -m … | close <repo> <n> | reopen <repo> <n> | 63 | ermite issue show <repo> <n> | comment <repo> <n> -m … | close <repo> <n> | reopen <repo> <n> | ||
| @@ -78,7 +78,7 @@ ermite environment create|list|delete … --repo <repo> | |||||
| 78 | ermite web login # one-time sign-in link (60 s) | 78 | ermite web login # one-time sign-in link (60 s) | ||
| 79 | ermite web logout --all # sign every browser out | 79 | ermite web logout --all # sign every browser out | ||
| 80 | ermite web passkey list | remove <id> | 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 | ermite oidc rotate-key | 82 | ermite oidc rotate-key | ||
| 83 | ermite watch [repo] [--json] # follow live events | 83 | ermite watch [repo] [--json] # follow live events | ||
| 84 | ermite version # client and server versions | 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 | ## Output | 90 | ## Output | ||
| 91 | 91 | ||||
| 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`. | 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 | - Errors are one line on stderr (`ermite: …`) with exit status 1. | 95 | - Errors are one line on stderr (`ermite: …`) with exit status 1. | ||
| 95 | - Shell completion completes repository names from the server. | 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 | - A key may sign for 1 to 10 emails; up to 100 keys. | 37 | - A key may sign for 1 to 10 emails; up to 100 keys. | ||
| 38 | - The id is the fingerprint: `SHA256:…` for SSH, the primary key fingerprint for OpenPGP. | 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 | - Private keys are refused. | 40 | - Private keys are refused. | ||
M docs/configuration.md +2 -2
| @@ -34,8 +34,8 @@ ermite serve | |||||
| 34 | |---|---|---| | 34 | |---|---|---| | ||
| 35 | | `ERMITE_GIT` | `git` | git 2.45 or newer | | 35 | | `ERMITE_GIT` | `git` | git 2.45 or newer | | ||
| 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 | | 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 | | 37 | + | | `ERMITE_COMMITTER_NAME` | `ermite` | committer of squash and merge commits | |
| 38 | - | | `ERMITE_COMMITTER_EMAIL` | `ermite@<public host>` | committer email of squash merges | | 38 | + | | `ERMITE_COMMITTER_EMAIL` | `ermite@<public host>` | committer email of squash and merge commits | |
| 39 | 39 | ||||
| 40 | ## CI | 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 | ```sh | 5 | ```sh | ||
| 6 | ermite mr create myrepo --source feature --title "Add feature" | 6 | ermite mr create myrepo --source feature --title "Add feature" | ||
| 7 | ermite mr show myrepo 1 --diff | 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 | `ermite mr create` and `ermite mr close` print a confirmation followed by the full | 11 | `ermite mr create` and `ermite mr close` print a confirmation followed by the full | ||
| 12 | merge request web URL on its own line. With `--json`, merge request objects | 12 | merge request web URL on its own line. With `--json`, merge request objects | ||
| 13 | (create, show, list, close and merge) include `url` alongside the other fields. | 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. | 15 | + | ## Merge modes |
| 16 | - | - The merge is refused when the branch moved since the reviewed `--head`, or when the target is not an ancestor of it. | 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 | - With `failing_checks_block` on (default), the merge is refused while a check of the head failed or is still running. | 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. | 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. |
| 19 | - | - `ermite repo edit --merge-modes` limits the modes per repository. | 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 | - Scopes: `mr:write` to open and comment, `mr:review` to give a verdict, `mr:merge` to merge. | 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 | ```sh | 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 | ## Notes | 66 | ## Notes | ||
| 29 | 67 | ||||
| 30 | A note is the only review object. It is published as soon as it is written: there is no draft and no submit step. | 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 | require ( | 14 | require ( | ||
| 15 | bnema.dev/ermite-actions v0.2.0 | 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 | bnema.dev/ermite-git v0.1.0 | 17 | bnema.dev/ermite-git v0.1.0 | ||
| 18 | bnema.dev/ermite-kv v0.1.0 | 18 | bnema.dev/ermite-kv v0.1.0 | ||
| 19 | connectrpc.com/connect v1.21.0 | 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 | func TestMissingFeatures(t *testing.T) { | 139 | func TestMissingFeatures(t *testing.T) { | ||
| 140 | assert.Empty(t, client.MissingFeatures(client.UsedFeatures())) | 140 | assert.Empty(t, client.MissingFeatures(client.UsedFeatures())) | ||
| 141 | assert.Equal(t, | 141 | assert.Equal(t, | ||
| 142 | - | []string{client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureIssues, client.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 | client.MissingFeatures([]string{client.FeatureRepoUpdate, client.FeatureSession, client.FeaturePasskeys, "other"})) | 143 | client.MissingFeatures([]string{client.FeatureRepoUpdate, client.FeatureSession, client.FeaturePasskeys, "other"})) | ||
| 144 | assert.Equal(t, []string{client.FeatureSigningKey}, client.MissingFeatures([]string{ | 144 | assert.Equal(t, []string{client.FeatureSigningKey}, client.MissingFeatures([]string{ | ||
| 145 | - | client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureIssues, client.FeatureSession, | 145 | + | client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureIssues, client.FeatureMergeability, client.FeatureSession, |
| 146 | client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, client.FeatureCIWorkflows, | 146 | client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, client.FeatureCIWorkflows, | ||
| 147 | client.FeatureCIArtifacts, client.FeaturePasskeys, client.FeatureCommitSignatures, | 147 | client.FeatureCIArtifacts, client.FeaturePasskeys, client.FeatureCommitSignatures, | ||
| 148 | })) | 148 | })) | ||
| 149 | assert.Equal(t, []string{client.FeaturePasskeys}, client.MissingFeatures([]string{ | 149 | assert.Equal(t, []string{client.FeaturePasskeys}, client.MissingFeatures([]string{ | ||
| 150 | - | client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureIssues, client.FeatureSigningKey, client.FeatureSession, | 150 | + | client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureIssues, client.FeatureMergeability, client.FeatureSigningKey, client.FeatureSession, |
| 151 | client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, client.FeatureCIWorkflows, client.FeatureCIArtifacts, client.FeatureCommitSignatures, | 151 | client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, client.FeatureCIWorkflows, client.FeatureCIArtifacts, client.FeatureCommitSignatures, | ||
| 152 | })) | 152 | })) | ||
| 153 | assert.Equal(t, client.UsedFeatures(), client.MissingFeatures(nil)) | 153 | assert.Equal(t, client.UsedFeatures(), client.MissingFeatures(nil)) | ||
M internal/adapters/client/features.go +4 -1
| @@ -54,11 +54,14 @@ const ( | |||||
| 54 | // FeatureCommitSignatures is TrustedKeyService and Commit.verification | 54 | // FeatureCommitSignatures is TrustedKeyService and Commit.verification | ||
| 55 | // (`ermite signing-key trust`). | 55 | // (`ermite signing-key trust`). | ||
| 56 | FeatureCommitSignatures = "commit_signatures" | 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 | // UsedFeatures lists the features the CLI uses, in the order they are reported. | 62 | // UsedFeatures lists the features the CLI uses, in the order they are reported. | ||
| 60 | func UsedFeatures() []string { | 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 | // MissingFeatures returns the features the CLI uses that have is lacking. | 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 | // runInput is run with the given standard input; nil means none. | 132 | // runInput is run with the given standard input; nil means none. | ||
| 133 | func (g *Git) runInput(ctx context.Context, stdin io.Reader, args ...string) (string, error) { | 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 | if _, ok := ctx.Deadline(); !ok { | 144 | if _, ok := ctx.Deadline(); !ok { | ||
| 135 | var cancel context.CancelFunc | 145 | var cancel context.CancelFunc | ||
| 136 | ctx, cancel = context.WithTimeout(ctx, commandTimeout) | 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 | var exit *exec.ExitError | 167 | var exit *exec.ExitError | ||
| 158 | if errors.As(err, &exit) && msg != "" { | 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 | return stdout.String(), nil | 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 | import ( | 5 | import ( | ||
| 6 | "context" | 6 | "context" | ||
| 7 | + | "fmt" | |||
| 7 | "strings" | 8 | "strings" | ||
| 8 | "testing" | 9 | "testing" | ||
| 9 | 10 | ||||
| @@ -231,3 +232,85 @@ func TestDeleteBranch(t *testing.T) { | |||||
| 231 | err = g.DeleteBranch(ctx, name(t, "missing"), "main", m.mainTip) | 232 | err = g.DeleteBranch(ctx, name(t, "missing"), "main", m.mainTip) | ||
| 232 | assert.ErrorIs(t, err, domain.ErrNotFound) | 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 | return nil | 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 | kv "bnema.dev/ermite-kv" | 17 | kv "bnema.dev/ermite-kv" | ||
| 18 | "github.com/bnema/zerowrap" | 18 | "github.com/bnema/zerowrap" | ||
| 19 | "google.golang.org/protobuf/proto" | 19 | "google.golang.org/protobuf/proto" | ||
| 20 | + | "google.golang.org/protobuf/types/known/timestamppb" | |||
| 20 | 21 | ||||
| 21 | storagepb "bnema.dev/ermite/gen/ermite/storage" | 22 | storagepb "bnema.dev/ermite/gen/ermite/storage" | ||
| 22 | "bnema.dev/ermite/internal/domain" | 23 | "bnema.dev/ermite/internal/domain" | ||
| @@ -27,10 +28,11 @@ import ( | |||||
| 27 | // ending in "/" never matches another repository; numbers and comment ids are | 28 | // ending in "/" never matches another repository; numbers and comment ids are | ||
| 28 | // zero-padded so that keys sort numerically. | 29 | // zero-padded so that keys sort numerically. | ||
| 29 | const ( | 30 | const ( | ||
| 30 | - | mrPrefix = "mr/" // mr/<repo>/<number:010d> | 31 | + | mrPrefix = "mr/" // mr/<repo>/<number:010d> |
| 31 | - | commentPrefix = "mrc/" // mrc/<repo>/<number:010d>/<id:010d> | 32 | + | commentPrefix = "mrc/" // mrc/<repo>/<number:010d>/<id:010d> |
| 32 | - | readPrefix = "mrread/" // mrread/<repo>/<number:010d> | 33 | + | readPrefix = "mrread/" // mrread/<repo>/<number:010d> |
| 33 | - | seqPrefix = "mrseq/" // mrseq/<repo> | 34 | + | seqPrefix = "mrseq/" // mrseq/<repo> |
| 35 | + | mergePrefix = "mrmerge/" // mrmerge/<repo>/<number:010d> | |||
| 34 | // The indexes of the open merge requests by source branch and by target | 36 | // The indexes of the open merge requests by source branch and by target | ||
| 35 | // branch, which a push looks up (the values are not read). They are written in | 37 | // branch, which a push looks up (the values are not read). They are written in | ||
| 36 | // the same batch as the merge request, so they are never out of step with it, | 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 | func readKey(repo domain.RepoName, n int64) []byte { | 71 | func readKey(repo domain.RepoName, n int64) []byte { | ||
| 70 | return []byte(readPrefix + repo.String() + "/" + fmt.Sprintf(numberFormat, n)) | 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 | func seqKey(repo domain.RepoName) []byte { return []byte(seqPrefix + repo.String()) } | 77 | func seqKey(repo domain.RepoName) []byte { return []byte(seqPrefix + repo.String()) } | ||
| 73 | 78 | ||||
| 74 | // mrOpenRepoPrefix is where the open merge requests of a repository are indexed | 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 | for _, p := range []string{ | 180 | for _, p := range []string{ | ||
| 176 | mrRepoPrefix(repo), commentPrefix + repo.String() + "/", readPrefix + repo.String() + "/", | 181 | mrRepoPrefix(repo), commentPrefix + repo.String() + "/", readPrefix + repo.String() + "/", | ||
| 177 | mrOpenRepoPrefix(repo), mrTargetPrefix + repo.String() + "/", | 182 | mrOpenRepoPrefix(repo), mrTargetPrefix + repo.String() + "/", | ||
| 183 | + | mergePrefix + repo.String() + "/", | |||
| 178 | } { | 184 | } { | ||
| 179 | for k := range snap.Prefix(ctx, []byte(p)) { | 185 | for k := range snap.Prefix(ctx, []byte(p)) { | ||
| 180 | keys = append(keys, slices.Clone(k)) | 186 | keys = append(keys, slices.Clone(k)) | ||
| @@ -469,6 +475,9 @@ func (m *MergeRequests) Update(ctx context.Context, mr domain.MergeRequest) erro | |||||
| 469 | } else { | 475 | } else { | ||
| 470 | indexOpen(&b, nil, mr) | 476 | indexOpen(&b, nil, mr) | ||
| 471 | } | 477 | } | ||
| 478 | + | if !mr.IsOpen() { | |||
| 479 | + | b.Delete(mergeKey(mr.Repo, mr.Number)) | |||
| 480 | + | } | |||
| 472 | if _, err := m.db.Apply(ctx, &b); err != nil { | 481 | if _, err := m.db.Apply(ctx, &b); err != nil { | ||
| 473 | if errors.Is(err, kv.ErrConflict) { | 482 | if errors.Is(err, kv.ErrConflict) { | ||
| 474 | return domain.ErrConflict | 483 | return domain.ErrConflict | ||
| @@ -579,6 +588,133 @@ func (m *MergeRequests) SetRead(ctx context.Context, repo domain.RepoName, numbe | |||||
| 579 | return fmt.Errorf("record read position of %d in %q: %w", number, repo, domain.ErrConflict) | 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 | func encodeMergeRequest(mr domain.MergeRequest) ([]byte, error) { | 718 | func encodeMergeRequest(mr domain.MergeRequest) ([]byte, error) { | ||
| 583 | if mr.Repo.IsZero() { | 719 | if mr.Repo.IsZero() { | ||
| 584 | return nil, fmt.Errorf("%w: empty name", domain.ErrInvalidName) | 720 | return nil, fmt.Errorf("%w: empty name", domain.ErrInvalidName) | ||
| @@ -735,6 +871,8 @@ func encodeMode(m domain.MergeMode) (storagepb.MergeMode, error) { | |||||
| 735 | return storagepb.MergeMode_MERGE_MODE_FAST_FORWARD, nil | 871 | return storagepb.MergeMode_MERGE_MODE_FAST_FORWARD, nil | ||
| 736 | case domain.MergeModeSquash: | 872 | case domain.MergeModeSquash: | ||
| 737 | return storagepb.MergeMode_MERGE_MODE_SQUASH, nil | 873 | return storagepb.MergeMode_MERGE_MODE_SQUASH, nil | ||
| 874 | + | case domain.MergeModeMerge: | |||
| 875 | + | return storagepb.MergeMode_MERGE_MODE_MERGE, nil | |||
| 738 | } | 876 | } | ||
| 739 | return 0, fmt.Errorf("%w: merge mode %q", domain.ErrInvalid, m) | 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 | return domain.MergeModeFastForward | 884 | return domain.MergeModeFastForward | ||
| 747 | case storagepb.MergeMode_MERGE_MODE_SQUASH: | 885 | case storagepb.MergeMode_MERGE_MODE_SQUASH: | ||
| 748 | return domain.MergeModeSquash | 886 | return domain.MergeModeSquash | ||
| 887 | + | case storagepb.MergeMode_MERGE_MODE_MERGE: | |||
| 888 | + | return domain.MergeModeMerge | |||
| 749 | } | 889 | } | ||
| 750 | return "" | 890 | return "" | ||
| 751 | } | 891 | } | ||
| @@ -773,7 +913,7 @@ func decodeSide(s storagepb.DiffSide) domain.DiffSide { | |||||
| 773 | } | 913 | } | ||
| 774 | 914 | ||||
| 775 | func encodeSettings(s domain.RepoSettings) (*storagepb.RepoSettings, error) { | 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 | for _, m := range s.AllowedMergeModes { | 917 | for _, m := range s.AllowedMergeModes { | ||
| 778 | mode, err := encodeMode(m) | 918 | mode, err := encodeMode(m) | ||
| 779 | if err != nil || mode == storagepb.MergeMode_MERGE_MODE_UNSPECIFIED { | 919 | if err != nil || mode == storagepb.MergeMode_MERGE_MODE_UNSPECIFIED { | ||
| @@ -810,5 +950,10 @@ func decodeSettings(pb *storagepb.RepoSettings) domain.RepoSettings { | |||||
| 810 | if err != nil { | 950 | if err != nil { | ||
| 811 | return domain.DefaultRepoSettings() | 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 | return domain.RepoSettings{AllowedMergeModes: normalized, FailingChecksBlock: pb.GetFailingChecksBlock(), Issues: decodeIssuesMode(pb.GetIssues())} | 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 | assert.Equal(t, int64(5), five.Number) | 284 | assert.Equal(t, int64(5), five.Number) | ||
| 285 | assert.Equal(t, []int64{1, 5}, bySource(t, m2, old, "feat/one")) | 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 | assert.Equal(t, c, got) | 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 | // always implements, as GetVersion lists them. FeaturePasskeys comes on top | 35 | // always implements, as GetVersion lists them. FeaturePasskeys comes on top | ||
| 36 | // when Config.Passkeys is set, and FeatureCISecrets when the settings services | 36 | // when Config.Passkeys is set, and FeatureCISecrets when the settings services | ||
| 37 | // are, and FeatureCommitSignatures when Config.TrustedKeys is set. | 37 | // are, and FeatureCommitSignatures when Config.TrustedKeys is set. | ||
| 38 | - | var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "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 | // FeaturePasskeys is listed by GetVersion when passkeys are available. | 40 | // FeaturePasskeys is listed by GetVersion when passkeys are available. | ||
| 41 | // FeatureCIArtifacts (artifact.go) when the artifact services are served. | 41 | // FeatureCIArtifacts (artifact.go) when the artifact services are served. | ||
M internal/adapters/rpc/convert.go +7 -0
| @@ -88,6 +88,8 @@ func mergeModeToPB(m domain.MergeMode) pb.MergeMode { | |||||
| 88 | return pb.MergeMode_MERGE_MODE_FAST_FORWARD | 88 | return pb.MergeMode_MERGE_MODE_FAST_FORWARD | ||
| 89 | case domain.MergeModeSquash: | 89 | case domain.MergeModeSquash: | ||
| 90 | return pb.MergeMode_MERGE_MODE_SQUASH | 90 | return pb.MergeMode_MERGE_MODE_SQUASH | ||
| 91 | + | case domain.MergeModeMerge: | |||
| 92 | + | return pb.MergeMode_MERGE_MODE_MERGE_COMMIT | |||
| 91 | } | 93 | } | ||
| 92 | return pb.MergeMode_MERGE_MODE_UNSPECIFIED | 94 | return pb.MergeMode_MERGE_MODE_UNSPECIFIED | ||
| 93 | } | 95 | } | ||
| @@ -102,6 +104,8 @@ func mergeModeFromPB(m pb.MergeMode) (domain.MergeMode, error) { | |||||
| 102 | return domain.MergeModeFastForward, nil | 104 | return domain.MergeModeFastForward, nil | ||
| 103 | case pb.MergeMode_MERGE_MODE_SQUASH: | 105 | case pb.MergeMode_MERGE_MODE_SQUASH: | ||
| 104 | return domain.MergeModeSquash, nil | 106 | return domain.MergeModeSquash, nil | ||
| 107 | + | case pb.MergeMode_MERGE_MODE_MERGE_COMMIT: | |||
| 108 | + | return domain.MergeModeMerge, nil | |||
| 105 | } | 109 | } | ||
| 106 | return "", fmt.Errorf("%w: merge mode %v", domain.ErrInvalid, m) | 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 | case domain.CommentResolved: | 501 | case domain.CommentResolved: | ||
| 498 | out.Time = timestamp(ev.At) | 502 | out.Time = timestamp(ev.At) | ||
| 499 | out.Kind = &pb.Event_CommentResolved{CommentResolved: &pb.CommentResolved{Repo: ev.Repo.String(), Number: ev.Number, Comment: commentToPB(ev.Comment)}} | 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 | case domain.RunUpdated: | 507 | case domain.RunUpdated: | ||
| 501 | out.Time = timestamp(ev.At) | 508 | out.Time = timestamp(ev.At) | ||
| 502 | out.Kind = runEventToPB(ev) | 509 | out.Kind = runEventToPB(ev) | ||
M internal/adapters/rpc/convert_test.go +23 -1
| @@ -213,7 +213,7 @@ func TestRepoToPBAlwaysHasSettings(t *testing.T) { | |||||
| 213 | t.Parallel() | 213 | t.Parallel() | ||
| 214 | // A repository value without settings (never stored with them) reads as the defaults. | 214 | // A repository value without settings (never stored with them) reads as the defaults. | ||
| 215 | r := repoToPB(ports.RepoView{Repo: domain.Repo{Name: mustName(t, "x")}}) | 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 | assert.True(t, r.GetSettings().GetFailingChecksBlock()) | 217 | assert.True(t, r.GetSettings().GetFailingChecksBlock()) | ||
| 218 | 218 | ||||
| 219 | custom := domain.RepoSettings{AllowedMergeModes: []domain.MergeMode{domain.MergeModeSquash}} | 219 | custom := domain.RepoSettings{AllowedMergeModes: []domain.MergeMode{domain.MergeModeSquash}} | ||
| @@ -376,3 +376,25 @@ func TestIssueStateToPBNeverPending(t *testing.T) { | |||||
| 376 | assert.Equal(t, pb.IssueState_ISSUE_STATE_UNSPECIFIED, issueStateToPB(domain.IssueState("pending"))) | 376 | assert.Equal(t, pb.IssueState_ISSUE_STATE_UNSPECIFIED, issueStateToPB(domain.IssueState("pending"))) | ||
| 377 | assert.Equal(t, pb.IssueState_ISSUE_STATE_OPEN, issueStateToPB(domain.IssueStateOpen)) | 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 | return "", fmt.Errorf("%w: diff side %v", domain.ErrInvalid, s) | 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 | func mergeRequestToPB(mr domain.MergeRequest) *pb.MergeRequest { | 121 | func mergeRequestToPB(mr domain.MergeRequest) *pb.MergeRequest { | ||
| 119 | - | return &pb.MergeRequest{ | 122 | + | out := &pb.MergeRequest{ |
| 120 | Repo: mr.Repo.String(), | 123 | Repo: mr.Repo.String(), | ||
| 121 | Number: mr.Number, | 124 | Number: mr.Number, | ||
| 122 | Title: mr.Title.String(), | 125 | Title: mr.Title.String(), | ||
| @@ -131,6 +134,47 @@ func mergeRequestToPB(mr domain.MergeRequest) *pb.MergeRequest { | |||||
| 131 | MergedCommitId: mr.MergedCommitID.String(), | 134 | MergedCommitId: mr.MergedCommitID.String(), | ||
| 132 | MergeMode: mergeModeToPB(mr.MergeMode), | 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 | func commentToPB(c domain.Comment) *pb.Comment { | 180 | func commentToPB(c domain.Comment) *pb.Comment { | ||
| @@ -214,7 +258,7 @@ func (s *mergeRequestService) ListMergeRequests(ctx context.Context, req *connec | |||||
| 214 | } | 258 | } | ||
| 215 | out := &pb.ListMergeRequestsResponse{NextPageToken: page.NextPageToken, MergeRequests: make([]*pb.MergeRequest, 0, len(page.MergeRequests))} | 259 | out := &pb.ListMergeRequestsResponse{NextPageToken: page.NextPageToken, MergeRequests: make([]*pb.MergeRequest, 0, len(page.MergeRequests))} | ||
| 216 | for _, mr := range page.MergeRequests { | 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 | return connect.NewResponse(out), nil | 263 | return connect.NewResponse(out), nil | ||
| 220 | } | 264 | } | ||
| @@ -229,7 +273,7 @@ func (s *mergeRequestService) GetMergeRequest(ctx context.Context, req *connect. | |||||
| 229 | return nil, connectError(ctx, err) | 273 | return nil, connectError(ctx, err) | ||
| 230 | } | 274 | } | ||
| 231 | out := &pb.GetMergeRequestResponse{ | 275 | out := &pb.GetMergeRequestResponse{ | ||
| 232 | - | MergeRequest: mergeRequestToPB(d.MergeRequest), | 276 | + | MergeRequest: mergeRequestWithMergeabilityToPB(d.MergeRequest, d.Mergeability), |
| 233 | CommitsTruncated: d.CommitsTruncated, | 277 | CommitsTruncated: d.CommitsTruncated, | ||
| 234 | DiffBaseId: d.DiffBaseID.String(), | 278 | DiffBaseId: d.DiffBaseID.String(), | ||
| 235 | Stats: diffStatsToPB(d.Stats), | 279 | Stats: diffStatsToPB(d.Stats), | ||
M internal/adapters/rpc/mergerequest_test.go +1 -0
| @@ -51,6 +51,7 @@ func TestMergeRequestServiceCreate(t *testing.T) { | |||||
| 51 | require.NoError(t, err) | 51 | require.NoError(t, err) | ||
| 52 | assert.EqualValues(t, 7, resp.Msg.GetMergeRequest().GetNumber()) | 52 | assert.EqualValues(t, 7, resp.Msg.GetMergeRequest().GetNumber()) | ||
| 53 | assert.Equal(t, pb.MergeRequestState_MERGE_REQUEST_STATE_OPEN, resp.Msg.GetMergeRequest().GetState()) | 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 | // GET /-/events?topic=repo:<name>&view=summary|refs | 31 | // GET /-/events?topic=repo:<name>&view=summary|refs | ||
| 32 | // GET /-/events?topic=repo:<name>&view=commits&ref=<branch>&head=<sha> | 32 | // GET /-/events?topic=repo:<name>&view=commits&ref=<branch>&head=<sha> | ||
| 33 | // GET /-/events?topic=repo:<name>&view=mrs | 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 | // GET /-/events?topic=repo:<name>&view=issues | 35 | // GET /-/events?topic=repo:<name>&view=issues | ||
| 36 | // GET /-/events?topic=repo:<name>&view=issue&n=<number> | 36 | // GET /-/events?topic=repo:<name>&view=issue&n=<number> | ||
| 37 | // GET /-/events?topic=repo:<name>&view=actions | 37 | // GET /-/events?topic=repo:<name>&view=actions | ||
| @@ -54,6 +54,10 @@ import ( | |||||
| 54 | // refs-list data: <tbody id="ref-rows">…</tbody>, and again with | 54 | // refs-list data: <tbody id="ref-rows">…</tbody>, and again with | ||
| 55 | // <dl id="refs-kv">…</dl>, the facts of the rail | 55 | // <dl id="refs-kv">…</dl>, the facts of the rail | ||
| 56 | // commits-head data: <div id="commits-head">N new commits — reload</div> | 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 | // mr-notice data: <div id="mr-notice">…</div>, on the merge request and issue pages: | 61 | // mr-notice data: <div id="mr-notice">…</div>, on the merge request and issue pages: | ||
| 58 | // something changed, with the link that reloads the page | 62 | // something changed, with the link that reloads the page | ||
| 59 | // ci-run data: <tr id="run-<id>">…</tr> (the list of runs), | 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 | return | 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 | if topic == ownerTopic { | 260 | if topic == ownerTopic { | ||
| 257 | st.filter = q.Get("repo") | 261 | st.filter = q.Get("repo") | ||
| 258 | } | 262 | } | ||
M internal/adapters/web/fragments.go +58 -8
| @@ -28,6 +28,7 @@ type liveStream struct { | |||||
| 28 | head string // commits: the commit listed first | 28 | head string // commits: the commit listed first | ||
| 29 | n string // mr: the number of the merge request shown; run: the run id | 29 | n string // mr: the number of the merge request shown; run: the run id | ||
| 30 | job string // job: the id of the job shown | 30 | job string // job: the id of the job shown | ||
| 31 | + | target string // mr: the target branch of the merge request shown | |||
| 31 | // sha is the commit of the run of the job shown, read once when the stream | 32 | // sha is the commit of the run of the job shown, read once when the stream | ||
| 32 | // is set up: the annotations link to files at it, and it never changes. | 33 | // is set up: the annotations link to files at it, and it never changes. | ||
| 33 | sha string | 34 | sha string | ||
| @@ -60,7 +61,7 @@ func liveEvent(we ports.WatchEvent) bool { | |||||
| 60 | } | 61 | } | ||
| 61 | switch we.Event.(type) { | 62 | switch we.Event.(type) { | ||
| 62 | case domain.RepoCreated, domain.RepoUpdated, domain.RepoDeleted, domain.PushEvent, | 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 | domain.RunUpdated, domain.JobUpdated, | 65 | domain.RunUpdated, domain.JobUpdated, | ||
| 65 | domain.IssueOpened, domain.IssueClosed, domain.IssueReopened, domain.IssueCommented, domain.IssueSubmitted, domain.IssuePendingRemoved: | 66 | domain.IssueOpened, domain.IssueClosed, domain.IssueReopened, domain.IssueCommented, domain.IssueSubmitted, domain.IssuePendingRemoved: | ||
| 66 | return true | 67 | return true | ||
| @@ -170,6 +171,11 @@ func (l *liveStream) repoPage(ctx context.Context, we ports.WatchEvent) ([]fragm | |||||
| 170 | return l.mrNotice(ctx, ev.MergeRequest.Number, "this merge request was closed — reload") | 171 | return l.mrNotice(ctx, ev.MergeRequest.Number, "this merge request was closed — reload") | ||
| 171 | case domain.MergeRequestReady: | 172 | case domain.MergeRequestReady: | ||
| 172 | return l.mrNotice(ctx, ev.MergeRequest.Number, "this merge request is ready for review — reload") | 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 | case domain.CommentEdited: | 179 | case domain.CommentEdited: | ||
| 174 | return l.mrNotice(ctx, ev.Number, "a note was edited — reload") | 180 | return l.mrNotice(ctx, ev.Number, "a note was edited — reload") | ||
| 175 | case domain.CommentDeleted: | 181 | case domain.CommentDeleted: | ||
| @@ -436,21 +442,65 @@ func (l *liveStream) issueNotice(ctx context.Context, number int64, text string) | |||||
| 436 | return nil, nil | 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 | func (l *liveStream) mrPushed(ctx context.Context, p domain.PushEvent) ([]fragment, error) { | 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 | return nil, nil | 450 | return nil, nil | ||
| 443 | } | 451 | } | ||
| 452 | + | var out []fragment | |||
| 453 | + | moved := false | |||
| 444 | for _, u := range p.Updates { | 454 | for _, u := range p.Updates { | ||
| 445 | - | if u.Ref == "refs/heads/"+l.ref { | 455 | + | switch { |
| 446 | - | n, err := strconv.ParseInt(l.n, 10, 64) | 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 | if err != nil { | 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 | func (l *liveStream) notice(ctx context.Context, text, href string) ([]fragment, error) { | 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 | htmx:sse:close, htmx:sse:error -> ermiteLive.lost(); | 102 | htmx:sse:close, htmx:sse:error -> ermiteLive.lost(); | ||
| 103 | htmx:sse:error -> ermiteLive.error(event.detail.status); | 103 | htmx:sse:error -> ermiteLive.error(event.detail.status); | ||
| 104 | htmx:sse:before:connection -> ermiteLive.gate(event); | 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 | gap -> ermiteLive.reload()" | 106 | gap -> ermiteLive.reload()" | ||
| 107 | ></span> | 107 | ></span> | ||
| 108 | } | 108 | } | ||
M internal/adapters/web/fragments_templ.go +1 -1 generated file: collapsed
M internal/adapters/web/fragments_test.go +1 -0
| @@ -33,6 +33,7 @@ func TestLiveSlotsAreStableStatusRegions(t *testing.T) { | |||||
| 33 | }{ | 33 | }{ | ||
| 34 | {"empty commits slot", LiveSlot("commits-head"), "commits-head", ""}, | 34 | {"empty commits slot", LiveSlot("commits-head"), "commits-head", ""}, | ||
| 35 | {"empty mr slot", LiveSlot("mr-notice"), "mr-notice", ""}, | 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 | {"commits banner", CommitsBanner("2 new commits — reload", "/x/commits/main"), "commits-head", "2 new commits — reload"}, | 37 | {"commits banner", CommitsBanner("2 new commits — reload", "/x/commits/main"), "commits-head", "2 new commits — reload"}, | ||
| 37 | {"mr notice", MRNotice("a comment was added — reload", "/x/merge-requests/1"), "mr-notice", "a comment was added — reload"}, | 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 | // CanDeleteBranch offers to delete the source after the merge: it is not | 196 | // CanDeleteBranch offers to delete the source after the merge: it is not | ||
| 197 | // the default branch. The server checks again, with the open requests. | 197 | // the default branch. The server checks again, with the open requests. | ||
| 198 | CanDeleteBranch bool | 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. | 210 | + | // ModeOption is a merge mode the repository allows. Disabled says that the |
| 202 | - | type ModeOption struct{ Value, Label string } | 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 | // SinceNav switches between the whole diff and the diff since the last read. | 232 | // SinceNav switches between the whole diff and the diff since the last read. | ||
| 205 | type SinceNav struct { | 233 | type SinceNav struct { | ||
| @@ -233,6 +261,16 @@ type MRPage struct { | |||||
| 233 | MergedShort string | 261 | MergedShort string | ||
| 234 | MergedHref string | 262 | MergedHref string | ||
| 235 | MergeMode string | 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 | Since *SinceNav | 275 | Since *SinceNav | ||
| 238 | SinceNote string | 276 | SinceNote string | ||
| @@ -346,10 +384,59 @@ func modeName(m domain.MergeMode) string { | |||||
| 346 | return "fast-forward" | 384 | return "fast-forward" | ||
| 347 | case domain.MergeModeSquash: | 385 | case domain.MergeModeSquash: | ||
| 348 | return "squash" | 386 | return "squash" | ||
| 387 | + | case domain.MergeModeMerge: | |||
| 388 | + | return "merge commit" | |||
| 349 | } | 389 | } | ||
| 350 | return "" | 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 | // mrRow converts a merge request of a listing. | 440 | // mrRow converts a merge request of a listing. | ||
| 354 | func (s *Server) mrRow(repo string, m domain.MergeRequest) MRRow { | 441 | func (s *Server) mrRow(repo string, m domain.MergeRequest) MRRow { | ||
| 355 | row := MRRow{ | 442 | row := MRRow{ | ||
| @@ -540,6 +627,9 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ | |||||
| 540 | p.MergedHref = repoHref(d.name(), "commit", m.MergedCommitID.String()) | 627 | p.MergedHref = repoHref(d.name(), "commit", m.MergedCommitID.String()) | ||
| 541 | p.MergeMode = modeName(m.MergeMode) | 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 | for _, mode := range d.repo.Settings.AllowedMergeModes { | 633 | for _, mode := range d.repo.Settings.AllowedMergeModes { | ||
| 544 | if name := modeName(mode); name != "" { | 634 | if name := modeName(mode); name != "" { | ||
| 545 | p.Modes = append(p.Modes, name) | 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 | p.Invisible = invisible | 714 | p.Invisible = invisible | ||
| 625 | p.DiffView = newDiffView(r, lines, withFrom(self+"?view=unified", from), withFrom(self+"?view=split", from)) | 715 | p.DiffView = newDiffView(r, lines, withFrom(self+"?view=unified", from), withFrom(self+"?view=split", from)) | ||
| 626 | if signed && open { | 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 | if m.IsDraft() { | 719 | if m.IsDraft() { | ||
| 629 | p.Box.ReadyAction = mrHref(d.name(), n, "ready") | 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 | if canWrite { | 729 | if canWrite { | ||
| 633 | p.GeneralForm = &CommentForm{ | 730 | p.GeneralForm = &CommentForm{ | ||
| 634 | ID: "cf-general", Action: mrHref(d.name(), n, "comments"), Title: "comment on the merge request", | 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 | l.Crumbs = append(l.Crumbs, Crumb{Label: "since", Current: true}) | 749 | l.Crumbs = append(l.Crumbs, Crumb{Label: "since", Current: true}) | ||
| 653 | } | 750 | } | ||
| 654 | p.Live = true | 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 | l.HasRail = true | 753 | l.HasRail = true | ||
| 657 | l.MR = true | 754 | l.MR = true | ||
| 658 | return MergeRequest(l, p), http.StatusOK, nil | 755 | return MergeRequest(l, p), http.StatusOK, nil | ||
| @@ -669,37 +766,78 @@ func (d *repoData) hasBranch(name string) bool { | |||||
| 669 | } | 766 | } | ||
| 670 | 767 | ||||
| 671 | // mergeBox is what the owner can do with an open request. | 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 | b := &MergeBox{ | 770 | b := &MergeBox{ | ||
| 674 | MergeAction: mrHref(d.name(), n, "merge"), CloseAction: mrHref(d.name(), n, "close"), | 771 | MergeAction: mrHref(d.name(), n, "merge"), CloseAction: mrHref(d.name(), n, "close"), | ||
| 675 | ReadAction: mrHref(d.name(), n, "read"), Head: head, HeadGone: head == "", | 772 | ReadAction: mrHref(d.name(), n, "read"), Head: head, HeadGone: head == "", | ||
| 676 | CanMarkRead: head != "" && head != lastRead, | 773 | CanMarkRead: head != "" && head != lastRead, | ||
| 677 | CanDeleteBranch: source != d.repo.DefaultBranch, | 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 | for _, mode := range d.repo.Settings.AllowedMergeModes { | 782 | for _, mode := range d.repo.Settings.AllowedMergeModes { | ||
| 681 | switch mode { | 783 | switch mode { | ||
| 682 | case domain.MergeModeFastForward: | 784 | case domain.MergeModeFastForward: | ||
| 683 | - | ff = true | 785 | + | b.Modes = append(b.Modes, ModeOption{Value: "fast-forward", Label: "fast-forward", Disabled: !mg.FFOk, Reason: reasonIf(!mg.FFOk, mg.FFReason)}) |
| 684 | - | b.Modes = append(b.Modes, ModeOption{Value: "fast-forward", Label: "fast-forward"}) | 786 | + | lines = append(lines, cmd) |
| 685 | case domain.MergeModeSquash: | 787 | case domain.MergeModeSquash: | ||
| 686 | - | sq = true | 788 | + | b.Signed = true |
| 687 | - | b.Modes = append(b.Modes, ModeOption{Value: "squash", Label: "squash (one signed commit)"}) | 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 | if head != "" { | 800 | if head != "" { | ||
| 691 | - | cmd := "ermite mr merge " + shellWord(d.name()) + " " + strconv.FormatInt(n, 10) + " --head " + head | 801 | + | b.CLI = Clone{ID: "cli-merge", Lines: lines} |
| 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 | - | } | |||
| 699 | } | 802 | } | ||
| 700 | return b | 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 | // shellWord quotes s for a shell unless it is made of safe characters, which | 841 | // shellWord quotes s for a shell unless it is made of safe characters, which | ||
| 704 | // a repository name always is. | 842 | // a repository name always is. | ||
| 705 | func shellWord(s string) string { | 843 | func shellWord(s string) string { | ||
M internal/adapters/web/mr.templ +92 -4
| @@ -2,7 +2,10 @@ | |||||
| 2 | 2 | ||||
| 3 | package web | 3 | package web | ||
| 4 | 4 | ||||
| 5 | - | import "strings" | 5 | + | import ( |
| 6 | + | "strconv" | |||
| 7 | + | "strings" | |||
| 8 | + | ) | |||
| 6 | 9 | ||||
| 7 | // MRList is the list of the merge requests of a repository: the open ones, | 10 | // MRList is the list of the merge requests of a repository: the open ones, | ||
| 8 | // then the merged and closed ones. | 11 | // then the merged and closed ones. | ||
| @@ -138,6 +141,12 @@ templ MergeRequest(l Layout, p MRPage) { | |||||
| 138 | } | 141 | } | ||
| 139 | </dl> | 142 | </dl> | ||
| 140 | </header> | 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 | @LiveSlot("mr-notice") | 150 | @LiveSlot("mr-notice") | ||
| 142 | if p.Desc != "" { | 151 | if p.Desc != "" { | ||
| 143 | <article class="mr-desc prose" aria-label="description" hx-ignore> | 152 | <article class="mr-desc prose" aria-label="description" hx-ignore> | ||
| @@ -176,6 +185,9 @@ templ MergeRequest(l Layout, p MRPage) { | |||||
| 176 | <div | 185 | <div | ||
| 177 | id="diffs" | 186 | id="diffs" | ||
| 178 | data-mr | 187 | data-mr | ||
| 188 | + | if p.Canonical != "" { | |||
| 189 | + | data-canonical={ p.Canonical } | |||
| 190 | + | } | |||
| 179 | if p.Both { | 191 | if p.Both { | ||
| 180 | data-diff={ p.View } | 192 | data-diff={ p.View } | ||
| 181 | } else { | 193 | } else { | ||
| @@ -446,8 +458,13 @@ templ mrBar(p MRPage) { | |||||
| 446 | // mergeMenu is the merge of an open request, for the owner: the form (it | 458 | // mergeMenu is the merge of an open request, for the owner: the form (it | ||
| 447 | // carries the head that was read), or "Ready for review" for a draft. The | 459 | // carries the head that was read), or "Ready for review" for a draft. The | ||
| 448 | // mode and the branch deletion have keys (1-3, D) while it is open. | 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 | templ mergeMenu(p MRPage) { | 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 | <summary class="btn primary" aria-keyshortcuts="Shift+M"> | 468 | <summary class="btn primary" aria-keyshortcuts="Shift+M"> | ||
| 452 | if p.Box.ReadyAction != "" { | 469 | if p.Box.ReadyAction != "" { | ||
| 453 | Draft | 470 | Draft | ||
| @@ -469,19 +486,32 @@ templ mergeMenu(p MRPage) { | |||||
| 469 | <dt>into</dt> | 486 | <dt>into</dt> | ||
| 470 | <dd>{ p.Target }</dd> | 487 | <dd>{ p.Target }</dd> | ||
| 471 | </dl> | 488 | </dl> | ||
| 489 | + | if p.Box.Error != "" { | |||
| 490 | + | <p class="d-err" role="alert">{ p.Box.Error }</p> | |||
| 491 | + | } | |||
| 472 | if p.Box.ReadyAction != "" { | 492 | if p.Box.ReadyAction != "" { | ||
| 473 | <p class="d-draft">this is a draft: it cannot be merged until it is ready for review.</p> | 493 | <p class="d-draft">this is a draft: it cannot be merged until it is ready for review.</p> | ||
| 474 | <form class="d-btns" method="post" action={ templ.SafeURL(p.Box.ReadyAction) }> | 494 | <form class="d-btns" method="post" action={ templ.SafeURL(p.Box.ReadyAction) }> | ||
| 475 | <button type="submit" class="btn primary" data-default title="remove the draft prefix from the title">Ready for review</button> | 495 | <button type="submit" class="btn primary" data-default title="remove the draft prefix from the title">Ready for review</button> | ||
| 476 | </form> | 496 | </form> | ||
| 477 | } else if !p.Box.HeadGone { | 497 | } else if !p.Box.HeadGone { | ||
| 498 | + | @mrMergeabilitySlot(p.Box.Mergeability) | |||
| 478 | <form class="d-acts" method="post" action={ templ.SafeURL(p.Box.MergeAction) }> | 499 | <form class="d-acts" method="post" action={ templ.SafeURL(p.Box.MergeAction) }> | ||
| 479 | <input type="hidden" name="head_id" value={ p.Box.Head }/> | 500 | <input type="hidden" name="head_id" value={ p.Box.Head }/> | ||
| 480 | <fieldset class="d-modes"> | 501 | <fieldset class="d-modes"> | ||
| 481 | <legend class="sr">merge mode</legend> | 502 | <legend class="sr">merge mode</legend> | ||
| 482 | for i, m := range p.Box.Modes { | 503 | for i, m := range p.Box.Modes { | ||
| 483 | <label> | 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 | { m.Label } | 515 | { m.Label } | ||
| 486 | if i < 9 { | 516 | if i < 9 { | ||
| 487 | <kbd aria-hidden="true">{ itoa(i + 1) }</kbd> | 517 | <kbd aria-hidden="true">{ itoa(i + 1) }</kbd> | ||
| @@ -489,11 +519,28 @@ templ mergeMenu(p MRPage) { | |||||
| 489 | </label> | 519 | </label> | ||
| 490 | } | 520 | } | ||
| 491 | </fieldset> | 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 | if p.Box.CanDeleteBranch { | 531 | if p.Box.CanDeleteBranch { | ||
| 493 | <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> | 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 | <div class="d-btns"> | 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 | </div> | 544 | </div> | ||
| 498 | </form> | 545 | </form> | ||
| 499 | } | 546 | } | ||
| @@ -501,6 +548,47 @@ templ mergeMenu(p MRPage) { | |||||
| 501 | </details> | 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 | // moreMenu is what else the owner does with an open request: mark the head | 592 | // moreMenu is what else the owner does with an open request: mark the head | ||
| 505 | // read, close it, merge it from a terminal. | 593 | // read, close it, merge it from a terminal. | ||
| 506 | templ moreMenu(p MRPage) { | 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 | return | 60 | return | ||
| 61 | } | 61 | } | ||
| 62 | to, back, err := h(ctx, r) | 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 | if err != nil { | 67 | if err != nil { | ||
| 64 | s.actionFailed(ctx, w, r, err, back) | 68 | s.actionFailed(ctx, w, r, err, back) | ||
| 65 | return | 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 | // actionFailed answers a refused action. | 112 | // actionFailed answers a refused action. | ||
| 72 | func (s *Server) actionFailed(ctx context.Context, w http.ResponseWriter, r *http.Request, err error, back string) { | 113 | func (s *Server) actionFailed(ctx context.Context, w http.ResponseWriter, r *http.Request, err error, back string) { | ||
| 73 | // The words of the use case, for the rules the owner can fix. | 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 | mode = domain.MergeModeFastForward | 363 | mode = domain.MergeModeFastForward | ||
| 323 | case "squash": | 364 | case "squash": | ||
| 324 | mode = domain.MergeModeSquash | 365 | mode = domain.MergeModeSquash | ||
| 366 | + | case "merge": | |||
| 367 | + | mode = domain.MergeModeMerge | |||
| 325 | default: | 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 | name, err := domain.ParseRepoName(repo) | 372 | name, err := domain.ParseRepoName(repo) | ||
| 329 | if err != nil { | 373 | if err != nil { | ||
| @@ -333,11 +377,19 @@ func (s *Server) mrMerge(ctx context.Context, r *http.Request) (string, string, | |||||
| 333 | if err != nil { | 377 | if err != nil { | ||
| 334 | return "", back, err | 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 | Mode: mode, HeadID: head, DeleteSource: r.PostFormValue("delete_branch") == "1", | 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 | return "", back, err | 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 | return back, back, nil | 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 | & .kv { margin: 0; } | 50 | & .kv { margin: 0; } | ||
| 51 | } | 51 | } | ||
| 52 | .mmenu { | 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 | & .d-modes { display: grid; gap: var(--s1); border: 0; padding: 0; margin: 0; } | 59 | & .d-modes { display: grid; gap: var(--s1); border: 0; padding: 0; margin: 0; } | ||
| 54 | & .d-modes label, & .d-check { | 60 | & .d-modes label, & .d-check { | ||
| 55 | display: flex; gap: var(--s2); align-items: center; padding: 4px var(--s2); margin: 0 calc(-1 * var(--s2)); | 61 | display: flex; gap: var(--s2); align-items: center; padding: 4px var(--s2); margin: 0 calc(-1 * var(--s2)); | ||
| @@ -63,7 +69,22 @@ | |||||
| 63 | & .d-btns { display: flex; margin-top: var(--s1); } | 69 | & .d-btns { display: flex; margin-top: var(--s1); } | ||
| 64 | & .d-btns .btn { flex: 1; justify-content: center; } | 70 | & .d-btns .btn { flex: 1; justify-content: center; } | ||
| 65 | & .d-draft { color: var(--ink-dim); } | 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 | .omenu { | 88 | .omenu { | ||
| 68 | & > summary { font-size: 18px; line-height: 1; } | 89 | & > summary { font-size: 18px; line-height: 1; } | ||
| 69 | & .menu-pop { width: 280px; } | 90 | & .menu-pop { width: 280px; } | ||
M internal/adapters/web/static/js/mr.js +10 -1
| @@ -219,7 +219,12 @@ window.ermiteMR = (() => { | |||||
| 219 | const box = $('input[name="delete_branch"]', m); | 219 | const box = $('input[name="delete_branch"]', m); | ||
| 220 | if (box) box.checked = !box.checked; | 220 | if (box) box.checked = !box.checked; | ||
| 221 | } else { | 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 | $('[data-default]', m)?.focus(); | 229 | $('[data-default]', m)?.focus(); | ||
| 225 | return true; | 230 | return true; | ||
| @@ -291,6 +296,10 @@ window.ermiteMR = (() => { | |||||
| 291 | let folded = false; | 296 | let folded = false; | ||
| 292 | try { folded = localStorage.getItem('mr-files-folded') === '1'; } catch { /* no storage: open */ } | 297 | try { folded = localStorage.getItem('mr-files-folded') === '1'; } catch { /* no storage: open */ } | ||
| 293 | if (folded) foldFiles(true); | 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 | watch(); | 303 | watch(); | ||
| 295 | // A link to a file or a comment that is not loaded yet. | 304 | // A link to a file or a comment that is not loaded yet. | ||
| 296 | const hash = () => { | 305 | const hash = () => { | ||
M internal/adapters/web/web.go +4 -0
| @@ -76,6 +76,10 @@ type Config struct { | |||||
| 76 | // leads to it. Without it there is neither. Leave it unset rather than | 76 | // leads to it. Without it there is neither. Leave it unset rather than | ||
| 77 | // assigning a typed nil pointer. | 77 | // assigning a typed nil pointer. | ||
| 78 | Inbox ports.InboxService | 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 | // Runs serves the CI pages (/<repo>/actions) and the checks shown next to | 83 | // Runs serves the CI pages (/<repo>/actions) and the checks shown next to | ||
| 80 | // commits and merge requests. Without it there are no such pages. Leave it | 84 | // commits and merge requests. Without it there are no such pages. Leave it | ||
| 81 | // unset rather than assigning a typed nil pointer. | 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 | return s | 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 | func (s *stream) next(t *testing.T, d time.Duration) *pb.Event { | 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 | t.Helper() | 185 | t.Helper() | ||
| 173 | select { | 186 | select { | ||
| 174 | case ev, ok := <-s.events: | 187 | case ev, ok := <-s.events: | ||
M internal/app/api_test.go +1 -1
| @@ -196,7 +196,7 @@ func TestAC2ReadAccess(t *testing.T) { | |||||
| 196 | for _, who := range allPrincipals { | 196 | for _, who := range allPrincipals { | ||
| 197 | resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{})) | 197 | resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{})) | ||
| 198 | require.NoError(t, err, who) | 198 | require.NoError(t, err, who) | ||
| 199 | - | assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "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 | assert.NotEmpty(t, resp.Msg.GetVersion()) | 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 | package app | 3 | package app | ||
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | + | "errors" | |||
| 6 | "strings" | 7 | "strings" | ||
| 7 | "testing" | 8 | "testing" | ||
| 8 | "time" | 9 | "time" | ||
| @@ -230,8 +231,11 @@ func TestMergeRecordsTheTargetAndDropsTheSourceRecord(t *testing.T) { | |||||
| 230 | })) | 231 | })) | ||
| 231 | require.NoError(t, err) | 232 | require.NoError(t, err) | ||
| 232 | 233 | ||||
| 233 | - | _, err = tips.GetRefTip(c.ctx, c.name("proj"), "refs/heads/feat") | 234 | + | // The deletion and the removal of the record run after the merge returned. |
| 234 | - | assert.ErrorIs(t, err, domain.ErrNotFound, "the deleted source branch has no record") | 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 | main, err := tips.GetRefTip(c.ctx, c.name("proj"), "refs/heads/main") | 239 | main, err := tips.GetRefTip(c.ctx, c.name("proj"), "refs/heads/main") | ||
| 236 | require.NoError(t, err) | 240 | require.NoError(t, err) | ||
| 237 | assert.Equal(t, c.ref("refs/heads/main"), main.Sha, "the merge recorded the new tip") | 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 | return w | 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 | func (w *watchProc) next(t *testing.T, d time.Duration) (raw string) { | 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 | t.Helper() | 140 | t.Helper() | ||
| 129 | select { | 141 | select { | ||
| 130 | case l, ok := <-w.lines: | 142 | case l, ok := <-w.lines: | ||
| @@ -338,7 +350,7 @@ func TestAC6CLIEndToEnd(t *testing.T) { | |||||
| 338 | ver := decode[versionOut](t, res.stdout) | 350 | ver := decode[versionOut](t, res.stdout) | ||
| 339 | assert.Equal(t, c.url, ver.Server.URL) | 351 | assert.Equal(t, c.url, ver.Server.URL) | ||
| 340 | assert.NotEmpty(t, ver.Server.Version) | 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 | assert.Equal(t, []string{"passkeys"}, ver.Server.MissingFeatures) | 354 | assert.Equal(t, []string{"passkeys"}, ver.Server.MissingFeatures) | ||
| 343 | stdout, stderr, code = c.bin(t, secret, "version") | 355 | stdout, stderr, code = c.bin(t, secret, "version") | ||
| 344 | require.Equal(t, 0, code, stderr) | 356 | require.Equal(t, 0, code, stderr) | ||
M internal/app/cli_mr.go +150 -24
| @@ -8,6 +8,7 @@ import ( | |||||
| 8 | "fmt" | 8 | "fmt" | ||
| 9 | "io" | 9 | "io" | ||
| 10 | "net/url" | 10 | "net/url" | ||
| 11 | + | "slices" | |||
| 11 | "strconv" | 12 | "strconv" | ||
| 12 | "strings" | 13 | "strings" | ||
| 13 | 14 | ||||
| @@ -31,6 +32,7 @@ const ( | |||||
| 31 | 32 | ||||
| 32 | modeFastForward = "fast-forward" | 33 | modeFastForward = "fast-forward" | ||
| 33 | modeSquash = "squash" | 34 | modeSquash = "squash" | ||
| 35 | + | modeMerge = "merge" | |||
| 34 | ) | 36 | ) | ||
| 35 | 37 | ||||
| 36 | func stateName(s ermite.MergeRequestState) string { | 38 | func stateName(s ermite.MergeRequestState) string { | ||
| @@ -65,6 +67,8 @@ func modeName(m ermite.MergeMode) string { | |||||
| 65 | return modeFastForward | 67 | return modeFastForward | ||
| 66 | case ermite.MergeMode_MERGE_MODE_SQUASH: | 68 | case ermite.MergeMode_MERGE_MODE_SQUASH: | ||
| 67 | return modeSquash | 69 | return modeSquash | ||
| 70 | + | case ermite.MergeMode_MERGE_MODE_MERGE_COMMIT: | |||
| 71 | + | return modeMerge | |||
| 68 | } | 72 | } | ||
| 69 | return "" | 73 | return "" | ||
| 70 | } | 74 | } | ||
| @@ -75,8 +79,10 @@ func parseMode(s string) (ermite.MergeMode, error) { | |||||
| 75 | return ermite.MergeMode_MERGE_MODE_FAST_FORWARD, nil | 79 | return ermite.MergeMode_MERGE_MODE_FAST_FORWARD, nil | ||
| 76 | case modeSquash: | 80 | case modeSquash: | ||
| 77 | return ermite.MergeMode_MERGE_MODE_SQUASH, nil | 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 | func sideName(s ermite.DiffSide) string { | 88 | func sideName(s ermite.DiffSide) string { | ||
| @@ -117,6 +123,71 @@ type jsonMergeRequest struct { | |||||
| 117 | MergeMode string `json:"merge_mode"` | 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 | // jsonMerged is the output of `mr merge --json`. | 191 | // jsonMerged is the output of `mr merge --json`. | ||
| 121 | type jsonMerged struct { | 192 | type jsonMerged struct { | ||
| 122 | jsonMergeRequest | 193 | jsonMergeRequest | ||
| @@ -173,9 +244,15 @@ const jsonMRHelp = ` | |||||
| 173 | JSON fields of a merge request (--json): repo, url (full web URL), number, title, description, source_branch, | 244 | JSON fields of a merge request (--json): repo, url (full web URL), number, title, description, source_branch, | ||
| 174 | target_branch, state ("open", "merged" or "closed"), author (token label), created_at and | 245 | target_branch, state ("open", "merged" or "closed"), author (token label), created_at and | ||
| 175 | updated_at (RFC 3339 UTC), head_id (the commit the source branch points to; empty when the | 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 | unless merged).` | 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 | const jsonCommentHelp = ` | 256 | const jsonCommentHelp = ` | ||
| 180 | JSON fields of a comment: id, author, path, line and side ("old" or "new"; empty path, line 0 | 257 | JSON fields of a comment: id, author, path, line and side ("old" or "new"; empty path, line 0 | ||
| 181 | and empty side for a general comment), commit_id (the head when it was written), text, | 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 | // jsonMRDetail is the stable JSON shape of `mr show --json`. | 489 | // jsonMRDetail is the stable JSON shape of `mr show --json`. | ||
| 413 | type jsonMRDetail struct { | 490 | type jsonMRDetail struct { | ||
| 414 | jsonMergeRequest | 491 | jsonMergeRequest | ||
| 492 | + | // Mergeability is null unless the request is open and the server computes it. | |||
| 493 | + | Mergeability *jsonMergeability `json:"mergeability"` | |||
| 415 | Commits []jsonCommit `json:"commits"` | 494 | Commits []jsonCommit `json:"commits"` | ||
| 416 | CommitsTruncated bool `json:"commits_truncated"` | 495 | CommitsTruncated bool `json:"commits_truncated"` | ||
| 417 | DiffBaseID string `json:"diff_base_id"` | 496 | DiffBaseID string `json:"diff_base_id"` | ||
| @@ -444,6 +523,7 @@ func lineKindName(k ermite.LineKind) string { | |||||
| 444 | func toJSONDetail(d *ermite.GetMergeRequestResponse, withDiff bool, baseURL string) jsonMRDetail { | 523 | func toJSONDetail(d *ermite.GetMergeRequestResponse, withDiff bool, baseURL string) jsonMRDetail { | ||
| 445 | out := jsonMRDetail{ | 524 | out := jsonMRDetail{ | ||
| 446 | jsonMergeRequest: toJSONMergeRequest(d.GetMergeRequest(), baseURL), | 525 | jsonMergeRequest: toJSONMergeRequest(d.GetMergeRequest(), baseURL), | ||
| 526 | + | Mergeability: toJSONMergeability(d.GetMergeRequest().GetMergeability()), | |||
| 447 | Commits: []jsonCommit{}, CommitsTruncated: d.GetCommitsTruncated(), | 527 | Commits: []jsonCommit{}, CommitsTruncated: d.GetCommitsTruncated(), | ||
| 448 | DiffBaseID: d.GetDiffBaseId(), | 528 | DiffBaseID: d.GetDiffBaseId(), | ||
| 449 | Stats: jsonStats{ | 529 | Stats: jsonStats{ | ||
| @@ -496,7 +576,9 @@ func newMRShowCmd(env *cliEnv) *cobra.Command { | |||||
| 496 | "--since-last-read shows the diff since the head recorded by --mark-read (or by the web) instead of\n" + | 576 | "--since-last-read shows the diff since the head recorded by --mark-read (or by the web) instead of\n" + | ||
| 497 | "the whole change; after a force-push it still works when the recorded commit exists, else the\n" + | 577 | "the whole change; after a force-push it still works when the recorded commit exists, else the\n" + | ||
| 498 | "whole change is shown and the output says so. --mark-read records the head shown as read; it needs\n" + | 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 | "--json adds to the merge request fields: commits (id, author, summary), commits_truncated,\n" + | 582 | "--json adds to the merge request fields: commits (id, author, summary), commits_truncated,\n" + | ||
| 501 | "diff_base_id (the commit the diff starts from), stats (files_changed, additions, deletions),\n" + | 583 | "diff_base_id (the commit the diff starts from), stats (files_changed, additions, deletions),\n" + | ||
| 502 | "diff_truncated, files (old_path, new_path, status, additions, deletions, binary, truncated and,\n" + | 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 | if m.GetState() == ermite.MergeRequestState_MERGE_REQUEST_STATE_MERGED { | 651 | if m.GetState() == ermite.MergeRequestState_MERGE_REQUEST_STATE_MERGED { | ||
| 570 | pairs = append(pairs, [2]string{"merged", orDash(m.GetMergedCommitId()) + " (" + modeName(m.GetMergeMode()) + ")"}) | 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 | if len(d.GetVerdicts()) > 0 { | 657 | if len(d.GetVerdicts()) > 0 { | ||
| 573 | var verdicts []string | 658 | var verdicts []string | ||
| 574 | for _, v := range d.GetVerdicts() { | 659 | for _, v := range d.GetVerdicts() { | ||
| @@ -957,36 +1042,52 @@ func newMRResolveCmd(env *cliEnv) *cobra.Command { | |||||
| 957 | } | 1042 | } | ||
| 958 | 1043 | ||||
| 959 | func newMRMergeCmd(env *cliEnv) *cobra.Command { | 1044 | func newMRMergeCmd(env *cliEnv) *cobra.Command { | ||
| 960 | - | var squash, deleteBranch bool | 1045 | + | var mode string |
| 1046 | + | var deleteBranch bool | |||
| 961 | var head string | 1047 | var head string | ||
| 962 | cmd := &cobra.Command{ | 1048 | cmd := &cobra.Command{ | ||
| 963 | Use: "merge <repo> <number>", | 1049 | Use: "merge <repo> <number>", | ||
| 964 | Short: "Merge a merge request", | 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" + | 1051 | + | Long: "Merge an open merge request with --mode (default fast-forward; the repository settings may allow\n" + |
| 966 | - | "and no commit is created. --squash makes one new commit with the tree of the head and the target\n" + | 1052 | + | "only some modes, see `ermite repo edit --merge-modes`):\n" + |
| 967 | - | "as only parent, signed by the server's key (see `ermite signing-key show`). The target must be an\n" + | 1053 | + | " fast-forward the target branch moves to the head and no commit is created. The target must be\n" + |
| 968 | - | "ancestor of the head, else rebase the branch and push it again; the repository settings may allow\n" + | 1054 | + | " an ancestor of the head, else rebase the branch and push it again, or use another mode.\n" + |
| 969 | - | "only one of the modes.\n\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 | "The server merges only if the source branch still points to the head you reviewed, so --head is\n" + | 1063 | "The server merges only if the source branch still points to the head you reviewed, so --head is\n" + | ||
| 971 | "required: give the full id of the head that `ermite mr show <repo> <number>` printed when you\n" + | 1064 | "required: give the full id of the head that `ermite mr show <repo> <number>` printed when you\n" + | ||
| 972 | "reviewed the change. If the branch moved since, the merge is refused: review again. Needs the\n" + | 1065 | "reviewed the change. If the branch moved since, the merge is refused: review again. Needs the\n" + | ||
| 973 | "mr:merge scope.\n\n" + | 1066 | "mr:merge scope.\n\n" + | ||
| 974 | - | "--delete-branch deletes the source branch after the merge. The merge is refused when the branch\n" + | 1067 | + | "The merge answers as soon as the target branch has moved. --delete-branch asks the server to delete\n" + |
| 975 | - | "is the default branch or used by another open merge request. If the deletion fails after the\n" + | 1068 | + | "the source branch right after, in the background. The merge is refused when the branch is the\n" + |
| 976 | - | "merge, the merge stands, the branch is kept and a warning goes to standard error." + jsonMRHelp + | 1069 | + | "default branch or used by another open merge request. If the deletion later fails (the branch\n" + |
| 977 | - | "\n--json adds source_branch_deleted (true when the branch was deleted).", | 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 | Args: cobra.ExactArgs(2), | 1072 | Args: cobra.ExactArgs(2), | ||
| 979 | ValidArgsFunction: completeMRRepoThenNumber(env), | 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 | cmd.Flags().StringVar(&head, "head", "", "full id of the head you reviewed, from `ermite mr show` (required)") | 1076 | cmd.Flags().StringVar(&head, "head", "", "full id of the head you reviewed, from `ermite mr show` (required)") | ||
| 983 | cmd.Flags().BoolVar(&deleteBranch, "delete-branch", false, "delete the source branch after the merge") | 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 | asJSON := addJSONFlag(cmd) | 1081 | asJSON := addJSONFlag(cmd) | ||
| 985 | cmd.RunE = func(cmd *cobra.Command, args []string) error { | 1082 | cmd.RunE = func(cmd *cobra.Command, args []string) error { | ||
| 986 | n, err := parseNumber(args[1]) | 1083 | n, err := parseNumber(args[1]) | ||
| 987 | if err != nil { | 1084 | if err != nil { | ||
| 988 | return err | 1085 | return err | ||
| 989 | } | 1086 | } | ||
| 1087 | + | pbMode, err := parseMode(mode) | |||
| 1088 | + | if err != nil { | |||
| 1089 | + | return err | |||
| 1090 | + | } | |||
| 990 | head = strings.TrimSpace(head) | 1091 | head = strings.TrimSpace(head) | ||
| 991 | if head == "" { | 1092 | if head == "" { | ||
| 992 | return fmt.Errorf("--head is required: run `ermite mr show %s %d` and give the head id it prints, "+ | 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 | return err | 1098 | return err | ||
| 998 | } | 1099 | } | ||
| 999 | ctx := env.ctx(cmd) | 1100 | ctx := env.ctx(cmd) | ||
| 1000 | - | mode := ermite.MergeMode_MERGE_MODE_FAST_FORWARD | 1101 | + | if pbMode == ermite.MergeMode_MERGE_MODE_MERGE_COMMIT { |
| 1001 | - | if squash { | 1102 | + | if err := requireFeature(ctx, c, client.FeatureMergeability, "has no merge commit mode (--mode merge)"); err != nil { |
| 1002 | - | mode = ermite.MergeMode_MERGE_MODE_SQUASH | 1103 | + | return err |
| 1104 | + | } | |||
| 1003 | } | 1105 | } | ||
| 1004 | res, err := c.MergeRequests().MergeMergeRequest(ctx, connect.NewRequest(&ermite.MergeMergeRequestRequest{ | 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 | if err != nil { | 1109 | if err != nil { | ||
| 1008 | return c.Describe(err) | 1110 | return c.Describe(err) | ||
| 1009 | } | 1111 | } | ||
| 1010 | m := res.Msg.GetMergeRequest() | 1112 | m := res.Msg.GetMergeRequest() | ||
| 1011 | deleted := res.Msg.GetSourceBranchDeleted() | 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 | if *asJSON { | 1114 | if *asJSON { | ||
| 1017 | return writeJSON(cmd.OutOrStdout(), jsonMerged{jsonMergeRequest: toJSONMergeRequest(m, c.URL()), SourceBranchDeleted: deleted}) | 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 | clean(m.GetTargetBranch()), modeName(m.GetMergeMode()), shortID(m.GetMergedCommitId())) | 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 | if err == nil && deleted { | 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 | return err | 1126 | return err | ||
| 1025 | } | 1127 | } | ||
| 1026 | return cmd | 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 | func newMRCloseCmd(env *cliEnv) *cobra.Command { | 1155 | func newMRCloseCmd(env *cliEnv) *cobra.Command { | ||
| 1030 | cmd := &cobra.Command{ | 1156 | cmd := &cobra.Command{ | ||
| 1031 | Use: "close <repo> <number>", | 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 | require.Error(t, res.err) | 260 | require.Error(t, res.err) | ||
| 261 | assert.Contains(t, res.err.Error(), "precondition failed") | 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 | c.mustGit(wt, "checkout", "-q", "-b", "feat/two", head3) | 264 | c.mustGit(wt, "checkout", "-q", "-b", "feat/two", head3) | ||
| 265 | c.mustGit(wt, "-c", "user.name=Cy", "-c", "user.email=cy@example.org", "commit", "-q", "--allow-empty", "-m", "empty by Cy") | 265 | c.mustGit(wt, "-c", "user.name=Cy", "-c", "user.email=cy@example.org", "commit", "-q", "--allow-empty", "-m", "empty by Cy") | ||
| 266 | c.writeFile(wt, "two.txt", []byte("two\n")) | 266 | c.writeFile(wt, "two.txt", []byte("two\n")) | ||
| 267 | c.mustGit(wt, "push", "-q", url, "feat/two") | 267 | c.mustGit(wt, "push", "-q", url, "feat/two") | ||
| 268 | c.cli(t, secret, "mr", "create", "proj", "--source", "feat/two", "--title", "Second").ok(t) | 268 | c.cli(t, secret, "mr", "create", "proj", "--source", "feat/two", "--title", "Second").ok(t) | ||
| 269 | w.nextOfType(t, "merge_request_opened") | 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 | requireOneLineError(t, stderr, code, "--head is required", "ermite mr show proj 2") | 271 | requireOneLineError(t, stderr, code, "--head is required", "ermite mr show proj 2") | ||
| 272 | head4 := c.bareGit("proj", "rev-parse", "feat/two") | 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 | require.NoError(t, res.err, res.stderr) | 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 | sq := c.bareGit("proj", "rev-parse", "main") | 276 | sq := c.bareGit("proj", "rev-parse", "main") | ||
| 277 | assert.Equal(t, head3, c.bareGit("proj", "rev-parse", sq+"^")) | 277 | assert.Equal(t, head3, c.bareGit("proj", "rev-parse", sq+"^")) | ||
| 278 | assert.Equal(t, "Second (!2)", c.bareGit("proj", "log", "-1", "--format=%s", sq)) | 278 | assert.Equal(t, "Second (!2)", c.bareGit("proj", "log", "-1", "--format=%s", sq)) | ||
| @@ -338,7 +338,7 @@ func TestSigningKeyShowVerifiesASquashCommit(t *testing.T) { | |||||
| 338 | c.writeFile(wt, "x.txt", []byte("x\n")) | 338 | c.writeFile(wt, "x.txt", []byte("x\n")) | ||
| 339 | c.mustGit(wt, "push", "-q", url, "main", "feat") | 339 | c.mustGit(wt, "push", "-q", url, "main", "feat") | ||
| 340 | c.cli(t, adminToken, "mr", "create", "proj", "--source", "feat", "--title", "Add x").ok(t) | 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 | require.NoError(t, res.err, res.stderr) | 342 | require.NoError(t, res.err, res.stderr) | ||
| 343 | sq := c.bareGit("proj", "rev-parse", "main") | 343 | sq := c.bareGit("proj", "rev-parse", "main") | ||
| 344 | 344 | ||||
| @@ -381,7 +381,7 @@ func TestRepoEditMergeSettingsCLI(t *testing.T) { | |||||
| 381 | MergeModes []string `json:"merge_modes"` | 381 | MergeModes []string `json:"merge_modes"` | ||
| 382 | FailingChecksBlock bool `json:"failing_checks_block"` | 382 | FailingChecksBlock bool `json:"failing_checks_block"` | ||
| 383 | }](t, res.stdout) | 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 | assert.True(t, repo.FailingChecksBlock) | 385 | assert.True(t, repo.FailingChecksBlock) | ||
| 386 | 386 | ||||
| 387 | res = c.cli(t, adminToken, "repo", "edit", "proj", "--merge-modes", "squash", "--json").ok(t) | 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 | package app | 3 | package app | ||
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | + | "encoding/json" | |||
| 7 | + | "regexp" | |||
| 6 | "strings" | 8 | "strings" | ||
| 7 | "testing" | 9 | "testing" | ||
| 8 | 10 | ||||
| @@ -174,6 +176,53 @@ func TestMRShow(t *testing.T) { | |||||
| 174 | assert.Contains(t, res.stderr, "head you last read is gone") | 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 | func TestMRShowMarkRead(t *testing.T) { | 226 | func TestMRShowMarkRead(t *testing.T) { | ||
| 178 | f := &fakeAPI{ | 227 | f := &fakeAPI{ | ||
| 179 | getMR: func(*ermite.GetMergeRequestRequest) (*ermite.GetMergeRequestResponse, error) { | 228 | getMR: func(*ermite.GetMergeRequestRequest) (*ermite.GetMergeRequestResponse, error) { | ||
| @@ -252,6 +301,12 @@ func TestMRResolveMergeAndClose(t *testing.T) { | |||||
| 252 | closeMR: func(*ermite.CloseMergeRequestRequest) (*ermite.CloseMergeRequestResponse, error) { | 301 | closeMR: func(*ermite.CloseMergeRequestRequest) (*ermite.CloseMergeRequestResponse, error) { | ||
| 253 | return &ermite.CloseMergeRequestResponse{MergeRequest: closed}, nil | 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 | url := f.serve(t) | 311 | url := f.serve(t) | ||
| 257 | 312 | ||||
| @@ -265,16 +320,37 @@ func TestMRResolveMergeAndClose(t *testing.T) { | |||||
| 265 | assert.False(t, reqs[1].(*ermite.ResolveCommentRequest).GetResolved()) | 320 | assert.False(t, reqs[1].(*ermite.ResolveCommentRequest).GetResolved()) | ||
| 266 | 321 | ||||
| 267 | // Merge with an explicit head and mode. | 322 | // Merge with an explicit head and mode. | ||
| 268 | - | res = runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--squash", "--head", headA).ok(t) | 323 | + | res = runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--mode", "squash", "--head", headA).ok(t) |
| 269 | - | assert.Equal(t, "merged alpha!1 into main (squash) at dddddddddd\n", res.stdout) | 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 | assert.Empty(t, res.stderr) | 326 | assert.Empty(t, res.stderr) | ||
| 271 | calls, reqs := f.snapshot() | 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 | m := reqs[2].(*ermite.MergeMergeRequestRequest) | 329 | m := reqs[2].(*ermite.MergeMergeRequestRequest) | ||
| 274 | assert.Equal(t, ermite.MergeMode_MERGE_MODE_SQUASH, m.GetMode()) | 330 | assert.Equal(t, ermite.MergeMode_MERGE_MODE_SQUASH, m.GetMode()) | ||
| 275 | assert.Equal(t, headA, m.GetHeadId()) | 331 | assert.Equal(t, headA, m.GetHeadId()) | ||
| 276 | assert.EqualValues(t, 1, m.GetNumber()) | 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 | // The default mode is a fast-forward. | 354 | // The default mode is a fast-forward. | ||
| 279 | res = runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--head", headA, "--json").ok(t) | 355 | res = runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--head", headA, "--json").ok(t) | ||
| 280 | assert.Equal(t, "merged", decode[mrOut](t, res.stdout).State) | 356 | assert.Equal(t, "merged", decode[mrOut](t, res.stdout).State) | ||
| @@ -299,7 +375,7 @@ func TestMRMergeNeedsTheHead(t *testing.T) { | |||||
| 299 | url := f.serve(t) | 375 | url := f.serve(t) | ||
| 300 | for _, args := range [][]string{ | 376 | for _, args := range [][]string{ | ||
| 301 | {"mr", "merge", "alpha", "1"}, | 377 | {"mr", "merge", "alpha", "1"}, | ||
| 302 | - | {"mr", "merge", "alpha", "1", "--squash"}, | 378 | + | {"mr", "merge", "alpha", "1", "--mode", "squash"}, |
| 303 | {"mr", "merge", "alpha", "1", "--head", " "}, | 379 | {"mr", "merge", "alpha", "1", "--head", " "}, | ||
| 304 | } { | 380 | } { | ||
| 305 | res := runCLI(t, envFor(url), "", args...) | 381 | res := runCLI(t, envFor(url), "", args...) | ||
| @@ -326,7 +402,7 @@ func TestMRMergeDeleteBranch(t *testing.T) { | |||||
| 326 | url := f.serve(t) | 402 | url := f.serve(t) | ||
| 327 | 403 | ||||
| 328 | res := runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--head", headA, "--delete-branch").ok(t) | 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 | assert.Empty(t, res.stderr) | 406 | assert.Empty(t, res.stderr) | ||
| 331 | _, reqs := f.snapshot() | 407 | _, reqs := f.snapshot() | ||
| 332 | assert.True(t, reqs[0].(*ermite.MergeMergeRequestRequest).GetDeleteSourceBranch()) | 408 | assert.True(t, reqs[0].(*ermite.MergeMergeRequestRequest).GetDeleteSourceBranch()) | ||
| @@ -334,11 +410,11 @@ func TestMRMergeDeleteBranch(t *testing.T) { | |||||
| 334 | res = runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--head", headA, "--delete-branch", "--json").ok(t) | 410 | res = runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--head", headA, "--delete-branch", "--json").ok(t) | ||
| 335 | assert.Contains(t, res.stdout, `"source_branch_deleted": true`) | 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 | deleted = false | 415 | deleted = false | ||
| 339 | res = runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--head", headA, "--delete-branch").ok(t) | 416 | res = runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--head", headA, "--delete-branch").ok(t) | ||
| 340 | assert.Equal(t, "merged alpha!1 into main (fast-forward) at "+headA[:10]+"\n", res.stdout) | 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 | // Without the flag nothing is asked and nothing is said. | 419 | // Without the flag nothing is asked and nothing is said. | ||
| 344 | res = runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--head", headA).ok(t) | 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 | DefaultBranch string `json:"default_branch"` | 33 | DefaultBranch string `json:"default_branch"` | ||
| 34 | CreatedAt *string `json:"created_at"` | 34 | CreatedAt *string `json:"created_at"` | ||
| 35 | PushedAt *string `json:"pushed_at"` | 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 | MergeModes []string `json:"merge_modes"` | 37 | MergeModes []string `json:"merge_modes"` | ||
| 38 | FailingChecksBlock bool `json:"failing_checks_block"` | 38 | FailingChecksBlock bool `json:"failing_checks_block"` | ||
| 39 | // Issues is the issue mode ("off", "owner" or "public"). | 39 | // Issues is the issue mode ("off", "owner" or "public"). | ||
| @@ -114,8 +114,8 @@ func parseVisibility(s string) (ermite.Visibility, error) { | |||||
| 114 | const jsonRepoHelp = ` | 114 | const jsonRepoHelp = ` | ||
| 115 | JSON fields of a repository (--json): name, description, visibility ("public" or | 115 | JSON fields of a repository (--json): name, description, visibility ("public" or | ||
| 116 | "private"), default_branch, created_at and pushed_at (RFC 3339 UTC; pushed_at is | 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 | 117 | + | null until the first push), merge_modes (array of the allowed merge modes, "fast-forward", "squash" and/or |
| 118 | - | "squash"), failing_checks_block (whether a failing check prevents a merge) and issues (the issue | 118 | + | "merge"), failing_checks_block (whether a failing check prevents a merge) and issues (the issue |
| 119 | mode: "off", "owner" or "public").` | 119 | mode: "off", "owner" or "public").` | ||
| 120 | 120 | ||||
| 121 | func newRepoCmd(env *cliEnv) *cobra.Command { | 121 | func newRepoCmd(env *cliEnv) *cobra.Command { | ||
| @@ -315,7 +315,9 @@ func newRepoEditCmd(env *cliEnv) *cobra.Command { | |||||
| 315 | Short: "Change a repository's description, topics, visibility, default branch, merge settings or issues", | 315 | Short: "Change a repository's description, topics, visibility, default branch, merge settings or issues", | ||
| 316 | Long: "Change the fields given as flags and leave the others alone; --description \"\" clears the\n" + | 316 | Long: "Change the fields given as flags and leave the others alone; --description \"\" clears the\n" + | ||
| 317 | "description and --topics \"\" clears the topics. The default branch must already exist. --merge-modes lists the modes a merge request\n" + | 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 | "--failing-checks-block says whether a failing check prevents a merge (a failed or unfinished CI check of the head; see README, CI).\n" + | 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 | "--issues sets who may use issues: off (default), owner (the owner and tokens with issue:write) or\n" + | 322 | "--issues sets who may use issues: off (default), owner (the owner and tokens with issue:write) or\n" + | ||
| 321 | "public (visitors of a public repository may also propose issues through a moderated form).\nNeeds a server with the issues feature.\n" + | 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 | cmd.Flags().StringSliceVar(&topics, "topics", nil, "new topics, comma-separated (empty clears them)") | 329 | cmd.Flags().StringSliceVar(&topics, "topics", nil, "new topics, comma-separated (empty clears them)") | ||
| 328 | cmd.Flags().StringVar(&visibility, "visibility", "", "new visibility: public or private") | 330 | cmd.Flags().StringVar(&visibility, "visibility", "", "new visibility: public or private") | ||
| 329 | cmd.Flags().StringVar(&defaultBranch, "default-branch", "", "short name of the new default branch") | 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 | cmd.Flags().BoolVar(&failingChecksBlock, "failing-checks-block", true, "a failing check prevents a merge (--failing-checks-block=false to allow it)") | 333 | cmd.Flags().BoolVar(&failingChecksBlock, "failing-checks-block", true, "a failing check prevents a merge (--failing-checks-block=false to allow it)") | ||
| 332 | cmd.Flags().StringVar(&issues, "issues", "", "issue mode: off, owner or public") | 334 | cmd.Flags().StringVar(&issues, "issues", "", "issue mode: off, owner or public") | ||
| 333 | _ = cmd.RegisterFlagCompletionFunc("issues", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { | 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 | _ = cmd.RegisterFlagCompletionFunc("visibility", visibilityCompletion) | 338 | _ = cmd.RegisterFlagCompletionFunc("visibility", visibilityCompletion) | ||
| 337 | _ = cmd.RegisterFlagCompletionFunc("merge-modes", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { | 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 | asJSON := addJSONFlag(cmd) | 342 | asJSON := addJSONFlag(cmd) | ||
| 341 | cmd.RunE = func(cmd *cobra.Command, args []string) error { | 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 | modes = append(modes, mode) | 402 | modes = append(modes, mode) | ||
| 401 | } | 403 | } | ||
| 402 | if len(modes) == 0 { | 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 | req.Settings = &ermite.RepoSettings{AllowedMergeModes: modes} | 407 | req.Settings = &ermite.RepoSettings{AllowedMergeModes: modes} | ||
| 406 | paths = append(paths, "settings.allowed_merge_modes") | 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 | client.FeatureMergeRequests: "`ermite mr` needs: the commands will fail on this server", | 114 | client.FeatureMergeRequests: "`ermite mr` needs: the commands will fail on this server", | ||
| 115 | client.FeatureMergeDeleteBranch: "`ermite mr merge --delete-branch` needs: the merge works but the branch is kept", | 115 | client.FeatureMergeDeleteBranch: "`ermite mr merge --delete-branch` needs: the merge works but the branch is kept", | ||
| 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", | 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 | client.FeatureSigningKey: "`ermite signing-key show` needs: the command will fail on this server", | 118 | client.FeatureSigningKey: "`ermite signing-key show` needs: the command will fail on this server", | ||
| 118 | client.FeatureSession: "`ermite web login` needs: the command will fail on this server", | 119 | client.FeatureSession: "`ermite web login` needs: the command will fail on this server", | ||
| 119 | client.FeatureTokenBranchPrefixes: "`ermite token create --branch-prefix` needs: the command is refused, and `token list` does not show the limits", | 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 | // allFeatures are the features this client uses. | 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 | // Every feature the client uses needs a sentence for the version warning. | 22 | // Every feature the client uses needs a sentence for the version warning. | ||
| 23 | func TestEveryUsedFeatureExplainsWhatBreaks(t *testing.T) { | 23 | func TestEveryUsedFeatureExplainsWhatBreaks(t *testing.T) { | ||
| @@ -45,15 +45,15 @@ func TestVersionWithServer(t *testing.T) { | |||||
| 45 | f := versionFake(allFeatures...) | 45 | f := versionFake(allFeatures...) | ||
| 46 | url := f.serve(t) | 46 | url := f.serve(t) | ||
| 47 | res := runCLI(t, envFor(url), "", "version").ok(t) | 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 | assert.Empty(t, res.stderr, "no warning when every feature is there") | 49 | assert.Empty(t, res.stderr, "no warning when every feature is there") | ||
| 50 | 50 | ||||
| 51 | res = runCLI(t, envFor(url), "", "version", "--json").ok(t) | 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 | func TestVersionWarnsAboutMissingFeatures(t *testing.T) { | 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 | url := f.serve(t) | 57 | url := f.serve(t) | ||
| 58 | res := runCLI(t, envFor(url), "", "version").ok(t) | 58 | res := runCLI(t, envFor(url), "", "version").ok(t) | ||
| 59 | assert.Contains(t, res.stdout, "v0.4.0") | 59 | assert.Contains(t, res.stdout, "v0.4.0") | ||
| @@ -61,7 +61,7 @@ func TestVersionWarnsAboutMissingFeatures(t *testing.T) { | |||||
| 61 | assert.NotContains(t, res.stderr, "repo.update") | 61 | assert.NotContains(t, res.stderr, "repo.update") | ||
| 62 | 62 | ||||
| 63 | res = runCLI(t, envFor(url), "", "version", "--json").ok(t) | 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 | assert.Contains(t, res.stderr, "events.resume", "the warning also goes to stderr with --json") | 65 | assert.Contains(t, res.stderr, "events.resume", "the warning also goes to stderr with --json") | ||
| 66 | 66 | ||||
| 67 | f = versionFake() | 67 | f = versionFake() | ||
M internal/app/cli_watch.go +30 -0
| @@ -29,6 +29,8 @@ type jsonEvent struct { | |||||
| 29 | MergeRequest *jsonMergeRequest `json:"merge_request,omitempty"` | 29 | MergeRequest *jsonMergeRequest `json:"merge_request,omitempty"` | ||
| 30 | Number int64 `json:"number,omitempty"` | 30 | Number int64 `json:"number,omitempty"` | ||
| 31 | Comment *jsonComment `json:"comment,omitempty"` | 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 | type jsonRefUpdate struct { | 36 | type jsonRefUpdate struct { | ||
| @@ -52,6 +54,7 @@ const ( | |||||
| 52 | eventCommentEdited = "comment_edited" | 54 | eventCommentEdited = "comment_edited" | ||
| 53 | eventCommentDeleted = "comment_deleted" | 55 | eventCommentDeleted = "comment_deleted" | ||
| 54 | eventCommentResolved = "comment_resolved" | 56 | eventCommentResolved = "comment_resolved" | ||
| 57 | + | eventMRMergeability = "merge_request_mergeability" | |||
| 55 | ) | 58 | ) | ||
| 56 | 59 | ||||
| 57 | // toJSONEvent maps an event; ok is false for kinds this client does not know, | 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 | case *ermite.Event_CommentResolved: | 97 | case *ermite.Event_CommentResolved: | ||
| 95 | c := toJSONComment(k.CommentResolved.GetComment()) | 98 | c := toJSONComment(k.CommentResolved.GetComment()) | ||
| 96 | out.Type, out.Repo, out.Number, out.Comment = eventCommentResolved, k.CommentResolved.GetRepo(), k.CommentResolved.GetNumber(), &c | 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 | case *ermite.Event_Gap: | 103 | case *ermite.Event_Gap: | ||
| 98 | out.Type = eventGap | 104 | out.Type = eventGap | ||
| 99 | default: | 105 | default: | ||
| @@ -126,12 +132,36 @@ func eventLine(e jsonEvent) string { | |||||
| 126 | detail = fmt.Sprintf("%s!%d %s -> %s", e.Repo, e.MergeRequest.Number, e.MergeRequest.SourceBranch, e.MergeRequest.TargetBranch) | 132 | detail = fmt.Sprintf("%s!%d %s -> %s", e.Repo, e.MergeRequest.Number, e.MergeRequest.SourceBranch, e.MergeRequest.TargetBranch) | ||
| 127 | case eventCommentAdded, eventCommentEdited, eventCommentDeleted, eventCommentResolved: | 133 | case eventCommentAdded, eventCommentEdited, eventCommentDeleted, eventCommentResolved: | ||
| 128 | detail = fmt.Sprintf("%s!%d by %s", e.Repo, e.Number, e.Comment.Author) | 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 | case eventGap: | 137 | case eventGap: | ||
| 130 | detail = "events were lost, reload what you display" | 138 | detail = "events were lost, reload what you display" | ||
| 131 | } | 139 | } | ||
| 132 | return clean(ts + " " + e.Type + " " + detail) | 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 | func refUpdateText(u jsonRefUpdate) string { | 165 | func refUpdateText(u jsonRefUpdate) string { | ||
| 136 | short := func(id string) string { | 166 | short := func(id string) string { | ||
| 137 | if len(id) > 8 { | 167 | if len(id) > 8 { | ||
M internal/app/cli_watch_test.go +43 -0
| @@ -200,6 +200,49 @@ func TestWatchMergeRequestEvents(t *testing.T) { | |||||
| 200 | assert.Equal(t, "merge_request_closed", decode[eventOut](t, lines[3]).Type) | 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 | func TestNoteWatchEvents(t *testing.T) { | 246 | func TestNoteWatchEvents(t *testing.T) { | ||
| 204 | 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"} | 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 | events := []*ermite.Event{ | 248 | events := []*ermite.Event{ | ||
M internal/app/forge.go +14 -1
| @@ -137,6 +137,8 @@ type forge struct { | |||||
| 137 | // Inbox serves the owner's inbox of the web. | 137 | // Inbox serves the owner's inbox of the web. | ||
| 138 | Inbox *usecase.Inbox | 138 | Inbox *usecase.Inbox | ||
| 139 | SigningKey *usecase.SigningKey | 139 | SigningKey *usecase.SigningKey | ||
| 140 | + | // Mergeability keeps the mergeability of the open merge requests up to date. | |||
| 141 | + | Mergeability *usecase.Mergeability | |||
| 140 | // Signatures are the trusted keys and the commit signature checks | 142 | // Signatures are the trusted keys and the commit signature checks | ||
| 141 | // (ADR 0030). | 143 | // (ADR 0030). | ||
| 142 | Signatures *usecase.Signatures | 144 | Signatures *usecase.Signatures | ||
| @@ -331,11 +333,16 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e | |||||
| 331 | if err := f.signatures(store, reader, signer.PublicKey(), committer, clock); err != nil { | 333 | if err := f.signatures(store, reader, signer.PublicKey(), committer, clock); err != nil { | ||
| 332 | return nil, err | 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 | f.MergeRequests = usecase.NewMergeRequests(usecase.MergeRequestsConfig{ | 340 | f.MergeRequests = usecase.NewMergeRequests(usecase.MergeRequestsConfig{ | ||
| 335 | Meta: store, Store: store.MergeRequests(), Repos: git, Git: git, Signer: signer, | 341 | Meta: store, Store: store.MergeRequests(), Repos: git, Git: git, Signer: signer, | ||
| 336 | Committer: committer, Bus: f.Bus, Clock: clock, Maint: f.maint, | 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 | f.MergeRequests.WithVerifier(f.Signatures) | 346 | f.MergeRequests.WithVerifier(f.Signatures) | ||
| 340 | f.Issues = usecase.NewIssues(usecase.IssuesConfig{ | 347 | f.Issues = usecase.NewIssues(usecase.IssuesConfig{ | ||
| 341 | Meta: store, Store: store.Issues(), MergeRequests: store.MergeRequests(), Bus: f.Bus, Clock: clock, | 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 | if f.hooks != nil { | 519 | if f.hooks != nil { | ||
| 513 | f.hooks.Close(ctx) | 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 | if f.CI != nil { | 525 | if f.CI != nil { | ||
| 516 | f.CI.Close(ctx) | 526 | f.CI.Close(ctx) | ||
| 517 | } | 527 | } | ||
| 528 | + | if f.Mergeability != nil { | |||
| 529 | + | f.Mergeability.Close(ctx) | |||
| 530 | + | } | |||
| 518 | if f.EventLog != nil { | 531 | if f.EventLog != nil { | ||
| 519 | f.EventLog.Close(ctx) | 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 | got, err := h.api("").repo.GetRepo(ctx, connect.NewRequest(&pb.GetRepoRequest{Repo: "proj"})) | 299 | got, err := h.api("").repo.GetRepo(ctx, connect.NewRequest(&pb.GetRepoRequest{Repo: "proj"})) | ||
| 300 | require.NoError(t, err) | 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 | assert.True(t, got.Msg.GetRepo().GetSettings().GetFailingChecksBlock()) | 302 | assert.True(t, got.Msg.GetRepo().GetSettings().GetFailingChecksBlock()) | ||
| 303 | 303 | ||||
| 304 | update := func(c *clients, paths []string, s *pb.RepoSettings) (*pb.Repo, error) { | 304 | update := func(c *clients, paths []string, s *pb.RepoSettings) (*pb.Repo, error) { | ||
| @@ -375,7 +375,8 @@ func TestMergeDeletesTheSourceOverTheAPI(t *testing.T) { | |||||
| 375 | Repo: "proj", Number: 1, HeadId: r.head, DeleteSourceBranch: true, | 375 | Repo: "proj", Number: 1, HeadId: r.head, DeleteSourceBranch: true, | ||
| 376 | })) | 376 | })) | ||
| 377 | require.NoError(t, err) | 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 | assert.Empty(t, r.bareGit("proj", "for-each-ref", "refs/heads/feat")) | 380 | assert.Empty(t, r.bareGit("proj", "for-each-ref", "refs/heads/feat")) | ||
| 380 | 381 | ||||
| 381 | // The default branch as a source: refused before anything changes. | 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 | htmx:sse:close, htmx:sse:error -> ermiteLive.lost(); | 972 | htmx:sse:close, htmx:sse:error -> ermiteLive.lost(); | ||
| 973 | htmx:sse:error -> ermiteLive.error(event.detail.status); | 973 | htmx:sse:error -> ermiteLive.error(event.detail.status); | ||
| 974 | htmx:sse:before:connection -> ermiteLive.gate(event); | 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 | gap -> ermiteLive.reload()"> | 976 | gap -> ermiteLive.reload()"> | ||
| 977 | </span> | 977 | </span> | ||
| 978 | </div> | 978 | </div> | ||
M internal/app/testdata/web/commits-small.html +1 -1
| @@ -317,7 +317,7 @@ | |||||
| 317 | htmx:sse:close, htmx:sse:error -> ermiteLive.lost(); | 317 | htmx:sse:close, htmx:sse:error -> ermiteLive.lost(); | ||
| 318 | htmx:sse:error -> ermiteLive.error(event.detail.status); | 318 | htmx:sse:error -> ermiteLive.error(event.detail.status); | ||
| 319 | htmx:sse:before:connection -> ermiteLive.gate(event); | 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 | gap -> ermiteLive.reload()"> | 321 | gap -> ermiteLive.reload()"> | ||
| 322 | </span> | 322 | </span> | ||
| 323 | </div> | 323 | </div> | ||
M internal/app/testdata/web/home.html +1 -1
| @@ -191,7 +191,7 @@ | |||||
| 191 | htmx:sse:close, htmx:sse:error -> ermiteLive.lost(); | 191 | htmx:sse:close, htmx:sse:error -> ermiteLive.lost(); | ||
| 192 | htmx:sse:error -> ermiteLive.error(event.detail.status); | 192 | htmx:sse:error -> ermiteLive.error(event.detail.status); | ||
| 193 | htmx:sse:before:connection -> ermiteLive.gate(event); | 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 | gap -> ermiteLive.reload()"> | 195 | gap -> ermiteLive.reload()"> | ||
| 196 | </span> | 196 | </span> | ||
| 197 | </div> | 197 | </div> | ||
M internal/app/testdata/web/refs.html +1 -1
| @@ -199,7 +199,7 @@ | |||||
| 199 | htmx:sse:close, htmx:sse:error -> ermiteLive.lost(); | 199 | htmx:sse:close, htmx:sse:error -> ermiteLive.lost(); | ||
| 200 | htmx:sse:error -> ermiteLive.error(event.detail.status); | 200 | htmx:sse:error -> ermiteLive.error(event.detail.status); | ||
| 201 | htmx:sse:before:connection -> ermiteLive.gate(event); | 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 | gap -> ermiteLive.reload()"> | 203 | gap -> ermiteLive.reload()"> | ||
| 204 | </span> | 204 | </span> | ||
| 205 | </div> | 205 | </div> | ||
M internal/app/testdata/web/summary.html +1 -1
| @@ -225,7 +225,7 @@ | |||||
| 225 | htmx:sse:close, htmx:sse:error -> ermiteLive.lost(); | 225 | htmx:sse:close, htmx:sse:error -> ermiteLive.lost(); | ||
| 226 | htmx:sse:error -> ermiteLive.error(event.detail.status); | 226 | htmx:sse:error -> ermiteLive.error(event.detail.status); | ||
| 227 | htmx:sse:before:connection -> ermiteLive.gate(event); | 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 | gap -> ermiteLive.reload()"> | 229 | gap -> ermiteLive.reload()"> | ||
| 230 | </span> | 230 | </span> | ||
| 231 | </div> | 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 | if f.Passkeys != nil { | 35 | if f.Passkeys != nil { | ||
| 36 | passkeys = webPasskeys{f.Passkeys} | 36 | passkeys = webPasskeys{f.Passkeys} | ||
| 37 | } | 37 | } | ||
| 38 | + | var signing ports.SigningKeyService | |||
| 39 | + | if f.SigningKey != nil { | |||
| 40 | + | signing = f.SigningKey | |||
| 41 | + | } | |||
| 38 | // Without artifacts (ERMITE_CI_ARTIFACTS=0) the run page has no section | 42 | // Without artifacts (ERMITE_CI_ARTIFACTS=0) the run page has no section | ||
| 39 | // for them and no download route. | 43 | // for them and no download route. | ||
| 40 | var artifacts ports.ArtifactService | 44 | var artifacts ports.ArtifactService | ||
| @@ -50,6 +54,7 @@ func newWeb(cfg config.Config, f *forge, limiter *clientlimit.Limiter) (http.Han | |||||
| 50 | Inbox: f.Inbox, | 54 | Inbox: f.Inbox, | ||
| 51 | IssueForm: f.IssueSubmissions, | 55 | IssueForm: f.IssueSubmissions, | ||
| 52 | Moderation: f.IssueModeration, | 56 | Moderation: f.IssueModeration, | ||
| 57 | + | SigningKey: signing, | |||
| 53 | Runs: f.CI, | 58 | Runs: f.CI, | ||
| 54 | Artifacts: artifacts, | 59 | Artifacts: artifacts, | ||
| 55 | Events: f.EventLog, | 60 | Events: f.EventLog, | ||
M internal/app/web_events_test.go +9 -0
| @@ -37,6 +37,12 @@ type sseStream struct { | |||||
| 37 | // background. It returns once the server answered, which it does when the | 37 | // background. It returns once the server answered, which it does when the | ||
| 38 | // watch is registered: what happens after is not missed. | 38 | // watch is registered: what happens after is not missed. | ||
| 39 | func (h *harness) sse(query, lastID string) *sseStream { | 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 | h.t.Helper() | 46 | h.t.Helper() | ||
| 41 | ctx, cancel := context.WithCancel(h.ctx) | 47 | ctx, cancel := context.WithCancel(h.ctx) | ||
| 42 | h.t.Cleanup(cancel) | 48 | h.t.Cleanup(cancel) | ||
| @@ -45,6 +51,9 @@ func (h *harness) sse(query, lastID string) *sseStream { | |||||
| 45 | if lastID != "" { | 51 | if lastID != "" { | ||
| 46 | req.Header.Set("Last-Event-ID", lastID) | 52 | req.Header.Set("Last-Event-ID", lastID) | ||
| 47 | } | 53 | } | ||
| 54 | + | for k, v := range headers { | |||
| 55 | + | req.Header.Set(k, v) | |||
| 56 | + | } | |||
| 48 | resp, err := http.DefaultClient.Do(req) | 57 | resp, err := http.DefaultClient.Do(req) | ||
| 49 | require.NoError(h.t, err) | 58 | require.NoError(h.t, err) | ||
| 50 | t := h.t | 59 | t := h.t | ||
M internal/app/web_mr_test.go +202 -10
| @@ -10,6 +10,7 @@ import ( | |||||
| 10 | "regexp" | 10 | "regexp" | ||
| 11 | "strings" | 11 | "strings" | ||
| 12 | "testing" | 12 | "testing" | ||
| 13 | + | "time" | |||
| 13 | 14 | ||||
| 14 | "github.com/stretchr/testify/assert" | 15 | "github.com/stretchr/testify/assert" | ||
| 15 | "github.com/stretchr/testify/require" | 16 | "github.com/stretchr/testify/require" | ||
| @@ -165,12 +166,15 @@ func TestWebMergeRequestPagesRender(t *testing.T) { | |||||
| 165 | assert.Equal(t, head, formHead(t, own.body)) | 166 | assert.Equal(t, head, formHead(t, own.body)) | ||
| 166 | assert.Contains(t, own.body, `action="/proj/merge-requests/1/merge"`) | 167 | assert.Contains(t, own.body, `action="/proj/merge-requests/1/merge"`) | ||
| 167 | 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, `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, `name="mode" value="fast-forward" checked`, "the first possible mode is the default") |
| 169 | - | assert.Contains(t, own.body, `<input type="radio" name="mode" value="squash">`) | 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 | assert.Contains(t, own.body, "ermite mr merge proj 1 --head "+head) | 172 | assert.Contains(t, own.body, "ermite mr merge proj 1 --head "+head) | ||
| 171 | assert.Contains(t, own.body, `name="delete_branch" value="1"> delete feat/search after the merge <kbd aria-hidden="true">D</kbd>`) | 173 | assert.Contains(t, own.body, `name="delete_branch" value="1"> delete feat/search after the merge <kbd aria-hidden="true">D</kbd>`) | ||
| 172 | assert.NotContains(t, own.body, `name="delete_branch" value="1" checked>`, "unchecked by default") | 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 | assert.Contains(t, own.body, `action="/proj/merge-requests/1/comments"`) | 178 | assert.Contains(t, own.body, `action="/proj/merge-requests/1/comments"`) | ||
| 175 | assert.Contains(t, own.body, `action="/proj/merge-requests/1/close"`) | 179 | assert.Contains(t, own.body, `action="/proj/merge-requests/1/close"`) | ||
| 176 | assert.Contains(t, own.body, `href="/proj/merge-requests/1?on=new:1:search.go#f`, "line numbers link to their form") | 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 | assert.Contains(t, b.get("/proj/merge-requests/1", as(token)).body, `name="delete_branch" value="1"`, "the box offers to delete the branch") | 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 | res = b.form("/proj/merge-requests/1/merge", token, url.Values{"mode": {"squash"}, "head_id": {head}, "delete_branch": {"1"}}) | 397 | res = b.form("/proj/merge-requests/1/merge", token, url.Values{"mode": {"squash"}, "head_id": {head}, "delete_branch": {"1"}}) | ||
| 394 | require.Equal(t, http.StatusSeeOther, res.status, res.body) | 398 | require.Equal(t, http.StatusSeeOther, res.status, res.body) | ||
| 399 | + | r.forge.MergeRequests.Wait(r.ctx) // the deletion follows the redirect | |||
| 395 | assert.Empty(t, r.bareGit("proj", "for-each-ref", "refs/heads/feat/search"), "the source branch is deleted") | 400 | assert.Empty(t, r.bareGit("proj", "for-each-ref", "refs/heads/feat/search"), "the source branch is deleted") | ||
| 396 | merged := r.headOf("main") | 401 | merged := r.headOf("main") | ||
| 397 | assert.NotEqual(t, head, merged, "squash makes a new commit") | 402 | assert.NotEqual(t, head, merged, "squash makes a new commit") | ||
| @@ -461,6 +466,9 @@ func TestWebMergeRequestDraft(t *testing.T) { | |||||
| 461 | 466 | ||||
| 462 | res := b.form("/proj/merge-requests/1/merge", token, url.Values{"mode": {"fast-forward"}, "head_id": {head}}) | 467 | res := b.form("/proj/merge-requests/1/merge", token, url.Values{"mode": {"fast-forward"}, "head_id": {head}}) | ||
| 463 | assert.Equal(t, http.StatusConflict, res.status, "a draft is not merged") | 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 | res = b.form("/proj/merge-requests/1/ready", token, nil) | 473 | res = b.form("/proj/merge-requests/1/ready", token, nil) | ||
| 466 | require.Equal(t, http.StatusSeeOther, res.status, res.body) | 474 | require.Equal(t, http.StatusSeeOther, res.status, res.body) | ||
| @@ -511,6 +519,7 @@ func TestWebMergeDeleteBranchBox(t *testing.T) { | |||||
| 511 | one := b.get("/proj/merge-requests/1", as(token)) | 519 | one := b.get("/proj/merge-requests/1", as(token)) | ||
| 512 | res := b.form("/proj/merge-requests/1/merge", token, url.Values{"mode": {"fast-forward"}, "head_id": {formHead(t, one.body)}, "delete_branch": {"1"}}) | 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 | require.Equal(t, http.StatusSeeOther, res.status, res.body) | 521 | require.Equal(t, http.StatusSeeOther, res.status, res.body) | ||
| 522 | + | r.forge.MergeRequests.Wait(r.ctx) // the deletion follows the redirect | |||
| 514 | assert.Empty(t, r.bareGit("proj", "for-each-ref", "refs/heads/feat/search")) | 523 | assert.Empty(t, r.bareGit("proj", "for-each-ref", "refs/heads/feat/search")) | ||
| 515 | done := b.get("/proj/merge-requests/1", nil) | 524 | done := b.get("/proj/merge-requests/1", nil) | ||
| 516 | assert.Equal(t, 200, done.status, "the merged page reads without its branch") | 525 | assert.Equal(t, 200, done.status, "the merged page reads without its branch") | ||
| @@ -569,8 +578,7 @@ func TestWebMergeRequestEvents(t *testing.T) { | |||||
| 569 | r := newMRRepo(t, domain.VisibilityPublic) | 578 | r := newMRRepo(t, domain.VisibilityPublic) | ||
| 570 | list := r.sse("topic=repo:proj&view=mrs", "") | 579 | list := r.sse("topic=repo:proj&view=mrs", "") | ||
| 571 | r.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/search", "--title", "Secret <title>").ok(t) | 580 | r.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/search", "--title", "Secret <title>").ok(t) | ||
| 572 | - | ev := list.next(t, watchWait) | 581 | + | ev := nextNamed(t, list, "mr-notice") |
| 573 | - | assert.Equal(t, "mr-notice", ev.name) | |||
| 574 | assert.Contains(t, ev.data, `<div id="mr-notice" class="live-slot" role="status" data-slot>`) | 582 | assert.Contains(t, ev.data, `<div id="mr-notice" class="live-slot" role="status" data-slot>`) | ||
| 575 | assert.Contains(t, ev.data, `href="/proj/merge-requests"`) | 583 | assert.Contains(t, ev.data, `href="/proj/merge-requests"`) | ||
| 576 | assert.NotContains(t, ev.data, "Secret") | 584 | assert.NotContains(t, ev.data, "Secret") | ||
| @@ -578,19 +586,18 @@ func TestWebMergeRequestEvents(t *testing.T) { | |||||
| 578 | one := r.sse("topic=repo:proj&view=mr&n=1&ref=feat/search", "") | 586 | one := r.sse("topic=repo:proj&view=mr&n=1&ref=feat/search", "") | ||
| 579 | other := r.sse("topic=repo:proj&view=mr&n=2&ref=feat/other", "") | 587 | other := r.sse("topic=repo:proj&view=mr&n=2&ref=feat/other", "") | ||
| 580 | r.cli(t, adminToken, "mr", "comment", "proj", "1", "-m", "private words").ok(t) | 588 | r.cli(t, adminToken, "mr", "comment", "proj", "1", "-m", "private words").ok(t) | ||
| 581 | - | ev = one.next(t, watchWait) | 589 | + | ev = nextNamed(t, one, "mr-notice") |
| 582 | - | assert.Equal(t, "mr-notice", ev.name) | |||
| 583 | assert.Contains(t, ev.data, "a comment was added") | 590 | assert.Contains(t, ev.data, "a comment was added") | ||
| 584 | assert.Contains(t, ev.data, `href="/proj/merge-requests/1"`) | 591 | assert.Contains(t, ev.data, `href="/proj/merge-requests/1"`) | ||
| 585 | assert.NotContains(t, ev.data, "private words") | 592 | assert.NotContains(t, ev.data, "private words") | ||
| 586 | 593 | ||||
| 587 | r.writeFile(r.wt, "more.txt", []byte("more\n")) | 594 | r.writeFile(r.wt, "more.txt", []byte("more\n")) | ||
| 588 | r.mustGit(r.wt, "push", "-q", r.url, "feat/search") | 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 | assert.Contains(t, ev.data, "the branch changed") | 597 | assert.Contains(t, ev.data, "the branch changed") | ||
| 591 | 598 | ||||
| 592 | r.cli(t, adminToken, "mr", "close", "proj", "1").ok(t) | 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 | // A page of another request hears nothing of this one. | 602 | // A page of another request hears nothing of this one. | ||
| 596 | for drained := false; !drained; { | 603 | for drained := false; !drained; { | ||
| @@ -603,7 +610,20 @@ func TestWebMergeRequestEvents(t *testing.T) { | |||||
| 603 | drained = true | 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 | func (r *mrRepo) get1(path string) string { | 629 | func (r *mrRepo) get1(path string) string { | ||
| @@ -722,3 +742,175 @@ func TestWebMergeRequestFileWindows(t *testing.T) { | |||||
| 722 | } | 742 | } | ||
| 723 | 743 | ||||
| 724 | var reWindowSrc = regexp.MustCompile(`data-src="([^"]*from=25[^"]*)"`) | 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 | // EventKind implements Event. | 284 | // EventKind implements Event. | ||
| 285 | func (CommentResolved) EventKind() string { return "mr.comment_resolved" } | 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 | // EventGap tells a subscriber that it missed events because it was too slow; | 301 | // EventGap tells a subscriber that it missed events because it was too slow; | ||
| 288 | // it must refresh its whole view. | 302 | // it must refresh its whole view. | ||
| 289 | type EventGap struct{} | 303 | type EventGap struct{} | ||
M internal/domain/mergerequest.go +155 -16
| @@ -18,18 +18,23 @@ const ( | |||||
| 18 | MergeModeFastForward MergeMode = "fast-forward" | 18 | MergeModeFastForward MergeMode = "fast-forward" | ||
| 19 | // MergeModeSquash makes one new signed commit with the tree of the head. | 19 | // MergeModeSquash makes one new signed commit with the tree of the head. | ||
| 20 | MergeModeSquash MergeMode = "squash" | 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. | 26 | + | // MergeModes returns the modes in their canonical order. |
| 24 | - | func MergeModes() []MergeMode { return []MergeMode{MergeModeFastForward, MergeModeSquash} } | 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 | func ParseMergeMode(s string) (MergeMode, error) { | 32 | func ParseMergeMode(s string) (MergeMode, error) { | ||
| 28 | m := MergeMode(s) | 33 | m := MergeMode(s) | ||
| 29 | if slices.Contains(MergeModes(), m) { | 34 | if slices.Contains(MergeModes(), m) { | ||
| 30 | return m, nil | 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 | // String returns the mode as text. | 40 | // String returns the mode as text. | ||
| @@ -47,7 +52,7 @@ type RepoSettings struct { | |||||
| 47 | Issues IssuesMode | 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 | // failing checks block the merge. | 56 | // failing checks block the merge. | ||
| 52 | func DefaultRepoSettings() RepoSettings { | 57 | func DefaultRepoSettings() RepoSettings { | ||
| 53 | return RepoSettings{AllowedMergeModes: MergeModes(), FailingChecksBlock: true, Issues: IssuesOff} | 58 | return RepoSettings{AllowedMergeModes: MergeModes(), FailingChecksBlock: true, Issues: IssuesOff} | ||
| @@ -249,10 +254,10 @@ type Identity struct { | |||||
| 249 | Name, Email string | 254 | Name, Email string | ||
| 250 | } | 255 | } | ||
| 251 | 256 | ||||
| 252 | - | // SquashCommit is the commit a squash merge creates: the tree of the head on | 257 | + | // ServerCommit is a commit the server creates: a squash or a merge commit. |
| 253 | - | // top of the target. | 258 | + | type ServerCommit struct { |
| 254 | - | type SquashCommit struct { | 259 | + | Tree ObjectID |
| 255 | - | Tree, Parent ObjectID | 260 | + | Parents []ObjectID |
| 256 | Author, Committer Signature | 261 | Author, Committer Signature | ||
| 257 | Message string | 262 | Message string | ||
| 258 | } | 263 | } | ||
| @@ -267,7 +272,7 @@ const MaxSquashCommits = 1000 | |||||
| 267 | // trailers, in order of appearance. The message is the title, " (!<number>)", | 272 | // trailers, in order of appearance. The message is the title, " (!<number>)", | ||
| 268 | // a blank line, the summaries of the commits oldest first as a list, and the | 273 | // a blank line, the summaries of the commits oldest first as a list, and the | ||
| 269 | // trailers. | 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 | oldest := slices.Clone(commits) | 276 | oldest := slices.Clone(commits) | ||
| 272 | slices.Reverse(oldest) | 277 | slices.Reverse(oldest) | ||
| 273 | var first Signature | 278 | var first Signature | ||
| @@ -293,26 +298,41 @@ func NewSquashCommit(mr MergeRequest, tree, parent ObjectID, commits []Commit, c | |||||
| 293 | if len(trailers) > 0 { | 298 | if len(trailers) > 0 { | ||
| 294 | msg.WriteString("\n" + strings.Join(trailers, "\n") + "\n") | 299 | msg.WriteString("\n" + strings.Join(trailers, "\n") + "\n") | ||
| 295 | } | 300 | } | ||
| 296 | - | return SquashCommit{ | 301 | + | return ServerCommit{ |
| 297 | - | Tree: tree, Parent: parent, | 302 | + | Tree: tree, Parents: []ObjectID{parent}, |
| 298 | Author: first, | 303 | Author: first, | ||
| 299 | Committer: Signature{Name: committer.Name, Email: committer.Email, Time: at.UTC()}, | 304 | Committer: Signature{Name: committer.Name, Email: committer.Email, Time: at.UTC()}, | ||
| 300 | Message: msg.String(), | 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 | // Payload returns the text of the commit object without a signature, which is | 322 | // Payload returns the text of the commit object without a signature, which is | ||
| 305 | // what the signature covers. | 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 | // Signed returns the text of the commit object with the armored SSH signature | 326 | // Signed returns the text of the commit object with the armored SSH signature | ||
| 309 | // in its gpgsig header. | 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 | var b strings.Builder | 331 | var b strings.Builder | ||
| 314 | b.WriteString("tree " + c.Tree.String() + "\n") | 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 | b.WriteString("author " + signatureLine(c.Author) + "\n") | 336 | b.WriteString("author " + signatureLine(c.Author) + "\n") | ||
| 317 | b.WriteString("committer " + signatureLine(c.Committer) + "\n") | 337 | b.WriteString("committer " + signatureLine(c.Committer) + "\n") | ||
| 318 | if armored != "" { | 338 | if armored != "" { | ||
| @@ -430,3 +450,122 @@ func CurrentVerdicts(comments []Comment, head ObjectID) []AuthorVerdict { | |||||
| 430 | }) | 450 | }) | ||
| 431 | return out | 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 | package domain_test | 3 | package domain_test | ||
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | + | "strconv" | |||
| 6 | "strings" | 7 | "strings" | ||
| 7 | "testing" | 8 | "testing" | ||
| 8 | "time" | 9 | "time" | ||
| @@ -23,7 +24,7 @@ func oid(t *testing.T, c byte) domain.ObjectID { | |||||
| 23 | func TestDefaultRepoSettings(t *testing.T) { | 24 | func TestDefaultRepoSettings(t *testing.T) { | ||
| 24 | t.Parallel() | 25 | t.Parallel() | ||
| 25 | s := domain.DefaultRepoSettings() | 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 | assert.True(t, s.FailingChecksBlock) | 28 | assert.True(t, s.FailingChecksBlock) | ||
| 28 | assert.True(t, s.Allows(domain.MergeModeSquash)) | 29 | assert.True(t, s.Allows(domain.MergeModeSquash)) | ||
| 29 | assert.False(t, domain.RepoSettings{}.Allows(domain.MergeModeSquash)) | 30 | assert.False(t, domain.RepoSettings{}.Allows(domain.MergeModeSquash)) | ||
| @@ -39,7 +40,7 @@ func TestNormalizeMergeModes(t *testing.T) { | |||||
| 39 | t.Parallel() | 40 | t.Parallel() | ||
| 40 | got, err := domain.NormalizeMergeModes([]domain.MergeMode{domain.MergeModeSquash, domain.MergeModeFastForward, domain.MergeModeSquash}) | 41 | got, err := domain.NormalizeMergeModes([]domain.MergeMode{domain.MergeModeSquash, domain.MergeModeFastForward, domain.MergeModeSquash}) | ||
| 41 | require.NoError(t, err) | 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 | got, err = domain.NormalizeMergeModes([]domain.MergeMode{domain.MergeModeSquash}) | 45 | got, err = domain.NormalizeMergeModes([]domain.MergeMode{domain.MergeModeSquash}) | ||
| 45 | require.NoError(t, err) | 46 | require.NoError(t, err) | ||
| @@ -56,7 +57,10 @@ func TestParseMergeMode(t *testing.T) { | |||||
| 56 | m, err := domain.ParseMergeMode("fast-forward") | 57 | m, err := domain.ParseMergeMode("fast-forward") | ||
| 57 | require.NoError(t, err) | 58 | require.NoError(t, err) | ||
| 58 | assert.Equal(t, domain.MergeModeFastForward, m) | 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 | assert.ErrorIs(t, err, domain.ErrInvalid) | 64 | assert.ErrorIs(t, err, domain.ErrInvalid) | ||
| 61 | } | 65 | } | ||
| 62 | 66 | ||||
| @@ -283,3 +287,46 @@ func TestReviewScopeOwnerRights(t *testing.T) { | |||||
| 283 | assert.Equal(t, tc.want, domain.AuthorName(tc.p)) | 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 | GetRefTip(ctx context.Context, repo domain.RepoName, ref string) (domain.CIRefTip, error) | 93 | GetRefTip(ctx context.Context, repo domain.RepoName, ref string) (domain.CIRefTip, error) | ||
| 94 | // DeleteRefTip removes the record of a ref; a missing one is not an error. | 94 | // DeleteRefTip removes the record of a ref; a missing one is not an error. | ||
| 95 | DeleteRefTip(ctx context.Context, repo domain.RepoName, ref string) error | 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 | // CIWorkflowStore keeps, per repository, the workflow files that are disabled | 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 | MergeRequests []domain.MergeRequest | 198 | MergeRequests []domain.MergeRequest | ||
| 199 | // NextPageToken is empty on the last page. | 199 | // NextPageToken is empty on the last page. | ||
| 200 | NextPageToken string | 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 | // ListIssuesParams selects a page of issues from an IssueStore. | 206 | // ListIssuesParams selects a page of issues from an IssueStore. | ||
| @@ -262,6 +265,8 @@ type MergeRequestDetail struct { | |||||
| 262 | Comments []domain.Comment | 265 | Comments []domain.Comment | ||
| 263 | // Verdicts are the latest verdict per author in note ID order. | 266 | // Verdicts are the latest verdict per author in note ID order. | ||
| 264 | Verdicts []domain.AuthorVerdict | 267 | Verdicts []domain.AuthorVerdict | ||
| 268 | + | // Mergeability is zero unless the request is open. | |||
| 269 | + | Mergeability domain.Mergeability | |||
| 265 | // LastReadID is the head recorded by MarkRead; zero when none. | 270 | // LastReadID is the head recorded by MarkRead; zero when none. | ||
| 266 | LastReadID domain.ObjectID | 271 | LastReadID domain.ObjectID | ||
| 267 | // LastReadLost tells that since-last-read was asked for, a head was | 272 | // LastReadLost tells that since-last-read was asked for, a head was | ||
| @@ -319,8 +324,9 @@ type MergeParams struct { | |||||
| 319 | // MergeResult is a merged request and what happened to its source branch. | 324 | // MergeResult is a merged request and what happened to its source branch. | ||
| 320 | type MergeResult struct { | 325 | type MergeResult struct { | ||
| 321 | domain.MergeRequest | 326 | domain.MergeRequest | ||
| 322 | - | // SourceDeleted is true when DeleteSource was asked and the branch is | 327 | + | // SourceDeleted is true when DeleteSource was asked: the deletion is |
| 323 | - | // gone. | 328 | + | // requested, and done asynchronously after the merge returned (it is |
| 329 | + | // skipped if the branch moved meanwhile). | |||
| 324 | SourceDeleted bool | 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 | return _c | 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 | // GetRefTip provides a mock function for the type CIRefTipStore | 182 | // GetRefTip provides a mock function for the type CIRefTipStore | ||
| 114 | func (_mock *CIRefTipStore) GetRefTip(ctx context.Context, repo domain.RepoName, ref string) (domain.CIRefTip, error) { | 183 | func (_mock *CIRefTipStore) GetRefTip(ctx context.Context, repo domain.RepoName, ref string) (domain.CIRefTip, error) { | ||
| 115 | ret := _mock.Called(ctx, repo, ref) | 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 | return _c | 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 | // DeleteBranch provides a mock function for the type MergeGit | 278 | // DeleteBranch provides a mock function for the type MergeGit | ||
| 195 | func (_mock *MergeGit) DeleteBranch(ctx context.Context, repo domain.RepoName, branch string, old domain.ObjectID) error { | 279 | func (_mock *MergeGit) DeleteBranch(ctx context.Context, repo domain.RepoName, branch string, old domain.ObjectID) error { | ||
| 196 | ret := _mock.Called(ctx, repo, branch, old) | 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 | return _c | 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 | // UpdateBranch provides a mock function for the type MergeGit | 835 | // UpdateBranch provides a mock function for the type MergeGit | ||
| 674 | func (_mock *MergeGit) UpdateBranch(ctx context.Context, repo domain.RepoName, branch string, old domain.ObjectID, new domain.ObjectID) error { | 836 | func (_mock *MergeGit) UpdateBranch(ctx context.Context, repo domain.RepoName, branch string, old domain.ObjectID, new domain.ObjectID) error { | ||
| 675 | ret := _mock.Called(ctx, repo, branch, old, new) | 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 | return _c | 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 | // ResolveComment provides a mock function for the type MergeRequestService | 1023 | // ResolveComment provides a mock function for the type MergeRequestService | ||
| 946 | func (_mock *MergeRequestService) ResolveComment(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, commentID int64, resolved bool) (domain.Comment, error) { | 1024 | func (_mock *MergeRequestService) ResolveComment(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, commentID int64, resolved bool) (domain.Comment, error) { | ||
| 947 | ret := _mock.Called(ctx, p, name, number, commentID, resolved) | 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 | return _c | 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 | // Get provides a mock function for the type MergeRequestStore | 245 | // Get provides a mock function for the type MergeRequestStore | ||
| 183 | func (_mock *MergeRequestStore) Get(ctx context.Context, repo domain.RepoName, number int64) (domain.MergeRequest, error) { | 246 | func (_mock *MergeRequestStore) Get(ctx context.Context, repo domain.RepoName, number int64) (domain.MergeRequest, error) { | ||
| 184 | ret := _mock.Called(ctx, repo, number) | 247 | ret := _mock.Called(ctx, repo, number) | ||
| @@ -329,6 +392,78 @@ func (_c *MergeRequestStore_GetComment_Call) RunAndReturn(run func(ctx context.C | |||||
| 329 | return _c | 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 | // GetRead provides a mock function for the type MergeRequestStore | 467 | // GetRead provides a mock function for the type MergeRequestStore | ||
| 333 | func (_mock *MergeRequestStore) GetRead(ctx context.Context, repo domain.RepoName, number int64) (domain.ObjectID, error) { | 468 | func (_mock *MergeRequestStore) GetRead(ctx context.Context, repo domain.RepoName, number int64) (domain.ObjectID, error) { | ||
| 334 | ret := _mock.Called(ctx, repo, number) | 469 | ret := _mock.Called(ctx, repo, number) | ||
| @@ -845,6 +980,63 @@ func (_c *MergeRequestStore_OpenByTarget_Call) RunAndReturn(run func(ctx context | |||||
| 845 | return _c | 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 | // SaveComment provides a mock function for the type MergeRequestStore | 1040 | // SaveComment provides a mock function for the type MergeRequestStore | ||
| 849 | func (_mock *MergeRequestStore) SaveComment(ctx context.Context, mr domain.MergeRequest, c domain.Comment) error { | 1041 | func (_mock *MergeRequestStore) SaveComment(ctx context.Context, mr domain.MergeRequest, c domain.Comment) error { | ||
| 850 | ret := _mock.Called(ctx, mr, c) | 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 | // domain.ErrConflict when concurrent writes to the repository keep it | 377 | // domain.ErrConflict when concurrent writes to the repository keep it | ||
| 378 | // from landing. | 378 | // from landing. | ||
| 379 | SetRead(ctx context.Context, repo domain.RepoName, number int64, head domain.ObjectID) error | 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 | // MergeGit is what merge requests need from the Git repositories beyond | 393 | // MergeGit is what merge requests need from the Git repositories beyond | ||
| @@ -412,6 +423,32 @@ type MergeGit interface { | |||||
| 412 | // DeleteBranch deletes refs/heads/<branch> if it still points to old. It | 423 | // DeleteBranch deletes refs/heads/<branch> if it still points to old. It | ||
| 413 | // returns domain.ErrConflict when the branch moved or is gone. | 424 | // returns domain.ErrConflict when the branch moved or is gone. | ||
| 414 | DeleteBranch(ctx context.Context, repo domain.RepoName, branch string, old domain.ObjectID) error | 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 | // CommitSigner signs the commits ermite creates (ADR 0022). The private key | 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 | // CountOpen is the number of open merge requests of a repository the | 124 | // CountOpen is the number of open merge requests of a repository the | ||
| 125 | // principal can read. It reads no Git: the tab of every page shows it. | 125 | // principal can read. It reads no Git: the tab of every page shows it. | ||
| 126 | CountOpen(ctx context.Context, p domain.Principal, name domain.RepoName) (int, error) | 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 | // IssueService is the issue use cases. *usecase.Issues implements it. | 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 | u.git.EXPECT().BranchHead(mock.Anything, u.repo.Name, "feat").Return(m.head, nil) | 54 | u.git.EXPECT().BranchHead(mock.Anything, u.repo.Name, "feat").Return(m.head, nil) | ||
| 55 | u.git.EXPECT().BranchHead(mock.Anything, u.repo.Name, "main").Return(tip, nil) | 55 | u.git.EXPECT().BranchHead(mock.Anything, u.repo.Name, "main").Return(tip, nil) | ||
| 56 | u.git.EXPECT().IsAncestor(mock.Anything, u.repo.Name, tip, m.head).Return(true, nil) | 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 | return m | 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 | return repoSnapshot(ev.Visibility, ev.CreatedAt) | 198 | return repoSnapshot(ev.Visibility, ev.CreatedAt) | ||
| 199 | case domain.CommentResolved: | 199 | case domain.CommentResolved: | ||
| 200 | return repoSnapshot(ev.Visibility, ev.CreatedAt) | 200 | return repoSnapshot(ev.Visibility, ev.CreatedAt) | ||
| 201 | + | case domain.MergeRequestMergeability: | |||
| 202 | + | return repoSnapshot(ev.Visibility, ev.CreatedAt) | |||
| 201 | case domain.RunUpdated: | 203 | case domain.RunUpdated: | ||
| 202 | return repoSnapshot(ev.Visibility, ev.CreatedAt) | 204 | return repoSnapshot(ev.Visibility, ev.CreatedAt) | ||
| 203 | case domain.JobUpdated: | 205 | case domain.JobUpdated: | ||
| @@ -541,6 +543,8 @@ func (e *Events) deliver(ctx context.Context, w *watch, be bufferedEvent, send f | |||||
| 541 | return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send) | 543 | return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send) | ||
| 542 | case domain.CommentResolved: | 544 | case domain.CommentResolved: | ||
| 543 | return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send) | 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 | case domain.RunUpdated: | 548 | case domain.RunUpdated: | ||
| 545 | // Home shows the CI state of each repository. | 549 | // Home shows the CI state of each repository. | ||
| 546 | return e.deliverRepo(ctx, w, ev.Run.Repo, false, be, out, send) | 550 | return e.deliverRepo(ctx, w, ev.Run.Repo, false, be, out, send) | ||
| @@ -645,7 +649,7 @@ func isRepoLifecycleEvent(ev domain.Event) bool { | |||||
| 645 | func isItemEvent(ev domain.Event) bool { | 649 | func isItemEvent(ev domain.Event) bool { | ||
| 646 | switch ev.(type) { | 650 | switch ev.(type) { | ||
| 647 | case domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, | 651 | case domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, | ||
| 648 | - | domain.MergeRequestReady, domain.CommentAdded, | 652 | + | domain.MergeRequestReady, domain.MergeRequestMergeability, domain.CommentAdded, |
| 649 | domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved: | 653 | domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved: | ||
| 650 | return true | 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 | // MaxSpent is the bound of the spent set. | 162 | // MaxSpent is the bound of the spent set. | ||
| 163 | const MaxSpent = maxSpent | 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 | "errors" | 8 | "errors" | ||
| 9 | "fmt" | 9 | "fmt" | ||
| 10 | "math" | 10 | "math" | ||
| 11 | + | "runtime/debug" | |||
| 11 | "slices" | 12 | "slices" | ||
| 12 | "strconv" | 13 | "strconv" | ||
| 13 | "strings" | 14 | "strings" | ||
| @@ -56,9 +57,15 @@ type MergeRequests struct { | |||||
| 56 | pulls ports.PullRequestTrigger // nil: no pull_request workflows | 57 | pulls ports.PullRequestTrigger // nil: no pull_request workflows | ||
| 57 | tips ports.CIRefTipStore // nil: who merges is not recorded | 58 | tips ports.CIRefTipStore // nil: who merges is not recorded | ||
| 58 | verifier ports.CommitVerifier // nil: commits are not checked (ADR 0030) | 59 | verifier ports.CommitVerifier // nil: commits are not checked (ADR 0030) | ||
| 60 | + | mergeab MergeabilityHook // nil: no mergeability | |||
| 59 | 61 | ||||
| 60 | mu sync.Mutex | 62 | mu sync.Mutex | ||
| 61 | locks map[domain.RepoName]chan struct{} | 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 | var ( | 71 | var ( | ||
| @@ -95,13 +102,16 @@ type MergeRequestsConfig struct { | |||||
| 95 | // deleted source branch) before the merge does, as a push does | 102 | // deleted source branch) before the merge does, as a push does | ||
| 96 | // (domain.CIRefTip). Optional. | 103 | // (domain.CIRefTip). Optional. | ||
| 97 | RefTips ports.CIRefTipStore | 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 | // NewMergeRequests builds the merge request use cases. | 110 | // NewMergeRequests builds the merge request use cases. | ||
| 101 | func NewMergeRequests(cfg MergeRequestsConfig) *MergeRequests { | 111 | func NewMergeRequests(cfg MergeRequestsConfig) *MergeRequests { | ||
| 102 | return &MergeRequests{ | 112 | return &MergeRequests{ | ||
| 103 | meta: cfg.Meta, store: cfg.Store, repos: cfg.Repos, git: cfg.Git, signer: cfg.Signer, | 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 | locks: make(map[domain.RepoName]chan struct{}), | 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 | return m | 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 | // lockRepo takes the merge lock of a repository, waiting until ctx ends. The | 141 | // lockRepo takes the merge lock of a repository, waiting until ctx ends. The | ||
| 117 | // merges of one repository, and the creation of its merge requests, run one at | 142 | // merges of one repository, and the creation of its merge requests, run one at | ||
| 118 | // a time. The returned function releases the lock. | 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 | return domain.MergeRequest{}, err | 280 | return domain.MergeRequest{}, err | ||
| 256 | } | 281 | } | ||
| 257 | m.bus.Publish(ctx, domain.MergeRequestOpened{MergeRequest: mr, At: now, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt}) | 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 | if m.pulls != nil { | 286 | if m.pulls != nil { | ||
| 259 | // The commits the opening is for are the ones of now. | 287 | // The commits the opening is for are the ones of now. | ||
| 260 | base, _ := m.branchOrZero(ctx, name, target) | 288 | base, _ := m.branchOrZero(ctx, name, target) | ||
| @@ -335,10 +363,25 @@ func (m *MergeRequests) List(ctx context.Context, p domain.Principal, name domai | |||||
| 335 | return (params.State == "" || mr.State == params.State) && (after == 0 || mr.Number < after) | 363 | return (params.State == "" || mr.State == params.State) && (after == 0 || mr.Number < after) | ||
| 336 | }, | 364 | }, | ||
| 337 | encodeMRToken) | 365 | encodeMRToken) | ||
| 366 | + | tips := map[string]domain.ObjectID{} // one BranchHead per distinct target | |||
| 338 | for i, mr := range page.MergeRequests { | 367 | for i, mr := range page.MergeRequests { | ||
| 339 | if page.MergeRequests[i], err = m.withHead(ctx, mr); err != nil { | 368 | if page.MergeRequests[i], err = m.withHead(ctx, mr); err != nil { | ||
| 340 | return ports.MergeRequestPage{}, err | 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 | return page, nil | 386 | return page, nil | ||
| 344 | } | 387 | } | ||
| @@ -395,6 +438,28 @@ func (m *MergeRequests) CountOpen(ctx context.Context, p domain.Principal, name | |||||
| 395 | return n, nil | 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 | func encodeMRToken(last domain.MergeRequest) string { | 463 | func encodeMRToken(last domain.MergeRequest) string { | ||
| 399 | return base64.RawURLEncoding.EncodeToString([]byte(mrTokenPrefix + strconv.FormatInt(last.Number, 10))) | 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 | return nil | 530 | return nil | ||
| 466 | } | 531 | } | ||
| 467 | d := ports.MergeRequestDetail{MergeRequest: mr} | 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 | if d.Comments, err = m.store.ListComments(ctx, name, number); err != nil { | 540 | if d.Comments, err = m.store.ListComments(ctx, name, number); err != nil { | ||
| 469 | return ports.MergeRequestDetail{}, fmt.Errorf("list comments of !%d in %q: %w", number, name, err) | 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 | // domain.ErrPreconditionFailed when the request is not open, the mode is not | 921 | // domain.ErrPreconditionFailed when the request is not open, the mode is not | ||
| 850 | // allowed by the settings of the repository, the source branch no longer | 922 | // allowed by the settings of the repository, the source branch no longer | ||
| 851 | // points to the head ("branch moved, read again") or is gone, the target is | 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 | 924 | + | // gone, the mode's own condition does not hold (see below), or the branch |
| 853 | - | // again), or the branch brings nothing. Checks come with CI (CI-1): there are | 925 | + | // brings nothing. Checks come with CI (CI-1): there are none yet, so the |
| 854 | - | // none yet, so the setting that lets failing checks block the merge has | 926 | + | // setting that lets failing checks block the merge has nothing to act on. |
| 855 | - | // nothing to act on. | |||
| 856 | // | 927 | // | ||
| 857 | - | // A fast-forward moves the target to the head, with the old value as a | 928 | + | // The conditions are always checked with Git, under the lock, never from the |
| 858 | - | // precondition. A squash writes one commit with the tree of the head, the | 929 | + | // stored mergeability. A fast-forward needs the target to be an ancestor of |
| 859 | - | // target as parent and the author and message of domain.NewSquashCommit, | 930 | + | // the head (the author rebases and pushes again) and moves the target to the |
| 860 | - | // signed by the instance key, and moves the target to it the same way. | 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 | // With DeleteSource the merge is also refused, before anything changes, when | 938 | // With DeleteSource the merge is also refused, before anything changes, when | ||
| 863 | // the source is the default branch or the source or target of another open | 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 | 940 | + | // request. |
| 865 | - | // head; the PushEvent then carries the deletion too. A failed deletion is | 941 | + | // |
| 866 | - | // logged and leaves the branch: the merge stands and SourceDeleted is false. | 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 | func (m *MergeRequests) Merge(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params ports.MergeParams) (ports.MergeResult, error) { | 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) | 949 | + | mr, err := m.merge(ctx, p, name, number, params) |
| 869 | - | return ports.MergeResult{MergeRequest: mr, SourceDeleted: deleted}, err | 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 | repo, err := m.writable(ctx, p, name, domain.ScopeMRMerge) | 992 | repo, err := m.writable(ctx, p, name, domain.ScopeMRMerge) | ||
| 874 | if err != nil { | 993 | if err != nil { | ||
| 875 | - | return domain.MergeRequest{}, false, err | 994 | + | return domain.MergeRequest{}, err |
| 876 | } | 995 | } | ||
| 877 | mode := params.Mode | 996 | mode := params.Mode | ||
| 878 | if mode == "" { | 997 | if mode == "" { | ||
| @@ -886,116 +1005,134 @@ func (m *MergeRequests) merge(ctx context.Context, p domain.Principal, name doma | |||||
| 886 | v.Add("head_id", "is required") | 1005 | v.Add("head_id", "is required") | ||
| 887 | } | 1006 | } | ||
| 888 | if err := v.Err(); err != nil { | 1007 | if err := v.Err(); err != nil { | ||
| 889 | - | return domain.MergeRequest{}, false, err | 1008 | + | return domain.MergeRequest{}, err |
| 890 | } | 1009 | } | ||
| 891 | 1010 | ||||
| 892 | unlock, err := m.lockRepo(ctx, name) | 1011 | unlock, err := m.lockRepo(ctx, name) | ||
| 893 | if err != nil { | 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 | mr, err := m.store.Get(ctx, name, number) | 1019 | mr, err := m.store.Get(ctx, name, number) | ||
| 899 | if err != nil { | 1020 | if err != nil { | ||
| 900 | - | return domain.MergeRequest{}, false, err | 1021 | + | return domain.MergeRequest{}, err |
| 901 | } | 1022 | } | ||
| 902 | // A merge moves the target and may delete the source: a token limited to | 1023 | // A merge moves the target and may delete the source: a token limited to | ||
| 903 | // branch prefixes may do so only within them. | 1024 | // branch prefixes may do so only within them. | ||
| 904 | if !p.AllowsRef("refs/heads/" + mr.Target) { | 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 | if params.DeleteSource && !p.AllowsRef("refs/heads/"+mr.Source) { | 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 | if !mr.IsOpen() { | 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 | if mr.IsDraft() { | 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 | // The settings are read again under the lock: a change applies at once. | 1037 | // The settings are read again under the lock: a change applies at once. | ||
| 917 | repo, err = m.meta.Get(ctx, name) | 1038 | repo, err = m.meta.Get(ctx, name) | ||
| 918 | if err != nil { | 1039 | if err != nil { | ||
| 919 | if errors.Is(err, domain.ErrNotFound) { | 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 | settings := repo.Settings | 1045 | settings := repo.Settings | ||
| 925 | if settings.IsZero() { | 1046 | if settings.IsZero() { | ||
| 926 | settings = domain.DefaultRepoSettings() | 1047 | settings = domain.DefaultRepoSettings() | ||
| 927 | } | 1048 | } | ||
| 928 | if !settings.Allows(mode) { | 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 | head, err := m.branchOrZero(ctx, name, mr.Source) | 1053 | head, err := m.branchOrZero(ctx, name, mr.Source) | ||
| 933 | switch { | 1054 | switch { | ||
| 934 | case err != nil: | 1055 | case err != nil: | ||
| 935 | - | return domain.MergeRequest{}, false, err | 1056 | + | return domain.MergeRequest{}, err |
| 936 | case head.IsZero(): | 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 | case head != params.HeadID: | 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 | tip, err := m.branchOrZero(ctx, name, mr.Target) | 1062 | tip, err := m.branchOrZero(ctx, name, mr.Target) | ||
| 942 | switch { | 1063 | switch { | ||
| 943 | case err != nil: | 1064 | case err != nil: | ||
| 944 | - | return domain.MergeRequest{}, false, err | 1065 | + | return domain.MergeRequest{}, err |
| 945 | case tip.IsZero(): | 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 | case tip == head: | 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); { | 1071 | + | // A head behind the target (or at it) has nothing to merge, in any mode. |
| 951 | - | case err != nil: | 1072 | + | if behind, err := m.git.IsAncestor(ctx, name, head, tip); err != nil { |
| 952 | - | return domain.MergeRequest{}, false, err | 1073 | + | return domain.MergeRequest{}, err |
| 953 | - | case !ok: | 1074 | + | } else if behind { |
| 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) | 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 | if settings.FailingChecksBlock { | 1081 | if settings.FailingChecksBlock { | ||
| 957 | if err := m.checksAllow(ctx, name, head); err != nil { | 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 | if params.DeleteSource { | 1086 | if params.DeleteSource { | ||
| 962 | if err := m.checkDeletable(ctx, name, mr); err != nil { | 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 | if err != nil { | 1093 | if err != nil { | ||
| 969 | - | return domain.MergeRequest{}, false, err | 1094 | + | return domain.MergeRequest{}, err |
| 970 | } | 1095 | } | ||
| 971 | m.recordPush(ctx, name) // the target moved, whatever happens next | 1096 | m.recordPush(ctx, name) // the target moved, whatever happens next | ||
| 972 | mr, err = m.markMerged(ctx, name, number, mode, tip, head, merged) | 1097 | mr, err = m.markMerged(ctx, name, number, mode, tip, head, merged) | ||
| 973 | if err != nil { | 1098 | if err != nil { | ||
| 974 | - | return domain.MergeRequest{}, false, err | 1099 | + | return domain.MergeRequest{}, err |
| 975 | } | 1100 | } | ||
| 976 | log := zerowrap.FromCtx(ctx) | 1101 | log := zerowrap.FromCtx(ctx) | ||
| 977 | log.Info().Str("repo", name.String()).Int64("number", number).Str("mode", mode.String()). | 1102 | log.Info().Str("repo", name.String()).Int64("number", number).Str("mode", mode.String()). | ||
| 978 | Str("target", mr.Target).Str("commit", merged.String()). | 1103 | Str("target", mr.Target).Str("commit", merged.String()). | ||
| 979 | Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("merge request merged") | 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}} | 1105 | + | |
| 981 | - | deleted := false | 1106 | + | // The merge is done and recorded. The rest runs without the lock and after |
| 982 | - | if params.DeleteSource { | 1107 | + | // the caller got its answer. |
| 983 | - | // The merge is done and recorded: a failure here keeps the branch. Who | 1108 | + | release() |
| 984 | - | // pushed the source goes first (the branch is gone either way for a | 1109 | + | bg := context.WithoutCancel(ctx) |
| 985 | - | // schedule; a kept branch merely has no record: not trusted). | 1110 | + | m.after.Add(1) |
| 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 { | 1111 | + | go func() { |
| 987 | - | log.Warn().Err(err).Str("repo", name.String()).Str("branch", mr.Source).Msg("the record of the source branch was kept") | 1112 | + | defer m.after.Done() |
| 988 | - | } | 1113 | + | defer func() { |
| 989 | - | switch err := m.git.DeleteBranch(context.WithoutCancel(ctx), name, mr.Source, head); { | 1114 | + | if r := recover(); r != nil { |
| 990 | - | case err != nil: | 1115 | + | log := zerowrap.FromCtx(bg) |
| 991 | - | log.Warn().Err(err).Str("repo", name.String()).Int64("number", number).Str("branch", mr.Source). | 1116 | + | log.Error().Str("repo", name.String()).Int64("number", number). |
| 992 | - | Msg("source branch kept after the merge") | 1117 | + | Str("panic", fmt.Sprint(r)).Bytes("stack", debug.Stack()).Msg("post-merge side effects panicked") |
| 993 | - | default: | 1118 | + | } |
| 994 | - | deleted = true | 1119 | + | }() |
| 995 | - | updates = append(updates, domain.RefUpdate{Old: head.String(), New: domain.ZeroOID, Ref: "refs/heads/" + mr.Source}) | 1120 | + | if m.afterGate != nil { |
| 996 | - | log.Info().Str("repo", name.String()).Int64("number", number).Str("branch", mr.Source). | 1121 | + | m.afterGate() |
| 997 | - | Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("source branch deleted after the merge") | |||
| 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 | at := mr.UpdatedAt | 1137 | at := mr.UpdatedAt | ||
| 1001 | m.bus.Publish(ctx, domain.MergeRequestMerged{MergeRequest: mr, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt}) | 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 | Repo: name, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt, Updates: updates, | 1140 | Repo: name, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt, Updates: updates, | ||
| 1004 | }) | 1141 | }) | ||
| 1005 | if m.pulls != nil { | 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 | if m.ci != nil { | 1145 | if m.ci != nil { | ||
| 1009 | // GitHub runs the "push" workflows of a merge, with the owner as actor; | 1146 | // GitHub runs the "push" workflows of a merge, with the owner as actor; | ||
| 1010 | // the run is trusted with what the merging principal may read. | 1147 | // the run is trusted with what the merging principal may read. | ||
| 1011 | m.ci.OnMerge(ctx, name, updates, p) | 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 | // WhileOpen implements ports.PullGate: fn runs under the merge lock, so a | 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 | // moveTarget moves the target branch from tip to the head (fast-forward) or to | 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. | 1276 | + | // a new signed squash or merge commit with the given tree, 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) { | 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 | newTip := head | 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 | commits, more, err := m.git.ListRange(ctx, mr.Repo, tip, head, domain.MaxSquashCommits) | 1290 | commits, more, err := m.git.ListRange(ctx, mr.Repo, tip, head, domain.MaxSquashCommits) | ||
| 1111 | if err != nil { | 1291 | if err != nil { | ||
| 1112 | return domain.ObjectID{}, err | 1292 | return domain.ObjectID{}, err | ||
| @@ -1114,10 +1294,6 @@ func (m *MergeRequests) moveTarget(ctx context.Context, mr domain.MergeRequest, | |||||
| 1114 | if more { | 1294 | if more { | ||
| 1115 | return domain.ObjectID{}, fmt.Errorf("%w: the branch has more than %d commits to squash", domain.ErrPreconditionFailed, domain.MaxSquashCommits) | 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 | commit := domain.NewSquashCommit(mr, tree, tip, commits, m.committer, m.clock.Now()) | 1297 | commit := domain.NewSquashCommit(mr, tree, tip, commits, m.committer, m.clock.Now()) | ||
| 1122 | armored, err := m.signer.Sign(commit.Payload()) | 1298 | armored, err := m.signer.Sign(commit.Payload()) | ||
| 1123 | if err != nil { | 1299 | if err != nil { | ||
M internal/usecase/mergerequests_branches_test.go +1 -0
| @@ -67,6 +67,7 @@ func TestMergeWithBranchLimitedToken(t *testing.T) { | |||||
| 67 | res, err := r.mrs.Merge(r.ctx, branchMerger, r.repo, mr.Number, ports.MergeParams{HeadID: head, DeleteSource: true}) | 67 | res, err := r.mrs.Merge(r.ctx, branchMerger, r.repo, mr.Number, ports.MergeParams{HeadID: head, DeleteSource: true}) | ||
| 68 | require.NoError(t, err) | 68 | require.NoError(t, err) | ||
| 69 | assert.True(t, res.SourceDeleted) | 69 | assert.True(t, res.SourceDeleted) | ||
| 70 | + | r.mrs.Wait(r.ctx) // the deletion runs after Merge returned | |||
| 70 | assert.Equal(t, head, r.branchHead("agent/claude/base")) | 71 | assert.Equal(t, head, r.branchHead("agent/claude/base")) | ||
| 71 | _, err = r.git.BranchHead(r.ctx, r.repo, "agent/claude/feat") | 72 | _, err = r.git.BranchHead(r.ctx, r.repo, "agent/claude/feat") | ||
| 72 | assert.ErrorIs(t, err, domain.ErrNotFound) | 73 | assert.ErrorIs(t, err, domain.ErrNotFound) | ||
M internal/usecase/mergerequests_delete_test.go +150 -1
| @@ -3,8 +3,13 @@ | |||||
| 3 | package usecase_test | 3 | package usecase_test | ||
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | + | "bytes" | |||
| 6 | "context" | 7 | "context" | ||
| 8 | + | "sync" | |||
| 7 | "testing" | 9 | "testing" | ||
| 10 | + | "time" | |||
| 11 | + | ||||
| 12 | + | "github.com/bnema/zerowrap" | |||
| 8 | 13 | ||||
| 9 | "github.com/stretchr/testify/assert" | 14 | "github.com/stretchr/testify/assert" | ||
| 10 | "github.com/stretchr/testify/require" | 15 | "github.com/stretchr/testify/require" | ||
| @@ -31,6 +36,7 @@ func TestMergeDeletesTheSourceBranch(t *testing.T) { | |||||
| 31 | require.NoError(t, err) | 36 | require.NoError(t, err) | ||
| 32 | assert.True(t, res.SourceDeleted) | 37 | assert.True(t, res.SourceDeleted) | ||
| 33 | assert.Equal(t, domain.MRStateMerged, res.State) | 38 | assert.Equal(t, domain.MRStateMerged, res.State) | ||
| 39 | + | r.mrs.Wait(r.ctx) // the deletion happens after Merge returned | |||
| 34 | _, err = r.git.BranchHead(r.ctx, r.repo, "feat") | 40 | _, err = r.git.BranchHead(r.ctx, r.repo, "feat") | ||
| 35 | assert.ErrorIs(t, err, domain.ErrNotFound, "the branch is gone") | 41 | assert.ErrorIs(t, err, domain.ErrNotFound, "the branch is gone") | ||
| 36 | 42 | ||||
| @@ -131,6 +137,7 @@ func TestSquashMergedRequestReadsAfterItsCommitsArePruned(t *testing.T) { | |||||
| 131 | res, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{Mode: domain.MergeModeSquash, HeadID: head, DeleteSource: true}) | 137 | res, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{Mode: domain.MergeModeSquash, HeadID: head, DeleteSource: true}) | ||
| 132 | require.NoError(t, err) | 138 | require.NoError(t, err) | ||
| 133 | require.True(t, res.SourceDeleted) | 139 | require.True(t, res.SourceDeleted) | ||
| 140 | + | r.mrs.Wait(r.ctx) // the deletion runs after Merge returned | |||
| 134 | 141 | ||||
| 135 | r.run(r.bare(), r.env(author), "reflog", "expire", "--expire=now", "--all") | 142 | r.run(r.bare(), r.env(author), "reflog", "expire", "--expire=now", "--all") | ||
| 136 | r.run(r.bare(), r.env(author), "gc", "-q", "--prune=now") | 143 | r.run(r.bare(), r.env(author), "gc", "-q", "--prune=now") | ||
| @@ -157,6 +164,7 @@ func TestCreateAfterAMergeDeletedTheBranch(t *testing.T) { | |||||
| 157 | mr := r.open("feat") | 164 | mr := r.open("feat") | ||
| 158 | _, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, DeleteSource: true}) | 165 | _, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, DeleteSource: true}) | ||
| 159 | require.NoError(t, err) | 166 | require.NoError(t, err) | ||
| 167 | + | r.mrs.Wait(r.ctx) // the deletion runs after Merge returned | |||
| 160 | _, err = r.mrs.Create(r.ctx, root, r.repo, ports.CreateMergeRequestParams{Title: "late", Source: "feat"}) | 168 | _, err = r.mrs.Create(r.ctx, root, r.repo, ports.CreateMergeRequestParams{Title: "late", Source: "feat"}) | ||
| 161 | require.ErrorIs(t, err, domain.ErrInvalid) | 169 | require.ErrorIs(t, err, domain.ErrInvalid) | ||
| 162 | assert.Contains(t, fieldsOf(t, err), "source_branch") | 170 | assert.Contains(t, fieldsOf(t, err), "source_branch") | ||
| @@ -192,11 +200,152 @@ func TestMergeKeepsASourceThatMovedAfterTheMerge(t *testing.T) { | |||||
| 192 | 200 | ||||
| 193 | res, err := mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, DeleteSource: true}) | 201 | res, err := mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, DeleteSource: true}) | ||
| 194 | require.NoError(t, err, "the merge stands") | 202 | require.NoError(t, err, "the merge stands") | ||
| 195 | - | assert.False(t, res.SourceDeleted) | 203 | + | assert.True(t, res.SourceDeleted, "the deletion was requested") |
| 196 | assert.Equal(t, domain.MRStateMerged, res.State) | 204 | assert.Equal(t, domain.MRStateMerged, res.State) | ||
| 205 | + | mrs.Wait(r.ctx) | |||
| 197 | assert.Equal(t, head, r.branchHead("main")) | 206 | assert.Equal(t, head, r.branchHead("main")) | ||
| 198 | assert.Equal(t, moved, r.branchHead("feat"), "the late commit is kept") | 207 | assert.Equal(t, moved, r.branchHead("feat"), "the late commit is kept") | ||
| 199 | evs := r.drain() | 208 | evs := r.drain() | ||
| 200 | require.Len(t, evs, 2) | 209 | require.Len(t, evs, 2) | ||
| 201 | assert.Len(t, evs[1].(domain.PushEvent).Updates, 1, "only the target moved") | 210 | assert.Len(t, evs[1].(domain.PushEvent).Updates, 1, "only the target moved") | ||
| 202 | } | 211 | } | ||
| 212 | + | ||||
| 213 | + | // afterMergeRig builds merge request use cases over the rig with ref-tip | |||
| 214 | + | // records, whose post-merge goroutine waits for the returned release function. | |||
| 215 | + | func (r *mrRig) afterMergeRig(extra func(*usecase.MergeRequestsConfig)) (mrs *usecase.MergeRequests, release func()) { | |||
| 216 | + | gate := make(chan struct{}) | |||
| 217 | + | cfg := usecase.MergeRequestsConfig{ | |||
| 218 | + | Meta: r.store, Store: r.store.MergeRequests(), Repos: r.git, Git: r.git, Signer: nil, | |||
| 219 | + | Committer: domain.Identity{Name: "ermite", Email: "ermite@git.example.org"}, | |||
| 220 | + | Bus: r.bus, Clock: fixedClock{}, Maint: r.sched, RefTips: r.store.CI(), | |||
| 221 | + | } | |||
| 222 | + | if extra != nil { | |||
| 223 | + | extra(&cfg) | |||
| 224 | + | } | |||
| 225 | + | mrs = usecase.NewMergeRequests(cfg) | |||
| 226 | + | mrs.SetAfterMergeGate(func() { <-gate }) | |||
| 227 | + | var once sync.Once | |||
| 228 | + | return mrs, func() { once.Do(func() { close(gate) }) } | |||
| 229 | + | } | |||
| 230 | + | ||||
| 231 | + | // A request opened on the source after the merge, before the deletion runs, | |||
| 232 | + | // keeps the branch (GO-002). | |||
| 233 | + | func TestMergeKeepsTheSourceWhenAnotherRequestOpensOnItMeanwhile(t *testing.T) { | |||
| 234 | + | t.Parallel() | |||
| 235 | + | r := newMRRig(t) | |||
| 236 | + | r.seed() | |||
| 237 | + | head := r.feature("feat") | |||
| 238 | + | mr := r.open("feat") | |||
| 239 | + | r.sh("checkout", "-q", "-B", "release", "main") | |||
| 240 | + | r.push("release") | |||
| 241 | + | mrs, release := r.afterMergeRig(nil) | |||
| 242 | + | defer release() | |||
| 243 | + | ||||
| 244 | + | _, err := mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, DeleteSource: true}) | |||
| 245 | + | require.NoError(t, err) | |||
| 246 | + | _, err = mrs.Create(r.ctx, root, r.repo, ports.CreateMergeRequestParams{Title: "to release", Source: "feat", Target: "release"}) | |||
| 247 | + | require.NoError(t, err, "the merge released the lock; the deletion has not run") | |||
| 248 | + | release() | |||
| 249 | + | mrs.Wait(r.ctx) | |||
| 250 | + | assert.Equal(t, head, r.branchHead("feat"), "the branch is kept") | |||
| 251 | + | } | |||
| 252 | + | ||||
| 253 | + | // A push that moves the source after the merge keeps both the branch and the | |||
| 254 | + | // record of who pushed it (GO-003). | |||
| 255 | + | func TestMergeKeepsTheSourceAndItsRecordWhenAPushMovesItMeanwhile(t *testing.T) { | |||
| 256 | + | t.Parallel() | |||
| 257 | + | r := newMRRig(t) | |||
| 258 | + | r.seed() | |||
| 259 | + | head := r.feature("feat") | |||
| 260 | + | mr := r.open("feat") | |||
| 261 | + | mrs, release := r.afterMergeRig(nil) | |||
| 262 | + | defer release() | |||
| 263 | + | ||||
| 264 | + | _, err := mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, DeleteSource: true}) | |||
| 265 | + | require.NoError(t, err) | |||
| 266 | + | r.sh("checkout", "-q", "feat") | |||
| 267 | + | moved := r.commit(author, "later.txt", "later\n", "pushed meanwhile") | |||
| 268 | + | r.push("feat") | |||
| 269 | + | require.NoError(t, r.store.CI().PutRefTip(r.ctx, domain.CIRefTip{Repo: r.repo, Ref: "refs/heads/feat", Sha: moved.String(), Actor: "ada"})) | |||
| 270 | + | release() | |||
| 271 | + | mrs.Wait(r.ctx) | |||
| 272 | + | ||||
| 273 | + | assert.Equal(t, moved, r.branchHead("feat"), "the branch is kept") | |||
| 274 | + | tip, err := r.store.CI().GetRefTip(r.ctx, r.repo, "refs/heads/feat") | |||
| 275 | + | require.NoError(t, err, "the record of the new push is kept") | |||
| 276 | + | assert.Equal(t, moved.String(), tip.Sha) | |||
| 277 | + | } | |||
| 278 | + | ||||
| 279 | + | // The record of the source goes with the branch, once it is deleted. | |||
| 280 | + | func TestMergeRemovesTheSourceRecordAfterDeletingIt(t *testing.T) { | |||
| 281 | + | t.Parallel() | |||
| 282 | + | r := newMRRig(t) | |||
| 283 | + | r.seed() | |||
| 284 | + | head := r.feature("feat") | |||
| 285 | + | mr := r.open("feat") | |||
| 286 | + | mrs, release := r.afterMergeRig(nil) | |||
| 287 | + | release() | |||
| 288 | + | require.NoError(t, r.store.CI().PutRefTip(r.ctx, domain.CIRefTip{Repo: r.repo, Ref: "refs/heads/feat", Sha: head.String(), Actor: "ada"})) | |||
| 289 | + | ||||
| 290 | + | _, err := mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, DeleteSource: true}) | |||
| 291 | + | require.NoError(t, err) | |||
| 292 | + | mrs.Wait(r.ctx) | |||
| 293 | + | _, err = r.git.BranchHead(r.ctx, r.repo, "feat") | |||
| 294 | + | require.ErrorIs(t, err, domain.ErrNotFound) | |||
| 295 | + | _, err = r.store.CI().GetRefTip(r.ctx, r.repo, "refs/heads/feat") | |||
| 296 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |||
| 297 | + | } | |||
| 298 | + | ||||
| 299 | + | // panicHooks is a CI trigger whose merge hook panics. | |||
| 300 | + | type panicHooks struct{} | |||
| 301 | + | ||||
| 302 | + | func (panicHooks) OnPush(context.Context, domain.RepoName, []domain.RefUpdate, domain.Principal) {} | |||
| 303 | + | func (panicHooks) OnMerge(context.Context, domain.RepoName, []domain.RefUpdate, domain.Principal) { | |||
| 304 | + | panic("boom from ci") | |||
| 305 | + | } | |||
| 306 | + | ||||
| 307 | + | func TestMergeLogsAPanickingSideEffectAndWaitReturns(t *testing.T) { | |||
| 308 | + | t.Parallel() | |||
| 309 | + | r := newMRRig(t) | |||
| 310 | + | r.seed() | |||
| 311 | + | head := r.feature("feat") | |||
| 312 | + | mr := r.open("feat") | |||
| 313 | + | mrs, release := r.afterMergeRig(func(c *usecase.MergeRequestsConfig) { c.CI = panicHooks{} }) | |||
| 314 | + | release() | |||
| 315 | + | var logs bytes.Buffer | |||
| 316 | + | ctx := zerowrap.WithCtx(r.ctx, zerowrap.New(zerowrap.Config{Level: "info", Format: "json", Output: &logs})) | |||
| 317 | + | ||||
| 318 | + | _, err := mrs.Merge(ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head}) | |||
| 319 | + | require.NoError(t, err) | |||
| 320 | + | waited := make(chan struct{}) | |||
| 321 | + | go func() { mrs.Wait(context.Background()); close(waited) }() | |||
| 322 | + | select { | |||
| 323 | + | case <-waited: | |||
| 324 | + | case <-time.After(10 * time.Second): | |||
| 325 | + | t.Fatal("Wait did not return after a panicking side effect") | |||
| 326 | + | } | |||
| 327 | + | assert.Contains(t, logs.String(), "post-merge side effects panicked") | |||
| 328 | + | assert.Contains(t, logs.String(), "boom from ci") | |||
| 329 | + | } | |||
| 330 | + | ||||
| 331 | + | // A head behind the target has nothing to merge, whatever the mode (GO-007). | |||
| 332 | + | func TestMergeRefusesAHeadBehindTheTarget(t *testing.T) { | |||
| 333 | + | t.Parallel() | |||
| 334 | + | for _, mode := range []domain.MergeMode{domain.MergeModeSquash, domain.MergeModeMerge, domain.MergeModeFastForward} { | |||
| 335 | + | t.Run(string(mode), func(t *testing.T) { | |||
| 336 | + | t.Parallel() | |||
| 337 | + | r := newMRRig(t) | |||
| 338 | + | main := r.seed() | |||
| 339 | + | r.sh("checkout", "-q", "-B", "old", "main~1") | |||
| 340 | + | r.push("old") | |||
| 341 | + | old := r.branchHead("old") | |||
| 342 | + | require.NotEqual(t, main, old) | |||
| 343 | + | mr := r.open("old") | |||
| 344 | + | ||||
| 345 | + | _, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{Mode: mode, HeadID: old}) | |||
| 346 | + | require.ErrorIs(t, err, domain.ErrPreconditionFailed) | |||
| 347 | + | assert.Contains(t, err.Error(), "nothing to merge") | |||
| 348 | + | assert.Equal(t, main, r.branchHead("main"), "no commit was made") | |||
| 349 | + | }) | |||
| 350 | + | } | |||
| 351 | + | } | |||
M internal/usecase/mergerequests_integration_test.go +11 -6
| @@ -207,6 +207,7 @@ func (r *mrRig) open(branch string) domain.MergeRequest { | |||||
| 207 | // subscriber before Publish returns: whatever a returned call published is | 207 | // subscriber before Publish returns: whatever a returned call published is | ||
| 208 | // already in the channel. | 208 | // already in the channel. | ||
| 209 | func (r *mrRig) drain() []domain.Event { | 209 | func (r *mrRig) drain() []domain.Event { | ||
| 210 | + | r.mrs.Wait(r.ctx) // the events of a merge are published after it returned | |||
| 210 | var out []domain.Event | 211 | var out []domain.Event | ||
| 211 | for { | 212 | for { | ||
| 212 | select { | 213 | select { | ||
| @@ -959,11 +960,10 @@ func TestMergeRefusals(t *testing.T) { | |||||
| 959 | r.push("main") | 960 | r.push("main") | ||
| 960 | tip := r.branchHead("main") | 961 | tip := r.branchHead("main") | ||
| 961 | 962 | ||||
| 962 | - | for _, mode := range []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash} { | 963 | + | // Only a fast-forward needs the target to be an ancestor. |
| 963 | - | _, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, Mode: mode}) | 964 | + | _, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, Mode: domain.MergeModeFastForward}) |
| 964 | - | require.ErrorIs(t, err, domain.ErrPreconditionFailed, mode) | 965 | + | require.ErrorIs(t, err, domain.ErrPreconditionFailed) |
| 965 | - | assert.Contains(t, err.Error(), "rebase", mode) | 966 | + | assert.Contains(t, err.Error(), "the target has moved: rebase and push again") |
| 966 | - | } | |||
| 967 | assert.Equal(t, tip, r.branchHead("main")) | 967 | assert.Equal(t, tip, r.branchHead("main")) | ||
| 968 | d, err := r.mrs.Get(r.ctx, mrM, r.repo, mr.Number, ports.GetMergeRequestOptions{}) | 968 | d, err := r.mrs.Get(r.ctx, mrM, r.repo, mr.Number, ports.GetMergeRequestOptions{}) | ||
| 969 | require.NoError(t, err) | 969 | require.NoError(t, err) | ||
| @@ -1047,8 +1047,13 @@ func TestConcurrentMergesSerialize(t *testing.T) { | |||||
| 1047 | const n = 6 | 1047 | const n = 6 | ||
| 1048 | heads := map[int64]domain.ObjectID{} | 1048 | heads := map[int64]domain.ObjectID{} | ||
| 1049 | for i := range n { | 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 | b := fmt.Sprintf("f%d", i) | 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 | type outcome struct { | 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 | // Pushes are the use cases around a Git push. | 21 | // Pushes are the use cases around a Git push. | ||
| 22 | type Pushes struct { | 22 | type Pushes struct { | ||
| 23 | - | meta ports.RepoMetaStore | 23 | + | meta ports.RepoMetaStore |
| 24 | - | repos *Repos | 24 | + | repos *Repos |
| 25 | - | git ports.GitRepos | 25 | + | git ports.GitRepos |
| 26 | - | bus ports.EventBus | 26 | + | bus ports.EventBus |
| 27 | - | clock ports.Clock | 27 | + | clock ports.Clock |
| 28 | - | maint ports.MaintenanceScheduler | 28 | + | maint ports.MaintenanceScheduler |
| 29 | - | auth ports.Revalidator | 29 | + | auth ports.Revalidator |
| 30 | - | fresh ports.RepoFreshener // nil: the reader needs no telling | 30 | + | fresh ports.RepoFreshener // nil: the reader needs no telling |
| 31 | - | ci ports.WorkflowTrigger // nil: no CI | 31 | + | ci ports.WorkflowTrigger // nil: no CI |
| 32 | - | wf ports.WorkflowGuard // nil: pushes by job tokens are refused | 32 | + | wf ports.WorkflowGuard // nil: pushes by job tokens are refused |
| 33 | - | tips ports.CIRefTipStore // nil: who pushes a ref is not recorded | 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 | var ( | 37 | var ( | ||
| @@ -59,6 +60,14 @@ func (u *Pushes) WithWorkflowTrigger(t ports.WorkflowTrigger) *Pushes { | |||||
| 59 | return u | 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 | // WithRefTips makes Check record who is about to move each branch and tag | 71 | // WithRefTips makes Check record who is about to move each branch and tag | ||
| 63 | // (domain.CIRefTip), before Git moves it. Call it before the first push; it | 72 | // (domain.CIRefTip), before Git moves it. Call it before the first push; it | ||
| 64 | // returns u. | 73 | // returns u. | ||
| @@ -233,6 +242,9 @@ func (u *Pushes) Record(ctx context.Context, p domain.Principal, name domain.Rep | |||||
| 233 | if u.ci != nil { | 242 | if u.ci != nil { | ||
| 234 | u.ci.OnPush(ctx, name, updates, p) // queues only: never slows or fails the push | 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 | u.bus.Publish(ctx, domain.PushEvent{ | 248 | u.bus.Publish(ctx, domain.PushEvent{ | ||
| 237 | Repo: name, Updates: append([]domain.RefUpdate(nil), updates...), At: now, | 249 | Repo: name, Updates: append([]domain.RefUpdate(nil), updates...), At: now, | ||
| 238 | Visibility: repo.Visibility, CreatedAt: repo.CreatedAt, | 250 | Visibility: repo.Visibility, CreatedAt: repo.CreatedAt, | ||
M proto/ermite/storage/merge_request.proto +28 -0
| @@ -15,6 +15,7 @@ enum MergeMode { | |||||
| 15 | MERGE_MODE_UNSPECIFIED = 0; | 15 | MERGE_MODE_UNSPECIFIED = 0; | ||
| 16 | MERGE_MODE_FAST_FORWARD = 1; | 16 | MERGE_MODE_FAST_FORWARD = 1; | ||
| 17 | MERGE_MODE_SQUASH = 2; | 17 | MERGE_MODE_SQUASH = 2; | ||
| 18 | + | MERGE_MODE_MERGE = 3; | |||
| 18 | } | 19 | } | ||
| 19 | 20 | ||||
| 20 | // RepoSettings are the merge settings of a repository, stored inside Repo. | 21 | // RepoSettings are the merge settings of a repository, stored inside Repo. | ||
| @@ -24,6 +25,11 @@ message RepoSettings { | |||||
| 24 | bool failing_checks_block = 2; | 25 | bool failing_checks_block = 2; | ||
| 25 | // Who may write issues; unspecified reads as off. | 26 | // Who may write issues; unspecified reads as off. | ||
| 26 | IssuesMode issues = 3; | 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 | // IssuesMode says who may write issues in a repository. | 35 | // IssuesMode says who may write issues in a repository. | ||
| @@ -104,3 +110,25 @@ message Comment { | |||||
| 104 | message Sequence { | 110 | message Sequence { | ||
| 105 | int64 last = 1; | 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 | | `repo list [--json]` | none | repositories you can see | | 11 | | `repo list [--json]` | none | repositories you can see | | ||
| 12 | | `repo show <name> [--json]` | none | | | 12 | | `repo show <name> [--json]` | none | | | ||
| 13 | | `repo create <name> [--description TEXT] [--visibility public]` | `repo:write` | private by default | | 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 | | `repo delete <name> [--yes]` | `repo:admin` | | | 15 | | `repo delete <name> [--yes]` | `repo:admin` | | | ||
| 16 | 16 | ||||
| 17 | Repository JSON: `name`, `description`, `visibility`, `default_branch`, `created_at`, `pushed_at` (null before the first push), `merge_modes`, `failing_checks_block`. | 17 | Repository JSON: `name`, `description`, `visibility`, `default_branch`, `created_at`, `pushed_at` (null before the first push), `merge_modes`, `failing_checks_block`. | ||
| @@ -56,12 +56,12 @@ A token can never grant more than the token that creates it. `--repo` (repeatabl | |||||
| 56 | |---|---|---| | 56 | |---|---|---| | ||
| 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 | | 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 | | `mr list <repo> [--state open\|merged\|closed\|all] [--json]` | none | default `open` | | 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 | | `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 | | 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 | | `mr edit-comment <repo> <n> <comment-id> -m TEXT\|-` | `mr:write` | author only, open merge request; the verdict is unchanged | | 61 | | `mr edit-comment <repo> <n> <comment-id> -m TEXT\|-` | `mr:write` | author only, open merge request; the verdict is unchanged | | ||
| 62 | | `mr delete-comment <repo> <n> <comment-id>` | `mr:write` | author only; the text is lost, the verdict is kept | | 62 | | `mr delete-comment <repo> <n> <comment-id>` | `mr:write` | author only; the text is lost, the verdict is kept | | ||
| 63 | | `mr resolve <repo> <n> <comment-id> [--undo]` | `mr:write` | on the thread root, covers its replies; doing it twice is not an error | | 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 | | `mr close <repo> <n>` | `mr:write` | a closed merge request cannot be reopened | | 65 | | `mr close <repo> <n>` | `mr:write` | a closed merge request cannot be reopened | | ||
| 66 | 66 | ||||
| 67 | `ermite mr create` and `ermite mr close` print a confirmation followed by the full | 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 | | `repo_created`, `repo_updated`, `repo_deleted` | `repo`, `repository` | | 106 | | `repo_created`, `repo_updated`, `repo_deleted` | `repo`, `repository` | | ||
| 107 | | `pushed` | `repo`, `ref_updates` (`ref`, `old_id`, `new_id`; an empty id means created or deleted) | | 107 | | `pushed` | `repo`, `ref_updates` (`ref`, `old_id`, `new_id`; an empty id means created or deleted) | | ||
| 108 | | `merge_request_opened`, `merge_request_merged`, `merge_request_closed` | `repo`, `merge_request` | | 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 | | `comment_added`, `comment_edited`, `comment_deleted`, `comment_resolved` | `repo`, `number`, `comment` (a verdict note has `verdict` set) | | 110 | | `comment_added`, `comment_edited`, `comment_deleted`, `comment_resolved` | `repo`, `number`, `comment` (a verdict note has `verdict` set) | | ||
| 110 | | `gap` | none: events were lost, reload the state | | 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 | | `serve` | configured by `ERMITE_*` environment variables only (see the project README) | | 117 | | `serve` | configured by `ERMITE_*` environment variables only (see the project README) | | ||
| 117 | | `version [--json]` | client version; with a server URL (`ERMITE_URL` or the config file), the server version and features | | 118 | | `version [--json]` | client version; with a server URL (`ERMITE_URL` or the config file), the server version and features | | ||
| 118 | | `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 | | `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 | | `signing-key trust list [--json]` | keys commit signatures are checked against (the server key first) | | 121 | | `signing-key trust list [--json]` | keys commit signatures are checked against (the server key first) | | ||
| 121 | | `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 | | `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 | | `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 | | 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 | 2. Read: `ermite mr show <repo> <n> --diff --json`. Keep `head_id`; take line numbers from the hunks. | 37 | 2. Read: `ermite mr show <repo> <n> --diff --json`. Keep `head_id`; take line numbers from the hunks. | ||
| 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. | 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 | 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. | 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 | ### Act on a review | 44 | ### Act on a review | ||
| 45 | 45 | ||||