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.IssuesEnabled() {
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 recordActivity(ctx, i.meta, repo.Name, now)
128 i.bus.Publish(ctx, domain.IssueOpened{Issue: issue, At: now, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
129 return issue, nil
130}
131
132// List returns a page of the issues of a repository, highest number first.
133func (i *Issues) List(ctx context.Context, p domain.Principal, name domain.RepoName, params ports.ListIssuesParams) (ports.IssuePage, error) {
134 size, err := pageSize(params.PageSize)
135 if err != nil {
136 return ports.IssuePage{}, err
137 }
138 if _, err := i.readable(ctx, p, name); err != nil {
139 return ports.IssuePage{}, err
140 }
141 params.PageSize = size
142 page, err := i.store.List(ctx, name, params)
143 if err != nil {
144 return ports.IssuePage{}, fmt.Errorf("list issues of %q: %w", name, err)
145 }
146 return page, nil
147}
148
149// Get returns an issue with its comments.
150func (i *Issues) Get(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (ports.IssueDetail, error) {
151 if _, err := i.readable(ctx, p, name); err != nil {
152 return ports.IssueDetail{}, err
153 }
154 issue, err := i.store.Get(ctx, name, number)
155 if err != nil {
156 return ports.IssueDetail{}, err
157 }
158 comments, err := i.store.ListComments(ctx, name, number)
159 if err != nil {
160 return ports.IssueDetail{}, fmt.Errorf("list comments of #%d of %q: %w", number, name, err)
161 }
162 return ports.IssueDetail{Issue: issue, Comments: comments}, nil
163}
164
165// AddComment adds a comment and publishes IssueCommented. The principal needs
166// issue:write. The text must not be blank. A comment may follow a closed
167// issue; one that already holds MaxCommentsPerIssue comments gives
168// domain.ErrResourceExhausted.
169func (i *Issues) AddComment(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, text string) (domain.IssueComment, error) {
170 repo, err := i.writable(ctx, p, name)
171 if err != nil {
172 return domain.IssueComment{}, err
173 }
174 issue, err := i.store.Get(ctx, name, number)
175 if err != nil {
176 return domain.IssueComment{}, err
177 }
178 var v domain.Violations
179 body, err := domain.ParseIssueBody(text)
180 switch {
181 case err != nil:
182 v.AddErr("text", err)
183 case body.IsBlank():
184 v.Add("text", "is required")
185 }
186 if err := v.Err(); err != nil {
187 return domain.IssueComment{}, err
188 }
189 var c domain.IssueComment
190 saved := false
191 for range maxWriteRetries {
192 if issue.CommentSeq >= MaxCommentsPerIssue {
193 return domain.IssueComment{}, fmt.Errorf("%w: #%d already has %d comments", domain.ErrResourceExhausted, number, MaxCommentsPerIssue)
194 }
195 c = domain.IssueComment{ID: issue.CommentSeq + 1, Author: domain.AuthorName(p), Text: body, CreatedAt: i.clock.Now().UTC()}
196 issue.CommentSeq, issue.UpdatedAt = c.ID, c.CreatedAt
197 err = i.store.SaveComment(ctx, issue, c)
198 if err == nil {
199 saved = true
200 break
201 }
202 if !errors.Is(err, domain.ErrConflict) {
203 return domain.IssueComment{}, fmt.Errorf("save comment on #%d of %q: %w", number, name, err)
204 }
205 if issue, err = i.store.Get(ctx, name, number); err != nil {
206 return domain.IssueComment{}, err
207 }
208 }
209 if !saved {
210 return domain.IssueComment{}, fmt.Errorf("save comment on #%d of %q: %w", number, name, domain.ErrConflict)
211 }
212 log := zerowrap.FromCtx(ctx)
213 log.Info().Str("repo", name.String()).Int64("number", number).Int64("comment", c.ID).
214 Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("issue comment added")
215 recordActivity(ctx, i.meta, name, c.CreatedAt)
216 i.bus.Publish(ctx, domain.IssueCommented{
217 Repo: name, Number: number, Comment: c, At: c.CreatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt,
218 })
219 return c, nil
220}
221
222// Close closes an open issue and publishes IssueClosed. The principal needs
223// issue:write. An issue that is not open gives domain.ErrPreconditionFailed.
224func (i *Issues) Close(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.Issue, error) {
225 return i.transition(ctx, p, name, number, domain.IssueStateOpen, domain.IssueStateClosed)
226}
227
228// Reopen reopens a closed issue and publishes IssueReopened. The principal
229// needs issue:write. An issue that is not closed gives
230// domain.ErrPreconditionFailed.
231func (i *Issues) Reopen(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.Issue, error) {
232 return i.transition(ctx, p, name, number, domain.IssueStateClosed, domain.IssueStateOpen)
233}
234
235// transition moves an issue from one state to the other, retrying when another
236// write changes it in between.
237func (i *Issues) transition(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, from, to domain.IssueState) (domain.Issue, error) {
238 repo, err := i.writable(ctx, p, name)
239 if err != nil {
240 return domain.Issue{}, err
241 }
242 verb := "close"
243 if to == domain.IssueStateOpen {
244 verb = "reopen"
245 }
246 for range maxWriteRetries {
247 issue, err := i.store.Get(ctx, name, number)
248 if err != nil {
249 return domain.Issue{}, err
250 }
251 if issue.State != from {
252 return domain.Issue{}, fmt.Errorf("%w: #%d is %s", domain.ErrPreconditionFailed, number, issue.State)
253 }
254 issue.State, issue.UpdatedAt = to, i.clock.Now().UTC()
255 err = i.store.Update(ctx, issue)
256 if errors.Is(err, domain.ErrConflict) {
257 continue
258 }
259 if err != nil {
260 return domain.Issue{}, fmt.Errorf("%s #%d of %q: %w", verb, number, name, err)
261 }
262 log := zerowrap.FromCtx(ctx)
263 log.Info().Str("repo", name.String()).Int64("number", number).
264 Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("issue " + verb + "d")
265 recordActivity(ctx, i.meta, repo.Name, issue.UpdatedAt)
266 if to == domain.IssueStateOpen {
267 i.bus.Publish(ctx, domain.IssueReopened{Issue: issue, At: issue.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
268 } else {
269 i.bus.Publish(ctx, domain.IssueClosed{Issue: issue, At: issue.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
270 }
271 return issue, nil
272 }
273 return domain.Issue{}, fmt.Errorf("%s #%d of %q: %w", verb, number, name, domain.ErrConflict)
274}
275
276// Kind tells what number n of the repository names. The principal needs the
277// access of reading the repository, and issues must be on there: otherwise it
278// is domain.ErrNotFound.
279func (i *Issues) Kind(ctx context.Context, p domain.Principal, name domain.RepoName, n int64) (domain.NumberKind, error) {
280 if _, err := i.readable(ctx, p, name); err != nil {
281 return domain.NumberNone, err
282 }
283 if _, err := i.store.Get(ctx, name, n); err == nil {
284 return domain.NumberIssue, nil
285 } else if !errors.Is(err, domain.ErrNotFound) {
286 return domain.NumberNone, fmt.Errorf("read #%d of %q: %w", n, name, err)
287 }
288 if _, err := i.mrs.Get(ctx, name, n); err == nil {
289 return domain.NumberMergeRequest, nil
290 } else if !errors.Is(err, domain.ErrNotFound) {
291 return domain.NumberNone, fmt.Errorf("read !%d of %q: %w", n, name, err)
292 }
293 return domain.NumberNone, nil
294}