1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package usecase
4
5import (
6 "context"
7 "errors"
8 "fmt"
9 "slices"
10 "strings"
11 "time"
12
13 "bnema.dev/ermite/internal/domain"
14 "bnema.dev/ermite/internal/ports"
15)
16
17// Inbox is the owner's inbox (ADR 0031): the open merge requests and issues of
18// every repository in one place. Only the owner may read it; anyone else
19// gets domain.ErrNotFound, as for a page that does not exist. The issues of a
20// repository whose issues mode is off are left out.
21type Inbox struct {
22 meta ports.RepoMetaStore
23 mrs ports.MergeRequestStore
24 issues ports.IssueStore
25 // pending is nil when the public form is not wired.
26 pending ports.PendingIssueStore
27}
28
29var _ ports.InboxService = (*Inbox)(nil)
30
31// InboxConfig holds what the inbox needs. Every field is required.
32type InboxConfig struct {
33 Meta ports.RepoMetaStore
34 MergeRequests ports.MergeRequestStore
35 Issues ports.IssueStore
36 // Pending lists the issues awaiting moderation. Optional: without it the
37 // inbox has no pending section.
38 Pending ports.PendingIssueStore
39}
40
41// NewInbox builds the inbox use case.
42func NewInbox(cfg InboxConfig) *Inbox {
43 return &Inbox{meta: cfg.Meta, mrs: cfg.MergeRequests, issues: cfg.Issues, pending: cfg.Pending}
44}
45
46// Overview lists what is open, through the indexes of open items. The counts
47// and the list of repositories do not depend on the filter, the lists do.
48func (in *Inbox) Overview(ctx context.Context, p domain.Principal, filter domain.RepoName) (ports.InboxOverview, error) {
49 var out ports.InboxOverview
50 if !p.HasOwnerRights() {
51 return out, domain.ErrNotFound
52 }
53 mrs, err := in.mrs.ListOpenAll(ctx)
54 if err != nil {
55 return out, fmt.Errorf("list open merge requests: %w", err)
56 }
57 issues, err := in.issues.ListOpenAll(ctx)
58 if err != nil {
59 return out, fmt.Errorf("list open issues: %w", err)
60 }
61
62 // exists and issuesOn cache what is read of each repository.
63 issuesOn := make(map[domain.RepoName]bool)
64 exists := make(map[domain.RepoName]bool)
65 look := func(name domain.RepoName) error {
66 if _, ok := exists[name]; ok {
67 return nil
68 }
69 repo, err := lookupRepo(ctx, in.meta, name)
70 switch {
71 case errors.Is(err, domain.ErrNotFound):
72 exists[name] = false
73 case err != nil:
74 return fmt.Errorf("read repository %q: %w", name, err)
75 default:
76 exists[name] = true
77 issuesOn[name] = repo.Settings.IssuesEnabled()
78 }
79 return nil
80 }
81
82 repos := make(map[domain.RepoName]bool)
83 for _, m := range mrs {
84 if err := look(m.Repo); err != nil {
85 return out, err
86 }
87 if !exists[m.Repo] {
88 continue
89 }
90 repos[m.Repo] = true
91 out.Counts.MergeRequests++
92 if filter.IsZero() || m.Repo == filter {
93 out.MergeRequests = append(out.MergeRequests, m)
94 }
95 }
96 for _, i := range issues {
97 if err := look(i.Repo); err != nil {
98 return out, err
99 }
100 if !exists[i.Repo] || !issuesOn[i.Repo] {
101 continue
102 }
103 repos[i.Repo] = true
104 out.Counts.Issues++
105 if filter.IsZero() || i.Repo == filter {
106 out.Issues = append(out.Issues, i)
107 }
108 }
109
110 if in.pending != nil {
111 page, err := in.pending.List(ctx, domain.RepoName{}, ports.ListPendingParams{PageSize: domain.MaxPendingIssues})
112 if err != nil {
113 return out, fmt.Errorf("list pending issues: %w", err)
114 }
115 for _, pi := range page.Issues {
116 if err := look(pi.Repo); err != nil {
117 return out, err
118 }
119 if !exists[pi.Repo] || !issuesOn[pi.Repo] {
120 continue
121 }
122 pi.IPHash = nil // the hash is for bans, the page never needs it
123 out.Pending = append(out.Pending, pi)
124 repos[pi.Repo] = true
125 }
126 out.Counts.Pending = len(out.Pending)
127 }
128
129 slices.SortStableFunc(out.MergeRequests, func(a, b domain.MergeRequest) int {
130 return compareRecent(a.UpdatedAt, a.CreatedAt, a.Repo, a.Number, b.UpdatedAt, b.CreatedAt, b.Repo, b.Number)
131 })
132 slices.SortStableFunc(out.Issues, func(a, b domain.Issue) int {
133 return compareRecent(a.UpdatedAt, a.CreatedAt, a.Repo, a.Number, b.UpdatedAt, b.CreatedAt, b.Repo, b.Number)
134 })
135 for r := range repos {
136 out.Repos = append(out.Repos, r)
137 }
138 slices.SortFunc(out.Repos, func(a, b domain.RepoName) int { return strings.Compare(a.String(), b.String()) })
139 return out, nil
140}
141
142// compareRecent orders by update time, most recent first (the creation time
143// stands in for a missing one), then by repository and number.
144func compareRecent(aUp, aCr time.Time, aRepo domain.RepoName, aN int64, bUp, bCr time.Time, bRepo domain.RepoName, bN int64) int {
145 if aUp.IsZero() {
146 aUp = aCr
147 }
148 if bUp.IsZero() {
149 bUp = bCr
150 }
151 if c := bUp.Compare(aUp); c != 0 {
152 return c
153 }
154 if c := strings.Compare(aRepo.String(), bRepo.String()); c != 0 {
155 return c
156 }
157 switch {
158 case aN > bN:
159 return -1
160 case aN < bN:
161 return 1
162 }
163 return 0
164}