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 if i.links != nil {
166 links, err := i.links.LinksOfIssue(ctx, name, number, MaxIssueLinksPerIssue)
167 if err != nil {
168 return ports.IssueDetail{}, err
169 }
170 for _, link := range links {
171 mr, err := i.mrs.Get(ctx, name, link.MergeRequest)
172 if errors.Is(err, domain.ErrNotFound) {
173 continue
174 }
175 if err != nil {
176 return ports.IssueDetail{}, err
177 }
178 detail.References = append(detail.References, ports.IssueReference{Number: mr.Number, Title: mr.Title, State: mr.State, Closes: link.Closes})
179 }
180 }
181 return detail, nil
182}
183
184// AddComment adds a comment and publishes IssueCommented. The principal needs
185// issue:write. The text must not be blank. A comment may follow a closed
186// issue; one that already holds MaxCommentsPerIssue comments gives
187// domain.ErrResourceExhausted.
188func (i *Issues) AddComment(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, text string) (domain.IssueComment, error) {
189 repo, err := i.writable(ctx, p, name)
190 if err != nil {
191 return domain.IssueComment{}, err
192 }
193 issue, err := i.store.Get(ctx, name, number)
194 if err != nil {
195 return domain.IssueComment{}, err
196 }
197 var v domain.Violations
198 body, err := domain.ParseIssueBody(text)
199 switch {
200 case err != nil:
201 v.AddErr("text", err)
202 case body.IsBlank():
203 v.Add("text", "is required")
204 }
205 if err := v.Err(); err != nil {
206 return domain.IssueComment{}, err
207 }
208 var c domain.IssueComment
209 saved := false
210 for range maxWriteRetries {
211 if issue.CommentSeq >= MaxCommentsPerIssue {
212 return domain.IssueComment{}, fmt.Errorf("%w: #%d already has %d comments", domain.ErrResourceExhausted, number, MaxCommentsPerIssue)
213 }
214 c = domain.IssueComment{ID: issue.CommentSeq + 1, Author: domain.AuthorName(p), Text: body, CreatedAt: i.clock.Now().UTC()}
215 issue.CommentSeq, issue.UpdatedAt = c.ID, c.CreatedAt
216 err = i.store.SaveComment(ctx, issue, c)
217 if err == nil {
218 saved = true
219 break
220 }
221 if !errors.Is(err, domain.ErrConflict) {
222 return domain.IssueComment{}, fmt.Errorf("save comment on #%d of %q: %w", number, name, err)
223 }
224 if issue, err = i.store.Get(ctx, name, number); err != nil {
225 return domain.IssueComment{}, err
226 }
227 }
228 if !saved {
229 return domain.IssueComment{}, fmt.Errorf("save comment on #%d of %q: %w", number, name, domain.ErrConflict)
230 }
231 log := zerowrap.FromCtx(ctx)
232 log.Info().Str("repo", name.String()).Int64("number", number).Int64("comment", c.ID).
233 Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("issue comment added")
234 recordActivity(ctx, i.meta, name, c.CreatedAt)
235 i.bus.Publish(ctx, domain.IssueCommented{
236 Repo: name, Number: number, Comment: c, At: c.CreatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt,
237 })
238 return c, nil
239}
240
241// Close closes an open issue and publishes IssueClosed. The principal needs
242// issue:write. An issue that is not open gives domain.ErrPreconditionFailed.
243func (i *Issues) Close(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.Issue, error) {
244 return i.transition(ctx, p, name, number, domain.IssueStateOpen, domain.IssueStateClosed)
245}
246
247// Reopen reopens a closed issue and publishes IssueReopened. The principal
248// needs issue:write. An issue that is not closed gives
249// domain.ErrPreconditionFailed.
250func (i *Issues) Reopen(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.Issue, error) {
251 return i.transition(ctx, p, name, number, domain.IssueStateClosed, domain.IssueStateOpen)
252}
253
254// transition moves an issue from one state to the other, retrying when another
255// write changes it in between.
256func (i *Issues) transition(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, from, to domain.IssueState) (domain.Issue, error) {
257 repo, err := i.writable(ctx, p, name)
258 if err != nil {
259 return domain.Issue{}, err
260 }
261 verb := "close"
262 if to == domain.IssueStateOpen {
263 verb = "reopen"
264 }
265 for range maxWriteRetries {
266 issue, err := i.store.Get(ctx, name, number)
267 if err != nil {
268 return domain.Issue{}, err
269 }
270 if issue.State != from {
271 return domain.Issue{}, fmt.Errorf("%w: #%d is %s", domain.ErrPreconditionFailed, number, issue.State)
272 }
273 issue.ClosedByMergeRequest = 0
274 issue.State, issue.UpdatedAt = to, i.clock.Now().UTC()
275 err = i.store.Update(ctx, issue)
276 if errors.Is(err, domain.ErrConflict) {
277 continue
278 }
279 if err != nil {
280 return domain.Issue{}, fmt.Errorf("%s #%d of %q: %w", verb, number, name, err)
281 }
282 log := zerowrap.FromCtx(ctx)
283 log.Info().Str("repo", name.String()).Int64("number", number).
284 Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("issue " + verb + "d")
285 recordActivity(ctx, i.meta, repo.Name, issue.UpdatedAt)
286 if to == domain.IssueStateOpen {
287 i.bus.Publish(ctx, domain.IssueReopened{Issue: issue, At: issue.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
288 } else {
289 i.bus.Publish(ctx, domain.IssueClosed{Issue: issue, At: issue.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
290 }
291 return issue, nil
292 }
293 return domain.Issue{}, fmt.Errorf("%s #%d of %q: %w", verb, number, name, domain.ErrConflict)
294}
295
296// Kind tells what number n of the repository names. The principal needs the
297// access of reading the repository. Issues are resolved only when enabled;
298// merge requests are resolved even when issues are off.
299func (i *Issues) Kind(ctx context.Context, p domain.Principal, name domain.RepoName, n int64) (domain.NumberKind, error) {
300 repo, err := lookupRepo(ctx, i.meta, name)
301 if err != nil {
302 return domain.NumberNone, err
303 }
304 if !canRead(p, repo) {
305 return domain.NumberNone, domain.ErrNotFound
306 }
307 if repo.Settings.IssuesEnabled() {
308 if _, err := i.store.Get(ctx, name, n); err == nil {
309 return domain.NumberIssue, nil
310 } else if !errors.Is(err, domain.ErrNotFound) {
311 return domain.NumberNone, fmt.Errorf("read #%d of %q: %w", n, name, err)
312 }
313 }
314 if _, err := i.mrs.Get(ctx, name, n); err == nil {
315 return domain.NumberMergeRequest, nil
316 } else if !errors.Is(err, domain.ErrNotFound) {
317 return domain.NumberNone, fmt.Errorf("read !%d of %q: %w", n, name, err)
318 }
319 return domain.NumberNone, nil
320}