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.IssueStateResolved:
28 return pb.IssueState_ISSUE_STATE_RESOLVED
29 case domain.IssueStateClosed:
30 return pb.IssueState_ISSUE_STATE_CLOSED
31 }
32 return pb.IssueState_ISSUE_STATE_UNSPECIFIED
33}
34
35// issueStateFromPB maps the state filter of a listing; unspecified is the
36// empty state, which means every state. Pending is not listable (InvalidArgument): a domain Issue is never pending.
37func issueStateFromPB(s pb.IssueState) (domain.IssueState, error) {
38 switch s {
39 case pb.IssueState_ISSUE_STATE_UNSPECIFIED:
40 return "", nil
41 case pb.IssueState_ISSUE_STATE_OPEN:
42 return domain.IssueStateOpen, nil
43 case pb.IssueState_ISSUE_STATE_RESOLVED:
44 return domain.IssueStateResolved, nil
45 case pb.IssueState_ISSUE_STATE_CLOSED:
46 return domain.IssueStateClosed, nil
47 }
48 return "", fmt.Errorf("%w: issue state %v", domain.ErrInvalid, s)
49}
50
51func issuesModeToPB(m domain.IssuesMode) pb.IssuesMode {
52 switch m.OrDefault() {
53 case domain.IssuesOff:
54 return pb.IssuesMode_ISSUES_MODE_OFF
55 case domain.IssuesOwner:
56 return pb.IssuesMode_ISSUES_MODE_OWNER
57 case domain.IssuesPublic:
58 return pb.IssuesMode_ISSUES_MODE_PUBLIC
59 }
60 return pb.IssuesMode_ISSUES_MODE_UNSPECIFIED
61}
62
63func issuesModeFromPB(m pb.IssuesMode) (domain.IssuesMode, error) {
64 switch m {
65 case pb.IssuesMode_ISSUES_MODE_OFF:
66 return domain.IssuesOff, nil
67 case pb.IssuesMode_ISSUES_MODE_OWNER:
68 return domain.IssuesOwner, nil
69 case pb.IssuesMode_ISSUES_MODE_PUBLIC:
70 return domain.IssuesPublic, nil
71 }
72 return "", fmt.Errorf("%w: issues mode %v", domain.ErrInvalid, m)
73}
74
75func issueToPB(i domain.Issue) *pb.Issue {
76 return &pb.Issue{
77 Repo: i.Repo.String(),
78 Number: i.Number,
79 Title: i.Title.String(),
80 Body: i.Body.String(),
81 State: issueStateToPB(i.State),
82 Author: i.Author,
83 CreatedAt: timestamp(i.CreatedAt),
84 UpdatedAt: timestamp(i.UpdatedAt),
85 CommentCount: int32(i.CommentSeq), //nolint:gosec // bounded by usecase.MaxCommentsPerIssue
86 ClosedByMergeRequest: i.ClosedByMergeRequest,
87 }
88}
89
90func issueCommentToPB(c domain.IssueComment) *pb.IssueComment {
91 return &pb.IssueComment{
92 Id: strconv.FormatInt(c.ID, 10),
93 Author: c.Author,
94 Text: c.Text.String(),
95 CreatedAt: timestamp(c.CreatedAt),
96 }
97}
98
99func (s *issueService) CreateIssue(ctx context.Context, req *connect.Request[pb.CreateIssueRequest]) (*connect.Response[pb.CreateIssueResponse], error) {
100 name, err := domain.ParseRepoName(req.Msg.GetRepo())
101 if err != nil {
102 return nil, connectError(ctx, err)
103 }
104 issue, err := s.issues.Create(ctx, caller(ctx), name, ports.CreateIssueParams{Title: req.Msg.GetTitle(), Body: req.Msg.GetBody()})
105 if err != nil {
106 return nil, connectError(ctx, err)
107 }
108 return connect.NewResponse(&pb.CreateIssueResponse{Issue: issueToPB(issue)}), nil
109}
110
111func (s *issueService) ListIssues(ctx context.Context, req *connect.Request[pb.ListIssuesRequest]) (*connect.Response[pb.ListIssuesResponse], error) {
112 name, err := domain.ParseRepoName(req.Msg.GetRepo())
113 if err != nil {
114 return nil, connectError(ctx, err)
115 }
116 state, err := issueStateFromPB(req.Msg.GetState())
117 if err != nil {
118 return nil, connectError(ctx, err)
119 }
120 page, err := s.issues.List(ctx, caller(ctx), name, ports.ListIssuesParams{
121 State: state, PageSize: int(req.Msg.GetPageSize()), PageToken: req.Msg.GetPageToken(),
122 })
123 if err != nil {
124 return nil, connectError(ctx, err)
125 }
126 out := &pb.ListIssuesResponse{NextPageToken: page.NextPageToken, Issues: make([]*pb.Issue, 0, len(page.Issues))}
127 for _, i := range page.Issues {
128 out.Issues = append(out.Issues, issueToPB(i))
129 }
130 return connect.NewResponse(out), nil
131}
132
133func (s *issueService) GetIssue(ctx context.Context, req *connect.Request[pb.GetIssueRequest]) (*connect.Response[pb.GetIssueResponse], error) {
134 name, err := domain.ParseRepoName(req.Msg.GetRepo())
135 if err != nil {
136 return nil, connectError(ctx, err)
137 }
138 d, err := s.issues.Get(ctx, caller(ctx), name, req.Msg.GetNumber())
139 if err != nil {
140 return nil, connectError(ctx, err)
141 }
142 out := &pb.GetIssueResponse{Issue: issueToPB(d.Issue), Comments: make([]*pb.IssueComment, 0, len(d.Comments))}
143 for _, c := range d.Comments {
144 out.Comments = append(out.Comments, issueCommentToPB(c))
145 }
146 for _, e := range d.Events {
147 out.Events = append(out.Events, issueEventToPB(e))
148 }
149 out.References = make([]*pb.IssueReference, 0, len(d.References))
150 for _, r := range d.References {
151 out.References = append(out.References, &pb.IssueReference{
152 Number: r.Number,
153 Title: r.Title.String(),
154 State: mergeRequestStateToPB(r.State),
155 Closes: r.Closes,
156 })
157 }
158 return connect.NewResponse(out), nil
159}
160
161func (s *issueService) AddIssueComment(ctx context.Context, req *connect.Request[pb.AddIssueCommentRequest]) (*connect.Response[pb.AddIssueCommentResponse], error) {
162 name, err := domain.ParseRepoName(req.Msg.GetRepo())
163 if err != nil {
164 return nil, connectError(ctx, err)
165 }
166 c, err := s.issues.AddComment(ctx, caller(ctx), name, req.Msg.GetNumber(), req.Msg.GetText())
167 if err != nil {
168 return nil, connectError(ctx, err)
169 }
170 return connect.NewResponse(&pb.AddIssueCommentResponse{Comment: issueCommentToPB(c)}), nil
171}
172
173func (s *issueService) CloseIssue(ctx context.Context, req *connect.Request[pb.CloseIssueRequest]) (*connect.Response[pb.CloseIssueResponse], error) {
174 name, err := domain.ParseRepoName(req.Msg.GetRepo())
175 if err != nil {
176 return nil, connectError(ctx, err)
177 }
178 issue, err := s.issues.Close(ctx, caller(ctx), name, req.Msg.GetNumber())
179 if err != nil {
180 return nil, connectError(ctx, err)
181 }
182 return connect.NewResponse(&pb.CloseIssueResponse{Issue: issueToPB(issue)}), nil
183}
184
185func (s *issueService) ReopenIssue(ctx context.Context, req *connect.Request[pb.ReopenIssueRequest]) (*connect.Response[pb.ReopenIssueResponse], error) {
186 name, err := domain.ParseRepoName(req.Msg.GetRepo())
187 if err != nil {
188 return nil, connectError(ctx, err)
189 }
190 issue, err := s.issues.Reopen(ctx, caller(ctx), name, req.Msg.GetNumber())
191 if err != nil {
192 return nil, connectError(ctx, err)
193 }
194 return connect.NewResponse(&pb.ReopenIssueResponse{Issue: issueToPB(issue)}), nil
195}
196
197func pendingIssueToPB(p domain.PendingIssue) *pb.PendingIssue {
198 return &pb.PendingIssue{
199 Id: p.ID.String(), Repo: p.Repo.String(), Title: p.Title.String(), Body: p.Body.String(),
200 Author: p.Author, CreatedAt: timestamp(p.CreatedAt),
201 }
202}
203
204// pendingRef parses the repository and the id of a moderation request. A
205// malformed id is reported as not found, like an unknown one.
206func pendingRef(repo, id string) (domain.RepoName, domain.PendingIssueID, error) {
207 name, err := domain.ParseRepoName(repo)
208 if err != nil {
209 return domain.RepoName{}, "", err
210 }
211 pid, err := domain.ParsePendingIssueID(id)
212 if err != nil {
213 return domain.RepoName{}, "", domain.ErrNotFound
214 }
215 return name, pid, nil
216}
217
218// requireOwner answers NotFound to anyone without owner rights, before the
219// request is parsed: a malformed repository must not tell a non-owner more
220// than a well-formed one does.
221func requireOwner(ctx context.Context) error {
222 if !caller(ctx).HasOwnerRights() {
223 return connectError(ctx, domain.ErrNotFound)
224 }
225 return nil
226}
227
228func (s *issueService) ListPendingIssues(ctx context.Context, req *connect.Request[pb.ListPendingIssuesRequest]) (*connect.Response[pb.ListPendingIssuesResponse], error) {
229 if err := requireOwner(ctx); err != nil {
230 return nil, err
231 }
232 var name domain.RepoName
233 if req.Msg.GetRepo() != "" {
234 var err error
235 if name, err = domain.ParseRepoName(req.Msg.GetRepo()); err != nil {
236 return nil, connectError(ctx, err)
237 }
238 }
239 page, err := s.moderation.ListPending(ctx, caller(ctx), name, ports.ListPendingParams{
240 PageSize: int(req.Msg.GetPageSize()), PageToken: req.Msg.GetPageToken(),
241 })
242 if err != nil {
243 return nil, connectError(ctx, err)
244 }
245 out := &pb.ListPendingIssuesResponse{NextPageToken: page.NextPageToken, Issues: make([]*pb.PendingIssue, 0, len(page.Issues))}
246 for _, p := range page.Issues {
247 out.Issues = append(out.Issues, pendingIssueToPB(p))
248 }
249 return connect.NewResponse(out), nil
250}
251
252func (s *issueService) ApproveIssue(ctx context.Context, req *connect.Request[pb.ApproveIssueRequest]) (*connect.Response[pb.ApproveIssueResponse], error) {
253 if err := requireOwner(ctx); err != nil {
254 return nil, err
255 }
256 name, id, err := pendingRef(req.Msg.GetRepo(), req.Msg.GetId())
257 if err != nil {
258 return nil, connectError(ctx, err)
259 }
260 issue, err := s.moderation.Approve(ctx, caller(ctx), name, id)
261 if err != nil {
262 return nil, connectError(ctx, err)
263 }
264 return connect.NewResponse(&pb.ApproveIssueResponse{Issue: issueToPB(issue)}), nil
265}
266
267func (s *issueService) RejectIssue(ctx context.Context, req *connect.Request[pb.RejectIssueRequest]) (*connect.Response[pb.RejectIssueResponse], error) {
268 if err := requireOwner(ctx); err != nil {
269 return nil, err
270 }
271 name, id, err := pendingRef(req.Msg.GetRepo(), req.Msg.GetId())
272 if err != nil {
273 return nil, connectError(ctx, err)
274 }
275 if err := s.moderation.Reject(ctx, caller(ctx), name, id, req.Msg.GetBan()); err != nil {
276 return nil, connectError(ctx, err)
277 }
278 return connect.NewResponse(&pb.RejectIssueResponse{}), nil
279}
280
281// ResolveIssue marks an open issue as resolved.
282func (s *issueService) ResolveIssue(ctx context.Context, req *connect.Request[pb.ResolveIssueRequest]) (*connect.Response[pb.ResolveIssueResponse], error) {
283 name, err := domain.ParseRepoName(req.Msg.GetRepo())
284 if err != nil {
285 return nil, connectError(ctx, err)
286 }
287 issue, err := s.issues.Resolve(ctx, caller(ctx), name, req.Msg.GetNumber())
288 if err != nil {
289 return nil, connectError(ctx, err)
290 }
291 return connect.NewResponse(&pb.ResolveIssueResponse{Issue: issueToPB(issue)}), nil
292}
293
294func issueEventToPB(d ports.IssueEventDetail) *pb.IssueEvent {
295 e := d.Event
296 kind := pb.IssueEventKind_ISSUE_EVENT_KIND_UNSPECIFIED
297 switch e.Kind {
298 case domain.IssueEventReferenced:
299 kind = pb.IssueEventKind_ISSUE_EVENT_KIND_REFERENCED
300 case domain.IssueEventClosed:
301 kind = pb.IssueEventKind_ISSUE_EVENT_KIND_CLOSED
302 case domain.IssueEventReopened:
303 kind = pb.IssueEventKind_ISSUE_EVENT_KIND_REOPENED
304 case domain.IssueEventResolved:
305 kind = pb.IssueEventKind_ISSUE_EVENT_KIND_RESOLVED
306 }
307 return &pb.IssueEvent{Id: strconv.FormatInt(e.ID, 10), Kind: kind, Issue: e.Issue, MergeRequest: e.MergeRequest, MergeRequestTitle: d.MergeRequestTitle.String(), Closes: e.Closes, Actor: e.Actor, CreatedAt: timestamp(e.CreatedAt)}
308}
309
310func mergeRequestIssueToPB(i ports.MergeRequestIssue) *pb.MergeRequestIssue {
311 state := pb.MergeRequestIssueState_MERGE_REQUEST_ISSUE_STATE_UNSPECIFIED
312 switch i.State {
313 case domain.IssueStateOpen:
314 state = pb.MergeRequestIssueState_MERGE_REQUEST_ISSUE_STATE_OPEN
315 case domain.IssueStateClosed:
316 state = pb.MergeRequestIssueState_MERGE_REQUEST_ISSUE_STATE_CLOSED
317 case domain.IssueStateResolved:
318 state = pb.MergeRequestIssueState_MERGE_REQUEST_ISSUE_STATE_RESOLVED
319 }
320 return &pb.MergeRequestIssue{Number: i.Number, Title: i.Title.String(), State: state, Closes: i.Closes}
321}