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	issueStore := r.store.Issues()
28	links := usecase.NewIssueLinks(usecase.IssueLinksConfig{Meta: r.store, Issues: issueStore, MRs: r.store.MergeRequests(), Links: r.store.IssueLinks(), Git: r.git, Bus: r.bus, Clock: fixedClock{}})
29	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})
30	issues := usecase.NewIssues(usecase.IssuesConfig{Meta: r.store, Store: issueStore, MergeRequests: r.store.MergeRequests(), Links: r.store.IssueLinks(), Bus: r.bus, Clock: fixedClock{}})
31	return r, links, issues
32}
33
34func TestIssueLinksTextsPushAndReferences(t *testing.T) {
35	r, links, issues := linkedRig(t)
36	issue, err := issues.Create(r.ctx, root, r.repo, ports.CreateIssueParams{Title: "bug"})
37	require.NoError(t, err)
38	r.feature("feat")
39	mr, err := r.mrs.Create(r.ctx, root, r.repo, ports.CreateMergeRequestParams{Title: "mention", Source: "feat", Description: fmt.Sprintf("see #%d #999", issue.Number)})
40	require.NoError(t, err)
41	got, err := issues.Get(r.ctx, root, r.repo, issue.Number)
42	require.NoError(t, err)
43	require.Len(t, got.References, 1)
44	assert.Equal(t, mr.Title, got.References[0].Title)
45	other, err := issues.Create(r.ctx, root, r.repo, ports.CreateIssueParams{Title: "other"})
46	require.NoError(t, err)
47	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)})
48	require.NoError(t, err)
49	got, err = issues.Get(r.ctx, root, r.repo, other.Number)
50	require.NoError(t, err)
51	require.Len(t, got.References, 1)
52	assert.False(t, got.References[0].Closes)
53	indexed, err := r.store.IssueLinks().LinksOfMergeRequest(r.ctx, r.repo, mr.Number)
54	require.NoError(t, err)
55	require.Len(t, indexed, 2, "MR and missing numbers must not be stored")
56	_, err = r.mrs.EditComment(r.ctx, root, r.repo, mr.Number, note.ID, "no mention")
57	require.NoError(t, err)
58	got, err = issues.Get(r.ctx, root, r.repo, other.Number)
59	require.NoError(t, err)
60	assert.Empty(t, got.References)
61	head := r.commit(author, "push.txt", "push", fmt.Sprintf("Fixes #%d", other.Number))
62	r.push("feat")
63	links.OnBranchesMoved(r.ctx, domain.Principal{}, r.repo, []domain.RefUpdate{{Ref: "refs/heads/feat", New: head.String()}})
64	got, err = issues.Get(r.ctx, root, r.repo, other.Number)
65	require.NoError(t, err)
66	require.Len(t, got.References, 1)
67	assert.True(t, got.References[0].Closes)
68	stored, err := r.store.IssueLinks().LinksOfMergeRequest(r.ctx, r.repo, mr.Number)
69	require.NoError(t, err)
70	require.Len(t, stored, 2)
71	_, err = r.mrs.Close(r.ctx, root, r.repo, mr.Number)
72	require.NoError(t, err)
73	got, err = issues.Get(r.ctx, root, r.repo, other.Number)
74	require.NoError(t, err)
75	assert.True(t, got.Issue.IsOpen())
76	assert.Equal(t, domain.MRStateClosed, got.References[0].State)
77}
78
79func TestIssueLinksMergeClosing(t *testing.T) {
80	for _, mode := range []string{"open", "off", "closed"} {
81		t.Run(mode, func(t *testing.T) {
82			r, _, issues := linkedRig(t)
83			issue, err := issues.Create(r.ctx, root, r.repo, ports.CreateIssueParams{Title: "bug"})
84			require.NoError(t, err)
85			if mode == "closed" {
86				issue, err = issues.Close(r.ctx, root, r.repo, issue.Number)
87				require.NoError(t, err)
88			}
89			r.feature("feat")
90			head := r.commit(author, "fix.txt", "fix", fmt.Sprintf("Fixes #%d", issue.Number))
91			r.push("feat")
92			mr := r.open("feat")
93			if mode == "off" {
94				_, err = r.store.Mutate(r.ctx, r.repo, func(repo *domain.Repo) (bool, error) { repo.Settings.Issues = domain.IssuesOff; return true, nil })
95				require.NoError(t, err)
96			}
97			issue, err = r.store.Issues().Get(r.ctx, r.repo, issue.Number)
98			require.NoError(t, err)
99			r.drain()
100			_, err = r.mrs.Merge(r.ctx, root, r.repo, mr.Number, ports.MergeParams{HeadID: head, Mode: domain.MergeModeFastForward, DeleteSource: true})
101			require.NoError(t, err)
102			events := r.drain()
103			detail, err := issues.Get(r.ctx, root, r.repo, issue.Number)
104			if mode == "off" {
105				stored, readErr := r.store.Issues().Get(r.ctx, r.repo, issue.Number)
106				require.NoError(t, readErr)
107				detail.Issue = stored
108			} else {
109				require.NoError(t, err)
110			}
111			got := detail.Issue
112			refs, err := r.store.IssueLinks().LinksOfMergeRequest(r.ctx, r.repo, mr.Number)
113			require.NoError(t, err)
114			require.Len(t, refs, 1)
115			assert.True(t, refs[0].Closes)
116			closedEvents, referenced := 0, 0
117			for _, ev := range events {
118				switch ev.(type) {
119				case domain.IssueResolved:
120					closedEvents++
121				case domain.IssueReferenced:
122					referenced++
123				}
124			}
125			assert.Positive(t, referenced)
126			if mode == "open" {
127				assert.Equal(t, mr.Number, got.ClosedByMergeRequest)
128				assert.Equal(t, domain.IssueStateResolved, got.State)
129				assert.Equal(t, 1, closedEvents)
130				persisted, err := r.store.Issues().Get(r.ctx, r.repo, issue.Number)
131				require.NoError(t, err)
132				assert.Equal(t, got, persisted)
133				history, err := r.store.Issues().ListEvents(r.ctx, r.repo, issue.Number)
134				require.NoError(t, err)
135				require.NotEmpty(t, history)
136				last := history[len(history)-1]
137				assert.Equal(t, domain.IssueEventResolved, last.Kind)
138				assert.Equal(t, domain.AuthorName(root), last.Actor)
139				assert.Equal(t, mr.Number, last.MergeRequest)
140				page, err := r.store.Issues().List(r.ctx, r.repo, ports.ListIssuesParams{State: domain.IssueStateOpen})
141				require.NoError(t, err)
142				assert.Empty(t, page.Issues)
143
144				reopened, err := issues.Reopen(r.ctx, root, r.repo, issue.Number)
145				require.NoError(t, err)
146				assert.Zero(t, reopened.ClosedByMergeRequest)
147				persisted, err = r.store.Issues().Get(r.ctx, r.repo, issue.Number)
148				require.NoError(t, err)
149				assert.Equal(t, domain.IssueStateOpen, persisted.State)
150				assert.Zero(t, persisted.ClosedByMergeRequest)
151				history, err = r.store.Issues().ListEvents(r.ctx, r.repo, issue.Number)
152				require.NoError(t, err)
153				assert.Equal(t, domain.IssueEventReopened, history[len(history)-1].Kind)
154				page, err = r.store.Issues().List(r.ctx, r.repo, ports.ListIssuesParams{State: domain.IssueStateOpen})
155				require.NoError(t, err)
156				require.Len(t, page.Issues, 1)
157				assert.Equal(t, persisted, page.Issues[0])
158
159			} else {
160				assert.Zero(t, got.ClosedByMergeRequest)
161				assert.Equal(t, 0, closedEvents)
162				if mode == "closed" {
163					assert.Equal(t, issue, got)
164				} else {
165					assert.True(t, got.IsOpen())
166				}
167			}
168		})
169	}
170}
171
172type gatedBranchGit struct {
173	ports.MergeGit
174	entered atomic.Int32
175	started chan struct{}
176	gate    chan struct{}
177}
178
179func (g *gatedBranchGit) BranchHead(ctx context.Context, name domain.RepoName, branch string) (domain.ObjectID, error) {
180	if g.entered.Add(1) == 1 {
181		close(g.started)
182		<-g.gate
183	}
184	return g.MergeGit.BranchHead(ctx, name, branch)
185}
186
187func TestIssueLinksReindexSerializedPerMergeRequest(t *testing.T) {
188	r, _, issues := linkedRig(t)
189	issue, err := issues.Create(r.ctx, root, r.repo, ports.CreateIssueParams{Title: "bug"})
190	require.NoError(t, err)
191	r.feature("feat")
192	mr := r.open("feat")
193	g := &gatedBranchGit{MergeGit: r.git, started: make(chan struct{}), gate: make(chan struct{})}
194	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{}})
195	repo, err := r.store.Get(r.ctx, r.repo)
196	require.NoError(t, err)
197	done := make(chan error, 2)
198	go func() { done <- links.Reindex(r.ctx, domain.Principal{}, repo, mr.Number) }()
199	<-g.started
200	// The first reindex read the old texts; a note is added afterwards.
201	_, err = r.mrs.AddComment(r.ctx, root, r.repo, mr.Number, ports.AddCommentParams{Text: fmt.Sprintf("see #%d", issue.Number)})
202	require.NoError(t, err)
203	go func() { done <- links.Reindex(r.ctx, domain.Principal{}, repo, mr.Number) }()
204	time.Sleep(100 * time.Millisecond)
205	assert.EqualValues(t, 1, g.entered.Load(), "the second reindex waits for the first")
206	close(g.gate)
207	require.NoError(t, <-done)
208	require.NoError(t, <-done)
209	got, err := r.store.IssueLinks().LinksOfMergeRequest(r.ctx, r.repo, mr.Number)
210	require.NoError(t, err)
211	require.Len(t, got, 1, "the final index reflects the latest content")
212}
213
214func TestIssueLinksNotifiesRemovalsAndClosingFlips(t *testing.T) {
215	r, links, issues := linkedRig(t)
216	issue, err := issues.Create(r.ctx, root, r.repo, ports.CreateIssueParams{Title: "bug"})
217	require.NoError(t, err)
218	r.feature("feat")
219	mr := r.open("feat")
220	referenced := func() (n int) {
221		for _, ev := range r.drain() {
222			if e, ok := ev.(domain.IssueReferenced); ok && e.Issue == issue.Number {
223				n++
224			}
225		}
226		return n
227	}
228	note, err := r.mrs.AddComment(r.ctx, root, r.repo, mr.Number, ports.AddCommentParams{Text: fmt.Sprintf("see #%d", issue.Number)})
229	require.NoError(t, err)
230	assert.Equal(t, 1, referenced())
231	_, err = r.mrs.EditComment(r.ctx, root, r.repo, mr.Number, note.ID, "nothing")
232	require.NoError(t, err)
233	assert.Equal(t, 1, referenced(), "removing the last mention notifies")
234
235	// closes true -> false emits one event: a commit message fixes the issue,
236	// then is rewritten to a plain mention.
237	head := r.commit(author, "fix.txt", "fix", fmt.Sprintf("Fixes #%d", issue.Number))
238	r.push("feat")
239	links.OnBranchesMoved(r.ctx, domain.Principal{}, r.repo, []domain.RefUpdate{{Ref: "refs/heads/feat", New: head.String()}})
240	referenced()
241	r.sh("commit", "-q", "--amend", "-m", fmt.Sprintf("See #%d", issue.Number))
242	r.push("feat")
243	links.OnBranchesMoved(r.ctx, domain.Principal{}, r.repo, []domain.RefUpdate{{Ref: "refs/heads/feat", New: r.branchHead("feat").String()}})
244	assert.Equal(t, 1, referenced(), "a closing flip notifies once")
245}
246
247func TestIssueLinksMergeReindexesRequestsWhoseSourceMoved(t *testing.T) {
248	r, _, issues := linkedRig(t)
249	issue, err := issues.Create(r.ctx, root, r.repo, ports.CreateIssueParams{Title: "bug"})
250	require.NoError(t, err)
251	r.sh("checkout", "-q", "-B", "x", "main")
252	r.commit(author, "x.txt", "x", "work on x")
253	r.push("x")
254	r.sh("checkout", "-q", "-B", "feat", "x")
255	head := r.commit(author, "fix.txt", "fix", fmt.Sprintf("Fixes #%d", issue.Number))
256	r.push("feat")
257	b, err := r.mrs.Create(r.ctx, root, r.repo, ports.CreateMergeRequestParams{Title: "B", Source: "x"})
258	require.NoError(t, err)
259	a, err := r.mrs.Create(r.ctx, root, r.repo, ports.CreateMergeRequestParams{Title: "A", Source: "feat", Target: "x"})
260	require.NoError(t, err)
261	before, err := r.store.IssueLinks().LinksOfMergeRequest(r.ctx, r.repo, b.Number)
262	require.NoError(t, err)
263	require.Empty(t, before)
264	_, err = r.mrs.Merge(r.ctx, root, r.repo, a.Number, ports.MergeParams{HeadID: head, Mode: domain.MergeModeFastForward})
265	require.NoError(t, err)
266	r.mrs.Wait(r.ctx)
267	after, err := r.store.IssueLinks().LinksOfMergeRequest(r.ctx, r.repo, b.Number)
268	require.NoError(t, err)
269	require.Len(t, after, 1)
270	assert.Equal(t, issue.Number, after[0].Issue)
271}
272
273func TestAutoMergeClosesLinkedIssue(t *testing.T) {
274	r, _, issues := linkedRig(t)
275	issue, err := issues.Create(r.ctx, root, r.repo, ports.CreateIssueParams{Title: "bug"})
276	require.NoError(t, err)
277	r.feature("feat")
278	mr, err := r.mrs.Create(r.ctx, root, r.repo, ports.CreateMergeRequestParams{Title: "fix", Source: "feat", Description: fmt.Sprintf("closes #%d", issue.Number)})
279	require.NoError(t, err)
280	_, err = r.repos.Update(r.ctx, root, r.repo, ports.UpdateRepoParams{AllowAutoMerge: ptr(true)})
281	require.NoError(t, err)
282	checks := mocks.NewCIChecksReader(t)
283	r.mrs.SetChecks(checks)
284	enableAuto(t, r, mr)
285	checks.EXPECT().CommitChecks(mock.Anything, r.repo, r.head().String()).Return([]domain.CICheck{{Status: domain.CICompleted, Conclusion: domain.CISuccess}}, nil).Twice()
286	a := autoEvaluator(r, checks, nil, nil)
287	defer a.Close(r.ctx)
288	require.True(t, a.Evaluate(r.repo))
289	r.mrs.Wait(r.ctx)
290	merged := storedAuto(t, r, mr)
291	require.Equal(t, domain.MRStateMerged, merged.State)
292	require.True(t, merged.AutoMerged)
293	got, err := issues.Get(r.ctx, root, r.repo, issue.Number)
294	require.NoError(t, err)
295	assert.False(t, got.Issue.IsOpen())
296}
297
298func TestMergeResolutionActorAndReferenceEvent(t *testing.T) {
299	for _, state := range []domain.IssueState{domain.IssueStateOpen, domain.IssueStateClosed, domain.IssueStateResolved} {
300		t.Run(string(state), func(t *testing.T) {
301			u := newIssueUnit(t, domain.VisibilityPublic, domain.IssuesOwner)
302			links := mocks.NewIssueLinkStore(t)
303			clock := mocks.NewClock(t)
304			clock.EXPECT().Now().Return(now).Maybe()
305			p := issuePrincipal(domain.ScopeIssueWrite)
306			title, err := domain.ParseMRTitle("Fixes #3")
307			require.NoError(t, err)
308			mr := domain.MergeRequest{Repo: u.repo.Name, Number: 4, Title: title, State: domain.MRStateMerged}
309			u.mrs.EXPECT().Get(mock.Anything, u.repo.Name, int64(4)).Return(mr, nil)
310			u.mrs.EXPECT().ListComments(mock.Anything, u.repo.Name, int64(4)).Return(nil, nil)
311			u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(3)).Return(domain.Issue{Repo: u.repo.Name, Number: 3, State: state}, nil).Twice()
312			links.EXPECT().ReplaceLinks(mock.Anything, u.repo.Name, int64(4), []int64{3}, []int64{3}, ports.ReferenceEvent{Actor: "agent-1", At: now}).Return(nil, nil, nil)
313			links.EXPECT().LinksOfMergeRequest(mock.Anything, u.repo.Name, int64(4)).Return(nil, nil)
314			if state == domain.IssueStateOpen {
315				u.store.EXPECT().Update(mock.Anything, mock.MatchedBy(func(i domain.Issue) bool { return i.State == domain.IssueStateResolved && i.ClosedByMergeRequest == 4 }), mock.MatchedBy(func(events []domain.IssueEvent) bool {
316					return len(events) == 1 && events[0].Kind == domain.IssueEventResolved && events[0].MergeRequest == 4 && events[0].Actor == "agent-1" && events[0].CreatedAt.Equal(now)
317				})).Return(nil).Once()
318				u.bus.EXPECT().Publish(mock.Anything, mock.MatchedBy(func(e domain.Event) bool { return e.EventKind() == "issue.resolved" })).Return().Once()
319			}
320			svc := usecase.NewIssueLinks(usecase.IssueLinksConfig{Meta: u.meta, Issues: u.store, MRs: u.mrs, Links: links, Bus: u.bus, Clock: clock})
321			svc.OnMerged(context.Background(), p, u.repo, mr)
322		})
323	}
324}

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 4

Tags 45

↑↓ moveenter openesc close