detached 59d5a0b

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			"CloseIssue": func(c *clients) error {
78				_, err := c.issue.CloseIssue(ctx, connect.NewRequest(&pb.CloseIssueRequest{Repo: repo, Number: 1}))
79				return err
80			},
81			"ReopenIssue": func(c *clients) error {
82				_, err := c.issue.ReopenIssue(ctx, connect.NewRequest(&pb.ReopenIssueRequest{Repo: repo, Number: 1}))
83				return err
84			},
85		}
86		return reads, writes
87	}
88	// setState puts issue 1 in the state a call needs to succeed.
89	setState := func(repo, call string) {
90		name := r.name(repo)
91		if call == "ReopenIssue" {
92			_, _ = r.forge.Issues.Close(ctx, domain.Root(), name, 1)
93		} else {
94			_, _ = r.forge.Issues.Reopen(ctx, domain.Root(), name, 1)
95		}
96	}
97
98	// Mode owner: the first issue of each repository is #1.
99	for _, repo := range []string{"pub", "priv"} {
100		r.mode(repo, domain.IssuesOwner)
101		_, err := r.forge.Issues.Create(ctx, domain.Root(), r.name(repo), ports.CreateIssueParams{Title: "first"})
102		require.NoError(t, err)
103	}
104
105	readWant := map[string]map[string]string{
106		"priv": {"anonymous": "not_found", "reader": "ok", "writer": "ok", "blind": "not_found", "branch-limited": "ok", "root": "ok"},
107		"pub":  {"anonymous": "ok", "reader": "ok", "writer": "ok", "blind": "ok", "branch-limited": "ok", "root": "ok"},
108	}
109	writeWant := map[string]map[string]string{
110		// A token with issue:write alone sees the repository for the action,
111		// as mr:write does; a reader without the scope is refused.
112		"priv": {"anonymous": "not_found", "reader": "permission_denied", "writer": "ok", "blind": "ok", "branch-limited": "ok", "root": "ok"},
113		"pub":  {"anonymous": "unauthenticated", "reader": "permission_denied", "writer": "ok", "blind": "ok", "branch-limited": "ok", "root": "ok"},
114	}
115	for _, repo := range []string{"priv", "pub"} {
116		reads, writes := calls(repo)
117		for _, w := range all {
118			for name, call := range reads {
119				assert.Equal(t, readWant[repo][w.name], codeName(call(w.c)), "%s %s %s", repo, w.name, name)
120			}
121			for name, call := range writes {
122				setState(repo, name)
123				assert.Equal(t, writeWant[repo][w.name], codeName(call(w.c)), "%s %s %s", repo, w.name, name)
124			}
125		}
126	}
127
128	// Issues off, an unknown repository: not found for everyone, root included.
129	r.mode("priv", domain.IssuesOff)
130	r.mode("pub", domain.IssuesOff)
131	for _, repo := range []string{"priv", "pub", "nope"} {
132		reads, writes := calls(repo)
133		for _, w := range all {
134			for name, call := range reads {
135				assert.Equal(t, "not_found", codeName(call(w.c)), "%s %s %s", repo, w.name, name)
136			}
137			for name, call := range writes {
138				assert.Equal(t, "not_found", codeName(call(w.c)), "%s %s %s", repo, w.name, name)
139			}
140		}
141	}
142}
143
144func TestIssueLifecycleAndSharedNumbersOverTheAPI(t *testing.T) {
145	r := newMRRig(t, domain.VisibilityPublic)
146	ctx := r.ctx
147	_, err := r.forge.Repos.Update(ctx, domain.Root(), r.name("proj"), ports.UpdateRepoParams{Issues: new(domain.IssuesOwner)})
148	require.NoError(t, err)
149
150	mr, err := r.create(r.root)
151	require.NoError(t, err)
152	assert.EqualValues(t, 1, mr.GetNumber())
153
154	created, err := r.root.issue.CreateIssue(ctx, connect.NewRequest(&pb.CreateIssueRequest{Repo: "proj", Title: "Crash", Body: "it crashes"}))
155	require.NoError(t, err)
156	issue := created.Msg.GetIssue()
157	assert.EqualValues(t, 2, issue.GetNumber(), "numbers are shared with merge requests")
158	assert.Equal(t, pb.IssueState_ISSUE_STATE_OPEN, issue.GetState())
159	assert.Equal(t, "root", issue.GetAuthor())
160
161	// An issue is not a merge request, and the other way round.
162	_, err = r.root.mr.GetMergeRequest(ctx, connect.NewRequest(&pb.GetMergeRequestRequest{Repo: "proj", Number: 2}))
163	assert.Equal(t, "not_found", codeName(err))
164	_, err = r.root.issue.GetIssue(ctx, connect.NewRequest(&pb.GetIssueRequest{Repo: "proj", Number: 1}))
165	assert.Equal(t, "not_found", codeName(err))
166
167	_, err = r.root.issue.AddIssueComment(ctx, connect.NewRequest(&pb.AddIssueCommentRequest{Repo: "proj", Number: 2, Text: "seen"}))
168	require.NoError(t, err)
169	_, err = r.root.issue.AddIssueComment(ctx, connect.NewRequest(&pb.AddIssueCommentRequest{Repo: "proj", Number: 2, Text: " "}))
170	assert.Equal(t, "invalid_argument", codeName(err), "a blank comment")
171	_, err = r.root.issue.CreateIssue(ctx, connect.NewRequest(&pb.CreateIssueRequest{Repo: "proj", Title: ""}))
172	assert.Equal(t, "invalid_argument", codeName(err), "an empty title")
173
174	_, err = r.root.issue.ReopenIssue(ctx, connect.NewRequest(&pb.ReopenIssueRequest{Repo: "proj", Number: 2}))
175	assert.Equal(t, "failed_precondition", codeName(err), "reopening an open issue")
176	closed, err := r.root.issue.CloseIssue(ctx, connect.NewRequest(&pb.CloseIssueRequest{Repo: "proj", Number: 2}))
177	require.NoError(t, err)
178	assert.Equal(t, pb.IssueState_ISSUE_STATE_CLOSED, closed.Msg.GetIssue().GetState())
179	_, err = r.root.issue.CloseIssue(ctx, connect.NewRequest(&pb.CloseIssueRequest{Repo: "proj", Number: 2}))
180	assert.Equal(t, "failed_precondition", codeName(err), "closing a closed issue")
181
182	got, err := r.anon.issue.GetIssue(ctx, connect.NewRequest(&pb.GetIssueRequest{Repo: "proj", Number: 2}))
183	require.NoError(t, err)
184	assert.Equal(t, pb.IssueState_ISSUE_STATE_CLOSED, got.Msg.GetIssue().GetState())
185	assert.EqualValues(t, 1, got.Msg.GetIssue().GetCommentCount())
186	require.Len(t, got.Msg.GetComments(), 1)
187	assert.Equal(t, "seen", got.Msg.GetComments()[0].GetText())
188
189	reopened, err := r.root.issue.ReopenIssue(ctx, connect.NewRequest(&pb.ReopenIssueRequest{Repo: "proj", Number: 2}))
190	require.NoError(t, err)
191	assert.Equal(t, pb.IssueState_ISSUE_STATE_OPEN, reopened.Msg.GetIssue().GetState())
192
193	list, err := r.anon.issue.ListIssues(ctx, connect.NewRequest(&pb.ListIssuesRequest{Repo: "proj", State: pb.IssueState_ISSUE_STATE_OPEN}))
194	require.NoError(t, err)
195	require.Len(t, list.Msg.GetIssues(), 1)
196	list, err = r.anon.issue.ListIssues(ctx, connect.NewRequest(&pb.ListIssuesRequest{Repo: "proj", State: pb.IssueState_ISSUE_STATE_CLOSED}))
197	require.NoError(t, err)
198	assert.Empty(t, list.Msg.GetIssues())
199}
200
201func TestIssuesFeatureAndSettingOverTheAPI(t *testing.T) {
202	r := newIssueRig(t)
203	ctx := r.ctx
204	ver, err := r.anon.server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{}))
205	require.NoError(t, err)
206	assert.Contains(t, ver.Msg.GetFeatures(), "issues")
207
208	update := func(c *clients, mode pb.IssuesMode) (*pb.Repo, error) {
209		res, err := c.repo.UpdateRepo(ctx, connect.NewRequest(&pb.UpdateRepoRequest{
210			Repo: "priv", UpdateMask: &fieldmaskpb.FieldMask{}, Issues: &mode,
211		}))
212		if err != nil {
213			return nil, err
214		}
215		return res.Msg.GetRepo(), nil
216	}
217	repo, err := r.root.repo.GetRepo(ctx, connect.NewRequest(&pb.GetRepoRequest{Repo: "priv"}))
218	require.NoError(t, err)
219	assert.Equal(t, pb.IssuesMode_ISSUES_MODE_OFF, repo.Msg.GetRepo().GetSettings().GetIssues())
220
221	updated, err := update(r.root, pb.IssuesMode_ISSUES_MODE_OWNER)
222	require.NoError(t, err)
223	assert.Equal(t, pb.IssuesMode_ISSUES_MODE_OWNER, updated.GetSettings().GetIssues())
224	_, err = r.root.issue.CreateIssue(ctx, connect.NewRequest(&pb.CreateIssueRequest{Repo: "priv", Title: "t"}))
225	require.NoError(t, err)
226
227	updated, err = update(r.root, pb.IssuesMode_ISSUES_MODE_PUBLIC)
228	require.NoError(t, err, "public issues are accepted")
229	assert.Equal(t, pb.IssuesMode_ISSUES_MODE_PUBLIC, updated.GetSettings().GetIssues())
230	_, err = update(r.root, pb.IssuesMode_ISSUES_MODE_OWNER)
231	require.NoError(t, err)
232	_, err = update(r.root, pb.IssuesMode_ISSUES_MODE_UNSPECIFIED)
233	assert.Equal(t, "invalid_argument", codeName(err))
234	repo, err = r.root.repo.GetRepo(ctx, connect.NewRequest(&pb.GetRepoRequest{Repo: "priv"}))
235	require.NoError(t, err)
236	assert.Equal(t, pb.IssuesMode_ISSUES_MODE_OWNER, repo.Msg.GetRepo().GetSettings().GetIssues())
237
238	_, err = update(r.writer, pb.IssuesMode_ISSUES_MODE_OFF)
239	assert.Equal(t, "permission_denied", codeName(err), "repo:admin is needed")
240	_, err = update(r.anon, pb.IssuesMode_ISSUES_MODE_OFF)
241	assert.Equal(t, "not_found", codeName(err), "anonymous cannot see the private repository")
242	_, err = update(r.root, pb.IssuesMode_ISSUES_MODE_OFF)
243	require.NoError(t, err)
244}
245
246func TestIssueReferencesAndClosedByOverTheAPI(t *testing.T) {
247	r := newMRRig(t, domain.VisibilityPublic)
248	ctx := r.ctx
249	_, err := r.forge.Repos.Update(ctx, domain.Root(), r.name("proj"), ports.UpdateRepoParams{Issues: new(domain.IssuesOwner)})
250	require.NoError(t, err)
251	for _, title := range []string{"Crash", "Slow"} {
252		_, err = r.root.issue.CreateIssue(ctx, connect.NewRequest(&pb.CreateIssueRequest{Repo: "proj", Title: title}))
253		require.NoError(t, err)
254	}
255	// The merge request mentions #1 (closing) and #2 (plain).
256	created, err := r.merger.mr.CreateMergeRequest(ctx, connect.NewRequest(&pb.CreateMergeRequestRequest{
257		Repo: "proj", Title: "Change a", Description: "Fixes #1, see also #2", SourceBranch: "feat",
258	}))
259	require.NoError(t, err)
260	number := created.Msg.GetMergeRequest().GetNumber()
261
262	get := func(n int64) *pb.GetIssueResponse {
263		res, err := r.anon.issue.GetIssue(ctx, connect.NewRequest(&pb.GetIssueRequest{Repo: "proj", Number: n}))
264		require.NoError(t, err)
265		return res.Msg
266	}
267	one := get(1)
268	require.Len(t, one.GetReferences(), 1)
269	ref := one.GetReferences()[0]
270	assert.Equal(t, number, ref.GetNumber())
271	assert.Equal(t, "Change a", ref.GetTitle())
272	assert.Equal(t, pb.MergeRequestState_MERGE_REQUEST_STATE_OPEN, ref.GetState())
273	assert.True(t, ref.GetCloses())
274	two := get(2)
275	require.Len(t, two.GetReferences(), 1)
276	assert.False(t, two.GetReferences()[0].GetCloses())
277	assert.Zero(t, one.GetIssue().GetClosedByMergeRequest())
278
279	_, err = r.merger.mr.MergeMergeRequest(ctx, connect.NewRequest(&pb.MergeMergeRequestRequest{Repo: "proj", Number: number, HeadId: r.head}))
280	require.NoError(t, err)
281	r.forge.MergeRequests.Wait(ctx)
282
283	one = get(1)
284	assert.Equal(t, pb.IssueState_ISSUE_STATE_CLOSED, one.GetIssue().GetState())
285	assert.Equal(t, number, one.GetIssue().GetClosedByMergeRequest())
286	require.Len(t, one.GetReferences(), 1)
287	assert.Equal(t, pb.MergeRequestState_MERGE_REQUEST_STATE_MERGED, one.GetReferences()[0].GetState())
288	two = get(2)
289	assert.Equal(t, pb.IssueState_ISSUE_STATE_OPEN, two.GetIssue().GetState(), "a plain mention does not close")
290	assert.Zero(t, two.GetIssue().GetClosedByMergeRequest())
291
292	// Reopening by hand clears "closed by".
293	reopened, err := r.root.issue.ReopenIssue(ctx, connect.NewRequest(&pb.ReopenIssueRequest{Repo: "proj", Number: 1}))
294	require.NoError(t, err)
295	assert.Zero(t, reopened.Msg.GetIssue().GetClosedByMergeRequest())
296}

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