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 }
83}
84
85func issueCommentToPB(c domain.IssueComment) *pb.IssueComment {
86 return &pb.IssueComment{
87 Id: strconv.FormatInt(c.ID, 10),
88 Author: c.Author,
89 Text: c.Text.String(),
90 CreatedAt: timestamp(c.CreatedAt),
91 }
92}
93
94func (s *issueService) CreateIssue(ctx context.Context, req *connect.Request[pb.CreateIssueRequest]) (*connect.Response[pb.CreateIssueResponse], error) {
95 name, err := domain.ParseRepoName(req.Msg.GetRepo())
96 if err != nil {
97 return nil, connectError(ctx, err)
98 }
99 issue, err := s.issues.Create(ctx, caller(ctx), name, ports.CreateIssueParams{Title: req.Msg.GetTitle(), Body: req.Msg.GetBody()})
100 if err != nil {
101 return nil, connectError(ctx, err)
102 }
103 return connect.NewResponse(&pb.CreateIssueResponse{Issue: issueToPB(issue)}), nil
104}
105
106func (s *issueService) ListIssues(ctx context.Context, req *connect.Request[pb.ListIssuesRequest]) (*connect.Response[pb.ListIssuesResponse], error) {
107 name, err := domain.ParseRepoName(req.Msg.GetRepo())
108 if err != nil {
109 return nil, connectError(ctx, err)
110 }
111 state, err := issueStateFromPB(req.Msg.GetState())
112 if err != nil {
113 return nil, connectError(ctx, err)
114 }
115 page, err := s.issues.List(ctx, caller(ctx), name, ports.ListIssuesParams{
116 State: state, PageSize: int(req.Msg.GetPageSize()), PageToken: req.Msg.GetPageToken(),
117 })
118 if err != nil {
119 return nil, connectError(ctx, err)
120 }
121 out := &pb.ListIssuesResponse{NextPageToken: page.NextPageToken, Issues: make([]*pb.Issue, 0, len(page.Issues))}
122 for _, i := range page.Issues {
123 out.Issues = append(out.Issues, issueToPB(i))
124 }
125 return connect.NewResponse(out), nil
126}
127
128func (s *issueService) GetIssue(ctx context.Context, req *connect.Request[pb.GetIssueRequest]) (*connect.Response[pb.GetIssueResponse], error) {
129 name, err := domain.ParseRepoName(req.Msg.GetRepo())
130 if err != nil {
131 return nil, connectError(ctx, err)
132 }
133 d, err := s.issues.Get(ctx, caller(ctx), name, req.Msg.GetNumber())
134 if err != nil {
135 return nil, connectError(ctx, err)
136 }
137 out := &pb.GetIssueResponse{Issue: issueToPB(d.Issue), Comments: make([]*pb.IssueComment, 0, len(d.Comments))}
138 for _, c := range d.Comments {
139 out.Comments = append(out.Comments, issueCommentToPB(c))
140 }
141 return connect.NewResponse(out), nil
142}
143
144func (s *issueService) AddIssueComment(ctx context.Context, req *connect.Request[pb.AddIssueCommentRequest]) (*connect.Response[pb.AddIssueCommentResponse], error) {
145 name, err := domain.ParseRepoName(req.Msg.GetRepo())
146 if err != nil {
147 return nil, connectError(ctx, err)
148 }
149 c, err := s.issues.AddComment(ctx, caller(ctx), name, req.Msg.GetNumber(), req.Msg.GetText())
150 if err != nil {
151 return nil, connectError(ctx, err)
152 }
153 return connect.NewResponse(&pb.AddIssueCommentResponse{Comment: issueCommentToPB(c)}), nil
154}
155
156func (s *issueService) CloseIssue(ctx context.Context, req *connect.Request[pb.CloseIssueRequest]) (*connect.Response[pb.CloseIssueResponse], error) {
157 name, err := domain.ParseRepoName(req.Msg.GetRepo())
158 if err != nil {
159 return nil, connectError(ctx, err)
160 }
161 issue, err := s.issues.Close(ctx, caller(ctx), name, req.Msg.GetNumber())
162 if err != nil {
163 return nil, connectError(ctx, err)
164 }
165 return connect.NewResponse(&pb.CloseIssueResponse{Issue: issueToPB(issue)}), nil
166}
167
168func (s *issueService) ReopenIssue(ctx context.Context, req *connect.Request[pb.ReopenIssueRequest]) (*connect.Response[pb.ReopenIssueResponse], error) {
169 name, err := domain.ParseRepoName(req.Msg.GetRepo())
170 if err != nil {
171 return nil, connectError(ctx, err)
172 }
173 issue, err := s.issues.Reopen(ctx, caller(ctx), name, req.Msg.GetNumber())
174 if err != nil {
175 return nil, connectError(ctx, err)
176 }
177 return connect.NewResponse(&pb.ReopenIssueResponse{Issue: issueToPB(issue)}), nil
178}
179
180func pendingIssueToPB(p domain.PendingIssue) *pb.PendingIssue {
181 return &pb.PendingIssue{
182 Id: p.ID.String(), Repo: p.Repo.String(), Title: p.Title.String(), Body: p.Body.String(),
183 Author: p.Author, CreatedAt: timestamp(p.CreatedAt),
184 }
185}
186
187// pendingRef parses the repository and the id of a moderation request. A
188// malformed id is reported as not found, like an unknown one.
189func pendingRef(repo, id string) (domain.RepoName, domain.PendingIssueID, error) {
190 name, err := domain.ParseRepoName(repo)
191 if err != nil {
192 return domain.RepoName{}, "", err
193 }
194 pid, err := domain.ParsePendingIssueID(id)
195 if err != nil {
196 return domain.RepoName{}, "", domain.ErrNotFound
197 }
198 return name, pid, nil
199}
200
201// requireOwner answers NotFound to anyone without owner rights, before the
202// request is parsed: a malformed repository must not tell a non-owner more
203// than a well-formed one does.
204func requireOwner(ctx context.Context) error {
205 if !caller(ctx).HasOwnerRights() {
206 return connectError(ctx, domain.ErrNotFound)
207 }
208 return nil
209}
210
211func (s *issueService) ListPendingIssues(ctx context.Context, req *connect.Request[pb.ListPendingIssuesRequest]) (*connect.Response[pb.ListPendingIssuesResponse], error) {
212 if err := requireOwner(ctx); err != nil {
213 return nil, err
214 }
215 var name domain.RepoName
216 if req.Msg.GetRepo() != "" {
217 var err error
218 if name, err = domain.ParseRepoName(req.Msg.GetRepo()); err != nil {
219 return nil, connectError(ctx, err)
220 }
221 }
222 page, err := s.moderation.ListPending(ctx, caller(ctx), name, ports.ListPendingParams{
223 PageSize: int(req.Msg.GetPageSize()), PageToken: req.Msg.GetPageToken(),
224 })
225 if err != nil {
226 return nil, connectError(ctx, err)
227 }
228 out := &pb.ListPendingIssuesResponse{NextPageToken: page.NextPageToken, Issues: make([]*pb.PendingIssue, 0, len(page.Issues))}
229 for _, p := range page.Issues {
230 out.Issues = append(out.Issues, pendingIssueToPB(p))
231 }
232 return connect.NewResponse(out), nil
233}
234
235func (s *issueService) ApproveIssue(ctx context.Context, req *connect.Request[pb.ApproveIssueRequest]) (*connect.Response[pb.ApproveIssueResponse], error) {
236 if err := requireOwner(ctx); err != nil {
237 return nil, err
238 }
239 name, id, err := pendingRef(req.Msg.GetRepo(), req.Msg.GetId())
240 if err != nil {
241 return nil, connectError(ctx, err)
242 }
243 issue, err := s.moderation.Approve(ctx, caller(ctx), name, id)
244 if err != nil {
245 return nil, connectError(ctx, err)
246 }
247 return connect.NewResponse(&pb.ApproveIssueResponse{Issue: issueToPB(issue)}), nil
248}
249
250func (s *issueService) RejectIssue(ctx context.Context, req *connect.Request[pb.RejectIssueRequest]) (*connect.Response[pb.RejectIssueResponse], error) {
251 if err := requireOwner(ctx); err != nil {
252 return nil, err
253 }
254 name, id, err := pendingRef(req.Msg.GetRepo(), req.Msg.GetId())
255 if err != nil {
256 return nil, connectError(ctx, err)
257 }
258 if err := s.moderation.Reject(ctx, caller(ctx), name, id, req.Msg.GetBan()); err != nil {
259 return nil, connectError(ctx, err)
260 }
261 return connect.NewResponse(&pb.RejectIssueResponse{}), nil
262}