detached aa7340c

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	meta  ports.RepoMetaStore
30	store ports.IssueStore
31	mrs   ports.MergeRequestStore
32	bus   ports.EventBus
33	clock ports.Clock
34}
35
36var (
37	_ ports.IssueService        = (*Issues)(nil)
38	_ ports.IssueNumberResolver = (*Issues)(nil)
39)
40
41// IssuesConfig holds what the issue use cases need. Every field is required.
42type IssuesConfig struct {
43	Meta  ports.RepoMetaStore
44	Store ports.IssueStore
45	// MergeRequests tells which numbers name a merge request (Kind).
46	MergeRequests ports.MergeRequestStore
47	Bus           ports.EventBus
48	Clock         ports.Clock
49}
50
51// NewIssues builds the issue use cases.
52func NewIssues(cfg IssuesConfig) *Issues {
53	return &Issues{meta: cfg.Meta, store: cfg.Store, mrs: cfg.MergeRequests, bus: cfg.Bus, clock: cfg.Clock}
54}
55
56// enabled looks the repository up and hides it when issues are off.
57func (i *Issues) enabled(ctx context.Context, name domain.RepoName) (domain.Repo, error) {
58	repo, err := lookupRepo(ctx, i.meta, name)
59	if err != nil {
60		return domain.Repo{}, err
61	}
62	if repo.Settings.Issues.OrDefault() == domain.IssuesOff {
63		return domain.Repo{}, domain.ErrNotFound
64	}
65	return repo, nil
66}
67
68// readable applies the read rule: the access of reading the repository.
69func (i *Issues) readable(ctx context.Context, p domain.Principal, name domain.RepoName) (domain.Repo, error) {
70	repo, err := i.enabled(ctx, name)
71	if err != nil {
72		return domain.Repo{}, err
73	}
74	if !canRead(p, repo) {
75		return domain.Repo{}, domain.ErrNotFound
76	}
77	return repo, nil
78}
79
80// writable is the write rule, as MergeRequests.writable: a principal that can
81// neither read the repository nor holds the scope on it gets
82// domain.ErrNotFound, one that cannot write the error of scopeAccess.
83func (i *Issues) writable(ctx context.Context, p domain.Principal, name domain.RepoName) (domain.Repo, error) {
84	repo, err := i.enabled(ctx, name)
85	if err != nil {
86		return domain.Repo{}, err
87	}
88	if !canRead(p, repo) && !p.Allows(domain.ScopeIssueWrite, name) {
89		return domain.Repo{}, domain.ErrNotFound
90	}
91	if err := scopeAccess(p, repo, domain.ScopeIssueWrite); err != nil {
92		return domain.Repo{}, err
93	}
94	return repo, nil
95}
96
97// Create opens an issue and publishes IssueOpened. The principal needs
98// issue:write. Invalid texts give an *domain.InvalidFieldsError.
99func (i *Issues) Create(ctx context.Context, p domain.Principal, name domain.RepoName, params ports.CreateIssueParams) (domain.Issue, error) {
100	repo, err := i.writable(ctx, p, name)
101	if err != nil {
102		return domain.Issue{}, err
103	}
104	var v domain.Violations
105	title, err := domain.ParseIssueTitle(params.Title)
106	if err != nil {
107		v.AddErr("title", err)
108	}
109	body, err := domain.ParseIssueBody(params.Body)
110	if err != nil {
111		v.AddErr("body", err)
112	}
113	if err := v.Err(); err != nil {
114		return domain.Issue{}, err
115	}
116	now := i.clock.Now().UTC()
117	issue, err := i.store.Create(ctx, domain.Issue{
118		Repo: name, Title: title, Body: body, State: domain.IssueStateOpen,
119		Author: domain.AuthorName(p), CreatedAt: now, UpdatedAt: now,
120	})
121	if err != nil {
122		return domain.Issue{}, fmt.Errorf("create issue of %q: %w", name, err)
123	}
124	log := zerowrap.FromCtx(ctx)
125	log.Info().Str("repo", name.String()).Int64("number", issue.Number).
126		Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("issue opened")
127	i.bus.Publish(ctx, domain.IssueOpened{Issue: issue, At: now, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
128	return issue, nil
129}
130
131// List returns a page of the issues of a repository, highest number first.
132func (i *Issues) List(ctx context.Context, p domain.Principal, name domain.RepoName, params ports.ListIssuesParams) (ports.IssuePage, error) {
133	size, err := pageSize(params.PageSize)
134	if err != nil {
135		return ports.IssuePage{}, err
136	}
137	if _, err := i.readable(ctx, p, name); err != nil {
138		return ports.IssuePage{}, err
139	}
140	params.PageSize = size
141	page, err := i.store.List(ctx, name, params)
142	if err != nil {
143		return ports.IssuePage{}, fmt.Errorf("list issues of %q: %w", name, err)
144	}
145	return page, nil
146}
147
148// Get returns an issue with its comments.
149func (i *Issues) Get(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (ports.IssueDetail, error) {
150	if _, err := i.readable(ctx, p, name); err != nil {
151		return ports.IssueDetail{}, err
152	}
153	issue, err := i.store.Get(ctx, name, number)
154	if err != nil {
155		return ports.IssueDetail{}, err
156	}
157	comments, err := i.store.ListComments(ctx, name, number)
158	if err != nil {
159		return ports.IssueDetail{}, fmt.Errorf("list comments of #%d of %q: %w", number, name, err)
160	}
161	return ports.IssueDetail{Issue: issue, Comments: comments}, nil
162}
163
164// AddComment adds a comment and publishes IssueCommented. The principal needs
165// issue:write. The text must not be blank. A comment may follow a closed
166// issue; one that already holds MaxCommentsPerIssue comments gives
167// domain.ErrResourceExhausted.
168func (i *Issues) AddComment(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, text string) (domain.IssueComment, error) {
169	repo, err := i.writable(ctx, p, name)
170	if err != nil {
171		return domain.IssueComment{}, err
172	}
173	issue, err := i.store.Get(ctx, name, number)
174	if err != nil {
175		return domain.IssueComment{}, err
176	}
177	var v domain.Violations
178	body, err := domain.ParseIssueBody(text)
179	switch {
180	case err != nil:
181		v.AddErr("text", err)
182	case body.IsBlank():
183		v.Add("text", "is required")
184	}
185	if err := v.Err(); err != nil {
186		return domain.IssueComment{}, err
187	}
188	var c domain.IssueComment
189	saved := false
190	for range maxWriteRetries {
191		if issue.CommentSeq >= MaxCommentsPerIssue {
192			return domain.IssueComment{}, fmt.Errorf("%w: #%d already has %d comments", domain.ErrResourceExhausted, number, MaxCommentsPerIssue)
193		}
194		c = domain.IssueComment{ID: issue.CommentSeq + 1, Author: domain.AuthorName(p), Text: body, CreatedAt: i.clock.Now().UTC()}
195		issue.CommentSeq, issue.UpdatedAt = c.ID, c.CreatedAt
196		err = i.store.SaveComment(ctx, issue, c)
197		if err == nil {
198			saved = true
199			break
200		}
201		if !errors.Is(err, domain.ErrConflict) {
202			return domain.IssueComment{}, fmt.Errorf("save comment on #%d of %q: %w", number, name, err)
203		}
204		if issue, err = i.store.Get(ctx, name, number); err != nil {
205			return domain.IssueComment{}, err
206		}
207	}
208	if !saved {
209		return domain.IssueComment{}, fmt.Errorf("save comment on #%d of %q: %w", number, name, domain.ErrConflict)
210	}
211	log := zerowrap.FromCtx(ctx)
212	log.Info().Str("repo", name.String()).Int64("number", number).Int64("comment", c.ID).
213		Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("issue comment added")
214	i.bus.Publish(ctx, domain.IssueCommented{
215		Repo: name, Number: number, Comment: c, At: c.CreatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt,
216	})
217	return c, nil
218}
219
220// Close closes an open issue and publishes IssueClosed. The principal needs
221// issue:write. An issue that is not open gives domain.ErrPreconditionFailed.
222func (i *Issues) Close(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.Issue, error) {
223	return i.transition(ctx, p, name, number, domain.IssueStateOpen, domain.IssueStateClosed)
224}
225
226// Reopen reopens a closed issue and publishes IssueReopened. The principal
227// needs issue:write. An issue that is not closed gives
228// domain.ErrPreconditionFailed.
229func (i *Issues) Reopen(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.Issue, error) {
230	return i.transition(ctx, p, name, number, domain.IssueStateClosed, domain.IssueStateOpen)
231}
232
233// transition moves an issue from one state to the other, retrying when another
234// write changes it in between.
235func (i *Issues) transition(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, from, to domain.IssueState) (domain.Issue, error) {
236	repo, err := i.writable(ctx, p, name)
237	if err != nil {
238		return domain.Issue{}, err
239	}
240	verb := "close"
241	if to == domain.IssueStateOpen {
242		verb = "reopen"
243	}
244	for range maxWriteRetries {
245		issue, err := i.store.Get(ctx, name, number)
246		if err != nil {
247			return domain.Issue{}, err
248		}
249		if issue.State != from {
250			return domain.Issue{}, fmt.Errorf("%w: #%d is %s", domain.ErrPreconditionFailed, number, issue.State)
251		}
252		issue.State, issue.UpdatedAt = to, i.clock.Now().UTC()
253		err = i.store.Update(ctx, issue)
254		if errors.Is(err, domain.ErrConflict) {
255			continue
256		}
257		if err != nil {
258			return domain.Issue{}, fmt.Errorf("%s #%d of %q: %w", verb, number, name, err)
259		}
260		log := zerowrap.FromCtx(ctx)
261		log.Info().Str("repo", name.String()).Int64("number", number).
262			Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("issue " + verb + "d")
263		if to == domain.IssueStateOpen {
264			i.bus.Publish(ctx, domain.IssueReopened{Issue: issue, At: issue.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
265		} else {
266			i.bus.Publish(ctx, domain.IssueClosed{Issue: issue, At: issue.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
267		}
268		return issue, nil
269	}
270	return domain.Issue{}, fmt.Errorf("%s #%d of %q: %w", verb, number, name, domain.ErrConflict)
271}
272
273// Kind tells what number n of the repository names. The principal needs the
274// access of reading the repository, and issues must be on there: otherwise it
275// is domain.ErrNotFound.
276func (i *Issues) Kind(ctx context.Context, p domain.Principal, name domain.RepoName, n int64) (domain.NumberKind, error) {
277	if _, err := i.readable(ctx, p, name); err != nil {
278		return domain.NumberNone, err
279	}
280	if _, err := i.store.Get(ctx, name, n); err == nil {
281		return domain.NumberIssue, nil
282	} else if !errors.Is(err, domain.ErrNotFound) {
283		return domain.NumberNone, fmt.Errorf("read #%d of %q: %w", n, name, err)
284	}
285	if _, err := i.mrs.Get(ctx, name, n); err == nil {
286		return domain.NumberMergeRequest, nil
287	} else if !errors.Is(err, domain.ErrNotFound) {
288		return domain.NumberNone, fmt.Errorf("read !%d of %q: %w", n, name, err)
289	}
290	return domain.NumberNone, nil
291}

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