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 ci ports.CIStore
28}
29
30var _ ports.InboxService = (*Inbox)(nil)
31
32// InboxConfig holds what the inbox needs; Pending and CI are optional.
33type InboxConfig struct {
34 Meta ports.RepoMetaStore
35 MergeRequests ports.MergeRequestStore
36 Issues ports.IssueStore
37 // Pending lists the issues awaiting moderation. Optional: without it the
38 // inbox has no pending section.
39 Pending ports.PendingIssueStore
40 CI ports.CIStore
41}
42
43// NewInbox builds the inbox use case.
44func NewInbox(cfg InboxConfig) *Inbox {
45 return &Inbox{meta: cfg.Meta, mrs: cfg.MergeRequests, issues: cfg.Issues, pending: cfg.Pending, ci: cfg.CI}
46}
47
48// Overview lists what is open, through the indexes of open items. The counts
49// and the list of repositories do not depend on the filter, the lists do.
50func (in *Inbox) Overview(ctx context.Context, p domain.Principal, filter domain.RepoName) (ports.InboxOverview, error) {
51 var out ports.InboxOverview
52 if !p.HasOwnerRights() {
53 return out, domain.ErrNotFound
54 }
55 mrs, err := in.mrs.ListOpenAll(ctx)
56 if err != nil {
57 return out, fmt.Errorf("list open merge requests: %w", err)
58 }
59 issues, err := in.issues.ListOpenAll(ctx)
60 if err != nil {
61 return out, fmt.Errorf("list open issues: %w", err)
62 }
63
64 // exists and issuesOn cache what is read of each repository.
65 issuesOn := make(map[domain.RepoName]bool)
66 exists := make(map[domain.RepoName]bool)
67 look := func(name domain.RepoName) error {
68 if _, ok := exists[name]; ok {
69 return nil
70 }
71 repo, err := lookupRepo(ctx, in.meta, name)
72 switch {
73 case errors.Is(err, domain.ErrNotFound):
74 exists[name] = false
75 case err != nil:
76 return fmt.Errorf("read repository %q: %w", name, err)
77 default:
78 exists[name] = true
79 issuesOn[name] = repo.Settings.IssuesEnabled()
80 }
81 return nil
82 }
83
84 repos := make(map[domain.RepoName]bool)
85 for _, m := range mrs {
86 if err := look(m.Repo); err != nil {
87 return out, err
88 }
89 if !exists[m.Repo] {
90 continue
91 }
92 repos[m.Repo] = true
93 out.Counts.MergeRequests++
94 if filter.IsZero() || m.Repo == filter {
95 out.MergeRequests = append(out.MergeRequests, m)
96 }
97 }
98 for _, i := range issues {
99 if err := look(i.Repo); err != nil {
100 return out, err
101 }
102 if !exists[i.Repo] || !issuesOn[i.Repo] {
103 continue
104 }
105 repos[i.Repo] = true
106 out.Counts.Issues++
107 if filter.IsZero() || i.Repo == filter {
108 out.Issues = append(out.Issues, i)
109 }
110 }
111
112 if in.pending != nil {
113 page, err := in.pending.List(ctx, domain.RepoName{}, ports.ListPendingParams{PageSize: domain.MaxPendingIssues})
114 if err != nil {
115 return out, fmt.Errorf("list pending issues: %w", err)
116 }
117 for _, pi := range page.Issues {
118 if err := look(pi.Repo); err != nil {
119 return out, err
120 }
121 if !exists[pi.Repo] || !issuesOn[pi.Repo] {
122 continue
123 }
124 pi.IPHash = nil // the hash is for bans, the page never needs it
125 out.Pending = append(out.Pending, pi)
126 repos[pi.Repo] = true
127 }
128 out.Counts.Pending = len(out.Pending)
129 }
130
131 if in.ci != nil {
132 out.RunsEnabled = true
133 runs, err := in.ci.ListOpenRuns(ctx)
134 if err != nil {
135 return out, fmt.Errorf("list open runs: %w", err)
136 }
137 for _, run := range runs {
138 if run.Status != domain.CIQueued && run.Status != domain.CIInProgress {
139 continue
140 }
141 if err := look(run.Repo); err != nil {
142 return out, err
143 }
144 if !exists[run.Repo] {
145 continue
146 }
147 repos[run.Repo] = true
148 out.Counts.Running++
149 if filter.IsZero() || run.Repo == filter {
150 out.Runs = append(out.Runs, run)
151 }
152 }
153 slices.SortStableFunc(out.Runs, func(a, b domain.CIRun) int {
154 return compareRecent(a.CreatedAt, a.CreatedAt, a.Repo, a.ID, b.CreatedAt, b.CreatedAt, b.Repo, b.ID)
155 })
156 }
157
158 slices.SortStableFunc(out.MergeRequests, func(a, b domain.MergeRequest) int {
159 return compareRecent(a.UpdatedAt, a.CreatedAt, a.Repo, a.Number, b.UpdatedAt, b.CreatedAt, b.Repo, b.Number)
160 })
161 slices.SortStableFunc(out.Issues, func(a, b domain.Issue) int {
162 return compareRecent(a.UpdatedAt, a.CreatedAt, a.Repo, a.Number, b.UpdatedAt, b.CreatedAt, b.Repo, b.Number)
163 })
164 for r := range repos {
165 out.Repos = append(out.Repos, r)
166 }
167 slices.SortFunc(out.Repos, func(a, b domain.RepoName) int { return strings.Compare(a.String(), b.String()) })
168 return out, nil
169}
170
171// compareRecent orders by update time, most recent first (the creation time
172// stands in for a missing one), then by repository and number.
173func compareRecent(aUp, aCr time.Time, aRepo domain.RepoName, aN int64, bUp, bCr time.Time, bRepo domain.RepoName, bN int64) int {
174 if aUp.IsZero() {
175 aUp = aCr
176 }
177 if bUp.IsZero() {
178 bUp = bCr
179 }
180 if c := bUp.Compare(aUp); c != 0 {
181 return c
182 }
183 if c := strings.Compare(aRepo.String(), bRepo.String()); c != 0 {
184 return c
185 }
186 switch {
187 case aN > bN:
188 return -1
189 case aN < bN:
190 return 1
191 }
192 return 0
193}