v0.31.0 d5e0505

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		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}

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 2

Tags 45

↑↓ moveenter openesc close