detached a5d6827

feat(rpc): serve IssueService and the issues setting, wire issues

commit
a5d6827eb29c19e5efa957269e4f27e8447b5921
author
brice · · 2026-10-19 05:40 UTC
parent
4c8ce48
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
10 files · +487 -8
M internal/adapters/rpc/api.go +5 -2
@@ -35,7 +35,7 @@ const RunnerMaxRequestBytes = MaxRequestBytes + 64<<10
3535 // always implements, as GetVersion lists them. FeaturePasskeys comes on top
3636 // when Config.Passkeys is set, and FeatureCISecrets when the settings services
3737 // are, and FeatureCommitSignatures when Config.TrustedKeys is set.
38-var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.cache", "ci.workflows"}
38+var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.cache", "ci.workflows"}
3939
4040 // FeaturePasskeys is listed by GetVersion when passkeys are available.
4141 // FeatureCIArtifacts (artifact.go) when the artifact services are served.
@@ -55,7 +55,9 @@ type Config struct {
5555 // MergeRequests serves MergeRequestService, SigningKey GetSigningKey.
5656 MergeRequests ports.MergeRequestService
5757 SigningKey ports.SigningKeyService
58- Version string
58+ // Issues serves IssueService.
59+ Issues ports.IssueService
60+ Version string
5961 // TrustedProxies are the networks allowed to set X-Forwarded-For.
6062 TrustedProxies []netip.Prefix
6163 // Limiter counts failed authentications; nil builds a default one. Share
@@ -142,6 +144,7 @@ func New(cfg Config) *API {
142144 }
143145 mux.Handle(ermiteconnect.NewServerServiceHandler(&serverService{version: cfg.Version, features: features, key: cfg.SigningKey, oidc: cfg.OIDCKeys}, opts...))
144146 mux.Handle(ermiteconnect.NewMergeRequestServiceHandler(&mergeRequestService{cfg.MergeRequests}, opts...))
147+ mux.Handle(ermiteconnect.NewIssueServiceHandler(&issueService{cfg.Issues}, opts...))
145148 if cfg.Sessions != nil {
146149 mux.Handle(ermiteconnect.NewSessionServiceHandler(&sessionService{sessions: cfg.Sessions, passkeys: cfg.Passkeys}, opts...))
147150 }
@@ -35,7 +35,7 @@ const RunnerMaxRequestBytes = MaxRequestBytes + 64<<10
35 // always implements, as GetVersion lists them. FeaturePasskeys comes on top35 // always implements, as GetVersion lists them. FeaturePasskeys comes on top
36 // when Config.Passkeys is set, and FeatureCISecrets when the settings services36 // when Config.Passkeys is set, and FeatureCISecrets when the settings services
37 // are, and FeatureCommitSignatures when Config.TrustedKeys is set.37 // are, and FeatureCommitSignatures when Config.TrustedKeys is set.
38-var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.cache", "ci.workflows"}38+var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.cache", "ci.workflows"}
39 39
40 // FeaturePasskeys is listed by GetVersion when passkeys are available.40 // FeaturePasskeys is listed by GetVersion when passkeys are available.
41 // FeatureCIArtifacts (artifact.go) when the artifact services are served.41 // FeatureCIArtifacts (artifact.go) when the artifact services are served.
@@ -55,7 +55,9 @@ type Config struct {
55 // MergeRequests serves MergeRequestService, SigningKey GetSigningKey.55 // MergeRequests serves MergeRequestService, SigningKey GetSigningKey.
56 MergeRequests ports.MergeRequestService56 MergeRequests ports.MergeRequestService
57 SigningKey ports.SigningKeyService57 SigningKey ports.SigningKeyService
58- Version string58+ // Issues serves IssueService.
59+ Issues ports.IssueService
60+ Version string
59 // TrustedProxies are the networks allowed to set X-Forwarded-For.61 // TrustedProxies are the networks allowed to set X-Forwarded-For.
60 TrustedProxies []netip.Prefix62 TrustedProxies []netip.Prefix
61 // Limiter counts failed authentications; nil builds a default one. Share63 // Limiter counts failed authentications; nil builds a default one. Share
@@ -142,6 +144,7 @@ func New(cfg Config) *API {
142 }144 }
143 mux.Handle(ermiteconnect.NewServerServiceHandler(&serverService{version: cfg.Version, features: features, key: cfg.SigningKey, oidc: cfg.OIDCKeys}, opts...))145 mux.Handle(ermiteconnect.NewServerServiceHandler(&serverService{version: cfg.Version, features: features, key: cfg.SigningKey, oidc: cfg.OIDCKeys}, opts...))
144 mux.Handle(ermiteconnect.NewMergeRequestServiceHandler(&mergeRequestService{cfg.MergeRequests}, opts...))146 mux.Handle(ermiteconnect.NewMergeRequestServiceHandler(&mergeRequestService{cfg.MergeRequests}, opts...))
147+ mux.Handle(ermiteconnect.NewIssueServiceHandler(&issueService{cfg.Issues}, opts...))
145 if cfg.Sessions != nil {148 if cfg.Sessions != nil {
146 mux.Handle(ermiteconnect.NewSessionServiceHandler(&sessionService{sessions: cfg.Sessions, passkeys: cfg.Passkeys}, opts...))149 mux.Handle(ermiteconnect.NewSessionServiceHandler(&sessionService{sessions: cfg.Sessions, passkeys: cfg.Passkeys}, opts...))
147 }150 }
M internal/adapters/rpc/convert.go +13 -1
@@ -75,7 +75,7 @@ func repoToPB(v ports.RepoView) *pb.Repo {
7575 }
7676
7777 func settingsToPB(s domain.RepoSettings) *pb.RepoSettings {
78- out := &pb.RepoSettings{FailingChecksBlock: s.FailingChecksBlock}
78+ out := &pb.RepoSettings{FailingChecksBlock: s.FailingChecksBlock, Issues: issuesModeToPB(s.Issues)}
7979 for _, m := range s.AllowedMergeModes {
8080 out.AllowedMergeModes = append(out.AllowedMergeModes, mergeModeToPB(m))
8181 }
@@ -472,6 +472,18 @@ func watchEventToPB(we ports.WatchEvent) *pb.Event {
472472 case domain.CommentAdded:
473473 out.Time = timestamp(ev.At)
474474 out.Kind = &pb.Event_CommentAdded{CommentAdded: &pb.CommentAdded{Repo: ev.Repo.String(), Number: ev.Number, Comment: commentToPB(ev.Comment)}}
475+ case domain.IssueOpened:
476+ out.Time = timestamp(ev.At)
477+ out.Kind = &pb.Event_IssueOpened{IssueOpened: &pb.IssueOpened{Issue: issueToPB(ev.Issue)}}
478+ case domain.IssueClosed:
479+ out.Time = timestamp(ev.At)
480+ out.Kind = &pb.Event_IssueClosed{IssueClosed: &pb.IssueClosed{Issue: issueToPB(ev.Issue)}}
481+ case domain.IssueReopened:
482+ out.Time = timestamp(ev.At)
483+ out.Kind = &pb.Event_IssueReopened{IssueReopened: &pb.IssueReopened{Issue: issueToPB(ev.Issue)}}
484+ case domain.IssueCommented:
485+ out.Time = timestamp(ev.At)
486+ out.Kind = &pb.Event_IssueCommented{IssueCommented: &pb.IssueCommented{Repo: ev.Repo.String(), Number: ev.Number, Comment: issueCommentToPB(ev.Comment)}}
475487 case domain.RunUpdated:
476488 out.Time = timestamp(ev.At)
477489 out.Kind = runEventToPB(ev)
@@ -75,7 +75,7 @@ func repoToPB(v ports.RepoView) *pb.Repo {
75 }75 }
76 76
77 func settingsToPB(s domain.RepoSettings) *pb.RepoSettings {77 func settingsToPB(s domain.RepoSettings) *pb.RepoSettings {
78- out := &pb.RepoSettings{FailingChecksBlock: s.FailingChecksBlock}78+ out := &pb.RepoSettings{FailingChecksBlock: s.FailingChecksBlock, Issues: issuesModeToPB(s.Issues)}
79 for _, m := range s.AllowedMergeModes {79 for _, m := range s.AllowedMergeModes {
80 out.AllowedMergeModes = append(out.AllowedMergeModes, mergeModeToPB(m))80 out.AllowedMergeModes = append(out.AllowedMergeModes, mergeModeToPB(m))
81 }81 }
@@ -472,6 +472,18 @@ func watchEventToPB(we ports.WatchEvent) *pb.Event {
472 case domain.CommentAdded:472 case domain.CommentAdded:
473 out.Time = timestamp(ev.At)473 out.Time = timestamp(ev.At)
474 out.Kind = &pb.Event_CommentAdded{CommentAdded: &pb.CommentAdded{Repo: ev.Repo.String(), Number: ev.Number, Comment: commentToPB(ev.Comment)}}474 out.Kind = &pb.Event_CommentAdded{CommentAdded: &pb.CommentAdded{Repo: ev.Repo.String(), Number: ev.Number, Comment: commentToPB(ev.Comment)}}
475+ case domain.IssueOpened:
476+ out.Time = timestamp(ev.At)
477+ out.Kind = &pb.Event_IssueOpened{IssueOpened: &pb.IssueOpened{Issue: issueToPB(ev.Issue)}}
478+ case domain.IssueClosed:
479+ out.Time = timestamp(ev.At)
480+ out.Kind = &pb.Event_IssueClosed{IssueClosed: &pb.IssueClosed{Issue: issueToPB(ev.Issue)}}
481+ case domain.IssueReopened:
482+ out.Time = timestamp(ev.At)
483+ out.Kind = &pb.Event_IssueReopened{IssueReopened: &pb.IssueReopened{Issue: issueToPB(ev.Issue)}}
484+ case domain.IssueCommented:
485+ out.Time = timestamp(ev.At)
486+ out.Kind = &pb.Event_IssueCommented{IssueCommented: &pb.IssueCommented{Repo: ev.Repo.String(), Number: ev.Number, Comment: issueCommentToPB(ev.Comment)}}
475 case domain.RunUpdated:487 case domain.RunUpdated:
476 out.Time = timestamp(ev.At)488 out.Time = timestamp(ev.At)
477 out.Kind = runEventToPB(ev)489 out.Kind = runEventToPB(ev)
M internal/adapters/rpc/convert_test.go +29 -0
@@ -336,3 +336,32 @@ func TestWatchEventToPBMergeRequestEvents(t *testing.T) {
336336 assert.Equal(t, pb.DiffSide_DIFF_SIDE_OLD, added.GetComment().GetSide())
337337 assert.EqualValues(t, 3, added.GetComment().GetLine())
338338 }
339+
340+func TestIssueMappers(t *testing.T) {
341+ t.Parallel()
342+ for _, m := range domain.IssuesModes() {
343+ got, err := issuesModeFromPB(issuesModeToPB(m))
344+ require.NoError(t, err)
345+ assert.Equal(t, m, got)
346+ }
347+ assert.Equal(t, pb.IssuesMode_ISSUES_MODE_OFF, issuesModeToPB(""), "the empty mode is off")
348+ _, err := issuesModeFromPB(pb.IssuesMode_ISSUES_MODE_UNSPECIFIED)
349+ assert.ErrorIs(t, err, domain.ErrInvalid)
350+
351+ s, err := issueStateFromPB(pb.IssueState_ISSUE_STATE_UNSPECIFIED)
352+ require.NoError(t, err)
353+ assert.Empty(t, s)
354+ _, err = issueStateFromPB(pb.IssueState_ISSUE_STATE_PENDING)
355+ assert.ErrorIs(t, err, domain.ErrInvalid)
356+
357+ n, err := domain.ParseRepoName("proj")
358+ require.NoError(t, err)
359+ ev := watchEventToPB(ports.WatchEvent{ID: "1", Event: domain.IssueClosed{Issue: domain.Issue{Repo: n, Number: 4, State: domain.IssueStateClosed}}})
360+ require.NotNil(t, ev.GetIssueClosed())
361+ assert.EqualValues(t, 4, ev.GetIssueClosed().GetIssue().GetNumber())
362+ assert.Equal(t, pb.IssueState_ISSUE_STATE_CLOSED, ev.GetIssueClosed().GetIssue().GetState())
363+ ev = watchEventToPB(ports.WatchEvent{ID: "2", Event: domain.IssueCommented{Repo: n, Number: 4, Comment: domain.IssueComment{ID: 3}}})
364+ assert.Equal(t, "3", ev.GetIssueCommented().GetComment().GetId())
365+ assert.NotNil(t, watchEventToPB(ports.WatchEvent{Event: domain.IssueOpened{}}).GetIssueOpened())
366+ assert.NotNil(t, watchEventToPB(ports.WatchEvent{Event: domain.IssueReopened{}}).GetIssueReopened())
367+}
@@ -336,3 +336,32 @@ func TestWatchEventToPBMergeRequestEvents(t *testing.T) {
336 assert.Equal(t, pb.DiffSide_DIFF_SIDE_OLD, added.GetComment().GetSide())336 assert.Equal(t, pb.DiffSide_DIFF_SIDE_OLD, added.GetComment().GetSide())
337 assert.EqualValues(t, 3, added.GetComment().GetLine())337 assert.EqualValues(t, 3, added.GetComment().GetLine())
338 }338 }
339+
340+func TestIssueMappers(t *testing.T) {
341+ t.Parallel()
342+ for _, m := range domain.IssuesModes() {
343+ got, err := issuesModeFromPB(issuesModeToPB(m))
344+ require.NoError(t, err)
345+ assert.Equal(t, m, got)
346+ }
347+ assert.Equal(t, pb.IssuesMode_ISSUES_MODE_OFF, issuesModeToPB(""), "the empty mode is off")
348+ _, err := issuesModeFromPB(pb.IssuesMode_ISSUES_MODE_UNSPECIFIED)
349+ assert.ErrorIs(t, err, domain.ErrInvalid)
350+
351+ s, err := issueStateFromPB(pb.IssueState_ISSUE_STATE_UNSPECIFIED)
352+ require.NoError(t, err)
353+ assert.Empty(t, s)
354+ _, err = issueStateFromPB(pb.IssueState_ISSUE_STATE_PENDING)
355+ assert.ErrorIs(t, err, domain.ErrInvalid)
356+
357+ n, err := domain.ParseRepoName("proj")
358+ require.NoError(t, err)
359+ ev := watchEventToPB(ports.WatchEvent{ID: "1", Event: domain.IssueClosed{Issue: domain.Issue{Repo: n, Number: 4, State: domain.IssueStateClosed}}})
360+ require.NotNil(t, ev.GetIssueClosed())
361+ assert.EqualValues(t, 4, ev.GetIssueClosed().GetIssue().GetNumber())
362+ assert.Equal(t, pb.IssueState_ISSUE_STATE_CLOSED, ev.GetIssueClosed().GetIssue().GetState())
363+ ev = watchEventToPB(ports.WatchEvent{ID: "2", Event: domain.IssueCommented{Repo: n, Number: 4, Comment: domain.IssueComment{ID: 3}}})
364+ assert.Equal(t, "3", ev.GetIssueCommented().GetComment().GetId())
365+ assert.NotNil(t, watchEventToPB(ports.WatchEvent{Event: domain.IssueOpened{}}).GetIssueOpened())
366+ assert.NotNil(t, watchEventToPB(ports.WatchEvent{Event: domain.IssueReopened{}}).GetIssueReopened())
367+}
A internal/adapters/rpc/issue.go +177 -0
@@ -0,0 +1,177 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package rpc
4+
5+import (
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.
18+type issueService struct{ issues ports.IssueService }
19+
20+func issueStateToPB(s domain.IssueState) pb.IssueState {
21+ switch s {
22+ case domain.IssueStateOpen:
23+ return pb.IssueState_ISSUE_STATE_OPEN
24+ case domain.IssueStateClosed:
25+ return pb.IssueState_ISSUE_STATE_CLOSED
26+ case domain.IssueStatePending:
27+ return pb.IssueState_ISSUE_STATE_PENDING
28+ }
29+ return pb.IssueState_ISSUE_STATE_UNSPECIFIED
30+}
31+
32+// issueStateFromPB maps the state filter of a listing; unspecified is the
33+// empty state, which means every state. Pending is not listable.
34+func issueStateFromPB(s pb.IssueState) (domain.IssueState, error) {
35+ switch s {
36+ case pb.IssueState_ISSUE_STATE_UNSPECIFIED:
37+ return "", nil
38+ case pb.IssueState_ISSUE_STATE_OPEN:
39+ return domain.IssueStateOpen, nil
40+ case pb.IssueState_ISSUE_STATE_CLOSED:
41+ return domain.IssueStateClosed, nil
42+ }
43+ return "", fmt.Errorf("%w: issue state %v", domain.ErrInvalid, s)
44+}
45+
46+func issuesModeToPB(m domain.IssuesMode) pb.IssuesMode {
47+ switch m.OrDefault() {
48+ case domain.IssuesOff:
49+ return pb.IssuesMode_ISSUES_MODE_OFF
50+ case domain.IssuesOwner:
51+ return pb.IssuesMode_ISSUES_MODE_OWNER
52+ case domain.IssuesPublic:
53+ return pb.IssuesMode_ISSUES_MODE_PUBLIC
54+ }
55+ return pb.IssuesMode_ISSUES_MODE_UNSPECIFIED
56+}
57+
58+func issuesModeFromPB(m pb.IssuesMode) (domain.IssuesMode, error) {
59+ switch m {
60+ case pb.IssuesMode_ISSUES_MODE_OFF:
61+ return domain.IssuesOff, nil
62+ case pb.IssuesMode_ISSUES_MODE_OWNER:
63+ return domain.IssuesOwner, nil
64+ case pb.IssuesMode_ISSUES_MODE_PUBLIC:
65+ return domain.IssuesPublic, nil
66+ }
67+ return "", fmt.Errorf("%w: issues mode %v", domain.ErrInvalid, m)
68+}
69+
70+func issueToPB(i domain.Issue) *pb.Issue {
71+ return &pb.Issue{
72+ Repo: i.Repo.String(),
73+ Number: i.Number,
74+ Title: i.Title.String(),
75+ Body: i.Body.String(),
76+ State: issueStateToPB(i.State),
77+ Author: i.Author,
78+ CreatedAt: timestamp(i.CreatedAt),
79+ UpdatedAt: timestamp(i.UpdatedAt),
80+ CommentCount: int32(i.CommentSeq), //nolint:gosec // bounded by usecase.MaxCommentsPerIssue
81+ }
82+}
83+
84+func issueCommentToPB(c domain.IssueComment) *pb.IssueComment {
85+ return &pb.IssueComment{
86+ Id: strconv.FormatInt(c.ID, 10),
87+ Author: c.Author,
88+ Text: c.Text.String(),
89+ CreatedAt: timestamp(c.CreatedAt),
90+ }
91+}
92+
93+func (s *issueService) CreateIssue(ctx context.Context, req *connect.Request[pb.CreateIssueRequest]) (*connect.Response[pb.CreateIssueResponse], error) {
94+ name, err := domain.ParseRepoName(req.Msg.GetRepo())
95+ if err != nil {
96+ return nil, connectError(ctx, err)
97+ }
98+ issue, err := s.issues.Create(ctx, caller(ctx), name, ports.CreateIssueParams{Title: req.Msg.GetTitle(), Body: req.Msg.GetBody()})
99+ if err != nil {
100+ return nil, connectError(ctx, err)
101+ }
102+ return connect.NewResponse(&pb.CreateIssueResponse{Issue: issueToPB(issue)}), nil
103+}
104+
105+func (s *issueService) ListIssues(ctx context.Context, req *connect.Request[pb.ListIssuesRequest]) (*connect.Response[pb.ListIssuesResponse], error) {
106+ name, err := domain.ParseRepoName(req.Msg.GetRepo())
107+ if err != nil {
108+ return nil, connectError(ctx, err)
109+ }
110+ state, err := issueStateFromPB(req.Msg.GetState())
111+ if err != nil {
112+ return nil, connectError(ctx, err)
113+ }
114+ page, err := s.issues.List(ctx, caller(ctx), name, ports.ListIssuesParams{
115+ State: state, PageSize: int(req.Msg.GetPageSize()), PageToken: req.Msg.GetPageToken(),
116+ })
117+ if err != nil {
118+ return nil, connectError(ctx, err)
119+ }
120+ out := &pb.ListIssuesResponse{NextPageToken: page.NextPageToken, Issues: make([]*pb.Issue, 0, len(page.Issues))}
121+ for _, i := range page.Issues {
122+ out.Issues = append(out.Issues, issueToPB(i))
123+ }
124+ return connect.NewResponse(out), nil
125+}
126+
127+func (s *issueService) GetIssue(ctx context.Context, req *connect.Request[pb.GetIssueRequest]) (*connect.Response[pb.GetIssueResponse], error) {
128+ name, err := domain.ParseRepoName(req.Msg.GetRepo())
129+ if err != nil {
130+ return nil, connectError(ctx, err)
131+ }
132+ d, err := s.issues.Get(ctx, caller(ctx), name, req.Msg.GetNumber())
133+ if err != nil {
134+ return nil, connectError(ctx, err)
135+ }
136+ out := &pb.GetIssueResponse{Issue: issueToPB(d.Issue), Comments: make([]*pb.IssueComment, 0, len(d.Comments))}
137+ for _, c := range d.Comments {
138+ out.Comments = append(out.Comments, issueCommentToPB(c))
139+ }
140+ return connect.NewResponse(out), nil
141+}
142+
143+func (s *issueService) AddIssueComment(ctx context.Context, req *connect.Request[pb.AddIssueCommentRequest]) (*connect.Response[pb.AddIssueCommentResponse], error) {
144+ name, err := domain.ParseRepoName(req.Msg.GetRepo())
145+ if err != nil {
146+ return nil, connectError(ctx, err)
147+ }
148+ c, err := s.issues.AddComment(ctx, caller(ctx), name, req.Msg.GetNumber(), req.Msg.GetText())
149+ if err != nil {
150+ return nil, connectError(ctx, err)
151+ }
152+ return connect.NewResponse(&pb.AddIssueCommentResponse{Comment: issueCommentToPB(c)}), nil
153+}
154+
155+func (s *issueService) CloseIssue(ctx context.Context, req *connect.Request[pb.CloseIssueRequest]) (*connect.Response[pb.CloseIssueResponse], error) {
156+ name, err := domain.ParseRepoName(req.Msg.GetRepo())
157+ if err != nil {
158+ return nil, connectError(ctx, err)
159+ }
160+ issue, err := s.issues.Close(ctx, caller(ctx), name, req.Msg.GetNumber())
161+ if err != nil {
162+ return nil, connectError(ctx, err)
163+ }
164+ return connect.NewResponse(&pb.CloseIssueResponse{Issue: issueToPB(issue)}), nil
165+}
166+
167+func (s *issueService) ReopenIssue(ctx context.Context, req *connect.Request[pb.ReopenIssueRequest]) (*connect.Response[pb.ReopenIssueResponse], error) {
168+ name, err := domain.ParseRepoName(req.Msg.GetRepo())
169+ if err != nil {
170+ return nil, connectError(ctx, err)
171+ }
172+ issue, err := s.issues.Reopen(ctx, caller(ctx), name, req.Msg.GetNumber())
173+ if err != nil {
174+ return nil, connectError(ctx, err)
175+ }
176+ return connect.NewResponse(&pb.ReopenIssueResponse{Issue: issueToPB(issue)}), nil
177+}
@@ -0,0 +1,177 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package rpc
4+
5+import (
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.
18+type issueService struct{ issues ports.IssueService }
19+
20+func issueStateToPB(s domain.IssueState) pb.IssueState {
21+ switch s {
22+ case domain.IssueStateOpen:
23+ return pb.IssueState_ISSUE_STATE_OPEN
24+ case domain.IssueStateClosed:
25+ return pb.IssueState_ISSUE_STATE_CLOSED
26+ case domain.IssueStatePending:
27+ return pb.IssueState_ISSUE_STATE_PENDING
28+ }
29+ return pb.IssueState_ISSUE_STATE_UNSPECIFIED
30+}
31+
32+// issueStateFromPB maps the state filter of a listing; unspecified is the
33+// empty state, which means every state. Pending is not listable.
34+func issueStateFromPB(s pb.IssueState) (domain.IssueState, error) {
35+ switch s {
36+ case pb.IssueState_ISSUE_STATE_UNSPECIFIED:
37+ return "", nil
38+ case pb.IssueState_ISSUE_STATE_OPEN:
39+ return domain.IssueStateOpen, nil
40+ case pb.IssueState_ISSUE_STATE_CLOSED:
41+ return domain.IssueStateClosed, nil
42+ }
43+ return "", fmt.Errorf("%w: issue state %v", domain.ErrInvalid, s)
44+}
45+
46+func issuesModeToPB(m domain.IssuesMode) pb.IssuesMode {
47+ switch m.OrDefault() {
48+ case domain.IssuesOff:
49+ return pb.IssuesMode_ISSUES_MODE_OFF
50+ case domain.IssuesOwner:
51+ return pb.IssuesMode_ISSUES_MODE_OWNER
52+ case domain.IssuesPublic:
53+ return pb.IssuesMode_ISSUES_MODE_PUBLIC
54+ }
55+ return pb.IssuesMode_ISSUES_MODE_UNSPECIFIED
56+}
57+
58+func issuesModeFromPB(m pb.IssuesMode) (domain.IssuesMode, error) {
59+ switch m {
60+ case pb.IssuesMode_ISSUES_MODE_OFF:
61+ return domain.IssuesOff, nil
62+ case pb.IssuesMode_ISSUES_MODE_OWNER:
63+ return domain.IssuesOwner, nil
64+ case pb.IssuesMode_ISSUES_MODE_PUBLIC:
65+ return domain.IssuesPublic, nil
66+ }
67+ return "", fmt.Errorf("%w: issues mode %v", domain.ErrInvalid, m)
68+}
69+
70+func issueToPB(i domain.Issue) *pb.Issue {
71+ return &pb.Issue{
72+ Repo: i.Repo.String(),
73+ Number: i.Number,
74+ Title: i.Title.String(),
75+ Body: i.Body.String(),
76+ State: issueStateToPB(i.State),
77+ Author: i.Author,
78+ CreatedAt: timestamp(i.CreatedAt),
79+ UpdatedAt: timestamp(i.UpdatedAt),
80+ CommentCount: int32(i.CommentSeq), //nolint:gosec // bounded by usecase.MaxCommentsPerIssue
81+ }
82+}
83+
84+func issueCommentToPB(c domain.IssueComment) *pb.IssueComment {
85+ return &pb.IssueComment{
86+ Id: strconv.FormatInt(c.ID, 10),
87+ Author: c.Author,
88+ Text: c.Text.String(),
89+ CreatedAt: timestamp(c.CreatedAt),
90+ }
91+}
92+
93+func (s *issueService) CreateIssue(ctx context.Context, req *connect.Request[pb.CreateIssueRequest]) (*connect.Response[pb.CreateIssueResponse], error) {
94+ name, err := domain.ParseRepoName(req.Msg.GetRepo())
95+ if err != nil {
96+ return nil, connectError(ctx, err)
97+ }
98+ issue, err := s.issues.Create(ctx, caller(ctx), name, ports.CreateIssueParams{Title: req.Msg.GetTitle(), Body: req.Msg.GetBody()})
99+ if err != nil {
100+ return nil, connectError(ctx, err)
101+ }
102+ return connect.NewResponse(&pb.CreateIssueResponse{Issue: issueToPB(issue)}), nil
103+}
104+
105+func (s *issueService) ListIssues(ctx context.Context, req *connect.Request[pb.ListIssuesRequest]) (*connect.Response[pb.ListIssuesResponse], error) {
106+ name, err := domain.ParseRepoName(req.Msg.GetRepo())
107+ if err != nil {
108+ return nil, connectError(ctx, err)
109+ }
110+ state, err := issueStateFromPB(req.Msg.GetState())
111+ if err != nil {
112+ return nil, connectError(ctx, err)
113+ }
114+ page, err := s.issues.List(ctx, caller(ctx), name, ports.ListIssuesParams{
115+ State: state, PageSize: int(req.Msg.GetPageSize()), PageToken: req.Msg.GetPageToken(),
116+ })
117+ if err != nil {
118+ return nil, connectError(ctx, err)
119+ }
120+ out := &pb.ListIssuesResponse{NextPageToken: page.NextPageToken, Issues: make([]*pb.Issue, 0, len(page.Issues))}
121+ for _, i := range page.Issues {
122+ out.Issues = append(out.Issues, issueToPB(i))
123+ }
124+ return connect.NewResponse(out), nil
125+}
126+
127+func (s *issueService) GetIssue(ctx context.Context, req *connect.Request[pb.GetIssueRequest]) (*connect.Response[pb.GetIssueResponse], error) {
128+ name, err := domain.ParseRepoName(req.Msg.GetRepo())
129+ if err != nil {
130+ return nil, connectError(ctx, err)
131+ }
132+ d, err := s.issues.Get(ctx, caller(ctx), name, req.Msg.GetNumber())
133+ if err != nil {
134+ return nil, connectError(ctx, err)
135+ }
136+ out := &pb.GetIssueResponse{Issue: issueToPB(d.Issue), Comments: make([]*pb.IssueComment, 0, len(d.Comments))}
137+ for _, c := range d.Comments {
138+ out.Comments = append(out.Comments, issueCommentToPB(c))
139+ }
140+ return connect.NewResponse(out), nil
141+}
142+
143+func (s *issueService) AddIssueComment(ctx context.Context, req *connect.Request[pb.AddIssueCommentRequest]) (*connect.Response[pb.AddIssueCommentResponse], error) {
144+ name, err := domain.ParseRepoName(req.Msg.GetRepo())
145+ if err != nil {
146+ return nil, connectError(ctx, err)
147+ }
148+ c, err := s.issues.AddComment(ctx, caller(ctx), name, req.Msg.GetNumber(), req.Msg.GetText())
149+ if err != nil {
150+ return nil, connectError(ctx, err)
151+ }
152+ return connect.NewResponse(&pb.AddIssueCommentResponse{Comment: issueCommentToPB(c)}), nil
153+}
154+
155+func (s *issueService) CloseIssue(ctx context.Context, req *connect.Request[pb.CloseIssueRequest]) (*connect.Response[pb.CloseIssueResponse], error) {
156+ name, err := domain.ParseRepoName(req.Msg.GetRepo())
157+ if err != nil {
158+ return nil, connectError(ctx, err)
159+ }
160+ issue, err := s.issues.Close(ctx, caller(ctx), name, req.Msg.GetNumber())
161+ if err != nil {
162+ return nil, connectError(ctx, err)
163+ }
164+ return connect.NewResponse(&pb.CloseIssueResponse{Issue: issueToPB(issue)}), nil
165+}
166+
167+func (s *issueService) ReopenIssue(ctx context.Context, req *connect.Request[pb.ReopenIssueRequest]) (*connect.Response[pb.ReopenIssueResponse], error) {
168+ name, err := domain.ParseRepoName(req.Msg.GetRepo())
169+ if err != nil {
170+ return nil, connectError(ctx, err)
171+ }
172+ issue, err := s.issues.Reopen(ctx, caller(ctx), name, req.Msg.GetNumber())
173+ if err != nil {
174+ return nil, connectError(ctx, err)
175+ }
176+ return connect.NewResponse(&pb.ReopenIssueResponse{Issue: issueToPB(issue)}), nil
177+}
M internal/adapters/rpc/repo.go +10 -2
@@ -81,15 +81,23 @@ func (s *repoService) UpdateRepo(ctx context.Context, req *connect.Request[pb.Up
8181 // updateParams maps the field mask of an update. The mask must name at least
8282 // one of "description", "topics", "visibility", "default_branch", "settings",
8383 // "settings.allowed_merge_modes" and "settings.failing_checks_block", and
84-// nothing else.
84+// nothing else; the optional issues field stands outside the mask, and alone
85+// it is a valid update.
8586 //
8687 //hexcheck:ignore suspicious-business-logic-in-adapter maps the field mask of the contract to the optional fields of the use case; no rule is decided here
8788 func updateParams(msg *pb.UpdateRepoRequest) (ports.UpdateRepoParams, error) {
8889 var params ports.UpdateRepoParams
8990 paths := msg.GetUpdateMask().GetPaths()
90- if len(paths) == 0 {
91+ if len(paths) == 0 && msg.Issues == nil {
9192 return params, fmt.Errorf("%w: update_mask is empty", domain.ErrInvalid)
9293 }
94+ if msg.Issues != nil {
95+ mode, err := issuesModeFromPB(msg.GetIssues())
96+ if err != nil {
97+ return params, err
98+ }
99+ params.Issues = &mode
100+ }
93101 for _, path := range paths {
94102 switch path {
95103 case "description":
@@ -81,15 +81,23 @@ func (s *repoService) UpdateRepo(ctx context.Context, req *connect.Request[pb.Up
81 // updateParams maps the field mask of an update. The mask must name at least81 // updateParams maps the field mask of an update. The mask must name at least
82 // one of "description", "topics", "visibility", "default_branch", "settings",82 // one of "description", "topics", "visibility", "default_branch", "settings",
83 // "settings.allowed_merge_modes" and "settings.failing_checks_block", and83 // "settings.allowed_merge_modes" and "settings.failing_checks_block", and
84-// nothing else.84+// nothing else; the optional issues field stands outside the mask, and alone
85+// it is a valid update.
85 //86 //
86 //hexcheck:ignore suspicious-business-logic-in-adapter maps the field mask of the contract to the optional fields of the use case; no rule is decided here87 //hexcheck:ignore suspicious-business-logic-in-adapter maps the field mask of the contract to the optional fields of the use case; no rule is decided here
87 func updateParams(msg *pb.UpdateRepoRequest) (ports.UpdateRepoParams, error) {88 func updateParams(msg *pb.UpdateRepoRequest) (ports.UpdateRepoParams, error) {
88 var params ports.UpdateRepoParams89 var params ports.UpdateRepoParams
89 paths := msg.GetUpdateMask().GetPaths()90 paths := msg.GetUpdateMask().GetPaths()
90- if len(paths) == 0 {91+ if len(paths) == 0 && msg.Issues == nil {
91 return params, fmt.Errorf("%w: update_mask is empty", domain.ErrInvalid)92 return params, fmt.Errorf("%w: update_mask is empty", domain.ErrInvalid)
92 }93 }
94+ if msg.Issues != nil {
95+ mode, err := issuesModeFromPB(msg.GetIssues())
96+ if err != nil {
97+ return params, err
98+ }
99+ params.Issues = &mode
100+ }
93 for _, path := range paths {101 for _, path := range paths {
94 switch path {102 switch path {
95 case "description":103 case "description":
M internal/app/api_helpers_test.go +2 -0
@@ -31,6 +31,7 @@ type clients struct {
3131 event ermiteconnect.EventServiceClient
3232 server ermiteconnect.ServerServiceClient
3333 mr ermiteconnect.MergeRequestServiceClient
34+ issue ermiteconnect.IssueServiceClient
3435
3536 session ermiteconnect.SessionServiceClient
3637 runner ermiteconnect.RunnerServiceClient
@@ -85,6 +86,7 @@ func newClientsOn(hc connect.HTTPClient, base string, opts ...connect.ClientOpti
8586 event: ermiteconnect.NewEventServiceClient(hc, base, opts...),
8687 server: ermiteconnect.NewServerServiceClient(hc, base, opts...),
8788 mr: ermiteconnect.NewMergeRequestServiceClient(hc, base, opts...),
89+ issue: ermiteconnect.NewIssueServiceClient(hc, base, opts...),
8890
8991 session: ermiteconnect.NewSessionServiceClient(hc, base, opts...),
9092 runner: ermiteconnect.NewRunnerServiceClient(hc, base, opts...),
@@ -31,6 +31,7 @@ type clients struct {
31 event ermiteconnect.EventServiceClient31 event ermiteconnect.EventServiceClient
32 server ermiteconnect.ServerServiceClient32 server ermiteconnect.ServerServiceClient
33 mr ermiteconnect.MergeRequestServiceClient33 mr ermiteconnect.MergeRequestServiceClient
34+ issue ermiteconnect.IssueServiceClient
34 35
35 session ermiteconnect.SessionServiceClient36 session ermiteconnect.SessionServiceClient
36 runner ermiteconnect.RunnerServiceClient37 runner ermiteconnect.RunnerServiceClient
@@ -85,6 +86,7 @@ func newClientsOn(hc connect.HTTPClient, base string, opts ...connect.ClientOpti
85 event: ermiteconnect.NewEventServiceClient(hc, base, opts...),86 event: ermiteconnect.NewEventServiceClient(hc, base, opts...),
86 server: ermiteconnect.NewServerServiceClient(hc, base, opts...),87 server: ermiteconnect.NewServerServiceClient(hc, base, opts...),
87 mr: ermiteconnect.NewMergeRequestServiceClient(hc, base, opts...),88 mr: ermiteconnect.NewMergeRequestServiceClient(hc, base, opts...),
89+ issue: ermiteconnect.NewIssueServiceClient(hc, base, opts...),
88 90
89 session: ermiteconnect.NewSessionServiceClient(hc, base, opts...),91 session: ermiteconnect.NewSessionServiceClient(hc, base, opts...),
90 runner: ermiteconnect.NewRunnerServiceClient(hc, base, opts...),92 runner: ermiteconnect.NewRunnerServiceClient(hc, base, opts...),
M internal/app/api_test.go +1 -1
@@ -196,7 +196,7 @@ func TestAC2ReadAccess(t *testing.T) {
196196 for _, who := range allPrincipals {
197197 resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{}))
198198 require.NoError(t, err, who)
199- assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures"}, resp.Msg.GetFeatures(), who)
199+ assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures"}, resp.Msg.GetFeatures(), who)
200200 assert.NotEmpty(t, resp.Msg.GetVersion())
201201 }
202202 })
@@ -196,7 +196,7 @@ func TestAC2ReadAccess(t *testing.T) {
196 for _, who := range allPrincipals {196 for _, who := range allPrincipals {
197 resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{}))197 resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{}))
198 require.NoError(t, err, who)198 require.NoError(t, err, who)
199- assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures"}, resp.Msg.GetFeatures(), who)199+ assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures"}, resp.Msg.GetFeatures(), who)
200 assert.NotEmpty(t, resp.Msg.GetVersion())200 assert.NotEmpty(t, resp.Msg.GetVersion())
201 }201 }
202 })202 })
M internal/app/cli_e2e_test.go +1 -1
@@ -338,7 +338,7 @@ func TestAC6CLIEndToEnd(t *testing.T) {
338338 ver := decode[versionOut](t, res.stdout)
339339 assert.Equal(t, c.url, ver.Server.URL)
340340 assert.NotEmpty(t, ver.Server.Version)
341- assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures"}, ver.Server.Features)
341+ assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures"}, ver.Server.Features)
342342 assert.Equal(t, []string{"passkeys"}, ver.Server.MissingFeatures)
343343 stdout, stderr, code = c.bin(t, secret, "version")
344344 require.Equal(t, 0, code, stderr)
@@ -338,7 +338,7 @@ func TestAC6CLIEndToEnd(t *testing.T) {
338 ver := decode[versionOut](t, res.stdout)338 ver := decode[versionOut](t, res.stdout)
339 assert.Equal(t, c.url, ver.Server.URL)339 assert.Equal(t, c.url, ver.Server.URL)
340 assert.NotEmpty(t, ver.Server.Version)340 assert.NotEmpty(t, ver.Server.Version)
341- assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures"}, ver.Server.Features)341+ assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures"}, ver.Server.Features)
342 assert.Equal(t, []string{"passkeys"}, ver.Server.MissingFeatures)342 assert.Equal(t, []string{"passkeys"}, ver.Server.MissingFeatures)
343 stdout, stderr, code = c.bin(t, secret, "version")343 stdout, stderr, code = c.bin(t, secret, "version")
344 require.Equal(t, 0, code, stderr)344 require.Equal(t, 0, code, stderr)
M internal/app/forge.go +7 -1
@@ -115,7 +115,9 @@ type forge struct {
115115 // MergeRequests and SigningKey serve MergeRequestService and
116116 // ServerService.GetSigningKey.
117117 MergeRequests *usecase.MergeRequests
118- SigningKey *usecase.SigningKey
118+ // Issues serves IssueService, and tells what a number names.
119+ Issues *usecase.Issues
120+ SigningKey *usecase.SigningKey
119121 // Signatures are the trusted keys and the commit signature checks
120122 // (ADR 0030).
121123 Signatures *usecase.Signatures
@@ -316,6 +318,9 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
316318 CI: f.CI, Checks: f.CI, PullRequests: f.CI, RefTips: store.CI(),
317319 })
318320 f.MergeRequests.WithVerifier(f.Signatures)
321+ f.Issues = usecase.NewIssues(usecase.IssuesConfig{
322+ Meta: store, Store: store.Issues(), MergeRequests: store.MergeRequests(), Bus: f.Bus, Clock: clock,
323+ })
319324 f.CI.SetPullGate(f.MergeRequests)
320325 log.Info().Str("fingerprint", signer.PublicKey().Fingerprint).Msg("commit signing key ready")
321326
@@ -362,6 +367,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
362367 Browse: f.Browse,
363368 Events: f.EventLog,
364369 MergeRequests: f.MergeRequests,
370+ Issues: f.Issues,
365371 SigningKey: f.SigningKey,
366372 TrustedKeys: f.Signatures,
367373 Sessions: f.Sessions,
@@ -115,7 +115,9 @@ type forge struct {
115 // MergeRequests and SigningKey serve MergeRequestService and115 // MergeRequests and SigningKey serve MergeRequestService and
116 // ServerService.GetSigningKey.116 // ServerService.GetSigningKey.
117 MergeRequests *usecase.MergeRequests117 MergeRequests *usecase.MergeRequests
118- SigningKey *usecase.SigningKey118+ // Issues serves IssueService, and tells what a number names.
119+ Issues *usecase.Issues
120+ SigningKey *usecase.SigningKey
119 // Signatures are the trusted keys and the commit signature checks121 // Signatures are the trusted keys and the commit signature checks
120 // (ADR 0030).122 // (ADR 0030).
121 Signatures *usecase.Signatures123 Signatures *usecase.Signatures
@@ -316,6 +318,9 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
316 CI: f.CI, Checks: f.CI, PullRequests: f.CI, RefTips: store.CI(),318 CI: f.CI, Checks: f.CI, PullRequests: f.CI, RefTips: store.CI(),
317 })319 })
318 f.MergeRequests.WithVerifier(f.Signatures)320 f.MergeRequests.WithVerifier(f.Signatures)
321+ f.Issues = usecase.NewIssues(usecase.IssuesConfig{
322+ Meta: store, Store: store.Issues(), MergeRequests: store.MergeRequests(), Bus: f.Bus, Clock: clock,
323+ })
319 f.CI.SetPullGate(f.MergeRequests)324 f.CI.SetPullGate(f.MergeRequests)
320 log.Info().Str("fingerprint", signer.PublicKey().Fingerprint).Msg("commit signing key ready")325 log.Info().Str("fingerprint", signer.PublicKey().Fingerprint).Msg("commit signing key ready")
321 326
@@ -362,6 +367,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
362 Browse: f.Browse,367 Browse: f.Browse,
363 Events: f.EventLog,368 Events: f.EventLog,
364 MergeRequests: f.MergeRequests,369 MergeRequests: f.MergeRequests,
370+ Issues: f.Issues,
365 SigningKey: f.SigningKey,371 SigningKey: f.SigningKey,
366 TrustedKeys: f.Signatures,372 TrustedKeys: f.Signatures,
367 Sessions: f.Sessions,373 Sessions: f.Sessions,
A internal/app/issue_api_test.go +242 -0
@@ -0,0 +1,242 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "testing"
7+
8+ "connectrpc.com/connect"
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+ "google.golang.org/protobuf/types/known/fieldmaskpb"
12+
13+ pb "bnema.dev/ermite-api/gen/ermite"
14+ "bnema.dev/ermite/internal/domain"
15+ "bnema.dev/ermite/internal/ports"
16+)
17+
18+// issueRig is a harness with the repositories pub and priv, and one caller per
19+// kind of principal.
20+type issueRig struct {
21+ *harness
22+ anon, root, reader, writer, blind, branch *clients
23+}
24+
25+func newIssueRig(t *testing.T) *issueRig {
26+ t.Helper()
27+ h := newHarness(t, harnessOpts{})
28+ r := &issueRig{harness: h}
29+ h.seed("pub", domain.VisibilityPublic)
30+ h.seed("priv", domain.VisibilityPrivate)
31+ r.anon, r.root = h.api(""), h.api(adminToken)
32+ // reader has no issue:write; writer reads and writes; blind holds only
33+ // issue:write; branch is writer limited to branch prefixes.
34+ r.reader = h.api(h.token(scopes(domain.ScopeRepoRead)))
35+ r.writer = h.api(h.token(scopes(domain.ScopeRepoRead, domain.ScopeIssueWrite)))
36+ r.blind = h.api(h.token(scopes(domain.ScopeIssueWrite)))
37+ secret, _ := h.branchToken(scopes(domain.ScopeRepoRead, domain.ScopeRepoWrite, domain.ScopeIssueWrite), []string{"agent/"})
38+ r.branch = h.api(secret)
39+ return r
40+}
41+
42+func (r *issueRig) mode(repo string, mode domain.IssuesMode) {
43+ r.t.Helper()
44+ _, err := r.forge.Repos.Update(r.ctx, domain.Root(), r.name(repo), ports.UpdateRepoParams{Issues: &mode})
45+ require.NoError(r.t, err)
46+}
47+
48+func TestIssueAccessMatrixOverTheAPI(t *testing.T) {
49+ r := newIssueRig(t)
50+ ctx := r.ctx
51+ type who struct {
52+ name string
53+ c *clients
54+ }
55+ all := []who{{"anonymous", r.anon}, {"reader", r.reader}, {"writer", r.writer}, {"blind", r.blind}, {"branch-limited", r.branch}, {"root", r.root}}
56+
57+ calls := func(repo string) (reads, writes map[string]func(c *clients) error) {
58+ reads = map[string]func(c *clients) error{
59+ "ListIssues": func(c *clients) error {
60+ _, err := c.issue.ListIssues(ctx, connect.NewRequest(&pb.ListIssuesRequest{Repo: repo}))
61+ return err
62+ },
63+ "GetIssue": func(c *clients) error {
64+ _, err := c.issue.GetIssue(ctx, connect.NewRequest(&pb.GetIssueRequest{Repo: repo, Number: 1}))
65+ return err
66+ },
67+ }
68+ writes = map[string]func(c *clients) error{
69+ "CreateIssue": func(c *clients) error {
70+ _, err := c.issue.CreateIssue(ctx, connect.NewRequest(&pb.CreateIssueRequest{Repo: repo, Title: "t", Body: "b"}))
71+ return err
72+ },
73+ "AddIssueComment": func(c *clients) error {
74+ _, err := c.issue.AddIssueComment(ctx, connect.NewRequest(&pb.AddIssueCommentRequest{Repo: repo, Number: 1, Text: "x"}))
75+ return err
76+ },
77+ "CloseIssue": func(c *clients) error {
78+ _, err := c.issue.CloseIssue(ctx, connect.NewRequest(&pb.CloseIssueRequest{Repo: repo, Number: 1}))
79+ return err
80+ },
81+ "ReopenIssue": func(c *clients) error {
82+ _, err := c.issue.ReopenIssue(ctx, connect.NewRequest(&pb.ReopenIssueRequest{Repo: repo, Number: 1}))
83+ return err
84+ },
85+ }
86+ return reads, writes
87+ }
88+ // setState puts issue 1 in the state a call needs to succeed.
89+ setState := func(repo, call string) {
90+ name := r.name(repo)
91+ if call == "ReopenIssue" {
92+ _, _ = r.forge.Issues.Close(ctx, domain.Root(), name, 1)
93+ } else {
94+ _, _ = r.forge.Issues.Reopen(ctx, domain.Root(), name, 1)
95+ }
96+ }
97+
98+ // Mode owner: the first issue of each repository is #1.
99+ for _, repo := range []string{"pub", "priv"} {
100+ r.mode(repo, domain.IssuesOwner)
101+ _, err := r.forge.Issues.Create(ctx, domain.Root(), r.name(repo), ports.CreateIssueParams{Title: "first"})
102+ require.NoError(t, err)
103+ }
104+
105+ readWant := map[string]map[string]string{
106+ "priv": {"anonymous": "not_found", "reader": "ok", "writer": "ok", "blind": "not_found", "branch-limited": "ok", "root": "ok"},
107+ "pub": {"anonymous": "ok", "reader": "ok", "writer": "ok", "blind": "ok", "branch-limited": "ok", "root": "ok"},
108+ }
109+ writeWant := map[string]map[string]string{
110+ // A token with issue:write alone sees the repository for the action,
111+ // as mr:write does; a reader without the scope is refused.
112+ "priv": {"anonymous": "not_found", "reader": "permission_denied", "writer": "ok", "blind": "ok", "branch-limited": "ok", "root": "ok"},
113+ "pub": {"anonymous": "unauthenticated", "reader": "permission_denied", "writer": "ok", "blind": "ok", "branch-limited": "ok", "root": "ok"},
114+ }
115+ for _, repo := range []string{"priv", "pub"} {
116+ reads, writes := calls(repo)
117+ for _, w := range all {
118+ for name, call := range reads {
119+ assert.Equal(t, readWant[repo][w.name], codeName(call(w.c)), "%s %s %s", repo, w.name, name)
120+ }
121+ for name, call := range writes {
122+ setState(repo, name)
123+ assert.Equal(t, writeWant[repo][w.name], codeName(call(w.c)), "%s %s %s", repo, w.name, name)
124+ }
125+ }
126+ }
127+
128+ // Issues off, an unknown repository: not found for everyone, root included.
129+ r.mode("priv", domain.IssuesOff)
130+ r.mode("pub", domain.IssuesOff)
131+ for _, repo := range []string{"priv", "pub", "nope"} {
132+ reads, writes := calls(repo)
133+ for _, w := range all {
134+ for name, call := range reads {
135+ assert.Equal(t, "not_found", codeName(call(w.c)), "%s %s %s", repo, w.name, name)
136+ }
137+ for name, call := range writes {
138+ assert.Equal(t, "not_found", codeName(call(w.c)), "%s %s %s", repo, w.name, name)
139+ }
140+ }
141+ }
142+}
143+
144+func TestIssueLifecycleAndSharedNumbersOverTheAPI(t *testing.T) {
145+ r := newMRRig(t, domain.VisibilityPublic)
146+ ctx := r.ctx
147+ _, err := r.forge.Repos.Update(ctx, domain.Root(), r.name("proj"), ports.UpdateRepoParams{Issues: new(domain.IssuesOwner)})
148+ require.NoError(t, err)
149+
150+ mr, err := r.create(r.root)
151+ require.NoError(t, err)
152+ assert.EqualValues(t, 1, mr.GetNumber())
153+
154+ created, err := r.root.issue.CreateIssue(ctx, connect.NewRequest(&pb.CreateIssueRequest{Repo: "proj", Title: "Crash", Body: "it crashes"}))
155+ require.NoError(t, err)
156+ issue := created.Msg.GetIssue()
157+ assert.EqualValues(t, 2, issue.GetNumber(), "numbers are shared with merge requests")
158+ assert.Equal(t, pb.IssueState_ISSUE_STATE_OPEN, issue.GetState())
159+ assert.Equal(t, "root", issue.GetAuthor())
160+
161+ // An issue is not a merge request, and the other way round.
162+ _, err = r.root.mr.GetMergeRequest(ctx, connect.NewRequest(&pb.GetMergeRequestRequest{Repo: "proj", Number: 2}))
163+ assert.Equal(t, "not_found", codeName(err))
164+ _, err = r.root.issue.GetIssue(ctx, connect.NewRequest(&pb.GetIssueRequest{Repo: "proj", Number: 1}))
165+ assert.Equal(t, "not_found", codeName(err))
166+
167+ _, err = r.root.issue.AddIssueComment(ctx, connect.NewRequest(&pb.AddIssueCommentRequest{Repo: "proj", Number: 2, Text: "seen"}))
168+ require.NoError(t, err)
169+ _, err = r.root.issue.AddIssueComment(ctx, connect.NewRequest(&pb.AddIssueCommentRequest{Repo: "proj", Number: 2, Text: " "}))
170+ assert.Equal(t, "invalid_argument", codeName(err), "a blank comment")
171+ _, err = r.root.issue.CreateIssue(ctx, connect.NewRequest(&pb.CreateIssueRequest{Repo: "proj", Title: ""}))
172+ assert.Equal(t, "invalid_argument", codeName(err), "an empty title")
173+
174+ _, err = r.root.issue.ReopenIssue(ctx, connect.NewRequest(&pb.ReopenIssueRequest{Repo: "proj", Number: 2}))
175+ assert.Equal(t, "failed_precondition", codeName(err), "reopening an open issue")
176+ closed, err := r.root.issue.CloseIssue(ctx, connect.NewRequest(&pb.CloseIssueRequest{Repo: "proj", Number: 2}))
177+ require.NoError(t, err)
178+ assert.Equal(t, pb.IssueState_ISSUE_STATE_CLOSED, closed.Msg.GetIssue().GetState())
179+ _, err = r.root.issue.CloseIssue(ctx, connect.NewRequest(&pb.CloseIssueRequest{Repo: "proj", Number: 2}))
180+ assert.Equal(t, "failed_precondition", codeName(err), "closing a closed issue")
181+
182+ got, err := r.anon.issue.GetIssue(ctx, connect.NewRequest(&pb.GetIssueRequest{Repo: "proj", Number: 2}))
183+ require.NoError(t, err)
184+ assert.Equal(t, pb.IssueState_ISSUE_STATE_CLOSED, got.Msg.GetIssue().GetState())
185+ assert.EqualValues(t, 1, got.Msg.GetIssue().GetCommentCount())
186+ require.Len(t, got.Msg.GetComments(), 1)
187+ assert.Equal(t, "seen", got.Msg.GetComments()[0].GetText())
188+
189+ reopened, err := r.root.issue.ReopenIssue(ctx, connect.NewRequest(&pb.ReopenIssueRequest{Repo: "proj", Number: 2}))
190+ require.NoError(t, err)
191+ assert.Equal(t, pb.IssueState_ISSUE_STATE_OPEN, reopened.Msg.GetIssue().GetState())
192+
193+ list, err := r.anon.issue.ListIssues(ctx, connect.NewRequest(&pb.ListIssuesRequest{Repo: "proj", State: pb.IssueState_ISSUE_STATE_OPEN}))
194+ require.NoError(t, err)
195+ require.Len(t, list.Msg.GetIssues(), 1)
196+ list, err = r.anon.issue.ListIssues(ctx, connect.NewRequest(&pb.ListIssuesRequest{Repo: "proj", State: pb.IssueState_ISSUE_STATE_CLOSED}))
197+ require.NoError(t, err)
198+ assert.Empty(t, list.Msg.GetIssues())
199+}
200+
201+func TestIssuesFeatureAndSettingOverTheAPI(t *testing.T) {
202+ r := newIssueRig(t)
203+ ctx := r.ctx
204+ ver, err := r.anon.server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{}))
205+ require.NoError(t, err)
206+ assert.Contains(t, ver.Msg.GetFeatures(), "issues")
207+
208+ update := func(c *clients, mode pb.IssuesMode) (*pb.Repo, error) {
209+ res, err := c.repo.UpdateRepo(ctx, connect.NewRequest(&pb.UpdateRepoRequest{
210+ Repo: "priv", UpdateMask: &fieldmaskpb.FieldMask{}, Issues: &mode,
211+ }))
212+ if err != nil {
213+ return nil, err
214+ }
215+ return res.Msg.GetRepo(), nil
216+ }
217+ repo, err := r.root.repo.GetRepo(ctx, connect.NewRequest(&pb.GetRepoRequest{Repo: "priv"}))
218+ require.NoError(t, err)
219+ assert.Equal(t, pb.IssuesMode_ISSUES_MODE_OFF, repo.Msg.GetRepo().GetSettings().GetIssues())
220+
221+ updated, err := update(r.root, pb.IssuesMode_ISSUES_MODE_OWNER)
222+ require.NoError(t, err)
223+ assert.Equal(t, pb.IssuesMode_ISSUES_MODE_OWNER, updated.GetSettings().GetIssues())
224+ assert.True(t, updated.GetSettings().GetFailingChecksBlock(), "the other settings are kept")
225+ _, err = r.root.issue.CreateIssue(ctx, connect.NewRequest(&pb.CreateIssueRequest{Repo: "priv", Title: "t"}))
226+ require.NoError(t, err)
227+
228+ _, err = update(r.root, pb.IssuesMode_ISSUES_MODE_PUBLIC)
229+ assert.Equal(t, "invalid_argument", codeName(err), "public issues are refused")
230+ _, err = update(r.root, pb.IssuesMode_ISSUES_MODE_UNSPECIFIED)
231+ assert.Equal(t, "invalid_argument", codeName(err))
232+ repo, err = r.root.repo.GetRepo(ctx, connect.NewRequest(&pb.GetRepoRequest{Repo: "priv"}))
233+ require.NoError(t, err)
234+ assert.Equal(t, pb.IssuesMode_ISSUES_MODE_OWNER, repo.Msg.GetRepo().GetSettings().GetIssues())
235+
236+ _, err = update(r.writer, pb.IssuesMode_ISSUES_MODE_OFF)
237+ assert.Equal(t, "permission_denied", codeName(err), "repo:admin is needed")
238+ _, err = update(r.anon, pb.IssuesMode_ISSUES_MODE_OFF)
239+ assert.Equal(t, "not_found", codeName(err), "anonymous cannot see the private repository")
240+ _, err = update(r.root, pb.IssuesMode_ISSUES_MODE_OFF)
241+ require.NoError(t, err)
242+}
@@ -0,0 +1,242 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "testing"
7+
8+ "connectrpc.com/connect"
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+ "google.golang.org/protobuf/types/known/fieldmaskpb"
12+
13+ pb "bnema.dev/ermite-api/gen/ermite"
14+ "bnema.dev/ermite/internal/domain"
15+ "bnema.dev/ermite/internal/ports"
16+)
17+
18+// issueRig is a harness with the repositories pub and priv, and one caller per
19+// kind of principal.
20+type issueRig struct {
21+ *harness
22+ anon, root, reader, writer, blind, branch *clients
23+}
24+
25+func newIssueRig(t *testing.T) *issueRig {
26+ t.Helper()
27+ h := newHarness(t, harnessOpts{})
28+ r := &issueRig{harness: h}
29+ h.seed("pub", domain.VisibilityPublic)
30+ h.seed("priv", domain.VisibilityPrivate)
31+ r.anon, r.root = h.api(""), h.api(adminToken)
32+ // reader has no issue:write; writer reads and writes; blind holds only
33+ // issue:write; branch is writer limited to branch prefixes.
34+ r.reader = h.api(h.token(scopes(domain.ScopeRepoRead)))
35+ r.writer = h.api(h.token(scopes(domain.ScopeRepoRead, domain.ScopeIssueWrite)))
36+ r.blind = h.api(h.token(scopes(domain.ScopeIssueWrite)))
37+ secret, _ := h.branchToken(scopes(domain.ScopeRepoRead, domain.ScopeRepoWrite, domain.ScopeIssueWrite), []string{"agent/"})
38+ r.branch = h.api(secret)
39+ return r
40+}
41+
42+func (r *issueRig) mode(repo string, mode domain.IssuesMode) {
43+ r.t.Helper()
44+ _, err := r.forge.Repos.Update(r.ctx, domain.Root(), r.name(repo), ports.UpdateRepoParams{Issues: &mode})
45+ require.NoError(r.t, err)
46+}
47+
48+func TestIssueAccessMatrixOverTheAPI(t *testing.T) {
49+ r := newIssueRig(t)
50+ ctx := r.ctx
51+ type who struct {
52+ name string
53+ c *clients
54+ }
55+ all := []who{{"anonymous", r.anon}, {"reader", r.reader}, {"writer", r.writer}, {"blind", r.blind}, {"branch-limited", r.branch}, {"root", r.root}}
56+
57+ calls := func(repo string) (reads, writes map[string]func(c *clients) error) {
58+ reads = map[string]func(c *clients) error{
59+ "ListIssues": func(c *clients) error {
60+ _, err := c.issue.ListIssues(ctx, connect.NewRequest(&pb.ListIssuesRequest{Repo: repo}))
61+ return err
62+ },
63+ "GetIssue": func(c *clients) error {
64+ _, err := c.issue.GetIssue(ctx, connect.NewRequest(&pb.GetIssueRequest{Repo: repo, Number: 1}))
65+ return err
66+ },
67+ }
68+ writes = map[string]func(c *clients) error{
69+ "CreateIssue": func(c *clients) error {
70+ _, err := c.issue.CreateIssue(ctx, connect.NewRequest(&pb.CreateIssueRequest{Repo: repo, Title: "t", Body: "b"}))
71+ return err
72+ },
73+ "AddIssueComment": func(c *clients) error {
74+ _, err := c.issue.AddIssueComment(ctx, connect.NewRequest(&pb.AddIssueCommentRequest{Repo: repo, Number: 1, Text: "x"}))
75+ return err
76+ },
77+ "CloseIssue": func(c *clients) error {
78+ _, err := c.issue.CloseIssue(ctx, connect.NewRequest(&pb.CloseIssueRequest{Repo: repo, Number: 1}))
79+ return err
80+ },
81+ "ReopenIssue": func(c *clients) error {
82+ _, err := c.issue.ReopenIssue(ctx, connect.NewRequest(&pb.ReopenIssueRequest{Repo: repo, Number: 1}))
83+ return err
84+ },
85+ }
86+ return reads, writes
87+ }
88+ // setState puts issue 1 in the state a call needs to succeed.
89+ setState := func(repo, call string) {
90+ name := r.name(repo)
91+ if call == "ReopenIssue" {
92+ _, _ = r.forge.Issues.Close(ctx, domain.Root(), name, 1)
93+ } else {
94+ _, _ = r.forge.Issues.Reopen(ctx, domain.Root(), name, 1)
95+ }
96+ }
97+
98+ // Mode owner: the first issue of each repository is #1.
99+ for _, repo := range []string{"pub", "priv"} {
100+ r.mode(repo, domain.IssuesOwner)
101+ _, err := r.forge.Issues.Create(ctx, domain.Root(), r.name(repo), ports.CreateIssueParams{Title: "first"})
102+ require.NoError(t, err)
103+ }
104+
105+ readWant := map[string]map[string]string{
106+ "priv": {"anonymous": "not_found", "reader": "ok", "writer": "ok", "blind": "not_found", "branch-limited": "ok", "root": "ok"},
107+ "pub": {"anonymous": "ok", "reader": "ok", "writer": "ok", "blind": "ok", "branch-limited": "ok", "root": "ok"},
108+ }
109+ writeWant := map[string]map[string]string{
110+ // A token with issue:write alone sees the repository for the action,
111+ // as mr:write does; a reader without the scope is refused.
112+ "priv": {"anonymous": "not_found", "reader": "permission_denied", "writer": "ok", "blind": "ok", "branch-limited": "ok", "root": "ok"},
113+ "pub": {"anonymous": "unauthenticated", "reader": "permission_denied", "writer": "ok", "blind": "ok", "branch-limited": "ok", "root": "ok"},
114+ }
115+ for _, repo := range []string{"priv", "pub"} {
116+ reads, writes := calls(repo)
117+ for _, w := range all {
118+ for name, call := range reads {
119+ assert.Equal(t, readWant[repo][w.name], codeName(call(w.c)), "%s %s %s", repo, w.name, name)
120+ }
121+ for name, call := range writes {
122+ setState(repo, name)
123+ assert.Equal(t, writeWant[repo][w.name], codeName(call(w.c)), "%s %s %s", repo, w.name, name)
124+ }
125+ }
126+ }
127+
128+ // Issues off, an unknown repository: not found for everyone, root included.
129+ r.mode("priv", domain.IssuesOff)
130+ r.mode("pub", domain.IssuesOff)
131+ for _, repo := range []string{"priv", "pub", "nope"} {
132+ reads, writes := calls(repo)
133+ for _, w := range all {
134+ for name, call := range reads {
135+ assert.Equal(t, "not_found", codeName(call(w.c)), "%s %s %s", repo, w.name, name)
136+ }
137+ for name, call := range writes {
138+ assert.Equal(t, "not_found", codeName(call(w.c)), "%s %s %s", repo, w.name, name)
139+ }
140+ }
141+ }
142+}
143+
144+func TestIssueLifecycleAndSharedNumbersOverTheAPI(t *testing.T) {
145+ r := newMRRig(t, domain.VisibilityPublic)
146+ ctx := r.ctx
147+ _, err := r.forge.Repos.Update(ctx, domain.Root(), r.name("proj"), ports.UpdateRepoParams{Issues: new(domain.IssuesOwner)})
148+ require.NoError(t, err)
149+
150+ mr, err := r.create(r.root)
151+ require.NoError(t, err)
152+ assert.EqualValues(t, 1, mr.GetNumber())
153+
154+ created, err := r.root.issue.CreateIssue(ctx, connect.NewRequest(&pb.CreateIssueRequest{Repo: "proj", Title: "Crash", Body: "it crashes"}))
155+ require.NoError(t, err)
156+ issue := created.Msg.GetIssue()
157+ assert.EqualValues(t, 2, issue.GetNumber(), "numbers are shared with merge requests")
158+ assert.Equal(t, pb.IssueState_ISSUE_STATE_OPEN, issue.GetState())
159+ assert.Equal(t, "root", issue.GetAuthor())
160+
161+ // An issue is not a merge request, and the other way round.
162+ _, err = r.root.mr.GetMergeRequest(ctx, connect.NewRequest(&pb.GetMergeRequestRequest{Repo: "proj", Number: 2}))
163+ assert.Equal(t, "not_found", codeName(err))
164+ _, err = r.root.issue.GetIssue(ctx, connect.NewRequest(&pb.GetIssueRequest{Repo: "proj", Number: 1}))
165+ assert.Equal(t, "not_found", codeName(err))
166+
167+ _, err = r.root.issue.AddIssueComment(ctx, connect.NewRequest(&pb.AddIssueCommentRequest{Repo: "proj", Number: 2, Text: "seen"}))
168+ require.NoError(t, err)
169+ _, err = r.root.issue.AddIssueComment(ctx, connect.NewRequest(&pb.AddIssueCommentRequest{Repo: "proj", Number: 2, Text: " "}))
170+ assert.Equal(t, "invalid_argument", codeName(err), "a blank comment")
171+ _, err = r.root.issue.CreateIssue(ctx, connect.NewRequest(&pb.CreateIssueRequest{Repo: "proj", Title: ""}))
172+ assert.Equal(t, "invalid_argument", codeName(err), "an empty title")
173+
174+ _, err = r.root.issue.ReopenIssue(ctx, connect.NewRequest(&pb.ReopenIssueRequest{Repo: "proj", Number: 2}))
175+ assert.Equal(t, "failed_precondition", codeName(err), "reopening an open issue")
176+ closed, err := r.root.issue.CloseIssue(ctx, connect.NewRequest(&pb.CloseIssueRequest{Repo: "proj", Number: 2}))
177+ require.NoError(t, err)
178+ assert.Equal(t, pb.IssueState_ISSUE_STATE_CLOSED, closed.Msg.GetIssue().GetState())
179+ _, err = r.root.issue.CloseIssue(ctx, connect.NewRequest(&pb.CloseIssueRequest{Repo: "proj", Number: 2}))
180+ assert.Equal(t, "failed_precondition", codeName(err), "closing a closed issue")
181+
182+ got, err := r.anon.issue.GetIssue(ctx, connect.NewRequest(&pb.GetIssueRequest{Repo: "proj", Number: 2}))
183+ require.NoError(t, err)
184+ assert.Equal(t, pb.IssueState_ISSUE_STATE_CLOSED, got.Msg.GetIssue().GetState())
185+ assert.EqualValues(t, 1, got.Msg.GetIssue().GetCommentCount())
186+ require.Len(t, got.Msg.GetComments(), 1)
187+ assert.Equal(t, "seen", got.Msg.GetComments()[0].GetText())
188+
189+ reopened, err := r.root.issue.ReopenIssue(ctx, connect.NewRequest(&pb.ReopenIssueRequest{Repo: "proj", Number: 2}))
190+ require.NoError(t, err)
191+ assert.Equal(t, pb.IssueState_ISSUE_STATE_OPEN, reopened.Msg.GetIssue().GetState())
192+
193+ list, err := r.anon.issue.ListIssues(ctx, connect.NewRequest(&pb.ListIssuesRequest{Repo: "proj", State: pb.IssueState_ISSUE_STATE_OPEN}))
194+ require.NoError(t, err)
195+ require.Len(t, list.Msg.GetIssues(), 1)
196+ list, err = r.anon.issue.ListIssues(ctx, connect.NewRequest(&pb.ListIssuesRequest{Repo: "proj", State: pb.IssueState_ISSUE_STATE_CLOSED}))
197+ require.NoError(t, err)
198+ assert.Empty(t, list.Msg.GetIssues())
199+}
200+
201+func TestIssuesFeatureAndSettingOverTheAPI(t *testing.T) {
202+ r := newIssueRig(t)
203+ ctx := r.ctx
204+ ver, err := r.anon.server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{}))
205+ require.NoError(t, err)
206+ assert.Contains(t, ver.Msg.GetFeatures(), "issues")
207+
208+ update := func(c *clients, mode pb.IssuesMode) (*pb.Repo, error) {
209+ res, err := c.repo.UpdateRepo(ctx, connect.NewRequest(&pb.UpdateRepoRequest{
210+ Repo: "priv", UpdateMask: &fieldmaskpb.FieldMask{}, Issues: &mode,
211+ }))
212+ if err != nil {
213+ return nil, err
214+ }
215+ return res.Msg.GetRepo(), nil
216+ }
217+ repo, err := r.root.repo.GetRepo(ctx, connect.NewRequest(&pb.GetRepoRequest{Repo: "priv"}))
218+ require.NoError(t, err)
219+ assert.Equal(t, pb.IssuesMode_ISSUES_MODE_OFF, repo.Msg.GetRepo().GetSettings().GetIssues())
220+
221+ updated, err := update(r.root, pb.IssuesMode_ISSUES_MODE_OWNER)
222+ require.NoError(t, err)
223+ assert.Equal(t, pb.IssuesMode_ISSUES_MODE_OWNER, updated.GetSettings().GetIssues())
224+ assert.True(t, updated.GetSettings().GetFailingChecksBlock(), "the other settings are kept")
225+ _, err = r.root.issue.CreateIssue(ctx, connect.NewRequest(&pb.CreateIssueRequest{Repo: "priv", Title: "t"}))
226+ require.NoError(t, err)
227+
228+ _, err = update(r.root, pb.IssuesMode_ISSUES_MODE_PUBLIC)
229+ assert.Equal(t, "invalid_argument", codeName(err), "public issues are refused")
230+ _, err = update(r.root, pb.IssuesMode_ISSUES_MODE_UNSPECIFIED)
231+ assert.Equal(t, "invalid_argument", codeName(err))
232+ repo, err = r.root.repo.GetRepo(ctx, connect.NewRequest(&pb.GetRepoRequest{Repo: "priv"}))
233+ require.NoError(t, err)
234+ assert.Equal(t, pb.IssuesMode_ISSUES_MODE_OWNER, repo.Msg.GetRepo().GetSettings().GetIssues())
235+
236+ _, err = update(r.writer, pb.IssuesMode_ISSUES_MODE_OFF)
237+ assert.Equal(t, "permission_denied", codeName(err), "repo:admin is needed")
238+ _, err = update(r.anon, pb.IssuesMode_ISSUES_MODE_OFF)
239+ assert.Equal(t, "not_found", codeName(err), "anonymous cannot see the private repository")
240+ _, err = update(r.root, pb.IssuesMode_ISSUES_MODE_OFF)
241+ require.NoError(t, err)
242+}

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 43

↑↓ moveenter openesc close