v0.33.0 cf26365

issue_links_test.go RawHistoryFolder

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

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 45

↑↓ moveenter openesc close