feat(rpc): serve IssueService and the issues setting, wire issues
- commit
- a5d6827eb29c19e5efa957269e4f27e8447b5921
- author
- brice · · 2026-10-19 05:40 UTC
- parent
- 4c8ce48
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 | |||
| 35 | 35 | // always implements, as GetVersion lists them. FeaturePasskeys comes on top | |
| 36 | 36 | // when Config.Passkeys is set, and FeatureCISecrets when the settings services | |
| 37 | 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 | 40 | // FeaturePasskeys is listed by GetVersion when passkeys are available. | |
| 41 | 41 | // FeatureCIArtifacts (artifact.go) when the artifact services are served. | |
| @@ -55,7 +55,9 @@ type Config struct { | |||
| 55 | 55 | // MergeRequests serves MergeRequestService, SigningKey GetSigningKey. | |
| 56 | 56 | MergeRequests ports.MergeRequestService | |
| 57 | 57 | SigningKey ports.SigningKeyService | |
| 58 | - | Version string | |
| 58 | + | // Issues serves IssueService. | |
| 59 | + | Issues ports.IssueService | |
| 60 | + | Version string | |
| 59 | 61 | // TrustedProxies are the networks allowed to set X-Forwarded-For. | |
| 60 | 62 | TrustedProxies []netip.Prefix | |
| 61 | 63 | // Limiter counts failed authentications; nil builds a default one. Share | |
| @@ -142,6 +144,7 @@ func New(cfg Config) *API { | |||
| 142 | 144 | } | |
| 143 | 145 | mux.Handle(ermiteconnect.NewServerServiceHandler(&serverService{version: cfg.Version, features: features, key: cfg.SigningKey, oidc: cfg.OIDCKeys}, opts...)) | |
| 144 | 146 | mux.Handle(ermiteconnect.NewMergeRequestServiceHandler(&mergeRequestService{cfg.MergeRequests}, opts...)) | |
| 147 | + | mux.Handle(ermiteconnect.NewIssueServiceHandler(&issueService{cfg.Issues}, opts...)) | |
| 145 | 148 | if cfg.Sessions != nil { | |
| 146 | 149 | mux.Handle(ermiteconnect.NewSessionServiceHandler(&sessionService{sessions: cfg.Sessions, passkeys: cfg.Passkeys}, opts...)) | |
| 147 | 150 | } | |
| @@ -35,7 +35,7 @@ const RunnerMaxRequestBytes = MaxRequestBytes + 64<<10 | |||||
| 35 | // always implements, as GetVersion lists them. FeaturePasskeys comes on top | 35 | // always implements, as GetVersion lists them. FeaturePasskeys comes on top | ||
| 36 | // when Config.Passkeys is set, and FeatureCISecrets when the settings services | 36 | // 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.MergeRequestService | 56 | MergeRequests ports.MergeRequestService | ||
| 57 | SigningKey ports.SigningKeyService | 57 | SigningKey ports.SigningKeyService | ||
| 58 | - | Version string | 58 | + | // 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.Prefix | 62 | TrustedProxies []netip.Prefix | ||
| 61 | // Limiter counts failed authentications; nil builds a default one. Share | 63 | // 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 { | |||
| 75 | 75 | } | |
| 76 | 76 | ||
| 77 | 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 | 79 | for _, m := range s.AllowedMergeModes { | |
| 80 | 80 | out.AllowedMergeModes = append(out.AllowedMergeModes, mergeModeToPB(m)) | |
| 81 | 81 | } | |
| @@ -472,6 +472,18 @@ func watchEventToPB(we ports.WatchEvent) *pb.Event { | |||
| 472 | 472 | case domain.CommentAdded: | |
| 473 | 473 | out.Time = timestamp(ev.At) | |
| 474 | 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 | 487 | case domain.RunUpdated: | |
| 476 | 488 | out.Time = timestamp(ev.At) | |
| 477 | 489 | 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) { | |||
| 336 | 336 | assert.Equal(t, pb.DiffSide_DIFF_SIDE_OLD, added.GetComment().GetSide()) | |
| 337 | 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 | + | } | |
| @@ -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 | |||
| 81 | 81 | // updateParams maps the field mask of an update. The mask must name at least | |
| 82 | 82 | // one of "description", "topics", "visibility", "default_branch", "settings", | |
| 83 | 83 | // "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 | 87 | //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 | 88 | func updateParams(msg *pb.UpdateRepoRequest) (ports.UpdateRepoParams, error) { | |
| 88 | 89 | var params ports.UpdateRepoParams | |
| 89 | 90 | paths := msg.GetUpdateMask().GetPaths() | |
| 90 | - | if len(paths) == 0 { | |
| 91 | + | if len(paths) == 0 && msg.Issues == nil { | |
| 91 | 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 | 101 | for _, path := range paths { | |
| 94 | 102 | switch path { | |
| 95 | 103 | 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 least | 81 | // 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", and | 83 | // "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 here | 87 | //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.UpdateRepoParams | 89 | 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 { | |||
| 31 | 31 | event ermiteconnect.EventServiceClient | |
| 32 | 32 | server ermiteconnect.ServerServiceClient | |
| 33 | 33 | mr ermiteconnect.MergeRequestServiceClient | |
| 34 | + | issue ermiteconnect.IssueServiceClient | |
| 34 | 35 | ||
| 35 | 36 | session ermiteconnect.SessionServiceClient | |
| 36 | 37 | runner ermiteconnect.RunnerServiceClient | |
| @@ -85,6 +86,7 @@ func newClientsOn(hc connect.HTTPClient, base string, opts ...connect.ClientOpti | |||
| 85 | 86 | event: ermiteconnect.NewEventServiceClient(hc, base, opts...), | |
| 86 | 87 | server: ermiteconnect.NewServerServiceClient(hc, base, opts...), | |
| 87 | 88 | mr: ermiteconnect.NewMergeRequestServiceClient(hc, base, opts...), | |
| 89 | + | issue: ermiteconnect.NewIssueServiceClient(hc, base, opts...), | |
| 88 | 90 | ||
| 89 | 91 | session: ermiteconnect.NewSessionServiceClient(hc, base, opts...), | |
| 90 | 92 | runner: ermiteconnect.NewRunnerServiceClient(hc, base, opts...), | |
| @@ -31,6 +31,7 @@ type clients struct { | |||||
| 31 | event ermiteconnect.EventServiceClient | 31 | event ermiteconnect.EventServiceClient | ||
| 32 | server ermiteconnect.ServerServiceClient | 32 | server ermiteconnect.ServerServiceClient | ||
| 33 | mr ermiteconnect.MergeRequestServiceClient | 33 | mr ermiteconnect.MergeRequestServiceClient | ||
| 34 | + | issue ermiteconnect.IssueServiceClient | |||
| 34 | 35 | ||||
| 35 | session ermiteconnect.SessionServiceClient | 36 | session ermiteconnect.SessionServiceClient | ||
| 36 | runner ermiteconnect.RunnerServiceClient | 37 | 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) { | |||
| 196 | 196 | for _, who := range allPrincipals { | |
| 197 | 197 | resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{})) | |
| 198 | 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 | 200 | assert.NotEmpty(t, resp.Msg.GetVersion()) | |
| 201 | 201 | } | |
| 202 | 202 | }) | |
| @@ -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) { | |||
| 338 | 338 | ver := decode[versionOut](t, res.stdout) | |
| 339 | 339 | assert.Equal(t, c.url, ver.Server.URL) | |
| 340 | 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 | 342 | assert.Equal(t, []string{"passkeys"}, ver.Server.MissingFeatures) | |
| 343 | 343 | stdout, stderr, code = c.bin(t, secret, "version") | |
| 344 | 344 | 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 { | |||
| 115 | 115 | // MergeRequests and SigningKey serve MergeRequestService and | |
| 116 | 116 | // ServerService.GetSigningKey. | |
| 117 | 117 | 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 | |
| 119 | 121 | // Signatures are the trusted keys and the commit signature checks | |
| 120 | 122 | // (ADR 0030). | |
| 121 | 123 | Signatures *usecase.Signatures | |
| @@ -316,6 +318,9 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e | |||
| 316 | 318 | CI: f.CI, Checks: f.CI, PullRequests: f.CI, RefTips: store.CI(), | |
| 317 | 319 | }) | |
| 318 | 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 | 324 | f.CI.SetPullGate(f.MergeRequests) | |
| 320 | 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 | 367 | Browse: f.Browse, | |
| 363 | 368 | Events: f.EventLog, | |
| 364 | 369 | MergeRequests: f.MergeRequests, | |
| 370 | + | Issues: f.Issues, | |
| 365 | 371 | SigningKey: f.SigningKey, | |
| 366 | 372 | TrustedKeys: f.Signatures, | |
| 367 | 373 | Sessions: f.Sessions, | |
| @@ -115,7 +115,9 @@ type forge struct { | |||||
| 115 | // MergeRequests and SigningKey serve MergeRequestService and | 115 | // MergeRequests and SigningKey serve MergeRequestService and | ||
| 116 | // ServerService.GetSigningKey. | 116 | // ServerService.GetSigningKey. | ||
| 117 | MergeRequests *usecase.MergeRequests | 117 | 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 | |||
| 119 | // Signatures are the trusted keys and the commit signature checks | 121 | // Signatures are the trusted keys and the commit signature checks | ||
| 120 | // (ADR 0030). | 122 | // (ADR 0030). | ||
| 121 | Signatures *usecase.Signatures | 123 | 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 | + | } | |||