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

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