1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package rpc
4
5import (
6 "context"
7 "fmt"
8 "strconv"
9
10 "connectrpc.com/connect"
11
12 pb "bnema.dev/ermite-api/gen/ermite"
13 "bnema.dev/ermite/internal/domain"
14 "bnema.dev/ermite/internal/ports"
15)
16
17// issueService implements ermiteconnect.IssueServiceHandler.
18type issueService struct {
19 issues ports.IssueService
20 moderation ports.IssueModerationService
21}
22
23func issueStateToPB(s domain.IssueState) pb.IssueState {
24 switch s {
25 case domain.IssueStateOpen:
26 return pb.IssueState_ISSUE_STATE_OPEN
27 case domain.IssueStateClosed:
28 return pb.IssueState_ISSUE_STATE_CLOSED
29 }
30 return pb.IssueState_ISSUE_STATE_UNSPECIFIED
31}
32
33// issueStateFromPB maps the state filter of a listing; unspecified is the
34// empty state, which means every state. Pending is not listable (InvalidArgument): a domain Issue is never pending.
35func issueStateFromPB(s pb.IssueState) (domain.IssueState, error) {
36 switch s {
37 case pb.IssueState_ISSUE_STATE_UNSPECIFIED:
38 return "", nil
39 case pb.IssueState_ISSUE_STATE_OPEN:
40 return domain.IssueStateOpen, nil
41 case pb.IssueState_ISSUE_STATE_CLOSED:
42 return domain.IssueStateClosed, nil
43 }
44 return "", fmt.Errorf("%w: issue state %v", domain.ErrInvalid, s)
45}
46
47func issuesModeToPB(m domain.IssuesMode) pb.IssuesMode {
48 switch m.OrDefault() {
49 case domain.IssuesOff:
50 return pb.IssuesMode_ISSUES_MODE_OFF
51 case domain.IssuesOwner:
52 return pb.IssuesMode_ISSUES_MODE_OWNER
53 case domain.IssuesPublic:
54 return pb.IssuesMode_ISSUES_MODE_PUBLIC
55 }
56 return pb.IssuesMode_ISSUES_MODE_UNSPECIFIED
57}
58
59func issuesModeFromPB(m pb.IssuesMode) (domain.IssuesMode, error) {
60 switch m {
61 case pb.IssuesMode_ISSUES_MODE_OFF:
62 return domain.IssuesOff, nil
63 case pb.IssuesMode_ISSUES_MODE_OWNER:
64 return domain.IssuesOwner, nil
65 case pb.IssuesMode_ISSUES_MODE_PUBLIC:
66 return domain.IssuesPublic, nil
67 }
68 return "", fmt.Errorf("%w: issues mode %v", domain.ErrInvalid, m)
69}
70
71func issueToPB(i domain.Issue) *pb.Issue {
72 return &pb.Issue{
73 Repo: i.Repo.String(),
74 Number: i.Number,
75 Title: i.Title.String(),
76 Body: i.Body.String(),
77 State: issueStateToPB(i.State),
78 Author: i.Author,
79 CreatedAt: timestamp(i.CreatedAt),
80 UpdatedAt: timestamp(i.UpdatedAt),
81 CommentCount: int32(i.CommentSeq), //nolint:gosec // bounded by usecase.MaxCommentsPerIssue
82 ClosedByMergeRequest: i.ClosedByMergeRequest,
83 }
84}
85
86func issueCommentToPB(c domain.IssueComment) *pb.IssueComment {
87 return &pb.IssueComment{
88 Id: strconv.FormatInt(c.ID, 10),
89 Author: c.Author,
90 Text: c.Text.String(),
91 CreatedAt: timestamp(c.CreatedAt),
92 }
93}
94
95func (s *issueService) CreateIssue(ctx context.Context, req *connect.Request[pb.CreateIssueRequest]) (*connect.Response[pb.CreateIssueResponse], error) {
96 name, err := domain.ParseRepoName(req.Msg.GetRepo())
97 if err != nil {
98 return nil, connectError(ctx, err)
99 }
100 issue, err := s.issues.Create(ctx, caller(ctx), name, ports.CreateIssueParams{Title: req.Msg.GetTitle(), Body: req.Msg.GetBody()})
101 if err != nil {
102 return nil, connectError(ctx, err)
103 }
104 return connect.NewResponse(&pb.CreateIssueResponse{Issue: issueToPB(issue)}), nil
105}
106
107func (s *issueService) ListIssues(ctx context.Context, req *connect.Request[pb.ListIssuesRequest]) (*connect.Response[pb.ListIssuesResponse], error) {
108 name, err := domain.ParseRepoName(req.Msg.GetRepo())
109 if err != nil {
110 return nil, connectError(ctx, err)
111 }
112 state, err := issueStateFromPB(req.Msg.GetState())
113 if err != nil {
114 return nil, connectError(ctx, err)
115 }
116 page, err := s.issues.List(ctx, caller(ctx), name, ports.ListIssuesParams{
117 State: state, PageSize: int(req.Msg.GetPageSize()), PageToken: req.Msg.GetPageToken(),
118 })
119 if err != nil {
120 return nil, connectError(ctx, err)
121 }
122 out := &pb.ListIssuesResponse{NextPageToken: page.NextPageToken, Issues: make([]*pb.Issue, 0, len(page.Issues))}
123 for _, i := range page.Issues {
124 out.Issues = append(out.Issues, issueToPB(i))
125 }
126 return connect.NewResponse(out), nil
127}
128
129func (s *issueService) GetIssue(ctx context.Context, req *connect.Request[pb.GetIssueRequest]) (*connect.Response[pb.GetIssueResponse], error) {
130 name, err := domain.ParseRepoName(req.Msg.GetRepo())
131 if err != nil {
132 return nil, connectError(ctx, err)
133 }
134 d, err := s.issues.Get(ctx, caller(ctx), name, req.Msg.GetNumber())
135 if err != nil {
136 return nil, connectError(ctx, err)
137 }
138 out := &pb.GetIssueResponse{Issue: issueToPB(d.Issue), Comments: make([]*pb.IssueComment, 0, len(d.Comments))}
139 for _, c := range d.Comments {
140 out.Comments = append(out.Comments, issueCommentToPB(c))
141 }
142 out.References = make([]*pb.IssueReference, 0, len(d.References))
143 for _, r := range d.References {
144 out.References = append(out.References, &pb.IssueReference{
145 Number: r.Number,
146 Title: r.Title.String(),
147 State: mergeRequestStateToPB(r.State),
148 Closes: r.Closes,
149 })
150 }
151 return connect.NewResponse(out), nil
152}
153
154func (s *issueService) AddIssueComment(ctx context.Context, req *connect.Request[pb.AddIssueCommentRequest]) (*connect.Response[pb.AddIssueCommentResponse], error) {
155 name, err := domain.ParseRepoName(req.Msg.GetRepo())
156 if err != nil {
157 return nil, connectError(ctx, err)
158 }
159 c, err := s.issues.AddComment(ctx, caller(ctx), name, req.Msg.GetNumber(), req.Msg.GetText())
160 if err != nil {
161 return nil, connectError(ctx, err)
162 }
163 return connect.NewResponse(&pb.AddIssueCommentResponse{Comment: issueCommentToPB(c)}), nil
164}
165
166func (s *issueService) CloseIssue(ctx context.Context, req *connect.Request[pb.CloseIssueRequest]) (*connect.Response[pb.CloseIssueResponse], error) {
167 name, err := domain.ParseRepoName(req.Msg.GetRepo())
168 if err != nil {
169 return nil, connectError(ctx, err)
170 }
171 issue, err := s.issues.Close(ctx, caller(ctx), name, req.Msg.GetNumber())
172 if err != nil {
173 return nil, connectError(ctx, err)
174 }
175 return connect.NewResponse(&pb.CloseIssueResponse{Issue: issueToPB(issue)}), nil
176}
177
178func (s *issueService) ReopenIssue(ctx context.Context, req *connect.Request[pb.ReopenIssueRequest]) (*connect.Response[pb.ReopenIssueResponse], error) {
179 name, err := domain.ParseRepoName(req.Msg.GetRepo())
180 if err != nil {
181 return nil, connectError(ctx, err)
182 }
183 issue, err := s.issues.Reopen(ctx, caller(ctx), name, req.Msg.GetNumber())
184 if err != nil {
185 return nil, connectError(ctx, err)
186 }
187 return connect.NewResponse(&pb.ReopenIssueResponse{Issue: issueToPB(issue)}), nil
188}
189
190func pendingIssueToPB(p domain.PendingIssue) *pb.PendingIssue {
191 return &pb.PendingIssue{
192 Id: p.ID.String(), Repo: p.Repo.String(), Title: p.Title.String(), Body: p.Body.String(),
193 Author: p.Author, CreatedAt: timestamp(p.CreatedAt),
194 }
195}
196
197// pendingRef parses the repository and the id of a moderation request. A
198// malformed id is reported as not found, like an unknown one.
199func pendingRef(repo, id string) (domain.RepoName, domain.PendingIssueID, error) {
200 name, err := domain.ParseRepoName(repo)
201 if err != nil {
202 return domain.RepoName{}, "", err
203 }
204 pid, err := domain.ParsePendingIssueID(id)
205 if err != nil {
206 return domain.RepoName{}, "", domain.ErrNotFound
207 }
208 return name, pid, nil
209}
210
211// requireOwner answers NotFound to anyone without owner rights, before the
212// request is parsed: a malformed repository must not tell a non-owner more
213// than a well-formed one does.
214func requireOwner(ctx context.Context) error {
215 if !caller(ctx).HasOwnerRights() {
216 return connectError(ctx, domain.ErrNotFound)
217 }
218 return nil
219}
220
221func (s *issueService) ListPendingIssues(ctx context.Context, req *connect.Request[pb.ListPendingIssuesRequest]) (*connect.Response[pb.ListPendingIssuesResponse], error) {
222 if err := requireOwner(ctx); err != nil {
223 return nil, err
224 }
225 var name domain.RepoName
226 if req.Msg.GetRepo() != "" {
227 var err error
228 if name, err = domain.ParseRepoName(req.Msg.GetRepo()); err != nil {
229 return nil, connectError(ctx, err)
230 }
231 }
232 page, err := s.moderation.ListPending(ctx, caller(ctx), name, ports.ListPendingParams{
233 PageSize: int(req.Msg.GetPageSize()), PageToken: req.Msg.GetPageToken(),
234 })
235 if err != nil {
236 return nil, connectError(ctx, err)
237 }
238 out := &pb.ListPendingIssuesResponse{NextPageToken: page.NextPageToken, Issues: make([]*pb.PendingIssue, 0, len(page.Issues))}
239 for _, p := range page.Issues {
240 out.Issues = append(out.Issues, pendingIssueToPB(p))
241 }
242 return connect.NewResponse(out), nil
243}
244
245func (s *issueService) ApproveIssue(ctx context.Context, req *connect.Request[pb.ApproveIssueRequest]) (*connect.Response[pb.ApproveIssueResponse], error) {
246 if err := requireOwner(ctx); err != nil {
247 return nil, err
248 }
249 name, id, err := pendingRef(req.Msg.GetRepo(), req.Msg.GetId())
250 if err != nil {
251 return nil, connectError(ctx, err)
252 }
253 issue, err := s.moderation.Approve(ctx, caller(ctx), name, id)
254 if err != nil {
255 return nil, connectError(ctx, err)
256 }
257 return connect.NewResponse(&pb.ApproveIssueResponse{Issue: issueToPB(issue)}), nil
258}
259
260func (s *issueService) RejectIssue(ctx context.Context, req *connect.Request[pb.RejectIssueRequest]) (*connect.Response[pb.RejectIssueResponse], error) {
261 if err := requireOwner(ctx); err != nil {
262 return nil, err
263 }
264 name, id, err := pendingRef(req.Msg.GetRepo(), req.Msg.GetId())
265 if err != nil {
266 return nil, connectError(ctx, err)
267 }
268 if err := s.moderation.Reject(ctx, caller(ctx), name, id, req.Msg.GetBan()); err != nil {
269 return nil, connectError(ctx, err)
270 }
271 return connect.NewResponse(&pb.RejectIssueResponse{}), nil
272}