issue_api_test.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app
4
5import (
6	"testing"
7
8	"connectrpc.com/connect"
9	"github.com/stretchr/testify/assert"
10	"github.com/stretchr/testify/require"
11	"google.golang.org/protobuf/types/known/fieldmaskpb"
12
13	pb "bnema.dev/ermite-api/gen/ermite"
14	"bnema.dev/ermite/internal/domain"
15	"bnema.dev/ermite/internal/ports"
16)
17
18// issueRig is a harness with the repositories pub and priv, and one caller per
19// kind of principal.
20type issueRig struct {
21	*harness
22	anon, root, reader, writer, blind, branch *clients
23}
24
25func newIssueRig(t *testing.T) *issueRig {
26	t.Helper()
27	h := newHarness(t, harnessOpts{})
28	r := &issueRig{harness: h}
29	h.seed("pub", domain.VisibilityPublic)
30	h.seed("priv", domain.VisibilityPrivate)
31	r.anon, r.root = h.api(""), h.api(adminToken)
32	// reader has no issue:write; writer reads and writes; blind holds only
33	// issue:write; branch is writer limited to branch prefixes.
34	r.reader = h.api(h.token(scopes(domain.ScopeRepoRead)))
35	r.writer = h.api(h.token(scopes(domain.ScopeRepoRead, domain.ScopeIssueWrite)))
36	r.blind = h.api(h.token(scopes(domain.ScopeIssueWrite)))
37	secret, _ := h.branchToken(scopes(domain.ScopeRepoRead, domain.ScopeRepoWrite, domain.ScopeIssueWrite), []string{"agent/"})
38	r.branch = h.api(secret)
39	return r
40}
41
42func (r *issueRig) mode(repo string, mode domain.IssuesMode) {
43	r.t.Helper()
44	_, err := r.forge.Repos.Update(r.ctx, domain.Root(), r.name(repo), ports.UpdateRepoParams{Issues: &mode})
45	require.NoError(r.t, err)
46}
47
48func TestIssueAccessMatrixOverTheAPI(t *testing.T) {
49	r := newIssueRig(t)
50	ctx := r.ctx
51	type who struct {
52		name string
53		c    *clients
54	}
55	all := []who{{"anonymous", r.anon}, {"reader", r.reader}, {"writer", r.writer}, {"blind", r.blind}, {"branch-limited", r.branch}, {"root", r.root}}
56
57	calls := func(repo string) (reads, writes map[string]func(c *clients) error) {
58		reads = map[string]func(c *clients) error{
59			"ListIssues": func(c *clients) error {
60				_, err := c.issue.ListIssues(ctx, connect.NewRequest(&pb.ListIssuesRequest{Repo: repo}))
61				return err
62			},
63			"GetIssue": func(c *clients) error {
64				_, err := c.issue.GetIssue(ctx, connect.NewRequest(&pb.GetIssueRequest{Repo: repo, Number: 1}))
65				return err
66			},
67		}
68		writes = map[string]func(c *clients) error{
69			"CreateIssue": func(c *clients) error {
70				_, err := c.issue.CreateIssue(ctx, connect.NewRequest(&pb.CreateIssueRequest{Repo: repo, Title: "t", Body: "b"}))
71				return err
72			},
73			"AddIssueComment": func(c *clients) error {
74				_, err := c.issue.AddIssueComment(ctx, connect.NewRequest(&pb.AddIssueCommentRequest{Repo: repo, Number: 1, Text: "x"}))
75				return err
76			},
77			"ResolveIssue": func(c *clients) error {
78				_, err := c.issue.ResolveIssue(ctx, connect.NewRequest(&pb.ResolveIssueRequest{Repo: repo, Number: 1}))
79				return err
80			},
81			"CloseIssue": func(c *clients) error {
82				_, err := c.issue.CloseIssue(ctx, connect.NewRequest(&pb.CloseIssueRequest{Repo: repo, Number: 1}))
83				return err
84			},
85			"ReopenIssue": func(c *clients) error {
86				_, err := c.issue.ReopenIssue(ctx, connect.NewRequest(&pb.ReopenIssueRequest{Repo: repo, Number: 1}))
87				return err
88			},
89		}
90		return reads, writes
91	}
92	// setState puts issue 1 in the state a call needs to succeed.
93	setState := func(repo, call string) {
94		name := r.name(repo)
95		if call == "ReopenIssue" {
96			_, _ = r.forge.Issues.Close(ctx, domain.Root(), name, 1)
97		} else {
98			_, _ = r.forge.Issues.Reopen(ctx, domain.Root(), name, 1)
99		}
100	}
101
102	// Mode owner: the first issue of each repository is #1.
103	for _, repo := range []string{"pub", "priv"} {
104		r.mode(repo, domain.IssuesOwner)
105		_, err := r.forge.Issues.Create(ctx, domain.Root(), r.name(repo), ports.CreateIssueParams{Title: "first"})
106		require.NoError(t, err)
107	}
108
109	readWant := map[string]map[string]string{
110		"priv": {"anonymous": "not_found", "reader": "ok", "writer": "ok", "blind": "not_found", "branch-limited": "ok", "root": "ok"},
111		"pub":  {"anonymous": "ok", "reader": "ok", "writer": "ok", "blind": "ok", "branch-limited": "ok", "root": "ok"},
112	}
113	writeWant := map[string]map[string]string{
114		// A token with issue:write alone sees the repository for the action,
115		// as mr:write does; a reader without the scope is refused.
116		"priv": {"anonymous": "not_found", "reader": "permission_denied", "writer": "ok", "blind": "ok", "branch-limited": "ok", "root": "ok"},
117		"pub":  {"anonymous": "unauthenticated", "reader": "permission_denied", "writer": "ok", "blind": "ok", "branch-limited": "ok", "root": "ok"},
118	}
119	for _, repo := range []string{"priv", "pub"} {
120		reads, writes := calls(repo)
121		for _, w := range all {
122			for name, call := range reads {
123				assert.Equal(t, readWant[repo][w.name], codeName(call(w.c)), "%s %s %s", repo, w.name, name)
124			}
125			for name, call := range writes {
126				setState(repo, name)
127				assert.Equal(t, writeWant[repo][w.name], codeName(call(w.c)), "%s %s %s", repo, w.name, name)
128			}
129		}
130	}
131
132	// Issues off, an unknown repository: not found for everyone, root included.
133	r.mode("priv", domain.IssuesOff)
134	r.mode("pub", domain.IssuesOff)
135	for _, repo := range []string{"priv", "pub", "nope"} {
136		reads, writes := calls(repo)
137		for _, w := range all {
138			for name, call := range reads {
139				assert.Equal(t, "not_found", codeName(call(w.c)), "%s %s %s", repo, w.name, name)
140			}
141			for name, call := range writes {
142				assert.Equal(t, "not_found", codeName(call(w.c)), "%s %s %s", repo, w.name, name)
143			}
144		}
145	}
146}
147
148func TestIssueLifecycleAndSharedNumbersOverTheAPI(t *testing.T) {
149	r := newMRRig(t, domain.VisibilityPublic)
150	ctx := r.ctx
151	_, err := r.forge.Repos.Update(ctx, domain.Root(), r.name("proj"), ports.UpdateRepoParams{Issues: new(domain.IssuesOwner)})
152	require.NoError(t, err)
153
154	mr, err := r.create(r.root)
155	require.NoError(t, err)
156	assert.EqualValues(t, 1, mr.GetNumber())
157
158	created, err := r.root.issue.CreateIssue(ctx, connect.NewRequest(&pb.CreateIssueRequest{Repo: "proj", Title: "Crash", Body: "it crashes"}))
159	require.NoError(t, err)
160	issue := created.Msg.GetIssue()
161	assert.EqualValues(t, 2, issue.GetNumber(), "numbers are shared with merge requests")
162	assert.Equal(t, pb.IssueState_ISSUE_STATE_OPEN, issue.GetState())
163	assert.Equal(t, "root", issue.GetAuthor())
164
165	// An issue is not a merge request, and the other way round.
166	_, err = r.root.mr.GetMergeRequest(ctx, connect.NewRequest(&pb.GetMergeRequestRequest{Repo: "proj", Number: 2}))
167	assert.Equal(t, "not_found", codeName(err))
168	_, err = r.root.issue.GetIssue(ctx, connect.NewRequest(&pb.GetIssueRequest{Repo: "proj", Number: 1}))
169	assert.Equal(t, "not_found", codeName(err))
170
171	_, err = r.root.issue.AddIssueComment(ctx, connect.NewRequest(&pb.AddIssueCommentRequest{Repo: "proj", Number: 2, Text: "seen"}))
172	require.NoError(t, err)
173	_, err = r.root.issue.AddIssueComment(ctx, connect.NewRequest(&pb.AddIssueCommentRequest{Repo: "proj", Number: 2, Text: " "}))
174	assert.Equal(t, "invalid_argument", codeName(err), "a blank comment")
175	_, err = r.root.issue.CreateIssue(ctx, connect.NewRequest(&pb.CreateIssueRequest{Repo: "proj", Title: ""}))
176	assert.Equal(t, "invalid_argument", codeName(err), "an empty title")
177
178	_, err = r.root.issue.ReopenIssue(ctx, connect.NewRequest(&pb.ReopenIssueRequest{Repo: "proj", Number: 2}))
179	assert.Equal(t, "failed_precondition", codeName(err), "reopening an open issue")
180	closed, err := r.root.issue.CloseIssue(ctx, connect.NewRequest(&pb.CloseIssueRequest{Repo: "proj", Number: 2}))
181	require.NoError(t, err)
182	assert.Equal(t, pb.IssueState_ISSUE_STATE_CLOSED, closed.Msg.GetIssue().GetState())
183	_, err = r.root.issue.CloseIssue(ctx, connect.NewRequest(&pb.CloseIssueRequest{Repo: "proj", Number: 2}))
184	assert.Equal(t, "failed_precondition", codeName(err), "closing a closed issue")
185
186	got, err := r.anon.issue.GetIssue(ctx, connect.NewRequest(&pb.GetIssueRequest{Repo: "proj", Number: 2}))
187	require.NoError(t, err)
188	assert.Equal(t, pb.IssueState_ISSUE_STATE_CLOSED, got.Msg.GetIssue().GetState())
189	assert.EqualValues(t, 1, got.Msg.GetIssue().GetCommentCount())
190	require.Len(t, got.Msg.GetComments(), 1)
191	assert.Equal(t, "seen", got.Msg.GetComments()[0].GetText())
192
193	reopened, err := r.root.issue.ReopenIssue(ctx, connect.NewRequest(&pb.ReopenIssueRequest{Repo: "proj", Number: 2}))
194	require.NoError(t, err)
195	assert.Equal(t, pb.IssueState_ISSUE_STATE_OPEN, reopened.Msg.GetIssue().GetState())
196
197	list, err := r.anon.issue.ListIssues(ctx, connect.NewRequest(&pb.ListIssuesRequest{Repo: "proj", State: pb.IssueState_ISSUE_STATE_OPEN}))
198	require.NoError(t, err)
199	require.Len(t, list.Msg.GetIssues(), 1)
200	list, err = r.anon.issue.ListIssues(ctx, connect.NewRequest(&pb.ListIssuesRequest{Repo: "proj", State: pb.IssueState_ISSUE_STATE_CLOSED}))
201	require.NoError(t, err)
202	assert.Empty(t, list.Msg.GetIssues())
203}
204
205func TestIssuesFeatureAndSettingOverTheAPI(t *testing.T) {
206	r := newIssueRig(t)
207	ctx := r.ctx
208	ver, err := r.anon.server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{}))
209	require.NoError(t, err)
210	assert.Contains(t, ver.Msg.GetFeatures(), "issues")
211	assert.Contains(t, ver.Msg.GetFeatures(), "issues.resolve")
212
213	update := func(c *clients, mode pb.IssuesMode) (*pb.Repo, error) {
214		res, err := c.repo.UpdateRepo(ctx, connect.NewRequest(&pb.UpdateRepoRequest{
215			Repo: "priv", UpdateMask: &fieldmaskpb.FieldMask{}, Issues: &mode,
216		}))
217		if err != nil {
218			return nil, err
219		}
220		return res.Msg.GetRepo(), nil
221	}
222	repo, err := r.root.repo.GetRepo(ctx, connect.NewRequest(&pb.GetRepoRequest{Repo: "priv"}))
223	require.NoError(t, err)
224	assert.Equal(t, pb.IssuesMode_ISSUES_MODE_OFF, repo.Msg.GetRepo().GetSettings().GetIssues())
225
226	updated, err := update(r.root, pb.IssuesMode_ISSUES_MODE_OWNER)
227	require.NoError(t, err)
228	assert.Equal(t, pb.IssuesMode_ISSUES_MODE_OWNER, updated.GetSettings().GetIssues())
229	_, err = r.root.issue.CreateIssue(ctx, connect.NewRequest(&pb.CreateIssueRequest{Repo: "priv", Title: "t"}))
230	require.NoError(t, err)
231
232	updated, err = update(r.root, pb.IssuesMode_ISSUES_MODE_PUBLIC)
233	require.NoError(t, err, "public issues are accepted")
234	assert.Equal(t, pb.IssuesMode_ISSUES_MODE_PUBLIC, updated.GetSettings().GetIssues())
235	_, err = update(r.root, pb.IssuesMode_ISSUES_MODE_OWNER)
236	require.NoError(t, err)
237	_, err = update(r.root, pb.IssuesMode_ISSUES_MODE_UNSPECIFIED)
238	assert.Equal(t, "invalid_argument", codeName(err))
239	repo, err = r.root.repo.GetRepo(ctx, connect.NewRequest(&pb.GetRepoRequest{Repo: "priv"}))
240	require.NoError(t, err)
241	assert.Equal(t, pb.IssuesMode_ISSUES_MODE_OWNER, repo.Msg.GetRepo().GetSettings().GetIssues())
242
243	_, err = update(r.writer, pb.IssuesMode_ISSUES_MODE_OFF)
244	assert.Equal(t, "permission_denied", codeName(err), "repo:admin is needed")
245	_, err = update(r.anon, pb.IssuesMode_ISSUES_MODE_OFF)
246	assert.Equal(t, "not_found", codeName(err), "anonymous cannot see the private repository")
247	_, err = update(r.root, pb.IssuesMode_ISSUES_MODE_OFF)
248	require.NoError(t, err)
249}
250
251func TestIssueReferencesAndClosedByOverTheAPI(t *testing.T) {
252	r := newMRRig(t, domain.VisibilityPublic)
253	ctx := r.ctx
254	_, err := r.forge.Repos.Update(ctx, domain.Root(), r.name("proj"), ports.UpdateRepoParams{Issues: new(domain.IssuesOwner)})
255	require.NoError(t, err)
256	for _, title := range []string{"Crash", "Slow"} {
257		_, err = r.root.issue.CreateIssue(ctx, connect.NewRequest(&pb.CreateIssueRequest{Repo: "proj", Title: title}))
258		require.NoError(t, err)
259	}
260	// The merge request mentions #1 (closing) and #2 (plain).
261	created, err := r.merger.mr.CreateMergeRequest(ctx, connect.NewRequest(&pb.CreateMergeRequestRequest{
262		Repo: "proj", Title: "Change a", Description: "Fixes #1, see also #2", SourceBranch: "feat",
263	}))
264	require.NoError(t, err)
265	number := created.Msg.GetMergeRequest().GetNumber()
266
267	get := func(n int64) *pb.GetIssueResponse {
268		res, err := r.anon.issue.GetIssue(ctx, connect.NewRequest(&pb.GetIssueRequest{Repo: "proj", Number: n}))
269		require.NoError(t, err)
270		return res.Msg
271	}
272	one := get(1)
273	require.Len(t, one.GetReferences(), 1)
274	ref := one.GetReferences()[0]
275	assert.Equal(t, number, ref.GetNumber())
276	assert.Equal(t, "Change a", ref.GetTitle())
277	assert.Equal(t, pb.MergeRequestState_MERGE_REQUEST_STATE_OPEN, ref.GetState())
278	assert.True(t, ref.GetCloses())
279	two := get(2)
280	require.Len(t, two.GetReferences(), 1)
281	assert.False(t, two.GetReferences()[0].GetCloses())
282	assert.Zero(t, one.GetIssue().GetClosedByMergeRequest())
283
284	watch := r.watch(r.anon, "", "repo:proj")
285	_, err = r.merger.mr.MergeMergeRequest(ctx, connect.NewRequest(&pb.MergeMergeRequestRequest{Repo: "proj", Number: number, HeadId: r.head}))
286	require.NoError(t, err)
287	r.forge.MergeRequests.Wait(ctx)
288	var streamed *pb.IssueResolved
289	for range 8 {
290		event := watch.next(t, watchWait)
291		if streamed = event.GetIssueResolved(); streamed != nil {
292			break
293		}
294	}
295	require.NotNil(t, streamed, "merge publishes issue_resolved")
296	assert.EqualValues(t, 1, streamed.GetIssue().GetNumber())
297	assert.Equal(t, pb.IssueState_ISSUE_STATE_RESOLVED, streamed.GetIssue().GetState())
298
299	one = get(1)
300	assert.Equal(t, pb.IssueState_ISSUE_STATE_RESOLVED, one.GetIssue().GetState())
301	assert.Equal(t, number, one.GetIssue().GetClosedByMergeRequest())
302	require.Len(t, one.GetEvents(), 2)
303	referenced, resolved := one.GetEvents()[0], one.GetEvents()[1]
304	assert.Equal(t, pb.IssueEventKind_ISSUE_EVENT_KIND_REFERENCED, referenced.GetKind())
305	assert.True(t, referenced.GetCloses())
306	assert.Equal(t, "Change a", referenced.GetMergeRequestTitle())
307	assert.Equal(t, pb.IssueEventKind_ISSUE_EVENT_KIND_RESOLVED, resolved.GetKind())
308	assert.Equal(t, number, resolved.GetMergeRequest())
309	assert.NotEmpty(t, resolved.GetActor())
310	assert.Equal(t, referenced.GetActor(), resolved.GetActor(), "same token opened and merged")
311	detail, err := r.anon.mr.GetMergeRequest(ctx, connect.NewRequest(&pb.GetMergeRequestRequest{Repo: "proj", Number: number}))
312	require.NoError(t, err)
313	require.Len(t, detail.Msg.GetIssues(), 2)
314	assert.EqualValues(t, 1, detail.Msg.GetIssues()[0].GetNumber())
315	assert.Equal(t, pb.MergeRequestIssueState_MERGE_REQUEST_ISSUE_STATE_RESOLVED, detail.Msg.GetIssues()[0].GetState())
316	assert.True(t, detail.Msg.GetIssues()[0].GetCloses())
317	require.Len(t, detail.Msg.GetIssueEvents(), 3)
318
319	require.Len(t, one.GetReferences(), 1)
320	assert.Equal(t, pb.MergeRequestState_MERGE_REQUEST_STATE_MERGED, one.GetReferences()[0].GetState())
321	two = get(2)
322	assert.Equal(t, pb.IssueState_ISSUE_STATE_OPEN, two.GetIssue().GetState(), "a plain mention does not close")
323	assert.Zero(t, two.GetIssue().GetClosedByMergeRequest())
324
325	// Reopening by hand clears "closed by".
326	reopened, err := r.root.issue.ReopenIssue(ctx, connect.NewRequest(&pb.ReopenIssueRequest{Repo: "proj", Number: 1}))
327	require.NoError(t, err)
328	assert.Zero(t, reopened.Msg.GetIssue().GetClosedByMergeRequest())
329}
330
331func TestIssueResolveRoundTripOverTheAPI(t *testing.T) {
332	r := newIssueRig(t)
333	r.mode("pub", domain.IssuesOwner)
334	_, err := r.root.issue.CreateIssue(r.ctx, connect.NewRequest(&pb.CreateIssueRequest{Repo: "pub", Title: "Fix"}))
335	require.NoError(t, err)
336	resolved, err := r.root.issue.ResolveIssue(r.ctx, connect.NewRequest(&pb.ResolveIssueRequest{Repo: "pub", Number: 1}))
337	require.NoError(t, err)
338	assert.Equal(t, pb.IssueState_ISSUE_STATE_RESOLVED, resolved.Msg.GetIssue().GetState())
339	assert.Zero(t, resolved.Msg.GetIssue().GetClosedByMergeRequest())
340	_, err = r.root.issue.ResolveIssue(r.ctx, connect.NewRequest(&pb.ResolveIssueRequest{Repo: "pub", Number: 1}))
341	assert.Equal(t, "failed_precondition", codeName(err))
342	list, err := r.anon.issue.ListIssues(r.ctx, connect.NewRequest(&pb.ListIssuesRequest{Repo: "pub", State: pb.IssueState_ISSUE_STATE_RESOLVED}))
343	require.NoError(t, err)
344	require.Len(t, list.Msg.GetIssues(), 1)
345	reopened, err := r.root.issue.ReopenIssue(r.ctx, connect.NewRequest(&pb.ReopenIssueRequest{Repo: "pub", Number: 1}))
346	require.NoError(t, err)
347	assert.Equal(t, pb.IssueState_ISSUE_STATE_OPEN, reopened.Msg.GetIssue().GetState())
348	d, err := r.anon.issue.GetIssue(r.ctx, connect.NewRequest(&pb.GetIssueRequest{Repo: "pub", Number: 1}))
349	require.NoError(t, err)
350	require.Len(t, d.Msg.GetEvents(), 2)
351	assert.Equal(t, pb.IssueEventKind_ISSUE_EVENT_KIND_RESOLVED, d.Msg.GetEvents()[0].GetKind())
352	assert.Equal(t, "root", d.Msg.GetEvents()[0].GetActor())
353	assert.Equal(t, pb.IssueEventKind_ISSUE_EVENT_KIND_REOPENED, d.Msg.GetEvents()[1].GetKind())
354}

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