| @@ -0,0 +1,235 @@ |
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later |
| 2 | + | |
| 3 | + | package usecase_test |
| 4 | + | |
| 5 | + | import ( |
| 6 | + | "context" |
| 7 | + | "fmt" |
| 8 | + | "sync/atomic" |
| 9 | + | "testing" |
| 10 | + | "time" |
| 11 | + | |
| 12 | + | "bnema.dev/ermite/internal/domain" |
| 13 | + | "bnema.dev/ermite/internal/ports" |
| 14 | + | "bnema.dev/ermite/internal/usecase" |
| 15 | + | "github.com/stretchr/testify/assert" |
| 16 | + | "github.com/stretchr/testify/require" |
| 17 | + | ) |
| 18 | + | |
| 19 | + | func linkedRig(t *testing.T) (*mrRig, *usecase.IssueLinks, *usecase.Issues) { |
| 20 | + | t.Helper() |
| 21 | + | r := newMRRig(t) |
| 22 | + | r.seed() |
| 23 | + | _, err := r.store.Mutate(r.ctx, r.repo, func(repo *domain.Repo) (bool, error) { repo.Settings.Issues = domain.IssuesOwner; return true, nil }) |
| 24 | + | require.NoError(t, err) |
| 25 | + | links := usecase.NewIssueLinks(usecase.IssueLinksConfig{Meta: r.store, Issues: r.store.Issues(), MRs: r.store.MergeRequests(), Links: r.store.IssueLinks(), Git: r.git, Bus: r.bus, Clock: fixedClock{}}) |
| 26 | + | r.mrs = usecase.NewMergeRequests(usecase.MergeRequestsConfig{Meta: r.store, Store: r.store.MergeRequests(), Repos: r.git, Git: r.git, Bus: r.bus, Clock: fixedClock{}, Maint: r.sched, Links: links}) |
| 27 | + | issues := usecase.NewIssues(usecase.IssuesConfig{Meta: r.store, Store: r.store.Issues(), MergeRequests: r.store.MergeRequests(), Links: r.store.IssueLinks(), Bus: r.bus, Clock: fixedClock{}}) |
| 28 | + | return r, links, issues |
| 29 | + | } |
| 30 | + | |
| 31 | + | func TestIssueLinksTextsPushAndReferences(t *testing.T) { |
| 32 | + | r, links, issues := linkedRig(t) |
| 33 | + | issue, err := issues.Create(r.ctx, root, r.repo, ports.CreateIssueParams{Title: "bug"}) |
| 34 | + | require.NoError(t, err) |
| 35 | + | r.feature("feat") |
| 36 | + | mr, err := r.mrs.Create(r.ctx, root, r.repo, ports.CreateMergeRequestParams{Title: "mention", Source: "feat", Description: fmt.Sprintf("see #%d #999", issue.Number)}) |
| 37 | + | require.NoError(t, err) |
| 38 | + | got, err := issues.Get(r.ctx, root, r.repo, issue.Number) |
| 39 | + | require.NoError(t, err) |
| 40 | + | require.Len(t, got.References, 1) |
| 41 | + | assert.Equal(t, mr.Title, got.References[0].Title) |
| 42 | + | other, err := issues.Create(r.ctx, root, r.repo, ports.CreateIssueParams{Title: "other"}) |
| 43 | + | require.NoError(t, err) |
| 44 | + | note, err := r.mrs.AddComment(r.ctx, root, r.repo, mr.Number, ports.AddCommentParams{Text: fmt.Sprintf("Fixes #%d; see #%d #999", other.Number, mr.Number)}) |
| 45 | + | require.NoError(t, err) |
| 46 | + | got, err = issues.Get(r.ctx, root, r.repo, other.Number) |
| 47 | + | require.NoError(t, err) |
| 48 | + | require.Len(t, got.References, 1) |
| 49 | + | assert.False(t, got.References[0].Closes) |
| 50 | + | indexed, err := r.store.IssueLinks().LinksOfMergeRequest(r.ctx, r.repo, mr.Number) |
| 51 | + | require.NoError(t, err) |
| 52 | + | require.Len(t, indexed, 2, "MR and missing numbers must not be stored") |
| 53 | + | _, err = r.mrs.EditComment(r.ctx, root, r.repo, mr.Number, note.ID, "no mention") |
| 54 | + | require.NoError(t, err) |
| 55 | + | got, err = issues.Get(r.ctx, root, r.repo, other.Number) |
| 56 | + | require.NoError(t, err) |
| 57 | + | assert.Empty(t, got.References) |
| 58 | + | head := r.commit(author, "push.txt", "push", fmt.Sprintf("Fixes #%d", other.Number)) |
| 59 | + | r.push("feat") |
| 60 | + | links.OnBranchesMoved(r.ctx, r.repo, []domain.RefUpdate{{Ref: "refs/heads/feat", New: head.String()}}) |
| 61 | + | got, err = issues.Get(r.ctx, root, r.repo, other.Number) |
| 62 | + | require.NoError(t, err) |
| 63 | + | require.Len(t, got.References, 1) |
| 64 | + | assert.True(t, got.References[0].Closes) |
| 65 | + | stored, err := r.store.IssueLinks().LinksOfMergeRequest(r.ctx, r.repo, mr.Number) |
| 66 | + | require.NoError(t, err) |
| 67 | + | require.Len(t, stored, 2) |
| 68 | + | _, err = r.mrs.Close(r.ctx, root, r.repo, mr.Number) |
| 69 | + | require.NoError(t, err) |
| 70 | + | got, err = issues.Get(r.ctx, root, r.repo, other.Number) |
| 71 | + | require.NoError(t, err) |
| 72 | + | assert.True(t, got.Issue.IsOpen()) |
| 73 | + | assert.Equal(t, domain.MRStateClosed, got.References[0].State) |
| 74 | + | } |
| 75 | + | |
| 76 | + | func TestIssueLinksMergeClosing(t *testing.T) { |
| 77 | + | for _, mode := range []string{"open", "off", "closed"} { |
| 78 | + | t.Run(mode, func(t *testing.T) { |
| 79 | + | r, _, issues := linkedRig(t) |
| 80 | + | issue, err := issues.Create(r.ctx, root, r.repo, ports.CreateIssueParams{Title: "bug"}) |
| 81 | + | require.NoError(t, err) |
| 82 | + | if mode == "closed" { |
| 83 | + | issue, err = issues.Close(r.ctx, root, r.repo, issue.Number) |
| 84 | + | require.NoError(t, err) |
| 85 | + | } |
| 86 | + | r.feature("feat") |
| 87 | + | head := r.commit(author, "fix.txt", "fix", fmt.Sprintf("Fixes #%d", issue.Number)) |
| 88 | + | r.push("feat") |
| 89 | + | mr := r.open("feat") |
| 90 | + | if mode == "off" { |
| 91 | + | _, err = r.store.Mutate(r.ctx, r.repo, func(repo *domain.Repo) (bool, error) { repo.Settings.Issues = domain.IssuesOff; return true, nil }) |
| 92 | + | require.NoError(t, err) |
| 93 | + | } |
| 94 | + | issue, err = r.store.Issues().Get(r.ctx, r.repo, issue.Number) |
| 95 | + | require.NoError(t, err) |
| 96 | + | r.drain() |
| 97 | + | _, err = r.mrs.Merge(r.ctx, root, r.repo, mr.Number, ports.MergeParams{HeadID: head, Mode: domain.MergeModeFastForward, DeleteSource: true}) |
| 98 | + | require.NoError(t, err) |
| 99 | + | events := r.drain() |
| 100 | + | got, err := r.store.Issues().Get(r.ctx, r.repo, issue.Number) |
| 101 | + | require.NoError(t, err) |
| 102 | + | refs, err := r.store.IssueLinks().LinksOfMergeRequest(r.ctx, r.repo, mr.Number) |
| 103 | + | require.NoError(t, err) |
| 104 | + | require.Len(t, refs, 1) |
| 105 | + | assert.True(t, refs[0].Closes) |
| 106 | + | closedEvents, referenced := 0, 0 |
| 107 | + | for _, ev := range events { |
| 108 | + | switch ev.(type) { |
| 109 | + | case domain.IssueClosed: |
| 110 | + | closedEvents++ |
| 111 | + | case domain.IssueReferenced: |
| 112 | + | referenced++ |
| 113 | + | } |
| 114 | + | } |
| 115 | + | assert.Positive(t, referenced) |
| 116 | + | if mode == "open" { |
| 117 | + | assert.Equal(t, mr.Number, got.ClosedByMergeRequest) |
| 118 | + | assert.False(t, got.IsOpen()) |
| 119 | + | assert.Equal(t, 1, closedEvents) |
| 120 | + | reopened, err := issues.Reopen(r.ctx, root, r.repo, issue.Number) |
| 121 | + | require.NoError(t, err) |
| 122 | + | assert.Zero(t, reopened.ClosedByMergeRequest) |
| 123 | + | } else { |
| 124 | + | assert.Zero(t, got.ClosedByMergeRequest) |
| 125 | + | assert.Equal(t, 0, closedEvents) |
| 126 | + | if mode == "closed" { |
| 127 | + | assert.Equal(t, issue, got) |
| 128 | + | } else { |
| 129 | + | assert.True(t, got.IsOpen()) |
| 130 | + | } |
| 131 | + | } |
| 132 | + | }) |
| 133 | + | } |
| 134 | + | } |
| 135 | + | |
| 136 | + | type gatedBranchGit struct { |
| 137 | + | ports.MergeGit |
| 138 | + | entered atomic.Int32 |
| 139 | + | started chan struct{} |
| 140 | + | gate chan struct{} |
| 141 | + | } |
| 142 | + | |
| 143 | + | func (g *gatedBranchGit) BranchHead(ctx context.Context, name domain.RepoName, branch string) (domain.ObjectID, error) { |
| 144 | + | if g.entered.Add(1) == 1 { |
| 145 | + | close(g.started) |
| 146 | + | <-g.gate |
| 147 | + | } |
| 148 | + | return g.MergeGit.BranchHead(ctx, name, branch) |
| 149 | + | } |
| 150 | + | |
| 151 | + | func TestIssueLinksReindexSerializedPerMergeRequest(t *testing.T) { |
| 152 | + | r, _, issues := linkedRig(t) |
| 153 | + | issue, err := issues.Create(r.ctx, root, r.repo, ports.CreateIssueParams{Title: "bug"}) |
| 154 | + | require.NoError(t, err) |
| 155 | + | r.feature("feat") |
| 156 | + | mr := r.open("feat") |
| 157 | + | g := &gatedBranchGit{MergeGit: r.git, started: make(chan struct{}), gate: make(chan struct{})} |
| 158 | + | links := usecase.NewIssueLinks(usecase.IssueLinksConfig{Meta: r.store, Issues: r.store.Issues(), MRs: r.store.MergeRequests(), Links: r.store.IssueLinks(), Git: g, Bus: r.bus, Clock: fixedClock{}}) |
| 159 | + | repo, err := r.store.Get(r.ctx, r.repo) |
| 160 | + | require.NoError(t, err) |
| 161 | + | done := make(chan error, 2) |
| 162 | + | go func() { done <- links.Reindex(r.ctx, repo, mr.Number) }() |
| 163 | + | <-g.started |
| 164 | + | // The first reindex read the old texts; a note is added afterwards. |
| 165 | + | _, err = r.mrs.AddComment(r.ctx, root, r.repo, mr.Number, ports.AddCommentParams{Text: fmt.Sprintf("see #%d", issue.Number)}) |
| 166 | + | require.NoError(t, err) |
| 167 | + | go func() { done <- links.Reindex(r.ctx, repo, mr.Number) }() |
| 168 | + | time.Sleep(100 * time.Millisecond) |
| 169 | + | assert.EqualValues(t, 1, g.entered.Load(), "the second reindex waits for the first") |
| 170 | + | close(g.gate) |
| 171 | + | require.NoError(t, <-done) |
| 172 | + | require.NoError(t, <-done) |
| 173 | + | got, err := r.store.IssueLinks().LinksOfMergeRequest(r.ctx, r.repo, mr.Number) |
| 174 | + | require.NoError(t, err) |
| 175 | + | require.Len(t, got, 1, "the final index reflects the latest content") |
| 176 | + | } |
| 177 | + | |
| 178 | + | func TestIssueLinksNotifiesRemovalsAndClosingFlips(t *testing.T) { |
| 179 | + | r, links, issues := linkedRig(t) |
| 180 | + | issue, err := issues.Create(r.ctx, root, r.repo, ports.CreateIssueParams{Title: "bug"}) |
| 181 | + | require.NoError(t, err) |
| 182 | + | r.feature("feat") |
| 183 | + | mr := r.open("feat") |
| 184 | + | referenced := func() (n int) { |
| 185 | + | for _, ev := range r.drain() { |
| 186 | + | if e, ok := ev.(domain.IssueReferenced); ok && e.Issue == issue.Number { |
| 187 | + | n++ |
| 188 | + | } |
| 189 | + | } |
| 190 | + | return n |
| 191 | + | } |
| 192 | + | note, err := r.mrs.AddComment(r.ctx, root, r.repo, mr.Number, ports.AddCommentParams{Text: fmt.Sprintf("see #%d", issue.Number)}) |
| 193 | + | require.NoError(t, err) |
| 194 | + | assert.Equal(t, 1, referenced()) |
| 195 | + | _, err = r.mrs.EditComment(r.ctx, root, r.repo, mr.Number, note.ID, "nothing") |
| 196 | + | require.NoError(t, err) |
| 197 | + | assert.Equal(t, 1, referenced(), "removing the last mention notifies") |
| 198 | + | |
| 199 | + | // closes true -> false emits one event: a commit message fixes the issue, |
| 200 | + | // then is rewritten to a plain mention. |
| 201 | + | head := r.commit(author, "fix.txt", "fix", fmt.Sprintf("Fixes #%d", issue.Number)) |
| 202 | + | r.push("feat") |
| 203 | + | links.OnBranchesMoved(r.ctx, r.repo, []domain.RefUpdate{{Ref: "refs/heads/feat", New: head.String()}}) |
| 204 | + | referenced() |
| 205 | + | r.sh("commit", "-q", "--amend", "-m", fmt.Sprintf("See #%d", issue.Number)) |
| 206 | + | r.push("feat") |
| 207 | + | links.OnBranchesMoved(r.ctx, r.repo, []domain.RefUpdate{{Ref: "refs/heads/feat", New: r.branchHead("feat").String()}}) |
| 208 | + | assert.Equal(t, 1, referenced(), "a closing flip notifies once") |
| 209 | + | } |
| 210 | + | |
| 211 | + | func TestIssueLinksMergeReindexesRequestsWhoseSourceMoved(t *testing.T) { |
| 212 | + | r, _, issues := linkedRig(t) |
| 213 | + | issue, err := issues.Create(r.ctx, root, r.repo, ports.CreateIssueParams{Title: "bug"}) |
| 214 | + | require.NoError(t, err) |
| 215 | + | r.sh("checkout", "-q", "-B", "x", "main") |
| 216 | + | r.commit(author, "x.txt", "x", "work on x") |
| 217 | + | r.push("x") |
| 218 | + | r.sh("checkout", "-q", "-B", "feat", "x") |
| 219 | + | head := r.commit(author, "fix.txt", "fix", fmt.Sprintf("Fixes #%d", issue.Number)) |
| 220 | + | r.push("feat") |
| 221 | + | b, err := r.mrs.Create(r.ctx, root, r.repo, ports.CreateMergeRequestParams{Title: "B", Source: "x"}) |
| 222 | + | require.NoError(t, err) |
| 223 | + | a, err := r.mrs.Create(r.ctx, root, r.repo, ports.CreateMergeRequestParams{Title: "A", Source: "feat", Target: "x"}) |
| 224 | + | require.NoError(t, err) |
| 225 | + | before, err := r.store.IssueLinks().LinksOfMergeRequest(r.ctx, r.repo, b.Number) |
| 226 | + | require.NoError(t, err) |
| 227 | + | require.Empty(t, before) |
| 228 | + | _, err = r.mrs.Merge(r.ctx, root, r.repo, a.Number, ports.MergeParams{HeadID: head, Mode: domain.MergeModeFastForward}) |
| 229 | + | require.NoError(t, err) |
| 230 | + | r.mrs.Wait(r.ctx) |
| 231 | + | after, err := r.store.IssueLinks().LinksOfMergeRequest(r.ctx, r.repo, b.Number) |
| 232 | + | require.NoError(t, err) |
| 233 | + | require.Len(t, after, 1) |
| 234 | + | assert.Equal(t, issue.Number, after[0].Issue) |
| 235 | + | } |