issues.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package usecase
4
5import (
6	"context"
7	"errors"
8	"fmt"
9
10	"github.com/bnema/zerowrap"
11
12	"bnema.dev/ermite/internal/domain"
13	"bnema.dev/ermite/internal/ports"
14)
15
16// MaxCommentsPerIssue is the most comments an issue holds. Past it AddComment
17// fails with domain.ErrResourceExhausted. Comments are never deleted, so the
18// limit counts the ones ever added.
19const MaxCommentsPerIssue = 1000
20
21// Issues are the issue use cases (ADR 0031): open, comment on, close and
22// reopen an issue. Issues share their numbers with the merge requests of the
23// repository. A repository whose issues mode is off answers domain.ErrNotFound
24// to everyone, the owner included, like a repository that does not exist.
25// Reading needs the access of reading the repository (canRead), writing needs
26// issue:write on it, which the branch prefixes of a token do not restrict:
27// an issue writes no ref.
28type Issues struct {
29	links ports.IssueLinkStore
30	meta  ports.RepoMetaStore
31	store ports.IssueStore
32	mrs   ports.MergeRequestStore
33	bus   ports.EventBus
34	clock ports.Clock
35}
36
37var (
38	_ ports.IssueService        = (*Issues)(nil)
39	_ ports.IssueNumberResolver = (*Issues)(nil)
40)
41
42// IssuesConfig holds what the issue use cases need. Every field is required.
43type IssuesConfig struct {
44	Links ports.IssueLinkStore
45	Meta  ports.RepoMetaStore
46	Store ports.IssueStore
47	// MergeRequests tells which numbers name a merge request (Kind).
48	MergeRequests ports.MergeRequestStore
49	Bus           ports.EventBus
50	Clock         ports.Clock
51}
52
53// NewIssues builds the issue use cases.
54func NewIssues(cfg IssuesConfig) *Issues {
55	return &Issues{links: cfg.Links, meta: cfg.Meta, store: cfg.Store, mrs: cfg.MergeRequests, bus: cfg.Bus, clock: cfg.Clock}
56}
57
58// enabled looks the repository up and hides it when issues are off.
59func (i *Issues) enabled(ctx context.Context, name domain.RepoName) (domain.Repo, error) {
60	repo, err := lookupRepo(ctx, i.meta, name)
61	if err != nil {
62		return domain.Repo{}, err
63	}
64	if !repo.Settings.IssuesEnabled() {
65		return domain.Repo{}, domain.ErrNotFound
66	}
67	return repo, nil
68}
69
70// readable applies the read rule: the access of reading the repository.
71func (i *Issues) readable(ctx context.Context, p domain.Principal, name domain.RepoName) (domain.Repo, error) {
72	repo, err := i.enabled(ctx, name)
73	if err != nil {
74		return domain.Repo{}, err
75	}
76	if !canRead(p, repo) {
77		return domain.Repo{}, domain.ErrNotFound
78	}
79	return repo, nil
80}
81
82// writable is the write rule, as MergeRequests.writable: a principal that can
83// neither read the repository nor holds the scope on it gets
84// domain.ErrNotFound, one that cannot write the error of scopeAccess.
85func (i *Issues) writable(ctx context.Context, p domain.Principal, name domain.RepoName) (domain.Repo, error) {
86	repo, err := i.enabled(ctx, name)
87	if err != nil {
88		return domain.Repo{}, err
89	}
90	if !canRead(p, repo) && !p.Allows(domain.ScopeIssueWrite, name) {
91		return domain.Repo{}, domain.ErrNotFound
92	}
93	if err := scopeAccess(p, repo, domain.ScopeIssueWrite); err != nil {
94		return domain.Repo{}, err
95	}
96	return repo, nil
97}
98
99// Create opens an issue and publishes IssueOpened. The principal needs
100// issue:write. Invalid texts give an *domain.InvalidFieldsError.
101func (i *Issues) Create(ctx context.Context, p domain.Principal, name domain.RepoName, params ports.CreateIssueParams) (domain.Issue, error) {
102	repo, err := i.writable(ctx, p, name)
103	if err != nil {
104		return domain.Issue{}, err
105	}
106	var v domain.Violations
107	title, err := domain.ParseIssueTitle(params.Title)
108	if err != nil {
109		v.AddErr("title", err)
110	}
111	body, err := domain.ParseIssueBody(params.Body)
112	if err != nil {
113		v.AddErr("body", err)
114	}
115	if err := v.Err(); err != nil {
116		return domain.Issue{}, err
117	}
118	now := i.clock.Now().UTC()
119	issue, err := i.store.Create(ctx, domain.Issue{
120		Repo: name, Title: title, Body: body, State: domain.IssueStateOpen,
121		Author: domain.AuthorName(p), CreatedAt: now, UpdatedAt: now,
122	})
123	if err != nil {
124		return domain.Issue{}, fmt.Errorf("create issue of %q: %w", name, err)
125	}
126	log := zerowrap.FromCtx(ctx)
127	log.Info().Str("repo", name.String()).Int64("number", issue.Number).
128		Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("issue opened")
129	recordActivity(ctx, i.meta, repo.Name, now)
130	i.bus.Publish(ctx, domain.IssueOpened{Issue: issue, At: now, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
131	return issue, nil
132}
133
134// List returns a page of the issues of a repository, highest number first.
135func (i *Issues) List(ctx context.Context, p domain.Principal, name domain.RepoName, params ports.ListIssuesParams) (ports.IssuePage, error) {
136	size, err := pageSize(params.PageSize)
137	if err != nil {
138		return ports.IssuePage{}, err
139	}
140	if _, err := i.readable(ctx, p, name); err != nil {
141		return ports.IssuePage{}, err
142	}
143	params.PageSize = size
144	page, err := i.store.List(ctx, name, params)
145	if err != nil {
146		return ports.IssuePage{}, fmt.Errorf("list issues of %q: %w", name, err)
147	}
148	return page, nil
149}
150
151// Get returns an issue with its comments.
152func (i *Issues) Get(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (ports.IssueDetail, error) {
153	if _, err := i.readable(ctx, p, name); err != nil {
154		return ports.IssueDetail{}, err
155	}
156	issue, err := i.store.Get(ctx, name, number)
157	if err != nil {
158		return ports.IssueDetail{}, err
159	}
160	comments, err := i.store.ListComments(ctx, name, number)
161	if err != nil {
162		return ports.IssueDetail{}, fmt.Errorf("list comments of #%d of %q: %w", number, name, err)
163	}
164	detail := ports.IssueDetail{Issue: issue, Comments: comments}
165	events, err := i.store.ListEvents(ctx, name, number)
166	if err != nil {
167		return ports.IssueDetail{}, fmt.Errorf("list events of #%d of %q: %w", number, name, err)
168	}
169	titles := make(map[int64]domain.MRTitle)
170	for _, event := range events {
171		if event.MergeRequest != 0 {
172			if _, ok := titles[event.MergeRequest]; !ok {
173				mr, err := i.mrs.Get(ctx, name, event.MergeRequest)
174				if err != nil && !errors.Is(err, domain.ErrNotFound) {
175					return ports.IssueDetail{}, err
176				}
177				titles[event.MergeRequest] = mr.Title
178			}
179		}
180		detail.Events = append(detail.Events, ports.IssueEventDetail{Event: event, MergeRequestTitle: titles[event.MergeRequest]})
181	}
182	if i.links != nil {
183		links, err := i.links.LinksOfIssue(ctx, name, number, MaxIssueLinksPerIssue)
184		if err != nil {
185			return ports.IssueDetail{}, err
186		}
187		for _, link := range links {
188			mr, err := i.mrs.Get(ctx, name, link.MergeRequest)
189			if errors.Is(err, domain.ErrNotFound) {
190				continue
191			}
192			if err != nil {
193				return ports.IssueDetail{}, err
194			}
195			detail.References = append(detail.References, ports.IssueReference{Number: mr.Number, Title: mr.Title, State: mr.State, Closes: link.Closes})
196		}
197	}
198	return detail, nil
199}
200
201// AddComment adds a comment and publishes IssueCommented. The principal needs
202// issue:write. The text must not be blank. A comment may follow a closed
203// issue; one that already holds MaxCommentsPerIssue comments gives
204// domain.ErrResourceExhausted.
205func (i *Issues) AddComment(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, text string) (domain.IssueComment, error) {
206	repo, err := i.writable(ctx, p, name)
207	if err != nil {
208		return domain.IssueComment{}, err
209	}
210	issue, err := i.store.Get(ctx, name, number)
211	if err != nil {
212		return domain.IssueComment{}, err
213	}
214	var v domain.Violations
215	body, err := domain.ParseIssueBody(text)
216	switch {
217	case err != nil:
218		v.AddErr("text", err)
219	case body.IsBlank():
220		v.Add("text", "is required")
221	}
222	if err := v.Err(); err != nil {
223		return domain.IssueComment{}, err
224	}
225	var c domain.IssueComment
226	saved := false
227	for range maxWriteRetries {
228		if issue.CommentSeq >= MaxCommentsPerIssue {
229			return domain.IssueComment{}, fmt.Errorf("%w: #%d already has %d comments", domain.ErrResourceExhausted, number, MaxCommentsPerIssue)
230		}
231		c = domain.IssueComment{ID: issue.CommentSeq + 1, Author: domain.AuthorName(p), Text: body, CreatedAt: i.clock.Now().UTC()}
232		issue.CommentSeq, issue.UpdatedAt = c.ID, c.CreatedAt
233		err = i.store.SaveComment(ctx, issue, c)
234		if err == nil {
235			saved = true
236			break
237		}
238		if !errors.Is(err, domain.ErrConflict) {
239			return domain.IssueComment{}, fmt.Errorf("save comment on #%d of %q: %w", number, name, err)
240		}
241		if issue, err = i.store.Get(ctx, name, number); err != nil {
242			return domain.IssueComment{}, err
243		}
244	}
245	if !saved {
246		return domain.IssueComment{}, fmt.Errorf("save comment on #%d of %q: %w", number, name, domain.ErrConflict)
247	}
248	log := zerowrap.FromCtx(ctx)
249	log.Info().Str("repo", name.String()).Int64("number", number).Int64("comment", c.ID).
250		Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("issue comment added")
251	recordActivity(ctx, i.meta, name, c.CreatedAt)
252	i.bus.Publish(ctx, domain.IssueCommented{
253		Repo: name, Number: number, Comment: c, At: c.CreatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt,
254	})
255	return c, nil
256}
257
258// Close closes an open issue and publishes IssueClosed. The principal needs
259// issue:write. An issue that is not open gives domain.ErrPreconditionFailed.
260func (i *Issues) Close(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.Issue, error) {
261	return i.transition(ctx, p, name, number, domain.IssueStateClosed)
262}
263
264// Reopen reopens a closed or resolved issue and publishes IssueReopened. The principal
265// needs issue:write. An issue that is not done gives
266// domain.ErrPreconditionFailed.
267func (i *Issues) Reopen(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.Issue, error) {
268	return i.transition(ctx, p, name, number, domain.IssueStateOpen)
269}
270
271// transition moves an issue from one state to the other, retrying when another
272// write changes it in between.
273func (i *Issues) transition(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, to domain.IssueState) (domain.Issue, error) {
274	repo, err := i.writable(ctx, p, name)
275	if err != nil {
276		return domain.Issue{}, err
277	}
278	verb := "close"
279	kind := domain.IssueEventClosed
280	switch to {
281	case domain.IssueStateOpen:
282		verb = "reopen"
283		kind = domain.IssueEventReopened
284	case domain.IssueStateResolved:
285		verb = "resolve"
286		kind = domain.IssueEventResolved
287	}
288	for range maxWriteRetries {
289		issue, err := i.store.Get(ctx, name, number)
290		if err != nil {
291			return domain.Issue{}, err
292		}
293		if (to == domain.IssueStateOpen && !issue.IsDone()) || (to != domain.IssueStateOpen && !issue.IsOpen()) {
294			return domain.Issue{}, fmt.Errorf("%w: #%d is %s", domain.ErrPreconditionFailed, number, issue.State)
295		}
296		issue.ClosedByMergeRequest = 0
297		issue.State, issue.UpdatedAt = to, i.clock.Now().UTC()
298		err = i.store.Update(ctx, issue, domain.IssueEvent{Kind: kind, Actor: domain.AuthorName(p), CreatedAt: issue.UpdatedAt})
299		if errors.Is(err, domain.ErrConflict) {
300			continue
301		}
302		if err != nil {
303			return domain.Issue{}, fmt.Errorf("%s #%d of %q: %w", verb, number, name, err)
304		}
305		log := zerowrap.FromCtx(ctx)
306		log.Info().Str("repo", name.String()).Int64("number", number).
307			Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("issue " + verb + "d")
308		recordActivity(ctx, i.meta, repo.Name, issue.UpdatedAt)
309		switch to {
310		case domain.IssueStateOpen:
311			i.bus.Publish(ctx, domain.IssueReopened{Issue: issue, At: issue.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
312		case domain.IssueStateResolved:
313			i.bus.Publish(ctx, domain.IssueResolved{Issue: issue, At: issue.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
314		default:
315			i.bus.Publish(ctx, domain.IssueClosed{Issue: issue, At: issue.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
316		}
317		return issue, nil
318	}
319	return domain.Issue{}, fmt.Errorf("%s #%d of %q: %w", verb, number, name, domain.ErrConflict)
320}
321
322// Kind tells what number n of the repository names. The principal needs the
323// access of reading the repository. Issues are resolved only when enabled;
324// merge requests are resolved even when issues are off.
325func (i *Issues) Kind(ctx context.Context, p domain.Principal, name domain.RepoName, n int64) (domain.NumberKind, error) {
326	repo, err := lookupRepo(ctx, i.meta, name)
327	if err != nil {
328		return domain.NumberNone, err
329	}
330	if !canRead(p, repo) {
331		return domain.NumberNone, domain.ErrNotFound
332	}
333	if repo.Settings.IssuesEnabled() {
334		if _, err := i.store.Get(ctx, name, n); err == nil {
335			return domain.NumberIssue, nil
336		} else if !errors.Is(err, domain.ErrNotFound) {
337			return domain.NumberNone, fmt.Errorf("read #%d of %q: %w", n, name, err)
338		}
339	}
340	if _, err := i.mrs.Get(ctx, name, n); err == nil {
341		return domain.NumberMergeRequest, nil
342	} else if !errors.Is(err, domain.ErrNotFound) {
343		return domain.NumberNone, fmt.Errorf("read !%d of %q: %w", n, name, err)
344	}
345	return domain.NumberNone, nil
346}
347
348// Resolve marks an open issue resolved. The principal needs issue:write.
349func (i *Issues) Resolve(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.Issue, error) {
350	return i.transition(ctx, p, name, number, domain.IssueStateResolved)
351}

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