detached 27e453c

issue.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 4

Tags 45

↑↓ moveenter openesc close