feat(app): add the merge request use cases and the signing key
Create, list, get (commits, diff against the merge base, diff since the last read with a fallback when the recorded head is gone), comment on a line of the diff, resolve, close, mark read and merge by fast-forward or signed squash. Merges and creations of one repository run one at a time; the merge checks the head the caller reviewed, that the target is an ancestor, and the allowed modes of the repository settings, and moves the target with the old value as a precondition. Repository settings are set at creation and changed by Update. Merge request events go through the event log, filtered by the visibility of the repository like the other repository events.
- commit
- 224c22d9e1c17471dc7e34e6c5e98fc6d50286ba
- author
- brice · · 2026-10-10 16:57 UTC
- parent
- 4023100
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
10 files · +2144 -10
M internal/app/access.go +32 -1
| @@ -2,7 +2,11 @@ | |||
| 2 | 2 | ||
| 3 | 3 | package app | |
| 4 | 4 | ||
| 5 | - | import "bnema.dev/ermite/internal/domain" | |
| 5 | + | import ( | |
| 6 | + | "fmt" | |
| 7 | + | ||
| 8 | + | "bnema.dev/ermite/internal/domain" | |
| 9 | + | ) | |
| 6 | 10 | ||
| 7 | 11 | // canRead is the access rule for reading a repository, the single place that | |
| 8 | 12 | // decides it: root reads everything, anyone reads a public repository, and a | |
| @@ -55,3 +59,30 @@ func canManageTokens(p domain.Principal) bool { | |||
| 55 | 59 | func canAdmin(p domain.Principal, name domain.RepoName) bool { | |
| 56 | 60 | return p.Allows(domain.ScopeRepoAdmin, name) | |
| 57 | 61 | } | |
| 62 | + | ||
| 63 | + | // scopeAccess is the access rule for an action that needs a scope on a | |
| 64 | + | // repository the caller can already read, such as mr:write or mr:merge: | |
| 65 | + | // unauthenticated for an anonymous caller, forbidden for one without the scope | |
| 66 | + | // on this repository. | |
| 67 | + | func scopeAccess(p domain.Principal, repo domain.Repo, scope domain.Scope) error { | |
| 68 | + | switch { | |
| 69 | + | case p.Allows(scope, repo.Name): | |
| 70 | + | return nil | |
| 71 | + | case p.IsAnonymous(): | |
| 72 | + | return domain.ErrUnauthenticated | |
| 73 | + | } | |
| 74 | + | return fmt.Errorf("%w: this needs the %s scope on %s", domain.ErrForbidden, scope, repo.Name) | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | // authorOf names who acts, for the author of a merge request or a comment: the | |
| 78 | + | // label of the token, "root" for the root token, "owner" for any other caller | |
| 79 | + | // (a web session). | |
| 80 | + | func authorOf(p domain.Principal) string { | |
| 81 | + | switch { | |
| 82 | + | case p.IsRoot(): | |
| 83 | + | return "root" | |
| 84 | + | case p.TokenLabel() != "": | |
| 85 | + | return p.TokenLabel() | |
| 86 | + | } | |
| 87 | + | return "owner" | |
| 88 | + | } | |
| @@ -2,7 +2,11 @@ | |||||
| 2 | 2 | ||||
| 3 | package app | 3 | package app | ||
| 4 | 4 | ||||
| 5 | - | import "bnema.dev/ermite/internal/domain" | 5 | + | import ( |
| 6 | + | "fmt" | |||
| 7 | + | ||||
| 8 | + | "bnema.dev/ermite/internal/domain" | |||
| 9 | + | ) | |||
| 6 | 10 | ||||
| 7 | // canRead is the access rule for reading a repository, the single place that | 11 | // canRead is the access rule for reading a repository, the single place that | ||
| 8 | // decides it: root reads everything, anyone reads a public repository, and a | 12 | // decides it: root reads everything, anyone reads a public repository, and a | ||
| @@ -55,3 +59,30 @@ func canManageTokens(p domain.Principal) bool { | |||||
| 55 | func canAdmin(p domain.Principal, name domain.RepoName) bool { | 59 | func canAdmin(p domain.Principal, name domain.RepoName) bool { | ||
| 56 | return p.Allows(domain.ScopeRepoAdmin, name) | 60 | return p.Allows(domain.ScopeRepoAdmin, name) | ||
| 57 | } | 61 | } | ||
| 62 | + | ||||
| 63 | + | // scopeAccess is the access rule for an action that needs a scope on a | |||
| 64 | + | // repository the caller can already read, such as mr:write or mr:merge: | |||
| 65 | + | // unauthenticated for an anonymous caller, forbidden for one without the scope | |||
| 66 | + | // on this repository. | |||
| 67 | + | func scopeAccess(p domain.Principal, repo domain.Repo, scope domain.Scope) error { | |||
| 68 | + | switch { | |||
| 69 | + | case p.Allows(scope, repo.Name): | |||
| 70 | + | return nil | |||
| 71 | + | case p.IsAnonymous(): | |||
| 72 | + | return domain.ErrUnauthenticated | |||
| 73 | + | } | |||
| 74 | + | return fmt.Errorf("%w: this needs the %s scope on %s", domain.ErrForbidden, scope, repo.Name) | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | // authorOf names who acts, for the author of a merge request or a comment: the | |||
| 78 | + | // label of the token, "root" for the root token, "owner" for any other caller | |||
| 79 | + | // (a web session). | |||
| 80 | + | func authorOf(p domain.Principal) string { | |||
| 81 | + | switch { | |||
| 82 | + | case p.IsRoot(): | |||
| 83 | + | return "root" | |||
| 84 | + | case p.TokenLabel() != "": | |||
| 85 | + | return p.TokenLabel() | |||
| 86 | + | } | |||
| 87 | + | return "owner" | |||
| 88 | + | } | |||
M internal/app/events.go +29 -1
| @@ -166,10 +166,28 @@ func (e *Events) snapshot(ctx context.Context, ev domain.Event) bufferedEvent { | |||
| 166 | 166 | return bufferedEvent{} // repository unknown at publish time: nobody is told | |
| 167 | 167 | } | |
| 168 | 168 | return bufferedEvent{known: true, vis: ev.Visibility, created: ev.CreatedAt} | |
| 169 | + | case domain.MergeRequestOpened: | |
| 170 | + | return repoSnapshot(ev.Visibility, ev.CreatedAt) | |
| 171 | + | case domain.MergeRequestMerged: | |
| 172 | + | return repoSnapshot(ev.Visibility, ev.CreatedAt) | |
| 173 | + | case domain.MergeRequestClosed: | |
| 174 | + | return repoSnapshot(ev.Visibility, ev.CreatedAt) | |
| 175 | + | case domain.CommentAdded: | |
| 176 | + | return repoSnapshot(ev.Visibility, ev.CreatedAt) | |
| 169 | 177 | } | |
| 170 | 178 | return bufferedEvent{known: true} | |
| 171 | 179 | } | |
| 172 | 180 | ||
| 181 | + | // repoSnapshot is the snapshot of an event that carries the visibility and the | |
| 182 | + | // creation time of its repository; a zero creation time means the repository | |
| 183 | + | // was unknown when it was published, and nobody is told. | |
| 184 | + | func repoSnapshot(vis domain.Visibility, created time.Time) bufferedEvent { | |
| 185 | + | if created.IsZero() { | |
| 186 | + | return bufferedEvent{} | |
| 187 | + | } | |
| 188 | + | return bufferedEvent{known: true, vis: vis, created: created} | |
| 189 | + | } | |
| 190 | + | ||
| 173 | 191 | func (e *Events) append(ctx context.Context, ev domain.Event) { | |
| 174 | 192 | be := e.snapshot(ctx, ev) // outside the lock: it reads the store | |
| 175 | 193 | e.mu.Lock() | |
| @@ -253,7 +271,9 @@ type WatchEvent struct { | |||
| 253 | 271 | // Gap tells that events were lost: the caller must reload what it shows. | |
| 254 | 272 | // Event is nil then. | |
| 255 | 273 | Gap bool | |
| 256 | - | // Event is a domain.RepoCreated, RepoUpdated, RepoDeleted or PushEvent. | |
| 274 | + | // Event is a domain.RepoCreated, RepoUpdated, RepoDeleted, PushEvent, | |
| 275 | + | // MergeRequestOpened, MergeRequestMerged, MergeRequestClosed or | |
| 276 | + | // CommentAdded. | |
| 257 | 277 | Event domain.Event | |
| 258 | 278 | // Repo is the state of the repository at delivery, for RepoCreated and | |
| 259 | 279 | // RepoUpdated. | |
| @@ -483,6 +503,14 @@ func (e *Events) deliver(ctx context.Context, w *watch, be bufferedEvent, send f | |||
| 483 | 503 | return e.deliverRepo(ctx, w, ev.Repo, true, be, out, send) | |
| 484 | 504 | case domain.PushEvent: | |
| 485 | 505 | return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send) | |
| 506 | + | case domain.MergeRequestOpened: | |
| 507 | + | return e.deliverRepo(ctx, w, ev.MergeRequest.Repo, false, be, out, send) | |
| 508 | + | case domain.MergeRequestMerged: | |
| 509 | + | return e.deliverRepo(ctx, w, ev.MergeRequest.Repo, false, be, out, send) | |
| 510 | + | case domain.MergeRequestClosed: | |
| 511 | + | return e.deliverRepo(ctx, w, ev.MergeRequest.Repo, false, be, out, send) | |
| 512 | + | case domain.CommentAdded: | |
| 513 | + | return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send) | |
| 486 | 514 | } | |
| 487 | 515 | return nil // an event this version does not stream | |
| 488 | 516 | } | |
| @@ -166,10 +166,28 @@ func (e *Events) snapshot(ctx context.Context, ev domain.Event) bufferedEvent { | |||||
| 166 | return bufferedEvent{} // repository unknown at publish time: nobody is told | 166 | return bufferedEvent{} // repository unknown at publish time: nobody is told | ||
| 167 | } | 167 | } | ||
| 168 | return bufferedEvent{known: true, vis: ev.Visibility, created: ev.CreatedAt} | 168 | return bufferedEvent{known: true, vis: ev.Visibility, created: ev.CreatedAt} | ||
| 169 | + | case domain.MergeRequestOpened: | |||
| 170 | + | return repoSnapshot(ev.Visibility, ev.CreatedAt) | |||
| 171 | + | case domain.MergeRequestMerged: | |||
| 172 | + | return repoSnapshot(ev.Visibility, ev.CreatedAt) | |||
| 173 | + | case domain.MergeRequestClosed: | |||
| 174 | + | return repoSnapshot(ev.Visibility, ev.CreatedAt) | |||
| 175 | + | case domain.CommentAdded: | |||
| 176 | + | return repoSnapshot(ev.Visibility, ev.CreatedAt) | |||
| 169 | } | 177 | } | ||
| 170 | return bufferedEvent{known: true} | 178 | return bufferedEvent{known: true} | ||
| 171 | } | 179 | } | ||
| 172 | 180 | ||||
| 181 | + | // repoSnapshot is the snapshot of an event that carries the visibility and the | |||
| 182 | + | // creation time of its repository; a zero creation time means the repository | |||
| 183 | + | // was unknown when it was published, and nobody is told. | |||
| 184 | + | func repoSnapshot(vis domain.Visibility, created time.Time) bufferedEvent { | |||
| 185 | + | if created.IsZero() { | |||
| 186 | + | return bufferedEvent{} | |||
| 187 | + | } | |||
| 188 | + | return bufferedEvent{known: true, vis: vis, created: created} | |||
| 189 | + | } | |||
| 190 | + | ||||
| 173 | func (e *Events) append(ctx context.Context, ev domain.Event) { | 191 | func (e *Events) append(ctx context.Context, ev domain.Event) { | ||
| 174 | be := e.snapshot(ctx, ev) // outside the lock: it reads the store | 192 | be := e.snapshot(ctx, ev) // outside the lock: it reads the store | ||
| 175 | e.mu.Lock() | 193 | e.mu.Lock() | ||
| @@ -253,7 +271,9 @@ type WatchEvent struct { | |||||
| 253 | // Gap tells that events were lost: the caller must reload what it shows. | 271 | // Gap tells that events were lost: the caller must reload what it shows. | ||
| 254 | // Event is nil then. | 272 | // Event is nil then. | ||
| 255 | Gap bool | 273 | Gap bool | ||
| 256 | - | // Event is a domain.RepoCreated, RepoUpdated, RepoDeleted or PushEvent. | 274 | + | // Event is a domain.RepoCreated, RepoUpdated, RepoDeleted, PushEvent, |
| 275 | + | // MergeRequestOpened, MergeRequestMerged, MergeRequestClosed or | |||
| 276 | + | // CommentAdded. | |||
| 257 | Event domain.Event | 277 | Event domain.Event | ||
| 258 | // Repo is the state of the repository at delivery, for RepoCreated and | 278 | // Repo is the state of the repository at delivery, for RepoCreated and | ||
| 259 | // RepoUpdated. | 279 | // RepoUpdated. | ||
| @@ -483,6 +503,14 @@ func (e *Events) deliver(ctx context.Context, w *watch, be bufferedEvent, send f | |||||
| 483 | return e.deliverRepo(ctx, w, ev.Repo, true, be, out, send) | 503 | return e.deliverRepo(ctx, w, ev.Repo, true, be, out, send) | ||
| 484 | case domain.PushEvent: | 504 | case domain.PushEvent: | ||
| 485 | return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send) | 505 | return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send) | ||
| 506 | + | case domain.MergeRequestOpened: | |||
| 507 | + | return e.deliverRepo(ctx, w, ev.MergeRequest.Repo, false, be, out, send) | |||
| 508 | + | case domain.MergeRequestMerged: | |||
| 509 | + | return e.deliverRepo(ctx, w, ev.MergeRequest.Repo, false, be, out, send) | |||
| 510 | + | case domain.MergeRequestClosed: | |||
| 511 | + | return e.deliverRepo(ctx, w, ev.MergeRequest.Repo, false, be, out, send) | |||
| 512 | + | case domain.CommentAdded: | |||
| 513 | + | return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send) | |||
| 486 | } | 514 | } | ||
| 487 | return nil // an event this version does not stream | 515 | return nil // an event this version does not stream | ||
| 488 | } | 516 | } | ||
A internal/app/mergerequests.go +845 -0
| @@ -0,0 +1,845 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package app | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "encoding/base64" | |
| 8 | + | "errors" | |
| 9 | + | "fmt" | |
| 10 | + | "strconv" | |
| 11 | + | "sync" | |
| 12 | + | ||
| 13 | + | "github.com/bnema/zerowrap" | |
| 14 | + | ||
| 15 | + | "bnema.dev/ermite/internal/domain" | |
| 16 | + | "bnema.dev/ermite/internal/ports" | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | // MaxMergeRequestCommits is the most commits GetMergeRequest returns. | |
| 20 | + | const MaxMergeRequestCommits = 100 | |
| 21 | + | ||
| 22 | + | // maxWriteRetries bounds the retries of a write that loses a race with another | |
| 23 | + | // write of the same merge request. Each lost race means another writer won, so | |
| 24 | + | // a writer loses at most as many times as there are other writers at once; the | |
| 25 | + | // retry is a read and a write of ermite-kv, cheap enough for a bound this high. | |
| 26 | + | const maxWriteRetries = 32 | |
| 27 | + | ||
| 28 | + | const mrTokenPrefix = "mp1:" | |
| 29 | + | ||
| 30 | + | // MergeRequests are the merge request use cases (ADR 0017): review, comment, | |
| 31 | + | // close and merge a branch. Reading needs the access of reading the | |
| 32 | + | // repository: a repository the principal may not read, a missing one and an | |
| 33 | + | // unknown number all answer domain.ErrNotFound. Writing needs mr:write, and | |
| 34 | + | // merging mr:merge, on the repository (see scopeAccess). | |
| 35 | + | type MergeRequests struct { | |
| 36 | + | meta ports.RepoMetaStore | |
| 37 | + | store ports.MergeRequestStore | |
| 38 | + | repos ports.GitRepos | |
| 39 | + | git ports.MergeGit | |
| 40 | + | signer ports.CommitSigner | |
| 41 | + | committer domain.Identity | |
| 42 | + | bus ports.EventBus | |
| 43 | + | clock ports.Clock | |
| 44 | + | ||
| 45 | + | mu sync.Mutex | |
| 46 | + | locks map[domain.RepoName]chan struct{} | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | // MergeRequestsConfig holds what the merge request use cases need. Every field | |
| 50 | + | // is required. | |
| 51 | + | type MergeRequestsConfig struct { | |
| 52 | + | Meta ports.RepoMetaStore | |
| 53 | + | Store ports.MergeRequestStore | |
| 54 | + | // Repos gives the default branch of a repository. | |
| 55 | + | Repos ports.GitRepos | |
| 56 | + | Git ports.MergeGit | |
| 57 | + | // Signer signs the squash commits. | |
| 58 | + | Signer ports.CommitSigner | |
| 59 | + | // Committer is recorded as the committer of the squash commits. | |
| 60 | + | Committer domain.Identity | |
| 61 | + | Bus ports.EventBus | |
| 62 | + | Clock ports.Clock | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | // NewMergeRequests builds the merge request use cases. | |
| 66 | + | func NewMergeRequests(cfg MergeRequestsConfig) *MergeRequests { | |
| 67 | + | return &MergeRequests{ | |
| 68 | + | meta: cfg.Meta, store: cfg.Store, repos: cfg.Repos, git: cfg.Git, signer: cfg.Signer, | |
| 69 | + | committer: cfg.Committer, bus: cfg.Bus, clock: cfg.Clock, | |
| 70 | + | locks: make(map[domain.RepoName]chan struct{}), | |
| 71 | + | } | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | // lockRepo takes the merge lock of a repository, waiting until ctx ends. The | |
| 75 | + | // merges of one repository, and the creation of its merge requests, run one at | |
| 76 | + | // a time. The returned function releases the lock. | |
| 77 | + | func (m *MergeRequests) lockRepo(ctx context.Context, name domain.RepoName) (unlock func(), err error) { | |
| 78 | + | m.mu.Lock() | |
| 79 | + | ch, ok := m.locks[name] | |
| 80 | + | if !ok { | |
| 81 | + | ch = make(chan struct{}, 1) | |
| 82 | + | m.locks[name] = ch | |
| 83 | + | } | |
| 84 | + | m.mu.Unlock() | |
| 85 | + | select { | |
| 86 | + | case ch <- struct{}{}: | |
| 87 | + | return func() { <-ch }, nil | |
| 88 | + | case <-ctx.Done(): | |
| 89 | + | return nil, ctx.Err() | |
| 90 | + | } | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | // readable looks the repository up and applies the read rule. | |
| 94 | + | func (m *MergeRequests) readable(ctx context.Context, p domain.Principal, name domain.RepoName) (domain.Repo, error) { | |
| 95 | + | repo, err := lookupRepo(ctx, m.meta, name) | |
| 96 | + | if err != nil { | |
| 97 | + | return domain.Repo{}, err | |
| 98 | + | } | |
| 99 | + | if !canRead(p, repo) { | |
| 100 | + | return domain.Repo{}, domain.ErrNotFound | |
| 101 | + | } | |
| 102 | + | return repo, nil | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | // writable is readable plus the scope. | |
| 106 | + | func (m *MergeRequests) writable(ctx context.Context, p domain.Principal, name domain.RepoName, scope domain.Scope) (domain.Repo, error) { | |
| 107 | + | repo, err := m.readable(ctx, p, name) | |
| 108 | + | if err != nil { | |
| 109 | + | return domain.Repo{}, err | |
| 110 | + | } | |
| 111 | + | if err := scopeAccess(p, repo, scope); err != nil { | |
| 112 | + | return domain.Repo{}, err | |
| 113 | + | } | |
| 114 | + | return repo, nil | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | // withHead fills HeadID of an open request from its source branch: zero when | |
| 118 | + | // the branch is gone. Merged and closed requests keep the head they stored. | |
| 119 | + | func (m *MergeRequests) withHead(ctx context.Context, mr domain.MergeRequest) (domain.MergeRequest, error) { | |
| 120 | + | if !mr.IsOpen() { | |
| 121 | + | return mr, nil | |
| 122 | + | } | |
| 123 | + | head, err := m.git.BranchHead(ctx, mr.Repo, mr.Source) | |
| 124 | + | switch { | |
| 125 | + | case errors.Is(err, domain.ErrNotFound): | |
| 126 | + | mr.HeadID = domain.ObjectID{} | |
| 127 | + | case err != nil: | |
| 128 | + | return domain.MergeRequest{}, err | |
| 129 | + | default: | |
| 130 | + | mr.HeadID = head | |
| 131 | + | } | |
| 132 | + | return mr, nil | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | // branchOrZero returns the head of a branch, the zero id when it is gone. | |
| 136 | + | func (m *MergeRequests) branchOrZero(ctx context.Context, repo domain.RepoName, branch string) (domain.ObjectID, error) { | |
| 137 | + | id, err := m.git.BranchHead(ctx, repo, branch) | |
| 138 | + | if errors.Is(err, domain.ErrNotFound) { | |
| 139 | + | return domain.ObjectID{}, nil | |
| 140 | + | } | |
| 141 | + | return id, err | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | // CreateMergeRequestParams describes the merge request to open. | |
| 145 | + | type CreateMergeRequestParams struct { | |
| 146 | + | Title string | |
| 147 | + | Description string | |
| 148 | + | // Source is required; Target defaults to the default branch. | |
| 149 | + | Source, Target string | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | // Create opens a merge request and publishes MergeRequestOpened. The principal | |
| 153 | + | // needs mr:write. A request that breaks a rule of ADR 0017 (the texts, a | |
| 154 | + | // branch that does not exist, source and target the same) is rejected whole | |
| 155 | + | // with an *domain.InvalidFieldsError naming the fields. An open request with | |
| 156 | + | // the same source and target gives domain.ErrAlreadyExists. | |
| 157 | + | func (m *MergeRequests) Create(ctx context.Context, p domain.Principal, name domain.RepoName, params CreateMergeRequestParams) (domain.MergeRequest, error) { | |
| 158 | + | repo, err := m.writable(ctx, p, name, domain.ScopeMRWrite) | |
| 159 | + | if err != nil { | |
| 160 | + | return domain.MergeRequest{}, err | |
| 161 | + | } | |
| 162 | + | var v domain.Violations | |
| 163 | + | title, err := domain.ParseMRTitle(params.Title) | |
| 164 | + | if err != nil { | |
| 165 | + | v.AddErr("title", err) | |
| 166 | + | } | |
| 167 | + | desc, err := domain.ParsePlainText(params.Description) | |
| 168 | + | if err != nil { | |
| 169 | + | v.AddErr("description", err) | |
| 170 | + | } | |
| 171 | + | source, target := m.checkBranches(ctx, name, params, &v) | |
| 172 | + | if err := v.Err(); err != nil { | |
| 173 | + | return domain.MergeRequest{}, err | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | // Under the lock, so that two requests for the same pair cannot both pass | |
| 177 | + | // the check for an existing one. | |
| 178 | + | unlock, err := m.lockRepo(ctx, name) | |
| 179 | + | if err != nil { | |
| 180 | + | return domain.MergeRequest{}, err | |
| 181 | + | } | |
| 182 | + | defer unlock() | |
| 183 | + | existing, err := m.store.List(ctx, name) | |
| 184 | + | if err != nil { | |
| 185 | + | return domain.MergeRequest{}, fmt.Errorf("list merge requests of %q: %w", name, err) | |
| 186 | + | } | |
| 187 | + | for _, e := range existing { | |
| 188 | + | if e.IsOpen() && e.Source == source && e.Target == target { | |
| 189 | + | return domain.MergeRequest{}, fmt.Errorf("%w: !%d already merges %s into %s", domain.ErrAlreadyExists, e.Number, source, target) | |
| 190 | + | } | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | now := m.clock.Now().UTC() | |
| 194 | + | mr, err := m.store.Create(ctx, domain.MergeRequest{ | |
| 195 | + | Repo: name, Title: title, Description: desc, Source: source, Target: target, | |
| 196 | + | State: domain.MRStateOpen, Author: authorOf(p), CreatedAt: now, UpdatedAt: now, | |
| 197 | + | }) | |
| 198 | + | if err != nil { | |
| 199 | + | return domain.MergeRequest{}, fmt.Errorf("create merge request of %q: %w", name, err) | |
| 200 | + | } | |
| 201 | + | log := zerowrap.FromCtx(ctx) | |
| 202 | + | log.Info().Str("repo", name.String()).Int64("number", mr.Number). | |
| 203 | + | Str("source", source).Str("target", target). | |
| 204 | + | Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("merge request opened") | |
| 205 | + | mr, err = m.withHead(ctx, mr) | |
| 206 | + | if err != nil { | |
| 207 | + | return domain.MergeRequest{}, err | |
| 208 | + | } | |
| 209 | + | m.bus.Publish(ctx, domain.MergeRequestOpened{MergeRequest: mr, At: now, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt}) | |
| 210 | + | return mr, nil | |
| 211 | + | } | |
| 212 | + | ||
| 213 | + | // checkBranches validates the branches of a new request and returns them, with | |
| 214 | + | // the default branch filled in for the target. | |
| 215 | + | func (m *MergeRequests) checkBranches(ctx context.Context, name domain.RepoName, params CreateMergeRequestParams, v *domain.Violations) (source, target string) { | |
| 216 | + | source, target = params.Source, params.Target | |
| 217 | + | if source == "" { | |
| 218 | + | v.Add("source_branch", "is required") | |
| 219 | + | } else if _, err := domain.ParseBranchName(source); err != nil { | |
| 220 | + | v.Add("source_branch", "is not a valid branch name") | |
| 221 | + | source = "" | |
| 222 | + | } | |
| 223 | + | if target == "" { | |
| 224 | + | def, exists, err := m.repos.DefaultBranch(ctx, name) | |
| 225 | + | switch { | |
| 226 | + | case err != nil: | |
| 227 | + | v.Add("target_branch", "cannot be read: "+err.Error()) | |
| 228 | + | case !exists: | |
| 229 | + | v.Add("target_branch", fmt.Sprintf("is empty and the default branch %q does not exist", def)) | |
| 230 | + | default: | |
| 231 | + | target = def | |
| 232 | + | } | |
| 233 | + | } else if _, err := domain.ParseBranchName(target); err != nil { | |
| 234 | + | v.Add("target_branch", "is not a valid branch name") | |
| 235 | + | target = "" | |
| 236 | + | } | |
| 237 | + | if source != "" && source == target { | |
| 238 | + | v.Add("target_branch", "must differ from source_branch") | |
| 239 | + | return source, target | |
| 240 | + | } | |
| 241 | + | for _, b := range []struct{ field, name string }{{"source_branch", source}, {"target_branch", target}} { | |
| 242 | + | if b.name == "" { | |
| 243 | + | continue | |
| 244 | + | } | |
| 245 | + | _, err := m.git.BranchHead(ctx, name, b.name) | |
| 246 | + | if errors.Is(err, domain.ErrNotFound) { | |
| 247 | + | v.Add(b.field, fmt.Sprintf("branch %q does not exist", b.name)) | |
| 248 | + | } else if err != nil { | |
| 249 | + | v.Add(b.field, "cannot be read: "+err.Error()) | |
| 250 | + | } | |
| 251 | + | } | |
| 252 | + | return source, target | |
| 253 | + | } | |
| 254 | + | ||
| 255 | + | // ListMergeRequestsParams selects a page of merge requests. | |
| 256 | + | type ListMergeRequestsParams struct { | |
| 257 | + | // State restricts the result; empty means every state. | |
| 258 | + | State domain.MRState | |
| 259 | + | // PageSize is 0 for the default, and lowered to MaxPageSize. | |
| 260 | + | PageSize int | |
| 261 | + | // PageToken is the NextPageToken of the previous page, or empty. | |
| 262 | + | PageToken string | |
| 263 | + | } | |
| 264 | + | ||
| 265 | + | // MergeRequestPage is a page of merge requests, highest number first. | |
| 266 | + | type MergeRequestPage struct { | |
| 267 | + | MergeRequests []domain.MergeRequest | |
| 268 | + | // NextPageToken is empty on the last page. | |
| 269 | + | NextPageToken string | |
| 270 | + | } | |
| 271 | + | ||
| 272 | + | // List returns a page of the merge requests of a repository, highest number | |
| 273 | + | // first. The open ones carry the head of their source branch. The page token | |
| 274 | + | // names the last number of the previous page, so requests created between two | |
| 275 | + | // pages never shift the others. | |
| 276 | + | func (m *MergeRequests) List(ctx context.Context, p domain.Principal, name domain.RepoName, params ListMergeRequestsParams) (MergeRequestPage, error) { | |
| 277 | + | size, err := pageSize(params.PageSize) | |
| 278 | + | if err != nil { | |
| 279 | + | return MergeRequestPage{}, err | |
| 280 | + | } | |
| 281 | + | after, err := decodeMRToken(params.PageToken) | |
| 282 | + | if err != nil { | |
| 283 | + | return MergeRequestPage{}, err | |
| 284 | + | } | |
| 285 | + | if _, err := m.readable(ctx, p, name); err != nil { | |
| 286 | + | return MergeRequestPage{}, err | |
| 287 | + | } | |
| 288 | + | all, err := m.store.List(ctx, name) | |
| 289 | + | if err != nil { | |
| 290 | + | return MergeRequestPage{}, fmt.Errorf("list merge requests of %q: %w", name, err) | |
| 291 | + | } | |
| 292 | + | var page MergeRequestPage | |
| 293 | + | page.MergeRequests, page.NextPageToken = pageAfter(all, size, | |
| 294 | + | func(mr domain.MergeRequest) bool { | |
| 295 | + | return (params.State == "" || mr.State == params.State) && (after == 0 || mr.Number < after) | |
| 296 | + | }, | |
| 297 | + | encodeMRToken) | |
| 298 | + | for i, mr := range page.MergeRequests { | |
| 299 | + | if page.MergeRequests[i], err = m.withHead(ctx, mr); err != nil { | |
| 300 | + | return MergeRequestPage{}, err | |
| 301 | + | } | |
| 302 | + | } | |
| 303 | + | return page, nil | |
| 304 | + | } | |
| 305 | + | ||
| 306 | + | func encodeMRToken(last domain.MergeRequest) string { | |
| 307 | + | return base64.RawURLEncoding.EncodeToString([]byte(mrTokenPrefix + strconv.FormatInt(last.Number, 10))) | |
| 308 | + | } | |
| 309 | + | ||
| 310 | + | // decodeMRToken returns 0 for the empty token. | |
| 311 | + | func decodeMRToken(tok string) (int64, error) { | |
| 312 | + | if tok == "" { | |
| 313 | + | return 0, nil | |
| 314 | + | } | |
| 315 | + | raw, ok := decodeToken(tok, mrTokenPrefix) | |
| 316 | + | if ok { | |
| 317 | + | if n, err := strconv.ParseInt(raw, 10, 64); err == nil && n > 0 { | |
| 318 | + | return n, nil | |
| 319 | + | } | |
| 320 | + | } | |
| 321 | + | return 0, fmt.Errorf("%w: merge requests", domain.ErrInvalidPageToken) | |
| 322 | + | } | |
| 323 | + | ||
| 324 | + | // get reads a merge request of a repository the principal can read. | |
| 325 | + | func (m *MergeRequests) get(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.Repo, domain.MergeRequest, error) { | |
| 326 | + | repo, err := m.readable(ctx, p, name) | |
| 327 | + | if err != nil { | |
| 328 | + | return domain.Repo{}, domain.MergeRequest{}, err | |
| 329 | + | } | |
| 330 | + | mr, err := m.store.Get(ctx, name, number) | |
| 331 | + | if err != nil { | |
| 332 | + | return domain.Repo{}, domain.MergeRequest{}, err | |
| 333 | + | } | |
| 334 | + | return repo, mr, nil | |
| 335 | + | } | |
| 336 | + | ||
| 337 | + | // MergeRequestDetail is a merge request with its review data. | |
| 338 | + | type MergeRequestDetail struct { | |
| 339 | + | MergeRequest domain.MergeRequest | |
| 340 | + | // Commits are the commits of the head that the target lacks, newest | |
| 341 | + | // first, at most MaxMergeRequestCommits. | |
| 342 | + | Commits []domain.Commit | |
| 343 | + | CommitsTruncated bool | |
| 344 | + | // DiffBaseID is the commit the diff compares from; zero when the source | |
| 345 | + | // branch is gone, then the diff is empty. | |
| 346 | + | DiffBaseID domain.ObjectID | |
| 347 | + | Stats domain.DiffStats | |
| 348 | + | Diff domain.Diff | |
| 349 | + | // Comments are the comments, oldest first. | |
| 350 | + | Comments []domain.Comment | |
| 351 | + | // LastReadID is the head recorded by MarkRead; zero when none. | |
| 352 | + | LastReadID domain.ObjectID | |
| 353 | + | // LastReadLost tells that since-last-read was asked for, a head was | |
| 354 | + | // recorded, but that commit is gone: the diff is against the target. | |
| 355 | + | LastReadLost bool | |
| 356 | + | } | |
| 357 | + | ||
| 358 | + | // Get returns a merge request with its commits, its diff, its comments and the | |
| 359 | + | // read position. The diff compares the merge base of the target and the head | |
| 360 | + | // with the head ("target...head"), within domain.DefaultDiffLimits. With | |
| 361 | + | // sinceLastRead it compares the head recorded by MarkRead with the current | |
| 362 | + | // head instead, which works after a force-push as long as the recorded commit | |
| 363 | + | // still exists; when none was recorded the diff is the default one, and when | |
| 364 | + | // the recorded commit is gone too, with LastReadLost set. A merged or closed | |
| 365 | + | // request keeps the head and the base it had. | |
| 366 | + | func (m *MergeRequests) Get(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, sinceLastRead bool) (MergeRequestDetail, error) { | |
| 367 | + | _, mr, err := m.get(ctx, p, name, number) | |
| 368 | + | if err != nil { | |
| 369 | + | return MergeRequestDetail{}, err | |
| 370 | + | } | |
| 371 | + | if mr, err = m.withHead(ctx, mr); err != nil { | |
| 372 | + | return MergeRequestDetail{}, err | |
| 373 | + | } | |
| 374 | + | d := MergeRequestDetail{MergeRequest: mr} | |
| 375 | + | if d.Comments, err = m.store.ListComments(ctx, name, number); err != nil { | |
| 376 | + | return MergeRequestDetail{}, fmt.Errorf("list comments of !%d in %q: %w", number, name, err) | |
| 377 | + | } | |
| 378 | + | if d.LastReadID, err = m.store.GetRead(ctx, name, number); err != nil { | |
| 379 | + | return MergeRequestDetail{}, err | |
| 380 | + | } | |
| 381 | + | ||
| 382 | + | exclude, base, err := m.rangeOf(ctx, mr) | |
| 383 | + | if err != nil { | |
| 384 | + | return MergeRequestDetail{}, err | |
| 385 | + | } | |
| 386 | + | head := mr.HeadID | |
| 387 | + | if head.IsZero() { | |
| 388 | + | return d, nil // the source branch is gone: no commits, no diff | |
| 389 | + | } | |
| 390 | + | if d.Commits, d.CommitsTruncated, err = m.git.ListRange(ctx, name, exclude, head, MaxMergeRequestCommits); err != nil { | |
| 391 | + | return MergeRequestDetail{}, err | |
| 392 | + | } | |
| 393 | + | if sinceLastRead && !d.LastReadID.IsZero() { | |
| 394 | + | switch ok, err := m.git.HasCommit(ctx, name, d.LastReadID); { | |
| 395 | + | case err != nil: | |
| 396 | + | return MergeRequestDetail{}, err | |
| 397 | + | case ok: | |
| 398 | + | base = d.LastReadID | |
| 399 | + | default: | |
| 400 | + | d.LastReadLost = true | |
| 401 | + | } | |
| 402 | + | } | |
| 403 | + | if base.IsZero() { | |
| 404 | + | return d, nil | |
| 405 | + | } | |
| 406 | + | d.DiffBaseID = base | |
| 407 | + | if d.Stats, d.Diff, err = m.git.DiffCommits(ctx, name, base, head, domain.DefaultDiffLimits()); err != nil { | |
| 408 | + | return MergeRequestDetail{}, err | |
| 409 | + | } | |
| 410 | + | return d, nil | |
| 411 | + | } | |
| 412 | + | ||
| 413 | + | // rangeOf returns what the review of a request compares: the commit whose | |
| 414 | + | // history the request's commits exclude, and the base of its diff. For an open | |
| 415 | + | // request they are the tip of the target and the merge base of the target and | |
| 416 | + | // the head; both are zero when the target is gone or shares no history with | |
| 417 | + | // the head. A merged or closed request keeps the base it stored for both. | |
| 418 | + | func (m *MergeRequests) rangeOf(ctx context.Context, mr domain.MergeRequest) (exclude, base domain.ObjectID, err error) { | |
| 419 | + | if !mr.IsOpen() { | |
| 420 | + | return mr.BaseID, mr.BaseID, nil | |
| 421 | + | } | |
| 422 | + | if mr.HeadID.IsZero() { | |
| 423 | + | return domain.ObjectID{}, domain.ObjectID{}, nil | |
| 424 | + | } | |
| 425 | + | tip, err := m.branchOrZero(ctx, mr.Repo, mr.Target) | |
| 426 | + | if err != nil || tip.IsZero() { | |
| 427 | + | return domain.ObjectID{}, domain.ObjectID{}, err | |
| 428 | + | } | |
| 429 | + | base, err = m.git.MergeBase(ctx, mr.Repo, tip, mr.HeadID) | |
| 430 | + | if errors.Is(err, domain.ErrNotFound) { | |
| 431 | + | return tip, domain.ObjectID{}, nil | |
| 432 | + | } | |
| 433 | + | return tip, base, err | |
| 434 | + | } | |
| 435 | + | ||
| 436 | + | // AddCommentParams describes the comment to add. | |
| 437 | + | type AddCommentParams struct { | |
| 438 | + | // Path, Line and Side designate a line of the diff; all empty for a | |
| 439 | + | // general comment. | |
| 440 | + | Path string | |
| 441 | + | Line int | |
| 442 | + | Side domain.DiffSide | |
| 443 | + | Text string | |
| 444 | + | } | |
| 445 | + | ||
| 446 | + | // AddComment adds a comment and publishes CommentAdded. The principal needs | |
| 447 | + | // mr:write. The text must follow the rules of ADR 0017 and not be blank; a | |
| 448 | + | // comment on a line must name a line of the current diff of the request (see | |
| 449 | + | // domain.Diff.HasLine), else the call fails with domain.ErrInvalid. A request | |
| 450 | + | // that is not open gives domain.ErrPreconditionFailed. | |
| 451 | + | func (m *MergeRequests) AddComment(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params AddCommentParams) (domain.Comment, error) { | |
| 452 | + | repo, err := m.writable(ctx, p, name, domain.ScopeMRWrite) | |
| 453 | + | if err != nil { | |
| 454 | + | return domain.Comment{}, err | |
| 455 | + | } | |
| 456 | + | mr, err := m.store.Get(ctx, name, number) | |
| 457 | + | if err != nil { | |
| 458 | + | return domain.Comment{}, err | |
| 459 | + | } | |
| 460 | + | var v domain.Violations | |
| 461 | + | text, err := domain.ParsePlainText(params.Text) | |
| 462 | + | switch { | |
| 463 | + | case err != nil: | |
| 464 | + | v.AddErr("text", err) | |
| 465 | + | case text.IsBlank(): | |
| 466 | + | v.Add("text", "is required") | |
| 467 | + | } | |
| 468 | + | general := params.Path == "" | |
| 469 | + | switch { | |
| 470 | + | case general && (params.Line != 0 || params.Side != domain.SideNone): | |
| 471 | + | v.Add("path", "is required with line and side") | |
| 472 | + | case !general && params.Line < 1: | |
| 473 | + | v.Add("line", "must be 1 or more with a path") | |
| 474 | + | case !general && params.Side != domain.SideOld && params.Side != domain.SideNew: | |
| 475 | + | v.Add("side", "is required with a path") | |
| 476 | + | } | |
| 477 | + | if err := v.Err(); err != nil { | |
| 478 | + | return domain.Comment{}, err | |
| 479 | + | } | |
| 480 | + | if !mr.IsOpen() { | |
| 481 | + | return domain.Comment{}, fmt.Errorf("%w: !%d is %s", domain.ErrPreconditionFailed, number, mr.State) | |
| 482 | + | } | |
| 483 | + | ||
| 484 | + | if mr, err = m.withHead(ctx, mr); err != nil { | |
| 485 | + | return domain.Comment{}, err | |
| 486 | + | } | |
| 487 | + | if !general { | |
| 488 | + | if err := m.checkLine(ctx, mr, params); err != nil { | |
| 489 | + | return domain.Comment{}, err | |
| 490 | + | } | |
| 491 | + | } | |
| 492 | + | ||
| 493 | + | var c domain.Comment | |
| 494 | + | for range maxWriteRetries { | |
| 495 | + | c = domain.Comment{ | |
| 496 | + | ID: mr.CommentSeq + 1, Author: authorOf(p), Path: params.Path, Line: params.Line, Side: params.Side, | |
| 497 | + | CommitID: mr.HeadID, Text: text, CreatedAt: m.clock.Now().UTC(), | |
| 498 | + | } | |
| 499 | + | mr.CommentSeq, mr.UpdatedAt = c.ID, c.CreatedAt | |
| 500 | + | err = m.store.SaveComment(ctx, mr, c) | |
| 501 | + | if !errors.Is(err, domain.ErrConflict) { | |
| 502 | + | break | |
| 503 | + | } | |
| 504 | + | // Another write got in between: read the request again. | |
| 505 | + | fresh, gerr := m.store.Get(ctx, name, number) | |
| 506 | + | if gerr != nil { | |
| 507 | + | return domain.Comment{}, gerr | |
| 508 | + | } | |
| 509 | + | if !fresh.IsOpen() { | |
| 510 | + | return domain.Comment{}, fmt.Errorf("%w: !%d is %s", domain.ErrPreconditionFailed, number, fresh.State) | |
| 511 | + | } | |
| 512 | + | fresh.HeadID = mr.HeadID | |
| 513 | + | mr = fresh | |
| 514 | + | } | |
| 515 | + | if err != nil { | |
| 516 | + | return domain.Comment{}, fmt.Errorf("save comment on !%d of %q: %w", number, name, err) | |
| 517 | + | } | |
| 518 | + | log := zerowrap.FromCtx(ctx) | |
| 519 | + | log.Info().Str("repo", name.String()).Int64("number", number).Int64("comment", c.ID). | |
| 520 | + | Bool("on_line", !general).Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("comment added") | |
| 521 | + | m.bus.Publish(ctx, domain.CommentAdded{ | |
| 522 | + | Repo: name, Number: number, Comment: c, At: c.CreatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt, | |
| 523 | + | }) | |
| 524 | + | return c, nil | |
| 525 | + | } | |
| 526 | + | ||
| 527 | + | // checkLine verifies that a comment designates a line of the current diff of | |
| 528 | + | // the request. | |
| 529 | + | func (m *MergeRequests) checkLine(ctx context.Context, mr domain.MergeRequest, params AddCommentParams) error { | |
| 530 | + | var v domain.Violations | |
| 531 | + | _, base, err := m.rangeOf(ctx, mr) | |
| 532 | + | if err != nil { | |
| 533 | + | return err | |
| 534 | + | } | |
| 535 | + | if mr.HeadID.IsZero() || base.IsZero() { | |
| 536 | + | v.Add("path", "the merge request has no diff to comment on") | |
| 537 | + | return v.Err() | |
| 538 | + | } | |
| 539 | + | _, diff, err := m.git.DiffCommits(ctx, mr.Repo, base, mr.HeadID, domain.DefaultDiffLimits()) | |
| 540 | + | if err != nil { | |
| 541 | + | return err | |
| 542 | + | } | |
| 543 | + | if !diff.HasLine(params.Path, params.Side, params.Line) { | |
| 544 | + | v.Add("path", fmt.Sprintf("%s:%d on the %s side is not a line of the diff", params.Path, params.Line, params.Side)) | |
| 545 | + | } | |
| 546 | + | return v.Err() | |
| 547 | + | } | |
| 548 | + | ||
| 549 | + | // ResolveComment marks a comment resolved or unresolved. The principal needs | |
| 550 | + | // mr:write. An unknown comment gives domain.ErrNotFound and a request that is | |
| 551 | + | // not open domain.ErrPreconditionFailed. Setting the state the comment already | |
| 552 | + | // has succeeds and writes nothing. | |
| 553 | + | func (m *MergeRequests) ResolveComment(ctx context.Context, p domain.Principal, name domain.RepoName, number, commentID int64, resolved bool) (domain.Comment, error) { | |
| 554 | + | if _, err := m.writable(ctx, p, name, domain.ScopeMRWrite); err != nil { | |
| 555 | + | return domain.Comment{}, err | |
| 556 | + | } | |
| 557 | + | for range maxWriteRetries { | |
| 558 | + | mr, err := m.store.Get(ctx, name, number) | |
| 559 | + | if err != nil { | |
| 560 | + | return domain.Comment{}, err | |
| 561 | + | } | |
| 562 | + | c, err := m.store.GetComment(ctx, name, number, commentID) | |
| 563 | + | if err != nil { | |
| 564 | + | return domain.Comment{}, err | |
| 565 | + | } | |
| 566 | + | if !mr.IsOpen() { | |
| 567 | + | return domain.Comment{}, fmt.Errorf("%w: !%d is %s", domain.ErrPreconditionFailed, number, mr.State) | |
| 568 | + | } | |
| 569 | + | if c.Resolved == resolved { | |
| 570 | + | return c, nil | |
| 571 | + | } | |
| 572 | + | c.Resolved = resolved | |
| 573 | + | mr.UpdatedAt = m.clock.Now().UTC() | |
| 574 | + | err = m.store.SaveComment(ctx, mr, c) | |
| 575 | + | if errors.Is(err, domain.ErrConflict) { | |
| 576 | + | continue | |
| 577 | + | } | |
| 578 | + | if err != nil { | |
| 579 | + | return domain.Comment{}, fmt.Errorf("resolve comment %d of !%d in %q: %w", commentID, number, name, err) | |
| 580 | + | } | |
| 581 | + | return c, nil | |
| 582 | + | } | |
| 583 | + | return domain.Comment{}, fmt.Errorf("resolve comment %d of !%d in %q: %w", commentID, number, name, domain.ErrConflict) | |
| 584 | + | } | |
| 585 | + | ||
| 586 | + | // Close closes an open merge request without merging it and publishes | |
| 587 | + | // MergeRequestClosed. The principal needs mr:write. A request that is not open | |
| 588 | + | // gives domain.ErrPreconditionFailed. The request keeps the head it had and | |
| 589 | + | // its merge base, so that its diff stays readable. | |
| 590 | + | func (m *MergeRequests) Close(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.MergeRequest, error) { | |
| 591 | + | repo, err := m.writable(ctx, p, name, domain.ScopeMRWrite) | |
| 592 | + | if err != nil { | |
| 593 | + | return domain.MergeRequest{}, err | |
| 594 | + | } | |
| 595 | + | unlock, err := m.lockRepo(ctx, name) // a merge and a close of one request exclude each other | |
| 596 | + | if err != nil { | |
| 597 | + | return domain.MergeRequest{}, err | |
| 598 | + | } | |
| 599 | + | defer unlock() | |
| 600 | + | for range maxWriteRetries { | |
| 601 | + | mr, err := m.store.Get(ctx, name, number) | |
| 602 | + | if err != nil { | |
| 603 | + | return domain.MergeRequest{}, err | |
| 604 | + | } | |
| 605 | + | if !mr.IsOpen() { | |
| 606 | + | return domain.MergeRequest{}, fmt.Errorf("%w: !%d is %s", domain.ErrPreconditionFailed, number, mr.State) | |
| 607 | + | } | |
| 608 | + | if mr, err = m.withHead(ctx, mr); err != nil { | |
| 609 | + | return domain.MergeRequest{}, err | |
| 610 | + | } | |
| 611 | + | _, base, err := m.rangeOf(ctx, mr) | |
| 612 | + | if err != nil { | |
| 613 | + | return domain.MergeRequest{}, err | |
| 614 | + | } | |
| 615 | + | mr.State, mr.BaseID, mr.UpdatedAt = domain.MRStateClosed, base, m.clock.Now().UTC() | |
| 616 | + | err = m.store.Update(ctx, mr) | |
| 617 | + | if errors.Is(err, domain.ErrConflict) { | |
| 618 | + | continue | |
| 619 | + | } | |
| 620 | + | if err != nil { | |
| 621 | + | return domain.MergeRequest{}, fmt.Errorf("close !%d of %q: %w", number, name, err) | |
| 622 | + | } | |
| 623 | + | log := zerowrap.FromCtx(ctx) | |
| 624 | + | log.Info().Str("repo", name.String()).Int64("number", number). | |
| 625 | + | Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("merge request closed") | |
| 626 | + | m.bus.Publish(ctx, domain.MergeRequestClosed{MergeRequest: mr, At: mr.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt}) | |
| 627 | + | return mr, nil | |
| 628 | + | } | |
| 629 | + | return domain.MergeRequest{}, fmt.Errorf("close !%d of %q: %w", number, name, domain.ErrConflict) | |
| 630 | + | } | |
| 631 | + | ||
| 632 | + | // MarkRead records the head the reviewer has read, for the since-last-read diff | |
| 633 | + | // of Get. The principal needs mr:write. A zero head means the current head of | |
| 634 | + | // the source branch (the stored one for a merged or closed request); one that | |
| 635 | + | // is not a commit of the repository gives domain.ErrInvalid. It returns the | |
| 636 | + | // head recorded. | |
| 637 | + | func (m *MergeRequests) MarkRead(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, head domain.ObjectID) (domain.ObjectID, error) { | |
| 638 | + | if _, err := m.writable(ctx, p, name, domain.ScopeMRWrite); err != nil { | |
| 639 | + | return domain.ObjectID{}, err | |
| 640 | + | } | |
| 641 | + | mr, err := m.store.Get(ctx, name, number) | |
| 642 | + | if err != nil { | |
| 643 | + | return domain.ObjectID{}, err | |
| 644 | + | } | |
| 645 | + | if head.IsZero() { | |
| 646 | + | if mr, err = m.withHead(ctx, mr); err != nil { | |
| 647 | + | return domain.ObjectID{}, err | |
| 648 | + | } | |
| 649 | + | if head = mr.HeadID; head.IsZero() { | |
| 650 | + | return domain.ObjectID{}, fmt.Errorf("%w: the source branch %q no longer exists, give a head", domain.ErrPreconditionFailed, mr.Source) | |
| 651 | + | } | |
| 652 | + | } else { | |
| 653 | + | var v domain.Violations | |
| 654 | + | switch ok, err := m.git.HasCommit(ctx, name, head); { | |
| 655 | + | case err != nil: | |
| 656 | + | return domain.ObjectID{}, err | |
| 657 | + | case !ok: | |
| 658 | + | v.Add("head_id", fmt.Sprintf("%s is not a commit of the repository", head)) | |
| 659 | + | return domain.ObjectID{}, v.Err() | |
| 660 | + | } | |
| 661 | + | } | |
| 662 | + | if err := m.store.SetRead(ctx, name, number, head); err != nil { | |
| 663 | + | return domain.ObjectID{}, err | |
| 664 | + | } | |
| 665 | + | return head, nil | |
| 666 | + | } | |
| 667 | + | ||
| 668 | + | // MergeParams describes the merge to perform. | |
| 669 | + | type MergeParams struct { | |
| 670 | + | // Mode defaults to fast-forward. | |
| 671 | + | Mode domain.MergeMode | |
| 672 | + | // HeadID is the commit the caller reviewed; the source branch must still | |
| 673 | + | // point to it. | |
| 674 | + | HeadID domain.ObjectID | |
| 675 | + | } | |
| 676 | + | ||
| 677 | + | // Merge merges an open merge request, publishes MergeRequestMerged then a | |
| 678 | + | // PushEvent for the target branch, and returns the merged request. The | |
| 679 | + | // principal needs mr:merge. Merges of one repository run one at a time. | |
| 680 | + | // | |
| 681 | + | // It fails with domain.ErrInvalid for a zero head, and with | |
| 682 | + | // domain.ErrPreconditionFailed when the request is not open, the mode is not | |
| 683 | + | // allowed by the settings of the repository, the source branch no longer | |
| 684 | + | // points to the head ("branch moved, read again") or is gone, the target is | |
| 685 | + | // gone or is not an ancestor of the head (the author rebases and pushes | |
| 686 | + | // again), or the branch brings nothing. Checks come with CI (CI-1): there are | |
| 687 | + | // none yet, so the setting that lets failing checks block the merge has | |
| 688 | + | // nothing to act on. | |
| 689 | + | // | |
| 690 | + | // A fast-forward moves the target to the head, with the old value as a | |
| 691 | + | // precondition. A squash writes one commit with the tree of the head, the | |
| 692 | + | // target as parent and the author and message of domain.NewSquashCommit, | |
| 693 | + | // signed by the instance key, and moves the target to it the same way. | |
| 694 | + | func (m *MergeRequests) Merge(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params MergeParams) (domain.MergeRequest, error) { | |
| 695 | + | repo, err := m.writable(ctx, p, name, domain.ScopeMRMerge) | |
| 696 | + | if err != nil { | |
| 697 | + | return domain.MergeRequest{}, err | |
| 698 | + | } | |
| 699 | + | mode := params.Mode | |
| 700 | + | if mode == "" { | |
| 701 | + | mode = domain.MergeModeFastForward | |
| 702 | + | } | |
| 703 | + | var v domain.Violations | |
| 704 | + | if _, err := domain.ParseMergeMode(mode.String()); err != nil { | |
| 705 | + | v.Add("mode", "is not a merge mode") | |
| 706 | + | } | |
| 707 | + | if params.HeadID.IsZero() { | |
| 708 | + | v.Add("head_id", "is required") | |
| 709 | + | } | |
| 710 | + | if err := v.Err(); err != nil { | |
| 711 | + | return domain.MergeRequest{}, err | |
| 712 | + | } | |
| 713 | + | ||
| 714 | + | unlock, err := m.lockRepo(ctx, name) | |
| 715 | + | if err != nil { | |
| 716 | + | return domain.MergeRequest{}, err | |
| 717 | + | } | |
| 718 | + | defer unlock() | |
| 719 | + | ||
| 720 | + | mr, err := m.store.Get(ctx, name, number) | |
| 721 | + | if err != nil { | |
| 722 | + | return domain.MergeRequest{}, err | |
| 723 | + | } | |
| 724 | + | if !mr.IsOpen() { | |
| 725 | + | return domain.MergeRequest{}, fmt.Errorf("%w: !%d is %s", domain.ErrPreconditionFailed, number, mr.State) | |
| 726 | + | } | |
| 727 | + | // The settings are read again under the lock: a change applies at once. | |
| 728 | + | if current, err := m.meta.Get(ctx, name); err == nil { | |
| 729 | + | repo = current | |
| 730 | + | } | |
| 731 | + | settings := repo.Settings | |
| 732 | + | if settings.IsZero() { | |
| 733 | + | settings = domain.DefaultRepoSettings() | |
| 734 | + | } | |
| 735 | + | if !settings.Allows(mode) { | |
| 736 | + | return domain.MergeRequest{}, fmt.Errorf("%w: the %s merge mode is not allowed in %s", domain.ErrPreconditionFailed, mode, name) | |
| 737 | + | } | |
| 738 | + | ||
| 739 | + | head, err := m.branchOrZero(ctx, name, mr.Source) | |
| 740 | + | switch { | |
| 741 | + | case err != nil: | |
| 742 | + | return domain.MergeRequest{}, err | |
| 743 | + | case head.IsZero(): | |
| 744 | + | return domain.MergeRequest{}, fmt.Errorf("%w: the source branch %q no longer exists", domain.ErrPreconditionFailed, mr.Source) | |
| 745 | + | case head != params.HeadID: | |
| 746 | + | return domain.MergeRequest{}, fmt.Errorf("%w: branch moved, read again: %q is at %s, not %s", domain.ErrPreconditionFailed, mr.Source, head, params.HeadID) | |
| 747 | + | } | |
| 748 | + | tip, err := m.branchOrZero(ctx, name, mr.Target) | |
| 749 | + | switch { | |
| 750 | + | case err != nil: | |
| 751 | + | return domain.MergeRequest{}, err | |
| 752 | + | case tip.IsZero(): | |
| 753 | + | return domain.MergeRequest{}, fmt.Errorf("%w: the target branch %q no longer exists", domain.ErrPreconditionFailed, mr.Target) | |
| 754 | + | case tip == head: | |
| 755 | + | return domain.MergeRequest{}, fmt.Errorf("%w: nothing to merge, %q already is at %s", domain.ErrPreconditionFailed, mr.Target, head) | |
| 756 | + | } | |
| 757 | + | switch ok, err := m.git.IsAncestor(ctx, name, tip, head); { | |
| 758 | + | case err != nil: | |
| 759 | + | return domain.MergeRequest{}, err | |
| 760 | + | case !ok: | |
| 761 | + | return domain.MergeRequest{}, 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) | |
| 762 | + | } | |
| 763 | + | ||
| 764 | + | merged, err := m.moveTarget(ctx, mr, mode, tip, head) | |
| 765 | + | if err != nil { | |
| 766 | + | return domain.MergeRequest{}, err | |
| 767 | + | } | |
| 768 | + | mr, err = m.markMerged(ctx, name, number, mode, tip, head, merged) | |
| 769 | + | if err != nil { | |
| 770 | + | return domain.MergeRequest{}, err | |
| 771 | + | } | |
| 772 | + | log := zerowrap.FromCtx(ctx) | |
| 773 | + | log.Info().Str("repo", name.String()).Int64("number", number).Str("mode", mode.String()). | |
| 774 | + | Str("target", mr.Target).Str("commit", merged.String()). | |
| 775 | + | Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("merge request merged") | |
| 776 | + | at := mr.UpdatedAt | |
| 777 | + | m.bus.Publish(ctx, domain.MergeRequestMerged{MergeRequest: mr, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt}) | |
| 778 | + | m.bus.Publish(ctx, domain.PushEvent{ | |
| 779 | + | Repo: name, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt, | |
| 780 | + | Updates: []domain.RefUpdate{{Old: tip.String(), New: merged.String(), Ref: "refs/heads/" + mr.Target}}, | |
| 781 | + | }) | |
| 782 | + | return mr, nil | |
| 783 | + | } | |
| 784 | + | ||
| 785 | + | // moveTarget moves the target branch from tip to the head (fast-forward) or to | |
| 786 | + | // a new signed squash commit, and returns the new tip. | |
| 787 | + | func (m *MergeRequests) moveTarget(ctx context.Context, mr domain.MergeRequest, mode domain.MergeMode, tip, head domain.ObjectID) (domain.ObjectID, error) { | |
| 788 | + | newTip := head | |
| 789 | + | if mode == domain.MergeModeSquash { | |
| 790 | + | commits, more, err := m.git.ListRange(ctx, mr.Repo, tip, head, domain.MaxSquashCommits) | |
| 791 | + | if err != nil { | |
| 792 | + | return domain.ObjectID{}, err | |
| 793 | + | } | |
| 794 | + | if more { | |
| 795 | + | return domain.ObjectID{}, fmt.Errorf("%w: the branch has more than %d commits to squash", domain.ErrPreconditionFailed, domain.MaxSquashCommits) | |
| 796 | + | } | |
| 797 | + | tree, err := m.git.CommitTree(ctx, mr.Repo, head) | |
| 798 | + | if err != nil { | |
| 799 | + | return domain.ObjectID{}, err | |
| 800 | + | } | |
| 801 | + | commit := domain.NewSquashCommit(mr, tree, tip, commits, m.committer, m.clock.Now()) | |
| 802 | + | armored, err := m.signer.Sign(commit.Payload()) | |
| 803 | + | if err != nil { | |
| 804 | + | return domain.ObjectID{}, fmt.Errorf("sign the squash commit of !%d in %q: %w", mr.Number, mr.Repo, err) | |
| 805 | + | } | |
| 806 | + | if newTip, err = m.git.WriteCommit(ctx, mr.Repo, commit.Signed(armored)); err != nil { | |
| 807 | + | return domain.ObjectID{}, err | |
| 808 | + | } | |
| 809 | + | } | |
| 810 | + | err := m.git.UpdateBranch(ctx, mr.Repo, mr.Target, tip, newTip) | |
| 811 | + | if errors.Is(err, domain.ErrConflict) { | |
| 812 | + | return domain.ObjectID{}, fmt.Errorf("%w: the target branch %q moved during the merge, try again", domain.ErrPreconditionFailed, mr.Target) | |
| 813 | + | } | |
| 814 | + | if err != nil { | |
| 815 | + | return domain.ObjectID{}, err | |
| 816 | + | } | |
| 817 | + | return newTip, nil | |
| 818 | + | } | |
| 819 | + | ||
| 820 | + | // markMerged records the merge. The target already moved, so a write that | |
| 821 | + | // loses a race (a comment added meanwhile) is repeated on the fresh request. | |
| 822 | + | func (m *MergeRequests) markMerged(ctx context.Context, name domain.RepoName, number int64, mode domain.MergeMode, base, head, merged domain.ObjectID) (domain.MergeRequest, error) { | |
| 823 | + | // The merge must be recorded even when the caller gives up. | |
| 824 | + | ctx = context.WithoutCancel(ctx) | |
| 825 | + | for range maxWriteRetries { | |
| 826 | + | mr, err := m.store.Get(ctx, name, number) | |
| 827 | + | if err != nil { | |
| 828 | + | return domain.MergeRequest{}, fmt.Errorf("record the merge of !%d in %q: %w", number, name, err) | |
| 829 | + | } | |
| 830 | + | mr.State, mr.HeadID, mr.BaseID, mr.MergedCommitID, mr.MergeMode = domain.MRStateMerged, head, base, merged, mode | |
| 831 | + | mr.UpdatedAt = m.clock.Now().UTC() | |
| 832 | + | err = m.store.Update(ctx, mr) | |
| 833 | + | if errors.Is(err, domain.ErrConflict) { | |
| 834 | + | continue | |
| 835 | + | } | |
| 836 | + | if err != nil { | |
| 837 | + | return domain.MergeRequest{}, fmt.Errorf("record the merge of !%d in %q: %w", number, name, err) | |
| 838 | + | } | |
| 839 | + | return mr, nil | |
| 840 | + | } | |
| 841 | + | log := zerowrap.FromCtx(ctx) | |
| 842 | + | log.Error().Str("repo", name.String()).Int64("number", number).Str("commit", merged.String()). | |
| 843 | + | Msg("the target branch moved but the merge request could not be marked merged") | |
| 844 | + | return domain.MergeRequest{}, fmt.Errorf("record the merge of !%d in %q: %w", number, name, domain.ErrConflict) | |
| 845 | + | } | |
| @@ -0,0 +1,845 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package app | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "encoding/base64" | |||
| 8 | + | "errors" | |||
| 9 | + | "fmt" | |||
| 10 | + | "strconv" | |||
| 11 | + | "sync" | |||
| 12 | + | ||||
| 13 | + | "github.com/bnema/zerowrap" | |||
| 14 | + | ||||
| 15 | + | "bnema.dev/ermite/internal/domain" | |||
| 16 | + | "bnema.dev/ermite/internal/ports" | |||
| 17 | + | ) | |||
| 18 | + | ||||
| 19 | + | // MaxMergeRequestCommits is the most commits GetMergeRequest returns. | |||
| 20 | + | const MaxMergeRequestCommits = 100 | |||
| 21 | + | ||||
| 22 | + | // maxWriteRetries bounds the retries of a write that loses a race with another | |||
| 23 | + | // write of the same merge request. Each lost race means another writer won, so | |||
| 24 | + | // a writer loses at most as many times as there are other writers at once; the | |||
| 25 | + | // retry is a read and a write of ermite-kv, cheap enough for a bound this high. | |||
| 26 | + | const maxWriteRetries = 32 | |||
| 27 | + | ||||
| 28 | + | const mrTokenPrefix = "mp1:" | |||
| 29 | + | ||||
| 30 | + | // MergeRequests are the merge request use cases (ADR 0017): review, comment, | |||
| 31 | + | // close and merge a branch. Reading needs the access of reading the | |||
| 32 | + | // repository: a repository the principal may not read, a missing one and an | |||
| 33 | + | // unknown number all answer domain.ErrNotFound. Writing needs mr:write, and | |||
| 34 | + | // merging mr:merge, on the repository (see scopeAccess). | |||
| 35 | + | type MergeRequests struct { | |||
| 36 | + | meta ports.RepoMetaStore | |||
| 37 | + | store ports.MergeRequestStore | |||
| 38 | + | repos ports.GitRepos | |||
| 39 | + | git ports.MergeGit | |||
| 40 | + | signer ports.CommitSigner | |||
| 41 | + | committer domain.Identity | |||
| 42 | + | bus ports.EventBus | |||
| 43 | + | clock ports.Clock | |||
| 44 | + | ||||
| 45 | + | mu sync.Mutex | |||
| 46 | + | locks map[domain.RepoName]chan struct{} | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | // MergeRequestsConfig holds what the merge request use cases need. Every field | |||
| 50 | + | // is required. | |||
| 51 | + | type MergeRequestsConfig struct { | |||
| 52 | + | Meta ports.RepoMetaStore | |||
| 53 | + | Store ports.MergeRequestStore | |||
| 54 | + | // Repos gives the default branch of a repository. | |||
| 55 | + | Repos ports.GitRepos | |||
| 56 | + | Git ports.MergeGit | |||
| 57 | + | // Signer signs the squash commits. | |||
| 58 | + | Signer ports.CommitSigner | |||
| 59 | + | // Committer is recorded as the committer of the squash commits. | |||
| 60 | + | Committer domain.Identity | |||
| 61 | + | Bus ports.EventBus | |||
| 62 | + | Clock ports.Clock | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | // NewMergeRequests builds the merge request use cases. | |||
| 66 | + | func NewMergeRequests(cfg MergeRequestsConfig) *MergeRequests { | |||
| 67 | + | return &MergeRequests{ | |||
| 68 | + | meta: cfg.Meta, store: cfg.Store, repos: cfg.Repos, git: cfg.Git, signer: cfg.Signer, | |||
| 69 | + | committer: cfg.Committer, bus: cfg.Bus, clock: cfg.Clock, | |||
| 70 | + | locks: make(map[domain.RepoName]chan struct{}), | |||
| 71 | + | } | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | // lockRepo takes the merge lock of a repository, waiting until ctx ends. The | |||
| 75 | + | // merges of one repository, and the creation of its merge requests, run one at | |||
| 76 | + | // a time. The returned function releases the lock. | |||
| 77 | + | func (m *MergeRequests) lockRepo(ctx context.Context, name domain.RepoName) (unlock func(), err error) { | |||
| 78 | + | m.mu.Lock() | |||
| 79 | + | ch, ok := m.locks[name] | |||
| 80 | + | if !ok { | |||
| 81 | + | ch = make(chan struct{}, 1) | |||
| 82 | + | m.locks[name] = ch | |||
| 83 | + | } | |||
| 84 | + | m.mu.Unlock() | |||
| 85 | + | select { | |||
| 86 | + | case ch <- struct{}{}: | |||
| 87 | + | return func() { <-ch }, nil | |||
| 88 | + | case <-ctx.Done(): | |||
| 89 | + | return nil, ctx.Err() | |||
| 90 | + | } | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | // readable looks the repository up and applies the read rule. | |||
| 94 | + | func (m *MergeRequests) readable(ctx context.Context, p domain.Principal, name domain.RepoName) (domain.Repo, error) { | |||
| 95 | + | repo, err := lookupRepo(ctx, m.meta, name) | |||
| 96 | + | if err != nil { | |||
| 97 | + | return domain.Repo{}, err | |||
| 98 | + | } | |||
| 99 | + | if !canRead(p, repo) { | |||
| 100 | + | return domain.Repo{}, domain.ErrNotFound | |||
| 101 | + | } | |||
| 102 | + | return repo, nil | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | // writable is readable plus the scope. | |||
| 106 | + | func (m *MergeRequests) writable(ctx context.Context, p domain.Principal, name domain.RepoName, scope domain.Scope) (domain.Repo, error) { | |||
| 107 | + | repo, err := m.readable(ctx, p, name) | |||
| 108 | + | if err != nil { | |||
| 109 | + | return domain.Repo{}, err | |||
| 110 | + | } | |||
| 111 | + | if err := scopeAccess(p, repo, scope); err != nil { | |||
| 112 | + | return domain.Repo{}, err | |||
| 113 | + | } | |||
| 114 | + | return repo, nil | |||
| 115 | + | } | |||
| 116 | + | ||||
| 117 | + | // withHead fills HeadID of an open request from its source branch: zero when | |||
| 118 | + | // the branch is gone. Merged and closed requests keep the head they stored. | |||
| 119 | + | func (m *MergeRequests) withHead(ctx context.Context, mr domain.MergeRequest) (domain.MergeRequest, error) { | |||
| 120 | + | if !mr.IsOpen() { | |||
| 121 | + | return mr, nil | |||
| 122 | + | } | |||
| 123 | + | head, err := m.git.BranchHead(ctx, mr.Repo, mr.Source) | |||
| 124 | + | switch { | |||
| 125 | + | case errors.Is(err, domain.ErrNotFound): | |||
| 126 | + | mr.HeadID = domain.ObjectID{} | |||
| 127 | + | case err != nil: | |||
| 128 | + | return domain.MergeRequest{}, err | |||
| 129 | + | default: | |||
| 130 | + | mr.HeadID = head | |||
| 131 | + | } | |||
| 132 | + | return mr, nil | |||
| 133 | + | } | |||
| 134 | + | ||||
| 135 | + | // branchOrZero returns the head of a branch, the zero id when it is gone. | |||
| 136 | + | func (m *MergeRequests) branchOrZero(ctx context.Context, repo domain.RepoName, branch string) (domain.ObjectID, error) { | |||
| 137 | + | id, err := m.git.BranchHead(ctx, repo, branch) | |||
| 138 | + | if errors.Is(err, domain.ErrNotFound) { | |||
| 139 | + | return domain.ObjectID{}, nil | |||
| 140 | + | } | |||
| 141 | + | return id, err | |||
| 142 | + | } | |||
| 143 | + | ||||
| 144 | + | // CreateMergeRequestParams describes the merge request to open. | |||
| 145 | + | type CreateMergeRequestParams struct { | |||
| 146 | + | Title string | |||
| 147 | + | Description string | |||
| 148 | + | // Source is required; Target defaults to the default branch. | |||
| 149 | + | Source, Target string | |||
| 150 | + | } | |||
| 151 | + | ||||
| 152 | + | // Create opens a merge request and publishes MergeRequestOpened. The principal | |||
| 153 | + | // needs mr:write. A request that breaks a rule of ADR 0017 (the texts, a | |||
| 154 | + | // branch that does not exist, source and target the same) is rejected whole | |||
| 155 | + | // with an *domain.InvalidFieldsError naming the fields. An open request with | |||
| 156 | + | // the same source and target gives domain.ErrAlreadyExists. | |||
| 157 | + | func (m *MergeRequests) Create(ctx context.Context, p domain.Principal, name domain.RepoName, params CreateMergeRequestParams) (domain.MergeRequest, error) { | |||
| 158 | + | repo, err := m.writable(ctx, p, name, domain.ScopeMRWrite) | |||
| 159 | + | if err != nil { | |||
| 160 | + | return domain.MergeRequest{}, err | |||
| 161 | + | } | |||
| 162 | + | var v domain.Violations | |||
| 163 | + | title, err := domain.ParseMRTitle(params.Title) | |||
| 164 | + | if err != nil { | |||
| 165 | + | v.AddErr("title", err) | |||
| 166 | + | } | |||
| 167 | + | desc, err := domain.ParsePlainText(params.Description) | |||
| 168 | + | if err != nil { | |||
| 169 | + | v.AddErr("description", err) | |||
| 170 | + | } | |||
| 171 | + | source, target := m.checkBranches(ctx, name, params, &v) | |||
| 172 | + | if err := v.Err(); err != nil { | |||
| 173 | + | return domain.MergeRequest{}, err | |||
| 174 | + | } | |||
| 175 | + | ||||
| 176 | + | // Under the lock, so that two requests for the same pair cannot both pass | |||
| 177 | + | // the check for an existing one. | |||
| 178 | + | unlock, err := m.lockRepo(ctx, name) | |||
| 179 | + | if err != nil { | |||
| 180 | + | return domain.MergeRequest{}, err | |||
| 181 | + | } | |||
| 182 | + | defer unlock() | |||
| 183 | + | existing, err := m.store.List(ctx, name) | |||
| 184 | + | if err != nil { | |||
| 185 | + | return domain.MergeRequest{}, fmt.Errorf("list merge requests of %q: %w", name, err) | |||
| 186 | + | } | |||
| 187 | + | for _, e := range existing { | |||
| 188 | + | if e.IsOpen() && e.Source == source && e.Target == target { | |||
| 189 | + | return domain.MergeRequest{}, fmt.Errorf("%w: !%d already merges %s into %s", domain.ErrAlreadyExists, e.Number, source, target) | |||
| 190 | + | } | |||
| 191 | + | } | |||
| 192 | + | ||||
| 193 | + | now := m.clock.Now().UTC() | |||
| 194 | + | mr, err := m.store.Create(ctx, domain.MergeRequest{ | |||
| 195 | + | Repo: name, Title: title, Description: desc, Source: source, Target: target, | |||
| 196 | + | State: domain.MRStateOpen, Author: authorOf(p), CreatedAt: now, UpdatedAt: now, | |||
| 197 | + | }) | |||
| 198 | + | if err != nil { | |||
| 199 | + | return domain.MergeRequest{}, fmt.Errorf("create merge request of %q: %w", name, err) | |||
| 200 | + | } | |||
| 201 | + | log := zerowrap.FromCtx(ctx) | |||
| 202 | + | log.Info().Str("repo", name.String()).Int64("number", mr.Number). | |||
| 203 | + | Str("source", source).Str("target", target). | |||
| 204 | + | Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("merge request opened") | |||
| 205 | + | mr, err = m.withHead(ctx, mr) | |||
| 206 | + | if err != nil { | |||
| 207 | + | return domain.MergeRequest{}, err | |||
| 208 | + | } | |||
| 209 | + | m.bus.Publish(ctx, domain.MergeRequestOpened{MergeRequest: mr, At: now, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt}) | |||
| 210 | + | return mr, nil | |||
| 211 | + | } | |||
| 212 | + | ||||
| 213 | + | // checkBranches validates the branches of a new request and returns them, with | |||
| 214 | + | // the default branch filled in for the target. | |||
| 215 | + | func (m *MergeRequests) checkBranches(ctx context.Context, name domain.RepoName, params CreateMergeRequestParams, v *domain.Violations) (source, target string) { | |||
| 216 | + | source, target = params.Source, params.Target | |||
| 217 | + | if source == "" { | |||
| 218 | + | v.Add("source_branch", "is required") | |||
| 219 | + | } else if _, err := domain.ParseBranchName(source); err != nil { | |||
| 220 | + | v.Add("source_branch", "is not a valid branch name") | |||
| 221 | + | source = "" | |||
| 222 | + | } | |||
| 223 | + | if target == "" { | |||
| 224 | + | def, exists, err := m.repos.DefaultBranch(ctx, name) | |||
| 225 | + | switch { | |||
| 226 | + | case err != nil: | |||
| 227 | + | v.Add("target_branch", "cannot be read: "+err.Error()) | |||
| 228 | + | case !exists: | |||
| 229 | + | v.Add("target_branch", fmt.Sprintf("is empty and the default branch %q does not exist", def)) | |||
| 230 | + | default: | |||
| 231 | + | target = def | |||
| 232 | + | } | |||
| 233 | + | } else if _, err := domain.ParseBranchName(target); err != nil { | |||
| 234 | + | v.Add("target_branch", "is not a valid branch name") | |||
| 235 | + | target = "" | |||
| 236 | + | } | |||
| 237 | + | if source != "" && source == target { | |||
| 238 | + | v.Add("target_branch", "must differ from source_branch") | |||
| 239 | + | return source, target | |||
| 240 | + | } | |||
| 241 | + | for _, b := range []struct{ field, name string }{{"source_branch", source}, {"target_branch", target}} { | |||
| 242 | + | if b.name == "" { | |||
| 243 | + | continue | |||
| 244 | + | } | |||
| 245 | + | _, err := m.git.BranchHead(ctx, name, b.name) | |||
| 246 | + | if errors.Is(err, domain.ErrNotFound) { | |||
| 247 | + | v.Add(b.field, fmt.Sprintf("branch %q does not exist", b.name)) | |||
| 248 | + | } else if err != nil { | |||
| 249 | + | v.Add(b.field, "cannot be read: "+err.Error()) | |||
| 250 | + | } | |||
| 251 | + | } | |||
| 252 | + | return source, target | |||
| 253 | + | } | |||
| 254 | + | ||||
| 255 | + | // ListMergeRequestsParams selects a page of merge requests. | |||
| 256 | + | type ListMergeRequestsParams struct { | |||
| 257 | + | // State restricts the result; empty means every state. | |||
| 258 | + | State domain.MRState | |||
| 259 | + | // PageSize is 0 for the default, and lowered to MaxPageSize. | |||
| 260 | + | PageSize int | |||
| 261 | + | // PageToken is the NextPageToken of the previous page, or empty. | |||
| 262 | + | PageToken string | |||
| 263 | + | } | |||
| 264 | + | ||||
| 265 | + | // MergeRequestPage is a page of merge requests, highest number first. | |||
| 266 | + | type MergeRequestPage struct { | |||
| 267 | + | MergeRequests []domain.MergeRequest | |||
| 268 | + | // NextPageToken is empty on the last page. | |||
| 269 | + | NextPageToken string | |||
| 270 | + | } | |||
| 271 | + | ||||
| 272 | + | // List returns a page of the merge requests of a repository, highest number | |||
| 273 | + | // first. The open ones carry the head of their source branch. The page token | |||
| 274 | + | // names the last number of the previous page, so requests created between two | |||
| 275 | + | // pages never shift the others. | |||
| 276 | + | func (m *MergeRequests) List(ctx context.Context, p domain.Principal, name domain.RepoName, params ListMergeRequestsParams) (MergeRequestPage, error) { | |||
| 277 | + | size, err := pageSize(params.PageSize) | |||
| 278 | + | if err != nil { | |||
| 279 | + | return MergeRequestPage{}, err | |||
| 280 | + | } | |||
| 281 | + | after, err := decodeMRToken(params.PageToken) | |||
| 282 | + | if err != nil { | |||
| 283 | + | return MergeRequestPage{}, err | |||
| 284 | + | } | |||
| 285 | + | if _, err := m.readable(ctx, p, name); err != nil { | |||
| 286 | + | return MergeRequestPage{}, err | |||
| 287 | + | } | |||
| 288 | + | all, err := m.store.List(ctx, name) | |||
| 289 | + | if err != nil { | |||
| 290 | + | return MergeRequestPage{}, fmt.Errorf("list merge requests of %q: %w", name, err) | |||
| 291 | + | } | |||
| 292 | + | var page MergeRequestPage | |||
| 293 | + | page.MergeRequests, page.NextPageToken = pageAfter(all, size, | |||
| 294 | + | func(mr domain.MergeRequest) bool { | |||
| 295 | + | return (params.State == "" || mr.State == params.State) && (after == 0 || mr.Number < after) | |||
| 296 | + | }, | |||
| 297 | + | encodeMRToken) | |||
| 298 | + | for i, mr := range page.MergeRequests { | |||
| 299 | + | if page.MergeRequests[i], err = m.withHead(ctx, mr); err != nil { | |||
| 300 | + | return MergeRequestPage{}, err | |||
| 301 | + | } | |||
| 302 | + | } | |||
| 303 | + | return page, nil | |||
| 304 | + | } | |||
| 305 | + | ||||
| 306 | + | func encodeMRToken(last domain.MergeRequest) string { | |||
| 307 | + | return base64.RawURLEncoding.EncodeToString([]byte(mrTokenPrefix + strconv.FormatInt(last.Number, 10))) | |||
| 308 | + | } | |||
| 309 | + | ||||
| 310 | + | // decodeMRToken returns 0 for the empty token. | |||
| 311 | + | func decodeMRToken(tok string) (int64, error) { | |||
| 312 | + | if tok == "" { | |||
| 313 | + | return 0, nil | |||
| 314 | + | } | |||
| 315 | + | raw, ok := decodeToken(tok, mrTokenPrefix) | |||
| 316 | + | if ok { | |||
| 317 | + | if n, err := strconv.ParseInt(raw, 10, 64); err == nil && n > 0 { | |||
| 318 | + | return n, nil | |||
| 319 | + | } | |||
| 320 | + | } | |||
| 321 | + | return 0, fmt.Errorf("%w: merge requests", domain.ErrInvalidPageToken) | |||
| 322 | + | } | |||
| 323 | + | ||||
| 324 | + | // get reads a merge request of a repository the principal can read. | |||
| 325 | + | func (m *MergeRequests) get(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.Repo, domain.MergeRequest, error) { | |||
| 326 | + | repo, err := m.readable(ctx, p, name) | |||
| 327 | + | if err != nil { | |||
| 328 | + | return domain.Repo{}, domain.MergeRequest{}, err | |||
| 329 | + | } | |||
| 330 | + | mr, err := m.store.Get(ctx, name, number) | |||
| 331 | + | if err != nil { | |||
| 332 | + | return domain.Repo{}, domain.MergeRequest{}, err | |||
| 333 | + | } | |||
| 334 | + | return repo, mr, nil | |||
| 335 | + | } | |||
| 336 | + | ||||
| 337 | + | // MergeRequestDetail is a merge request with its review data. | |||
| 338 | + | type MergeRequestDetail struct { | |||
| 339 | + | MergeRequest domain.MergeRequest | |||
| 340 | + | // Commits are the commits of the head that the target lacks, newest | |||
| 341 | + | // first, at most MaxMergeRequestCommits. | |||
| 342 | + | Commits []domain.Commit | |||
| 343 | + | CommitsTruncated bool | |||
| 344 | + | // DiffBaseID is the commit the diff compares from; zero when the source | |||
| 345 | + | // branch is gone, then the diff is empty. | |||
| 346 | + | DiffBaseID domain.ObjectID | |||
| 347 | + | Stats domain.DiffStats | |||
| 348 | + | Diff domain.Diff | |||
| 349 | + | // Comments are the comments, oldest first. | |||
| 350 | + | Comments []domain.Comment | |||
| 351 | + | // LastReadID is the head recorded by MarkRead; zero when none. | |||
| 352 | + | LastReadID domain.ObjectID | |||
| 353 | + | // LastReadLost tells that since-last-read was asked for, a head was | |||
| 354 | + | // recorded, but that commit is gone: the diff is against the target. | |||
| 355 | + | LastReadLost bool | |||
| 356 | + | } | |||
| 357 | + | ||||
| 358 | + | // Get returns a merge request with its commits, its diff, its comments and the | |||
| 359 | + | // read position. The diff compares the merge base of the target and the head | |||
| 360 | + | // with the head ("target...head"), within domain.DefaultDiffLimits. With | |||
| 361 | + | // sinceLastRead it compares the head recorded by MarkRead with the current | |||
| 362 | + | // head instead, which works after a force-push as long as the recorded commit | |||
| 363 | + | // still exists; when none was recorded the diff is the default one, and when | |||
| 364 | + | // the recorded commit is gone too, with LastReadLost set. A merged or closed | |||
| 365 | + | // request keeps the head and the base it had. | |||
| 366 | + | func (m *MergeRequests) Get(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, sinceLastRead bool) (MergeRequestDetail, error) { | |||
| 367 | + | _, mr, err := m.get(ctx, p, name, number) | |||
| 368 | + | if err != nil { | |||
| 369 | + | return MergeRequestDetail{}, err | |||
| 370 | + | } | |||
| 371 | + | if mr, err = m.withHead(ctx, mr); err != nil { | |||
| 372 | + | return MergeRequestDetail{}, err | |||
| 373 | + | } | |||
| 374 | + | d := MergeRequestDetail{MergeRequest: mr} | |||
| 375 | + | if d.Comments, err = m.store.ListComments(ctx, name, number); err != nil { | |||
| 376 | + | return MergeRequestDetail{}, fmt.Errorf("list comments of !%d in %q: %w", number, name, err) | |||
| 377 | + | } | |||
| 378 | + | if d.LastReadID, err = m.store.GetRead(ctx, name, number); err != nil { | |||
| 379 | + | return MergeRequestDetail{}, err | |||
| 380 | + | } | |||
| 381 | + | ||||
| 382 | + | exclude, base, err := m.rangeOf(ctx, mr) | |||
| 383 | + | if err != nil { | |||
| 384 | + | return MergeRequestDetail{}, err | |||
| 385 | + | } | |||
| 386 | + | head := mr.HeadID | |||
| 387 | + | if head.IsZero() { | |||
| 388 | + | return d, nil // the source branch is gone: no commits, no diff | |||
| 389 | + | } | |||
| 390 | + | if d.Commits, d.CommitsTruncated, err = m.git.ListRange(ctx, name, exclude, head, MaxMergeRequestCommits); err != nil { | |||
| 391 | + | return MergeRequestDetail{}, err | |||
| 392 | + | } | |||
| 393 | + | if sinceLastRead && !d.LastReadID.IsZero() { | |||
| 394 | + | switch ok, err := m.git.HasCommit(ctx, name, d.LastReadID); { | |||
| 395 | + | case err != nil: | |||
| 396 | + | return MergeRequestDetail{}, err | |||
| 397 | + | case ok: | |||
| 398 | + | base = d.LastReadID | |||
| 399 | + | default: | |||
| 400 | + | d.LastReadLost = true | |||
| 401 | + | } | |||
| 402 | + | } | |||
| 403 | + | if base.IsZero() { | |||
| 404 | + | return d, nil | |||
| 405 | + | } | |||
| 406 | + | d.DiffBaseID = base | |||
| 407 | + | if d.Stats, d.Diff, err = m.git.DiffCommits(ctx, name, base, head, domain.DefaultDiffLimits()); err != nil { | |||
| 408 | + | return MergeRequestDetail{}, err | |||
| 409 | + | } | |||
| 410 | + | return d, nil | |||
| 411 | + | } | |||
| 412 | + | ||||
| 413 | + | // rangeOf returns what the review of a request compares: the commit whose | |||
| 414 | + | // history the request's commits exclude, and the base of its diff. For an open | |||
| 415 | + | // request they are the tip of the target and the merge base of the target and | |||
| 416 | + | // the head; both are zero when the target is gone or shares no history with | |||
| 417 | + | // the head. A merged or closed request keeps the base it stored for both. | |||
| 418 | + | func (m *MergeRequests) rangeOf(ctx context.Context, mr domain.MergeRequest) (exclude, base domain.ObjectID, err error) { | |||
| 419 | + | if !mr.IsOpen() { | |||
| 420 | + | return mr.BaseID, mr.BaseID, nil | |||
| 421 | + | } | |||
| 422 | + | if mr.HeadID.IsZero() { | |||
| 423 | + | return domain.ObjectID{}, domain.ObjectID{}, nil | |||
| 424 | + | } | |||
| 425 | + | tip, err := m.branchOrZero(ctx, mr.Repo, mr.Target) | |||
| 426 | + | if err != nil || tip.IsZero() { | |||
| 427 | + | return domain.ObjectID{}, domain.ObjectID{}, err | |||
| 428 | + | } | |||
| 429 | + | base, err = m.git.MergeBase(ctx, mr.Repo, tip, mr.HeadID) | |||
| 430 | + | if errors.Is(err, domain.ErrNotFound) { | |||
| 431 | + | return tip, domain.ObjectID{}, nil | |||
| 432 | + | } | |||
| 433 | + | return tip, base, err | |||
| 434 | + | } | |||
| 435 | + | ||||
| 436 | + | // AddCommentParams describes the comment to add. | |||
| 437 | + | type AddCommentParams struct { | |||
| 438 | + | // Path, Line and Side designate a line of the diff; all empty for a | |||
| 439 | + | // general comment. | |||
| 440 | + | Path string | |||
| 441 | + | Line int | |||
| 442 | + | Side domain.DiffSide | |||
| 443 | + | Text string | |||
| 444 | + | } | |||
| 445 | + | ||||
| 446 | + | // AddComment adds a comment and publishes CommentAdded. The principal needs | |||
| 447 | + | // mr:write. The text must follow the rules of ADR 0017 and not be blank; a | |||
| 448 | + | // comment on a line must name a line of the current diff of the request (see | |||
| 449 | + | // domain.Diff.HasLine), else the call fails with domain.ErrInvalid. A request | |||
| 450 | + | // that is not open gives domain.ErrPreconditionFailed. | |||
| 451 | + | func (m *MergeRequests) AddComment(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params AddCommentParams) (domain.Comment, error) { | |||
| 452 | + | repo, err := m.writable(ctx, p, name, domain.ScopeMRWrite) | |||
| 453 | + | if err != nil { | |||
| 454 | + | return domain.Comment{}, err | |||
| 455 | + | } | |||
| 456 | + | mr, err := m.store.Get(ctx, name, number) | |||
| 457 | + | if err != nil { | |||
| 458 | + | return domain.Comment{}, err | |||
| 459 | + | } | |||
| 460 | + | var v domain.Violations | |||
| 461 | + | text, err := domain.ParsePlainText(params.Text) | |||
| 462 | + | switch { | |||
| 463 | + | case err != nil: | |||
| 464 | + | v.AddErr("text", err) | |||
| 465 | + | case text.IsBlank(): | |||
| 466 | + | v.Add("text", "is required") | |||
| 467 | + | } | |||
| 468 | + | general := params.Path == "" | |||
| 469 | + | switch { | |||
| 470 | + | case general && (params.Line != 0 || params.Side != domain.SideNone): | |||
| 471 | + | v.Add("path", "is required with line and side") | |||
| 472 | + | case !general && params.Line < 1: | |||
| 473 | + | v.Add("line", "must be 1 or more with a path") | |||
| 474 | + | case !general && params.Side != domain.SideOld && params.Side != domain.SideNew: | |||
| 475 | + | v.Add("side", "is required with a path") | |||
| 476 | + | } | |||
| 477 | + | if err := v.Err(); err != nil { | |||
| 478 | + | return domain.Comment{}, err | |||
| 479 | + | } | |||
| 480 | + | if !mr.IsOpen() { | |||
| 481 | + | return domain.Comment{}, fmt.Errorf("%w: !%d is %s", domain.ErrPreconditionFailed, number, mr.State) | |||
| 482 | + | } | |||
| 483 | + | ||||
| 484 | + | if mr, err = m.withHead(ctx, mr); err != nil { | |||
| 485 | + | return domain.Comment{}, err | |||
| 486 | + | } | |||
| 487 | + | if !general { | |||
| 488 | + | if err := m.checkLine(ctx, mr, params); err != nil { | |||
| 489 | + | return domain.Comment{}, err | |||
| 490 | + | } | |||
| 491 | + | } | |||
| 492 | + | ||||
| 493 | + | var c domain.Comment | |||
| 494 | + | for range maxWriteRetries { | |||
| 495 | + | c = domain.Comment{ | |||
| 496 | + | ID: mr.CommentSeq + 1, Author: authorOf(p), Path: params.Path, Line: params.Line, Side: params.Side, | |||
| 497 | + | CommitID: mr.HeadID, Text: text, CreatedAt: m.clock.Now().UTC(), | |||
| 498 | + | } | |||
| 499 | + | mr.CommentSeq, mr.UpdatedAt = c.ID, c.CreatedAt | |||
| 500 | + | err = m.store.SaveComment(ctx, mr, c) | |||
| 501 | + | if !errors.Is(err, domain.ErrConflict) { | |||
| 502 | + | break | |||
| 503 | + | } | |||
| 504 | + | // Another write got in between: read the request again. | |||
| 505 | + | fresh, gerr := m.store.Get(ctx, name, number) | |||
| 506 | + | if gerr != nil { | |||
| 507 | + | return domain.Comment{}, gerr | |||
| 508 | + | } | |||
| 509 | + | if !fresh.IsOpen() { | |||
| 510 | + | return domain.Comment{}, fmt.Errorf("%w: !%d is %s", domain.ErrPreconditionFailed, number, fresh.State) | |||
| 511 | + | } | |||
| 512 | + | fresh.HeadID = mr.HeadID | |||
| 513 | + | mr = fresh | |||
| 514 | + | } | |||
| 515 | + | if err != nil { | |||
| 516 | + | return domain.Comment{}, fmt.Errorf("save comment on !%d of %q: %w", number, name, err) | |||
| 517 | + | } | |||
| 518 | + | log := zerowrap.FromCtx(ctx) | |||
| 519 | + | log.Info().Str("repo", name.String()).Int64("number", number).Int64("comment", c.ID). | |||
| 520 | + | Bool("on_line", !general).Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("comment added") | |||
| 521 | + | m.bus.Publish(ctx, domain.CommentAdded{ | |||
| 522 | + | Repo: name, Number: number, Comment: c, At: c.CreatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt, | |||
| 523 | + | }) | |||
| 524 | + | return c, nil | |||
| 525 | + | } | |||
| 526 | + | ||||
| 527 | + | // checkLine verifies that a comment designates a line of the current diff of | |||
| 528 | + | // the request. | |||
| 529 | + | func (m *MergeRequests) checkLine(ctx context.Context, mr domain.MergeRequest, params AddCommentParams) error { | |||
| 530 | + | var v domain.Violations | |||
| 531 | + | _, base, err := m.rangeOf(ctx, mr) | |||
| 532 | + | if err != nil { | |||
| 533 | + | return err | |||
| 534 | + | } | |||
| 535 | + | if mr.HeadID.IsZero() || base.IsZero() { | |||
| 536 | + | v.Add("path", "the merge request has no diff to comment on") | |||
| 537 | + | return v.Err() | |||
| 538 | + | } | |||
| 539 | + | _, diff, err := m.git.DiffCommits(ctx, mr.Repo, base, mr.HeadID, domain.DefaultDiffLimits()) | |||
| 540 | + | if err != nil { | |||
| 541 | + | return err | |||
| 542 | + | } | |||
| 543 | + | if !diff.HasLine(params.Path, params.Side, params.Line) { | |||
| 544 | + | v.Add("path", fmt.Sprintf("%s:%d on the %s side is not a line of the diff", params.Path, params.Line, params.Side)) | |||
| 545 | + | } | |||
| 546 | + | return v.Err() | |||
| 547 | + | } | |||
| 548 | + | ||||
| 549 | + | // ResolveComment marks a comment resolved or unresolved. The principal needs | |||
| 550 | + | // mr:write. An unknown comment gives domain.ErrNotFound and a request that is | |||
| 551 | + | // not open domain.ErrPreconditionFailed. Setting the state the comment already | |||
| 552 | + | // has succeeds and writes nothing. | |||
| 553 | + | func (m *MergeRequests) ResolveComment(ctx context.Context, p domain.Principal, name domain.RepoName, number, commentID int64, resolved bool) (domain.Comment, error) { | |||
| 554 | + | if _, err := m.writable(ctx, p, name, domain.ScopeMRWrite); err != nil { | |||
| 555 | + | return domain.Comment{}, err | |||
| 556 | + | } | |||
| 557 | + | for range maxWriteRetries { | |||
| 558 | + | mr, err := m.store.Get(ctx, name, number) | |||
| 559 | + | if err != nil { | |||
| 560 | + | return domain.Comment{}, err | |||
| 561 | + | } | |||
| 562 | + | c, err := m.store.GetComment(ctx, name, number, commentID) | |||
| 563 | + | if err != nil { | |||
| 564 | + | return domain.Comment{}, err | |||
| 565 | + | } | |||
| 566 | + | if !mr.IsOpen() { | |||
| 567 | + | return domain.Comment{}, fmt.Errorf("%w: !%d is %s", domain.ErrPreconditionFailed, number, mr.State) | |||
| 568 | + | } | |||
| 569 | + | if c.Resolved == resolved { | |||
| 570 | + | return c, nil | |||
| 571 | + | } | |||
| 572 | + | c.Resolved = resolved | |||
| 573 | + | mr.UpdatedAt = m.clock.Now().UTC() | |||
| 574 | + | err = m.store.SaveComment(ctx, mr, c) | |||
| 575 | + | if errors.Is(err, domain.ErrConflict) { | |||
| 576 | + | continue | |||
| 577 | + | } | |||
| 578 | + | if err != nil { | |||
| 579 | + | return domain.Comment{}, fmt.Errorf("resolve comment %d of !%d in %q: %w", commentID, number, name, err) | |||
| 580 | + | } | |||
| 581 | + | return c, nil | |||
| 582 | + | } | |||
| 583 | + | return domain.Comment{}, fmt.Errorf("resolve comment %d of !%d in %q: %w", commentID, number, name, domain.ErrConflict) | |||
| 584 | + | } | |||
| 585 | + | ||||
| 586 | + | // Close closes an open merge request without merging it and publishes | |||
| 587 | + | // MergeRequestClosed. The principal needs mr:write. A request that is not open | |||
| 588 | + | // gives domain.ErrPreconditionFailed. The request keeps the head it had and | |||
| 589 | + | // its merge base, so that its diff stays readable. | |||
| 590 | + | func (m *MergeRequests) Close(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.MergeRequest, error) { | |||
| 591 | + | repo, err := m.writable(ctx, p, name, domain.ScopeMRWrite) | |||
| 592 | + | if err != nil { | |||
| 593 | + | return domain.MergeRequest{}, err | |||
| 594 | + | } | |||
| 595 | + | unlock, err := m.lockRepo(ctx, name) // a merge and a close of one request exclude each other | |||
| 596 | + | if err != nil { | |||
| 597 | + | return domain.MergeRequest{}, err | |||
| 598 | + | } | |||
| 599 | + | defer unlock() | |||
| 600 | + | for range maxWriteRetries { | |||
| 601 | + | mr, err := m.store.Get(ctx, name, number) | |||
| 602 | + | if err != nil { | |||
| 603 | + | return domain.MergeRequest{}, err | |||
| 604 | + | } | |||
| 605 | + | if !mr.IsOpen() { | |||
| 606 | + | return domain.MergeRequest{}, fmt.Errorf("%w: !%d is %s", domain.ErrPreconditionFailed, number, mr.State) | |||
| 607 | + | } | |||
| 608 | + | if mr, err = m.withHead(ctx, mr); err != nil { | |||
| 609 | + | return domain.MergeRequest{}, err | |||
| 610 | + | } | |||
| 611 | + | _, base, err := m.rangeOf(ctx, mr) | |||
| 612 | + | if err != nil { | |||
| 613 | + | return domain.MergeRequest{}, err | |||
| 614 | + | } | |||
| 615 | + | mr.State, mr.BaseID, mr.UpdatedAt = domain.MRStateClosed, base, m.clock.Now().UTC() | |||
| 616 | + | err = m.store.Update(ctx, mr) | |||
| 617 | + | if errors.Is(err, domain.ErrConflict) { | |||
| 618 | + | continue | |||
| 619 | + | } | |||
| 620 | + | if err != nil { | |||
| 621 | + | return domain.MergeRequest{}, fmt.Errorf("close !%d of %q: %w", number, name, err) | |||
| 622 | + | } | |||
| 623 | + | log := zerowrap.FromCtx(ctx) | |||
| 624 | + | log.Info().Str("repo", name.String()).Int64("number", number). | |||
| 625 | + | Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("merge request closed") | |||
| 626 | + | m.bus.Publish(ctx, domain.MergeRequestClosed{MergeRequest: mr, At: mr.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt}) | |||
| 627 | + | return mr, nil | |||
| 628 | + | } | |||
| 629 | + | return domain.MergeRequest{}, fmt.Errorf("close !%d of %q: %w", number, name, domain.ErrConflict) | |||
| 630 | + | } | |||
| 631 | + | ||||
| 632 | + | // MarkRead records the head the reviewer has read, for the since-last-read diff | |||
| 633 | + | // of Get. The principal needs mr:write. A zero head means the current head of | |||
| 634 | + | // the source branch (the stored one for a merged or closed request); one that | |||
| 635 | + | // is not a commit of the repository gives domain.ErrInvalid. It returns the | |||
| 636 | + | // head recorded. | |||
| 637 | + | func (m *MergeRequests) MarkRead(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, head domain.ObjectID) (domain.ObjectID, error) { | |||
| 638 | + | if _, err := m.writable(ctx, p, name, domain.ScopeMRWrite); err != nil { | |||
| 639 | + | return domain.ObjectID{}, err | |||
| 640 | + | } | |||
| 641 | + | mr, err := m.store.Get(ctx, name, number) | |||
| 642 | + | if err != nil { | |||
| 643 | + | return domain.ObjectID{}, err | |||
| 644 | + | } | |||
| 645 | + | if head.IsZero() { | |||
| 646 | + | if mr, err = m.withHead(ctx, mr); err != nil { | |||
| 647 | + | return domain.ObjectID{}, err | |||
| 648 | + | } | |||
| 649 | + | if head = mr.HeadID; head.IsZero() { | |||
| 650 | + | return domain.ObjectID{}, fmt.Errorf("%w: the source branch %q no longer exists, give a head", domain.ErrPreconditionFailed, mr.Source) | |||
| 651 | + | } | |||
| 652 | + | } else { | |||
| 653 | + | var v domain.Violations | |||
| 654 | + | switch ok, err := m.git.HasCommit(ctx, name, head); { | |||
| 655 | + | case err != nil: | |||
| 656 | + | return domain.ObjectID{}, err | |||
| 657 | + | case !ok: | |||
| 658 | + | v.Add("head_id", fmt.Sprintf("%s is not a commit of the repository", head)) | |||
| 659 | + | return domain.ObjectID{}, v.Err() | |||
| 660 | + | } | |||
| 661 | + | } | |||
| 662 | + | if err := m.store.SetRead(ctx, name, number, head); err != nil { | |||
| 663 | + | return domain.ObjectID{}, err | |||
| 664 | + | } | |||
| 665 | + | return head, nil | |||
| 666 | + | } | |||
| 667 | + | ||||
| 668 | + | // MergeParams describes the merge to perform. | |||
| 669 | + | type MergeParams struct { | |||
| 670 | + | // Mode defaults to fast-forward. | |||
| 671 | + | Mode domain.MergeMode | |||
| 672 | + | // HeadID is the commit the caller reviewed; the source branch must still | |||
| 673 | + | // point to it. | |||
| 674 | + | HeadID domain.ObjectID | |||
| 675 | + | } | |||
| 676 | + | ||||
| 677 | + | // Merge merges an open merge request, publishes MergeRequestMerged then a | |||
| 678 | + | // PushEvent for the target branch, and returns the merged request. The | |||
| 679 | + | // principal needs mr:merge. Merges of one repository run one at a time. | |||
| 680 | + | // | |||
| 681 | + | // It fails with domain.ErrInvalid for a zero head, and with | |||
| 682 | + | // domain.ErrPreconditionFailed when the request is not open, the mode is not | |||
| 683 | + | // allowed by the settings of the repository, the source branch no longer | |||
| 684 | + | // points to the head ("branch moved, read again") or is gone, the target is | |||
| 685 | + | // gone or is not an ancestor of the head (the author rebases and pushes | |||
| 686 | + | // again), or the branch brings nothing. Checks come with CI (CI-1): there are | |||
| 687 | + | // none yet, so the setting that lets failing checks block the merge has | |||
| 688 | + | // nothing to act on. | |||
| 689 | + | // | |||
| 690 | + | // A fast-forward moves the target to the head, with the old value as a | |||
| 691 | + | // precondition. A squash writes one commit with the tree of the head, the | |||
| 692 | + | // target as parent and the author and message of domain.NewSquashCommit, | |||
| 693 | + | // signed by the instance key, and moves the target to it the same way. | |||
| 694 | + | func (m *MergeRequests) Merge(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params MergeParams) (domain.MergeRequest, error) { | |||
| 695 | + | repo, err := m.writable(ctx, p, name, domain.ScopeMRMerge) | |||
| 696 | + | if err != nil { | |||
| 697 | + | return domain.MergeRequest{}, err | |||
| 698 | + | } | |||
| 699 | + | mode := params.Mode | |||
| 700 | + | if mode == "" { | |||
| 701 | + | mode = domain.MergeModeFastForward | |||
| 702 | + | } | |||
| 703 | + | var v domain.Violations | |||
| 704 | + | if _, err := domain.ParseMergeMode(mode.String()); err != nil { | |||
| 705 | + | v.Add("mode", "is not a merge mode") | |||
| 706 | + | } | |||
| 707 | + | if params.HeadID.IsZero() { | |||
| 708 | + | v.Add("head_id", "is required") | |||
| 709 | + | } | |||
| 710 | + | if err := v.Err(); err != nil { | |||
| 711 | + | return domain.MergeRequest{}, err | |||
| 712 | + | } | |||
| 713 | + | ||||
| 714 | + | unlock, err := m.lockRepo(ctx, name) | |||
| 715 | + | if err != nil { | |||
| 716 | + | return domain.MergeRequest{}, err | |||
| 717 | + | } | |||
| 718 | + | defer unlock() | |||
| 719 | + | ||||
| 720 | + | mr, err := m.store.Get(ctx, name, number) | |||
| 721 | + | if err != nil { | |||
| 722 | + | return domain.MergeRequest{}, err | |||
| 723 | + | } | |||
| 724 | + | if !mr.IsOpen() { | |||
| 725 | + | return domain.MergeRequest{}, fmt.Errorf("%w: !%d is %s", domain.ErrPreconditionFailed, number, mr.State) | |||
| 726 | + | } | |||
| 727 | + | // The settings are read again under the lock: a change applies at once. | |||
| 728 | + | if current, err := m.meta.Get(ctx, name); err == nil { | |||
| 729 | + | repo = current | |||
| 730 | + | } | |||
| 731 | + | settings := repo.Settings | |||
| 732 | + | if settings.IsZero() { | |||
| 733 | + | settings = domain.DefaultRepoSettings() | |||
| 734 | + | } | |||
| 735 | + | if !settings.Allows(mode) { | |||
| 736 | + | return domain.MergeRequest{}, fmt.Errorf("%w: the %s merge mode is not allowed in %s", domain.ErrPreconditionFailed, mode, name) | |||
| 737 | + | } | |||
| 738 | + | ||||
| 739 | + | head, err := m.branchOrZero(ctx, name, mr.Source) | |||
| 740 | + | switch { | |||
| 741 | + | case err != nil: | |||
| 742 | + | return domain.MergeRequest{}, err | |||
| 743 | + | case head.IsZero(): | |||
| 744 | + | return domain.MergeRequest{}, fmt.Errorf("%w: the source branch %q no longer exists", domain.ErrPreconditionFailed, mr.Source) | |||
| 745 | + | case head != params.HeadID: | |||
| 746 | + | return domain.MergeRequest{}, fmt.Errorf("%w: branch moved, read again: %q is at %s, not %s", domain.ErrPreconditionFailed, mr.Source, head, params.HeadID) | |||
| 747 | + | } | |||
| 748 | + | tip, err := m.branchOrZero(ctx, name, mr.Target) | |||
| 749 | + | switch { | |||
| 750 | + | case err != nil: | |||
| 751 | + | return domain.MergeRequest{}, err | |||
| 752 | + | case tip.IsZero(): | |||
| 753 | + | return domain.MergeRequest{}, fmt.Errorf("%w: the target branch %q no longer exists", domain.ErrPreconditionFailed, mr.Target) | |||
| 754 | + | case tip == head: | |||
| 755 | + | return domain.MergeRequest{}, fmt.Errorf("%w: nothing to merge, %q already is at %s", domain.ErrPreconditionFailed, mr.Target, head) | |||
| 756 | + | } | |||
| 757 | + | switch ok, err := m.git.IsAncestor(ctx, name, tip, head); { | |||
| 758 | + | case err != nil: | |||
| 759 | + | return domain.MergeRequest{}, err | |||
| 760 | + | case !ok: | |||
| 761 | + | return domain.MergeRequest{}, 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) | |||
| 762 | + | } | |||
| 763 | + | ||||
| 764 | + | merged, err := m.moveTarget(ctx, mr, mode, tip, head) | |||
| 765 | + | if err != nil { | |||
| 766 | + | return domain.MergeRequest{}, err | |||
| 767 | + | } | |||
| 768 | + | mr, err = m.markMerged(ctx, name, number, mode, tip, head, merged) | |||
| 769 | + | if err != nil { | |||
| 770 | + | return domain.MergeRequest{}, err | |||
| 771 | + | } | |||
| 772 | + | log := zerowrap.FromCtx(ctx) | |||
| 773 | + | log.Info().Str("repo", name.String()).Int64("number", number).Str("mode", mode.String()). | |||
| 774 | + | Str("target", mr.Target).Str("commit", merged.String()). | |||
| 775 | + | Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("merge request merged") | |||
| 776 | + | at := mr.UpdatedAt | |||
| 777 | + | m.bus.Publish(ctx, domain.MergeRequestMerged{MergeRequest: mr, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt}) | |||
| 778 | + | m.bus.Publish(ctx, domain.PushEvent{ | |||
| 779 | + | Repo: name, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt, | |||
| 780 | + | Updates: []domain.RefUpdate{{Old: tip.String(), New: merged.String(), Ref: "refs/heads/" + mr.Target}}, | |||
| 781 | + | }) | |||
| 782 | + | return mr, nil | |||
| 783 | + | } | |||
| 784 | + | ||||
| 785 | + | // moveTarget moves the target branch from tip to the head (fast-forward) or to | |||
| 786 | + | // a new signed squash commit, and returns the new tip. | |||
| 787 | + | func (m *MergeRequests) moveTarget(ctx context.Context, mr domain.MergeRequest, mode domain.MergeMode, tip, head domain.ObjectID) (domain.ObjectID, error) { | |||
| 788 | + | newTip := head | |||
| 789 | + | if mode == domain.MergeModeSquash { | |||
| 790 | + | commits, more, err := m.git.ListRange(ctx, mr.Repo, tip, head, domain.MaxSquashCommits) | |||
| 791 | + | if err != nil { | |||
| 792 | + | return domain.ObjectID{}, err | |||
| 793 | + | } | |||
| 794 | + | if more { | |||
| 795 | + | return domain.ObjectID{}, fmt.Errorf("%w: the branch has more than %d commits to squash", domain.ErrPreconditionFailed, domain.MaxSquashCommits) | |||
| 796 | + | } | |||
| 797 | + | tree, err := m.git.CommitTree(ctx, mr.Repo, head) | |||
| 798 | + | if err != nil { | |||
| 799 | + | return domain.ObjectID{}, err | |||
| 800 | + | } | |||
| 801 | + | commit := domain.NewSquashCommit(mr, tree, tip, commits, m.committer, m.clock.Now()) | |||
| 802 | + | armored, err := m.signer.Sign(commit.Payload()) | |||
| 803 | + | if err != nil { | |||
| 804 | + | return domain.ObjectID{}, fmt.Errorf("sign the squash commit of !%d in %q: %w", mr.Number, mr.Repo, err) | |||
| 805 | + | } | |||
| 806 | + | if newTip, err = m.git.WriteCommit(ctx, mr.Repo, commit.Signed(armored)); err != nil { | |||
| 807 | + | return domain.ObjectID{}, err | |||
| 808 | + | } | |||
| 809 | + | } | |||
| 810 | + | err := m.git.UpdateBranch(ctx, mr.Repo, mr.Target, tip, newTip) | |||
| 811 | + | if errors.Is(err, domain.ErrConflict) { | |||
| 812 | + | return domain.ObjectID{}, fmt.Errorf("%w: the target branch %q moved during the merge, try again", domain.ErrPreconditionFailed, mr.Target) | |||
| 813 | + | } | |||
| 814 | + | if err != nil { | |||
| 815 | + | return domain.ObjectID{}, err | |||
| 816 | + | } | |||
| 817 | + | return newTip, nil | |||
| 818 | + | } | |||
| 819 | + | ||||
| 820 | + | // markMerged records the merge. The target already moved, so a write that | |||
| 821 | + | // loses a race (a comment added meanwhile) is repeated on the fresh request. | |||
| 822 | + | func (m *MergeRequests) markMerged(ctx context.Context, name domain.RepoName, number int64, mode domain.MergeMode, base, head, merged domain.ObjectID) (domain.MergeRequest, error) { | |||
| 823 | + | // The merge must be recorded even when the caller gives up. | |||
| 824 | + | ctx = context.WithoutCancel(ctx) | |||
| 825 | + | for range maxWriteRetries { | |||
| 826 | + | mr, err := m.store.Get(ctx, name, number) | |||
| 827 | + | if err != nil { | |||
| 828 | + | return domain.MergeRequest{}, fmt.Errorf("record the merge of !%d in %q: %w", number, name, err) | |||
| 829 | + | } | |||
| 830 | + | mr.State, mr.HeadID, mr.BaseID, mr.MergedCommitID, mr.MergeMode = domain.MRStateMerged, head, base, merged, mode | |||
| 831 | + | mr.UpdatedAt = m.clock.Now().UTC() | |||
| 832 | + | err = m.store.Update(ctx, mr) | |||
| 833 | + | if errors.Is(err, domain.ErrConflict) { | |||
| 834 | + | continue | |||
| 835 | + | } | |||
| 836 | + | if err != nil { | |||
| 837 | + | return domain.MergeRequest{}, fmt.Errorf("record the merge of !%d in %q: %w", number, name, err) | |||
| 838 | + | } | |||
| 839 | + | return mr, nil | |||
| 840 | + | } | |||
| 841 | + | log := zerowrap.FromCtx(ctx) | |||
| 842 | + | log.Error().Str("repo", name.String()).Int64("number", number).Str("commit", merged.String()). | |||
| 843 | + | Msg("the target branch moved but the merge request could not be marked merged") | |||
| 844 | + | return domain.MergeRequest{}, fmt.Errorf("record the merge of !%d in %q: %w", number, name, domain.ErrConflict) | |||
| 845 | + | } | |||
A internal/app/mergerequests_integration_test.go +1178 -0
| @@ -0,0 +1,1178 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package app_test | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "bytes" | |
| 7 | + | "context" | |
| 8 | + | "crypto/ed25519" | |
| 9 | + | "fmt" | |
| 10 | + | "os" | |
| 11 | + | "os/exec" | |
| 12 | + | "path/filepath" | |
| 13 | + | "strings" | |
| 14 | + | "sync" | |
| 15 | + | "testing" | |
| 16 | + | "time" | |
| 17 | + | ||
| 18 | + | "github.com/stretchr/testify/assert" | |
| 19 | + | "github.com/stretchr/testify/require" | |
| 20 | + | ||
| 21 | + | "bnema.dev/ermite/internal/adapters/eventbus" | |
| 22 | + | "bnema.dev/ermite/internal/adapters/gitcli" | |
| 23 | + | "bnema.dev/ermite/internal/adapters/kvstore" | |
| 24 | + | "bnema.dev/ermite/internal/adapters/sshsig" | |
| 25 | + | "bnema.dev/ermite/internal/app" | |
| 26 | + | "bnema.dev/ermite/internal/domain" | |
| 27 | + | ) | |
| 28 | + | ||
| 29 | + | // mrRig runs the merge request use cases over the real adapters: a data | |
| 30 | + | // directory with ermite-kv and bare repositories, and a work tree to push | |
| 31 | + | // branches from. | |
| 32 | + | type mrRig struct { | |
| 33 | + | t *testing.T | |
| 34 | + | ctx context.Context | |
| 35 | + | data string | |
| 36 | + | repos *app.Repos | |
| 37 | + | mrs *app.MergeRequests | |
| 38 | + | key *app.SigningKey | |
| 39 | + | store *kvstore.Store | |
| 40 | + | git *gitcli.Git | |
| 41 | + | bus *eventbus.Bus | |
| 42 | + | events <-chan domain.Event | |
| 43 | + | work string | |
| 44 | + | repo domain.RepoName | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | var ( | |
| 48 | + | root = domain.Root() | |
| 49 | + | author = domain.Identity{Name: "Ada Lovelace", Email: "ada@example.org"} | |
| 50 | + | ) | |
| 51 | + | ||
| 52 | + | func newMRRig(t *testing.T) *mrRig { | |
| 53 | + | t.Helper() | |
| 54 | + | ctx := context.Background() | |
| 55 | + | bin, err := exec.LookPath("git") | |
| 56 | + | if err != nil { | |
| 57 | + | unusable(t, "git not found") | |
| 58 | + | } | |
| 59 | + | data := t.TempDir() | |
| 60 | + | for _, d := range []string{"repos/.staging", "trash", "run/git-home", "kv"} { | |
| 61 | + | require.NoError(t, os.MkdirAll(filepath.Join(data, d), 0o700)) | |
| 62 | + | } | |
| 63 | + | git := gitcli.New(gitcli.Config{Bin: bin, DataDir: data, Path: os.Getenv("PATH")}) | |
| 64 | + | if _, err := git.CheckVersion(ctx); err != nil { | |
| 65 | + | unusable(t, "git is not usable by ermite: %v", err) | |
| 66 | + | } | |
| 67 | + | t.Cleanup(func() { _ = git.Close(context.Background()) }) | |
| 68 | + | store, err := kvstore.Open(ctx, filepath.Join(data, "kv")) | |
| 69 | + | require.NoError(t, err) | |
| 70 | + | t.Cleanup(func() { require.NoError(t, store.Close(context.Background())) }) | |
| 71 | + | ||
| 72 | + | signer, err := sshsig.New(bytes.Repeat([]byte{9}, ed25519.SeedSize)) | |
| 73 | + | require.NoError(t, err) | |
| 74 | + | bus := eventbus.NewWithBuffer(1024) | |
| 75 | + | committer := domain.Identity{Name: "ermite", Email: "ermite@git.example.org"} | |
| 76 | + | r := &mrRig{ | |
| 77 | + | t: t, ctx: ctx, data: data, store: store, git: git, bus: bus, events: bus.Subscribe(ctx), | |
| 78 | + | repos: app.NewRepos(store, git, bus, fixedClock{}), | |
| 79 | + | key: app.NewSigningKey(signer, committer), | |
| 80 | + | work: t.TempDir(), | |
| 81 | + | repo: name(t, "proj"), | |
| 82 | + | } | |
| 83 | + | r.mrs = app.NewMergeRequests(app.MergeRequestsConfig{ | |
| 84 | + | Meta: store, Store: store.MergeRequests(), Repos: git, Git: git, Signer: signer, | |
| 85 | + | Committer: committer, Bus: bus, Clock: fixedClock{}, | |
| 86 | + | }) | |
| 87 | + | _, err = r.repos.Create(ctx, root, app.CreateParams{Name: r.repo, Visibility: domain.VisibilityPrivate}) | |
| 88 | + | require.NoError(t, err) | |
| 89 | + | r.sh("init", "-q", "--initial-branch=main") | |
| 90 | + | return r | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | func (r *mrRig) bare() string { return filepath.Join(r.data, "repos", r.repo.String()+".git") } | |
| 94 | + | ||
| 95 | + | func (r *mrRig) env(who domain.Identity, extra ...string) []string { | |
| 96 | + | return append([]string{ | |
| 97 | + | "PATH=" + os.Getenv("PATH"), "HOME=" + r.work, "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_NOSYSTEM=1", | |
| 98 | + | "GIT_AUTHOR_NAME=" + who.Name, "GIT_AUTHOR_EMAIL=" + who.Email, | |
| 99 | + | "GIT_COMMITTER_NAME=" + who.Name, "GIT_COMMITTER_EMAIL=" + who.Email, | |
| 100 | + | "LC_ALL=C", | |
| 101 | + | }, extra...) | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | func (r *mrRig) run(dir string, env []string, args ...string) string { | |
| 105 | + | r.t.Helper() | |
| 106 | + | cmd := exec.Command("git", args...) | |
| 107 | + | cmd.Dir, cmd.Env = dir, env | |
| 108 | + | out, err := cmd.CombinedOutput() | |
| 109 | + | require.NoError(r.t, err, "git %s\n%s", strings.Join(args, " "), out) | |
| 110 | + | return strings.TrimSpace(string(out)) | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | // sh runs git in the work tree as the default author. | |
| 114 | + | func (r *mrRig) sh(args ...string) string { return r.run(r.work, r.env(author), args...) } | |
| 115 | + | ||
| 116 | + | // commit writes a file and commits it as who; it returns the commit id. | |
| 117 | + | func (r *mrRig) commit(who domain.Identity, path, content, msg string) domain.ObjectID { | |
| 118 | + | r.t.Helper() | |
| 119 | + | full := filepath.Join(r.work, path) | |
| 120 | + | require.NoError(r.t, os.MkdirAll(filepath.Dir(full), 0o755)) | |
| 121 | + | require.NoError(r.t, os.WriteFile(full, []byte(content), 0o644)) | |
| 122 | + | r.run(r.work, r.env(who), "add", path) | |
| 123 | + | r.run(r.work, r.env(who), "commit", "-q", "-m", msg) | |
| 124 | + | return r.head() | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | func (r *mrRig) head() domain.ObjectID { | |
| 128 | + | r.t.Helper() | |
| 129 | + | return oidFrom(r.t, r.sh("rev-parse", "HEAD")) | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | func oidFrom(t *testing.T, s string) domain.ObjectID { | |
| 133 | + | t.Helper() | |
| 134 | + | id, err := domain.ParseObjectID(s) | |
| 135 | + | require.NoError(t, err) | |
| 136 | + | return id | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | // push pushes the branch of the work tree to the repository, forcing it. | |
| 140 | + | func (r *mrRig) push(branch string) { | |
| 141 | + | r.t.Helper() | |
| 142 | + | r.sh("push", "-q", "--force", r.bare(), branch+":refs/heads/"+branch) | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | // branchHead reads a branch of the bare repository. | |
| 146 | + | func (r *mrRig) branchHead(branch string) domain.ObjectID { | |
| 147 | + | r.t.Helper() | |
| 148 | + | return oidFrom(r.t, r.run(r.bare(), r.env(author), "rev-parse", "refs/heads/"+branch)) | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | // seed pushes main with two files and returns its id. | |
| 152 | + | func (r *mrRig) seed() domain.ObjectID { | |
| 153 | + | r.t.Helper() | |
| 154 | + | r.commit(author, "a.txt", "one\ntwo\nthree\n", "add a") | |
| 155 | + | id := r.commit(author, "b.txt", "bee\n", "add b") | |
| 156 | + | r.push("main") | |
| 157 | + | return id | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | // feature creates the branch feat from main with two commits by two authors | |
| 161 | + | // and pushes it; it returns its head. | |
| 162 | + | func (r *mrRig) feature(branch string) domain.ObjectID { | |
| 163 | + | r.t.Helper() | |
| 164 | + | r.sh("checkout", "-q", "-B", branch, "main") | |
| 165 | + | r.commit(author, "a.txt", "one\n2\nthree\n", "change a") | |
| 166 | + | bob := domain.Identity{Name: "Bob", Email: "bob@example.org"} | |
| 167 | + | id := r.commit(bob, "c.txt", "see\n", "add c") | |
| 168 | + | r.push(branch) | |
| 169 | + | return id | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | func (r *mrRig) open(branch string) domain.MergeRequest { | |
| 173 | + | r.t.Helper() | |
| 174 | + | mr, err := r.mrs.Create(r.ctx, root, r.repo, app.CreateMergeRequestParams{Title: "Add " + branch, Source: branch}) | |
| 175 | + | require.NoError(r.t, err) | |
| 176 | + | return mr | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | // drain returns the events published so far. | |
| 180 | + | func (r *mrRig) drain() []domain.Event { | |
| 181 | + | var out []domain.Event | |
| 182 | + | for { | |
| 183 | + | select { | |
| 184 | + | case ev := <-r.events: | |
| 185 | + | out = append(out, ev) | |
| 186 | + | case <-time.After(20 * time.Millisecond): | |
| 187 | + | return out | |
| 188 | + | } | |
| 189 | + | } | |
| 190 | + | } | |
| 191 | + | ||
| 192 | + | func writer(scopes ...domain.Scope) domain.Principal { | |
| 193 | + | return domain.TokenPrincipal(domain.Token{ID: "t", Label: "agent-1", Scopes: scopes}) | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | var ( | |
| 197 | + | reader = writer(domain.ScopeRepoRead) | |
| 198 | + | mrW = writer(domain.ScopeRepoRead, domain.ScopeMRWrite) | |
| 199 | + | mrM = writer(domain.ScopeRepoRead, domain.ScopeMRWrite, domain.ScopeMRMerge) | |
| 200 | + | ) | |
| 201 | + | ||
| 202 | + | func fieldsOf(t *testing.T, err error) map[string]string { | |
| 203 | + | t.Helper() | |
| 204 | + | var fe *domain.InvalidFieldsError | |
| 205 | + | require.ErrorAs(t, err, &fe, "want a rejection naming the fields") | |
| 206 | + | out := map[string]string{} | |
| 207 | + | for _, f := range fe.Fields { | |
| 208 | + | out[f.Field] = f.Reason | |
| 209 | + | } | |
| 210 | + | return out | |
| 211 | + | } | |
| 212 | + | ||
| 213 | + | // AC3: the input rules of ADR 0017. | |
| 214 | + | func TestCreateMergeRequestInputRules(t *testing.T) { | |
| 215 | + | r := newMRRig(t) | |
| 216 | + | r.seed() | |
| 217 | + | r.feature("feat") | |
| 218 | + | r.sh("checkout", "-q", "-B", "other", "main") | |
| 219 | + | r.commit(author, "o.txt", "o\n", "other") | |
| 220 | + | r.push("other") | |
| 221 | + | ||
| 222 | + | tests := []struct { | |
| 223 | + | name string | |
| 224 | + | params app.CreateMergeRequestParams | |
| 225 | + | field string // "" for valid | |
| 226 | + | }{ | |
| 227 | + | {"valid, default target", app.CreateMergeRequestParams{Title: "t", Source: "feat"}, ""}, | |
| 228 | + | {"valid, explicit target and description", app.CreateMergeRequestParams{Title: "t", Description: "a\nb", Source: "other", Target: "main"}, ""}, | |
| 229 | + | {"empty title", app.CreateMergeRequestParams{Title: "", Source: "feat", Target: "other"}, "title"}, | |
| 230 | + | {"two-line title", app.CreateMergeRequestParams{Title: "a\nb", Source: "feat", Target: "other"}, "title"}, | |
| 231 | + | {"title too long", app.CreateMergeRequestParams{Title: strings.Repeat("x", 121), Source: "feat", Target: "other"}, "title"}, | |
| 232 | + | {"title with a bidi override", app.CreateMergeRequestParams{Title: "a\u202eb", Source: "feat", Target: "other"}, "title"}, | |
| 233 | + | {"title with an escape", app.CreateMergeRequestParams{Title: "\x1b[31m", Source: "feat", Target: "other"}, "title"}, | |
| 234 | + | {"description with a control character", app.CreateMergeRequestParams{Title: "t", Description: "a\x07", Source: "feat", Target: "other"}, "description"}, | |
| 235 | + | {"description with a bidi isolate", app.CreateMergeRequestParams{Title: "t", Description: "a\u2066b", Source: "feat", Target: "other"}, "description"}, | |
| 236 | + | {"description too long", app.CreateMergeRequestParams{Title: "t", Description: strings.Repeat("x", 8001), Source: "feat", Target: "other"}, "description"}, | |
| 237 | + | {"description not utf-8", app.CreateMergeRequestParams{Title: "t", Description: "\xff", Source: "feat", Target: "other"}, "description"}, | |
| 238 | + | {"source missing", app.CreateMergeRequestParams{Title: "t", Source: ""}, "source_branch"}, | |
| 239 | + | {"source does not exist", app.CreateMergeRequestParams{Title: "t", Source: "nope", Target: "main"}, "source_branch"}, | |
| 240 | + | {"source is not a branch name", app.CreateMergeRequestParams{Title: "t", Source: "a..b", Target: "main"}, "source_branch"}, | |
| 241 | + | {"source is a full ref", app.CreateMergeRequestParams{Title: "t", Source: "refs/heads/feat", Target: "main"}, "source_branch"}, | |
| 242 | + | {"target does not exist", app.CreateMergeRequestParams{Title: "t", Source: "feat", Target: "nope"}, "target_branch"}, | |
| 243 | + | {"same branch", app.CreateMergeRequestParams{Title: "t", Source: "feat", Target: "feat"}, "target_branch"}, | |
| 244 | + | } | |
| 245 | + | for _, tc := range tests { | |
| 246 | + | t.Run(tc.name, func(t *testing.T) { | |
| 247 | + | mr, err := r.mrs.Create(r.ctx, root, r.repo, tc.params) | |
| 248 | + | if tc.field == "" { | |
| 249 | + | require.NoError(t, err) | |
| 250 | + | assert.Equal(t, domain.MRStateOpen, mr.State) | |
| 251 | + | assert.Equal(t, "root", mr.Author) | |
| 252 | + | assert.Equal(t, "main", mr.Target) | |
| 253 | + | assert.NotZero(t, mr.HeadID) | |
| 254 | + | return | |
| 255 | + | } | |
| 256 | + | require.ErrorIs(t, err, domain.ErrInvalid) | |
| 257 | + | assert.Contains(t, fieldsOf(t, err), tc.field) | |
| 258 | + | }) | |
| 259 | + | } | |
| 260 | + | ||
| 261 | + | t.Run("a request is rejected whole with every field named", func(t *testing.T) { | |
| 262 | + | _, err := r.mrs.Create(r.ctx, root, r.repo, app.CreateMergeRequestParams{Title: "a\nb", Description: "\x07", Source: "nope", Target: "feat"}) | |
| 263 | + | got := fieldsOf(t, err) | |
| 264 | + | assert.Contains(t, got, "title") | |
| 265 | + | assert.Contains(t, got, "description") | |
| 266 | + | assert.Contains(t, got, "source_branch") | |
| 267 | + | }) | |
| 268 | + | } | |
| 269 | + | ||
| 270 | + | func TestCreateMergeRequestNumbersAndDuplicates(t *testing.T) { | |
| 271 | + | r := newMRRig(t) | |
| 272 | + | r.seed() | |
| 273 | + | r.feature("one") | |
| 274 | + | r.feature("two") | |
| 275 | + | ||
| 276 | + | first := r.open("one") | |
| 277 | + | assert.Equal(t, int64(1), first.Number) | |
| 278 | + | second := r.open("two") | |
| 279 | + | assert.Equal(t, int64(2), second.Number) | |
| 280 | + | ||
| 281 | + | _, err := r.mrs.Create(r.ctx, root, r.repo, app.CreateMergeRequestParams{Title: "again", Source: "one", Target: "main"}) | |
| 282 | + | require.ErrorIs(t, err, domain.ErrAlreadyExists, "same open source and target") | |
| 283 | + | _, err = r.mrs.Create(r.ctx, root, r.repo, app.CreateMergeRequestParams{Title: "again", Source: "one"}) | |
| 284 | + | require.ErrorIs(t, err, domain.ErrAlreadyExists, "the default target is the same target") | |
| 285 | + | ||
| 286 | + | // Another target is another request. | |
| 287 | + | r.sh("checkout", "-q", "-B", "stage", "main") | |
| 288 | + | r.push("stage") | |
| 289 | + | third, err := r.mrs.Create(r.ctx, root, r.repo, app.CreateMergeRequestParams{Title: "to stage", Source: "one", Target: "stage"}) | |
| 290 | + | require.NoError(t, err) | |
| 291 | + | assert.Equal(t, int64(3), third.Number) | |
| 292 | + | ||
| 293 | + | // Once the first is closed, the pair is free again, with a new number. | |
| 294 | + | _, err = r.mrs.Close(r.ctx, root, r.repo, first.Number) | |
| 295 | + | require.NoError(t, err) | |
| 296 | + | fourth, err := r.mrs.Create(r.ctx, root, r.repo, app.CreateMergeRequestParams{Title: "retry", Source: "one"}) | |
| 297 | + | require.NoError(t, err) | |
| 298 | + | assert.Equal(t, int64(4), fourth.Number, "numbers are never reused") | |
| 299 | + | } | |
| 300 | + | ||
| 301 | + | func TestMergeRequestAccess(t *testing.T) { | |
| 302 | + | r := newMRRig(t) // proj is private | |
| 303 | + | r.seed() | |
| 304 | + | r.feature("feat") | |
| 305 | + | mr := r.open("feat") | |
| 306 | + | n := mr.Number | |
| 307 | + | noScope := writer(domain.ScopeRepoWrite) | |
| 308 | + | otherRepo := domain.TokenPrincipal(domain.Token{ID: "o", Scopes: []domain.Scope{domain.ScopeRepoRead, domain.ScopeMRWrite, domain.ScopeMRMerge}, Repos: []domain.RepoName{name(t, "else")}}) | |
| 309 | + | ||
| 310 | + | reads := map[string]func(p domain.Principal) error{ | |
| 311 | + | "List": func(p domain.Principal) error { | |
| 312 | + | _, err := r.mrs.List(r.ctx, p, r.repo, app.ListMergeRequestsParams{}) | |
| 313 | + | return err | |
| 314 | + | }, | |
| 315 | + | "Get": func(p domain.Principal) error { | |
| 316 | + | _, err := r.mrs.Get(r.ctx, p, r.repo, n, false) | |
| 317 | + | return err | |
| 318 | + | }, | |
| 319 | + | } | |
| 320 | + | for what, call := range reads { | |
| 321 | + | for who, p := range map[string]domain.Principal{"anonymous": domain.Anonymous(), "no repo:read": noScope, "token of another repo": otherRepo} { | |
| 322 | + | assert.ErrorIs(t, call(p), domain.ErrNotFound, "%s by %s on a private repository", what, who) | |
| 323 | + | } | |
| 324 | + | assert.NoError(t, call(reader), what+" with repo:read") | |
| 325 | + | assert.NoError(t, call(root), what+" by root") | |
| 326 | + | } | |
| 327 | + | _, err := r.mrs.Get(r.ctx, reader, r.repo, n+1, false) | |
| 328 | + | assert.ErrorIs(t, err, domain.ErrNotFound, "unknown number") | |
| 329 | + | _, err = r.mrs.List(r.ctx, root, name(t, "ghost"), app.ListMergeRequestsParams{}) | |
| 330 | + | assert.ErrorIs(t, err, domain.ErrNotFound, "unknown repository") | |
| 331 | + | ||
| 332 | + | writes := map[string]func(p domain.Principal) error{ | |
| 333 | + | "Create": func(p domain.Principal) error { | |
| 334 | + | _, err := r.mrs.Create(r.ctx, p, r.repo, app.CreateMergeRequestParams{Title: "t", Source: "feat", Target: "feat"}) | |
| 335 | + | return err | |
| 336 | + | }, | |
| 337 | + | "AddComment": func(p domain.Principal) error { | |
| 338 | + | _, err := r.mrs.AddComment(r.ctx, p, r.repo, n, app.AddCommentParams{Text: "x"}) | |
| 339 | + | return err | |
| 340 | + | }, | |
| 341 | + | "ResolveComment": func(p domain.Principal) error { | |
| 342 | + | _, err := r.mrs.ResolveComment(r.ctx, p, r.repo, n, 1, true) | |
| 343 | + | return err | |
| 344 | + | }, | |
| 345 | + | "Close": func(p domain.Principal) error { | |
| 346 | + | _, err := r.mrs.Close(r.ctx, p, r.repo, n+100) | |
| 347 | + | return err | |
| 348 | + | }, | |
| 349 | + | "MarkRead": func(p domain.Principal) error { | |
| 350 | + | _, err := r.mrs.MarkRead(r.ctx, p, r.repo, n, domain.ObjectID{}) | |
| 351 | + | return err | |
| 352 | + | }, | |
| 353 | + | "Merge": func(p domain.Principal) error { | |
| 354 | + | _, err := r.mrs.Merge(r.ctx, p, r.repo, n+100, app.MergeParams{HeadID: mr.HeadID}) | |
| 355 | + | return err | |
| 356 | + | }, | |
| 357 | + | } | |
| 358 | + | for what, call := range writes { | |
| 359 | + | scope := domain.ScopeMRWrite | |
| 360 | + | if what == "Merge" { | |
| 361 | + | scope = domain.ScopeMRMerge | |
| 362 | + | } | |
| 363 | + | assert.ErrorIs(t, call(domain.Anonymous()), domain.ErrNotFound, "%s: anonymous cannot see the private repository", what) | |
| 364 | + | assert.ErrorIs(t, call(noScope), domain.ErrNotFound, "%s: no repo:read", what) | |
| 365 | + | assert.ErrorIs(t, call(otherRepo), domain.ErrNotFound, "%s: another repository's token", what) | |
| 366 | + | assert.ErrorIs(t, call(reader), domain.ErrForbidden, "%s: reading is not writing", what) | |
| 367 | + | if scope == domain.ScopeMRMerge { | |
| 368 | + | assert.ErrorIs(t, call(mrW), domain.ErrForbidden, "mr:write does not merge") | |
| 369 | + | continue | |
| 370 | + | } | |
| 371 | + | // With the scope the call gets past access (and fails on its subject). | |
| 372 | + | assert.NotErrorIs(t, call(mrW), domain.ErrForbidden, what) | |
| 373 | + | assert.NotErrorIs(t, call(mrW), domain.ErrUnauthenticated, what) | |
| 374 | + | } | |
| 375 | + | ||
| 376 | + | // On a public repository an anonymous caller reads, but writing is | |
| 377 | + | // unauthenticated rather than not found. | |
| 378 | + | pubName := name(t, "pub") | |
| 379 | + | _, err = r.repos.Create(r.ctx, root, app.CreateParams{Name: pubName, Visibility: domain.VisibilityPublic}) | |
| 380 | + | require.NoError(t, err) | |
| 381 | + | _, err = r.mrs.List(r.ctx, domain.Anonymous(), pubName, app.ListMergeRequestsParams{}) | |
| 382 | + | assert.NoError(t, err) | |
| 383 | + | _, err = r.mrs.Create(r.ctx, domain.Anonymous(), pubName, app.CreateMergeRequestParams{Title: "t", Source: "a", Target: "b"}) | |
| 384 | + | assert.ErrorIs(t, err, domain.ErrUnauthenticated) | |
| 385 | + | } | |
| 386 | + | ||
| 387 | + | func TestListMergeRequests(t *testing.T) { | |
| 388 | + | r := newMRRig(t) | |
| 389 | + | r.seed() | |
| 390 | + | var nums []int64 | |
| 391 | + | for i := range 5 { | |
| 392 | + | b := fmt.Sprintf("f%d", i) | |
| 393 | + | r.feature(b) | |
| 394 | + | nums = append(nums, r.open(b).Number) | |
| 395 | + | } | |
| 396 | + | _, err := r.mrs.Close(r.ctx, root, r.repo, 2) | |
| 397 | + | require.NoError(t, err) | |
| 398 | + | r.sh("checkout", "-q", "main") | |
| 399 | + | merged := r.feature("f0") | |
| 400 | + | _, err = r.mrs.Merge(r.ctx, root, r.repo, 1, app.MergeParams{HeadID: merged}) | |
| 401 | + | require.NoError(t, err) | |
| 402 | + | r.sh("branch", "-D", "f4") | |
| 403 | + | r.run(r.bare(), r.env(author), "branch", "-D", "f4") | |
| 404 | + | ||
| 405 | + | numbers := func(p app.MergeRequestPage) []int64 { | |
| 406 | + | var out []int64 | |
| 407 | + | for _, m := range p.MergeRequests { | |
| 408 | + | out = append(out, m.Number) | |
| 409 | + | } | |
| 410 | + | return out | |
| 411 | + | } | |
| 412 | + | all, err := r.mrs.List(r.ctx, root, r.repo, app.ListMergeRequestsParams{}) | |
| 413 | + | require.NoError(t, err) | |
| 414 | + | assert.Equal(t, []int64{5, 4, 3, 2, 1}, numbers(all), "highest number first") | |
| 415 | + | assert.Empty(t, all.NextPageToken) | |
| 416 | + | ||
| 417 | + | open, err := r.mrs.List(r.ctx, root, r.repo, app.ListMergeRequestsParams{State: domain.MRStateOpen}) | |
| 418 | + | require.NoError(t, err) | |
| 419 | + | assert.Equal(t, []int64{5, 4, 3}, numbers(open)) | |
| 420 | + | for _, m := range open.MergeRequests { | |
| 421 | + | if m.Number == 5 { | |
| 422 | + | assert.True(t, m.HeadID.IsZero(), "the branch of !5 was deleted") | |
| 423 | + | } else { | |
| 424 | + | assert.False(t, m.HeadID.IsZero(), "an open request carries its head") | |
| 425 | + | } | |
| 426 | + | } | |
| 427 | + | closed, err := r.mrs.List(r.ctx, root, r.repo, app.ListMergeRequestsParams{State: domain.MRStateClosed}) | |
| 428 | + | require.NoError(t, err) | |
| 429 | + | assert.Equal(t, []int64{2}, numbers(closed)) | |
| 430 | + | mergedPage, err := r.mrs.List(r.ctx, root, r.repo, app.ListMergeRequestsParams{State: domain.MRStateMerged}) | |
| 431 | + | require.NoError(t, err) | |
| 432 | + | assert.Equal(t, []int64{1}, numbers(mergedPage)) | |
| 433 | + | assert.Equal(t, merged, mergedPage.MergeRequests[0].HeadID, "a merged request keeps the head it had") | |
| 434 | + | ||
| 435 | + | // Paging: the token names the last number. | |
| 436 | + | p1, err := r.mrs.List(r.ctx, root, r.repo, app.ListMergeRequestsParams{PageSize: 2}) | |
| 437 | + | require.NoError(t, err) | |
| 438 | + | assert.Equal(t, []int64{5, 4}, numbers(p1)) | |
| 439 | + | require.NotEmpty(t, p1.NextPageToken) | |
| 440 | + | // A request created between the pages does not shift them. | |
| 441 | + | r.feature("late") | |
| 442 | + | r.open("late") | |
| 443 | + | p2, err := r.mrs.List(r.ctx, root, r.repo, app.ListMergeRequestsParams{PageSize: 2, PageToken: p1.NextPageToken}) | |
| 444 | + | require.NoError(t, err) | |
| 445 | + | assert.Equal(t, []int64{3, 2}, numbers(p2)) | |
| 446 | + | p3, err := r.mrs.List(r.ctx, root, r.repo, app.ListMergeRequestsParams{PageSize: 2, PageToken: p2.NextPageToken}) | |
| 447 | + | require.NoError(t, err) | |
| 448 | + | assert.Equal(t, []int64{1}, numbers(p3)) | |
| 449 | + | assert.Empty(t, p3.NextPageToken) | |
| 450 | + | ||
| 451 | + | for _, tok := range []string{"garbage", "bW9yZQ"} { | |
| 452 | + | _, err = r.mrs.List(r.ctx, root, r.repo, app.ListMergeRequestsParams{PageToken: tok}) | |
| 453 | + | assert.ErrorIs(t, err, domain.ErrInvalidPageToken) | |
| 454 | + | } | |
| 455 | + | _, err = r.mrs.List(r.ctx, root, r.repo, app.ListMergeRequestsParams{PageSize: -1}) | |
| 456 | + | assert.ErrorIs(t, err, domain.ErrInvalidPageSize) | |
| 457 | + | _ = nums | |
| 458 | + | } | |
| 459 | + | ||
| 460 | + | func TestGetMergeRequest(t *testing.T) { | |
| 461 | + | r := newMRRig(t) | |
| 462 | + | main := r.seed() | |
| 463 | + | head := r.feature("feat") | |
| 464 | + | mr := r.open("feat") | |
| 465 | + | _, err := r.mrs.AddComment(r.ctx, root, r.repo, mr.Number, app.AddCommentParams{Text: "first"}) | |
| 466 | + | require.NoError(t, err) | |
| 467 | + | _, err = r.mrs.AddComment(r.ctx, root, r.repo, mr.Number, app.AddCommentParams{Text: "second"}) | |
| 468 | + | require.NoError(t, err) | |
| 469 | + | ||
| 470 | + | d, err := r.mrs.Get(r.ctx, reader, r.repo, mr.Number, false) | |
| 471 | + | require.NoError(t, err) | |
| 472 | + | assert.Equal(t, head, d.MergeRequest.HeadID) | |
| 473 | + | assert.Equal(t, main, d.DiffBaseID, "the base is the merge base of the target and the head") | |
| 474 | + | require.Len(t, d.Commits, 2) | |
| 475 | + | assert.Equal(t, "add c", d.Commits[0].Summary, "newest first") | |
| 476 | + | assert.False(t, d.CommitsTruncated) | |
| 477 | + | assert.Equal(t, domain.DiffStats{FilesChanged: 2, Additions: 2, Deletions: 1}, d.Stats) | |
| 478 | + | require.Len(t, d.Diff.Files, 2) | |
| 479 | + | require.Len(t, d.Comments, 2) | |
| 480 | + | assert.Equal(t, "first", d.Comments[0].Text.String(), "oldest first") | |
| 481 | + | assert.True(t, d.LastReadID.IsZero()) | |
| 482 | + | assert.False(t, d.LastReadLost) | |
| 483 | + | ||
| 484 | + | // The target moved on: the base stays the merge base, not the new tip. | |
| 485 | + | r.sh("checkout", "-q", "main") | |
| 486 | + | r.commit(author, "later.txt", "x\n", "later on main") | |
| 487 | + | r.push("main") | |
| 488 | + | d, err = r.mrs.Get(r.ctx, reader, r.repo, mr.Number, false) | |
| 489 | + | require.NoError(t, err) | |
| 490 | + | assert.Equal(t, main, d.DiffBaseID) | |
| 491 | + | assert.Len(t, d.Commits, 2, "the commits that main lacks") | |
| 492 | + | ||
| 493 | + | // A deleted source branch: no commits, no diff, an empty head. | |
| 494 | + | r.run(r.bare(), r.env(author), "branch", "-D", "feat") | |
| 495 | + | d, err = r.mrs.Get(r.ctx, reader, r.repo, mr.Number, false) | |
| 496 | + | require.NoError(t, err) | |
| 497 | + | assert.True(t, d.MergeRequest.HeadID.IsZero()) | |
| 498 | + | assert.Empty(t, d.Commits) | |
| 499 | + | assert.True(t, d.DiffBaseID.IsZero()) | |
| 500 | + | assert.Empty(t, d.Diff.Files) | |
| 501 | + | assert.Len(t, d.Comments, 2) | |
| 502 | + | } | |
| 503 | + | ||
| 504 | + | func TestGetMergeRequestTruncatesCommits(t *testing.T) { | |
| 505 | + | r := newMRRig(t) | |
| 506 | + | r.seed() | |
| 507 | + | r.sh("checkout", "-q", "-B", "long", "main") | |
| 508 | + | for i := range app.MaxMergeRequestCommits + 5 { | |
| 509 | + | r.sh("commit", "-q", "--allow-empty", "-m", fmt.Sprintf("c%d", i)) | |
| 510 | + | } | |
| 511 | + | r.push("long") | |
| 512 | + | mr := r.open("long") | |
| 513 | + | d, err := r.mrs.Get(r.ctx, root, r.repo, mr.Number, false) | |
| 514 | + | require.NoError(t, err) | |
| 515 | + | assert.Len(t, d.Commits, app.MaxMergeRequestCommits) | |
| 516 | + | assert.True(t, d.CommitsTruncated) | |
| 517 | + | assert.Equal(t, "c104", d.Commits[0].Summary) | |
| 518 | + | } | |
| 519 | + | ||
| 520 | + | // AC3: a comment on a path:line must be a line of the current diff. | |
| 521 | + | func TestAddComment(t *testing.T) { | |
| 522 | + | r := newMRRig(t) | |
| 523 | + | r.seed() | |
| 524 | + | r.feature("feat") | |
| 525 | + | mr := r.open("feat") | |
| 526 | + | n := mr.Number | |
| 527 | + | // The diff: a.txt line 2 changed (old "two", new "2"); c.txt added, line 1. | |
| 528 | + | ||
| 529 | + | tests := []struct { | |
| 530 | + | name string | |
| 531 | + | params app.AddCommentParams | |
| 532 | + | field string // "" for valid | |
| 533 | + | }{ | |
| 534 | + | {"general", app.AddCommentParams{Text: "looks good"}, ""}, | |
| 535 | + | {"multi-line general", app.AddCommentParams{Text: "a\n\nb"}, ""}, | |
| 536 | + | {"new side of a changed line", app.AddCommentParams{Path: "a.txt", Line: 2, Side: domain.SideNew, Text: "x"}, ""}, | |
| 537 | + | {"old side of a changed line", app.AddCommentParams{Path: "a.txt", Line: 2, Side: domain.SideOld, Text: "x"}, ""}, | |
| 538 | + | {"context line", app.AddCommentParams{Path: "a.txt", Line: 3, Side: domain.SideNew, Text: "x"}, ""}, | |
| 539 | + | {"added file", app.AddCommentParams{Path: "c.txt", Line: 1, Side: domain.SideNew, Text: "x"}, ""}, | |
| 540 | + | {"empty text", app.AddCommentParams{Text: ""}, "text"}, | |
| 541 | + | {"blank text", app.AddCommentParams{Text: " \n "}, "text"}, | |
| 542 | + | {"text too long", app.AddCommentParams{Text: strings.Repeat("x", 8001)}, "text"}, | |
| 543 | + | {"text with a control character", app.AddCommentParams{Text: "a\x1b[0m"}, "text"}, | |
| 544 | + | {"text with a tab", app.AddCommentParams{Text: "a\tb"}, "text"}, | |
| 545 | + | {"text with a bidi override", app.AddCommentParams{Text: "a\u202eb"}, "text"}, | |
| 546 | + | {"line outside the diff", app.AddCommentParams{Path: "a.txt", Line: 40, Side: domain.SideNew, Text: "x"}, "path"}, | |
| 547 | + | {"unchanged file", app.AddCommentParams{Path: "b.txt", Line: 1, Side: domain.SideNew, Text: "x"}, "path"}, | |
| 548 | + | {"unknown path", app.AddCommentParams{Path: "nope.txt", Line: 1, Side: domain.SideNew, Text: "x"}, "path"}, | |
| 549 | + | {"deleted line on the wrong side", app.AddCommentParams{Path: "c.txt", Line: 1, Side: domain.SideOld, Text: "x"}, "path"}, | |
| 550 | + | {"path without a line", app.AddCommentParams{Path: "a.txt", Side: domain.SideNew, Text: "x"}, "line"}, | |
| 551 | + | {"path without a side", app.AddCommentParams{Path: "a.txt", Line: 2, Text: "x"}, "side"}, | |
| 552 | + | {"line without a path", app.AddCommentParams{Line: 2, Side: domain.SideNew, Text: "x"}, "path"}, | |
| 553 | + | {"side without a path", app.AddCommentParams{Side: domain.SideOld, Text: "x"}, "path"}, | |
| 554 | + | {"negative line", app.AddCommentParams{Path: "a.txt", Line: -2, Side: domain.SideNew, Text: "x"}, "line"}, | |
| 555 | + | } | |
| 556 | + | for _, tc := range tests { | |
| 557 | + | t.Run(tc.name, func(t *testing.T) { | |
| 558 | + | c, err := r.mrs.AddComment(r.ctx, mrW, r.repo, n, tc.params) | |
| 559 | + | if tc.field == "" { | |
| 560 | + | require.NoError(t, err) | |
| 561 | + | assert.Equal(t, "agent-1", c.Author) | |
| 562 | + | assert.Equal(t, tc.params.Path, c.Path) | |
| 563 | + | assert.Equal(t, tc.params.Line, c.Line) | |
| 564 | + | assert.Equal(t, tc.params.Side, c.Side) | |
| 565 | + | assert.Equal(t, mr.HeadID, c.CommitID, "the head it was written against") | |
| 566 | + | assert.False(t, c.Resolved) | |
| 567 | + | return | |
| 568 | + | } | |
| 569 | + | require.ErrorIs(t, err, domain.ErrInvalid) | |
| 570 | + | assert.Contains(t, fieldsOf(t, err), tc.field) | |
| 571 | + | }) | |
| 572 | + | } | |
| 573 | + | ||
| 574 | + | // Ids count 1, 2, ... in order of creation and only the valid ones are kept. | |
| 575 | + | d, err := r.mrs.Get(r.ctx, root, r.repo, n, false) | |
| 576 | + | require.NoError(t, err) | |
| 577 | + | var kept int | |
| 578 | + | for _, tc := range tests { | |
| 579 | + | if tc.field == "" { | |
| 580 | + | kept++ | |
| 581 | + | } | |
| 582 | + | } | |
| 583 | + | require.Len(t, d.Comments, kept) | |
| 584 | + | for i, c := range d.Comments { | |
| 585 | + | assert.Equal(t, int64(i+1), c.ID) | |
| 586 | + | } | |
| 587 | + | ||
| 588 | + | t.Run("not open", func(t *testing.T) { | |
| 589 | + | _, err := r.mrs.Close(r.ctx, mrW, r.repo, n) | |
| 590 | + | require.NoError(t, err) | |
| 591 | + | _, err = r.mrs.AddComment(r.ctx, mrW, r.repo, n, app.AddCommentParams{Text: "late"}) | |
| 592 | + | assert.ErrorIs(t, err, domain.ErrPreconditionFailed) | |
| 593 | + | _, err = r.mrs.ResolveComment(r.ctx, mrW, r.repo, n, 1, true) | |
| 594 | + | assert.ErrorIs(t, err, domain.ErrPreconditionFailed) | |
| 595 | + | }) | |
| 596 | + | } | |
| 597 | + | ||
| 598 | + | func TestConcurrentComments(t *testing.T) { | |
| 599 | + | r := newMRRig(t) | |
| 600 | + | r.seed() | |
| 601 | + | r.feature("feat") | |
| 602 | + | mr := r.open("feat") | |
| 603 | + | const workers = 10 | |
| 604 | + | var wg sync.WaitGroup | |
| 605 | + | ids := make(chan int64, workers) | |
| 606 | + | for i := range workers { | |
| 607 | + | wg.Go(func() { | |
| 608 | + | c, err := r.mrs.AddComment(r.ctx, mrW, r.repo, mr.Number, app.AddCommentParams{Text: fmt.Sprintf("c%d", i)}) | |
| 609 | + | if assert.NoError(t, err) { | |
| 610 | + | ids <- c.ID | |
| 611 | + | } | |
| 612 | + | }) | |
| 613 | + | } | |
| 614 | + | wg.Wait() | |
| 615 | + | close(ids) | |
| 616 | + | seen := map[int64]bool{} | |
| 617 | + | for id := range ids { | |
| 618 | + | assert.False(t, seen[id], "id %d handed out twice", id) | |
| 619 | + | seen[id] = true | |
| 620 | + | } | |
| 621 | + | assert.Len(t, seen, workers) | |
| 622 | + | d, err := r.mrs.Get(r.ctx, root, r.repo, mr.Number, false) | |
| 623 | + | require.NoError(t, err) | |
| 624 | + | assert.Len(t, d.Comments, workers) | |
| 625 | + | } | |
| 626 | + | ||
| 627 | + | func TestResolveComment(t *testing.T) { | |
| 628 | + | r := newMRRig(t) | |
| 629 | + | r.seed() | |
| 630 | + | r.feature("feat") | |
| 631 | + | mr := r.open("feat") | |
| 632 | + | c, err := r.mrs.AddComment(r.ctx, mrW, r.repo, mr.Number, app.AddCommentParams{Text: "fix this"}) | |
| 633 | + | require.NoError(t, err) | |
| 634 | + | r.drain() | |
| 635 | + | ||
| 636 | + | got, err := r.mrs.ResolveComment(r.ctx, mrW, r.repo, mr.Number, c.ID, true) | |
| 637 | + | require.NoError(t, err) | |
| 638 | + | assert.True(t, got.Resolved) | |
| 639 | + | again, err := r.mrs.ResolveComment(r.ctx, mrW, r.repo, mr.Number, c.ID, true) | |
| 640 | + | require.NoError(t, err, "resolving twice is not an error") | |
| 641 | + | assert.True(t, again.Resolved) | |
| 642 | + | undone, err := r.mrs.ResolveComment(r.ctx, mrW, r.repo, mr.Number, c.ID, false) | |
| 643 | + | require.NoError(t, err) | |
| 644 | + | assert.False(t, undone.Resolved) | |
| 645 | + | undone, err = r.mrs.ResolveComment(r.ctx, mrW, r.repo, mr.Number, c.ID, false) | |
| 646 | + | require.NoError(t, err) | |
| 647 | + | assert.False(t, undone.Resolved) | |
| 648 | + | ||
| 649 | + | _, err = r.mrs.ResolveComment(r.ctx, mrW, r.repo, mr.Number, 99, true) | |
| 650 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| 651 | + | _, err = r.mrs.ResolveComment(r.ctx, mrW, r.repo, mr.Number, 0, true) | |
| 652 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| 653 | + | ||
| 654 | + | assert.Empty(t, r.drain(), "a resolved comment has no event") | |
| 655 | + | } | |
| 656 | + | ||
| 657 | + | func TestEventsOfAMergeRequest(t *testing.T) { | |
| 658 | + | r := newMRRig(t) | |
| 659 | + | r.seed() | |
| 660 | + | head := r.feature("feat") | |
| 661 | + | r.drain() | |
| 662 | + | ||
| 663 | + | mr := r.open("feat") | |
| 664 | + | c, err := r.mrs.AddComment(r.ctx, mrW, r.repo, mr.Number, app.AddCommentParams{Text: "note"}) | |
| 665 | + | require.NoError(t, err) | |
| 666 | + | _, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, app.MergeParams{HeadID: head}) | |
| 667 | + | require.NoError(t, err) | |
| 668 | + | ||
| 669 | + | r.feature("other") | |
| 670 | + | other := r.open("other") | |
| 671 | + | _, err = r.mrs.Close(r.ctx, mrW, r.repo, other.Number) | |
| 672 | + | require.NoError(t, err) | |
| 673 | + | ||
| 674 | + | evs := r.drain() | |
| 675 | + | var kinds []string | |
| 676 | + | for _, ev := range evs { | |
| 677 | + | kinds = append(kinds, ev.EventKind()) | |
| 678 | + | } | |
| 679 | + | assert.Equal(t, []string{"mr.opened", "mr.comment_added", "mr.merged", "repo.pushed", "mr.opened", "mr.closed"}, kinds) | |
| 680 | + | ||
| 681 | + | opened := evs[0].(domain.MergeRequestOpened) | |
| 682 | + | assert.Equal(t, mr.Number, opened.MergeRequest.Number) | |
| 683 | + | assert.Equal(t, domain.VisibilityPrivate, opened.Visibility, "the event carries the visibility, so that it is filtered") | |
| 684 | + | assert.False(t, opened.CreatedAt.IsZero()) | |
| 685 | + | added := evs[1].(domain.CommentAdded) | |
| 686 | + | assert.Equal(t, c, added.Comment) | |
| 687 | + | assert.Equal(t, mr.Number, added.Number) | |
| 688 | + | merged := evs[2].(domain.MergeRequestMerged) | |
| 689 | + | assert.Equal(t, domain.MRStateMerged, merged.MergeRequest.State) | |
| 690 | + | push := evs[3].(domain.PushEvent) | |
| 691 | + | assert.Equal(t, []domain.RefUpdate{{Old: r.seedID(), New: head.String(), Ref: "refs/heads/main"}}, push.Updates) | |
| 692 | + | assert.Equal(t, domain.VisibilityPrivate, push.Visibility) | |
| 693 | + | assert.Equal(t, domain.MRStateClosed, evs[5].(domain.MergeRequestClosed).MergeRequest.State) | |
| 694 | + | ||
| 695 | + | // Failed calls publish nothing. | |
| 696 | + | _, err = r.mrs.Close(r.ctx, mrW, r.repo, other.Number) | |
| 697 | + | require.Error(t, err) | |
| 698 | + | _, err = r.mrs.AddComment(r.ctx, mrW, r.repo, other.Number, app.AddCommentParams{Text: "x"}) | |
| 699 | + | require.Error(t, err) | |
| 700 | + | assert.Empty(t, r.drain()) | |
| 701 | + | } | |
| 702 | + | ||
| 703 | + | // seedID is the id main had before the merges of the test: the parent of its | |
| 704 | + | // first feature commit. | |
| 705 | + | func (r *mrRig) seedID() string { | |
| 706 | + | return r.run(r.bare(), r.env(author), "rev-parse", "refs/heads/feat~2") | |
| 707 | + | } | |
| 708 | + | ||
| 709 | + | func TestMarkReadAndSinceLastRead(t *testing.T) { | |
| 710 | + | r := newMRRig(t) | |
| 711 | + | base := r.seed() | |
| 712 | + | head1 := r.feature("feat") | |
| 713 | + | mr := r.open("feat") | |
| 714 | + | n := mr.Number | |
| 715 | + | ||
| 716 | + | // Nothing recorded: the flag changes nothing. | |
| 717 | + | d, err := r.mrs.Get(r.ctx, mrW, r.repo, n, true) | |
| 718 | + | require.NoError(t, err) | |
| 719 | + | assert.True(t, d.LastReadID.IsZero()) | |
| 720 | + | assert.False(t, d.LastReadLost) | |
| 721 | + | assert.Equal(t, base, d.DiffBaseID) | |
| 722 | + | ||
| 723 | + | // Without a head: the current head. | |
| 724 | + | got, err := r.mrs.MarkRead(r.ctx, mrW, r.repo, n, domain.ObjectID{}) | |
| 725 | + | require.NoError(t, err) | |
| 726 | + | assert.Equal(t, head1, got) | |
| 727 | + | d, err = r.mrs.Get(r.ctx, reader, r.repo, n, false) | |
| 728 | + | require.NoError(t, err) | |
| 729 | + | assert.Equal(t, head1, d.LastReadID, "reported whether or not since_last_read is set") | |
| 730 | + | assert.Equal(t, base, d.DiffBaseID) | |
| 731 | + | ||
| 732 | + | // New work on the branch: the diff since the last read has only that. | |
| 733 | + | r.commit(author, "d.txt", "dee\n", "add d") | |
| 734 | + | head2 := r.head() | |
| 735 | + | r.push("feat") | |
| 736 | + | d, err = r.mrs.Get(r.ctx, reader, r.repo, n, true) | |
| 737 | + | require.NoError(t, err) | |
| 738 | + | assert.Equal(t, head1, d.DiffBaseID) | |
| 739 | + | assert.Equal(t, head2, d.MergeRequest.HeadID) | |
| 740 | + | require.Len(t, d.Diff.Files, 1) | |
| 741 | + | assert.Equal(t, "d.txt", d.Diff.Files[0].NewPath) | |
| 742 | + | assert.Equal(t, domain.DiffStats{FilesChanged: 1, Additions: 1}, d.Stats) | |
| 743 | + | assert.Len(t, d.Commits, 3, "the commits are still those of the target..head range") | |
| 744 | + | ||
| 745 | + | // Marking a commit that is not in the repository is refused. | |
| 746 | + | _, err = r.mrs.MarkRead(r.ctx, mrW, r.repo, n, oidFrom(t, strings.Repeat("1", 40))) | |
| 747 | + | require.ErrorIs(t, err, domain.ErrInvalid) | |
| 748 | + | assert.Contains(t, fieldsOf(t, err), "head_id") | |
| 749 | + | // Neither is a tree. | |
| 750 | + | tree := oidFrom(t, r.run(r.bare(), r.env(author), "rev-parse", head1.String()+"^{tree}")) | |
| 751 | + | _, err = r.mrs.MarkRead(r.ctx, mrW, r.repo, n, tree) | |
| 752 | + | require.ErrorIs(t, err, domain.ErrInvalid) | |
| 753 | + | // An explicit head is recorded as given. | |
| 754 | + | _, err = r.mrs.MarkRead(r.ctx, mrW, r.repo, n, base) | |
| 755 | + | require.NoError(t, err) | |
| 756 | + | d, err = r.mrs.Get(r.ctx, reader, r.repo, n, true) | |
| 757 | + | require.NoError(t, err) | |
| 758 | + | assert.Equal(t, base, d.DiffBaseID) | |
| 759 | + | } | |
| 760 | + | ||
| 761 | + | // AC6: the diff since the last read is right after a force-push; when the | |
| 762 | + | // recorded commit is gone the whole change is shown and the response says so. | |
| 763 | + | func TestSinceLastReadAfterForcePush(t *testing.T) { | |
| 764 | + | r := newMRRig(t) | |
| 765 | + | base := r.seed() | |
| 766 | + | head1 := r.feature("feat") | |
| 767 | + | mr := r.open("feat") | |
| 768 | + | _, err := r.mrs.MarkRead(r.ctx, mrW, r.repo, mr.Number, domain.ObjectID{}) | |
| 769 | + | require.NoError(t, err) | |
| 770 | + | ||
| 771 | + | // Force-push: the last commit is amended (the old one is unreachable but | |
| 772 | + | // still stored), and one more file is added. | |
| 773 | + | r.sh("checkout", "-q", "feat") | |
| 774 | + | require.NoError(t, os.WriteFile(filepath.Join(r.work, "c.txt"), []byte("see, amended\n"), 0o644)) | |
| 775 | + | r.sh("add", "c.txt") | |
| 776 | + | r.sh("commit", "-q", "--amend", "-m", "add c, amended") | |
| 777 | + | head2 := r.head() | |
| 778 | + | require.NotEqual(t, head1, head2) | |
| 779 | + | r.push("feat") | |
| 780 | + | assert.Equal(t, head2, r.branchHead("feat")) | |
| 781 | + | // head1 is not an ancestor of head2 any more. | |
| 782 | + | r.run(r.bare(), r.env(author), "cat-file", "-e", head1.String()) | |
| 783 | + | ||
| 784 | + | d, err := r.mrs.Get(r.ctx, reader, r.repo, mr.Number, true) | |
| 785 | + | require.NoError(t, err) | |
| 786 | + | assert.False(t, d.LastReadLost) | |
| 787 | + | assert.Equal(t, head1, d.DiffBaseID, "the diff starts at the head that was read") | |
| 788 | + | require.Len(t, d.Diff.Files, 1, "only what the force-push changed") | |
| 789 | + | assert.Equal(t, "c.txt", d.Diff.Files[0].NewPath) | |
| 790 | + | var added, removed []string | |
| 791 | + | for _, l := range d.Diff.Files[0].Hunks[0].Lines { | |
| 792 | + | switch l.Kind { | |
| 793 | + | case domain.LineAddition: | |
| 794 | + | added = append(added, l.Text) | |
| 795 | + | case domain.LineDeletion: | |
| 796 | + | removed = append(removed, l.Text) | |
| 797 | + | } | |
| 798 | + | } | |
| 799 | + | assert.Equal(t, []string{"see, amended"}, added) | |
| 800 | + | assert.Equal(t, []string{"see"}, removed) | |
| 801 | + | ||
| 802 | + | // The whole change is still available without the flag. | |
| 803 | + | d, err = r.mrs.Get(r.ctx, reader, r.repo, mr.Number, false) | |
| 804 | + | require.NoError(t, err) | |
| 805 | + | assert.Equal(t, base, d.DiffBaseID) | |
| 806 | + | assert.Len(t, d.Diff.Files, 2) | |
| 807 | + | ||
| 808 | + | // Once the recorded commit is pruned, the response says so. | |
| 809 | + | r.run(r.bare(), r.env(author), "gc", "-q", "--prune=now") | |
| 810 | + | d, err = r.mrs.Get(r.ctx, reader, r.repo, mr.Number, true) | |
| 811 | + | require.NoError(t, err) | |
| 812 | + | assert.True(t, d.LastReadLost, "the recorded head is gone") | |
| 813 | + | assert.Equal(t, head1, d.LastReadID, "still reported") | |
| 814 | + | assert.Equal(t, base, d.DiffBaseID, "the diff is against the target") | |
| 815 | + | assert.Len(t, d.Diff.Files, 2) | |
| 816 | + | } | |
| 817 | + | ||
| 818 | + | func TestMergeFastForward(t *testing.T) { | |
| 819 | + | r := newMRRig(t) | |
| 820 | + | main := r.seed() | |
| 821 | + | head := r.feature("feat") | |
| 822 | + | mr := r.open("feat") | |
| 823 | + | r.drain() | |
| 824 | + | ||
| 825 | + | merged, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, app.MergeParams{HeadID: head}) | |
| 826 | + | require.NoError(t, err) | |
| 827 | + | assert.Equal(t, domain.MRStateMerged, merged.State) | |
| 828 | + | assert.Equal(t, domain.MergeModeFastForward, merged.MergeMode, "the default mode") | |
| 829 | + | assert.Equal(t, head, merged.MergedCommitID, "no commit is created") | |
| 830 | + | assert.Equal(t, head, merged.HeadID) | |
| 831 | + | assert.Equal(t, head, r.branchHead("main")) | |
| 832 | + | assert.Equal(t, "root", merged.Author) | |
| 833 | + | _ = main | |
| 834 | + | ||
| 835 | + | // The stored request is the merged one, and reads keep a diff. | |
| 836 | + | d, err := r.mrs.Get(r.ctx, reader, r.repo, mr.Number, false) | |
| 837 | + | require.NoError(t, err) | |
| 838 | + | assert.Equal(t, domain.MRStateMerged, d.MergeRequest.State) | |
| 839 | + | assert.Equal(t, head, d.MergeRequest.HeadID) | |
| 840 | + | assert.Equal(t, head, d.MergeRequest.MergedCommitID) | |
| 841 | + | assert.Equal(t, main, d.DiffBaseID, "the base of the merge stays readable") | |
| 842 | + | assert.Len(t, d.Commits, 2, "the commits that were merged") | |
| 843 | + | assert.Len(t, d.Diff.Files, 2) | |
| 844 | + | ||
| 845 | + | // Repeating it, or touching a final request, is a failed precondition. | |
| 846 | + | _, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, app.MergeParams{HeadID: head}) | |
| 847 | + | assert.ErrorIs(t, err, domain.ErrPreconditionFailed) | |
| 848 | + | _, err = r.mrs.Close(r.ctx, mrW, r.repo, mr.Number) | |
| 849 | + | assert.ErrorIs(t, err, domain.ErrPreconditionFailed) | |
| 850 | + | _, err = r.mrs.AddComment(r.ctx, mrW, r.repo, mr.Number, app.AddCommentParams{Text: "x"}) | |
| 851 | + | assert.ErrorIs(t, err, domain.ErrPreconditionFailed) | |
| 852 | + | } | |
| 853 | + | ||
| 854 | + | // AC4: the checks of a merge. | |
| 855 | + | func TestMergeRefusals(t *testing.T) { | |
| 856 | + | t.Run("a branch that moved since the review", func(t *testing.T) { | |
| 857 | + | r := newMRRig(t) | |
| 858 | + | r.seed() | |
| 859 | + | head := r.feature("feat") | |
| 860 | + | mr := r.open("feat") | |
| 861 | + | r.commit(author, "more.txt", "m\n", "pushed after the review") | |
| 862 | + | r.push("feat") | |
| 863 | + | _, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, app.MergeParams{HeadID: head}) | |
| 864 | + | require.ErrorIs(t, err, domain.ErrPreconditionFailed) | |
| 865 | + | assert.Contains(t, err.Error(), "branch moved, read again") | |
| 866 | + | assert.NotEqual(t, head, r.branchHead("main"), "main did not move") | |
| 867 | + | // Reading again gives the new head, which merges. | |
| 868 | + | d, err := r.mrs.Get(r.ctx, mrM, r.repo, mr.Number, false) | |
| 869 | + | require.NoError(t, err) | |
| 870 | + | _, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, app.MergeParams{HeadID: d.MergeRequest.HeadID}) | |
| 871 | + | require.NoError(t, err) | |
| 872 | + | }) | |
| 873 | + | ||
| 874 | + | t.Run("a target that is not an ancestor of the head", func(t *testing.T) { | |
| 875 | + | r := newMRRig(t) | |
| 876 | + | r.seed() | |
| 877 | + | head := r.feature("feat") | |
| 878 | + | mr := r.open("feat") | |
| 879 | + | r.sh("checkout", "-q", "main") | |
| 880 | + | r.commit(author, "news.txt", "n\n", "main moved") | |
| 881 | + | r.push("main") | |
| 882 | + | tip := r.branchHead("main") | |
| 883 | + | ||
| 884 | + | for _, mode := range []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash} { | |
| 885 | + | _, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, app.MergeParams{HeadID: head, Mode: mode}) | |
| 886 | + | require.ErrorIs(t, err, domain.ErrPreconditionFailed, mode) | |
| 887 | + | assert.Contains(t, err.Error(), "rebase", mode) | |
| 888 | + | } | |
| 889 | + | assert.Equal(t, tip, r.branchHead("main")) | |
| 890 | + | d, err := r.mrs.Get(r.ctx, mrM, r.repo, mr.Number, false) | |
| 891 | + | require.NoError(t, err) | |
| 892 | + | assert.Equal(t, domain.MRStateOpen, d.MergeRequest.State) | |
| 893 | + | ||
| 894 | + | // After a rebase and a push it merges. | |
| 895 | + | r.sh("checkout", "-q", "feat") | |
| 896 | + | r.sh("rebase", "-q", "main") | |
| 897 | + | r.push("feat") | |
| 898 | + | _, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, app.MergeParams{HeadID: r.head()}) | |
| 899 | + | require.NoError(t, err) | |
| 900 | + | }) | |
| 901 | + | ||
| 902 | + | t.Run("a mode the repository does not allow", func(t *testing.T) { | |
| 903 | + | r := newMRRig(t) | |
| 904 | + | r.seed() | |
| 905 | + | head := r.feature("feat") | |
| 906 | + | mr := r.open("feat") | |
| 907 | + | modes := []domain.MergeMode{domain.MergeModeFastForward} | |
| 908 | + | _, err := r.repos.Update(r.ctx, root, r.repo, app.UpdateParams{AllowedMergeModes: &modes}) | |
| 909 | + | require.NoError(t, err) | |
| 910 | + | ||
| 911 | + | _, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, app.MergeParams{HeadID: head, Mode: domain.MergeModeSquash}) | |
| 912 | + | require.ErrorIs(t, err, domain.ErrPreconditionFailed) | |
| 913 | + | assert.Contains(t, err.Error(), "squash") | |
| 914 | + | _, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, app.MergeParams{HeadID: head}) | |
| 915 | + | require.NoError(t, err, "fast-forward is allowed") | |
| 916 | + | }) | |
| 917 | + | ||
| 918 | + | t.Run("only squash is allowed, so the default mode is refused", func(t *testing.T) { | |
| 919 | + | r := newMRRig(t) | |
| 920 | + | r.seed() | |
| 921 | + | head := r.feature("feat") | |
| 922 | + | mr := r.open("feat") | |
| 923 | + | modes := []domain.MergeMode{domain.MergeModeSquash} | |
| 924 | + | _, err := r.repos.Update(r.ctx, root, r.repo, app.UpdateParams{AllowedMergeModes: &modes}) | |
| 925 | + | require.NoError(t, err) | |
| 926 | + | _, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, app.MergeParams{HeadID: head}) | |
| 927 | + | require.ErrorIs(t, err, domain.ErrPreconditionFailed) | |
| 928 | + | _, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, app.MergeParams{HeadID: head, Mode: domain.MergeModeSquash}) | |
| 929 | + | require.NoError(t, err) | |
| 930 | + | }) | |
| 931 | + | ||
| 932 | + | t.Run("bad arguments", func(t *testing.T) { | |
| 933 | + | r := newMRRig(t) | |
| 934 | + | r.seed() | |
| 935 | + | head := r.feature("feat") | |
| 936 | + | mr := r.open("feat") | |
| 937 | + | _, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, app.MergeParams{}) | |
| 938 | + | require.ErrorIs(t, err, domain.ErrInvalid) | |
| 939 | + | assert.Contains(t, fieldsOf(t, err), "head_id") | |
| 940 | + | _, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, app.MergeParams{HeadID: head, Mode: "rebase"}) | |
| 941 | + | require.ErrorIs(t, err, domain.ErrInvalid) | |
| 942 | + | assert.Contains(t, fieldsOf(t, err), "mode") | |
| 943 | + | _, err = r.mrs.Merge(r.ctx, mrM, r.repo, 99, app.MergeParams{HeadID: head}) | |
| 944 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| 945 | + | }) | |
| 946 | + | ||
| 947 | + | t.Run("a deleted source or target, or nothing to merge", func(t *testing.T) { | |
| 948 | + | r := newMRRig(t) | |
| 949 | + | r.seed() | |
| 950 | + | head := r.feature("feat") | |
| 951 | + | mr := r.open("feat") | |
| 952 | + | r.run(r.bare(), r.env(author), "branch", "-D", "feat") | |
| 953 | + | _, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, app.MergeParams{HeadID: head}) | |
| 954 | + | require.ErrorIs(t, err, domain.ErrPreconditionFailed) | |
| 955 | + | assert.Contains(t, err.Error(), "no longer exists") | |
| 956 | + | ||
| 957 | + | r.push("feat") | |
| 958 | + | r.run(r.bare(), r.env(author), "update-ref", "refs/heads/main", head.String()) | |
| 959 | + | _, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, app.MergeParams{HeadID: head}) | |
| 960 | + | require.ErrorIs(t, err, domain.ErrPreconditionFailed, "the target already is at the head") | |
| 961 | + | assert.Contains(t, err.Error(), "nothing to merge") | |
| 962 | + | }) | |
| 963 | + | } | |
| 964 | + | ||
| 965 | + | // AC4: merges of one repository run one at a time. | |
| 966 | + | func TestConcurrentMergesSerialize(t *testing.T) { | |
| 967 | + | r := newMRRig(t) | |
| 968 | + | r.seed() | |
| 969 | + | const n = 6 | |
| 970 | + | heads := map[int64]domain.ObjectID{} | |
| 971 | + | for i := range n { | |
| 972 | + | b := fmt.Sprintf("f%d", i) | |
| 973 | + | heads[r.openFeature(b).Number] = r.branchHead(b) | |
| 974 | + | } | |
| 975 | + | ||
| 976 | + | type outcome struct { | |
| 977 | + | number int64 | |
| 978 | + | err error | |
| 979 | + | } | |
| 980 | + | results := make(chan outcome, n*2) | |
| 981 | + | var wg sync.WaitGroup | |
| 982 | + | for number, head := range heads { | |
| 983 | + | for _, mode := range []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash} { | |
| 984 | + | if mode == domain.MergeModeSquash && number%2 == 0 { | |
| 985 | + | continue | |
| 986 | + | } | |
| 987 | + | wg.Go(func() { | |
| 988 | + | _, err := r.mrs.Merge(r.ctx, mrM, r.repo, number, app.MergeParams{HeadID: head, Mode: mode}) | |
| 989 | + | results <- outcome{number, err} | |
| 990 | + | }) | |
| 991 | + | } | |
| 992 | + | } | |
| 993 | + | wg.Wait() | |
| 994 | + | close(results) | |
| 995 | + | ||
| 996 | + | won := map[int64]int{} | |
| 997 | + | for o := range results { | |
| 998 | + | if o.err == nil { | |
| 999 | + | won[o.number]++ | |
| 1000 | + | continue | |
| 1001 | + | } | |
| 1002 | + | require.ErrorIs(t, o.err, domain.ErrPreconditionFailed, "a loser fails cleanly: %v", o.err) | |
| 1003 | + | } | |
| 1004 | + | // All the branches start from the same main: exactly one request can win, | |
| 1005 | + | // once, whatever the mode, and main is where it left it. | |
| 1006 | + | require.Len(t, won, 1, "exactly one merge request was merged: %v", won) | |
| 1007 | + | for number, times := range won { | |
| 1008 | + | assert.Equal(t, 1, times, "!%d merged twice", number) | |
| 1009 | + | mr, err := r.mrs.Get(r.ctx, root, r.repo, number, false) | |
| 1010 | + | require.NoError(t, err) | |
| 1011 | + | assert.Equal(t, domain.MRStateMerged, mr.MergeRequest.State) | |
| 1012 | + | assert.Equal(t, mr.MergeRequest.MergedCommitID, r.branchHead("main")) | |
| 1013 | + | } | |
| 1014 | + | for number := range heads { | |
| 1015 | + | if won[number] == 0 { | |
| 1016 | + | d, err := r.mrs.Get(r.ctx, root, r.repo, number, false) | |
| 1017 | + | require.NoError(t, err) | |
| 1018 | + | assert.Equal(t, domain.MRStateOpen, d.MergeRequest.State, "!%d lost and stays open", number) | |
| 1019 | + | } | |
| 1020 | + | } | |
| 1021 | + | } | |
| 1022 | + | ||
| 1023 | + | // openFeature pushes a branch from main with one commit and opens a request. | |
| 1024 | + | func (r *mrRig) openFeature(branch string) domain.MergeRequest { | |
| 1025 | + | r.t.Helper() | |
| 1026 | + | r.sh("checkout", "-q", "-B", branch, "main") | |
| 1027 | + | r.commit(author, branch+".txt", branch+"\n", "add "+branch) | |
| 1028 | + | r.push(branch) | |
| 1029 | + | return r.open(branch) | |
| 1030 | + | } | |
| 1031 | + | ||
| 1032 | + | func TestSquashMerge(t *testing.T) { | |
| 1033 | + | r := newMRRig(t) | |
| 1034 | + | main := r.seed() | |
| 1035 | + | // A branch of three commits by two authors; Bob wrote the first one. | |
| 1036 | + | bob := domain.Identity{Name: "Bob", Email: "bob@example.org"} | |
| 1037 | + | cy := domain.Identity{Name: "Cy", Email: "cy@example.org"} | |
| 1038 | + | r.sh("checkout", "-q", "-B", "feat", "main") | |
| 1039 | + | r.commit(bob, "x.txt", "x\n", "bob starts") | |
| 1040 | + | r.commit(author, "y.txt", "y\n", "ada continues") | |
| 1041 | + | r.commit(cy, "z.txt", "z\n", "cy finishes") | |
| 1042 | + | r.commit(bob, "w.txt", "w\n", "bob again") | |
| 1043 | + | r.push("feat") | |
| 1044 | + | head := r.branchHead("feat") | |
| 1045 | + | mr, err := r.mrs.Create(r.ctx, root, r.repo, app.CreateMergeRequestParams{Title: "Add things", Source: "feat"}) | |
| 1046 | + | require.NoError(t, err) | |
| 1047 | + | r.drain() | |
| 1048 | + | ||
| 1049 | + | merged, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, app.MergeParams{HeadID: head, Mode: domain.MergeModeSquash}) | |
| 1050 | + | require.NoError(t, err) | |
| 1051 | + | assert.Equal(t, domain.MergeModeSquash, merged.MergeMode) | |
| 1052 | + | require.False(t, merged.MergedCommitID.IsZero()) | |
| 1053 | + | assert.NotEqual(t, head, merged.MergedCommitID, "a squash creates a commit") | |
| 1054 | + | assert.Equal(t, merged.MergedCommitID, r.branchHead("main")) | |
| 1055 | + | ||
| 1056 | + | sq := merged.MergedCommitID.String() | |
| 1057 | + | g := func(args ...string) string { return r.run(r.bare(), r.env(author), args...) } | |
| 1058 | + | assert.Equal(t, main.String(), g("rev-parse", sq+"^"), "the target is the only parent") | |
| 1059 | + | assert.Equal(t, g("rev-parse", head.String()+"^{tree}"), g("rev-parse", sq+"^{tree}"), "the tree of the head") | |
| 1060 | + | assert.Equal(t, "Bob <bob@example.org>", g("log", "-1", "--format=%an <%ae>", sq), "author of the first commit") | |
| 1061 | + | assert.Equal(t, "ermite <ermite@git.example.org>", g("log", "-1", "--format=%cn <%ce>", sq)) | |
| 1062 | + | assert.Equal(t, "Add things (!1)\n\n- bob starts\n- ada continues\n- cy finishes\n- bob again\n\n"+ | |
| 1063 | + | "Co-authored-by: Ada Lovelace <ada@example.org>\nCo-authored-by: Cy <cy@example.org>", | |
| 1064 | + | g("log", "-1", "--format=%B", sq)) | |
| 1065 | + | assert.Contains(t, g("cat-file", "commit", sq), "\ngpgsig -----BEGIN SSH SIGNATURE-----\n") | |
| 1066 | + | ||
| 1067 | + | // Events: merged, then pushed for the target branch. | |
| 1068 | + | evs := r.drain() | |
| 1069 | + | require.Len(t, evs, 2) | |
| 1070 | + | assert.Equal(t, merged.MergedCommitID, evs[0].(domain.MergeRequestMerged).MergeRequest.MergedCommitID) | |
| 1071 | + | assert.Equal(t, []domain.RefUpdate{{Old: main.String(), New: sq, Ref: "refs/heads/main"}}, evs[1].(domain.PushEvent).Updates) | |
| 1072 | + | ||
| 1073 | + | // The signature verifies with an allowed_signers file built from the | |
| 1074 | + | // public key the server hands out. | |
| 1075 | + | r.verifyCommit(sq) | |
| 1076 | + | } | |
| 1077 | + | ||
| 1078 | + | // verifyCommit checks the signature of a commit of the repository with | |
| 1079 | + | // `git verify-commit` and an allowed signers file made from the signing key | |
| 1080 | + | // info, and that another key does not verify. | |
| 1081 | + | func (r *mrRig) verifyCommit(id string) { | |
| 1082 | + | r.t.Helper() | |
| 1083 | + | if _, err := exec.LookPath("ssh-keygen"); err != nil { | |
| 1084 | + | unusable(r.t, "ssh-keygen not found: install openssh to verify the signed commits (CI must have it)") | |
| 1085 | + | } | |
| 1086 | + | dir := r.t.TempDir() | |
| 1087 | + | signers := filepath.Join(dir, "allowed_signers") | |
| 1088 | + | require.NoError(r.t, os.WriteFile(signers, []byte(r.key.Info().AllowedSigners()+"\n"), 0o600)) | |
| 1089 | + | out := r.run(r.bare(), r.env(author), "-c", "gpg.format=ssh", "-c", "gpg.ssh.allowedSignersFile="+signers, "verify-commit", "--raw", id) | |
| 1090 | + | assert.Contains(r.t, out, "Good \"git\" signature for ermite@git.example.org") | |
| 1091 | + | ||
| 1092 | + | other, err := sshsig.New(bytes.Repeat([]byte{1}, ed25519.SeedSize)) | |
| 1093 | + | require.NoError(r.t, err) | |
| 1094 | + | wrong := filepath.Join(dir, "wrong_signers") | |
| 1095 | + | require.NoError(r.t, os.WriteFile(wrong, []byte("ermite@git.example.org "+other.PublicKey().AuthorizedKey+"\n"), 0o600)) | |
| 1096 | + | cmd := exec.Command("git", "-c", "gpg.format=ssh", "-c", "gpg.ssh.allowedSignersFile="+wrong, "verify-commit", id) | |
| 1097 | + | cmd.Dir, cmd.Env = r.bare(), r.env(author) | |
| 1098 | + | assert.Error(r.t, cmd.Run(), "another key must not verify the commit") | |
| 1099 | + | } | |
| 1100 | + | ||
| 1101 | + | func TestSquashSingleCommitHasNoTrailer(t *testing.T) { | |
| 1102 | + | r := newMRRig(t) | |
| 1103 | + | r.seed() | |
| 1104 | + | head := r.feature("solo") // two authors; make a one-author branch instead | |
| 1105 | + | _ = head | |
| 1106 | + | r.sh("checkout", "-q", "-B", "mine", "main") | |
| 1107 | + | r.commit(author, "m.txt", "m\n", "only") | |
| 1108 | + | r.push("mine") | |
| 1109 | + | mr := r.open("mine") | |
| 1110 | + | merged, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, app.MergeParams{HeadID: r.branchHead("mine"), Mode: domain.MergeModeSquash}) | |
| 1111 | + | require.NoError(t, err) | |
| 1112 | + | msg := r.run(r.bare(), r.env(author), "log", "-1", "--format=%B", merged.MergedCommitID.String()) | |
| 1113 | + | assert.Equal(t, "Add mine (!1)\n\n- only", msg) | |
| 1114 | + | r.verifyCommit(merged.MergedCommitID.String()) | |
| 1115 | + | } | |
| 1116 | + | ||
| 1117 | + | func TestUpdateRepoSettings(t *testing.T) { | |
| 1118 | + | r := newMRRig(t) | |
| 1119 | + | repo, err := r.repos.Get(r.ctx, root, r.repo) | |
| 1120 | + | require.NoError(t, err) | |
| 1121 | + | assert.Equal(t, domain.DefaultRepoSettings(), repo.Settings, "stored explicitly at creation") | |
| 1122 | + | ||
| 1123 | + | modes := []domain.MergeMode{domain.MergeModeSquash, domain.MergeModeSquash} | |
| 1124 | + | off := false | |
| 1125 | + | got, err := r.repos.Update(r.ctx, root, r.repo, app.UpdateParams{AllowedMergeModes: &modes, FailingChecksBlock: &off}) | |
| 1126 | + | require.NoError(t, err) | |
| 1127 | + | assert.Equal(t, domain.RepoSettings{AllowedMergeModes: []domain.MergeMode{domain.MergeModeSquash}, FailingChecksBlock: false}, got.Settings) | |
| 1128 | + | again, err := r.repos.Get(r.ctx, root, r.repo) | |
| 1129 | + | require.NoError(t, err) | |
| 1130 | + | assert.Equal(t, got.Settings, again.Settings) | |
| 1131 | + | ||
| 1132 | + | // Only the named setting changes. | |
| 1133 | + | on := true | |
| 1134 | + | got, err = r.repos.Update(r.ctx, root, r.repo, app.UpdateParams{FailingChecksBlock: &on}) | |
| 1135 | + | require.NoError(t, err) | |
| 1136 | + | assert.Equal(t, []domain.MergeMode{domain.MergeModeSquash}, got.Settings.AllowedMergeModes) | |
| 1137 | + | assert.True(t, got.Settings.FailingChecksBlock) | |
| 1138 | + | ||
| 1139 | + | for _, bad := range [][]domain.MergeMode{{}, nil, {"rebase"}} { | |
| 1140 | + | bad := bad | |
| 1141 | + | _, err = r.repos.Update(r.ctx, root, r.repo, app.UpdateParams{AllowedMergeModes: &bad}) | |
| 1142 | + | assert.ErrorIs(t, err, domain.ErrInvalid, "%v", bad) | |
| 1143 | + | } | |
| 1144 | + | again, err = r.repos.Get(r.ctx, root, r.repo) | |
| 1145 | + | require.NoError(t, err) | |
| 1146 | + | assert.Equal(t, got.Settings, again.Settings, "a refused change stores nothing") | |
| 1147 | + | ||
| 1148 | + | // repo:admin is needed, like for any other field. | |
| 1149 | + | _, err = r.repos.Update(r.ctx, writer(domain.ScopeRepoRead, domain.ScopeMRMerge), r.repo, app.UpdateParams{FailingChecksBlock: &off}) | |
| 1150 | + | assert.ErrorIs(t, err, domain.ErrForbidden) | |
| 1151 | + | } | |
| 1152 | + | ||
| 1153 | + | func TestDeletingARepositoryDropsItsMergeRequests(t *testing.T) { | |
| 1154 | + | r := newMRRig(t) | |
| 1155 | + | r.seed() | |
| 1156 | + | r.feature("feat") | |
| 1157 | + | r.open("feat") | |
| 1158 | + | require.NoError(t, r.repos.Delete(r.ctx, root, r.repo)) | |
| 1159 | + | ||
| 1160 | + | _, err := r.mrs.List(r.ctx, root, r.repo, app.ListMergeRequestsParams{}) | |
| 1161 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| 1162 | + | ||
| 1163 | + | // Created again under the same name, it starts empty. | |
| 1164 | + | _, err = r.repos.Create(r.ctx, root, app.CreateParams{Name: r.repo}) | |
| 1165 | + | require.NoError(t, err) | |
| 1166 | + | page, err := r.mrs.List(r.ctx, root, r.repo, app.ListMergeRequestsParams{}) | |
| 1167 | + | require.NoError(t, err) | |
| 1168 | + | assert.Empty(t, page.MergeRequests) | |
| 1169 | + | } | |
| 1170 | + | ||
| 1171 | + | func TestSigningKeyInfo(t *testing.T) { | |
| 1172 | + | r := newMRRig(t) | |
| 1173 | + | info := r.key.Info() | |
| 1174 | + | assert.True(t, strings.HasPrefix(info.Key.AuthorizedKey, "ssh-ed25519 ")) | |
| 1175 | + | assert.True(t, strings.HasPrefix(info.Key.Fingerprint, "SHA256:")) | |
| 1176 | + | assert.Equal(t, "ermite@git.example.org "+info.Key.AuthorizedKey, info.AllowedSigners()) | |
| 1177 | + | assert.Equal(t, domain.Identity{Name: "ermite", Email: "ermite@git.example.org"}, info.Committer) | |
| 1178 | + | } | |
| @@ -0,0 +1,1178 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package app_test | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "bytes" | |||
| 7 | + | "context" | |||
| 8 | + | "crypto/ed25519" | |||
| 9 | + | "fmt" | |||
| 10 | + | "os" | |||
| 11 | + | "os/exec" | |||
| 12 | + | "path/filepath" | |||
| 13 | + | "strings" | |||
| 14 | + | "sync" | |||
| 15 | + | "testing" | |||
| 16 | + | "time" | |||
| 17 | + | ||||
| 18 | + | "github.com/stretchr/testify/assert" | |||
| 19 | + | "github.com/stretchr/testify/require" | |||
| 20 | + | ||||
| 21 | + | "bnema.dev/ermite/internal/adapters/eventbus" | |||
| 22 | + | "bnema.dev/ermite/internal/adapters/gitcli" | |||
| 23 | + | "bnema.dev/ermite/internal/adapters/kvstore" | |||
| 24 | + | "bnema.dev/ermite/internal/adapters/sshsig" | |||
| 25 | + | "bnema.dev/ermite/internal/app" | |||
| 26 | + | "bnema.dev/ermite/internal/domain" | |||
| 27 | + | ) | |||
| 28 | + | ||||
| 29 | + | // mrRig runs the merge request use cases over the real adapters: a data | |||
| 30 | + | // directory with ermite-kv and bare repositories, and a work tree to push | |||
| 31 | + | // branches from. | |||
| 32 | + | type mrRig struct { | |||
| 33 | + | t *testing.T | |||
| 34 | + | ctx context.Context | |||
| 35 | + | data string | |||
| 36 | + | repos *app.Repos | |||
| 37 | + | mrs *app.MergeRequests | |||
| 38 | + | key *app.SigningKey | |||
| 39 | + | store *kvstore.Store | |||
| 40 | + | git *gitcli.Git | |||
| 41 | + | bus *eventbus.Bus | |||
| 42 | + | events <-chan domain.Event | |||
| 43 | + | work string | |||
| 44 | + | repo domain.RepoName | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | var ( | |||
| 48 | + | root = domain.Root() | |||
| 49 | + | author = domain.Identity{Name: "Ada Lovelace", Email: "ada@example.org"} | |||
| 50 | + | ) | |||
| 51 | + | ||||
| 52 | + | func newMRRig(t *testing.T) *mrRig { | |||
| 53 | + | t.Helper() | |||
| 54 | + | ctx := context.Background() | |||
| 55 | + | bin, err := exec.LookPath("git") | |||
| 56 | + | if err != nil { | |||
| 57 | + | unusable(t, "git not found") | |||
| 58 | + | } | |||
| 59 | + | data := t.TempDir() | |||
| 60 | + | for _, d := range []string{"repos/.staging", "trash", "run/git-home", "kv"} { | |||
| 61 | + | require.NoError(t, os.MkdirAll(filepath.Join(data, d), 0o700)) | |||
| 62 | + | } | |||
| 63 | + | git := gitcli.New(gitcli.Config{Bin: bin, DataDir: data, Path: os.Getenv("PATH")}) | |||
| 64 | + | if _, err := git.CheckVersion(ctx); err != nil { | |||
| 65 | + | unusable(t, "git is not usable by ermite: %v", err) | |||
| 66 | + | } | |||
| 67 | + | t.Cleanup(func() { _ = git.Close(context.Background()) }) | |||
| 68 | + | store, err := kvstore.Open(ctx, filepath.Join(data, "kv")) | |||
| 69 | + | require.NoError(t, err) | |||
| 70 | + | t.Cleanup(func() { require.NoError(t, store.Close(context.Background())) }) | |||
| 71 | + | ||||
| 72 | + | signer, err := sshsig.New(bytes.Repeat([]byte{9}, ed25519.SeedSize)) | |||
| 73 | + | require.NoError(t, err) | |||
| 74 | + | bus := eventbus.NewWithBuffer(1024) | |||
| 75 | + | committer := domain.Identity{Name: "ermite", Email: "ermite@git.example.org"} | |||
| 76 | + | r := &mrRig{ | |||
| 77 | + | t: t, ctx: ctx, data: data, store: store, git: git, bus: bus, events: bus.Subscribe(ctx), | |||
| 78 | + | repos: app.NewRepos(store, git, bus, fixedClock{}), | |||
| 79 | + | key: app.NewSigningKey(signer, committer), | |||
| 80 | + | work: t.TempDir(), | |||
| 81 | + | repo: name(t, "proj"), | |||
| 82 | + | } | |||
| 83 | + | r.mrs = app.NewMergeRequests(app.MergeRequestsConfig{ | |||
| 84 | + | Meta: store, Store: store.MergeRequests(), Repos: git, Git: git, Signer: signer, | |||
| 85 | + | Committer: committer, Bus: bus, Clock: fixedClock{}, | |||
| 86 | + | }) | |||
| 87 | + | _, err = r.repos.Create(ctx, root, app.CreateParams{Name: r.repo, Visibility: domain.VisibilityPrivate}) | |||
| 88 | + | require.NoError(t, err) | |||
| 89 | + | r.sh("init", "-q", "--initial-branch=main") | |||
| 90 | + | return r | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | func (r *mrRig) bare() string { return filepath.Join(r.data, "repos", r.repo.String()+".git") } | |||
| 94 | + | ||||
| 95 | + | func (r *mrRig) env(who domain.Identity, extra ...string) []string { | |||
| 96 | + | return append([]string{ | |||
| 97 | + | "PATH=" + os.Getenv("PATH"), "HOME=" + r.work, "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_NOSYSTEM=1", | |||
| 98 | + | "GIT_AUTHOR_NAME=" + who.Name, "GIT_AUTHOR_EMAIL=" + who.Email, | |||
| 99 | + | "GIT_COMMITTER_NAME=" + who.Name, "GIT_COMMITTER_EMAIL=" + who.Email, | |||
| 100 | + | "LC_ALL=C", | |||
| 101 | + | }, extra...) | |||
| 102 | + | } | |||
| 103 | + | ||||
| 104 | + | func (r *mrRig) run(dir string, env []string, args ...string) string { | |||
| 105 | + | r.t.Helper() | |||
| 106 | + | cmd := exec.Command("git", args...) | |||
| 107 | + | cmd.Dir, cmd.Env = dir, env | |||
| 108 | + | out, err := cmd.CombinedOutput() | |||
| 109 | + | require.NoError(r.t, err, "git %s\n%s", strings.Join(args, " "), out) | |||
| 110 | + | return strings.TrimSpace(string(out)) | |||
| 111 | + | } | |||
| 112 | + | ||||
| 113 | + | // sh runs git in the work tree as the default author. | |||
| 114 | + | func (r *mrRig) sh(args ...string) string { return r.run(r.work, r.env(author), args...) } | |||
| 115 | + | ||||
| 116 | + | // commit writes a file and commits it as who; it returns the commit id. | |||
| 117 | + | func (r *mrRig) commit(who domain.Identity, path, content, msg string) domain.ObjectID { | |||
| 118 | + | r.t.Helper() | |||
| 119 | + | full := filepath.Join(r.work, path) | |||
| 120 | + | require.NoError(r.t, os.MkdirAll(filepath.Dir(full), 0o755)) | |||
| 121 | + | require.NoError(r.t, os.WriteFile(full, []byte(content), 0o644)) | |||
| 122 | + | r.run(r.work, r.env(who), "add", path) | |||
| 123 | + | r.run(r.work, r.env(who), "commit", "-q", "-m", msg) | |||
| 124 | + | return r.head() | |||
| 125 | + | } | |||
| 126 | + | ||||
| 127 | + | func (r *mrRig) head() domain.ObjectID { | |||
| 128 | + | r.t.Helper() | |||
| 129 | + | return oidFrom(r.t, r.sh("rev-parse", "HEAD")) | |||
| 130 | + | } | |||
| 131 | + | ||||
| 132 | + | func oidFrom(t *testing.T, s string) domain.ObjectID { | |||
| 133 | + | t.Helper() | |||
| 134 | + | id, err := domain.ParseObjectID(s) | |||
| 135 | + | require.NoError(t, err) | |||
| 136 | + | return id | |||
| 137 | + | } | |||
| 138 | + | ||||
| 139 | + | // push pushes the branch of the work tree to the repository, forcing it. | |||
| 140 | + | func (r *mrRig) push(branch string) { | |||
| 141 | + | r.t.Helper() | |||
| 142 | + | r.sh("push", "-q", "--force", r.bare(), branch+":refs/heads/"+branch) | |||
| 143 | + | } | |||
| 144 | + | ||||
| 145 | + | // branchHead reads a branch of the bare repository. | |||
| 146 | + | func (r *mrRig) branchHead(branch string) domain.ObjectID { | |||
| 147 | + | r.t.Helper() | |||
| 148 | + | return oidFrom(r.t, r.run(r.bare(), r.env(author), "rev-parse", "refs/heads/"+branch)) | |||
| 149 | + | } | |||
| 150 | + | ||||
| 151 | + | // seed pushes main with two files and returns its id. | |||
| 152 | + | func (r *mrRig) seed() domain.ObjectID { | |||
| 153 | + | r.t.Helper() | |||
| 154 | + | r.commit(author, "a.txt", "one\ntwo\nthree\n", "add a") | |||
| 155 | + | id := r.commit(author, "b.txt", "bee\n", "add b") | |||
| 156 | + | r.push("main") | |||
| 157 | + | return id | |||
| 158 | + | } | |||
| 159 | + | ||||
| 160 | + | // feature creates the branch feat from main with two commits by two authors | |||
| 161 | + | // and pushes it; it returns its head. | |||
| 162 | + | func (r *mrRig) feature(branch string) domain.ObjectID { | |||
| 163 | + | r.t.Helper() | |||
| 164 | + | r.sh("checkout", "-q", "-B", branch, "main") | |||
| 165 | + | r.commit(author, "a.txt", "one\n2\nthree\n", "change a") | |||
| 166 | + | bob := domain.Identity{Name: "Bob", Email: "bob@example.org"} | |||
| 167 | + | id := r.commit(bob, "c.txt", "see\n", "add c") | |||
| 168 | + | r.push(branch) | |||
| 169 | + | return id | |||
| 170 | + | } | |||
| 171 | + | ||||
| 172 | + | func (r *mrRig) open(branch string) domain.MergeRequest { | |||
| 173 | + | r.t.Helper() | |||
| 174 | + | mr, err := r.mrs.Create(r.ctx, root, r.repo, app.CreateMergeRequestParams{Title: "Add " + branch, Source: branch}) | |||
| 175 | + | require.NoError(r.t, err) | |||
| 176 | + | return mr | |||
| 177 | + | } | |||
| 178 | + | ||||
| 179 | + | // drain returns the events published so far. | |||
| 180 | + | func (r *mrRig) drain() []domain.Event { | |||
| 181 | + | var out []domain.Event | |||
| 182 | + | for { | |||
| 183 | + | select { | |||
| 184 | + | case ev := <-r.events: | |||
| 185 | + | out = append(out, ev) | |||
| 186 | + | case <-time.After(20 * time.Millisecond): | |||
| 187 | + | return out | |||
| 188 | + | } | |||
| 189 | + | } | |||
| 190 | + | } | |||
| 191 | + | ||||
| 192 | + | func writer(scopes ...domain.Scope) domain.Principal { | |||
| 193 | + | return domain.TokenPrincipal(domain.Token{ID: "t", Label: "agent-1", Scopes: scopes}) | |||
| 194 | + | } | |||
| 195 | + | ||||
| 196 | + | var ( | |||
| 197 | + | reader = writer(domain.ScopeRepoRead) | |||
| 198 | + | mrW = writer(domain.ScopeRepoRead, domain.ScopeMRWrite) | |||
| 199 | + | mrM = writer(domain.ScopeRepoRead, domain.ScopeMRWrite, domain.ScopeMRMerge) | |||
| 200 | + | ) | |||
| 201 | + | ||||
| 202 | + | func fieldsOf(t *testing.T, err error) map[string]string { | |||
| 203 | + | t.Helper() | |||
| 204 | + | var fe *domain.InvalidFieldsError | |||
| 205 | + | require.ErrorAs(t, err, &fe, "want a rejection naming the fields") | |||
| 206 | + | out := map[string]string{} | |||
| 207 | + | for _, f := range fe.Fields { | |||
| 208 | + | out[f.Field] = f.Reason | |||
| 209 | + | } | |||
| 210 | + | return out | |||
| 211 | + | } | |||
| 212 | + | ||||
| 213 | + | // AC3: the input rules of ADR 0017. | |||
| 214 | + | func TestCreateMergeRequestInputRules(t *testing.T) { | |||
| 215 | + | r := newMRRig(t) | |||
| 216 | + | r.seed() | |||
| 217 | + | r.feature("feat") | |||
| 218 | + | r.sh("checkout", "-q", "-B", "other", "main") | |||
| 219 | + | r.commit(author, "o.txt", "o\n", "other") | |||
| 220 | + | r.push("other") | |||
| 221 | + | ||||
| 222 | + | tests := []struct { | |||
| 223 | + | name string | |||
| 224 | + | params app.CreateMergeRequestParams | |||
| 225 | + | field string // "" for valid | |||
| 226 | + | }{ | |||
| 227 | + | {"valid, default target", app.CreateMergeRequestParams{Title: "t", Source: "feat"}, ""}, | |||
| 228 | + | {"valid, explicit target and description", app.CreateMergeRequestParams{Title: "t", Description: "a\nb", Source: "other", Target: "main"}, ""}, | |||
| 229 | + | {"empty title", app.CreateMergeRequestParams{Title: "", Source: "feat", Target: "other"}, "title"}, | |||
| 230 | + | {"two-line title", app.CreateMergeRequestParams{Title: "a\nb", Source: "feat", Target: "other"}, "title"}, | |||
| 231 | + | {"title too long", app.CreateMergeRequestParams{Title: strings.Repeat("x", 121), Source: "feat", Target: "other"}, "title"}, | |||
| 232 | + | {"title with a bidi override", app.CreateMergeRequestParams{Title: "a\u202eb", Source: "feat", Target: "other"}, "title"}, | |||
| 233 | + | {"title with an escape", app.CreateMergeRequestParams{Title: "\x1b[31m", Source: "feat", Target: "other"}, "title"}, | |||
| 234 | + | {"description with a control character", app.CreateMergeRequestParams{Title: "t", Description: "a\x07", Source: "feat", Target: "other"}, "description"}, | |||
| 235 | + | {"description with a bidi isolate", app.CreateMergeRequestParams{Title: "t", Description: "a\u2066b", Source: "feat", Target: "other"}, "description"}, | |||
| 236 | + | {"description too long", app.CreateMergeRequestParams{Title: "t", Description: strings.Repeat("x", 8001), Source: "feat", Target: "other"}, "description"}, | |||
| 237 | + | {"description not utf-8", app.CreateMergeRequestParams{Title: "t", Description: "\xff", Source: "feat", Target: "other"}, "description"}, | |||
| 238 | + | {"source missing", app.CreateMergeRequestParams{Title: "t", Source: ""}, "source_branch"}, | |||
| 239 | + | {"source does not exist", app.CreateMergeRequestParams{Title: "t", Source: "nope", Target: "main"}, "source_branch"}, | |||
| 240 | + | {"source is not a branch name", app.CreateMergeRequestParams{Title: "t", Source: "a..b", Target: "main"}, "source_branch"}, | |||
| 241 | + | {"source is a full ref", app.CreateMergeRequestParams{Title: "t", Source: "refs/heads/feat", Target: "main"}, "source_branch"}, | |||
| 242 | + | {"target does not exist", app.CreateMergeRequestParams{Title: "t", Source: "feat", Target: "nope"}, "target_branch"}, | |||
| 243 | + | {"same branch", app.CreateMergeRequestParams{Title: "t", Source: "feat", Target: "feat"}, "target_branch"}, | |||
| 244 | + | } | |||
| 245 | + | for _, tc := range tests { | |||
| 246 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 247 | + | mr, err := r.mrs.Create(r.ctx, root, r.repo, tc.params) | |||
| 248 | + | if tc.field == "" { | |||
| 249 | + | require.NoError(t, err) | |||
| 250 | + | assert.Equal(t, domain.MRStateOpen, mr.State) | |||
| 251 | + | assert.Equal(t, "root", mr.Author) | |||
| 252 | + | assert.Equal(t, "main", mr.Target) | |||
| 253 | + | assert.NotZero(t, mr.HeadID) | |||
| 254 | + | return | |||
| 255 | + | } | |||
| 256 | + | require.ErrorIs(t, err, domain.ErrInvalid) | |||
| 257 | + | assert.Contains(t, fieldsOf(t, err), tc.field) | |||
| 258 | + | }) | |||
| 259 | + | } | |||
| 260 | + | ||||
| 261 | + | t.Run("a request is rejected whole with every field named", func(t *testing.T) { | |||
| 262 | + | _, err := r.mrs.Create(r.ctx, root, r.repo, app.CreateMergeRequestParams{Title: "a\nb", Description: "\x07", Source: "nope", Target: "feat"}) | |||
| 263 | + | got := fieldsOf(t, err) | |||
| 264 | + | assert.Contains(t, got, "title") | |||
| 265 | + | assert.Contains(t, got, "description") | |||
| 266 | + | assert.Contains(t, got, "source_branch") | |||
| 267 | + | }) | |||
| 268 | + | } | |||
| 269 | + | ||||
| 270 | + | func TestCreateMergeRequestNumbersAndDuplicates(t *testing.T) { | |||
| 271 | + | r := newMRRig(t) | |||
| 272 | + | r.seed() | |||
| 273 | + | r.feature("one") | |||
| 274 | + | r.feature("two") | |||
| 275 | + | ||||
| 276 | + | first := r.open("one") | |||
| 277 | + | assert.Equal(t, int64(1), first.Number) | |||
| 278 | + | second := r.open("two") | |||
| 279 | + | assert.Equal(t, int64(2), second.Number) | |||
| 280 | + | ||||
| 281 | + | _, err := r.mrs.Create(r.ctx, root, r.repo, app.CreateMergeRequestParams{Title: "again", Source: "one", Target: "main"}) | |||
| 282 | + | require.ErrorIs(t, err, domain.ErrAlreadyExists, "same open source and target") | |||
| 283 | + | _, err = r.mrs.Create(r.ctx, root, r.repo, app.CreateMergeRequestParams{Title: "again", Source: "one"}) | |||
| 284 | + | require.ErrorIs(t, err, domain.ErrAlreadyExists, "the default target is the same target") | |||
| 285 | + | ||||
| 286 | + | // Another target is another request. | |||
| 287 | + | r.sh("checkout", "-q", "-B", "stage", "main") | |||
| 288 | + | r.push("stage") | |||
| 289 | + | third, err := r.mrs.Create(r.ctx, root, r.repo, app.CreateMergeRequestParams{Title: "to stage", Source: "one", Target: "stage"}) | |||
| 290 | + | require.NoError(t, err) | |||
| 291 | + | assert.Equal(t, int64(3), third.Number) | |||
| 292 | + | ||||
| 293 | + | // Once the first is closed, the pair is free again, with a new number. | |||
| 294 | + | _, err = r.mrs.Close(r.ctx, root, r.repo, first.Number) | |||
| 295 | + | require.NoError(t, err) | |||
| 296 | + | fourth, err := r.mrs.Create(r.ctx, root, r.repo, app.CreateMergeRequestParams{Title: "retry", Source: "one"}) | |||
| 297 | + | require.NoError(t, err) | |||
| 298 | + | assert.Equal(t, int64(4), fourth.Number, "numbers are never reused") | |||
| 299 | + | } | |||
| 300 | + | ||||
| 301 | + | func TestMergeRequestAccess(t *testing.T) { | |||
| 302 | + | r := newMRRig(t) // proj is private | |||
| 303 | + | r.seed() | |||
| 304 | + | r.feature("feat") | |||
| 305 | + | mr := r.open("feat") | |||
| 306 | + | n := mr.Number | |||
| 307 | + | noScope := writer(domain.ScopeRepoWrite) | |||
| 308 | + | otherRepo := domain.TokenPrincipal(domain.Token{ID: "o", Scopes: []domain.Scope{domain.ScopeRepoRead, domain.ScopeMRWrite, domain.ScopeMRMerge}, Repos: []domain.RepoName{name(t, "else")}}) | |||
| 309 | + | ||||
| 310 | + | reads := map[string]func(p domain.Principal) error{ | |||
| 311 | + | "List": func(p domain.Principal) error { | |||
| 312 | + | _, err := r.mrs.List(r.ctx, p, r.repo, app.ListMergeRequestsParams{}) | |||
| 313 | + | return err | |||
| 314 | + | }, | |||
| 315 | + | "Get": func(p domain.Principal) error { | |||
| 316 | + | _, err := r.mrs.Get(r.ctx, p, r.repo, n, false) | |||
| 317 | + | return err | |||
| 318 | + | }, | |||
| 319 | + | } | |||
| 320 | + | for what, call := range reads { | |||
| 321 | + | for who, p := range map[string]domain.Principal{"anonymous": domain.Anonymous(), "no repo:read": noScope, "token of another repo": otherRepo} { | |||
| 322 | + | assert.ErrorIs(t, call(p), domain.ErrNotFound, "%s by %s on a private repository", what, who) | |||
| 323 | + | } | |||
| 324 | + | assert.NoError(t, call(reader), what+" with repo:read") | |||
| 325 | + | assert.NoError(t, call(root), what+" by root") | |||
| 326 | + | } | |||
| 327 | + | _, err := r.mrs.Get(r.ctx, reader, r.repo, n+1, false) | |||
| 328 | + | assert.ErrorIs(t, err, domain.ErrNotFound, "unknown number") | |||
| 329 | + | _, err = r.mrs.List(r.ctx, root, name(t, "ghost"), app.ListMergeRequestsParams{}) | |||
| 330 | + | assert.ErrorIs(t, err, domain.ErrNotFound, "unknown repository") | |||
| 331 | + | ||||
| 332 | + | writes := map[string]func(p domain.Principal) error{ | |||
| 333 | + | "Create": func(p domain.Principal) error { | |||
| 334 | + | _, err := r.mrs.Create(r.ctx, p, r.repo, app.CreateMergeRequestParams{Title: "t", Source: "feat", Target: "feat"}) | |||
| 335 | + | return err | |||
| 336 | + | }, | |||
| 337 | + | "AddComment": func(p domain.Principal) error { | |||
| 338 | + | _, err := r.mrs.AddComment(r.ctx, p, r.repo, n, app.AddCommentParams{Text: "x"}) | |||
| 339 | + | return err | |||
| 340 | + | }, | |||
| 341 | + | "ResolveComment": func(p domain.Principal) error { | |||
| 342 | + | _, err := r.mrs.ResolveComment(r.ctx, p, r.repo, n, 1, true) | |||
| 343 | + | return err | |||
| 344 | + | }, | |||
| 345 | + | "Close": func(p domain.Principal) error { | |||
| 346 | + | _, err := r.mrs.Close(r.ctx, p, r.repo, n+100) | |||
| 347 | + | return err | |||
| 348 | + | }, | |||
| 349 | + | "MarkRead": func(p domain.Principal) error { | |||
| 350 | + | _, err := r.mrs.MarkRead(r.ctx, p, r.repo, n, domain.ObjectID{}) | |||
| 351 | + | return err | |||
| 352 | + | }, | |||
| 353 | + | "Merge": func(p domain.Principal) error { | |||
| 354 | + | _, err := r.mrs.Merge(r.ctx, p, r.repo, n+100, app.MergeParams{HeadID: mr.HeadID}) | |||
| 355 | + | return err | |||
| 356 | + | }, | |||
| 357 | + | } | |||
| 358 | + | for what, call := range writes { | |||
| 359 | + | scope := domain.ScopeMRWrite | |||
| 360 | + | if what == "Merge" { | |||
| 361 | + | scope = domain.ScopeMRMerge | |||
| 362 | + | } | |||
| 363 | + | assert.ErrorIs(t, call(domain.Anonymous()), domain.ErrNotFound, "%s: anonymous cannot see the private repository", what) | |||
| 364 | + | assert.ErrorIs(t, call(noScope), domain.ErrNotFound, "%s: no repo:read", what) | |||
| 365 | + | assert.ErrorIs(t, call(otherRepo), domain.ErrNotFound, "%s: another repository's token", what) | |||
| 366 | + | assert.ErrorIs(t, call(reader), domain.ErrForbidden, "%s: reading is not writing", what) | |||
| 367 | + | if scope == domain.ScopeMRMerge { | |||
| 368 | + | assert.ErrorIs(t, call(mrW), domain.ErrForbidden, "mr:write does not merge") | |||
| 369 | + | continue | |||
| 370 | + | } | |||
| 371 | + | // With the scope the call gets past access (and fails on its subject). | |||
| 372 | + | assert.NotErrorIs(t, call(mrW), domain.ErrForbidden, what) | |||
| 373 | + | assert.NotErrorIs(t, call(mrW), domain.ErrUnauthenticated, what) | |||
| 374 | + | } | |||
| 375 | + | ||||
| 376 | + | // On a public repository an anonymous caller reads, but writing is | |||
| 377 | + | // unauthenticated rather than not found. | |||
| 378 | + | pubName := name(t, "pub") | |||
| 379 | + | _, err = r.repos.Create(r.ctx, root, app.CreateParams{Name: pubName, Visibility: domain.VisibilityPublic}) | |||
| 380 | + | require.NoError(t, err) | |||
| 381 | + | _, err = r.mrs.List(r.ctx, domain.Anonymous(), pubName, app.ListMergeRequestsParams{}) | |||
| 382 | + | assert.NoError(t, err) | |||
| 383 | + | _, err = r.mrs.Create(r.ctx, domain.Anonymous(), pubName, app.CreateMergeRequestParams{Title: "t", Source: "a", Target: "b"}) | |||
| 384 | + | assert.ErrorIs(t, err, domain.ErrUnauthenticated) | |||
| 385 | + | } | |||
| 386 | + | ||||
| 387 | + | func TestListMergeRequests(t *testing.T) { | |||
| 388 | + | r := newMRRig(t) | |||
| 389 | + | r.seed() | |||
| 390 | + | var nums []int64 | |||
| 391 | + | for i := range 5 { | |||
| 392 | + | b := fmt.Sprintf("f%d", i) | |||
| 393 | + | r.feature(b) | |||
| 394 | + | nums = append(nums, r.open(b).Number) | |||
| 395 | + | } | |||
| 396 | + | _, err := r.mrs.Close(r.ctx, root, r.repo, 2) | |||
| 397 | + | require.NoError(t, err) | |||
| 398 | + | r.sh("checkout", "-q", "main") | |||
| 399 | + | merged := r.feature("f0") | |||
| 400 | + | _, err = r.mrs.Merge(r.ctx, root, r.repo, 1, app.MergeParams{HeadID: merged}) | |||
| 401 | + | require.NoError(t, err) | |||
| 402 | + | r.sh("branch", "-D", "f4") | |||
| 403 | + | r.run(r.bare(), r.env(author), "branch", "-D", "f4") | |||
| 404 | + | ||||
| 405 | + | numbers := func(p app.MergeRequestPage) []int64 { | |||
| 406 | + | var out []int64 | |||
| 407 | + | for _, m := range p.MergeRequests { | |||
| 408 | + | out = append(out, m.Number) | |||
| 409 | + | } | |||
| 410 | + | return out | |||
| 411 | + | } | |||
| 412 | + | all, err := r.mrs.List(r.ctx, root, r.repo, app.ListMergeRequestsParams{}) | |||
| 413 | + | require.NoError(t, err) | |||
| 414 | + | assert.Equal(t, []int64{5, 4, 3, 2, 1}, numbers(all), "highest number first") | |||
| 415 | + | assert.Empty(t, all.NextPageToken) | |||
| 416 | + | ||||
| 417 | + | open, err := r.mrs.List(r.ctx, root, r.repo, app.ListMergeRequestsParams{State: domain.MRStateOpen}) | |||
| 418 | + | require.NoError(t, err) | |||
| 419 | + | assert.Equal(t, []int64{5, 4, 3}, numbers(open)) | |||
| 420 | + | for _, m := range open.MergeRequests { | |||
| 421 | + | if m.Number == 5 { | |||
| 422 | + | assert.True(t, m.HeadID.IsZero(), "the branch of !5 was deleted") | |||
| 423 | + | } else { | |||
| 424 | + | assert.False(t, m.HeadID.IsZero(), "an open request carries its head") | |||
| 425 | + | } | |||
| 426 | + | } | |||
| 427 | + | closed, err := r.mrs.List(r.ctx, root, r.repo, app.ListMergeRequestsParams{State: domain.MRStateClosed}) | |||
| 428 | + | require.NoError(t, err) | |||
| 429 | + | assert.Equal(t, []int64{2}, numbers(closed)) | |||
| 430 | + | mergedPage, err := r.mrs.List(r.ctx, root, r.repo, app.ListMergeRequestsParams{State: domain.MRStateMerged}) | |||
| 431 | + | require.NoError(t, err) | |||
| 432 | + | assert.Equal(t, []int64{1}, numbers(mergedPage)) | |||
| 433 | + | assert.Equal(t, merged, mergedPage.MergeRequests[0].HeadID, "a merged request keeps the head it had") | |||
| 434 | + | ||||
| 435 | + | // Paging: the token names the last number. | |||
| 436 | + | p1, err := r.mrs.List(r.ctx, root, r.repo, app.ListMergeRequestsParams{PageSize: 2}) | |||
| 437 | + | require.NoError(t, err) | |||
| 438 | + | assert.Equal(t, []int64{5, 4}, numbers(p1)) | |||
| 439 | + | require.NotEmpty(t, p1.NextPageToken) | |||
| 440 | + | // A request created between the pages does not shift them. | |||
| 441 | + | r.feature("late") | |||
| 442 | + | r.open("late") | |||
| 443 | + | p2, err := r.mrs.List(r.ctx, root, r.repo, app.ListMergeRequestsParams{PageSize: 2, PageToken: p1.NextPageToken}) | |||
| 444 | + | require.NoError(t, err) | |||
| 445 | + | assert.Equal(t, []int64{3, 2}, numbers(p2)) | |||
| 446 | + | p3, err := r.mrs.List(r.ctx, root, r.repo, app.ListMergeRequestsParams{PageSize: 2, PageToken: p2.NextPageToken}) | |||
| 447 | + | require.NoError(t, err) | |||
| 448 | + | assert.Equal(t, []int64{1}, numbers(p3)) | |||
| 449 | + | assert.Empty(t, p3.NextPageToken) | |||
| 450 | + | ||||
| 451 | + | for _, tok := range []string{"garbage", "bW9yZQ"} { | |||
| 452 | + | _, err = r.mrs.List(r.ctx, root, r.repo, app.ListMergeRequestsParams{PageToken: tok}) | |||
| 453 | + | assert.ErrorIs(t, err, domain.ErrInvalidPageToken) | |||
| 454 | + | } | |||
| 455 | + | _, err = r.mrs.List(r.ctx, root, r.repo, app.ListMergeRequestsParams{PageSize: -1}) | |||
| 456 | + | assert.ErrorIs(t, err, domain.ErrInvalidPageSize) | |||
| 457 | + | _ = nums | |||
| 458 | + | } | |||
| 459 | + | ||||
| 460 | + | func TestGetMergeRequest(t *testing.T) { | |||
| 461 | + | r := newMRRig(t) | |||
| 462 | + | main := r.seed() | |||
| 463 | + | head := r.feature("feat") | |||
| 464 | + | mr := r.open("feat") | |||
| 465 | + | _, err := r.mrs.AddComment(r.ctx, root, r.repo, mr.Number, app.AddCommentParams{Text: "first"}) | |||
| 466 | + | require.NoError(t, err) | |||
| 467 | + | _, err = r.mrs.AddComment(r.ctx, root, r.repo, mr.Number, app.AddCommentParams{Text: "second"}) | |||
| 468 | + | require.NoError(t, err) | |||
| 469 | + | ||||
| 470 | + | d, err := r.mrs.Get(r.ctx, reader, r.repo, mr.Number, false) | |||
| 471 | + | require.NoError(t, err) | |||
| 472 | + | assert.Equal(t, head, d.MergeRequest.HeadID) | |||
| 473 | + | assert.Equal(t, main, d.DiffBaseID, "the base is the merge base of the target and the head") | |||
| 474 | + | require.Len(t, d.Commits, 2) | |||
| 475 | + | assert.Equal(t, "add c", d.Commits[0].Summary, "newest first") | |||
| 476 | + | assert.False(t, d.CommitsTruncated) | |||
| 477 | + | assert.Equal(t, domain.DiffStats{FilesChanged: 2, Additions: 2, Deletions: 1}, d.Stats) | |||
| 478 | + | require.Len(t, d.Diff.Files, 2) | |||
| 479 | + | require.Len(t, d.Comments, 2) | |||
| 480 | + | assert.Equal(t, "first", d.Comments[0].Text.String(), "oldest first") | |||
| 481 | + | assert.True(t, d.LastReadID.IsZero()) | |||
| 482 | + | assert.False(t, d.LastReadLost) | |||
| 483 | + | ||||
| 484 | + | // The target moved on: the base stays the merge base, not the new tip. | |||
| 485 | + | r.sh("checkout", "-q", "main") | |||
| 486 | + | r.commit(author, "later.txt", "x\n", "later on main") | |||
| 487 | + | r.push("main") | |||
| 488 | + | d, err = r.mrs.Get(r.ctx, reader, r.repo, mr.Number, false) | |||
| 489 | + | require.NoError(t, err) | |||
| 490 | + | assert.Equal(t, main, d.DiffBaseID) | |||
| 491 | + | assert.Len(t, d.Commits, 2, "the commits that main lacks") | |||
| 492 | + | ||||
| 493 | + | // A deleted source branch: no commits, no diff, an empty head. | |||
| 494 | + | r.run(r.bare(), r.env(author), "branch", "-D", "feat") | |||
| 495 | + | d, err = r.mrs.Get(r.ctx, reader, r.repo, mr.Number, false) | |||
| 496 | + | require.NoError(t, err) | |||
| 497 | + | assert.True(t, d.MergeRequest.HeadID.IsZero()) | |||
| 498 | + | assert.Empty(t, d.Commits) | |||
| 499 | + | assert.True(t, d.DiffBaseID.IsZero()) | |||
| 500 | + | assert.Empty(t, d.Diff.Files) | |||
| 501 | + | assert.Len(t, d.Comments, 2) | |||
| 502 | + | } | |||
| 503 | + | ||||
| 504 | + | func TestGetMergeRequestTruncatesCommits(t *testing.T) { | |||
| 505 | + | r := newMRRig(t) | |||
| 506 | + | r.seed() | |||
| 507 | + | r.sh("checkout", "-q", "-B", "long", "main") | |||
| 508 | + | for i := range app.MaxMergeRequestCommits + 5 { | |||
| 509 | + | r.sh("commit", "-q", "--allow-empty", "-m", fmt.Sprintf("c%d", i)) | |||
| 510 | + | } | |||
| 511 | + | r.push("long") | |||
| 512 | + | mr := r.open("long") | |||
| 513 | + | d, err := r.mrs.Get(r.ctx, root, r.repo, mr.Number, false) | |||
| 514 | + | require.NoError(t, err) | |||
| 515 | + | assert.Len(t, d.Commits, app.MaxMergeRequestCommits) | |||
| 516 | + | assert.True(t, d.CommitsTruncated) | |||
| 517 | + | assert.Equal(t, "c104", d.Commits[0].Summary) | |||
| 518 | + | } | |||
| 519 | + | ||||
| 520 | + | // AC3: a comment on a path:line must be a line of the current diff. | |||
| 521 | + | func TestAddComment(t *testing.T) { | |||
| 522 | + | r := newMRRig(t) | |||
| 523 | + | r.seed() | |||
| 524 | + | r.feature("feat") | |||
| 525 | + | mr := r.open("feat") | |||
| 526 | + | n := mr.Number | |||
| 527 | + | // The diff: a.txt line 2 changed (old "two", new "2"); c.txt added, line 1. | |||
| 528 | + | ||||
| 529 | + | tests := []struct { | |||
| 530 | + | name string | |||
| 531 | + | params app.AddCommentParams | |||
| 532 | + | field string // "" for valid | |||
| 533 | + | }{ | |||
| 534 | + | {"general", app.AddCommentParams{Text: "looks good"}, ""}, | |||
| 535 | + | {"multi-line general", app.AddCommentParams{Text: "a\n\nb"}, ""}, | |||
| 536 | + | {"new side of a changed line", app.AddCommentParams{Path: "a.txt", Line: 2, Side: domain.SideNew, Text: "x"}, ""}, | |||
| 537 | + | {"old side of a changed line", app.AddCommentParams{Path: "a.txt", Line: 2, Side: domain.SideOld, Text: "x"}, ""}, | |||
| 538 | + | {"context line", app.AddCommentParams{Path: "a.txt", Line: 3, Side: domain.SideNew, Text: "x"}, ""}, | |||
| 539 | + | {"added file", app.AddCommentParams{Path: "c.txt", Line: 1, Side: domain.SideNew, Text: "x"}, ""}, | |||
| 540 | + | {"empty text", app.AddCommentParams{Text: ""}, "text"}, | |||
| 541 | + | {"blank text", app.AddCommentParams{Text: " \n "}, "text"}, | |||
| 542 | + | {"text too long", app.AddCommentParams{Text: strings.Repeat("x", 8001)}, "text"}, | |||
| 543 | + | {"text with a control character", app.AddCommentParams{Text: "a\x1b[0m"}, "text"}, | |||
| 544 | + | {"text with a tab", app.AddCommentParams{Text: "a\tb"}, "text"}, | |||
| 545 | + | {"text with a bidi override", app.AddCommentParams{Text: "a\u202eb"}, "text"}, | |||
| 546 | + | {"line outside the diff", app.AddCommentParams{Path: "a.txt", Line: 40, Side: domain.SideNew, Text: "x"}, "path"}, | |||
| 547 | + | {"unchanged file", app.AddCommentParams{Path: "b.txt", Line: 1, Side: domain.SideNew, Text: "x"}, "path"}, | |||
| 548 | + | {"unknown path", app.AddCommentParams{Path: "nope.txt", Line: 1, Side: domain.SideNew, Text: "x"}, "path"}, | |||
| 549 | + | {"deleted line on the wrong side", app.AddCommentParams{Path: "c.txt", Line: 1, Side: domain.SideOld, Text: "x"}, "path"}, | |||
| 550 | + | {"path without a line", app.AddCommentParams{Path: "a.txt", Side: domain.SideNew, Text: "x"}, "line"}, | |||
| 551 | + | {"path without a side", app.AddCommentParams{Path: "a.txt", Line: 2, Text: "x"}, "side"}, | |||
| 552 | + | {"line without a path", app.AddCommentParams{Line: 2, Side: domain.SideNew, Text: "x"}, "path"}, | |||
| 553 | + | {"side without a path", app.AddCommentParams{Side: domain.SideOld, Text: "x"}, "path"}, | |||
| 554 | + | {"negative line", app.AddCommentParams{Path: "a.txt", Line: -2, Side: domain.SideNew, Text: "x"}, "line"}, | |||
| 555 | + | } | |||
| 556 | + | for _, tc := range tests { | |||
| 557 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 558 | + | c, err := r.mrs.AddComment(r.ctx, mrW, r.repo, n, tc.params) | |||
| 559 | + | if tc.field == "" { | |||
| 560 | + | require.NoError(t, err) | |||
| 561 | + | assert.Equal(t, "agent-1", c.Author) | |||
| 562 | + | assert.Equal(t, tc.params.Path, c.Path) | |||
| 563 | + | assert.Equal(t, tc.params.Line, c.Line) | |||
| 564 | + | assert.Equal(t, tc.params.Side, c.Side) | |||
| 565 | + | assert.Equal(t, mr.HeadID, c.CommitID, "the head it was written against") | |||
| 566 | + | assert.False(t, c.Resolved) | |||
| 567 | + | return | |||
| 568 | + | } | |||
| 569 | + | require.ErrorIs(t, err, domain.ErrInvalid) | |||
| 570 | + | assert.Contains(t, fieldsOf(t, err), tc.field) | |||
| 571 | + | }) | |||
| 572 | + | } | |||
| 573 | + | ||||
| 574 | + | // Ids count 1, 2, ... in order of creation and only the valid ones are kept. | |||
| 575 | + | d, err := r.mrs.Get(r.ctx, root, r.repo, n, false) | |||
| 576 | + | require.NoError(t, err) | |||
| 577 | + | var kept int | |||
| 578 | + | for _, tc := range tests { | |||
| 579 | + | if tc.field == "" { | |||
| 580 | + | kept++ | |||
| 581 | + | } | |||
| 582 | + | } | |||
| 583 | + | require.Len(t, d.Comments, kept) | |||
| 584 | + | for i, c := range d.Comments { | |||
| 585 | + | assert.Equal(t, int64(i+1), c.ID) | |||
| 586 | + | } | |||
| 587 | + | ||||
| 588 | + | t.Run("not open", func(t *testing.T) { | |||
| 589 | + | _, err := r.mrs.Close(r.ctx, mrW, r.repo, n) | |||
| 590 | + | require.NoError(t, err) | |||
| 591 | + | _, err = r.mrs.AddComment(r.ctx, mrW, r.repo, n, app.AddCommentParams{Text: "late"}) | |||
| 592 | + | assert.ErrorIs(t, err, domain.ErrPreconditionFailed) | |||
| 593 | + | _, err = r.mrs.ResolveComment(r.ctx, mrW, r.repo, n, 1, true) | |||
| 594 | + | assert.ErrorIs(t, err, domain.ErrPreconditionFailed) | |||
| 595 | + | }) | |||
| 596 | + | } | |||
| 597 | + | ||||
| 598 | + | func TestConcurrentComments(t *testing.T) { | |||
| 599 | + | r := newMRRig(t) | |||
| 600 | + | r.seed() | |||
| 601 | + | r.feature("feat") | |||
| 602 | + | mr := r.open("feat") | |||
| 603 | + | const workers = 10 | |||
| 604 | + | var wg sync.WaitGroup | |||
| 605 | + | ids := make(chan int64, workers) | |||
| 606 | + | for i := range workers { | |||
| 607 | + | wg.Go(func() { | |||
| 608 | + | c, err := r.mrs.AddComment(r.ctx, mrW, r.repo, mr.Number, app.AddCommentParams{Text: fmt.Sprintf("c%d", i)}) | |||
| 609 | + | if assert.NoError(t, err) { | |||
| 610 | + | ids <- c.ID | |||
| 611 | + | } | |||
| 612 | + | }) | |||
| 613 | + | } | |||
| 614 | + | wg.Wait() | |||
| 615 | + | close(ids) | |||
| 616 | + | seen := map[int64]bool{} | |||
| 617 | + | for id := range ids { | |||
| 618 | + | assert.False(t, seen[id], "id %d handed out twice", id) | |||
| 619 | + | seen[id] = true | |||
| 620 | + | } | |||
| 621 | + | assert.Len(t, seen, workers) | |||
| 622 | + | d, err := r.mrs.Get(r.ctx, root, r.repo, mr.Number, false) | |||
| 623 | + | require.NoError(t, err) | |||
| 624 | + | assert.Len(t, d.Comments, workers) | |||
| 625 | + | } | |||
| 626 | + | ||||
| 627 | + | func TestResolveComment(t *testing.T) { | |||
| 628 | + | r := newMRRig(t) | |||
| 629 | + | r.seed() | |||
| 630 | + | r.feature("feat") | |||
| 631 | + | mr := r.open("feat") | |||
| 632 | + | c, err := r.mrs.AddComment(r.ctx, mrW, r.repo, mr.Number, app.AddCommentParams{Text: "fix this"}) | |||
| 633 | + | require.NoError(t, err) | |||
| 634 | + | r.drain() | |||
| 635 | + | ||||
| 636 | + | got, err := r.mrs.ResolveComment(r.ctx, mrW, r.repo, mr.Number, c.ID, true) | |||
| 637 | + | require.NoError(t, err) | |||
| 638 | + | assert.True(t, got.Resolved) | |||
| 639 | + | again, err := r.mrs.ResolveComment(r.ctx, mrW, r.repo, mr.Number, c.ID, true) | |||
| 640 | + | require.NoError(t, err, "resolving twice is not an error") | |||
| 641 | + | assert.True(t, again.Resolved) | |||
| 642 | + | undone, err := r.mrs.ResolveComment(r.ctx, mrW, r.repo, mr.Number, c.ID, false) | |||
| 643 | + | require.NoError(t, err) | |||
| 644 | + | assert.False(t, undone.Resolved) | |||
| 645 | + | undone, err = r.mrs.ResolveComment(r.ctx, mrW, r.repo, mr.Number, c.ID, false) | |||
| 646 | + | require.NoError(t, err) | |||
| 647 | + | assert.False(t, undone.Resolved) | |||
| 648 | + | ||||
| 649 | + | _, err = r.mrs.ResolveComment(r.ctx, mrW, r.repo, mr.Number, 99, true) | |||
| 650 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |||
| 651 | + | _, err = r.mrs.ResolveComment(r.ctx, mrW, r.repo, mr.Number, 0, true) | |||
| 652 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |||
| 653 | + | ||||
| 654 | + | assert.Empty(t, r.drain(), "a resolved comment has no event") | |||
| 655 | + | } | |||
| 656 | + | ||||
| 657 | + | func TestEventsOfAMergeRequest(t *testing.T) { | |||
| 658 | + | r := newMRRig(t) | |||
| 659 | + | r.seed() | |||
| 660 | + | head := r.feature("feat") | |||
| 661 | + | r.drain() | |||
| 662 | + | ||||
| 663 | + | mr := r.open("feat") | |||
| 664 | + | c, err := r.mrs.AddComment(r.ctx, mrW, r.repo, mr.Number, app.AddCommentParams{Text: "note"}) | |||
| 665 | + | require.NoError(t, err) | |||
| 666 | + | _, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, app.MergeParams{HeadID: head}) | |||
| 667 | + | require.NoError(t, err) | |||
| 668 | + | ||||
| 669 | + | r.feature("other") | |||
| 670 | + | other := r.open("other") | |||
| 671 | + | _, err = r.mrs.Close(r.ctx, mrW, r.repo, other.Number) | |||
| 672 | + | require.NoError(t, err) | |||
| 673 | + | ||||
| 674 | + | evs := r.drain() | |||
| 675 | + | var kinds []string | |||
| 676 | + | for _, ev := range evs { | |||
| 677 | + | kinds = append(kinds, ev.EventKind()) | |||
| 678 | + | } | |||
| 679 | + | assert.Equal(t, []string{"mr.opened", "mr.comment_added", "mr.merged", "repo.pushed", "mr.opened", "mr.closed"}, kinds) | |||
| 680 | + | ||||
| 681 | + | opened := evs[0].(domain.MergeRequestOpened) | |||
| 682 | + | assert.Equal(t, mr.Number, opened.MergeRequest.Number) | |||
| 683 | + | assert.Equal(t, domain.VisibilityPrivate, opened.Visibility, "the event carries the visibility, so that it is filtered") | |||
| 684 | + | assert.False(t, opened.CreatedAt.IsZero()) | |||
| 685 | + | added := evs[1].(domain.CommentAdded) | |||
| 686 | + | assert.Equal(t, c, added.Comment) | |||
| 687 | + | assert.Equal(t, mr.Number, added.Number) | |||
| 688 | + | merged := evs[2].(domain.MergeRequestMerged) | |||
| 689 | + | assert.Equal(t, domain.MRStateMerged, merged.MergeRequest.State) | |||
| 690 | + | push := evs[3].(domain.PushEvent) | |||
| 691 | + | assert.Equal(t, []domain.RefUpdate{{Old: r.seedID(), New: head.String(), Ref: "refs/heads/main"}}, push.Updates) | |||
| 692 | + | assert.Equal(t, domain.VisibilityPrivate, push.Visibility) | |||
| 693 | + | assert.Equal(t, domain.MRStateClosed, evs[5].(domain.MergeRequestClosed).MergeRequest.State) | |||
| 694 | + | ||||
| 695 | + | // Failed calls publish nothing. | |||
| 696 | + | _, err = r.mrs.Close(r.ctx, mrW, r.repo, other.Number) | |||
| 697 | + | require.Error(t, err) | |||
| 698 | + | _, err = r.mrs.AddComment(r.ctx, mrW, r.repo, other.Number, app.AddCommentParams{Text: "x"}) | |||
| 699 | + | require.Error(t, err) | |||
| 700 | + | assert.Empty(t, r.drain()) | |||
| 701 | + | } | |||
| 702 | + | ||||
| 703 | + | // seedID is the id main had before the merges of the test: the parent of its | |||
| 704 | + | // first feature commit. | |||
| 705 | + | func (r *mrRig) seedID() string { | |||
| 706 | + | return r.run(r.bare(), r.env(author), "rev-parse", "refs/heads/feat~2") | |||
| 707 | + | } | |||
| 708 | + | ||||
| 709 | + | func TestMarkReadAndSinceLastRead(t *testing.T) { | |||
| 710 | + | r := newMRRig(t) | |||
| 711 | + | base := r.seed() | |||
| 712 | + | head1 := r.feature("feat") | |||
| 713 | + | mr := r.open("feat") | |||
| 714 | + | n := mr.Number | |||
| 715 | + | ||||
| 716 | + | // Nothing recorded: the flag changes nothing. | |||
| 717 | + | d, err := r.mrs.Get(r.ctx, mrW, r.repo, n, true) | |||
| 718 | + | require.NoError(t, err) | |||
| 719 | + | assert.True(t, d.LastReadID.IsZero()) | |||
| 720 | + | assert.False(t, d.LastReadLost) | |||
| 721 | + | assert.Equal(t, base, d.DiffBaseID) | |||
| 722 | + | ||||
| 723 | + | // Without a head: the current head. | |||
| 724 | + | got, err := r.mrs.MarkRead(r.ctx, mrW, r.repo, n, domain.ObjectID{}) | |||
| 725 | + | require.NoError(t, err) | |||
| 726 | + | assert.Equal(t, head1, got) | |||
| 727 | + | d, err = r.mrs.Get(r.ctx, reader, r.repo, n, false) | |||
| 728 | + | require.NoError(t, err) | |||
| 729 | + | assert.Equal(t, head1, d.LastReadID, "reported whether or not since_last_read is set") | |||
| 730 | + | assert.Equal(t, base, d.DiffBaseID) | |||
| 731 | + | ||||
| 732 | + | // New work on the branch: the diff since the last read has only that. | |||
| 733 | + | r.commit(author, "d.txt", "dee\n", "add d") | |||
| 734 | + | head2 := r.head() | |||
| 735 | + | r.push("feat") | |||
| 736 | + | d, err = r.mrs.Get(r.ctx, reader, r.repo, n, true) | |||
| 737 | + | require.NoError(t, err) | |||
| 738 | + | assert.Equal(t, head1, d.DiffBaseID) | |||
| 739 | + | assert.Equal(t, head2, d.MergeRequest.HeadID) | |||
| 740 | + | require.Len(t, d.Diff.Files, 1) | |||
| 741 | + | assert.Equal(t, "d.txt", d.Diff.Files[0].NewPath) | |||
| 742 | + | assert.Equal(t, domain.DiffStats{FilesChanged: 1, Additions: 1}, d.Stats) | |||
| 743 | + | assert.Len(t, d.Commits, 3, "the commits are still those of the target..head range") | |||
| 744 | + | ||||
| 745 | + | // Marking a commit that is not in the repository is refused. | |||
| 746 | + | _, err = r.mrs.MarkRead(r.ctx, mrW, r.repo, n, oidFrom(t, strings.Repeat("1", 40))) | |||
| 747 | + | require.ErrorIs(t, err, domain.ErrInvalid) | |||
| 748 | + | assert.Contains(t, fieldsOf(t, err), "head_id") | |||
| 749 | + | // Neither is a tree. | |||
| 750 | + | tree := oidFrom(t, r.run(r.bare(), r.env(author), "rev-parse", head1.String()+"^{tree}")) | |||
| 751 | + | _, err = r.mrs.MarkRead(r.ctx, mrW, r.repo, n, tree) | |||
| 752 | + | require.ErrorIs(t, err, domain.ErrInvalid) | |||
| 753 | + | // An explicit head is recorded as given. | |||
| 754 | + | _, err = r.mrs.MarkRead(r.ctx, mrW, r.repo, n, base) | |||
| 755 | + | require.NoError(t, err) | |||
| 756 | + | d, err = r.mrs.Get(r.ctx, reader, r.repo, n, true) | |||
| 757 | + | require.NoError(t, err) | |||
| 758 | + | assert.Equal(t, base, d.DiffBaseID) | |||
| 759 | + | } | |||
| 760 | + | ||||
| 761 | + | // AC6: the diff since the last read is right after a force-push; when the | |||
| 762 | + | // recorded commit is gone the whole change is shown and the response says so. | |||
| 763 | + | func TestSinceLastReadAfterForcePush(t *testing.T) { | |||
| 764 | + | r := newMRRig(t) | |||
| 765 | + | base := r.seed() | |||
| 766 | + | head1 := r.feature("feat") | |||
| 767 | + | mr := r.open("feat") | |||
| 768 | + | _, err := r.mrs.MarkRead(r.ctx, mrW, r.repo, mr.Number, domain.ObjectID{}) | |||
| 769 | + | require.NoError(t, err) | |||
| 770 | + | ||||
| 771 | + | // Force-push: the last commit is amended (the old one is unreachable but | |||
| 772 | + | // still stored), and one more file is added. | |||
| 773 | + | r.sh("checkout", "-q", "feat") | |||
| 774 | + | require.NoError(t, os.WriteFile(filepath.Join(r.work, "c.txt"), []byte("see, amended\n"), 0o644)) | |||
| 775 | + | r.sh("add", "c.txt") | |||
| 776 | + | r.sh("commit", "-q", "--amend", "-m", "add c, amended") | |||
| 777 | + | head2 := r.head() | |||
| 778 | + | require.NotEqual(t, head1, head2) | |||
| 779 | + | r.push("feat") | |||
| 780 | + | assert.Equal(t, head2, r.branchHead("feat")) | |||
| 781 | + | // head1 is not an ancestor of head2 any more. | |||
| 782 | + | r.run(r.bare(), r.env(author), "cat-file", "-e", head1.String()) | |||
| 783 | + | ||||
| 784 | + | d, err := r.mrs.Get(r.ctx, reader, r.repo, mr.Number, true) | |||
| 785 | + | require.NoError(t, err) | |||
| 786 | + | assert.False(t, d.LastReadLost) | |||
| 787 | + | assert.Equal(t, head1, d.DiffBaseID, "the diff starts at the head that was read") | |||
| 788 | + | require.Len(t, d.Diff.Files, 1, "only what the force-push changed") | |||
| 789 | + | assert.Equal(t, "c.txt", d.Diff.Files[0].NewPath) | |||
| 790 | + | var added, removed []string | |||
| 791 | + | for _, l := range d.Diff.Files[0].Hunks[0].Lines { | |||
| 792 | + | switch l.Kind { | |||
| 793 | + | case domain.LineAddition: | |||
| 794 | + | added = append(added, l.Text) | |||
| 795 | + | case domain.LineDeletion: | |||
| 796 | + | removed = append(removed, l.Text) | |||
| 797 | + | } | |||
| 798 | + | } | |||
| 799 | + | assert.Equal(t, []string{"see, amended"}, added) | |||
| 800 | + | assert.Equal(t, []string{"see"}, removed) | |||
| 801 | + | ||||
| 802 | + | // The whole change is still available without the flag. | |||
| 803 | + | d, err = r.mrs.Get(r.ctx, reader, r.repo, mr.Number, false) | |||
| 804 | + | require.NoError(t, err) | |||
| 805 | + | assert.Equal(t, base, d.DiffBaseID) | |||
| 806 | + | assert.Len(t, d.Diff.Files, 2) | |||
| 807 | + | ||||
| 808 | + | // Once the recorded commit is pruned, the response says so. | |||
| 809 | + | r.run(r.bare(), r.env(author), "gc", "-q", "--prune=now") | |||
| 810 | + | d, err = r.mrs.Get(r.ctx, reader, r.repo, mr.Number, true) | |||
| 811 | + | require.NoError(t, err) | |||
| 812 | + | assert.True(t, d.LastReadLost, "the recorded head is gone") | |||
| 813 | + | assert.Equal(t, head1, d.LastReadID, "still reported") | |||
| 814 | + | assert.Equal(t, base, d.DiffBaseID, "the diff is against the target") | |||
| 815 | + | assert.Len(t, d.Diff.Files, 2) | |||
| 816 | + | } | |||
| 817 | + | ||||
| 818 | + | func TestMergeFastForward(t *testing.T) { | |||
| 819 | + | r := newMRRig(t) | |||
| 820 | + | main := r.seed() | |||
| 821 | + | head := r.feature("feat") | |||
| 822 | + | mr := r.open("feat") | |||
| 823 | + | r.drain() | |||
| 824 | + | ||||
| 825 | + | merged, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, app.MergeParams{HeadID: head}) | |||
| 826 | + | require.NoError(t, err) | |||
| 827 | + | assert.Equal(t, domain.MRStateMerged, merged.State) | |||
| 828 | + | assert.Equal(t, domain.MergeModeFastForward, merged.MergeMode, "the default mode") | |||
| 829 | + | assert.Equal(t, head, merged.MergedCommitID, "no commit is created") | |||
| 830 | + | assert.Equal(t, head, merged.HeadID) | |||
| 831 | + | assert.Equal(t, head, r.branchHead("main")) | |||
| 832 | + | assert.Equal(t, "root", merged.Author) | |||
| 833 | + | _ = main | |||
| 834 | + | ||||
| 835 | + | // The stored request is the merged one, and reads keep a diff. | |||
| 836 | + | d, err := r.mrs.Get(r.ctx, reader, r.repo, mr.Number, false) | |||
| 837 | + | require.NoError(t, err) | |||
| 838 | + | assert.Equal(t, domain.MRStateMerged, d.MergeRequest.State) | |||
| 839 | + | assert.Equal(t, head, d.MergeRequest.HeadID) | |||
| 840 | + | assert.Equal(t, head, d.MergeRequest.MergedCommitID) | |||
| 841 | + | assert.Equal(t, main, d.DiffBaseID, "the base of the merge stays readable") | |||
| 842 | + | assert.Len(t, d.Commits, 2, "the commits that were merged") | |||
| 843 | + | assert.Len(t, d.Diff.Files, 2) | |||
| 844 | + | ||||
| 845 | + | // Repeating it, or touching a final request, is a failed precondition. | |||
| 846 | + | _, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, app.MergeParams{HeadID: head}) | |||
| 847 | + | assert.ErrorIs(t, err, domain.ErrPreconditionFailed) | |||
| 848 | + | _, err = r.mrs.Close(r.ctx, mrW, r.repo, mr.Number) | |||
| 849 | + | assert.ErrorIs(t, err, domain.ErrPreconditionFailed) | |||
| 850 | + | _, err = r.mrs.AddComment(r.ctx, mrW, r.repo, mr.Number, app.AddCommentParams{Text: "x"}) | |||
| 851 | + | assert.ErrorIs(t, err, domain.ErrPreconditionFailed) | |||
| 852 | + | } | |||
| 853 | + | ||||
| 854 | + | // AC4: the checks of a merge. | |||
| 855 | + | func TestMergeRefusals(t *testing.T) { | |||
| 856 | + | t.Run("a branch that moved since the review", func(t *testing.T) { | |||
| 857 | + | r := newMRRig(t) | |||
| 858 | + | r.seed() | |||
| 859 | + | head := r.feature("feat") | |||
| 860 | + | mr := r.open("feat") | |||
| 861 | + | r.commit(author, "more.txt", "m\n", "pushed after the review") | |||
| 862 | + | r.push("feat") | |||
| 863 | + | _, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, app.MergeParams{HeadID: head}) | |||
| 864 | + | require.ErrorIs(t, err, domain.ErrPreconditionFailed) | |||
| 865 | + | assert.Contains(t, err.Error(), "branch moved, read again") | |||
| 866 | + | assert.NotEqual(t, head, r.branchHead("main"), "main did not move") | |||
| 867 | + | // Reading again gives the new head, which merges. | |||
| 868 | + | d, err := r.mrs.Get(r.ctx, mrM, r.repo, mr.Number, false) | |||
| 869 | + | require.NoError(t, err) | |||
| 870 | + | _, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, app.MergeParams{HeadID: d.MergeRequest.HeadID}) | |||
| 871 | + | require.NoError(t, err) | |||
| 872 | + | }) | |||
| 873 | + | ||||
| 874 | + | t.Run("a target that is not an ancestor of the head", func(t *testing.T) { | |||
| 875 | + | r := newMRRig(t) | |||
| 876 | + | r.seed() | |||
| 877 | + | head := r.feature("feat") | |||
| 878 | + | mr := r.open("feat") | |||
| 879 | + | r.sh("checkout", "-q", "main") | |||
| 880 | + | r.commit(author, "news.txt", "n\n", "main moved") | |||
| 881 | + | r.push("main") | |||
| 882 | + | tip := r.branchHead("main") | |||
| 883 | + | ||||
| 884 | + | for _, mode := range []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash} { | |||
| 885 | + | _, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, app.MergeParams{HeadID: head, Mode: mode}) | |||
| 886 | + | require.ErrorIs(t, err, domain.ErrPreconditionFailed, mode) | |||
| 887 | + | assert.Contains(t, err.Error(), "rebase", mode) | |||
| 888 | + | } | |||
| 889 | + | assert.Equal(t, tip, r.branchHead("main")) | |||
| 890 | + | d, err := r.mrs.Get(r.ctx, mrM, r.repo, mr.Number, false) | |||
| 891 | + | require.NoError(t, err) | |||
| 892 | + | assert.Equal(t, domain.MRStateOpen, d.MergeRequest.State) | |||
| 893 | + | ||||
| 894 | + | // After a rebase and a push it merges. | |||
| 895 | + | r.sh("checkout", "-q", "feat") | |||
| 896 | + | r.sh("rebase", "-q", "main") | |||
| 897 | + | r.push("feat") | |||
| 898 | + | _, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, app.MergeParams{HeadID: r.head()}) | |||
| 899 | + | require.NoError(t, err) | |||
| 900 | + | }) | |||
| 901 | + | ||||
| 902 | + | t.Run("a mode the repository does not allow", func(t *testing.T) { | |||
| 903 | + | r := newMRRig(t) | |||
| 904 | + | r.seed() | |||
| 905 | + | head := r.feature("feat") | |||
| 906 | + | mr := r.open("feat") | |||
| 907 | + | modes := []domain.MergeMode{domain.MergeModeFastForward} | |||
| 908 | + | _, err := r.repos.Update(r.ctx, root, r.repo, app.UpdateParams{AllowedMergeModes: &modes}) | |||
| 909 | + | require.NoError(t, err) | |||
| 910 | + | ||||
| 911 | + | _, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, app.MergeParams{HeadID: head, Mode: domain.MergeModeSquash}) | |||
| 912 | + | require.ErrorIs(t, err, domain.ErrPreconditionFailed) | |||
| 913 | + | assert.Contains(t, err.Error(), "squash") | |||
| 914 | + | _, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, app.MergeParams{HeadID: head}) | |||
| 915 | + | require.NoError(t, err, "fast-forward is allowed") | |||
| 916 | + | }) | |||
| 917 | + | ||||
| 918 | + | t.Run("only squash is allowed, so the default mode is refused", func(t *testing.T) { | |||
| 919 | + | r := newMRRig(t) | |||
| 920 | + | r.seed() | |||
| 921 | + | head := r.feature("feat") | |||
| 922 | + | mr := r.open("feat") | |||
| 923 | + | modes := []domain.MergeMode{domain.MergeModeSquash} | |||
| 924 | + | _, err := r.repos.Update(r.ctx, root, r.repo, app.UpdateParams{AllowedMergeModes: &modes}) | |||
| 925 | + | require.NoError(t, err) | |||
| 926 | + | _, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, app.MergeParams{HeadID: head}) | |||
| 927 | + | require.ErrorIs(t, err, domain.ErrPreconditionFailed) | |||
| 928 | + | _, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, app.MergeParams{HeadID: head, Mode: domain.MergeModeSquash}) | |||
| 929 | + | require.NoError(t, err) | |||
| 930 | + | }) | |||
| 931 | + | ||||
| 932 | + | t.Run("bad arguments", func(t *testing.T) { | |||
| 933 | + | r := newMRRig(t) | |||
| 934 | + | r.seed() | |||
| 935 | + | head := r.feature("feat") | |||
| 936 | + | mr := r.open("feat") | |||
| 937 | + | _, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, app.MergeParams{}) | |||
| 938 | + | require.ErrorIs(t, err, domain.ErrInvalid) | |||
| 939 | + | assert.Contains(t, fieldsOf(t, err), "head_id") | |||
| 940 | + | _, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, app.MergeParams{HeadID: head, Mode: "rebase"}) | |||
| 941 | + | require.ErrorIs(t, err, domain.ErrInvalid) | |||
| 942 | + | assert.Contains(t, fieldsOf(t, err), "mode") | |||
| 943 | + | _, err = r.mrs.Merge(r.ctx, mrM, r.repo, 99, app.MergeParams{HeadID: head}) | |||
| 944 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |||
| 945 | + | }) | |||
| 946 | + | ||||
| 947 | + | t.Run("a deleted source or target, or nothing to merge", func(t *testing.T) { | |||
| 948 | + | r := newMRRig(t) | |||
| 949 | + | r.seed() | |||
| 950 | + | head := r.feature("feat") | |||
| 951 | + | mr := r.open("feat") | |||
| 952 | + | r.run(r.bare(), r.env(author), "branch", "-D", "feat") | |||
| 953 | + | _, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, app.MergeParams{HeadID: head}) | |||
| 954 | + | require.ErrorIs(t, err, domain.ErrPreconditionFailed) | |||
| 955 | + | assert.Contains(t, err.Error(), "no longer exists") | |||
| 956 | + | ||||
| 957 | + | r.push("feat") | |||
| 958 | + | r.run(r.bare(), r.env(author), "update-ref", "refs/heads/main", head.String()) | |||
| 959 | + | _, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, app.MergeParams{HeadID: head}) | |||
| 960 | + | require.ErrorIs(t, err, domain.ErrPreconditionFailed, "the target already is at the head") | |||
| 961 | + | assert.Contains(t, err.Error(), "nothing to merge") | |||
| 962 | + | }) | |||
| 963 | + | } | |||
| 964 | + | ||||
| 965 | + | // AC4: merges of one repository run one at a time. | |||
| 966 | + | func TestConcurrentMergesSerialize(t *testing.T) { | |||
| 967 | + | r := newMRRig(t) | |||
| 968 | + | r.seed() | |||
| 969 | + | const n = 6 | |||
| 970 | + | heads := map[int64]domain.ObjectID{} | |||
| 971 | + | for i := range n { | |||
| 972 | + | b := fmt.Sprintf("f%d", i) | |||
| 973 | + | heads[r.openFeature(b).Number] = r.branchHead(b) | |||
| 974 | + | } | |||
| 975 | + | ||||
| 976 | + | type outcome struct { | |||
| 977 | + | number int64 | |||
| 978 | + | err error | |||
| 979 | + | } | |||
| 980 | + | results := make(chan outcome, n*2) | |||
| 981 | + | var wg sync.WaitGroup | |||
| 982 | + | for number, head := range heads { | |||
| 983 | + | for _, mode := range []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash} { | |||
| 984 | + | if mode == domain.MergeModeSquash && number%2 == 0 { | |||
| 985 | + | continue | |||
| 986 | + | } | |||
| 987 | + | wg.Go(func() { | |||
| 988 | + | _, err := r.mrs.Merge(r.ctx, mrM, r.repo, number, app.MergeParams{HeadID: head, Mode: mode}) | |||
| 989 | + | results <- outcome{number, err} | |||
| 990 | + | }) | |||
| 991 | + | } | |||
| 992 | + | } | |||
| 993 | + | wg.Wait() | |||
| 994 | + | close(results) | |||
| 995 | + | ||||
| 996 | + | won := map[int64]int{} | |||
| 997 | + | for o := range results { | |||
| 998 | + | if o.err == nil { | |||
| 999 | + | won[o.number]++ | |||
| 1000 | + | continue | |||
| 1001 | + | } | |||
| 1002 | + | require.ErrorIs(t, o.err, domain.ErrPreconditionFailed, "a loser fails cleanly: %v", o.err) | |||
| 1003 | + | } | |||
| 1004 | + | // All the branches start from the same main: exactly one request can win, | |||
| 1005 | + | // once, whatever the mode, and main is where it left it. | |||
| 1006 | + | require.Len(t, won, 1, "exactly one merge request was merged: %v", won) | |||
| 1007 | + | for number, times := range won { | |||
| 1008 | + | assert.Equal(t, 1, times, "!%d merged twice", number) | |||
| 1009 | + | mr, err := r.mrs.Get(r.ctx, root, r.repo, number, false) | |||
| 1010 | + | require.NoError(t, err) | |||
| 1011 | + | assert.Equal(t, domain.MRStateMerged, mr.MergeRequest.State) | |||
| 1012 | + | assert.Equal(t, mr.MergeRequest.MergedCommitID, r.branchHead("main")) | |||
| 1013 | + | } | |||
| 1014 | + | for number := range heads { | |||
| 1015 | + | if won[number] == 0 { | |||
| 1016 | + | d, err := r.mrs.Get(r.ctx, root, r.repo, number, false) | |||
| 1017 | + | require.NoError(t, err) | |||
| 1018 | + | assert.Equal(t, domain.MRStateOpen, d.MergeRequest.State, "!%d lost and stays open", number) | |||
| 1019 | + | } | |||
| 1020 | + | } | |||
| 1021 | + | } | |||
| 1022 | + | ||||
| 1023 | + | // openFeature pushes a branch from main with one commit and opens a request. | |||
| 1024 | + | func (r *mrRig) openFeature(branch string) domain.MergeRequest { | |||
| 1025 | + | r.t.Helper() | |||
| 1026 | + | r.sh("checkout", "-q", "-B", branch, "main") | |||
| 1027 | + | r.commit(author, branch+".txt", branch+"\n", "add "+branch) | |||
| 1028 | + | r.push(branch) | |||
| 1029 | + | return r.open(branch) | |||
| 1030 | + | } | |||
| 1031 | + | ||||
| 1032 | + | func TestSquashMerge(t *testing.T) { | |||
| 1033 | + | r := newMRRig(t) | |||
| 1034 | + | main := r.seed() | |||
| 1035 | + | // A branch of three commits by two authors; Bob wrote the first one. | |||
| 1036 | + | bob := domain.Identity{Name: "Bob", Email: "bob@example.org"} | |||
| 1037 | + | cy := domain.Identity{Name: "Cy", Email: "cy@example.org"} | |||
| 1038 | + | r.sh("checkout", "-q", "-B", "feat", "main") | |||
| 1039 | + | r.commit(bob, "x.txt", "x\n", "bob starts") | |||
| 1040 | + | r.commit(author, "y.txt", "y\n", "ada continues") | |||
| 1041 | + | r.commit(cy, "z.txt", "z\n", "cy finishes") | |||
| 1042 | + | r.commit(bob, "w.txt", "w\n", "bob again") | |||
| 1043 | + | r.push("feat") | |||
| 1044 | + | head := r.branchHead("feat") | |||
| 1045 | + | mr, err := r.mrs.Create(r.ctx, root, r.repo, app.CreateMergeRequestParams{Title: "Add things", Source: "feat"}) | |||
| 1046 | + | require.NoError(t, err) | |||
| 1047 | + | r.drain() | |||
| 1048 | + | ||||
| 1049 | + | merged, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, app.MergeParams{HeadID: head, Mode: domain.MergeModeSquash}) | |||
| 1050 | + | require.NoError(t, err) | |||
| 1051 | + | assert.Equal(t, domain.MergeModeSquash, merged.MergeMode) | |||
| 1052 | + | require.False(t, merged.MergedCommitID.IsZero()) | |||
| 1053 | + | assert.NotEqual(t, head, merged.MergedCommitID, "a squash creates a commit") | |||
| 1054 | + | assert.Equal(t, merged.MergedCommitID, r.branchHead("main")) | |||
| 1055 | + | ||||
| 1056 | + | sq := merged.MergedCommitID.String() | |||
| 1057 | + | g := func(args ...string) string { return r.run(r.bare(), r.env(author), args...) } | |||
| 1058 | + | assert.Equal(t, main.String(), g("rev-parse", sq+"^"), "the target is the only parent") | |||
| 1059 | + | assert.Equal(t, g("rev-parse", head.String()+"^{tree}"), g("rev-parse", sq+"^{tree}"), "the tree of the head") | |||
| 1060 | + | assert.Equal(t, "Bob <bob@example.org>", g("log", "-1", "--format=%an <%ae>", sq), "author of the first commit") | |||
| 1061 | + | assert.Equal(t, "ermite <ermite@git.example.org>", g("log", "-1", "--format=%cn <%ce>", sq)) | |||
| 1062 | + | assert.Equal(t, "Add things (!1)\n\n- bob starts\n- ada continues\n- cy finishes\n- bob again\n\n"+ | |||
| 1063 | + | "Co-authored-by: Ada Lovelace <ada@example.org>\nCo-authored-by: Cy <cy@example.org>", | |||
| 1064 | + | g("log", "-1", "--format=%B", sq)) | |||
| 1065 | + | assert.Contains(t, g("cat-file", "commit", sq), "\ngpgsig -----BEGIN SSH SIGNATURE-----\n") | |||
| 1066 | + | ||||
| 1067 | + | // Events: merged, then pushed for the target branch. | |||
| 1068 | + | evs := r.drain() | |||
| 1069 | + | require.Len(t, evs, 2) | |||
| 1070 | + | assert.Equal(t, merged.MergedCommitID, evs[0].(domain.MergeRequestMerged).MergeRequest.MergedCommitID) | |||
| 1071 | + | assert.Equal(t, []domain.RefUpdate{{Old: main.String(), New: sq, Ref: "refs/heads/main"}}, evs[1].(domain.PushEvent).Updates) | |||
| 1072 | + | ||||
| 1073 | + | // The signature verifies with an allowed_signers file built from the | |||
| 1074 | + | // public key the server hands out. | |||
| 1075 | + | r.verifyCommit(sq) | |||
| 1076 | + | } | |||
| 1077 | + | ||||
| 1078 | + | // verifyCommit checks the signature of a commit of the repository with | |||
| 1079 | + | // `git verify-commit` and an allowed signers file made from the signing key | |||
| 1080 | + | // info, and that another key does not verify. | |||
| 1081 | + | func (r *mrRig) verifyCommit(id string) { | |||
| 1082 | + | r.t.Helper() | |||
| 1083 | + | if _, err := exec.LookPath("ssh-keygen"); err != nil { | |||
| 1084 | + | unusable(r.t, "ssh-keygen not found: install openssh to verify the signed commits (CI must have it)") | |||
| 1085 | + | } | |||
| 1086 | + | dir := r.t.TempDir() | |||
| 1087 | + | signers := filepath.Join(dir, "allowed_signers") | |||
| 1088 | + | require.NoError(r.t, os.WriteFile(signers, []byte(r.key.Info().AllowedSigners()+"\n"), 0o600)) | |||
| 1089 | + | out := r.run(r.bare(), r.env(author), "-c", "gpg.format=ssh", "-c", "gpg.ssh.allowedSignersFile="+signers, "verify-commit", "--raw", id) | |||
| 1090 | + | assert.Contains(r.t, out, "Good \"git\" signature for ermite@git.example.org") | |||
| 1091 | + | ||||
| 1092 | + | other, err := sshsig.New(bytes.Repeat([]byte{1}, ed25519.SeedSize)) | |||
| 1093 | + | require.NoError(r.t, err) | |||
| 1094 | + | wrong := filepath.Join(dir, "wrong_signers") | |||
| 1095 | + | require.NoError(r.t, os.WriteFile(wrong, []byte("ermite@git.example.org "+other.PublicKey().AuthorizedKey+"\n"), 0o600)) | |||
| 1096 | + | cmd := exec.Command("git", "-c", "gpg.format=ssh", "-c", "gpg.ssh.allowedSignersFile="+wrong, "verify-commit", id) | |||
| 1097 | + | cmd.Dir, cmd.Env = r.bare(), r.env(author) | |||
| 1098 | + | assert.Error(r.t, cmd.Run(), "another key must not verify the commit") | |||
| 1099 | + | } | |||
| 1100 | + | ||||
| 1101 | + | func TestSquashSingleCommitHasNoTrailer(t *testing.T) { | |||
| 1102 | + | r := newMRRig(t) | |||
| 1103 | + | r.seed() | |||
| 1104 | + | head := r.feature("solo") // two authors; make a one-author branch instead | |||
| 1105 | + | _ = head | |||
| 1106 | + | r.sh("checkout", "-q", "-B", "mine", "main") | |||
| 1107 | + | r.commit(author, "m.txt", "m\n", "only") | |||
| 1108 | + | r.push("mine") | |||
| 1109 | + | mr := r.open("mine") | |||
| 1110 | + | merged, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, app.MergeParams{HeadID: r.branchHead("mine"), Mode: domain.MergeModeSquash}) | |||
| 1111 | + | require.NoError(t, err) | |||
| 1112 | + | msg := r.run(r.bare(), r.env(author), "log", "-1", "--format=%B", merged.MergedCommitID.String()) | |||
| 1113 | + | assert.Equal(t, "Add mine (!1)\n\n- only", msg) | |||
| 1114 | + | r.verifyCommit(merged.MergedCommitID.String()) | |||
| 1115 | + | } | |||
| 1116 | + | ||||
| 1117 | + | func TestUpdateRepoSettings(t *testing.T) { | |||
| 1118 | + | r := newMRRig(t) | |||
| 1119 | + | repo, err := r.repos.Get(r.ctx, root, r.repo) | |||
| 1120 | + | require.NoError(t, err) | |||
| 1121 | + | assert.Equal(t, domain.DefaultRepoSettings(), repo.Settings, "stored explicitly at creation") | |||
| 1122 | + | ||||
| 1123 | + | modes := []domain.MergeMode{domain.MergeModeSquash, domain.MergeModeSquash} | |||
| 1124 | + | off := false | |||
| 1125 | + | got, err := r.repos.Update(r.ctx, root, r.repo, app.UpdateParams{AllowedMergeModes: &modes, FailingChecksBlock: &off}) | |||
| 1126 | + | require.NoError(t, err) | |||
| 1127 | + | assert.Equal(t, domain.RepoSettings{AllowedMergeModes: []domain.MergeMode{domain.MergeModeSquash}, FailingChecksBlock: false}, got.Settings) | |||
| 1128 | + | again, err := r.repos.Get(r.ctx, root, r.repo) | |||
| 1129 | + | require.NoError(t, err) | |||
| 1130 | + | assert.Equal(t, got.Settings, again.Settings) | |||
| 1131 | + | ||||
| 1132 | + | // Only the named setting changes. | |||
| 1133 | + | on := true | |||
| 1134 | + | got, err = r.repos.Update(r.ctx, root, r.repo, app.UpdateParams{FailingChecksBlock: &on}) | |||
| 1135 | + | require.NoError(t, err) | |||
| 1136 | + | assert.Equal(t, []domain.MergeMode{domain.MergeModeSquash}, got.Settings.AllowedMergeModes) | |||
| 1137 | + | assert.True(t, got.Settings.FailingChecksBlock) | |||
| 1138 | + | ||||
| 1139 | + | for _, bad := range [][]domain.MergeMode{{}, nil, {"rebase"}} { | |||
| 1140 | + | bad := bad | |||
| 1141 | + | _, err = r.repos.Update(r.ctx, root, r.repo, app.UpdateParams{AllowedMergeModes: &bad}) | |||
| 1142 | + | assert.ErrorIs(t, err, domain.ErrInvalid, "%v", bad) | |||
| 1143 | + | } | |||
| 1144 | + | again, err = r.repos.Get(r.ctx, root, r.repo) | |||
| 1145 | + | require.NoError(t, err) | |||
| 1146 | + | assert.Equal(t, got.Settings, again.Settings, "a refused change stores nothing") | |||
| 1147 | + | ||||
| 1148 | + | // repo:admin is needed, like for any other field. | |||
| 1149 | + | _, err = r.repos.Update(r.ctx, writer(domain.ScopeRepoRead, domain.ScopeMRMerge), r.repo, app.UpdateParams{FailingChecksBlock: &off}) | |||
| 1150 | + | assert.ErrorIs(t, err, domain.ErrForbidden) | |||
| 1151 | + | } | |||
| 1152 | + | ||||
| 1153 | + | func TestDeletingARepositoryDropsItsMergeRequests(t *testing.T) { | |||
| 1154 | + | r := newMRRig(t) | |||
| 1155 | + | r.seed() | |||
| 1156 | + | r.feature("feat") | |||
| 1157 | + | r.open("feat") | |||
| 1158 | + | require.NoError(t, r.repos.Delete(r.ctx, root, r.repo)) | |||
| 1159 | + | ||||
| 1160 | + | _, err := r.mrs.List(r.ctx, root, r.repo, app.ListMergeRequestsParams{}) | |||
| 1161 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |||
| 1162 | + | ||||
| 1163 | + | // Created again under the same name, it starts empty. | |||
| 1164 | + | _, err = r.repos.Create(r.ctx, root, app.CreateParams{Name: r.repo}) | |||
| 1165 | + | require.NoError(t, err) | |||
| 1166 | + | page, err := r.mrs.List(r.ctx, root, r.repo, app.ListMergeRequestsParams{}) | |||
| 1167 | + | require.NoError(t, err) | |||
| 1168 | + | assert.Empty(t, page.MergeRequests) | |||
| 1169 | + | } | |||
| 1170 | + | ||||
| 1171 | + | func TestSigningKeyInfo(t *testing.T) { | |||
| 1172 | + | r := newMRRig(t) | |||
| 1173 | + | info := r.key.Info() | |||
| 1174 | + | assert.True(t, strings.HasPrefix(info.Key.AuthorizedKey, "ssh-ed25519 ")) | |||
| 1175 | + | assert.True(t, strings.HasPrefix(info.Key.Fingerprint, "SHA256:")) | |||
| 1176 | + | assert.Equal(t, "ermite@git.example.org "+info.Key.AuthorizedKey, info.AllowedSigners()) | |||
| 1177 | + | assert.Equal(t, domain.Identity{Name: "ermite", Email: "ermite@git.example.org"}, info.Committer) | |||
| 1178 | + | } | |||
M internal/app/pushes_test.go +1 -1
| @@ -112,7 +112,7 @@ func TestPushesOpen(t *testing.T) { | |||
| 112 | 112 | f := newPushFixture(t) | |
| 113 | 113 | f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, domain.ErrNotFound) | |
| 114 | 114 | f.git.EXPECT().Stage(ctx).Return(ports.StagedID("s1"), nil) | |
| 115 | - | want := domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, CreatedAt: now} | |
| 115 | + | want := domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, CreatedAt: now, Settings: domain.DefaultRepoSettings()} | |
| 116 | 116 | f.meta.EXPECT().Create(ctx, want).Return(nil) | |
| 117 | 117 | f.git.EXPECT().Publish(ctx, ports.StagedID("s1"), n).Return(nil) | |
| 118 | 118 | f.bus.EXPECT().Publish(ctx, domain.RepoCreated{Repo: n, Visibility: domain.VisibilityPrivate, At: now}).Return() | |
| @@ -112,7 +112,7 @@ func TestPushesOpen(t *testing.T) { | |||||
| 112 | f := newPushFixture(t) | 112 | f := newPushFixture(t) | ||
| 113 | f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, domain.ErrNotFound) | 113 | f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, domain.ErrNotFound) | ||
| 114 | f.git.EXPECT().Stage(ctx).Return(ports.StagedID("s1"), nil) | 114 | f.git.EXPECT().Stage(ctx).Return(ports.StagedID("s1"), nil) | ||
| 115 | - | want := domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, CreatedAt: now} | 115 | + | want := domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, CreatedAt: now, Settings: domain.DefaultRepoSettings()} |
| 116 | f.meta.EXPECT().Create(ctx, want).Return(nil) | 116 | f.meta.EXPECT().Create(ctx, want).Return(nil) | ||
| 117 | f.git.EXPECT().Publish(ctx, ports.StagedID("s1"), n).Return(nil) | 117 | f.git.EXPECT().Publish(ctx, ports.StagedID("s1"), n).Return(nil) | ||
| 118 | f.bus.EXPECT().Publish(ctx, domain.RepoCreated{Repo: n, Visibility: domain.VisibilityPrivate, At: now}).Return() | 118 | f.bus.EXPECT().Publish(ctx, domain.RepoCreated{Repo: n, Visibility: domain.VisibilityPrivate, At: now}).Return() | ||
M internal/app/repos.go +2 -0
| @@ -70,6 +70,7 @@ func (r *Repos) Create(ctx context.Context, p domain.Principal, params CreatePar | |||
| 70 | 70 | Description: params.Description, | |
| 71 | 71 | Visibility: vis, | |
| 72 | 72 | CreatedAt: r.clock.Now().UTC(), | |
| 73 | + | Settings: domain.DefaultRepoSettings(), | |
| 73 | 74 | } | |
| 74 | 75 | ||
| 75 | 76 | log := zerowrap.FromCtx(ctx) | |
| @@ -200,6 +201,7 @@ func (r *Repos) Reconcile(ctx context.Context) (ReconcileReport, error) { | |||
| 200 | 201 | Name: name, | |
| 201 | 202 | Visibility: domain.VisibilityPrivate, | |
| 202 | 203 | CreatedAt: r.clock.Now().UTC(), | |
| 204 | + | Settings: domain.DefaultRepoSettings(), | |
| 203 | 205 | } | |
| 204 | 206 | if err := r.meta.Create(ctx, repo); err != nil && !errors.Is(err, domain.ErrAlreadyExists) { | |
| 205 | 207 | return rep, fmt.Errorf("register repository %q: %w", name, err) | |
| @@ -70,6 +70,7 @@ func (r *Repos) Create(ctx context.Context, p domain.Principal, params CreatePar | |||||
| 70 | Description: params.Description, | 70 | Description: params.Description, | ||
| 71 | Visibility: vis, | 71 | Visibility: vis, | ||
| 72 | CreatedAt: r.clock.Now().UTC(), | 72 | CreatedAt: r.clock.Now().UTC(), | ||
| 73 | + | Settings: domain.DefaultRepoSettings(), | |||
| 73 | } | 74 | } | ||
| 74 | 75 | ||||
| 75 | log := zerowrap.FromCtx(ctx) | 76 | log := zerowrap.FromCtx(ctx) | ||
| @@ -200,6 +201,7 @@ func (r *Repos) Reconcile(ctx context.Context) (ReconcileReport, error) { | |||||
| 200 | Name: name, | 201 | Name: name, | ||
| 201 | Visibility: domain.VisibilityPrivate, | 202 | Visibility: domain.VisibilityPrivate, | ||
| 202 | CreatedAt: r.clock.Now().UTC(), | 203 | CreatedAt: r.clock.Now().UTC(), | ||
| 204 | + | Settings: domain.DefaultRepoSettings(), | |||
| 203 | } | 205 | } | ||
| 204 | if err := r.meta.Create(ctx, repo); err != nil && !errors.Is(err, domain.ErrAlreadyExists) { | 206 | if err := r.meta.Create(ctx, repo); err != nil && !errors.Is(err, domain.ErrAlreadyExists) { | ||
| 205 | return rep, fmt.Errorf("register repository %q: %w", name, err) | 207 | return rep, fmt.Errorf("register repository %q: %w", name, err) | ||
M internal/app/repos_test.go +2 -2
| @@ -55,7 +55,7 @@ func TestCreate(t *testing.T) { | |||
| 55 | 55 | t.Run("success is private by default", func(t *testing.T) { | |
| 56 | 56 | f := newFixture(t) | |
| 57 | 57 | n := name(t, "tools") | |
| 58 | - | want := domain.Repo{Name: n, Description: "d", Visibility: domain.VisibilityPrivate, CreatedAt: now} | |
| 58 | + | want := domain.Repo{Name: n, Description: "d", Visibility: domain.VisibilityPrivate, CreatedAt: now, Settings: domain.DefaultRepoSettings()} | |
| 59 | 59 | f.git.EXPECT().Stage(ctx).Return(ports.StagedID("s1"), nil) | |
| 60 | 60 | f.meta.EXPECT().Create(ctx, want).Return(nil) | |
| 61 | 61 | f.git.EXPECT().Publish(ctx, ports.StagedID("s1"), n).Return(nil) | |
| @@ -275,7 +275,7 @@ func TestReconcile(t *testing.T) { | |||
| 275 | 275 | {Name: ghost, Visibility: domain.VisibilityPublic}, | |
| 276 | 276 | {Name: kept, Visibility: domain.VisibilityPublic}, | |
| 277 | 277 | }, nil) | |
| 278 | - | f.meta.EXPECT().Create(ctx, domain.Repo{Name: orphan, Visibility: domain.VisibilityPrivate, CreatedAt: now}).Return(nil) | |
| 278 | + | f.meta.EXPECT().Create(ctx, domain.Repo{Name: orphan, Visibility: domain.VisibilityPrivate, CreatedAt: now, Settings: domain.DefaultRepoSettings()}).Return(nil) | |
| 279 | 279 | ||
| 280 | 280 | rep, err := f.repos.Reconcile(ctx) | |
| 281 | 281 | require.NoError(t, err) | |
| @@ -55,7 +55,7 @@ func TestCreate(t *testing.T) { | |||||
| 55 | t.Run("success is private by default", func(t *testing.T) { | 55 | t.Run("success is private by default", func(t *testing.T) { | ||
| 56 | f := newFixture(t) | 56 | f := newFixture(t) | ||
| 57 | n := name(t, "tools") | 57 | n := name(t, "tools") | ||
| 58 | - | want := domain.Repo{Name: n, Description: "d", Visibility: domain.VisibilityPrivate, CreatedAt: now} | 58 | + | want := domain.Repo{Name: n, Description: "d", Visibility: domain.VisibilityPrivate, CreatedAt: now, Settings: domain.DefaultRepoSettings()} |
| 59 | f.git.EXPECT().Stage(ctx).Return(ports.StagedID("s1"), nil) | 59 | f.git.EXPECT().Stage(ctx).Return(ports.StagedID("s1"), nil) | ||
| 60 | f.meta.EXPECT().Create(ctx, want).Return(nil) | 60 | f.meta.EXPECT().Create(ctx, want).Return(nil) | ||
| 61 | f.git.EXPECT().Publish(ctx, ports.StagedID("s1"), n).Return(nil) | 61 | f.git.EXPECT().Publish(ctx, ports.StagedID("s1"), n).Return(nil) | ||
| @@ -275,7 +275,7 @@ func TestReconcile(t *testing.T) { | |||||
| 275 | {Name: ghost, Visibility: domain.VisibilityPublic}, | 275 | {Name: ghost, Visibility: domain.VisibilityPublic}, | ||
| 276 | {Name: kept, Visibility: domain.VisibilityPublic}, | 276 | {Name: kept, Visibility: domain.VisibilityPublic}, | ||
| 277 | }, nil) | 277 | }, nil) | ||
| 278 | - | f.meta.EXPECT().Create(ctx, domain.Repo{Name: orphan, Visibility: domain.VisibilityPrivate, CreatedAt: now}).Return(nil) | 278 | + | f.meta.EXPECT().Create(ctx, domain.Repo{Name: orphan, Visibility: domain.VisibilityPrivate, CreatedAt: now, Settings: domain.DefaultRepoSettings()}).Return(nil) |
| 279 | 279 | ||||
| 280 | rep, err := f.repos.Reconcile(ctx) | 280 | rep, err := f.repos.Reconcile(ctx) | ||
| 281 | require.NoError(t, err) | 281 | require.NoError(t, err) | ||
M internal/app/repos_write.go +27 -4
| @@ -56,10 +56,15 @@ type UpdateParams struct { | |||
| 56 | 56 | Description *string | |
| 57 | 57 | Visibility *domain.Visibility | |
| 58 | 58 | DefaultBranch *string | |
| 59 | + | // AllowedMergeModes replaces the allowed merge modes; it must hold at | |
| 60 | + | // least one known mode. | |
| 61 | + | AllowedMergeModes *[]domain.MergeMode | |
| 62 | + | // FailingChecksBlock replaces the setting of that name. | |
| 63 | + | FailingChecksBlock *bool | |
| 59 | 64 | } | |
| 60 | 65 | ||
| 61 | - | // Update changes the description, the visibility and the default branch of a | |
| 62 | - | // repository, and publishes RepoUpdated. The principal needs repo:admin on it | |
| 66 | + | // Update changes the description, the visibility, the default branch and the | |
| 67 | + | // merge settings of a repository, and publishes RepoUpdated. The principal needs repo:admin on it | |
| 63 | 68 | // (see adminAccess for what the others learn). At least one field must be set, | |
| 64 | 69 | // else domain.ErrInvalid. A default branch that does not exist gives | |
| 65 | 70 | // domain.ErrPreconditionFailed; nothing is written before every check passed. | |
| @@ -75,9 +80,17 @@ func (r *Repos) Update(ctx context.Context, p domain.Principal, name domain.Repo | |||
| 75 | 80 | return domain.Repo{}, err | |
| 76 | 81 | } | |
| 77 | 82 | before := repo | |
| 78 | - | if params.Description == nil && params.Visibility == nil && params.DefaultBranch == nil { | |
| 83 | + | if params.Description == nil && params.Visibility == nil && params.DefaultBranch == nil && | |
| 84 | + | params.AllowedMergeModes == nil && params.FailingChecksBlock == nil { | |
| 79 | 85 | return domain.Repo{}, fmt.Errorf("%w: nothing to update", domain.ErrInvalid) | |
| 80 | 86 | } | |
| 87 | + | if params.AllowedMergeModes != nil { | |
| 88 | + | modes, err := domain.NormalizeMergeModes(*params.AllowedMergeModes) | |
| 89 | + | if err != nil { | |
| 90 | + | return domain.Repo{}, err | |
| 91 | + | } | |
| 92 | + | params.AllowedMergeModes = &modes | |
| 93 | + | } | |
| 81 | 94 | if params.Description != nil { | |
| 82 | 95 | if err := validateDescription(*params.Description); err != nil { | |
| 83 | 96 | return domain.Repo{}, err | |
| @@ -108,7 +121,7 @@ func (r *Repos) Update(ctx context.Context, p domain.Principal, name domain.Repo | |||
| 108 | 121 | // branch was checked above, so the remaining causes are a vanished | |
| 109 | 122 | // repository or an I/O failure. | |
| 110 | 123 | metaDone := false | |
| 111 | - | if params.Description != nil || params.Visibility != nil { | |
| 124 | + | if params.Description != nil || params.Visibility != nil || params.AllowedMergeModes != nil || params.FailingChecksBlock != nil { | |
| 112 | 125 | if repo, err = r.updateMeta(ctx, name, params); err != nil { | |
| 113 | 126 | return domain.Repo{}, err | |
| 114 | 127 | } | |
| @@ -130,6 +143,7 @@ func (r *Repos) Update(ctx context.Context, p domain.Principal, name domain.Repo | |||
| 130 | 143 | Bool("description", params.Description != nil). | |
| 131 | 144 | Bool("visibility", params.Visibility != nil). | |
| 132 | 145 | Bool("default_branch", params.DefaultBranch != nil). | |
| 146 | + | Bool("settings", params.AllowedMergeModes != nil || params.FailingChecksBlock != nil). | |
| 133 | 147 | Str("principal", p.Label()).Str("token_id", p.TokenID()). | |
| 134 | 148 | Msg("repository updated") | |
| 135 | 149 | r.bus.Publish(ctx, domain.RepoUpdated{ | |
| @@ -156,6 +170,15 @@ func (r *Repos) updateMeta(ctx context.Context, name domain.RepoName, params Upd | |||
| 156 | 170 | if params.Visibility != nil { | |
| 157 | 171 | repo.Visibility = *params.Visibility | |
| 158 | 172 | } | |
| 173 | + | if repo.Settings.IsZero() { | |
| 174 | + | repo.Settings = domain.DefaultRepoSettings() | |
| 175 | + | } | |
| 176 | + | if params.AllowedMergeModes != nil { | |
| 177 | + | repo.Settings.AllowedMergeModes = *params.AllowedMergeModes | |
| 178 | + | } | |
| 179 | + | if params.FailingChecksBlock != nil { | |
| 180 | + | repo.Settings.FailingChecksBlock = *params.FailingChecksBlock | |
| 181 | + | } | |
| 159 | 182 | err = r.meta.Update(ctx, repo) | |
| 160 | 183 | if errors.Is(err, domain.ErrConflict) { | |
| 161 | 184 | continue | |
| @@ -56,10 +56,15 @@ type UpdateParams struct { | |||||
| 56 | Description *string | 56 | Description *string | ||
| 57 | Visibility *domain.Visibility | 57 | Visibility *domain.Visibility | ||
| 58 | DefaultBranch *string | 58 | DefaultBranch *string | ||
| 59 | + | // AllowedMergeModes replaces the allowed merge modes; it must hold at | |||
| 60 | + | // least one known mode. | |||
| 61 | + | AllowedMergeModes *[]domain.MergeMode | |||
| 62 | + | // FailingChecksBlock replaces the setting of that name. | |||
| 63 | + | FailingChecksBlock *bool | |||
| 59 | } | 64 | } | ||
| 60 | 65 | ||||
| 61 | - | // Update changes the description, the visibility and the default branch of a | 66 | + | // Update changes the description, the visibility, the default branch and the |
| 62 | - | // repository, and publishes RepoUpdated. The principal needs repo:admin on it | 67 | + | // merge settings of a repository, and publishes RepoUpdated. The principal needs repo:admin on it |
| 63 | // (see adminAccess for what the others learn). At least one field must be set, | 68 | // (see adminAccess for what the others learn). At least one field must be set, | ||
| 64 | // else domain.ErrInvalid. A default branch that does not exist gives | 69 | // else domain.ErrInvalid. A default branch that does not exist gives | ||
| 65 | // domain.ErrPreconditionFailed; nothing is written before every check passed. | 70 | // domain.ErrPreconditionFailed; nothing is written before every check passed. | ||
| @@ -75,9 +80,17 @@ func (r *Repos) Update(ctx context.Context, p domain.Principal, name domain.Repo | |||||
| 75 | return domain.Repo{}, err | 80 | return domain.Repo{}, err | ||
| 76 | } | 81 | } | ||
| 77 | before := repo | 82 | before := repo | ||
| 78 | - | if params.Description == nil && params.Visibility == nil && params.DefaultBranch == nil { | 83 | + | if params.Description == nil && params.Visibility == nil && params.DefaultBranch == nil && |
| 84 | + | params.AllowedMergeModes == nil && params.FailingChecksBlock == nil { | |||
| 79 | return domain.Repo{}, fmt.Errorf("%w: nothing to update", domain.ErrInvalid) | 85 | return domain.Repo{}, fmt.Errorf("%w: nothing to update", domain.ErrInvalid) | ||
| 80 | } | 86 | } | ||
| 87 | + | if params.AllowedMergeModes != nil { | |||
| 88 | + | modes, err := domain.NormalizeMergeModes(*params.AllowedMergeModes) | |||
| 89 | + | if err != nil { | |||
| 90 | + | return domain.Repo{}, err | |||
| 91 | + | } | |||
| 92 | + | params.AllowedMergeModes = &modes | |||
| 93 | + | } | |||
| 81 | if params.Description != nil { | 94 | if params.Description != nil { | ||
| 82 | if err := validateDescription(*params.Description); err != nil { | 95 | if err := validateDescription(*params.Description); err != nil { | ||
| 83 | return domain.Repo{}, err | 96 | return domain.Repo{}, err | ||
| @@ -108,7 +121,7 @@ func (r *Repos) Update(ctx context.Context, p domain.Principal, name domain.Repo | |||||
| 108 | // branch was checked above, so the remaining causes are a vanished | 121 | // branch was checked above, so the remaining causes are a vanished | ||
| 109 | // repository or an I/O failure. | 122 | // repository or an I/O failure. | ||
| 110 | metaDone := false | 123 | metaDone := false | ||
| 111 | - | if params.Description != nil || params.Visibility != nil { | 124 | + | if params.Description != nil || params.Visibility != nil || params.AllowedMergeModes != nil || params.FailingChecksBlock != nil { |
| 112 | if repo, err = r.updateMeta(ctx, name, params); err != nil { | 125 | if repo, err = r.updateMeta(ctx, name, params); err != nil { | ||
| 113 | return domain.Repo{}, err | 126 | return domain.Repo{}, err | ||
| 114 | } | 127 | } | ||
| @@ -130,6 +143,7 @@ func (r *Repos) Update(ctx context.Context, p domain.Principal, name domain.Repo | |||||
| 130 | Bool("description", params.Description != nil). | 143 | Bool("description", params.Description != nil). | ||
| 131 | Bool("visibility", params.Visibility != nil). | 144 | Bool("visibility", params.Visibility != nil). | ||
| 132 | Bool("default_branch", params.DefaultBranch != nil). | 145 | Bool("default_branch", params.DefaultBranch != nil). | ||
| 146 | + | Bool("settings", params.AllowedMergeModes != nil || params.FailingChecksBlock != nil). | |||
| 133 | Str("principal", p.Label()).Str("token_id", p.TokenID()). | 147 | Str("principal", p.Label()).Str("token_id", p.TokenID()). | ||
| 134 | Msg("repository updated") | 148 | Msg("repository updated") | ||
| 135 | r.bus.Publish(ctx, domain.RepoUpdated{ | 149 | r.bus.Publish(ctx, domain.RepoUpdated{ | ||
| @@ -156,6 +170,15 @@ func (r *Repos) updateMeta(ctx context.Context, name domain.RepoName, params Upd | |||||
| 156 | if params.Visibility != nil { | 170 | if params.Visibility != nil { | ||
| 157 | repo.Visibility = *params.Visibility | 171 | repo.Visibility = *params.Visibility | ||
| 158 | } | 172 | } | ||
| 173 | + | if repo.Settings.IsZero() { | |||
| 174 | + | repo.Settings = domain.DefaultRepoSettings() | |||
| 175 | + | } | |||
| 176 | + | if params.AllowedMergeModes != nil { | |||
| 177 | + | repo.Settings.AllowedMergeModes = *params.AllowedMergeModes | |||
| 178 | + | } | |||
| 179 | + | if params.FailingChecksBlock != nil { | |||
| 180 | + | repo.Settings.FailingChecksBlock = *params.FailingChecksBlock | |||
| 181 | + | } | |||
| 159 | err = r.meta.Update(ctx, repo) | 182 | err = r.meta.Update(ctx, repo) | ||
| 160 | if errors.Is(err, domain.ErrConflict) { | 183 | if errors.Is(err, domain.ErrConflict) { | ||
| 161 | continue | 184 | continue | ||
M internal/app/repos_write_test.go +1 -1
| @@ -27,7 +27,7 @@ func TestUpdate(t *testing.T) { | |||
| 27 | 27 | t.Run("description and visibility are stored and RepoUpdated is published", func(t *testing.T) { | |
| 28 | 28 | f := newFixture(t) | |
| 29 | 29 | f.meta.EXPECT().Get(ctx, n).Return(priv, nil).Twice() | |
| 30 | - | want := domain.Repo{Name: n, Visibility: domain.VisibilityPublic, Description: "new"} | |
| 30 | + | want := domain.Repo{Name: n, Visibility: domain.VisibilityPublic, Description: "new", Settings: domain.DefaultRepoSettings()} | |
| 31 | 31 | f.meta.EXPECT().Update(ctx, want).Return(nil) | |
| 32 | 32 | f.bus.EXPECT().Publish(ctx, domain.RepoUpdated{ | |
| 33 | 33 | Repo: n, At: now, Visibility: domain.VisibilityPublic, Previous: domain.VisibilityPrivate, | |
| @@ -27,7 +27,7 @@ func TestUpdate(t *testing.T) { | |||||
| 27 | t.Run("description and visibility are stored and RepoUpdated is published", func(t *testing.T) { | 27 | t.Run("description and visibility are stored and RepoUpdated is published", func(t *testing.T) { | ||
| 28 | f := newFixture(t) | 28 | f := newFixture(t) | ||
| 29 | f.meta.EXPECT().Get(ctx, n).Return(priv, nil).Twice() | 29 | f.meta.EXPECT().Get(ctx, n).Return(priv, nil).Twice() | ||
| 30 | - | want := domain.Repo{Name: n, Visibility: domain.VisibilityPublic, Description: "new"} | 30 | + | want := domain.Repo{Name: n, Visibility: domain.VisibilityPublic, Description: "new", Settings: domain.DefaultRepoSettings()} |
| 31 | f.meta.EXPECT().Update(ctx, want).Return(nil) | 31 | f.meta.EXPECT().Update(ctx, want).Return(nil) | ||
| 32 | f.bus.EXPECT().Publish(ctx, domain.RepoUpdated{ | 32 | f.bus.EXPECT().Publish(ctx, domain.RepoUpdated{ | ||
| 33 | Repo: n, At: now, Visibility: domain.VisibilityPublic, Previous: domain.VisibilityPrivate, | 33 | Repo: n, At: now, Visibility: domain.VisibilityPublic, Previous: domain.VisibilityPrivate, | ||
A internal/app/signing.go +27 -0
| @@ -0,0 +1,27 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package app | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "bnema.dev/ermite/internal/domain" | |
| 7 | + | "bnema.dev/ermite/internal/ports" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // SigningKey is the public side of the key that signs the commits ermite | |
| 11 | + | // creates (ADR 0022). | |
| 12 | + | type SigningKey struct { | |
| 13 | + | signer ports.CommitSigner | |
| 14 | + | committer domain.Identity | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | // NewSigningKey builds the use case over the signer and the identity recorded | |
| 18 | + | // as the committer of the signed commits. | |
| 19 | + | func NewSigningKey(signer ports.CommitSigner, committer domain.Identity) *SigningKey { | |
| 20 | + | return &SigningKey{signer: signer, committer: committer} | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | // Info returns what a verifier needs. It is public: no access rule applies, | |
| 24 | + | // and the private key never leaves the signer. | |
| 25 | + | func (s *SigningKey) Info() domain.SigningKeyInfo { | |
| 26 | + | return domain.SigningKeyInfo{Key: s.signer.PublicKey(), Committer: s.committer} | |
| 27 | + | } | |
| @@ -0,0 +1,27 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package app | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "bnema.dev/ermite/internal/domain" | |||
| 7 | + | "bnema.dev/ermite/internal/ports" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | // SigningKey is the public side of the key that signs the commits ermite | |||
| 11 | + | // creates (ADR 0022). | |||
| 12 | + | type SigningKey struct { | |||
| 13 | + | signer ports.CommitSigner | |||
| 14 | + | committer domain.Identity | |||
| 15 | + | } | |||
| 16 | + | ||||
| 17 | + | // NewSigningKey builds the use case over the signer and the identity recorded | |||
| 18 | + | // as the committer of the signed commits. | |||
| 19 | + | func NewSigningKey(signer ports.CommitSigner, committer domain.Identity) *SigningKey { | |||
| 20 | + | return &SigningKey{signer: signer, committer: committer} | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | // Info returns what a verifier needs. It is public: no access rule applies, | |||
| 24 | + | // and the private key never leaves the signer. | |||
| 25 | + | func (s *SigningKey) Info() domain.SigningKeyInfo { | |||
| 26 | + | return domain.SigningKeyInfo{Key: s.signer.PublicKey(), Committer: s.committer} | |||
| 27 | + | } | |||