feat(cli): add ermite issue commands and repo edit --issues
- commit
- 582cf616e3a61942bfd10e38ffa552d502b86755
- author
- brice · · 2026-10-19 05:54 UTC
- parent
- a5d6827
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
15 files · +956 -23
M internal/adapters/client/client.go +5 -0
| @@ -95,6 +95,11 @@ func (c *Client) MergeRequests() ermiteconnect.MergeRequestServiceClient { | |||
| 95 | 95 | return ermiteconnect.NewMergeRequestServiceClient(c.http, c.base, c.opts...) | |
| 96 | 96 | } | |
| 97 | 97 | ||
| 98 | + | // Issues returns the IssueService client. | |
| 99 | + | func (c *Client) Issues() ermiteconnect.IssueServiceClient { | |
| 100 | + | return ermiteconnect.NewIssueServiceClient(c.http, c.base, c.opts...) | |
| 101 | + | } | |
| 102 | + | ||
| 98 | 103 | // Secrets returns the SecretService client. | |
| 99 | 104 | func (c *Client) Secrets() ermiteconnect.SecretServiceClient { | |
| 100 | 105 | return ermiteconnect.NewSecretServiceClient(c.http, c.base, c.opts...) | |
| @@ -95,6 +95,11 @@ func (c *Client) MergeRequests() ermiteconnect.MergeRequestServiceClient { | |||||
| 95 | return ermiteconnect.NewMergeRequestServiceClient(c.http, c.base, c.opts...) | 95 | return ermiteconnect.NewMergeRequestServiceClient(c.http, c.base, c.opts...) | ||
| 96 | } | 96 | } | ||
| 97 | 97 | ||||
| 98 | + | // Issues returns the IssueService client. | |||
| 99 | + | func (c *Client) Issues() ermiteconnect.IssueServiceClient { | |||
| 100 | + | return ermiteconnect.NewIssueServiceClient(c.http, c.base, c.opts...) | |||
| 101 | + | } | |||
| 102 | + | ||||
| 98 | // Secrets returns the SecretService client. | 103 | // Secrets returns the SecretService client. | ||
| 99 | func (c *Client) Secrets() ermiteconnect.SecretServiceClient { | 104 | func (c *Client) Secrets() ermiteconnect.SecretServiceClient { | ||
| 100 | return ermiteconnect.NewSecretServiceClient(c.http, c.base, c.opts...) | 105 | return ermiteconnect.NewSecretServiceClient(c.http, c.base, c.opts...) | ||
M internal/adapters/client/client_test.go +3 -3
| @@ -139,15 +139,15 @@ func TestDescribeBoundsLongMessages(t *testing.T) { | |||
| 139 | 139 | func TestMissingFeatures(t *testing.T) { | |
| 140 | 140 | assert.Empty(t, client.MissingFeatures(client.UsedFeatures())) | |
| 141 | 141 | assert.Equal(t, | |
| 142 | - | []string{client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSigningKey, client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, client.FeatureCIWorkflows, client.FeatureCIArtifacts, client.FeatureCommitSignatures}, | |
| 142 | + | []string{client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureIssues, client.FeatureSigningKey, client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, client.FeatureCIWorkflows, client.FeatureCIArtifacts, client.FeatureCommitSignatures}, | |
| 143 | 143 | client.MissingFeatures([]string{client.FeatureRepoUpdate, client.FeatureSession, client.FeaturePasskeys, "other"})) | |
| 144 | 144 | assert.Equal(t, []string{client.FeatureSigningKey}, client.MissingFeatures([]string{ | |
| 145 | - | client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSession, | |
| 145 | + | client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureIssues, client.FeatureSession, | |
| 146 | 146 | client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, client.FeatureCIWorkflows, | |
| 147 | 147 | client.FeatureCIArtifacts, client.FeaturePasskeys, client.FeatureCommitSignatures, | |
| 148 | 148 | })) | |
| 149 | 149 | assert.Equal(t, []string{client.FeaturePasskeys}, client.MissingFeatures([]string{ | |
| 150 | - | client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSigningKey, client.FeatureSession, | |
| 150 | + | client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureIssues, client.FeatureSigningKey, client.FeatureSession, | |
| 151 | 151 | client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, client.FeatureCIWorkflows, client.FeatureCIArtifacts, client.FeatureCommitSignatures, | |
| 152 | 152 | })) | |
| 153 | 153 | assert.Equal(t, client.UsedFeatures(), client.MissingFeatures(nil)) | |
| @@ -139,15 +139,15 @@ func TestDescribeBoundsLongMessages(t *testing.T) { | |||||
| 139 | func TestMissingFeatures(t *testing.T) { | 139 | func TestMissingFeatures(t *testing.T) { | ||
| 140 | assert.Empty(t, client.MissingFeatures(client.UsedFeatures())) | 140 | assert.Empty(t, client.MissingFeatures(client.UsedFeatures())) | ||
| 141 | assert.Equal(t, | 141 | assert.Equal(t, | ||
| 142 | - | []string{client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSigningKey, client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, client.FeatureCIWorkflows, client.FeatureCIArtifacts, client.FeatureCommitSignatures}, | 142 | + | []string{client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureIssues, client.FeatureSigningKey, client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, client.FeatureCIWorkflows, client.FeatureCIArtifacts, client.FeatureCommitSignatures}, |
| 143 | client.MissingFeatures([]string{client.FeatureRepoUpdate, client.FeatureSession, client.FeaturePasskeys, "other"})) | 143 | client.MissingFeatures([]string{client.FeatureRepoUpdate, client.FeatureSession, client.FeaturePasskeys, "other"})) | ||
| 144 | assert.Equal(t, []string{client.FeatureSigningKey}, client.MissingFeatures([]string{ | 144 | assert.Equal(t, []string{client.FeatureSigningKey}, client.MissingFeatures([]string{ | ||
| 145 | - | client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSession, | 145 | + | client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureIssues, client.FeatureSession, |
| 146 | client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, client.FeatureCIWorkflows, | 146 | client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, client.FeatureCIWorkflows, | ||
| 147 | client.FeatureCIArtifacts, client.FeaturePasskeys, client.FeatureCommitSignatures, | 147 | client.FeatureCIArtifacts, client.FeaturePasskeys, client.FeatureCommitSignatures, | ||
| 148 | })) | 148 | })) | ||
| 149 | assert.Equal(t, []string{client.FeaturePasskeys}, client.MissingFeatures([]string{ | 149 | assert.Equal(t, []string{client.FeaturePasskeys}, client.MissingFeatures([]string{ | ||
| 150 | - | client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSigningKey, client.FeatureSession, | 150 | + | client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureIssues, client.FeatureSigningKey, client.FeatureSession, |
| 151 | client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, client.FeatureCIWorkflows, client.FeatureCIArtifacts, client.FeatureCommitSignatures, | 151 | client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, client.FeatureCIWorkflows, client.FeatureCIArtifacts, client.FeatureCommitSignatures, | ||
| 152 | })) | 152 | })) | ||
| 153 | assert.Equal(t, client.UsedFeatures(), client.MissingFeatures(nil)) | 153 | assert.Equal(t, client.UsedFeatures(), client.MissingFeatures(nil)) | ||
M internal/adapters/client/features.go +4 -1
| @@ -18,6 +18,9 @@ const ( | |||
| 18 | 18 | // FeatureMergeDeleteBranch is MergeMergeRequest with delete_source_branch | |
| 19 | 19 | // (`ermite mr merge --delete-branch`). | |
| 20 | 20 | FeatureMergeDeleteBranch = "merge_requests.delete_branch" | |
| 21 | + | // FeatureIssues is IssueService, RepoSettings.issues and the issue:write | |
| 22 | + | // scope (`ermite issue`, `ermite repo edit --issues`). | |
| 23 | + | FeatureIssues = "issues" | |
| 21 | 24 | // FeatureSigningKey is ServerService.GetSigningKey (`ermite signing-key`). | |
| 22 | 25 | FeatureSigningKey = "signing_key" | |
| 23 | 26 | // FeatureSession is SessionService (`ermite web login`, `web logout`). | |
| @@ -55,7 +58,7 @@ const ( | |||
| 55 | 58 | ||
| 56 | 59 | // UsedFeatures lists the features the CLI uses, in the order they are reported. | |
| 57 | 60 | func UsedFeatures() []string { | |
| 58 | - | return []string{FeatureRepoUpdate, FeatureEventsResume, FeatureMergeRequests, FeatureMergeDeleteBranch, FeatureSigningKey, FeatureSession, FeatureTokenBranchPrefixes, FeatureCIRuns, FeatureCIRunners, FeatureCISecrets, FeatureCIDispatch, FeatureCIWorkflows, FeatureCIArtifacts, FeaturePasskeys, FeatureCommitSignatures} | |
| 61 | + | return []string{FeatureRepoUpdate, FeatureEventsResume, FeatureMergeRequests, FeatureMergeDeleteBranch, FeatureIssues, FeatureSigningKey, FeatureSession, FeatureTokenBranchPrefixes, FeatureCIRuns, FeatureCIRunners, FeatureCISecrets, FeatureCIDispatch, FeatureCIWorkflows, FeatureCIArtifacts, FeaturePasskeys, FeatureCommitSignatures} | |
| 59 | 62 | } | |
| 60 | 63 | ||
| 61 | 64 | // MissingFeatures returns the features the CLI uses that have is lacking. | |
| @@ -18,6 +18,9 @@ const ( | |||||
| 18 | // FeatureMergeDeleteBranch is MergeMergeRequest with delete_source_branch | 18 | // FeatureMergeDeleteBranch is MergeMergeRequest with delete_source_branch | ||
| 19 | // (`ermite mr merge --delete-branch`). | 19 | // (`ermite mr merge --delete-branch`). | ||
| 20 | FeatureMergeDeleteBranch = "merge_requests.delete_branch" | 20 | FeatureMergeDeleteBranch = "merge_requests.delete_branch" | ||
| 21 | + | // FeatureIssues is IssueService, RepoSettings.issues and the issue:write | |||
| 22 | + | // scope (`ermite issue`, `ermite repo edit --issues`). | |||
| 23 | + | FeatureIssues = "issues" | |||
| 21 | // FeatureSigningKey is ServerService.GetSigningKey (`ermite signing-key`). | 24 | // FeatureSigningKey is ServerService.GetSigningKey (`ermite signing-key`). | ||
| 22 | FeatureSigningKey = "signing_key" | 25 | FeatureSigningKey = "signing_key" | ||
| 23 | // FeatureSession is SessionService (`ermite web login`, `web logout`). | 26 | // FeatureSession is SessionService (`ermite web login`, `web logout`). | ||
| @@ -55,7 +58,7 @@ const ( | |||||
| 55 | 58 | ||||
| 56 | // UsedFeatures lists the features the CLI uses, in the order they are reported. | 59 | // UsedFeatures lists the features the CLI uses, in the order they are reported. | ||
| 57 | func UsedFeatures() []string { | 60 | func UsedFeatures() []string { | ||
| 58 | - | return []string{FeatureRepoUpdate, FeatureEventsResume, FeatureMergeRequests, FeatureMergeDeleteBranch, FeatureSigningKey, FeatureSession, FeatureTokenBranchPrefixes, FeatureCIRuns, FeatureCIRunners, FeatureCISecrets, FeatureCIDispatch, FeatureCIWorkflows, FeatureCIArtifacts, FeaturePasskeys, FeatureCommitSignatures} | 61 | + | return []string{FeatureRepoUpdate, FeatureEventsResume, FeatureMergeRequests, FeatureMergeDeleteBranch, FeatureIssues, FeatureSigningKey, FeatureSession, FeatureTokenBranchPrefixes, FeatureCIRuns, FeatureCIRunners, FeatureCISecrets, FeatureCIDispatch, FeatureCIWorkflows, FeatureCIArtifacts, FeaturePasskeys, FeatureCommitSignatures} |
| 59 | } | 62 | } | ||
| 60 | 63 | ||||
| 61 | // MissingFeatures returns the features the CLI uses that have is lacking. | 64 | // MissingFeatures returns the features the CLI uses that have is lacking. | ||
M internal/app/cli_e2e_test.go +1 -1
| @@ -191,7 +191,7 @@ type eventOut struct { | |||
| 191 | 191 | MergeRequest *mrOut `json:"merge_request"` | |
| 192 | 192 | } | |
| 193 | 193 | ||
| 194 | - | var repoKeys = []string{"created_at", "default_branch", "description", "failing_checks_block", "merge_modes", "name", "pushed_at", "topics", "visibility"} | |
| 194 | + | var repoKeys = []string{"created_at", "default_branch", "description", "failing_checks_block", "issues", "merge_modes", "name", "pushed_at", "topics", "visibility"} | |
| 195 | 195 | ||
| 196 | 196 | // AC6: the CLI end to end against a real forge. | |
| 197 | 197 | func TestAC6CLIEndToEnd(t *testing.T) { | |
| @@ -191,7 +191,7 @@ type eventOut struct { | |||||
| 191 | MergeRequest *mrOut `json:"merge_request"` | 191 | MergeRequest *mrOut `json:"merge_request"` | ||
| 192 | } | 192 | } | ||
| 193 | 193 | ||||
| 194 | - | var repoKeys = []string{"created_at", "default_branch", "description", "failing_checks_block", "merge_modes", "name", "pushed_at", "topics", "visibility"} | 194 | + | var repoKeys = []string{"created_at", "default_branch", "description", "failing_checks_block", "issues", "merge_modes", "name", "pushed_at", "topics", "visibility"} |
| 195 | 195 | ||||
| 196 | // AC6: the CLI end to end against a real forge. | 196 | // AC6: the CLI end to end against a real forge. | ||
| 197 | func TestAC6CLIEndToEnd(t *testing.T) { | 197 | func TestAC6CLIEndToEnd(t *testing.T) { | ||
M internal/app/cli_fake_test.go +27 -0
| @@ -31,6 +31,7 @@ type fakeAPI struct { | |||
| 31 | 31 | ermiteconnect.UnimplementedServerServiceHandler | |
| 32 | 32 | ermiteconnect.UnimplementedEventServiceHandler | |
| 33 | 33 | ermiteconnect.UnimplementedMergeRequestServiceHandler | |
| 34 | + | ermiteconnect.UnimplementedIssueServiceHandler | |
| 34 | 35 | ermiteconnect.UnimplementedSessionServiceHandler | |
| 35 | 36 | ermiteconnect.UnimplementedRunServiceHandler | |
| 36 | 37 | ermiteconnect.UnimplementedTrustedKeyServiceHandler | |
| @@ -61,6 +62,12 @@ type fakeAPI struct { | |||
| 61 | 62 | closeMR func(*ermite.CloseMergeRequestRequest) (*ermite.CloseMergeRequestResponse, error) | |
| 62 | 63 | mergeMR func(*ermite.MergeMergeRequestRequest) (*ermite.MergeMergeRequestResponse, error) | |
| 63 | 64 | markRead func(*ermite.MarkReadRequest) (*ermite.MarkReadResponse, error) | |
| 65 | + | createIssue func(*ermite.CreateIssueRequest) (*ermite.CreateIssueResponse, error) | |
| 66 | + | listIssues func(*ermite.ListIssuesRequest) (*ermite.ListIssuesResponse, error) | |
| 67 | + | getIssue func(*ermite.GetIssueRequest) (*ermite.GetIssueResponse, error) | |
| 68 | + | addIssueCmt func(*ermite.AddIssueCommentRequest) (*ermite.AddIssueCommentResponse, error) | |
| 69 | + | closeIssue func(*ermite.CloseIssueRequest) (*ermite.CloseIssueResponse, error) | |
| 70 | + | reopenIssue func(*ermite.ReopenIssueRequest) (*ermite.ReopenIssueResponse, error) | |
| 64 | 71 | getSigningKey func(*ermite.GetSigningKeyRequest) (*ermite.GetSigningKeyResponse, error) | |
| 65 | 72 | ||
| 66 | 73 | listPasskeys func(*ermite.ListPasskeysRequest) (*ermite.ListPasskeysResponse, error) | |
| @@ -148,6 +155,24 @@ func (f *fakeAPI) MergeMergeRequest(_ context.Context, r *connect.Request[ermite | |||
| 148 | 155 | func (f *fakeAPI) MarkRead(_ context.Context, r *connect.Request[ermite.MarkReadRequest]) (*connect.Response[ermite.MarkReadResponse], error) { | |
| 149 | 156 | return respond(f, "MarkRead", r, f.markRead) | |
| 150 | 157 | } | |
| 158 | + | func (f *fakeAPI) CreateIssue(_ context.Context, r *connect.Request[ermite.CreateIssueRequest]) (*connect.Response[ermite.CreateIssueResponse], error) { | |
| 159 | + | return respond(f, "CreateIssue", r, f.createIssue) | |
| 160 | + | } | |
| 161 | + | func (f *fakeAPI) ListIssues(_ context.Context, r *connect.Request[ermite.ListIssuesRequest]) (*connect.Response[ermite.ListIssuesResponse], error) { | |
| 162 | + | return respond(f, "ListIssues", r, f.listIssues) | |
| 163 | + | } | |
| 164 | + | func (f *fakeAPI) GetIssue(_ context.Context, r *connect.Request[ermite.GetIssueRequest]) (*connect.Response[ermite.GetIssueResponse], error) { | |
| 165 | + | return respond(f, "GetIssue", r, f.getIssue) | |
| 166 | + | } | |
| 167 | + | func (f *fakeAPI) AddIssueComment(_ context.Context, r *connect.Request[ermite.AddIssueCommentRequest]) (*connect.Response[ermite.AddIssueCommentResponse], error) { | |
| 168 | + | return respond(f, "AddIssueComment", r, f.addIssueCmt) | |
| 169 | + | } | |
| 170 | + | func (f *fakeAPI) CloseIssue(_ context.Context, r *connect.Request[ermite.CloseIssueRequest]) (*connect.Response[ermite.CloseIssueResponse], error) { | |
| 171 | + | return respond(f, "CloseIssue", r, f.closeIssue) | |
| 172 | + | } | |
| 173 | + | func (f *fakeAPI) ReopenIssue(_ context.Context, r *connect.Request[ermite.ReopenIssueRequest]) (*connect.Response[ermite.ReopenIssueResponse], error) { | |
| 174 | + | return respond(f, "ReopenIssue", r, f.reopenIssue) | |
| 175 | + | } | |
| 151 | 176 | func (f *fakeAPI) GetSigningKey(_ context.Context, r *connect.Request[ermite.GetSigningKeyRequest]) (*connect.Response[ermite.GetSigningKeyResponse], error) { | |
| 152 | 177 | return respond(f, "GetSigningKey", r, f.getSigningKey) | |
| 153 | 178 | } | |
| @@ -189,6 +214,8 @@ func (f *fakeAPI) serve(t *testing.T) string { | |||
| 189 | 214 | mount(p, h) | |
| 190 | 215 | p, h = ermiteconnect.NewMergeRequestServiceHandler(f) | |
| 191 | 216 | mount(p, h) | |
| 217 | + | p, h = ermiteconnect.NewIssueServiceHandler(f) | |
| 218 | + | mount(p, h) | |
| 192 | 219 | p, h = ermiteconnect.NewSessionServiceHandler(f) | |
| 193 | 220 | mount(p, h) | |
| 194 | 221 | p, h = ermiteconnect.NewRunServiceHandler(f) | |
| @@ -31,6 +31,7 @@ type fakeAPI struct { | |||||
| 31 | ermiteconnect.UnimplementedServerServiceHandler | 31 | ermiteconnect.UnimplementedServerServiceHandler | ||
| 32 | ermiteconnect.UnimplementedEventServiceHandler | 32 | ermiteconnect.UnimplementedEventServiceHandler | ||
| 33 | ermiteconnect.UnimplementedMergeRequestServiceHandler | 33 | ermiteconnect.UnimplementedMergeRequestServiceHandler | ||
| 34 | + | ermiteconnect.UnimplementedIssueServiceHandler | |||
| 34 | ermiteconnect.UnimplementedSessionServiceHandler | 35 | ermiteconnect.UnimplementedSessionServiceHandler | ||
| 35 | ermiteconnect.UnimplementedRunServiceHandler | 36 | ermiteconnect.UnimplementedRunServiceHandler | ||
| 36 | ermiteconnect.UnimplementedTrustedKeyServiceHandler | 37 | ermiteconnect.UnimplementedTrustedKeyServiceHandler | ||
| @@ -61,6 +62,12 @@ type fakeAPI struct { | |||||
| 61 | closeMR func(*ermite.CloseMergeRequestRequest) (*ermite.CloseMergeRequestResponse, error) | 62 | closeMR func(*ermite.CloseMergeRequestRequest) (*ermite.CloseMergeRequestResponse, error) | ||
| 62 | mergeMR func(*ermite.MergeMergeRequestRequest) (*ermite.MergeMergeRequestResponse, error) | 63 | mergeMR func(*ermite.MergeMergeRequestRequest) (*ermite.MergeMergeRequestResponse, error) | ||
| 63 | markRead func(*ermite.MarkReadRequest) (*ermite.MarkReadResponse, error) | 64 | markRead func(*ermite.MarkReadRequest) (*ermite.MarkReadResponse, error) | ||
| 65 | + | createIssue func(*ermite.CreateIssueRequest) (*ermite.CreateIssueResponse, error) | |||
| 66 | + | listIssues func(*ermite.ListIssuesRequest) (*ermite.ListIssuesResponse, error) | |||
| 67 | + | getIssue func(*ermite.GetIssueRequest) (*ermite.GetIssueResponse, error) | |||
| 68 | + | addIssueCmt func(*ermite.AddIssueCommentRequest) (*ermite.AddIssueCommentResponse, error) | |||
| 69 | + | closeIssue func(*ermite.CloseIssueRequest) (*ermite.CloseIssueResponse, error) | |||
| 70 | + | reopenIssue func(*ermite.ReopenIssueRequest) (*ermite.ReopenIssueResponse, error) | |||
| 64 | getSigningKey func(*ermite.GetSigningKeyRequest) (*ermite.GetSigningKeyResponse, error) | 71 | getSigningKey func(*ermite.GetSigningKeyRequest) (*ermite.GetSigningKeyResponse, error) | ||
| 65 | 72 | ||||
| 66 | listPasskeys func(*ermite.ListPasskeysRequest) (*ermite.ListPasskeysResponse, error) | 73 | listPasskeys func(*ermite.ListPasskeysRequest) (*ermite.ListPasskeysResponse, error) | ||
| @@ -148,6 +155,24 @@ func (f *fakeAPI) MergeMergeRequest(_ context.Context, r *connect.Request[ermite | |||||
| 148 | func (f *fakeAPI) MarkRead(_ context.Context, r *connect.Request[ermite.MarkReadRequest]) (*connect.Response[ermite.MarkReadResponse], error) { | 155 | func (f *fakeAPI) MarkRead(_ context.Context, r *connect.Request[ermite.MarkReadRequest]) (*connect.Response[ermite.MarkReadResponse], error) { | ||
| 149 | return respond(f, "MarkRead", r, f.markRead) | 156 | return respond(f, "MarkRead", r, f.markRead) | ||
| 150 | } | 157 | } | ||
| 158 | + | func (f *fakeAPI) CreateIssue(_ context.Context, r *connect.Request[ermite.CreateIssueRequest]) (*connect.Response[ermite.CreateIssueResponse], error) { | |||
| 159 | + | return respond(f, "CreateIssue", r, f.createIssue) | |||
| 160 | + | } | |||
| 161 | + | func (f *fakeAPI) ListIssues(_ context.Context, r *connect.Request[ermite.ListIssuesRequest]) (*connect.Response[ermite.ListIssuesResponse], error) { | |||
| 162 | + | return respond(f, "ListIssues", r, f.listIssues) | |||
| 163 | + | } | |||
| 164 | + | func (f *fakeAPI) GetIssue(_ context.Context, r *connect.Request[ermite.GetIssueRequest]) (*connect.Response[ermite.GetIssueResponse], error) { | |||
| 165 | + | return respond(f, "GetIssue", r, f.getIssue) | |||
| 166 | + | } | |||
| 167 | + | func (f *fakeAPI) AddIssueComment(_ context.Context, r *connect.Request[ermite.AddIssueCommentRequest]) (*connect.Response[ermite.AddIssueCommentResponse], error) { | |||
| 168 | + | return respond(f, "AddIssueComment", r, f.addIssueCmt) | |||
| 169 | + | } | |||
| 170 | + | func (f *fakeAPI) CloseIssue(_ context.Context, r *connect.Request[ermite.CloseIssueRequest]) (*connect.Response[ermite.CloseIssueResponse], error) { | |||
| 171 | + | return respond(f, "CloseIssue", r, f.closeIssue) | |||
| 172 | + | } | |||
| 173 | + | func (f *fakeAPI) ReopenIssue(_ context.Context, r *connect.Request[ermite.ReopenIssueRequest]) (*connect.Response[ermite.ReopenIssueResponse], error) { | |||
| 174 | + | return respond(f, "ReopenIssue", r, f.reopenIssue) | |||
| 175 | + | } | |||
| 151 | func (f *fakeAPI) GetSigningKey(_ context.Context, r *connect.Request[ermite.GetSigningKeyRequest]) (*connect.Response[ermite.GetSigningKeyResponse], error) { | 176 | func (f *fakeAPI) GetSigningKey(_ context.Context, r *connect.Request[ermite.GetSigningKeyRequest]) (*connect.Response[ermite.GetSigningKeyResponse], error) { | ||
| 152 | return respond(f, "GetSigningKey", r, f.getSigningKey) | 177 | return respond(f, "GetSigningKey", r, f.getSigningKey) | ||
| 153 | } | 178 | } | ||
| @@ -189,6 +214,8 @@ func (f *fakeAPI) serve(t *testing.T) string { | |||||
| 189 | mount(p, h) | 214 | mount(p, h) | ||
| 190 | p, h = ermiteconnect.NewMergeRequestServiceHandler(f) | 215 | p, h = ermiteconnect.NewMergeRequestServiceHandler(f) | ||
| 191 | mount(p, h) | 216 | mount(p, h) | ||
| 217 | + | p, h = ermiteconnect.NewIssueServiceHandler(f) | |||
| 218 | + | mount(p, h) | |||
| 192 | p, h = ermiteconnect.NewSessionServiceHandler(f) | 219 | p, h = ermiteconnect.NewSessionServiceHandler(f) | ||
| 193 | mount(p, h) | 220 | mount(p, h) | ||
| 194 | p, h = ermiteconnect.NewRunServiceHandler(f) | 221 | p, h = ermiteconnect.NewRunServiceHandler(f) | ||
A internal/app/cli_issue.go +446 -0
| @@ -0,0 +1,446 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package app | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "errors" | |
| 8 | + | "fmt" | |
| 9 | + | "io" | |
| 10 | + | "os" | |
| 11 | + | "slices" | |
| 12 | + | "strconv" | |
| 13 | + | "strings" | |
| 14 | + | ||
| 15 | + | "connectrpc.com/connect" | |
| 16 | + | "github.com/spf13/cobra" | |
| 17 | + | ||
| 18 | + | "bnema.dev/ermite-api/gen/ermite" | |
| 19 | + | "bnema.dev/ermite/internal/adapters/client" | |
| 20 | + | ) | |
| 21 | + | ||
| 22 | + | const statePending = "pending" | |
| 23 | + | ||
| 24 | + | func issueStateName(s ermite.IssueState) string { | |
| 25 | + | switch s { | |
| 26 | + | case ermite.IssueState_ISSUE_STATE_OPEN: | |
| 27 | + | return stateOpen | |
| 28 | + | case ermite.IssueState_ISSUE_STATE_CLOSED: | |
| 29 | + | return stateClosed | |
| 30 | + | case ermite.IssueState_ISSUE_STATE_PENDING: | |
| 31 | + | return statePending | |
| 32 | + | case ermite.IssueState_ISSUE_STATE_UNSPECIFIED: | |
| 33 | + | } | |
| 34 | + | return "unknown" | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | func parseIssueState(s string) (ermite.IssueState, error) { | |
| 38 | + | switch strings.ToLower(strings.TrimSpace(s)) { | |
| 39 | + | case stateOpen: | |
| 40 | + | return ermite.IssueState_ISSUE_STATE_OPEN, nil | |
| 41 | + | case stateClosed: | |
| 42 | + | return ermite.IssueState_ISSUE_STATE_CLOSED, nil | |
| 43 | + | case stateAll: | |
| 44 | + | return ermite.IssueState_ISSUE_STATE_UNSPECIFIED, nil | |
| 45 | + | } | |
| 46 | + | return 0, fmt.Errorf("invalid state %q: use %s, %s or %s", s, stateOpen, stateClosed, stateAll) | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | // jsonIssue is the stable JSON shape of an issue. | |
| 50 | + | type jsonIssue struct { | |
| 51 | + | Repo string `json:"repo"` | |
| 52 | + | Number int64 `json:"number"` | |
| 53 | + | Title string `json:"title"` | |
| 54 | + | Body string `json:"body"` | |
| 55 | + | State string `json:"state"` | |
| 56 | + | Author string `json:"author"` | |
| 57 | + | CreatedAt *string `json:"created_at"` | |
| 58 | + | UpdatedAt *string `json:"updated_at"` | |
| 59 | + | CommentCount int32 `json:"comment_count"` | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | func toJSONIssue(i *ermite.Issue) jsonIssue { | |
| 63 | + | return jsonIssue{ | |
| 64 | + | Repo: i.GetRepo(), Number: i.GetNumber(), Title: i.GetTitle(), Body: i.GetBody(), | |
| 65 | + | State: issueStateName(i.GetState()), Author: i.GetAuthor(), | |
| 66 | + | CreatedAt: jsonTime(i.GetCreatedAt()), UpdatedAt: jsonTime(i.GetUpdatedAt()), CommentCount: i.GetCommentCount(), | |
| 67 | + | } | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | // jsonIssueComment is the stable JSON shape of an issue comment. | |
| 71 | + | type jsonIssueComment struct { | |
| 72 | + | ID string `json:"id"` | |
| 73 | + | Author string `json:"author"` | |
| 74 | + | Text string `json:"text"` | |
| 75 | + | CreatedAt *string `json:"created_at"` | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | func toJSONIssueComment(c *ermite.IssueComment) jsonIssueComment { | |
| 79 | + | return jsonIssueComment{ID: c.GetId(), Author: c.GetAuthor(), Text: c.GetText(), CreatedAt: jsonTime(c.GetCreatedAt())} | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | // jsonIssueDetail is the stable JSON shape of `issue show --json`. | |
| 83 | + | type jsonIssueDetail struct { | |
| 84 | + | jsonIssue | |
| 85 | + | Comments []jsonIssueComment `json:"comments"` | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | const jsonIssueHelp = ` | |
| 89 | + | JSON fields of an issue (--json): repo, number, title, body, state ("open" or "closed"), author | |
| 90 | + | (token label), created_at and updated_at (RFC 3339 UTC), comment_count.` | |
| 91 | + | ||
| 92 | + | const jsonIssueCommentHelp = ` | |
| 93 | + | JSON fields of an issue comment: id, author, text, created_at.` | |
| 94 | + | ||
| 95 | + | func newIssueCmd(env *cliEnv) *cobra.Command { | |
| 96 | + | issue := &cobra.Command{ | |
| 97 | + | Use: "issue", | |
| 98 | + | Short: "Track issues on a remote server", | |
| 99 | + | Long: "Issues record a task, a bug or a question about a repository. They share the numbering of merge\n" + | |
| 100 | + | "requests: the next issue after merge request 1 is number 2. Issues must be enabled for the\n" + | |
| 101 | + | "repository (`ermite repo edit <repo> --issues owner`). Needs a server with the issues feature (see\n" + | |
| 102 | + | "`ermite version`). An issue is named by its repository and its number: `ermite issue show proj 3`.", | |
| 103 | + | } | |
| 104 | + | issue.AddCommand( | |
| 105 | + | newIssueCreateCmd(env), newIssueListCmd(env), newIssueShowCmd(env), newIssueCommentCmd(env), | |
| 106 | + | newIssueCloseCmd(env), newIssueReopenCmd(env), | |
| 107 | + | ) | |
| 108 | + | return issue | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | // requireIssues refuses to go on with a server that does not list issues: it | |
| 112 | + | // has no IssueService. A server whose features cannot be read is refused too. | |
| 113 | + | func requireIssues(ctx context.Context, c *client.Client) error { | |
| 114 | + | res, err := c.Server().GetVersion(ctx, connect.NewRequest(&ermite.GetVersionRequest{})) | |
| 115 | + | if err != nil { | |
| 116 | + | return c.Describe(err) | |
| 117 | + | } | |
| 118 | + | if !slices.Contains(res.Msg.GetFeatures(), client.FeatureIssues) { | |
| 119 | + | return fmt.Errorf("the server does not support %s, so it has no issues (upgrade the server, see `ermite version`)", client.FeatureIssues) | |
| 120 | + | } | |
| 121 | + | return nil | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | // issueClient returns a client for a server that supports issues. | |
| 125 | + | func (e *cliEnv) issueClient(cmd *cobra.Command) (*client.Client, error) { | |
| 126 | + | c, err := e.client(cmd) | |
| 127 | + | if err != nil { | |
| 128 | + | return nil, err | |
| 129 | + | } | |
| 130 | + | if err := requireIssues(e.ctx(cmd), c); err != nil { | |
| 131 | + | return nil, err | |
| 132 | + | } | |
| 133 | + | return c, nil | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | // parseIssueNumber reads the number of an issue, written 3 or #3. | |
| 137 | + | func parseIssueNumber(s string) (int64, error) { | |
| 138 | + | n, err := strconv.ParseInt(strings.TrimPrefix(strings.TrimSpace(s), "#"), 10, 64) | |
| 139 | + | if err != nil || n < 1 { | |
| 140 | + | return 0, fmt.Errorf("invalid issue number %q: use a positive number such as 3 or #3", s) | |
| 141 | + | } | |
| 142 | + | return n, nil | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | func completeIssueRepoThenNumber(env *cliEnv) func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { | |
| 146 | + | return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { | |
| 147 | + | switch len(args) { | |
| 148 | + | case 0: | |
| 149 | + | return repoNames(env, cmd, toComplete), cobra.ShellCompDirectiveNoFileComp | |
| 150 | + | case 1: | |
| 151 | + | c, err := env.completionClient(cmd) | |
| 152 | + | if err != nil { | |
| 153 | + | return nil, cobra.ShellCompDirectiveNoFileComp | |
| 154 | + | } | |
| 155 | + | issues, err := listIssues(env.ctx(cmd), c, args[0], ermite.IssueState_ISSUE_STATE_UNSPECIFIED) | |
| 156 | + | if err != nil { | |
| 157 | + | return nil, cobra.ShellCompDirectiveNoFileComp | |
| 158 | + | } | |
| 159 | + | var out []string | |
| 160 | + | for _, i := range issues { | |
| 161 | + | if n := strconv.FormatInt(i.GetNumber(), 10); strings.HasPrefix(n, toComplete) { | |
| 162 | + | out = append(out, n+"\t"+issueStateName(i.GetState())+", "+clean(i.GetTitle())) | |
| 163 | + | } | |
| 164 | + | } | |
| 165 | + | return out, cobra.ShellCompDirectiveNoFileComp | |
| 166 | + | } | |
| 167 | + | return nil, cobra.ShellCompDirectiveNoFileComp | |
| 168 | + | } | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | func listIssues(ctx context.Context, c *client.Client, repo string, state ermite.IssueState) ([]*ermite.Issue, error) { | |
| 172 | + | return collect(ctx, func(token string) ([]*ermite.Issue, string, error) { | |
| 173 | + | res, err := c.Issues().ListIssues(ctx, connect.NewRequest(&ermite.ListIssuesRequest{ | |
| 174 | + | Repo: repo, State: state, PageToken: token, | |
| 175 | + | })) | |
| 176 | + | if err != nil { | |
| 177 | + | return nil, "", c.Describe(err) | |
| 178 | + | } | |
| 179 | + | return res.Msg.GetIssues(), res.Msg.GetNextPageToken(), nil | |
| 180 | + | }) | |
| 181 | + | } | |
| 182 | + | ||
| 183 | + | // outputIssue prints an issue as JSON, or as one confirmation line. | |
| 184 | + | func outputIssue(cmd *cobra.Command, i *ermite.Issue, asJSON bool, verb string) error { | |
| 185 | + | if asJSON { | |
| 186 | + | return writeJSON(cmd.OutOrStdout(), toJSONIssue(i)) | |
| 187 | + | } | |
| 188 | + | _, err := fmt.Fprintf(cmd.OutOrStdout(), "%s %s#%d (%s)\n", verb, clean(i.GetRepo()), i.GetNumber(), clean(i.GetTitle())) | |
| 189 | + | return err | |
| 190 | + | } | |
| 191 | + | ||
| 192 | + | func newIssueCreateCmd(env *cliEnv) *cobra.Command { | |
| 193 | + | var title, body, bodyFile string | |
| 194 | + | cmd := &cobra.Command{ | |
| 195 | + | Use: "create <repo>", | |
| 196 | + | Short: "Open an issue", | |
| 197 | + | Long: "Open an issue. The title is one line of at most 120 characters, the body plain text of at most\n" + | |
| 198 | + | "8000; neither may hold control characters (a body may hold line breaks) or bidirectional override\n" + | |
| 199 | + | "characters. --body - and --body-file - read the body from standard input; --body-file reads it\n" + | |
| 200 | + | "from a file otherwise. Needs the issue:write scope." + jsonIssueHelp, | |
| 201 | + | Args: cobra.ExactArgs(1), | |
| 202 | + | ValidArgsFunction: completeRepoNames(env), | |
| 203 | + | } | |
| 204 | + | cmd.Flags().StringVar(&title, "title", "", "title of the issue (required)") | |
| 205 | + | cmd.Flags().StringVar(&body, "body", "", "body, or - to read standard input") | |
| 206 | + | cmd.Flags().StringVar(&bodyFile, "body-file", "", "read the body from this file, or - for standard input") | |
| 207 | + | cmd.MarkFlagsMutuallyExclusive("body", "body-file") | |
| 208 | + | _ = cmd.MarkFlagRequired("title") | |
| 209 | + | _ = cmd.MarkFlagFilename("body-file") | |
| 210 | + | asJSON := addJSONFlag(cmd) | |
| 211 | + | cmd.RunE = func(cmd *cobra.Command, args []string) error { | |
| 212 | + | text := body | |
| 213 | + | if cmd.Flags().Changed("body-file") { | |
| 214 | + | if bodyFile == "-" { | |
| 215 | + | text = "-" | |
| 216 | + | } else { | |
| 217 | + | b, err := os.ReadFile(bodyFile) //nolint:gosec // a path typed by the user | |
| 218 | + | if err != nil { | |
| 219 | + | return fmt.Errorf("read --body-file: %w", err) | |
| 220 | + | } | |
| 221 | + | text = strings.TrimRight(string(b), "\r\n") | |
| 222 | + | } | |
| 223 | + | } | |
| 224 | + | text, err := readText(cmd, text) | |
| 225 | + | if err != nil { | |
| 226 | + | return err | |
| 227 | + | } | |
| 228 | + | c, err := env.issueClient(cmd) | |
| 229 | + | if err != nil { | |
| 230 | + | return err | |
| 231 | + | } | |
| 232 | + | res, err := c.Issues().CreateIssue(env.ctx(cmd), connect.NewRequest(&ermite.CreateIssueRequest{ | |
| 233 | + | Repo: args[0], Title: title, Body: text, | |
| 234 | + | })) | |
| 235 | + | if err != nil { | |
| 236 | + | return c.Describe(err) | |
| 237 | + | } | |
| 238 | + | return outputIssue(cmd, res.Msg.GetIssue(), *asJSON, "opened") | |
| 239 | + | } | |
| 240 | + | return cmd | |
| 241 | + | } | |
| 242 | + | ||
| 243 | + | func newIssueListCmd(env *cliEnv) *cobra.Command { | |
| 244 | + | var state string | |
| 245 | + | cmd := &cobra.Command{ | |
| 246 | + | Use: "list <repo>", | |
| 247 | + | Short: "List the issues of a repository", | |
| 248 | + | Long: "List the issues of a repository, newest first. --state restricts them to open or closed ones\n" + | |
| 249 | + | "(default: open; all shows every state)." + jsonIssueHelp + "\n--json prints an array of issues.", | |
| 250 | + | Args: cobra.ExactArgs(1), | |
| 251 | + | ValidArgsFunction: completeRepoNames(env), | |
| 252 | + | } | |
| 253 | + | cmd.Flags().StringVar(&state, "state", stateOpen, "state: open, closed or all") | |
| 254 | + | _ = cmd.RegisterFlagCompletionFunc("state", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { | |
| 255 | + | return []string{stateOpen, stateClosed, stateAll}, cobra.ShellCompDirectiveNoFileComp | |
| 256 | + | }) | |
| 257 | + | asJSON := addJSONFlag(cmd) | |
| 258 | + | cmd.RunE = func(cmd *cobra.Command, args []string) error { | |
| 259 | + | st, err := parseIssueState(state) | |
| 260 | + | if err != nil { | |
| 261 | + | return err | |
| 262 | + | } | |
| 263 | + | c, err := env.issueClient(cmd) | |
| 264 | + | if err != nil { | |
| 265 | + | return err | |
| 266 | + | } | |
| 267 | + | issues, err := listIssues(env.ctx(cmd), c, args[0], st) | |
| 268 | + | if err != nil { | |
| 269 | + | return err | |
| 270 | + | } | |
| 271 | + | if *asJSON { | |
| 272 | + | out := make([]jsonIssue, 0, len(issues)) | |
| 273 | + | for _, i := range issues { | |
| 274 | + | out = append(out, toJSONIssue(i)) | |
| 275 | + | } | |
| 276 | + | return writeJSON(cmd.OutOrStdout(), out) | |
| 277 | + | } | |
| 278 | + | if len(issues) == 0 { | |
| 279 | + | noticef(cmd, "no issues") | |
| 280 | + | return nil | |
| 281 | + | } | |
| 282 | + | rows := make([][]string, 0, len(issues)) | |
| 283 | + | for _, i := range issues { | |
| 284 | + | rows = append(rows, []string{ | |
| 285 | + | "#" + strconv.FormatInt(i.GetNumber(), 10), issueStateName(i.GetState()), i.GetAuthor(), | |
| 286 | + | strconv.Itoa(int(i.GetCommentCount())), shortTime(i.GetUpdatedAt(), "-"), i.GetTitle(), | |
| 287 | + | }) | |
| 288 | + | } | |
| 289 | + | return table(cmd.OutOrStdout(), []string{"NUMBER", "STATE", "AUTHOR", "COMMENTS", "UPDATED", "TITLE"}, rows) | |
| 290 | + | } | |
| 291 | + | return cmd | |
| 292 | + | } | |
| 293 | + | ||
| 294 | + | func newIssueShowCmd(env *cliEnv) *cobra.Command { | |
| 295 | + | cmd := &cobra.Command{ | |
| 296 | + | Use: "show <repo> <number>", | |
| 297 | + | Short: "Show an issue with its comments", | |
| 298 | + | Long: "Show an issue: its fields, its body and its comments, oldest first." + jsonIssueHelp + jsonIssueCommentHelp + | |
| 299 | + | "\n--json adds comments to the issue fields.", | |
| 300 | + | Args: cobra.ExactArgs(2), | |
| 301 | + | ValidArgsFunction: completeIssueRepoThenNumber(env), | |
| 302 | + | } | |
| 303 | + | asJSON := addJSONFlag(cmd) | |
| 304 | + | cmd.RunE = func(cmd *cobra.Command, args []string) error { | |
| 305 | + | n, err := parseIssueNumber(args[1]) | |
| 306 | + | if err != nil { | |
| 307 | + | return err | |
| 308 | + | } | |
| 309 | + | c, err := env.issueClient(cmd) | |
| 310 | + | if err != nil { | |
| 311 | + | return err | |
| 312 | + | } | |
| 313 | + | res, err := c.Issues().GetIssue(env.ctx(cmd), connect.NewRequest(&ermite.GetIssueRequest{Repo: args[0], Number: n})) | |
| 314 | + | if err != nil { | |
| 315 | + | return c.Describe(err) | |
| 316 | + | } | |
| 317 | + | if *asJSON { | |
| 318 | + | out := jsonIssueDetail{jsonIssue: toJSONIssue(res.Msg.GetIssue()), Comments: []jsonIssueComment{}} | |
| 319 | + | for _, cm := range res.Msg.GetComments() { | |
| 320 | + | out.Comments = append(out.Comments, toJSONIssueComment(cm)) | |
| 321 | + | } | |
| 322 | + | return writeJSON(cmd.OutOrStdout(), out) | |
| 323 | + | } | |
| 324 | + | return printIssueDetail(cmd.OutOrStdout(), res.Msg) | |
| 325 | + | } | |
| 326 | + | return cmd | |
| 327 | + | } | |
| 328 | + | ||
| 329 | + | func printIssueDetail(w io.Writer, d *ermite.GetIssueResponse) error { | |
| 330 | + | i := d.GetIssue() | |
| 331 | + | body := i.GetBody() | |
| 332 | + | if body == "" { | |
| 333 | + | body = "-" | |
| 334 | + | } | |
| 335 | + | err := fields(w, [][2]string{ | |
| 336 | + | {"issue", fmt.Sprintf("%s#%d", i.GetRepo(), i.GetNumber())}, | |
| 337 | + | {"title", i.GetTitle()}, | |
| 338 | + | {"state", issueStateName(i.GetState())}, | |
| 339 | + | {"author", i.GetAuthor()}, | |
| 340 | + | {"created", shortTime(i.GetCreatedAt(), "-")}, | |
| 341 | + | {"updated", shortTime(i.GetUpdatedAt(), "-")}, | |
| 342 | + | }) | |
| 343 | + | if err != nil { | |
| 344 | + | return err | |
| 345 | + | } | |
| 346 | + | _, _ = fmt.Fprintf(w, "\n%s\n", indentText(body)) | |
| 347 | + | _, _ = fmt.Fprintf(w, "\ncomments (%d)\n", len(d.GetComments())) | |
| 348 | + | for _, c := range d.GetComments() { | |
| 349 | + | _, _ = fmt.Fprintf(w, " #%s %s, %s\n%s\n", c.GetId(), clean(c.GetAuthor()), shortTime(c.GetCreatedAt(), "-"), indentText(c.GetText())) | |
| 350 | + | } | |
| 351 | + | return nil | |
| 352 | + | } | |
| 353 | + | ||
| 354 | + | func newIssueCommentCmd(env *cliEnv) *cobra.Command { | |
| 355 | + | var message string | |
| 356 | + | cmd := &cobra.Command{ | |
| 357 | + | Use: "comment <repo> <number>", | |
| 358 | + | Short: "Comment on an issue", | |
| 359 | + | Long: "Add a plain-text comment (at most 8000 characters, no control characters except line breaks).\n" + | |
| 360 | + | "-m - reads it from standard input. Needs the issue:write scope." + jsonIssueCommentHelp, | |
| 361 | + | Args: cobra.ExactArgs(2), | |
| 362 | + | ValidArgsFunction: completeIssueRepoThenNumber(env), | |
| 363 | + | } | |
| 364 | + | cmd.Flags().StringVarP(&message, "message", "m", "", "text of the comment, or - to read standard input (required)") | |
| 365 | + | cmd.Flags().StringVar(&message, "text", "", "alias of --message") | |
| 366 | + | _ = cmd.Flags().MarkHidden("text") | |
| 367 | + | asJSON := addJSONFlag(cmd) | |
| 368 | + | cmd.RunE = func(cmd *cobra.Command, args []string) error { | |
| 369 | + | n, err := parseIssueNumber(args[1]) | |
| 370 | + | if err != nil { | |
| 371 | + | return err | |
| 372 | + | } | |
| 373 | + | if !cmd.Flags().Changed("message") && !cmd.Flags().Changed("text") { | |
| 374 | + | return errors.New("the comment is required: give it with -m, or -m - for standard input") | |
| 375 | + | } | |
| 376 | + | text, err := readText(cmd, message) | |
| 377 | + | if err != nil { | |
| 378 | + | return err | |
| 379 | + | } | |
| 380 | + | c, err := env.issueClient(cmd) | |
| 381 | + | if err != nil { | |
| 382 | + | return err | |
| 383 | + | } | |
| 384 | + | res, err := c.Issues().AddIssueComment(env.ctx(cmd), connect.NewRequest(&ermite.AddIssueCommentRequest{ | |
| 385 | + | Repo: args[0], Number: n, Text: text, | |
| 386 | + | })) | |
| 387 | + | if err != nil { | |
| 388 | + | return c.Describe(err) | |
| 389 | + | } | |
| 390 | + | if *asJSON { | |
| 391 | + | return writeJSON(cmd.OutOrStdout(), toJSONIssueComment(res.Msg.GetComment())) | |
| 392 | + | } | |
| 393 | + | _, err = fmt.Fprintf(cmd.OutOrStdout(), "comment %s added to %s#%d\n", res.Msg.GetComment().GetId(), clean(args[0]), n) | |
| 394 | + | return err | |
| 395 | + | } | |
| 396 | + | return cmd | |
| 397 | + | } | |
| 398 | + | ||
| 399 | + | func newIssueCloseCmd(env *cliEnv) *cobra.Command { | |
| 400 | + | return newIssueStateCmd(env, "close", "closed", "Close an issue", "Close an open issue. `ermite issue reopen` opens it again. Needs the issue:write scope.", | |
| 401 | + | func(ctx context.Context, c *client.Client, repo string, n int64) (*ermite.Issue, error) { | |
| 402 | + | res, err := c.Issues().CloseIssue(ctx, connect.NewRequest(&ermite.CloseIssueRequest{Repo: repo, Number: n})) | |
| 403 | + | if err != nil { | |
| 404 | + | return nil, err | |
| 405 | + | } | |
| 406 | + | return res.Msg.GetIssue(), nil | |
| 407 | + | }) | |
| 408 | + | } | |
| 409 | + | ||
| 410 | + | func newIssueReopenCmd(env *cliEnv) *cobra.Command { | |
| 411 | + | return newIssueStateCmd(env, "reopen", "reopened", "Reopen a closed issue", "Reopen a closed issue. Needs the issue:write scope.", | |
| 412 | + | func(ctx context.Context, c *client.Client, repo string, n int64) (*ermite.Issue, error) { | |
| 413 | + | res, err := c.Issues().ReopenIssue(ctx, connect.NewRequest(&ermite.ReopenIssueRequest{Repo: repo, Number: n})) | |
| 414 | + | if err != nil { | |
| 415 | + | return nil, err | |
| 416 | + | } | |
| 417 | + | return res.Msg.GetIssue(), nil | |
| 418 | + | }) | |
| 419 | + | } | |
| 420 | + | ||
| 421 | + | func newIssueStateCmd(env *cliEnv, use, verb, short, long string, do func(context.Context, *client.Client, string, int64) (*ermite.Issue, error)) *cobra.Command { | |
| 422 | + | cmd := &cobra.Command{ | |
| 423 | + | Use: use + " <repo> <number>", | |
| 424 | + | Short: short, | |
| 425 | + | Long: long + jsonIssueHelp, | |
| 426 | + | Args: cobra.ExactArgs(2), | |
| 427 | + | ValidArgsFunction: completeIssueRepoThenNumber(env), | |
| 428 | + | } | |
| 429 | + | asJSON := addJSONFlag(cmd) | |
| 430 | + | cmd.RunE = func(cmd *cobra.Command, args []string) error { | |
| 431 | + | n, err := parseIssueNumber(args[1]) | |
| 432 | + | if err != nil { | |
| 433 | + | return err | |
| 434 | + | } | |
| 435 | + | c, err := env.issueClient(cmd) | |
| 436 | + | if err != nil { | |
| 437 | + | return err | |
| 438 | + | } | |
| 439 | + | i, err := do(env.ctx(cmd), c, args[0], n) | |
| 440 | + | if err != nil { | |
| 441 | + | return c.Describe(err) | |
| 442 | + | } | |
| 443 | + | return outputIssue(cmd, i, *asJSON, verb) | |
| 444 | + | } | |
| 445 | + | return cmd | |
| 446 | + | } | |
| @@ -0,0 +1,446 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package app | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "errors" | |||
| 8 | + | "fmt" | |||
| 9 | + | "io" | |||
| 10 | + | "os" | |||
| 11 | + | "slices" | |||
| 12 | + | "strconv" | |||
| 13 | + | "strings" | |||
| 14 | + | ||||
| 15 | + | "connectrpc.com/connect" | |||
| 16 | + | "github.com/spf13/cobra" | |||
| 17 | + | ||||
| 18 | + | "bnema.dev/ermite-api/gen/ermite" | |||
| 19 | + | "bnema.dev/ermite/internal/adapters/client" | |||
| 20 | + | ) | |||
| 21 | + | ||||
| 22 | + | const statePending = "pending" | |||
| 23 | + | ||||
| 24 | + | func issueStateName(s ermite.IssueState) string { | |||
| 25 | + | switch s { | |||
| 26 | + | case ermite.IssueState_ISSUE_STATE_OPEN: | |||
| 27 | + | return stateOpen | |||
| 28 | + | case ermite.IssueState_ISSUE_STATE_CLOSED: | |||
| 29 | + | return stateClosed | |||
| 30 | + | case ermite.IssueState_ISSUE_STATE_PENDING: | |||
| 31 | + | return statePending | |||
| 32 | + | case ermite.IssueState_ISSUE_STATE_UNSPECIFIED: | |||
| 33 | + | } | |||
| 34 | + | return "unknown" | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | func parseIssueState(s string) (ermite.IssueState, error) { | |||
| 38 | + | switch strings.ToLower(strings.TrimSpace(s)) { | |||
| 39 | + | case stateOpen: | |||
| 40 | + | return ermite.IssueState_ISSUE_STATE_OPEN, nil | |||
| 41 | + | case stateClosed: | |||
| 42 | + | return ermite.IssueState_ISSUE_STATE_CLOSED, nil | |||
| 43 | + | case stateAll: | |||
| 44 | + | return ermite.IssueState_ISSUE_STATE_UNSPECIFIED, nil | |||
| 45 | + | } | |||
| 46 | + | return 0, fmt.Errorf("invalid state %q: use %s, %s or %s", s, stateOpen, stateClosed, stateAll) | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | // jsonIssue is the stable JSON shape of an issue. | |||
| 50 | + | type jsonIssue struct { | |||
| 51 | + | Repo string `json:"repo"` | |||
| 52 | + | Number int64 `json:"number"` | |||
| 53 | + | Title string `json:"title"` | |||
| 54 | + | Body string `json:"body"` | |||
| 55 | + | State string `json:"state"` | |||
| 56 | + | Author string `json:"author"` | |||
| 57 | + | CreatedAt *string `json:"created_at"` | |||
| 58 | + | UpdatedAt *string `json:"updated_at"` | |||
| 59 | + | CommentCount int32 `json:"comment_count"` | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | func toJSONIssue(i *ermite.Issue) jsonIssue { | |||
| 63 | + | return jsonIssue{ | |||
| 64 | + | Repo: i.GetRepo(), Number: i.GetNumber(), Title: i.GetTitle(), Body: i.GetBody(), | |||
| 65 | + | State: issueStateName(i.GetState()), Author: i.GetAuthor(), | |||
| 66 | + | CreatedAt: jsonTime(i.GetCreatedAt()), UpdatedAt: jsonTime(i.GetUpdatedAt()), CommentCount: i.GetCommentCount(), | |||
| 67 | + | } | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | // jsonIssueComment is the stable JSON shape of an issue comment. | |||
| 71 | + | type jsonIssueComment struct { | |||
| 72 | + | ID string `json:"id"` | |||
| 73 | + | Author string `json:"author"` | |||
| 74 | + | Text string `json:"text"` | |||
| 75 | + | CreatedAt *string `json:"created_at"` | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | func toJSONIssueComment(c *ermite.IssueComment) jsonIssueComment { | |||
| 79 | + | return jsonIssueComment{ID: c.GetId(), Author: c.GetAuthor(), Text: c.GetText(), CreatedAt: jsonTime(c.GetCreatedAt())} | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | // jsonIssueDetail is the stable JSON shape of `issue show --json`. | |||
| 83 | + | type jsonIssueDetail struct { | |||
| 84 | + | jsonIssue | |||
| 85 | + | Comments []jsonIssueComment `json:"comments"` | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | const jsonIssueHelp = ` | |||
| 89 | + | JSON fields of an issue (--json): repo, number, title, body, state ("open" or "closed"), author | |||
| 90 | + | (token label), created_at and updated_at (RFC 3339 UTC), comment_count.` | |||
| 91 | + | ||||
| 92 | + | const jsonIssueCommentHelp = ` | |||
| 93 | + | JSON fields of an issue comment: id, author, text, created_at.` | |||
| 94 | + | ||||
| 95 | + | func newIssueCmd(env *cliEnv) *cobra.Command { | |||
| 96 | + | issue := &cobra.Command{ | |||
| 97 | + | Use: "issue", | |||
| 98 | + | Short: "Track issues on a remote server", | |||
| 99 | + | Long: "Issues record a task, a bug or a question about a repository. They share the numbering of merge\n" + | |||
| 100 | + | "requests: the next issue after merge request 1 is number 2. Issues must be enabled for the\n" + | |||
| 101 | + | "repository (`ermite repo edit <repo> --issues owner`). Needs a server with the issues feature (see\n" + | |||
| 102 | + | "`ermite version`). An issue is named by its repository and its number: `ermite issue show proj 3`.", | |||
| 103 | + | } | |||
| 104 | + | issue.AddCommand( | |||
| 105 | + | newIssueCreateCmd(env), newIssueListCmd(env), newIssueShowCmd(env), newIssueCommentCmd(env), | |||
| 106 | + | newIssueCloseCmd(env), newIssueReopenCmd(env), | |||
| 107 | + | ) | |||
| 108 | + | return issue | |||
| 109 | + | } | |||
| 110 | + | ||||
| 111 | + | // requireIssues refuses to go on with a server that does not list issues: it | |||
| 112 | + | // has no IssueService. A server whose features cannot be read is refused too. | |||
| 113 | + | func requireIssues(ctx context.Context, c *client.Client) error { | |||
| 114 | + | res, err := c.Server().GetVersion(ctx, connect.NewRequest(&ermite.GetVersionRequest{})) | |||
| 115 | + | if err != nil { | |||
| 116 | + | return c.Describe(err) | |||
| 117 | + | } | |||
| 118 | + | if !slices.Contains(res.Msg.GetFeatures(), client.FeatureIssues) { | |||
| 119 | + | return fmt.Errorf("the server does not support %s, so it has no issues (upgrade the server, see `ermite version`)", client.FeatureIssues) | |||
| 120 | + | } | |||
| 121 | + | return nil | |||
| 122 | + | } | |||
| 123 | + | ||||
| 124 | + | // issueClient returns a client for a server that supports issues. | |||
| 125 | + | func (e *cliEnv) issueClient(cmd *cobra.Command) (*client.Client, error) { | |||
| 126 | + | c, err := e.client(cmd) | |||
| 127 | + | if err != nil { | |||
| 128 | + | return nil, err | |||
| 129 | + | } | |||
| 130 | + | if err := requireIssues(e.ctx(cmd), c); err != nil { | |||
| 131 | + | return nil, err | |||
| 132 | + | } | |||
| 133 | + | return c, nil | |||
| 134 | + | } | |||
| 135 | + | ||||
| 136 | + | // parseIssueNumber reads the number of an issue, written 3 or #3. | |||
| 137 | + | func parseIssueNumber(s string) (int64, error) { | |||
| 138 | + | n, err := strconv.ParseInt(strings.TrimPrefix(strings.TrimSpace(s), "#"), 10, 64) | |||
| 139 | + | if err != nil || n < 1 { | |||
| 140 | + | return 0, fmt.Errorf("invalid issue number %q: use a positive number such as 3 or #3", s) | |||
| 141 | + | } | |||
| 142 | + | return n, nil | |||
| 143 | + | } | |||
| 144 | + | ||||
| 145 | + | func completeIssueRepoThenNumber(env *cliEnv) func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { | |||
| 146 | + | return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { | |||
| 147 | + | switch len(args) { | |||
| 148 | + | case 0: | |||
| 149 | + | return repoNames(env, cmd, toComplete), cobra.ShellCompDirectiveNoFileComp | |||
| 150 | + | case 1: | |||
| 151 | + | c, err := env.completionClient(cmd) | |||
| 152 | + | if err != nil { | |||
| 153 | + | return nil, cobra.ShellCompDirectiveNoFileComp | |||
| 154 | + | } | |||
| 155 | + | issues, err := listIssues(env.ctx(cmd), c, args[0], ermite.IssueState_ISSUE_STATE_UNSPECIFIED) | |||
| 156 | + | if err != nil { | |||
| 157 | + | return nil, cobra.ShellCompDirectiveNoFileComp | |||
| 158 | + | } | |||
| 159 | + | var out []string | |||
| 160 | + | for _, i := range issues { | |||
| 161 | + | if n := strconv.FormatInt(i.GetNumber(), 10); strings.HasPrefix(n, toComplete) { | |||
| 162 | + | out = append(out, n+"\t"+issueStateName(i.GetState())+", "+clean(i.GetTitle())) | |||
| 163 | + | } | |||
| 164 | + | } | |||
| 165 | + | return out, cobra.ShellCompDirectiveNoFileComp | |||
| 166 | + | } | |||
| 167 | + | return nil, cobra.ShellCompDirectiveNoFileComp | |||
| 168 | + | } | |||
| 169 | + | } | |||
| 170 | + | ||||
| 171 | + | func listIssues(ctx context.Context, c *client.Client, repo string, state ermite.IssueState) ([]*ermite.Issue, error) { | |||
| 172 | + | return collect(ctx, func(token string) ([]*ermite.Issue, string, error) { | |||
| 173 | + | res, err := c.Issues().ListIssues(ctx, connect.NewRequest(&ermite.ListIssuesRequest{ | |||
| 174 | + | Repo: repo, State: state, PageToken: token, | |||
| 175 | + | })) | |||
| 176 | + | if err != nil { | |||
| 177 | + | return nil, "", c.Describe(err) | |||
| 178 | + | } | |||
| 179 | + | return res.Msg.GetIssues(), res.Msg.GetNextPageToken(), nil | |||
| 180 | + | }) | |||
| 181 | + | } | |||
| 182 | + | ||||
| 183 | + | // outputIssue prints an issue as JSON, or as one confirmation line. | |||
| 184 | + | func outputIssue(cmd *cobra.Command, i *ermite.Issue, asJSON bool, verb string) error { | |||
| 185 | + | if asJSON { | |||
| 186 | + | return writeJSON(cmd.OutOrStdout(), toJSONIssue(i)) | |||
| 187 | + | } | |||
| 188 | + | _, err := fmt.Fprintf(cmd.OutOrStdout(), "%s %s#%d (%s)\n", verb, clean(i.GetRepo()), i.GetNumber(), clean(i.GetTitle())) | |||
| 189 | + | return err | |||
| 190 | + | } | |||
| 191 | + | ||||
| 192 | + | func newIssueCreateCmd(env *cliEnv) *cobra.Command { | |||
| 193 | + | var title, body, bodyFile string | |||
| 194 | + | cmd := &cobra.Command{ | |||
| 195 | + | Use: "create <repo>", | |||
| 196 | + | Short: "Open an issue", | |||
| 197 | + | Long: "Open an issue. The title is one line of at most 120 characters, the body plain text of at most\n" + | |||
| 198 | + | "8000; neither may hold control characters (a body may hold line breaks) or bidirectional override\n" + | |||
| 199 | + | "characters. --body - and --body-file - read the body from standard input; --body-file reads it\n" + | |||
| 200 | + | "from a file otherwise. Needs the issue:write scope." + jsonIssueHelp, | |||
| 201 | + | Args: cobra.ExactArgs(1), | |||
| 202 | + | ValidArgsFunction: completeRepoNames(env), | |||
| 203 | + | } | |||
| 204 | + | cmd.Flags().StringVar(&title, "title", "", "title of the issue (required)") | |||
| 205 | + | cmd.Flags().StringVar(&body, "body", "", "body, or - to read standard input") | |||
| 206 | + | cmd.Flags().StringVar(&bodyFile, "body-file", "", "read the body from this file, or - for standard input") | |||
| 207 | + | cmd.MarkFlagsMutuallyExclusive("body", "body-file") | |||
| 208 | + | _ = cmd.MarkFlagRequired("title") | |||
| 209 | + | _ = cmd.MarkFlagFilename("body-file") | |||
| 210 | + | asJSON := addJSONFlag(cmd) | |||
| 211 | + | cmd.RunE = func(cmd *cobra.Command, args []string) error { | |||
| 212 | + | text := body | |||
| 213 | + | if cmd.Flags().Changed("body-file") { | |||
| 214 | + | if bodyFile == "-" { | |||
| 215 | + | text = "-" | |||
| 216 | + | } else { | |||
| 217 | + | b, err := os.ReadFile(bodyFile) //nolint:gosec // a path typed by the user | |||
| 218 | + | if err != nil { | |||
| 219 | + | return fmt.Errorf("read --body-file: %w", err) | |||
| 220 | + | } | |||
| 221 | + | text = strings.TrimRight(string(b), "\r\n") | |||
| 222 | + | } | |||
| 223 | + | } | |||
| 224 | + | text, err := readText(cmd, text) | |||
| 225 | + | if err != nil { | |||
| 226 | + | return err | |||
| 227 | + | } | |||
| 228 | + | c, err := env.issueClient(cmd) | |||
| 229 | + | if err != nil { | |||
| 230 | + | return err | |||
| 231 | + | } | |||
| 232 | + | res, err := c.Issues().CreateIssue(env.ctx(cmd), connect.NewRequest(&ermite.CreateIssueRequest{ | |||
| 233 | + | Repo: args[0], Title: title, Body: text, | |||
| 234 | + | })) | |||
| 235 | + | if err != nil { | |||
| 236 | + | return c.Describe(err) | |||
| 237 | + | } | |||
| 238 | + | return outputIssue(cmd, res.Msg.GetIssue(), *asJSON, "opened") | |||
| 239 | + | } | |||
| 240 | + | return cmd | |||
| 241 | + | } | |||
| 242 | + | ||||
| 243 | + | func newIssueListCmd(env *cliEnv) *cobra.Command { | |||
| 244 | + | var state string | |||
| 245 | + | cmd := &cobra.Command{ | |||
| 246 | + | Use: "list <repo>", | |||
| 247 | + | Short: "List the issues of a repository", | |||
| 248 | + | Long: "List the issues of a repository, newest first. --state restricts them to open or closed ones\n" + | |||
| 249 | + | "(default: open; all shows every state)." + jsonIssueHelp + "\n--json prints an array of issues.", | |||
| 250 | + | Args: cobra.ExactArgs(1), | |||
| 251 | + | ValidArgsFunction: completeRepoNames(env), | |||
| 252 | + | } | |||
| 253 | + | cmd.Flags().StringVar(&state, "state", stateOpen, "state: open, closed or all") | |||
| 254 | + | _ = cmd.RegisterFlagCompletionFunc("state", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { | |||
| 255 | + | return []string{stateOpen, stateClosed, stateAll}, cobra.ShellCompDirectiveNoFileComp | |||
| 256 | + | }) | |||
| 257 | + | asJSON := addJSONFlag(cmd) | |||
| 258 | + | cmd.RunE = func(cmd *cobra.Command, args []string) error { | |||
| 259 | + | st, err := parseIssueState(state) | |||
| 260 | + | if err != nil { | |||
| 261 | + | return err | |||
| 262 | + | } | |||
| 263 | + | c, err := env.issueClient(cmd) | |||
| 264 | + | if err != nil { | |||
| 265 | + | return err | |||
| 266 | + | } | |||
| 267 | + | issues, err := listIssues(env.ctx(cmd), c, args[0], st) | |||
| 268 | + | if err != nil { | |||
| 269 | + | return err | |||
| 270 | + | } | |||
| 271 | + | if *asJSON { | |||
| 272 | + | out := make([]jsonIssue, 0, len(issues)) | |||
| 273 | + | for _, i := range issues { | |||
| 274 | + | out = append(out, toJSONIssue(i)) | |||
| 275 | + | } | |||
| 276 | + | return writeJSON(cmd.OutOrStdout(), out) | |||
| 277 | + | } | |||
| 278 | + | if len(issues) == 0 { | |||
| 279 | + | noticef(cmd, "no issues") | |||
| 280 | + | return nil | |||
| 281 | + | } | |||
| 282 | + | rows := make([][]string, 0, len(issues)) | |||
| 283 | + | for _, i := range issues { | |||
| 284 | + | rows = append(rows, []string{ | |||
| 285 | + | "#" + strconv.FormatInt(i.GetNumber(), 10), issueStateName(i.GetState()), i.GetAuthor(), | |||
| 286 | + | strconv.Itoa(int(i.GetCommentCount())), shortTime(i.GetUpdatedAt(), "-"), i.GetTitle(), | |||
| 287 | + | }) | |||
| 288 | + | } | |||
| 289 | + | return table(cmd.OutOrStdout(), []string{"NUMBER", "STATE", "AUTHOR", "COMMENTS", "UPDATED", "TITLE"}, rows) | |||
| 290 | + | } | |||
| 291 | + | return cmd | |||
| 292 | + | } | |||
| 293 | + | ||||
| 294 | + | func newIssueShowCmd(env *cliEnv) *cobra.Command { | |||
| 295 | + | cmd := &cobra.Command{ | |||
| 296 | + | Use: "show <repo> <number>", | |||
| 297 | + | Short: "Show an issue with its comments", | |||
| 298 | + | Long: "Show an issue: its fields, its body and its comments, oldest first." + jsonIssueHelp + jsonIssueCommentHelp + | |||
| 299 | + | "\n--json adds comments to the issue fields.", | |||
| 300 | + | Args: cobra.ExactArgs(2), | |||
| 301 | + | ValidArgsFunction: completeIssueRepoThenNumber(env), | |||
| 302 | + | } | |||
| 303 | + | asJSON := addJSONFlag(cmd) | |||
| 304 | + | cmd.RunE = func(cmd *cobra.Command, args []string) error { | |||
| 305 | + | n, err := parseIssueNumber(args[1]) | |||
| 306 | + | if err != nil { | |||
| 307 | + | return err | |||
| 308 | + | } | |||
| 309 | + | c, err := env.issueClient(cmd) | |||
| 310 | + | if err != nil { | |||
| 311 | + | return err | |||
| 312 | + | } | |||
| 313 | + | res, err := c.Issues().GetIssue(env.ctx(cmd), connect.NewRequest(&ermite.GetIssueRequest{Repo: args[0], Number: n})) | |||
| 314 | + | if err != nil { | |||
| 315 | + | return c.Describe(err) | |||
| 316 | + | } | |||
| 317 | + | if *asJSON { | |||
| 318 | + | out := jsonIssueDetail{jsonIssue: toJSONIssue(res.Msg.GetIssue()), Comments: []jsonIssueComment{}} | |||
| 319 | + | for _, cm := range res.Msg.GetComments() { | |||
| 320 | + | out.Comments = append(out.Comments, toJSONIssueComment(cm)) | |||
| 321 | + | } | |||
| 322 | + | return writeJSON(cmd.OutOrStdout(), out) | |||
| 323 | + | } | |||
| 324 | + | return printIssueDetail(cmd.OutOrStdout(), res.Msg) | |||
| 325 | + | } | |||
| 326 | + | return cmd | |||
| 327 | + | } | |||
| 328 | + | ||||
| 329 | + | func printIssueDetail(w io.Writer, d *ermite.GetIssueResponse) error { | |||
| 330 | + | i := d.GetIssue() | |||
| 331 | + | body := i.GetBody() | |||
| 332 | + | if body == "" { | |||
| 333 | + | body = "-" | |||
| 334 | + | } | |||
| 335 | + | err := fields(w, [][2]string{ | |||
| 336 | + | {"issue", fmt.Sprintf("%s#%d", i.GetRepo(), i.GetNumber())}, | |||
| 337 | + | {"title", i.GetTitle()}, | |||
| 338 | + | {"state", issueStateName(i.GetState())}, | |||
| 339 | + | {"author", i.GetAuthor()}, | |||
| 340 | + | {"created", shortTime(i.GetCreatedAt(), "-")}, | |||
| 341 | + | {"updated", shortTime(i.GetUpdatedAt(), "-")}, | |||
| 342 | + | }) | |||
| 343 | + | if err != nil { | |||
| 344 | + | return err | |||
| 345 | + | } | |||
| 346 | + | _, _ = fmt.Fprintf(w, "\n%s\n", indentText(body)) | |||
| 347 | + | _, _ = fmt.Fprintf(w, "\ncomments (%d)\n", len(d.GetComments())) | |||
| 348 | + | for _, c := range d.GetComments() { | |||
| 349 | + | _, _ = fmt.Fprintf(w, " #%s %s, %s\n%s\n", c.GetId(), clean(c.GetAuthor()), shortTime(c.GetCreatedAt(), "-"), indentText(c.GetText())) | |||
| 350 | + | } | |||
| 351 | + | return nil | |||
| 352 | + | } | |||
| 353 | + | ||||
| 354 | + | func newIssueCommentCmd(env *cliEnv) *cobra.Command { | |||
| 355 | + | var message string | |||
| 356 | + | cmd := &cobra.Command{ | |||
| 357 | + | Use: "comment <repo> <number>", | |||
| 358 | + | Short: "Comment on an issue", | |||
| 359 | + | Long: "Add a plain-text comment (at most 8000 characters, no control characters except line breaks).\n" + | |||
| 360 | + | "-m - reads it from standard input. Needs the issue:write scope." + jsonIssueCommentHelp, | |||
| 361 | + | Args: cobra.ExactArgs(2), | |||
| 362 | + | ValidArgsFunction: completeIssueRepoThenNumber(env), | |||
| 363 | + | } | |||
| 364 | + | cmd.Flags().StringVarP(&message, "message", "m", "", "text of the comment, or - to read standard input (required)") | |||
| 365 | + | cmd.Flags().StringVar(&message, "text", "", "alias of --message") | |||
| 366 | + | _ = cmd.Flags().MarkHidden("text") | |||
| 367 | + | asJSON := addJSONFlag(cmd) | |||
| 368 | + | cmd.RunE = func(cmd *cobra.Command, args []string) error { | |||
| 369 | + | n, err := parseIssueNumber(args[1]) | |||
| 370 | + | if err != nil { | |||
| 371 | + | return err | |||
| 372 | + | } | |||
| 373 | + | if !cmd.Flags().Changed("message") && !cmd.Flags().Changed("text") { | |||
| 374 | + | return errors.New("the comment is required: give it with -m, or -m - for standard input") | |||
| 375 | + | } | |||
| 376 | + | text, err := readText(cmd, message) | |||
| 377 | + | if err != nil { | |||
| 378 | + | return err | |||
| 379 | + | } | |||
| 380 | + | c, err := env.issueClient(cmd) | |||
| 381 | + | if err != nil { | |||
| 382 | + | return err | |||
| 383 | + | } | |||
| 384 | + | res, err := c.Issues().AddIssueComment(env.ctx(cmd), connect.NewRequest(&ermite.AddIssueCommentRequest{ | |||
| 385 | + | Repo: args[0], Number: n, Text: text, | |||
| 386 | + | })) | |||
| 387 | + | if err != nil { | |||
| 388 | + | return c.Describe(err) | |||
| 389 | + | } | |||
| 390 | + | if *asJSON { | |||
| 391 | + | return writeJSON(cmd.OutOrStdout(), toJSONIssueComment(res.Msg.GetComment())) | |||
| 392 | + | } | |||
| 393 | + | _, err = fmt.Fprintf(cmd.OutOrStdout(), "comment %s added to %s#%d\n", res.Msg.GetComment().GetId(), clean(args[0]), n) | |||
| 394 | + | return err | |||
| 395 | + | } | |||
| 396 | + | return cmd | |||
| 397 | + | } | |||
| 398 | + | ||||
| 399 | + | func newIssueCloseCmd(env *cliEnv) *cobra.Command { | |||
| 400 | + | return newIssueStateCmd(env, "close", "closed", "Close an issue", "Close an open issue. `ermite issue reopen` opens it again. Needs the issue:write scope.", | |||
| 401 | + | func(ctx context.Context, c *client.Client, repo string, n int64) (*ermite.Issue, error) { | |||
| 402 | + | res, err := c.Issues().CloseIssue(ctx, connect.NewRequest(&ermite.CloseIssueRequest{Repo: repo, Number: n})) | |||
| 403 | + | if err != nil { | |||
| 404 | + | return nil, err | |||
| 405 | + | } | |||
| 406 | + | return res.Msg.GetIssue(), nil | |||
| 407 | + | }) | |||
| 408 | + | } | |||
| 409 | + | ||||
| 410 | + | func newIssueReopenCmd(env *cliEnv) *cobra.Command { | |||
| 411 | + | return newIssueStateCmd(env, "reopen", "reopened", "Reopen a closed issue", "Reopen a closed issue. Needs the issue:write scope.", | |||
| 412 | + | func(ctx context.Context, c *client.Client, repo string, n int64) (*ermite.Issue, error) { | |||
| 413 | + | res, err := c.Issues().ReopenIssue(ctx, connect.NewRequest(&ermite.ReopenIssueRequest{Repo: repo, Number: n})) | |||
| 414 | + | if err != nil { | |||
| 415 | + | return nil, err | |||
| 416 | + | } | |||
| 417 | + | return res.Msg.GetIssue(), nil | |||
| 418 | + | }) | |||
| 419 | + | } | |||
| 420 | + | ||||
| 421 | + | func newIssueStateCmd(env *cliEnv, use, verb, short, long string, do func(context.Context, *client.Client, string, int64) (*ermite.Issue, error)) *cobra.Command { | |||
| 422 | + | cmd := &cobra.Command{ | |||
| 423 | + | Use: use + " <repo> <number>", | |||
| 424 | + | Short: short, | |||
| 425 | + | Long: long + jsonIssueHelp, | |||
| 426 | + | Args: cobra.ExactArgs(2), | |||
| 427 | + | ValidArgsFunction: completeIssueRepoThenNumber(env), | |||
| 428 | + | } | |||
| 429 | + | asJSON := addJSONFlag(cmd) | |||
| 430 | + | cmd.RunE = func(cmd *cobra.Command, args []string) error { | |||
| 431 | + | n, err := parseIssueNumber(args[1]) | |||
| 432 | + | if err != nil { | |||
| 433 | + | return err | |||
| 434 | + | } | |||
| 435 | + | c, err := env.issueClient(cmd) | |||
| 436 | + | if err != nil { | |||
| 437 | + | return err | |||
| 438 | + | } | |||
| 439 | + | i, err := do(env.ctx(cmd), c, args[0], n) | |||
| 440 | + | if err != nil { | |||
| 441 | + | return c.Describe(err) | |||
| 442 | + | } | |||
| 443 | + | return outputIssue(cmd, i, *asJSON, verb) | |||
| 444 | + | } | |||
| 445 | + | return cmd | |||
| 446 | + | } | |||
A internal/app/cli_issue_e2e_test.go +154 -0
| @@ -0,0 +1,154 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package app | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "strings" | |
| 7 | + | "testing" | |
| 8 | + | ||
| 9 | + | "github.com/stretchr/testify/assert" | |
| 10 | + | "github.com/stretchr/testify/require" | |
| 11 | + | ||
| 12 | + | "bnema.dev/ermite-api/gen/ermite" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | type issueOut struct { | |
| 16 | + | Repo string `json:"repo"` | |
| 17 | + | Number int64 `json:"number"` | |
| 18 | + | Title string `json:"title"` | |
| 19 | + | Body string `json:"body"` | |
| 20 | + | State string `json:"state"` | |
| 21 | + | Author string `json:"author"` | |
| 22 | + | CreatedAt *string `json:"created_at"` | |
| 23 | + | UpdatedAt *string `json:"updated_at"` | |
| 24 | + | CommentCount int `json:"comment_count"` | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | var issueKeys = []string{"author", "body", "comment_count", "created_at", "number", "repo", "state", "title", "updated_at"} | |
| 28 | + | ||
| 29 | + | type issueCommentOut struct { | |
| 30 | + | ID string `json:"id"` | |
| 31 | + | Author string `json:"author"` | |
| 32 | + | Text string `json:"text"` | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | // The issue commands against a real forge: issues share the numbering of the | |
| 36 | + | // merge requests. | |
| 37 | + | func TestIssuesCLIEndToEnd(t *testing.T) { | |
| 38 | + | c := newCLIHarness(t) | |
| 39 | + | agent := strings.TrimSpace(func() string { | |
| 40 | + | out, errOut, code := c.bin(t, adminToken, "token", "create", "agent-7", "--preset", "agent", "--scope", "issue:write", "--repo", "proj") | |
| 41 | + | require.Equal(t, 0, code, errOut) | |
| 42 | + | return out | |
| 43 | + | }()) | |
| 44 | + | c.cli(t, adminToken, "repo", "create", "proj").ok(t) | |
| 45 | + | ||
| 46 | + | // Issues are off by default. | |
| 47 | + | res := c.cli(t, adminToken, "repo", "show", "proj", "--json").ok(t) | |
| 48 | + | assert.Equal(t, "off", decode[struct { | |
| 49 | + | Issues string `json:"issues"` | |
| 50 | + | }](t, res.stdout).Issues) | |
| 51 | + | res = c.cli(t, adminToken, "issue", "create", "proj", "--title", "too early") | |
| 52 | + | require.Error(t, res.err) | |
| 53 | + | ||
| 54 | + | // repo edit --issues. | |
| 55 | + | res = c.cli(t, adminToken, "repo", "edit", "proj", "--issues", "owner", "--json").ok(t) | |
| 56 | + | assert.Equal(t, "owner", decode[struct { | |
| 57 | + | Issues string `json:"issues"` | |
| 58 | + | }](t, res.stdout).Issues) | |
| 59 | + | res = c.cli(t, adminToken, "repo", "show", "proj").ok(t) | |
| 60 | + | assert.Regexp(t, `issues\s+owner`, res.stdout) | |
| 61 | + | require.Error(t, c.cli(t, adminToken, "repo", "edit", "proj", "--issues", "everyone").err) | |
| 62 | + | res = c.cli(t, adminToken, "repo", "edit", "proj", "--issues", "public") | |
| 63 | + | require.Error(t, res.err, "public is refused by the server for now") | |
| 64 | + | ||
| 65 | + | // A merge request first: the issue takes the next number. | |
| 66 | + | wt := c.workTree("main", "README.md") | |
| 67 | + | url := c.urlAuth(adminToken, "/proj.git") | |
| 68 | + | c.mustGit(wt, "push", "-q", url, "main") | |
| 69 | + | c.mustGit(wt, "checkout", "-q", "-b", "feat/x") | |
| 70 | + | c.writeFile(wt, "x.txt", []byte("x\n")) | |
| 71 | + | c.mustGit(wt, "push", "-q", url, "feat/x") | |
| 72 | + | c.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/x", "--title", "First").ok(t) | |
| 73 | + | ||
| 74 | + | res = c.cli(t, agent, "issue", "create", "proj", "--title", "Crash on start", "--body", "It crashes.\nSecond line.", "--json").ok(t) | |
| 75 | + | assert.Equal(t, issueKeys, keys(t, res.stdout)) | |
| 76 | + | issue := decode[issueOut](t, res.stdout) | |
| 77 | + | assert.EqualValues(t, 2, issue.Number, "shared numbering with merge requests") | |
| 78 | + | assert.Equal(t, "open", issue.State) | |
| 79 | + | assert.Equal(t, "agent-7", issue.Author) | |
| 80 | + | assert.Equal(t, "It crashes.\nSecond line.", issue.Body) | |
| 81 | + | ||
| 82 | + | res = runCLI(t, c.vars(agent), "from stdin\n", "issue", "create", "proj", "--title", "Second", "--body-file", "-").ok(t) | |
| 83 | + | assert.Equal(t, "opened proj#3 (Second)\n", res.stdout) | |
| 84 | + | ||
| 85 | + | // list. | |
| 86 | + | res = c.cli(t, adminToken, "issue", "list", "proj", "--json").ok(t) | |
| 87 | + | list := decode[[]issueOut](t, res.stdout) | |
| 88 | + | require.Len(t, list, 2) | |
| 89 | + | assert.EqualValues(t, 3, list[0].Number, "highest number first") | |
| 90 | + | res = c.cli(t, adminToken, "issue", "list", "proj").ok(t) | |
| 91 | + | lines := strings.Split(strings.TrimSpace(res.stdout), "\n") | |
| 92 | + | require.Len(t, lines, 3) | |
| 93 | + | assert.Regexp(t, `^NUMBER\s+STATE\s+AUTHOR\s+COMMENTS\s+UPDATED\s+TITLE$`, lines[0]) | |
| 94 | + | assert.Regexp(t, `^#3\s+open\s+agent-7\s+0\s+\d{4}-\d{2}-\d{2} \d{2}:\d{2}\s+Second$`, lines[1]) | |
| 95 | + | ||
| 96 | + | // comment, then show with it. | |
| 97 | + | res = c.cli(t, agent, "issue", "comment", "proj", "2", "-m", "Reproduced.", "--json").ok(t) | |
| 98 | + | cm := decode[issueCommentOut](t, res.stdout) | |
| 99 | + | assert.Equal(t, "agent-7", cm.Author) | |
| 100 | + | assert.Equal(t, "Reproduced.", cm.Text) | |
| 101 | + | res = c.cli(t, adminToken, "issue", "comment", "proj", "#2", "-m", "Thanks.").ok(t) | |
| 102 | + | assert.Regexp(t, `^comment \S+ added to proj#2\n$`, res.stdout) | |
| 103 | + | res = c.cli(t, adminToken, "issue", "show", "proj", "2", "--json").ok(t) | |
| 104 | + | var show struct { | |
| 105 | + | issueOut | |
| 106 | + | Comments []issueCommentOut `json:"comments"` | |
| 107 | + | } | |
| 108 | + | show = decode[struct { | |
| 109 | + | issueOut | |
| 110 | + | Comments []issueCommentOut `json:"comments"` | |
| 111 | + | }](t, res.stdout) | |
| 112 | + | assert.Equal(t, 2, show.CommentCount) | |
| 113 | + | require.Len(t, show.Comments, 2) | |
| 114 | + | assert.Equal(t, "Reproduced.", show.Comments[0].Text, "oldest first") | |
| 115 | + | res = c.cli(t, adminToken, "issue", "show", "proj", "2").ok(t) | |
| 116 | + | assert.Contains(t, res.stdout, "Crash on start") | |
| 117 | + | assert.Contains(t, res.stdout, "comments (2)") | |
| 118 | + | ||
| 119 | + | // close and reopen. | |
| 120 | + | res = c.cli(t, agent, "issue", "close", "proj", "2", "--json").ok(t) | |
| 121 | + | assert.Equal(t, "closed", decode[issueOut](t, res.stdout).State) | |
| 122 | + | res = c.cli(t, adminToken, "issue", "list", "proj", "--json").ok(t) | |
| 123 | + | assert.Len(t, decode[[]issueOut](t, res.stdout), 1) | |
| 124 | + | res = c.cli(t, adminToken, "issue", "list", "proj", "--state", "closed", "--json").ok(t) | |
| 125 | + | assert.Len(t, decode[[]issueOut](t, res.stdout), 1) | |
| 126 | + | res = c.cli(t, adminToken, "issue", "list", "proj", "--state", "all", "--json").ok(t) | |
| 127 | + | assert.Len(t, decode[[]issueOut](t, res.stdout), 2) | |
| 128 | + | res = c.cli(t, agent, "issue", "reopen", "proj", "2").ok(t) | |
| 129 | + | assert.Equal(t, "reopened proj#2 (Crash on start)\n", res.stdout) | |
| 130 | + | ||
| 131 | + | // An issue is not a merge request and the other way round. | |
| 132 | + | _, stderr, code := c.bin(t, adminToken, "issue", "show", "proj", "1") | |
| 133 | + | requireOneLineError(t, stderr, code, "not found") | |
| 134 | + | _, stderr, code = c.bin(t, adminToken, "issue", "show", "proj", "9") | |
| 135 | + | requireOneLineError(t, stderr, code, "not found") | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | // A server without the issues feature is refused before anything is created. | |
| 139 | + | func TestIssueWriteTokenRefusedWithoutTheFeature(t *testing.T) { | |
| 140 | + | f := &fakeAPI{ | |
| 141 | + | getVersion: func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) { | |
| 142 | + | return &ermite.GetVersionResponse{Version: "v0.4.0", Features: []string{"merge_requests"}}, nil | |
| 143 | + | }, | |
| 144 | + | createToken: func(*ermite.CreateTokenRequest) (*ermite.CreateTokenResponse, error) { | |
| 145 | + | return &ermite.CreateTokenResponse{Token: &ermite.Token{Id: "t"}, Secret: "ermite_S"}, nil | |
| 146 | + | }, | |
| 147 | + | } | |
| 148 | + | res := runCLI(t, envFor(f.serve(t)), "", "token", "create", "bot", "--preset", "agent", "--scope", "issue:write") | |
| 149 | + | require.Error(t, res.err) | |
| 150 | + | assert.Contains(t, res.err.Error(), "does not support issues") | |
| 151 | + | assert.Empty(t, res.stdout) | |
| 152 | + | calls, _ := f.snapshot() | |
| 153 | + | assert.NotContains(t, calls, "CreateToken") | |
| 154 | + | } | |
| @@ -0,0 +1,154 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package app | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "strings" | |||
| 7 | + | "testing" | |||
| 8 | + | ||||
| 9 | + | "github.com/stretchr/testify/assert" | |||
| 10 | + | "github.com/stretchr/testify/require" | |||
| 11 | + | ||||
| 12 | + | "bnema.dev/ermite-api/gen/ermite" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | type issueOut struct { | |||
| 16 | + | Repo string `json:"repo"` | |||
| 17 | + | Number int64 `json:"number"` | |||
| 18 | + | Title string `json:"title"` | |||
| 19 | + | Body string `json:"body"` | |||
| 20 | + | State string `json:"state"` | |||
| 21 | + | Author string `json:"author"` | |||
| 22 | + | CreatedAt *string `json:"created_at"` | |||
| 23 | + | UpdatedAt *string `json:"updated_at"` | |||
| 24 | + | CommentCount int `json:"comment_count"` | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | var issueKeys = []string{"author", "body", "comment_count", "created_at", "number", "repo", "state", "title", "updated_at"} | |||
| 28 | + | ||||
| 29 | + | type issueCommentOut struct { | |||
| 30 | + | ID string `json:"id"` | |||
| 31 | + | Author string `json:"author"` | |||
| 32 | + | Text string `json:"text"` | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | // The issue commands against a real forge: issues share the numbering of the | |||
| 36 | + | // merge requests. | |||
| 37 | + | func TestIssuesCLIEndToEnd(t *testing.T) { | |||
| 38 | + | c := newCLIHarness(t) | |||
| 39 | + | agent := strings.TrimSpace(func() string { | |||
| 40 | + | out, errOut, code := c.bin(t, adminToken, "token", "create", "agent-7", "--preset", "agent", "--scope", "issue:write", "--repo", "proj") | |||
| 41 | + | require.Equal(t, 0, code, errOut) | |||
| 42 | + | return out | |||
| 43 | + | }()) | |||
| 44 | + | c.cli(t, adminToken, "repo", "create", "proj").ok(t) | |||
| 45 | + | ||||
| 46 | + | // Issues are off by default. | |||
| 47 | + | res := c.cli(t, adminToken, "repo", "show", "proj", "--json").ok(t) | |||
| 48 | + | assert.Equal(t, "off", decode[struct { | |||
| 49 | + | Issues string `json:"issues"` | |||
| 50 | + | }](t, res.stdout).Issues) | |||
| 51 | + | res = c.cli(t, adminToken, "issue", "create", "proj", "--title", "too early") | |||
| 52 | + | require.Error(t, res.err) | |||
| 53 | + | ||||
| 54 | + | // repo edit --issues. | |||
| 55 | + | res = c.cli(t, adminToken, "repo", "edit", "proj", "--issues", "owner", "--json").ok(t) | |||
| 56 | + | assert.Equal(t, "owner", decode[struct { | |||
| 57 | + | Issues string `json:"issues"` | |||
| 58 | + | }](t, res.stdout).Issues) | |||
| 59 | + | res = c.cli(t, adminToken, "repo", "show", "proj").ok(t) | |||
| 60 | + | assert.Regexp(t, `issues\s+owner`, res.stdout) | |||
| 61 | + | require.Error(t, c.cli(t, adminToken, "repo", "edit", "proj", "--issues", "everyone").err) | |||
| 62 | + | res = c.cli(t, adminToken, "repo", "edit", "proj", "--issues", "public") | |||
| 63 | + | require.Error(t, res.err, "public is refused by the server for now") | |||
| 64 | + | ||||
| 65 | + | // A merge request first: the issue takes the next number. | |||
| 66 | + | wt := c.workTree("main", "README.md") | |||
| 67 | + | url := c.urlAuth(adminToken, "/proj.git") | |||
| 68 | + | c.mustGit(wt, "push", "-q", url, "main") | |||
| 69 | + | c.mustGit(wt, "checkout", "-q", "-b", "feat/x") | |||
| 70 | + | c.writeFile(wt, "x.txt", []byte("x\n")) | |||
| 71 | + | c.mustGit(wt, "push", "-q", url, "feat/x") | |||
| 72 | + | c.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/x", "--title", "First").ok(t) | |||
| 73 | + | ||||
| 74 | + | res = c.cli(t, agent, "issue", "create", "proj", "--title", "Crash on start", "--body", "It crashes.\nSecond line.", "--json").ok(t) | |||
| 75 | + | assert.Equal(t, issueKeys, keys(t, res.stdout)) | |||
| 76 | + | issue := decode[issueOut](t, res.stdout) | |||
| 77 | + | assert.EqualValues(t, 2, issue.Number, "shared numbering with merge requests") | |||
| 78 | + | assert.Equal(t, "open", issue.State) | |||
| 79 | + | assert.Equal(t, "agent-7", issue.Author) | |||
| 80 | + | assert.Equal(t, "It crashes.\nSecond line.", issue.Body) | |||
| 81 | + | ||||
| 82 | + | res = runCLI(t, c.vars(agent), "from stdin\n", "issue", "create", "proj", "--title", "Second", "--body-file", "-").ok(t) | |||
| 83 | + | assert.Equal(t, "opened proj#3 (Second)\n", res.stdout) | |||
| 84 | + | ||||
| 85 | + | // list. | |||
| 86 | + | res = c.cli(t, adminToken, "issue", "list", "proj", "--json").ok(t) | |||
| 87 | + | list := decode[[]issueOut](t, res.stdout) | |||
| 88 | + | require.Len(t, list, 2) | |||
| 89 | + | assert.EqualValues(t, 3, list[0].Number, "highest number first") | |||
| 90 | + | res = c.cli(t, adminToken, "issue", "list", "proj").ok(t) | |||
| 91 | + | lines := strings.Split(strings.TrimSpace(res.stdout), "\n") | |||
| 92 | + | require.Len(t, lines, 3) | |||
| 93 | + | assert.Regexp(t, `^NUMBER\s+STATE\s+AUTHOR\s+COMMENTS\s+UPDATED\s+TITLE$`, lines[0]) | |||
| 94 | + | assert.Regexp(t, `^#3\s+open\s+agent-7\s+0\s+\d{4}-\d{2}-\d{2} \d{2}:\d{2}\s+Second$`, lines[1]) | |||
| 95 | + | ||||
| 96 | + | // comment, then show with it. | |||
| 97 | + | res = c.cli(t, agent, "issue", "comment", "proj", "2", "-m", "Reproduced.", "--json").ok(t) | |||
| 98 | + | cm := decode[issueCommentOut](t, res.stdout) | |||
| 99 | + | assert.Equal(t, "agent-7", cm.Author) | |||
| 100 | + | assert.Equal(t, "Reproduced.", cm.Text) | |||
| 101 | + | res = c.cli(t, adminToken, "issue", "comment", "proj", "#2", "-m", "Thanks.").ok(t) | |||
| 102 | + | assert.Regexp(t, `^comment \S+ added to proj#2\n$`, res.stdout) | |||
| 103 | + | res = c.cli(t, adminToken, "issue", "show", "proj", "2", "--json").ok(t) | |||
| 104 | + | var show struct { | |||
| 105 | + | issueOut | |||
| 106 | + | Comments []issueCommentOut `json:"comments"` | |||
| 107 | + | } | |||
| 108 | + | show = decode[struct { | |||
| 109 | + | issueOut | |||
| 110 | + | Comments []issueCommentOut `json:"comments"` | |||
| 111 | + | }](t, res.stdout) | |||
| 112 | + | assert.Equal(t, 2, show.CommentCount) | |||
| 113 | + | require.Len(t, show.Comments, 2) | |||
| 114 | + | assert.Equal(t, "Reproduced.", show.Comments[0].Text, "oldest first") | |||
| 115 | + | res = c.cli(t, adminToken, "issue", "show", "proj", "2").ok(t) | |||
| 116 | + | assert.Contains(t, res.stdout, "Crash on start") | |||
| 117 | + | assert.Contains(t, res.stdout, "comments (2)") | |||
| 118 | + | ||||
| 119 | + | // close and reopen. | |||
| 120 | + | res = c.cli(t, agent, "issue", "close", "proj", "2", "--json").ok(t) | |||
| 121 | + | assert.Equal(t, "closed", decode[issueOut](t, res.stdout).State) | |||
| 122 | + | res = c.cli(t, adminToken, "issue", "list", "proj", "--json").ok(t) | |||
| 123 | + | assert.Len(t, decode[[]issueOut](t, res.stdout), 1) | |||
| 124 | + | res = c.cli(t, adminToken, "issue", "list", "proj", "--state", "closed", "--json").ok(t) | |||
| 125 | + | assert.Len(t, decode[[]issueOut](t, res.stdout), 1) | |||
| 126 | + | res = c.cli(t, adminToken, "issue", "list", "proj", "--state", "all", "--json").ok(t) | |||
| 127 | + | assert.Len(t, decode[[]issueOut](t, res.stdout), 2) | |||
| 128 | + | res = c.cli(t, agent, "issue", "reopen", "proj", "2").ok(t) | |||
| 129 | + | assert.Equal(t, "reopened proj#2 (Crash on start)\n", res.stdout) | |||
| 130 | + | ||||
| 131 | + | // An issue is not a merge request and the other way round. | |||
| 132 | + | _, stderr, code := c.bin(t, adminToken, "issue", "show", "proj", "1") | |||
| 133 | + | requireOneLineError(t, stderr, code, "not found") | |||
| 134 | + | _, stderr, code = c.bin(t, adminToken, "issue", "show", "proj", "9") | |||
| 135 | + | requireOneLineError(t, stderr, code, "not found") | |||
| 136 | + | } | |||
| 137 | + | ||||
| 138 | + | // A server without the issues feature is refused before anything is created. | |||
| 139 | + | func TestIssueWriteTokenRefusedWithoutTheFeature(t *testing.T) { | |||
| 140 | + | f := &fakeAPI{ | |||
| 141 | + | getVersion: func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) { | |||
| 142 | + | return &ermite.GetVersionResponse{Version: "v0.4.0", Features: []string{"merge_requests"}}, nil | |||
| 143 | + | }, | |||
| 144 | + | createToken: func(*ermite.CreateTokenRequest) (*ermite.CreateTokenResponse, error) { | |||
| 145 | + | return &ermite.CreateTokenResponse{Token: &ermite.Token{Id: "t"}, Secret: "ermite_S"}, nil | |||
| 146 | + | }, | |||
| 147 | + | } | |||
| 148 | + | res := runCLI(t, envFor(f.serve(t)), "", "token", "create", "bot", "--preset", "agent", "--scope", "issue:write") | |||
| 149 | + | require.Error(t, res.err) | |||
| 150 | + | assert.Contains(t, res.err.Error(), "does not support issues") | |||
| 151 | + | assert.Empty(t, res.stdout) | |||
| 152 | + | calls, _ := f.snapshot() | |||
| 153 | + | assert.NotContains(t, calls, "CreateToken") | |||
| 154 | + | } | |||
A internal/app/cli_issue_test.go +224 -0
| @@ -0,0 +1,224 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package app | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "os" | |
| 7 | + | "path/filepath" | |
| 8 | + | "testing" | |
| 9 | + | ||
| 10 | + | "github.com/stretchr/testify/assert" | |
| 11 | + | "github.com/stretchr/testify/require" | |
| 12 | + | ||
| 13 | + | "bnema.dev/ermite-api/gen/ermite" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | var issueOne = &ermite.Issue{ | |
| 17 | + | Repo: "alpha", Number: 2, Title: "Crash on start", Body: "It crashes.", State: ermite.IssueState_ISSUE_STATE_OPEN, | |
| 18 | + | Author: "owner", CreatedAt: ts("2026-10-01T10:00:00Z"), UpdatedAt: ts("2026-10-02T11:30:00Z"), CommentCount: 1, | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | func withIssues(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) { | |
| 22 | + | return &ermite.GetVersionResponse{Version: "v0.5.0", Features: []string{"issues"}}, nil | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | func TestIssueCreateMapsFlags(t *testing.T) { | |
| 26 | + | f := &fakeAPI{getVersion: withIssues, createIssue: func(*ermite.CreateIssueRequest) (*ermite.CreateIssueResponse, error) { | |
| 27 | + | return &ermite.CreateIssueResponse{Issue: issueOne}, nil | |
| 28 | + | }} | |
| 29 | + | url := f.serve(t) | |
| 30 | + | res := runCLI(t, envFor(url), "", "issue", "create", "alpha", "--title", "Crash on start", "--body", "It crashes.").ok(t) | |
| 31 | + | assert.Equal(t, "opened alpha#2 (Crash on start)\n", res.stdout) | |
| 32 | + | _, reqs := f.snapshot() | |
| 33 | + | got := reqs[1].(*ermite.CreateIssueRequest) | |
| 34 | + | assert.Equal(t, "alpha", got.GetRepo()) | |
| 35 | + | assert.Equal(t, "Crash on start", got.GetTitle()) | |
| 36 | + | assert.Equal(t, "It crashes.", got.GetBody()) | |
| 37 | + | ||
| 38 | + | runCLI(t, envFor(url), "from\nstdin\n", "issue", "create", "alpha", "--title", "t", "--body", "-").ok(t) | |
| 39 | + | runCLI(t, envFor(url), "piped\n", "issue", "create", "alpha", "--title", "t", "--body-file", "-").ok(t) | |
| 40 | + | file := filepath.Join(t.TempDir(), "body.txt") | |
| 41 | + | require.NoError(t, os.WriteFile(file, []byte("from file\n"), 0o600)) | |
| 42 | + | runCLI(t, envFor(url), "", "issue", "create", "alpha", "--title", "t", "--body-file", file).ok(t) | |
| 43 | + | _, reqs = f.snapshot() | |
| 44 | + | var bodies []string | |
| 45 | + | for _, r := range reqs { | |
| 46 | + | if c, ok := r.(*ermite.CreateIssueRequest); ok { | |
| 47 | + | bodies = append(bodies, c.GetBody()) | |
| 48 | + | } | |
| 49 | + | } | |
| 50 | + | assert.Equal(t, []string{"It crashes.", "from\nstdin", "piped", "from file"}, bodies) | |
| 51 | + | ||
| 52 | + | res = runCLI(t, envFor(url), "", "issue", "create", "alpha", "--title", "t", "--json").ok(t) | |
| 53 | + | assert.JSONEq(t, `{"repo":"alpha","number":2,"title":"Crash on start","body":"It crashes.","state":"open","author":"owner","created_at":"2026-10-01T10:00:00Z","updated_at":"2026-10-02T11:30:00Z","comment_count":1}`, res.stdout) | |
| 54 | + | ||
| 55 | + | for _, args := range [][]string{ | |
| 56 | + | {"issue", "create", "alpha"}, | |
| 57 | + | {"issue", "create", "alpha", "--title", "t", "--body", "a", "--body-file", file}, | |
| 58 | + | } { | |
| 59 | + | require.Error(t, runCLI(t, envFor(url), "", args...).err, "%v", args) | |
| 60 | + | } | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | func TestIssueListTableJSONAndPages(t *testing.T) { | |
| 64 | + | closed := &ermite.Issue{Repo: "alpha", Number: 1, Title: "Old", State: ermite.IssueState_ISSUE_STATE_CLOSED, Author: "bot", UpdatedAt: ts("2026-09-01T08:00:00Z")} | |
| 65 | + | f := &fakeAPI{getVersion: withIssues, listIssues: func(r *ermite.ListIssuesRequest) (*ermite.ListIssuesResponse, error) { | |
| 66 | + | if r.GetPageToken() == "" { | |
| 67 | + | return &ermite.ListIssuesResponse{Issues: []*ermite.Issue{issueOne}, NextPageToken: "p2"}, nil | |
| 68 | + | } | |
| 69 | + | return &ermite.ListIssuesResponse{Issues: []*ermite.Issue{closed}}, nil | |
| 70 | + | }} | |
| 71 | + | url := f.serve(t) | |
| 72 | + | res := runCLI(t, envFor(url), "", "issue", "list", "alpha", "--state", "all").ok(t) | |
| 73 | + | assert.Regexp(t, `NUMBER\s+STATE\s+AUTHOR\s+COMMENTS\s+UPDATED\s+TITLE\n#2\s+open\s+owner\s+1\s+2026-10-02 11:30\s+Crash on start\n#1\s+closed\s+bot\s+0\s+2026-09-01 08:00\s+Old\n`, res.stdout) | |
| 74 | + | _, reqs := f.snapshot() | |
| 75 | + | require.Len(t, reqs, 3) // GetVersion, two pages | |
| 76 | + | assert.Equal(t, ermite.IssueState_ISSUE_STATE_UNSPECIFIED, reqs[1].(*ermite.ListIssuesRequest).GetState()) | |
| 77 | + | assert.Equal(t, "p2", reqs[2].(*ermite.ListIssuesRequest).GetPageToken()) | |
| 78 | + | ||
| 79 | + | runCLI(t, envFor(url), "", "issue", "list", "alpha").ok(t) | |
| 80 | + | _, reqs = f.snapshot() | |
| 81 | + | assert.Equal(t, ermite.IssueState_ISSUE_STATE_OPEN, reqs[len(reqs)-2].(*ermite.ListIssuesRequest).GetState()) | |
| 82 | + | ||
| 83 | + | res = runCLI(t, envFor(url), "", "issue", "list", "alpha", "--json").ok(t) | |
| 84 | + | assert.Contains(t, res.stdout, `"state": "closed"`) | |
| 85 | + | ||
| 86 | + | require.Error(t, runCLI(t, envFor(url), "", "issue", "list", "alpha", "--state", "merged").err) | |
| 87 | + | ||
| 88 | + | empty := &fakeAPI{getVersion: withIssues, listIssues: func(*ermite.ListIssuesRequest) (*ermite.ListIssuesResponse, error) { | |
| 89 | + | return &ermite.ListIssuesResponse{}, nil | |
| 90 | + | }} | |
| 91 | + | res = runCLI(t, envFor(empty.serve(t)), "", "issue", "list", "alpha", "--json").ok(t) | |
| 92 | + | assert.Equal(t, "[]\n", res.stdout) | |
| 93 | + | res = runCLI(t, envFor(empty.serve(t)), "", "issue", "list", "alpha").ok(t) | |
| 94 | + | assert.Equal(t, "ermite: no issues\n", res.stderr) | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | func TestIssueShowCommentCloseReopen(t *testing.T) { | |
| 98 | + | cmt := &ermite.IssueComment{Id: "1", Author: "agent-7", Text: "On it.", CreatedAt: ts("2026-10-02T11:30:00Z")} | |
| 99 | + | reopened := &ermite.Issue{Repo: "alpha", Number: 2, Title: "Crash on start", State: ermite.IssueState_ISSUE_STATE_OPEN} | |
| 100 | + | f := &fakeAPI{ | |
| 101 | + | getVersion: withIssues, | |
| 102 | + | getIssue: func(*ermite.GetIssueRequest) (*ermite.GetIssueResponse, error) { | |
| 103 | + | return &ermite.GetIssueResponse{Issue: issueOne, Comments: []*ermite.IssueComment{cmt}}, nil | |
| 104 | + | }, | |
| 105 | + | addIssueCmt: func(*ermite.AddIssueCommentRequest) (*ermite.AddIssueCommentResponse, error) { | |
| 106 | + | return &ermite.AddIssueCommentResponse{Comment: cmt}, nil | |
| 107 | + | }, | |
| 108 | + | closeIssue: func(*ermite.CloseIssueRequest) (*ermite.CloseIssueResponse, error) { | |
| 109 | + | c := &ermite.Issue{Repo: "alpha", Number: 2, Title: "Crash on start", State: ermite.IssueState_ISSUE_STATE_CLOSED} | |
| 110 | + | return &ermite.CloseIssueResponse{Issue: c}, nil | |
| 111 | + | }, | |
| 112 | + | reopenIssue: func(*ermite.ReopenIssueRequest) (*ermite.ReopenIssueResponse, error) { | |
| 113 | + | return &ermite.ReopenIssueResponse{Issue: reopened}, nil | |
| 114 | + | }, | |
| 115 | + | } | |
| 116 | + | url := f.serve(t) | |
| 117 | + | ||
| 118 | + | res := runCLI(t, envFor(url), "", "issue", "show", "alpha", "#2").ok(t) | |
| 119 | + | assert.Contains(t, res.stdout, "issue alpha#2") | |
| 120 | + | assert.Contains(t, res.stdout, "It crashes.") | |
| 121 | + | assert.Contains(t, res.stdout, "comments (1)") | |
| 122 | + | assert.Contains(t, res.stdout, "#1 agent-7") | |
| 123 | + | res = runCLI(t, envFor(url), "", "issue", "show", "alpha", "2", "--json").ok(t) | |
| 124 | + | assert.Contains(t, res.stdout, `"comments"`) | |
| 125 | + | assert.Contains(t, res.stdout, `"text": "On it."`) | |
| 126 | + | ||
| 127 | + | res = runCLI(t, envFor(url), "", "issue", "comment", "alpha", "2", "-m", "On it.").ok(t) | |
| 128 | + | assert.Equal(t, "comment 1 added to alpha#2\n", res.stdout) | |
| 129 | + | runCLI(t, envFor(url), "multi\nline\n", "issue", "comment", "alpha", "2", "--text", "-").ok(t) | |
| 130 | + | res = runCLI(t, envFor(url), "", "issue", "comment", "alpha", "2", "-m", "x", "--json").ok(t) | |
| 131 | + | assert.JSONEq(t, `{"id":"1","author":"agent-7","text":"On it.","created_at":"2026-10-02T11:30:00Z"}`, res.stdout) | |
| 132 | + | _, reqs := f.snapshot() | |
| 133 | + | var texts []string | |
| 134 | + | for _, r := range reqs { | |
| 135 | + | if c, ok := r.(*ermite.AddIssueCommentRequest); ok { | |
| 136 | + | assert.EqualValues(t, 2, c.GetNumber()) | |
| 137 | + | texts = append(texts, c.GetText()) | |
| 138 | + | } | |
| 139 | + | } | |
| 140 | + | assert.Equal(t, []string{"On it.", "multi\nline", "x"}, texts) | |
| 141 | + | require.Error(t, runCLI(t, envFor(url), "", "issue", "comment", "alpha", "2").err) | |
| 142 | + | ||
| 143 | + | res = runCLI(t, envFor(url), "", "issue", "close", "alpha", "2").ok(t) | |
| 144 | + | assert.Equal(t, "closed alpha#2 (Crash on start)\n", res.stdout) | |
| 145 | + | res = runCLI(t, envFor(url), "", "issue", "reopen", "alpha", "2", "--json").ok(t) | |
| 146 | + | assert.Contains(t, res.stdout, `"state": "open"`) | |
| 147 | + | ||
| 148 | + | for _, n := range []string{"0", "x", "-1"} { | |
| 149 | + | require.Error(t, runCLI(t, envFor(url), "", "issue", "show", "alpha", n).err, n) | |
| 150 | + | } | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | func TestIssueNeedsTheFeature(t *testing.T) { | |
| 154 | + | f := &fakeAPI{getVersion: func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) { | |
| 155 | + | return &ermite.GetVersionResponse{Version: "v0.4.0", Features: []string{"merge_requests"}}, nil | |
| 156 | + | }} | |
| 157 | + | url := f.serve(t) | |
| 158 | + | for _, args := range [][]string{ | |
| 159 | + | {"issue", "list", "alpha"}, {"issue", "show", "alpha", "1"}, {"issue", "create", "alpha", "--title", "t"}, | |
| 160 | + | {"issue", "comment", "alpha", "1", "-m", "x"}, {"issue", "close", "alpha", "1"}, {"issue", "reopen", "alpha", "1"}, | |
| 161 | + | {"repo", "edit", "alpha", "--issues", "owner"}, | |
| 162 | + | } { | |
| 163 | + | res := runCLI(t, envFor(url), "", args...) | |
| 164 | + | require.Error(t, res.err, "%v", args) | |
| 165 | + | assert.Contains(t, res.err.Error(), "does not support issues", "%v", args) | |
| 166 | + | } | |
| 167 | + | calls, _ := f.snapshot() | |
| 168 | + | for _, c := range calls { | |
| 169 | + | assert.Equal(t, "GetVersion", c) | |
| 170 | + | } | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | func TestRepoEditIssues(t *testing.T) { | |
| 174 | + | updated := &ermite.Repo{Name: "alpha", Settings: &ermite.RepoSettings{Issues: ermite.IssuesMode_ISSUES_MODE_OWNER}} | |
| 175 | + | f := &fakeAPI{getVersion: withIssues, updateRepo: func(*ermite.UpdateRepoRequest) (*ermite.UpdateRepoResponse, error) { | |
| 176 | + | return &ermite.UpdateRepoResponse{Repo: updated}, nil | |
| 177 | + | }} | |
| 178 | + | url := f.serve(t) | |
| 179 | + | for value, want := range map[string]ermite.IssuesMode{ | |
| 180 | + | "off": ermite.IssuesMode_ISSUES_MODE_OFF, "owner": ermite.IssuesMode_ISSUES_MODE_OWNER, "PUBLIC": ermite.IssuesMode_ISSUES_MODE_PUBLIC, | |
| 181 | + | } { | |
| 182 | + | res := runCLI(t, envFor(url), "", "repo", "edit", "alpha", "--issues", value, "--json").ok(t) | |
| 183 | + | assert.Contains(t, res.stdout, `"issues": "owner"`) | |
| 184 | + | _, reqs := f.snapshot() | |
| 185 | + | got := reqs[len(reqs)-1].(*ermite.UpdateRepoRequest) | |
| 186 | + | require.NotNil(t, got.Issues, value) | |
| 187 | + | assert.Equal(t, want, got.GetIssues()) | |
| 188 | + | } | |
| 189 | + | res := runCLI(t, envFor(url), "", "repo", "edit", "alpha", "--issues", "all") | |
| 190 | + | require.Error(t, res.err) | |
| 191 | + | assert.Contains(t, res.err.Error(), "off, owner or public") | |
| 192 | + | res = runCLI(t, envFor(url), "", "repo", "show", "alpha") | |
| 193 | + | require.Error(t, res.err) // getRepo not set: unimplemented | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | func TestRepoShowIssues(t *testing.T) { | |
| 197 | + | r := &ermite.Repo{Name: "alpha", Settings: &ermite.RepoSettings{Issues: ermite.IssuesMode_ISSUES_MODE_OWNER}} | |
| 198 | + | f := &fakeAPI{getRepo: func(*ermite.GetRepoRequest) (*ermite.GetRepoResponse, error) { | |
| 199 | + | return &ermite.GetRepoResponse{Repo: r}, nil | |
| 200 | + | }} | |
| 201 | + | url := f.serve(t) | |
| 202 | + | res := runCLI(t, envFor(url), "", "repo", "show", "alpha").ok(t) | |
| 203 | + | assert.Contains(t, res.stdout, "issues owner\n") | |
| 204 | + | res = runCLI(t, envFor(url), "", "repo", "show", "alpha", "--json").ok(t) | |
| 205 | + | assert.Contains(t, res.stdout, `"issues": "owner"`) | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | func TestTokenCreateIssueWriteNeedsTheFeature(t *testing.T) { | |
| 209 | + | echo := func(r *ermite.CreateTokenRequest) (*ermite.CreateTokenResponse, error) { | |
| 210 | + | return &ermite.CreateTokenResponse{Token: &ermite.Token{Id: "t", Label: r.GetLabel(), Scopes: r.GetScopes()}, Secret: "ermite_S"}, nil | |
| 211 | + | } | |
| 212 | + | f := &fakeAPI{createToken: echo, getVersion: func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) { | |
| 213 | + | return &ermite.GetVersionResponse{Version: "v0.4.0"}, nil | |
| 214 | + | }} | |
| 215 | + | res := runCLI(t, envFor(f.serve(t)), "", "token", "create", "x", "--scope", "issue:write") | |
| 216 | + | require.Error(t, res.err) | |
| 217 | + | assert.Contains(t, res.err.Error(), "does not support issues") | |
| 218 | + | calls, _ := f.snapshot() | |
| 219 | + | assert.NotContains(t, calls, "CreateToken") | |
| 220 | + | ||
| 221 | + | f = &fakeAPI{createToken: echo, getVersion: withIssues} | |
| 222 | + | runCLI(t, envFor(f.serve(t)), "", "token", "create", "x", "--preset", "agent", "--scope", "issue:write").ok(t) | |
| 223 | + | assert.Contains(t, lastCreateToken(t, f).GetScopes(), ermite.Scope_SCOPE_ISSUE_WRITE) | |
| 224 | + | } | |
| @@ -0,0 +1,224 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package app | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "os" | |||
| 7 | + | "path/filepath" | |||
| 8 | + | "testing" | |||
| 9 | + | ||||
| 10 | + | "github.com/stretchr/testify/assert" | |||
| 11 | + | "github.com/stretchr/testify/require" | |||
| 12 | + | ||||
| 13 | + | "bnema.dev/ermite-api/gen/ermite" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | var issueOne = &ermite.Issue{ | |||
| 17 | + | Repo: "alpha", Number: 2, Title: "Crash on start", Body: "It crashes.", State: ermite.IssueState_ISSUE_STATE_OPEN, | |||
| 18 | + | Author: "owner", CreatedAt: ts("2026-10-01T10:00:00Z"), UpdatedAt: ts("2026-10-02T11:30:00Z"), CommentCount: 1, | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | func withIssues(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) { | |||
| 22 | + | return &ermite.GetVersionResponse{Version: "v0.5.0", Features: []string{"issues"}}, nil | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | func TestIssueCreateMapsFlags(t *testing.T) { | |||
| 26 | + | f := &fakeAPI{getVersion: withIssues, createIssue: func(*ermite.CreateIssueRequest) (*ermite.CreateIssueResponse, error) { | |||
| 27 | + | return &ermite.CreateIssueResponse{Issue: issueOne}, nil | |||
| 28 | + | }} | |||
| 29 | + | url := f.serve(t) | |||
| 30 | + | res := runCLI(t, envFor(url), "", "issue", "create", "alpha", "--title", "Crash on start", "--body", "It crashes.").ok(t) | |||
| 31 | + | assert.Equal(t, "opened alpha#2 (Crash on start)\n", res.stdout) | |||
| 32 | + | _, reqs := f.snapshot() | |||
| 33 | + | got := reqs[1].(*ermite.CreateIssueRequest) | |||
| 34 | + | assert.Equal(t, "alpha", got.GetRepo()) | |||
| 35 | + | assert.Equal(t, "Crash on start", got.GetTitle()) | |||
| 36 | + | assert.Equal(t, "It crashes.", got.GetBody()) | |||
| 37 | + | ||||
| 38 | + | runCLI(t, envFor(url), "from\nstdin\n", "issue", "create", "alpha", "--title", "t", "--body", "-").ok(t) | |||
| 39 | + | runCLI(t, envFor(url), "piped\n", "issue", "create", "alpha", "--title", "t", "--body-file", "-").ok(t) | |||
| 40 | + | file := filepath.Join(t.TempDir(), "body.txt") | |||
| 41 | + | require.NoError(t, os.WriteFile(file, []byte("from file\n"), 0o600)) | |||
| 42 | + | runCLI(t, envFor(url), "", "issue", "create", "alpha", "--title", "t", "--body-file", file).ok(t) | |||
| 43 | + | _, reqs = f.snapshot() | |||
| 44 | + | var bodies []string | |||
| 45 | + | for _, r := range reqs { | |||
| 46 | + | if c, ok := r.(*ermite.CreateIssueRequest); ok { | |||
| 47 | + | bodies = append(bodies, c.GetBody()) | |||
| 48 | + | } | |||
| 49 | + | } | |||
| 50 | + | assert.Equal(t, []string{"It crashes.", "from\nstdin", "piped", "from file"}, bodies) | |||
| 51 | + | ||||
| 52 | + | res = runCLI(t, envFor(url), "", "issue", "create", "alpha", "--title", "t", "--json").ok(t) | |||
| 53 | + | assert.JSONEq(t, `{"repo":"alpha","number":2,"title":"Crash on start","body":"It crashes.","state":"open","author":"owner","created_at":"2026-10-01T10:00:00Z","updated_at":"2026-10-02T11:30:00Z","comment_count":1}`, res.stdout) | |||
| 54 | + | ||||
| 55 | + | for _, args := range [][]string{ | |||
| 56 | + | {"issue", "create", "alpha"}, | |||
| 57 | + | {"issue", "create", "alpha", "--title", "t", "--body", "a", "--body-file", file}, | |||
| 58 | + | } { | |||
| 59 | + | require.Error(t, runCLI(t, envFor(url), "", args...).err, "%v", args) | |||
| 60 | + | } | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | func TestIssueListTableJSONAndPages(t *testing.T) { | |||
| 64 | + | closed := &ermite.Issue{Repo: "alpha", Number: 1, Title: "Old", State: ermite.IssueState_ISSUE_STATE_CLOSED, Author: "bot", UpdatedAt: ts("2026-09-01T08:00:00Z")} | |||
| 65 | + | f := &fakeAPI{getVersion: withIssues, listIssues: func(r *ermite.ListIssuesRequest) (*ermite.ListIssuesResponse, error) { | |||
| 66 | + | if r.GetPageToken() == "" { | |||
| 67 | + | return &ermite.ListIssuesResponse{Issues: []*ermite.Issue{issueOne}, NextPageToken: "p2"}, nil | |||
| 68 | + | } | |||
| 69 | + | return &ermite.ListIssuesResponse{Issues: []*ermite.Issue{closed}}, nil | |||
| 70 | + | }} | |||
| 71 | + | url := f.serve(t) | |||
| 72 | + | res := runCLI(t, envFor(url), "", "issue", "list", "alpha", "--state", "all").ok(t) | |||
| 73 | + | assert.Regexp(t, `NUMBER\s+STATE\s+AUTHOR\s+COMMENTS\s+UPDATED\s+TITLE\n#2\s+open\s+owner\s+1\s+2026-10-02 11:30\s+Crash on start\n#1\s+closed\s+bot\s+0\s+2026-09-01 08:00\s+Old\n`, res.stdout) | |||
| 74 | + | _, reqs := f.snapshot() | |||
| 75 | + | require.Len(t, reqs, 3) // GetVersion, two pages | |||
| 76 | + | assert.Equal(t, ermite.IssueState_ISSUE_STATE_UNSPECIFIED, reqs[1].(*ermite.ListIssuesRequest).GetState()) | |||
| 77 | + | assert.Equal(t, "p2", reqs[2].(*ermite.ListIssuesRequest).GetPageToken()) | |||
| 78 | + | ||||
| 79 | + | runCLI(t, envFor(url), "", "issue", "list", "alpha").ok(t) | |||
| 80 | + | _, reqs = f.snapshot() | |||
| 81 | + | assert.Equal(t, ermite.IssueState_ISSUE_STATE_OPEN, reqs[len(reqs)-2].(*ermite.ListIssuesRequest).GetState()) | |||
| 82 | + | ||||
| 83 | + | res = runCLI(t, envFor(url), "", "issue", "list", "alpha", "--json").ok(t) | |||
| 84 | + | assert.Contains(t, res.stdout, `"state": "closed"`) | |||
| 85 | + | ||||
| 86 | + | require.Error(t, runCLI(t, envFor(url), "", "issue", "list", "alpha", "--state", "merged").err) | |||
| 87 | + | ||||
| 88 | + | empty := &fakeAPI{getVersion: withIssues, listIssues: func(*ermite.ListIssuesRequest) (*ermite.ListIssuesResponse, error) { | |||
| 89 | + | return &ermite.ListIssuesResponse{}, nil | |||
| 90 | + | }} | |||
| 91 | + | res = runCLI(t, envFor(empty.serve(t)), "", "issue", "list", "alpha", "--json").ok(t) | |||
| 92 | + | assert.Equal(t, "[]\n", res.stdout) | |||
| 93 | + | res = runCLI(t, envFor(empty.serve(t)), "", "issue", "list", "alpha").ok(t) | |||
| 94 | + | assert.Equal(t, "ermite: no issues\n", res.stderr) | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | func TestIssueShowCommentCloseReopen(t *testing.T) { | |||
| 98 | + | cmt := &ermite.IssueComment{Id: "1", Author: "agent-7", Text: "On it.", CreatedAt: ts("2026-10-02T11:30:00Z")} | |||
| 99 | + | reopened := &ermite.Issue{Repo: "alpha", Number: 2, Title: "Crash on start", State: ermite.IssueState_ISSUE_STATE_OPEN} | |||
| 100 | + | f := &fakeAPI{ | |||
| 101 | + | getVersion: withIssues, | |||
| 102 | + | getIssue: func(*ermite.GetIssueRequest) (*ermite.GetIssueResponse, error) { | |||
| 103 | + | return &ermite.GetIssueResponse{Issue: issueOne, Comments: []*ermite.IssueComment{cmt}}, nil | |||
| 104 | + | }, | |||
| 105 | + | addIssueCmt: func(*ermite.AddIssueCommentRequest) (*ermite.AddIssueCommentResponse, error) { | |||
| 106 | + | return &ermite.AddIssueCommentResponse{Comment: cmt}, nil | |||
| 107 | + | }, | |||
| 108 | + | closeIssue: func(*ermite.CloseIssueRequest) (*ermite.CloseIssueResponse, error) { | |||
| 109 | + | c := &ermite.Issue{Repo: "alpha", Number: 2, Title: "Crash on start", State: ermite.IssueState_ISSUE_STATE_CLOSED} | |||
| 110 | + | return &ermite.CloseIssueResponse{Issue: c}, nil | |||
| 111 | + | }, | |||
| 112 | + | reopenIssue: func(*ermite.ReopenIssueRequest) (*ermite.ReopenIssueResponse, error) { | |||
| 113 | + | return &ermite.ReopenIssueResponse{Issue: reopened}, nil | |||
| 114 | + | }, | |||
| 115 | + | } | |||
| 116 | + | url := f.serve(t) | |||
| 117 | + | ||||
| 118 | + | res := runCLI(t, envFor(url), "", "issue", "show", "alpha", "#2").ok(t) | |||
| 119 | + | assert.Contains(t, res.stdout, "issue alpha#2") | |||
| 120 | + | assert.Contains(t, res.stdout, "It crashes.") | |||
| 121 | + | assert.Contains(t, res.stdout, "comments (1)") | |||
| 122 | + | assert.Contains(t, res.stdout, "#1 agent-7") | |||
| 123 | + | res = runCLI(t, envFor(url), "", "issue", "show", "alpha", "2", "--json").ok(t) | |||
| 124 | + | assert.Contains(t, res.stdout, `"comments"`) | |||
| 125 | + | assert.Contains(t, res.stdout, `"text": "On it."`) | |||
| 126 | + | ||||
| 127 | + | res = runCLI(t, envFor(url), "", "issue", "comment", "alpha", "2", "-m", "On it.").ok(t) | |||
| 128 | + | assert.Equal(t, "comment 1 added to alpha#2\n", res.stdout) | |||
| 129 | + | runCLI(t, envFor(url), "multi\nline\n", "issue", "comment", "alpha", "2", "--text", "-").ok(t) | |||
| 130 | + | res = runCLI(t, envFor(url), "", "issue", "comment", "alpha", "2", "-m", "x", "--json").ok(t) | |||
| 131 | + | assert.JSONEq(t, `{"id":"1","author":"agent-7","text":"On it.","created_at":"2026-10-02T11:30:00Z"}`, res.stdout) | |||
| 132 | + | _, reqs := f.snapshot() | |||
| 133 | + | var texts []string | |||
| 134 | + | for _, r := range reqs { | |||
| 135 | + | if c, ok := r.(*ermite.AddIssueCommentRequest); ok { | |||
| 136 | + | assert.EqualValues(t, 2, c.GetNumber()) | |||
| 137 | + | texts = append(texts, c.GetText()) | |||
| 138 | + | } | |||
| 139 | + | } | |||
| 140 | + | assert.Equal(t, []string{"On it.", "multi\nline", "x"}, texts) | |||
| 141 | + | require.Error(t, runCLI(t, envFor(url), "", "issue", "comment", "alpha", "2").err) | |||
| 142 | + | ||||
| 143 | + | res = runCLI(t, envFor(url), "", "issue", "close", "alpha", "2").ok(t) | |||
| 144 | + | assert.Equal(t, "closed alpha#2 (Crash on start)\n", res.stdout) | |||
| 145 | + | res = runCLI(t, envFor(url), "", "issue", "reopen", "alpha", "2", "--json").ok(t) | |||
| 146 | + | assert.Contains(t, res.stdout, `"state": "open"`) | |||
| 147 | + | ||||
| 148 | + | for _, n := range []string{"0", "x", "-1"} { | |||
| 149 | + | require.Error(t, runCLI(t, envFor(url), "", "issue", "show", "alpha", n).err, n) | |||
| 150 | + | } | |||
| 151 | + | } | |||
| 152 | + | ||||
| 153 | + | func TestIssueNeedsTheFeature(t *testing.T) { | |||
| 154 | + | f := &fakeAPI{getVersion: func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) { | |||
| 155 | + | return &ermite.GetVersionResponse{Version: "v0.4.0", Features: []string{"merge_requests"}}, nil | |||
| 156 | + | }} | |||
| 157 | + | url := f.serve(t) | |||
| 158 | + | for _, args := range [][]string{ | |||
| 159 | + | {"issue", "list", "alpha"}, {"issue", "show", "alpha", "1"}, {"issue", "create", "alpha", "--title", "t"}, | |||
| 160 | + | {"issue", "comment", "alpha", "1", "-m", "x"}, {"issue", "close", "alpha", "1"}, {"issue", "reopen", "alpha", "1"}, | |||
| 161 | + | {"repo", "edit", "alpha", "--issues", "owner"}, | |||
| 162 | + | } { | |||
| 163 | + | res := runCLI(t, envFor(url), "", args...) | |||
| 164 | + | require.Error(t, res.err, "%v", args) | |||
| 165 | + | assert.Contains(t, res.err.Error(), "does not support issues", "%v", args) | |||
| 166 | + | } | |||
| 167 | + | calls, _ := f.snapshot() | |||
| 168 | + | for _, c := range calls { | |||
| 169 | + | assert.Equal(t, "GetVersion", c) | |||
| 170 | + | } | |||
| 171 | + | } | |||
| 172 | + | ||||
| 173 | + | func TestRepoEditIssues(t *testing.T) { | |||
| 174 | + | updated := &ermite.Repo{Name: "alpha", Settings: &ermite.RepoSettings{Issues: ermite.IssuesMode_ISSUES_MODE_OWNER}} | |||
| 175 | + | f := &fakeAPI{getVersion: withIssues, updateRepo: func(*ermite.UpdateRepoRequest) (*ermite.UpdateRepoResponse, error) { | |||
| 176 | + | return &ermite.UpdateRepoResponse{Repo: updated}, nil | |||
| 177 | + | }} | |||
| 178 | + | url := f.serve(t) | |||
| 179 | + | for value, want := range map[string]ermite.IssuesMode{ | |||
| 180 | + | "off": ermite.IssuesMode_ISSUES_MODE_OFF, "owner": ermite.IssuesMode_ISSUES_MODE_OWNER, "PUBLIC": ermite.IssuesMode_ISSUES_MODE_PUBLIC, | |||
| 181 | + | } { | |||
| 182 | + | res := runCLI(t, envFor(url), "", "repo", "edit", "alpha", "--issues", value, "--json").ok(t) | |||
| 183 | + | assert.Contains(t, res.stdout, `"issues": "owner"`) | |||
| 184 | + | _, reqs := f.snapshot() | |||
| 185 | + | got := reqs[len(reqs)-1].(*ermite.UpdateRepoRequest) | |||
| 186 | + | require.NotNil(t, got.Issues, value) | |||
| 187 | + | assert.Equal(t, want, got.GetIssues()) | |||
| 188 | + | } | |||
| 189 | + | res := runCLI(t, envFor(url), "", "repo", "edit", "alpha", "--issues", "all") | |||
| 190 | + | require.Error(t, res.err) | |||
| 191 | + | assert.Contains(t, res.err.Error(), "off, owner or public") | |||
| 192 | + | res = runCLI(t, envFor(url), "", "repo", "show", "alpha") | |||
| 193 | + | require.Error(t, res.err) // getRepo not set: unimplemented | |||
| 194 | + | } | |||
| 195 | + | ||||
| 196 | + | func TestRepoShowIssues(t *testing.T) { | |||
| 197 | + | r := &ermite.Repo{Name: "alpha", Settings: &ermite.RepoSettings{Issues: ermite.IssuesMode_ISSUES_MODE_OWNER}} | |||
| 198 | + | f := &fakeAPI{getRepo: func(*ermite.GetRepoRequest) (*ermite.GetRepoResponse, error) { | |||
| 199 | + | return &ermite.GetRepoResponse{Repo: r}, nil | |||
| 200 | + | }} | |||
| 201 | + | url := f.serve(t) | |||
| 202 | + | res := runCLI(t, envFor(url), "", "repo", "show", "alpha").ok(t) | |||
| 203 | + | assert.Contains(t, res.stdout, "issues owner\n") | |||
| 204 | + | res = runCLI(t, envFor(url), "", "repo", "show", "alpha", "--json").ok(t) | |||
| 205 | + | assert.Contains(t, res.stdout, `"issues": "owner"`) | |||
| 206 | + | } | |||
| 207 | + | ||||
| 208 | + | func TestTokenCreateIssueWriteNeedsTheFeature(t *testing.T) { | |||
| 209 | + | echo := func(r *ermite.CreateTokenRequest) (*ermite.CreateTokenResponse, error) { | |||
| 210 | + | return &ermite.CreateTokenResponse{Token: &ermite.Token{Id: "t", Label: r.GetLabel(), Scopes: r.GetScopes()}, Secret: "ermite_S"}, nil | |||
| 211 | + | } | |||
| 212 | + | f := &fakeAPI{createToken: echo, getVersion: func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) { | |||
| 213 | + | return &ermite.GetVersionResponse{Version: "v0.4.0"}, nil | |||
| 214 | + | }} | |||
| 215 | + | res := runCLI(t, envFor(f.serve(t)), "", "token", "create", "x", "--scope", "issue:write") | |||
| 216 | + | require.Error(t, res.err) | |||
| 217 | + | assert.Contains(t, res.err.Error(), "does not support issues") | |||
| 218 | + | calls, _ := f.snapshot() | |||
| 219 | + | assert.NotContains(t, calls, "CreateToken") | |||
| 220 | + | ||||
| 221 | + | f = &fakeAPI{createToken: echo, getVersion: withIssues} | |||
| 222 | + | runCLI(t, envFor(f.serve(t)), "", "token", "create", "x", "--preset", "agent", "--scope", "issue:write").ok(t) | |||
| 223 | + | assert.Contains(t, lastCreateToken(t, f).GetScopes(), ermite.Scope_SCOPE_ISSUE_WRITE) | |||
| 224 | + | } | |||
M internal/app/cli_repo.go +61 -6
| @@ -36,6 +36,8 @@ type jsonRepo struct { | |||
| 36 | 36 | // MergeModes are the allowed merge modes ("fast-forward", "squash"). | |
| 37 | 37 | MergeModes []string `json:"merge_modes"` | |
| 38 | 38 | FailingChecksBlock bool `json:"failing_checks_block"` | |
| 39 | + | // Issues is the issue mode ("off", "owner" or "public"). | |
| 40 | + | Issues string `json:"issues"` | |
| 39 | 41 | } | |
| 40 | 42 | ||
| 41 | 43 | func toJSONRepo(r *ermite.Repo) jsonRepo { | |
| @@ -46,6 +48,7 @@ func toJSONRepo(r *ermite.Repo) jsonRepo { | |||
| 46 | 48 | return jsonRepo{ | |
| 47 | 49 | MergeModes: modes, | |
| 48 | 50 | FailingChecksBlock: r.GetSettings().GetFailingChecksBlock(), | |
| 51 | + | Issues: issuesModeName(r.GetSettings().GetIssues()), | |
| 49 | 52 | Name: r.GetName(), | |
| 50 | 53 | Description: r.GetDescription(), | |
| 51 | 54 | Topics: append([]string{}, r.GetTopics()...), | |
| @@ -56,6 +59,36 @@ func toJSONRepo(r *ermite.Repo) jsonRepo { | |||
| 56 | 59 | } | |
| 57 | 60 | } | |
| 58 | 61 | ||
| 62 | + | const ( | |
| 63 | + | issuesOff = "off" | |
| 64 | + | issuesOwner = "owner" | |
| 65 | + | issuesPublic = "public" | |
| 66 | + | ) | |
| 67 | + | ||
| 68 | + | // issuesModeName names a mode; an unset one is off, the default. | |
| 69 | + | func issuesModeName(m ermite.IssuesMode) string { | |
| 70 | + | switch m { | |
| 71 | + | case ermite.IssuesMode_ISSUES_MODE_OWNER: | |
| 72 | + | return issuesOwner | |
| 73 | + | case ermite.IssuesMode_ISSUES_MODE_PUBLIC: | |
| 74 | + | return issuesPublic | |
| 75 | + | case ermite.IssuesMode_ISSUES_MODE_OFF, ermite.IssuesMode_ISSUES_MODE_UNSPECIFIED: | |
| 76 | + | } | |
| 77 | + | return issuesOff | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | func parseIssuesMode(s string) (ermite.IssuesMode, error) { | |
| 81 | + | switch strings.ToLower(strings.TrimSpace(s)) { | |
| 82 | + | case issuesOff: | |
| 83 | + | return ermite.IssuesMode_ISSUES_MODE_OFF, nil | |
| 84 | + | case issuesOwner: | |
| 85 | + | return ermite.IssuesMode_ISSUES_MODE_OWNER, nil | |
| 86 | + | case issuesPublic: | |
| 87 | + | return ermite.IssuesMode_ISSUES_MODE_PUBLIC, nil | |
| 88 | + | } | |
| 89 | + | return 0, fmt.Errorf("invalid issues mode %q: use %s, %s or %s", s, issuesOff, issuesOwner, issuesPublic) | |
| 90 | + | } | |
| 91 | + | ||
| 59 | 92 | func visibilityName(v ermite.Visibility) string { | |
| 60 | 93 | switch v { | |
| 61 | 94 | case ermite.Visibility_VISIBILITY_PUBLIC: | |
| @@ -82,7 +115,8 @@ const jsonRepoHelp = ` | |||
| 82 | 115 | JSON fields of a repository (--json): name, description, visibility ("public" or | |
| 83 | 116 | "private"), default_branch, created_at and pushed_at (RFC 3339 UTC; pushed_at is | |
| 84 | 117 | null until the first push), merge_modes (array of the allowed merge modes, "fast-forward" and/or | |
| 85 | - | "squash") and failing_checks_block (whether a failing check prevents a merge).` | |
| 118 | + | "squash"), failing_checks_block (whether a failing check prevents a merge) and issues (the issue | |
| 119 | + | mode: "off", "owner" or "public").` | |
| 86 | 120 | ||
| 87 | 121 | func newRepoCmd(env *cliEnv) *cobra.Command { | |
| 88 | 122 | repo := &cobra.Command{ | |
| @@ -195,6 +229,7 @@ func printRepo(w io.Writer, r *ermite.Repo) error { | |||
| 195 | 229 | {"last push", shortTime(r.GetPushedAt(), "never")}, | |
| 196 | 230 | {"merge modes", orDash(strings.Join(modes, ", "))}, | |
| 197 | 231 | {"failing checks block", strconv.FormatBool(r.GetSettings().GetFailingChecksBlock())}, | |
| 232 | + | {"issues", issuesModeName(r.GetSettings().GetIssues())}, | |
| 198 | 233 | }) | |
| 199 | 234 | } | |
| 200 | 235 | ||
| @@ -274,13 +309,16 @@ func newRepoEditCmd(env *cliEnv) *cobra.Command { | |||
| 274 | 309 | var description, visibility, defaultBranch string | |
| 275 | 310 | var topics, mergeModes []string | |
| 276 | 311 | var failingChecksBlock bool | |
| 312 | + | var issues string | |
| 277 | 313 | cmd := &cobra.Command{ | |
| 278 | 314 | Use: "edit <name>", | |
| 279 | - | Short: "Change a repository's description, topics, visibility, default branch or merge settings", | |
| 315 | + | Short: "Change a repository's description, topics, visibility, default branch, merge settings or issues", | |
| 280 | 316 | Long: "Change the fields given as flags and leave the others alone; --description \"\" clears the\n" + | |
| 281 | 317 | "description and --topics \"\" clears the topics. The default branch must already exist. --merge-modes lists the modes a merge request\n" + | |
| 282 | 318 | "may be merged with (fast-forward, squash; default for a new repository: both), and\n" + | |
| 283 | 319 | "--failing-checks-block says whether a failing check prevents a merge (a failed or unfinished CI check of the head; see README, CI).\n" + | |
| 320 | + | "--issues sets who may use issues: off (default), owner (the owner and tokens with issue:write) or\n" + | |
| 321 | + | "public (not available yet; the server refuses it). Needs a server with the issues feature.\n" + | |
| 284 | 322 | "Needs the repo:admin scope." + jsonRepoHelp, | |
| 285 | 323 | Args: cobra.ExactArgs(1), | |
| 286 | 324 | ValidArgsFunction: completeRepoNames(env), | |
| @@ -291,13 +329,17 @@ func newRepoEditCmd(env *cliEnv) *cobra.Command { | |||
| 291 | 329 | cmd.Flags().StringVar(&defaultBranch, "default-branch", "", "short name of the new default branch") | |
| 292 | 330 | cmd.Flags().StringSliceVar(&mergeModes, "merge-modes", nil, "allowed merge modes, comma-separated: fast-forward, squash") | |
| 293 | 331 | cmd.Flags().BoolVar(&failingChecksBlock, "failing-checks-block", true, "a failing check prevents a merge (--failing-checks-block=false to allow it)") | |
| 332 | + | cmd.Flags().StringVar(&issues, "issues", "", "issue mode: off, owner or public") | |
| 333 | + | _ = cmd.RegisterFlagCompletionFunc("issues", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { | |
| 334 | + | return []string{issuesOff, issuesOwner, issuesPublic}, cobra.ShellCompDirectiveNoFileComp | |
| 335 | + | }) | |
| 294 | 336 | _ = cmd.RegisterFlagCompletionFunc("visibility", visibilityCompletion) | |
| 295 | 337 | _ = cmd.RegisterFlagCompletionFunc("merge-modes", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { | |
| 296 | 338 | return []string{modeFastForward, modeSquash}, cobra.ShellCompDirectiveNoFileComp | |
| 297 | 339 | }) | |
| 298 | 340 | asJSON := addJSONFlag(cmd) | |
| 299 | 341 | cmd.RunE = func(cmd *cobra.Command, args []string) error { | |
| 300 | - | req, err := buildUpdateRepo(cmd, args[0], description, topics, visibility, defaultBranch, mergeModes, failingChecksBlock) | |
| 342 | + | req, err := buildUpdateRepo(cmd, args[0], description, topics, visibility, defaultBranch, mergeModes, failingChecksBlock, issues) | |
| 301 | 343 | if err != nil { | |
| 302 | 344 | return err | |
| 303 | 345 | } | |
| @@ -305,6 +347,11 @@ func newRepoEditCmd(env *cliEnv) *cobra.Command { | |||
| 305 | 347 | if err != nil { | |
| 306 | 348 | return err | |
| 307 | 349 | } | |
| 350 | + | if req.Issues != nil { | |
| 351 | + | if err := requireIssues(env.ctx(cmd), c); err != nil { | |
| 352 | + | return err | |
| 353 | + | } | |
| 354 | + | } | |
| 308 | 355 | res, err := c.Repos().UpdateRepo(env.ctx(cmd), connect.NewRequest(req)) | |
| 309 | 356 | if err != nil { | |
| 310 | 357 | return c.Describe(err) | |
| @@ -316,7 +363,7 @@ func newRepoEditCmd(env *cliEnv) *cobra.Command { | |||
| 316 | 363 | ||
| 317 | 364 | // buildUpdateRepo maps the flags of `repo edit` to the request: only the flags | |
| 318 | 365 | // the user passed go into the update mask. | |
| 319 | - | func buildUpdateRepo(cmd *cobra.Command, name, description string, topics []string, visibility, defaultBranch string, mergeModes []string, failingChecksBlock bool) (*ermite.UpdateRepoRequest, error) { | |
| 366 | + | func buildUpdateRepo(cmd *cobra.Command, name, description string, topics []string, visibility, defaultBranch string, mergeModes []string, failingChecksBlock bool, issues string) (*ermite.UpdateRepoRequest, error) { | |
| 320 | 367 | req := &ermite.UpdateRepoRequest{Repo: name} | |
| 321 | 368 | var paths []string | |
| 322 | 369 | if cmd.Flags().Changed("description") { | |
| @@ -365,8 +412,16 @@ func buildUpdateRepo(cmd *cobra.Command, name, description string, topics []stri | |||
| 365 | 412 | req.Settings.FailingChecksBlock = failingChecksBlock | |
| 366 | 413 | paths = append(paths, "settings.failing_checks_block") | |
| 367 | 414 | } | |
| 368 | - | if len(paths) == 0 { | |
| 369 | - | return nil, errors.New("nothing to change: give at least one of --description, --topics, --visibility, --default-branch, --merge-modes, --failing-checks-block") | |
| 415 | + | if cmd.Flags().Changed("issues") { | |
| 416 | + | mode, err := parseIssuesMode(issues) | |
| 417 | + | if err != nil { | |
| 418 | + | return nil, err | |
| 419 | + | } | |
| 420 | + | // The contract keeps issues out of the update mask. | |
| 421 | + | req.Issues = &mode | |
| 422 | + | } | |
| 423 | + | if len(paths) == 0 && req.Issues == nil { | |
| 424 | + | return nil, errors.New("nothing to change: give at least one of --description, --topics, --visibility, --default-branch, --merge-modes, --failing-checks-block, --issues") | |
| 370 | 425 | } | |
| 371 | 426 | req.UpdateMask = &fieldmaskpb.FieldMask{Paths: paths} | |
| 372 | 427 | return req, nil | |
| @@ -36,6 +36,8 @@ type jsonRepo struct { | |||||
| 36 | // MergeModes are the allowed merge modes ("fast-forward", "squash"). | 36 | // MergeModes are the allowed merge modes ("fast-forward", "squash"). | ||
| 37 | MergeModes []string `json:"merge_modes"` | 37 | MergeModes []string `json:"merge_modes"` | ||
| 38 | FailingChecksBlock bool `json:"failing_checks_block"` | 38 | FailingChecksBlock bool `json:"failing_checks_block"` | ||
| 39 | + | // Issues is the issue mode ("off", "owner" or "public"). | |||
| 40 | + | Issues string `json:"issues"` | |||
| 39 | } | 41 | } | ||
| 40 | 42 | ||||
| 41 | func toJSONRepo(r *ermite.Repo) jsonRepo { | 43 | func toJSONRepo(r *ermite.Repo) jsonRepo { | ||
| @@ -46,6 +48,7 @@ func toJSONRepo(r *ermite.Repo) jsonRepo { | |||||
| 46 | return jsonRepo{ | 48 | return jsonRepo{ | ||
| 47 | MergeModes: modes, | 49 | MergeModes: modes, | ||
| 48 | FailingChecksBlock: r.GetSettings().GetFailingChecksBlock(), | 50 | FailingChecksBlock: r.GetSettings().GetFailingChecksBlock(), | ||
| 51 | + | Issues: issuesModeName(r.GetSettings().GetIssues()), | |||
| 49 | Name: r.GetName(), | 52 | Name: r.GetName(), | ||
| 50 | Description: r.GetDescription(), | 53 | Description: r.GetDescription(), | ||
| 51 | Topics: append([]string{}, r.GetTopics()...), | 54 | Topics: append([]string{}, r.GetTopics()...), | ||
| @@ -56,6 +59,36 @@ func toJSONRepo(r *ermite.Repo) jsonRepo { | |||||
| 56 | } | 59 | } | ||
| 57 | } | 60 | } | ||
| 58 | 61 | ||||
| 62 | + | const ( | |||
| 63 | + | issuesOff = "off" | |||
| 64 | + | issuesOwner = "owner" | |||
| 65 | + | issuesPublic = "public" | |||
| 66 | + | ) | |||
| 67 | + | ||||
| 68 | + | // issuesModeName names a mode; an unset one is off, the default. | |||
| 69 | + | func issuesModeName(m ermite.IssuesMode) string { | |||
| 70 | + | switch m { | |||
| 71 | + | case ermite.IssuesMode_ISSUES_MODE_OWNER: | |||
| 72 | + | return issuesOwner | |||
| 73 | + | case ermite.IssuesMode_ISSUES_MODE_PUBLIC: | |||
| 74 | + | return issuesPublic | |||
| 75 | + | case ermite.IssuesMode_ISSUES_MODE_OFF, ermite.IssuesMode_ISSUES_MODE_UNSPECIFIED: | |||
| 76 | + | } | |||
| 77 | + | return issuesOff | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | func parseIssuesMode(s string) (ermite.IssuesMode, error) { | |||
| 81 | + | switch strings.ToLower(strings.TrimSpace(s)) { | |||
| 82 | + | case issuesOff: | |||
| 83 | + | return ermite.IssuesMode_ISSUES_MODE_OFF, nil | |||
| 84 | + | case issuesOwner: | |||
| 85 | + | return ermite.IssuesMode_ISSUES_MODE_OWNER, nil | |||
| 86 | + | case issuesPublic: | |||
| 87 | + | return ermite.IssuesMode_ISSUES_MODE_PUBLIC, nil | |||
| 88 | + | } | |||
| 89 | + | return 0, fmt.Errorf("invalid issues mode %q: use %s, %s or %s", s, issuesOff, issuesOwner, issuesPublic) | |||
| 90 | + | } | |||
| 91 | + | ||||
| 59 | func visibilityName(v ermite.Visibility) string { | 92 | func visibilityName(v ermite.Visibility) string { | ||
| 60 | switch v { | 93 | switch v { | ||
| 61 | case ermite.Visibility_VISIBILITY_PUBLIC: | 94 | case ermite.Visibility_VISIBILITY_PUBLIC: | ||
| @@ -82,7 +115,8 @@ const jsonRepoHelp = ` | |||||
| 82 | JSON fields of a repository (--json): name, description, visibility ("public" or | 115 | JSON fields of a repository (--json): name, description, visibility ("public" or | ||
| 83 | "private"), default_branch, created_at and pushed_at (RFC 3339 UTC; pushed_at is | 116 | "private"), default_branch, created_at and pushed_at (RFC 3339 UTC; pushed_at is | ||
| 84 | null until the first push), merge_modes (array of the allowed merge modes, "fast-forward" and/or | 117 | null until the first push), merge_modes (array of the allowed merge modes, "fast-forward" and/or | ||
| 85 | - | "squash") and failing_checks_block (whether a failing check prevents a merge).` | 118 | + | "squash"), failing_checks_block (whether a failing check prevents a merge) and issues (the issue |
| 119 | + | mode: "off", "owner" or "public").` | |||
| 86 | 120 | ||||
| 87 | func newRepoCmd(env *cliEnv) *cobra.Command { | 121 | func newRepoCmd(env *cliEnv) *cobra.Command { | ||
| 88 | repo := &cobra.Command{ | 122 | repo := &cobra.Command{ | ||
| @@ -195,6 +229,7 @@ func printRepo(w io.Writer, r *ermite.Repo) error { | |||||
| 195 | {"last push", shortTime(r.GetPushedAt(), "never")}, | 229 | {"last push", shortTime(r.GetPushedAt(), "never")}, | ||
| 196 | {"merge modes", orDash(strings.Join(modes, ", "))}, | 230 | {"merge modes", orDash(strings.Join(modes, ", "))}, | ||
| 197 | {"failing checks block", strconv.FormatBool(r.GetSettings().GetFailingChecksBlock())}, | 231 | {"failing checks block", strconv.FormatBool(r.GetSettings().GetFailingChecksBlock())}, | ||
| 232 | + | {"issues", issuesModeName(r.GetSettings().GetIssues())}, | |||
| 198 | }) | 233 | }) | ||
| 199 | } | 234 | } | ||
| 200 | 235 | ||||
| @@ -274,13 +309,16 @@ func newRepoEditCmd(env *cliEnv) *cobra.Command { | |||||
| 274 | var description, visibility, defaultBranch string | 309 | var description, visibility, defaultBranch string | ||
| 275 | var topics, mergeModes []string | 310 | var topics, mergeModes []string | ||
| 276 | var failingChecksBlock bool | 311 | var failingChecksBlock bool | ||
| 312 | + | var issues string | |||
| 277 | cmd := &cobra.Command{ | 313 | cmd := &cobra.Command{ | ||
| 278 | Use: "edit <name>", | 314 | Use: "edit <name>", | ||
| 279 | - | Short: "Change a repository's description, topics, visibility, default branch or merge settings", | 315 | + | Short: "Change a repository's description, topics, visibility, default branch, merge settings or issues", |
| 280 | Long: "Change the fields given as flags and leave the others alone; --description \"\" clears the\n" + | 316 | Long: "Change the fields given as flags and leave the others alone; --description \"\" clears the\n" + | ||
| 281 | "description and --topics \"\" clears the topics. The default branch must already exist. --merge-modes lists the modes a merge request\n" + | 317 | "description and --topics \"\" clears the topics. The default branch must already exist. --merge-modes lists the modes a merge request\n" + | ||
| 282 | "may be merged with (fast-forward, squash; default for a new repository: both), and\n" + | 318 | "may be merged with (fast-forward, squash; default for a new repository: both), and\n" + | ||
| 283 | "--failing-checks-block says whether a failing check prevents a merge (a failed or unfinished CI check of the head; see README, CI).\n" + | 319 | "--failing-checks-block says whether a failing check prevents a merge (a failed or unfinished CI check of the head; see README, CI).\n" + | ||
| 320 | + | "--issues sets who may use issues: off (default), owner (the owner and tokens with issue:write) or\n" + | |||
| 321 | + | "public (not available yet; the server refuses it). Needs a server with the issues feature.\n" + | |||
| 284 | "Needs the repo:admin scope." + jsonRepoHelp, | 322 | "Needs the repo:admin scope." + jsonRepoHelp, | ||
| 285 | Args: cobra.ExactArgs(1), | 323 | Args: cobra.ExactArgs(1), | ||
| 286 | ValidArgsFunction: completeRepoNames(env), | 324 | ValidArgsFunction: completeRepoNames(env), | ||
| @@ -291,13 +329,17 @@ func newRepoEditCmd(env *cliEnv) *cobra.Command { | |||||
| 291 | cmd.Flags().StringVar(&defaultBranch, "default-branch", "", "short name of the new default branch") | 329 | cmd.Flags().StringVar(&defaultBranch, "default-branch", "", "short name of the new default branch") | ||
| 292 | cmd.Flags().StringSliceVar(&mergeModes, "merge-modes", nil, "allowed merge modes, comma-separated: fast-forward, squash") | 330 | cmd.Flags().StringSliceVar(&mergeModes, "merge-modes", nil, "allowed merge modes, comma-separated: fast-forward, squash") | ||
| 293 | cmd.Flags().BoolVar(&failingChecksBlock, "failing-checks-block", true, "a failing check prevents a merge (--failing-checks-block=false to allow it)") | 331 | cmd.Flags().BoolVar(&failingChecksBlock, "failing-checks-block", true, "a failing check prevents a merge (--failing-checks-block=false to allow it)") | ||
| 332 | + | cmd.Flags().StringVar(&issues, "issues", "", "issue mode: off, owner or public") | |||
| 333 | + | _ = cmd.RegisterFlagCompletionFunc("issues", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { | |||
| 334 | + | return []string{issuesOff, issuesOwner, issuesPublic}, cobra.ShellCompDirectiveNoFileComp | |||
| 335 | + | }) | |||
| 294 | _ = cmd.RegisterFlagCompletionFunc("visibility", visibilityCompletion) | 336 | _ = cmd.RegisterFlagCompletionFunc("visibility", visibilityCompletion) | ||
| 295 | _ = cmd.RegisterFlagCompletionFunc("merge-modes", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { | 337 | _ = cmd.RegisterFlagCompletionFunc("merge-modes", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { | ||
| 296 | return []string{modeFastForward, modeSquash}, cobra.ShellCompDirectiveNoFileComp | 338 | return []string{modeFastForward, modeSquash}, cobra.ShellCompDirectiveNoFileComp | ||
| 297 | }) | 339 | }) | ||
| 298 | asJSON := addJSONFlag(cmd) | 340 | asJSON := addJSONFlag(cmd) | ||
| 299 | cmd.RunE = func(cmd *cobra.Command, args []string) error { | 341 | cmd.RunE = func(cmd *cobra.Command, args []string) error { | ||
| 300 | - | req, err := buildUpdateRepo(cmd, args[0], description, topics, visibility, defaultBranch, mergeModes, failingChecksBlock) | 342 | + | req, err := buildUpdateRepo(cmd, args[0], description, topics, visibility, defaultBranch, mergeModes, failingChecksBlock, issues) |
| 301 | if err != nil { | 343 | if err != nil { | ||
| 302 | return err | 344 | return err | ||
| 303 | } | 345 | } | ||
| @@ -305,6 +347,11 @@ func newRepoEditCmd(env *cliEnv) *cobra.Command { | |||||
| 305 | if err != nil { | 347 | if err != nil { | ||
| 306 | return err | 348 | return err | ||
| 307 | } | 349 | } | ||
| 350 | + | if req.Issues != nil { | |||
| 351 | + | if err := requireIssues(env.ctx(cmd), c); err != nil { | |||
| 352 | + | return err | |||
| 353 | + | } | |||
| 354 | + | } | |||
| 308 | res, err := c.Repos().UpdateRepo(env.ctx(cmd), connect.NewRequest(req)) | 355 | res, err := c.Repos().UpdateRepo(env.ctx(cmd), connect.NewRequest(req)) | ||
| 309 | if err != nil { | 356 | if err != nil { | ||
| 310 | return c.Describe(err) | 357 | return c.Describe(err) | ||
| @@ -316,7 +363,7 @@ func newRepoEditCmd(env *cliEnv) *cobra.Command { | |||||
| 316 | 363 | ||||
| 317 | // buildUpdateRepo maps the flags of `repo edit` to the request: only the flags | 364 | // buildUpdateRepo maps the flags of `repo edit` to the request: only the flags | ||
| 318 | // the user passed go into the update mask. | 365 | // the user passed go into the update mask. | ||
| 319 | - | func buildUpdateRepo(cmd *cobra.Command, name, description string, topics []string, visibility, defaultBranch string, mergeModes []string, failingChecksBlock bool) (*ermite.UpdateRepoRequest, error) { | 366 | + | func buildUpdateRepo(cmd *cobra.Command, name, description string, topics []string, visibility, defaultBranch string, mergeModes []string, failingChecksBlock bool, issues string) (*ermite.UpdateRepoRequest, error) { |
| 320 | req := &ermite.UpdateRepoRequest{Repo: name} | 367 | req := &ermite.UpdateRepoRequest{Repo: name} | ||
| 321 | var paths []string | 368 | var paths []string | ||
| 322 | if cmd.Flags().Changed("description") { | 369 | if cmd.Flags().Changed("description") { | ||
| @@ -365,8 +412,16 @@ func buildUpdateRepo(cmd *cobra.Command, name, description string, topics []stri | |||||
| 365 | req.Settings.FailingChecksBlock = failingChecksBlock | 412 | req.Settings.FailingChecksBlock = failingChecksBlock | ||
| 366 | paths = append(paths, "settings.failing_checks_block") | 413 | paths = append(paths, "settings.failing_checks_block") | ||
| 367 | } | 414 | } | ||
| 368 | - | if len(paths) == 0 { | 415 | + | if cmd.Flags().Changed("issues") { |
| 369 | - | return nil, errors.New("nothing to change: give at least one of --description, --topics, --visibility, --default-branch, --merge-modes, --failing-checks-block") | 416 | + | mode, err := parseIssuesMode(issues) |
| 417 | + | if err != nil { | |||
| 418 | + | return nil, err | |||
| 419 | + | } | |||
| 420 | + | // The contract keeps issues out of the update mask. | |||
| 421 | + | req.Issues = &mode | |||
| 422 | + | } | |||
| 423 | + | if len(paths) == 0 && req.Issues == nil { | |||
| 424 | + | return nil, errors.New("nothing to change: give at least one of --description, --topics, --visibility, --default-branch, --merge-modes, --failing-checks-block, --issues") | |||
| 370 | } | 425 | } | ||
| 371 | req.UpdateMask = &fieldmaskpb.FieldMask{Paths: paths} | 426 | req.UpdateMask = &fieldmaskpb.FieldMask{Paths: paths} | ||
| 372 | return req, nil | 427 | return req, nil | ||
M internal/app/cli_repo_test.go +6 -5
| @@ -54,8 +54,8 @@ func TestRepoList(t *testing.T) { | |||
| 54 | 54 | { | |
| 55 | 55 | name: "json", repos: both, args: []string{"repo", "list", "--json"}, wantJSON: true, | |
| 56 | 56 | wantStdout: `[ | |
| 57 | - | {"name":"alpha","description":"The first one","topics":["cli","go"],"visibility":"public","default_branch":"main","created_at":"2026-01-02T03:04:05Z","pushed_at":"2026-03-04T05:06:07Z","merge_modes":["fast-forward","squash"],"failing_checks_block":true}, | |
| 58 | - | {"name":"beta","description":"","topics":[],"visibility":"private","default_branch":"trunk","created_at":"2026-02-02T00:00:00Z","pushed_at":null,"merge_modes":["squash"],"failing_checks_block":false} | |
| 57 | + | {"name":"alpha","description":"The first one","topics":["cli","go"],"visibility":"public","default_branch":"main","created_at":"2026-01-02T03:04:05Z","pushed_at":"2026-03-04T05:06:07Z","merge_modes":["fast-forward","squash"],"failing_checks_block":true,"issues":"off"}, | |
| 58 | + | {"name":"beta","description":"","topics":[],"visibility":"private","default_branch":"trunk","created_at":"2026-02-02T00:00:00Z","pushed_at":null,"merge_modes":["squash"],"failing_checks_block":false,"issues":"off"} | |
| 59 | 59 | ]`, | |
| 60 | 60 | }, | |
| 61 | 61 | { | |
| @@ -115,12 +115,13 @@ func TestRepoShow(t *testing.T) { | |||
| 115 | 115 | "created 2026-01-02 03:04\n"+ | |
| 116 | 116 | "last push 2026-03-04 05:06\n"+ | |
| 117 | 117 | "merge modes fast-forward, squash\n"+ | |
| 118 | - | "failing checks block true\n", res.stdout) | |
| 118 | + | "failing checks block true\n"+ | |
| 119 | + | "issues off\n", res.stdout) | |
| 119 | 120 | _, reqs := f.snapshot() | |
| 120 | 121 | assert.Equal(t, "alpha", reqs[0].(*ermite.GetRepoRequest).GetRepo()) | |
| 121 | 122 | ||
| 122 | 123 | res = runCLI(t, envFor(url), "", "repo", "show", "alpha", "--json").ok(t) | |
| 123 | - | assert.JSONEq(t, `{"name":"alpha","description":"The first one","topics":["cli","go"],"visibility":"public","default_branch":"main","created_at":"2026-01-02T03:04:05Z","pushed_at":"2026-03-04T05:06:07Z","merge_modes":["fast-forward","squash"],"failing_checks_block":true}`, res.stdout) | |
| 124 | + | assert.JSONEq(t, `{"name":"alpha","description":"The first one","topics":["cli","go"],"visibility":"public","default_branch":"main","created_at":"2026-01-02T03:04:05Z","pushed_at":"2026-03-04T05:06:07Z","merge_modes":["fast-forward","squash"],"failing_checks_block":true,"issues":"off"}`, res.stdout) | |
| 124 | 125 | } | |
| 125 | 126 | ||
| 126 | 127 | func TestRepoShowNeedsOneArg(t *testing.T) { | |
| @@ -176,7 +177,7 @@ func TestRepoCreateJSON(t *testing.T) { | |||
| 176 | 177 | return &ermite.CreateRepoResponse{Repo: repoBeta}, nil | |
| 177 | 178 | }} | |
| 178 | 179 | res := runCLI(t, envFor(f.serve(t)), "", "repo", "create", "beta", "--json").ok(t) | |
| 179 | - | assert.JSONEq(t, `{"name":"beta","description":"","topics":[],"visibility":"private","default_branch":"trunk","created_at":"2026-02-02T00:00:00Z","pushed_at":null,"merge_modes":["squash"],"failing_checks_block":false}`, res.stdout) | |
| 180 | + | assert.JSONEq(t, `{"name":"beta","description":"","topics":[],"visibility":"private","default_branch":"trunk","created_at":"2026-02-02T00:00:00Z","pushed_at":null,"merge_modes":["squash"],"failing_checks_block":false,"issues":"off"}`, res.stdout) | |
| 180 | 181 | } | |
| 181 | 182 | ||
| 182 | 183 | func TestRepoEditMask(t *testing.T) { | |
| @@ -54,8 +54,8 @@ func TestRepoList(t *testing.T) { | |||||
| 54 | { | 54 | { | ||
| 55 | name: "json", repos: both, args: []string{"repo", "list", "--json"}, wantJSON: true, | 55 | name: "json", repos: both, args: []string{"repo", "list", "--json"}, wantJSON: true, | ||
| 56 | wantStdout: `[ | 56 | wantStdout: `[ | ||
| 57 | - | {"name":"alpha","description":"The first one","topics":["cli","go"],"visibility":"public","default_branch":"main","created_at":"2026-01-02T03:04:05Z","pushed_at":"2026-03-04T05:06:07Z","merge_modes":["fast-forward","squash"],"failing_checks_block":true}, | 57 | + | {"name":"alpha","description":"The first one","topics":["cli","go"],"visibility":"public","default_branch":"main","created_at":"2026-01-02T03:04:05Z","pushed_at":"2026-03-04T05:06:07Z","merge_modes":["fast-forward","squash"],"failing_checks_block":true,"issues":"off"}, |
| 58 | - | {"name":"beta","description":"","topics":[],"visibility":"private","default_branch":"trunk","created_at":"2026-02-02T00:00:00Z","pushed_at":null,"merge_modes":["squash"],"failing_checks_block":false} | 58 | + | {"name":"beta","description":"","topics":[],"visibility":"private","default_branch":"trunk","created_at":"2026-02-02T00:00:00Z","pushed_at":null,"merge_modes":["squash"],"failing_checks_block":false,"issues":"off"} |
| 59 | ]`, | 59 | ]`, | ||
| 60 | }, | 60 | }, | ||
| 61 | { | 61 | { | ||
| @@ -115,12 +115,13 @@ func TestRepoShow(t *testing.T) { | |||||
| 115 | "created 2026-01-02 03:04\n"+ | 115 | "created 2026-01-02 03:04\n"+ | ||
| 116 | "last push 2026-03-04 05:06\n"+ | 116 | "last push 2026-03-04 05:06\n"+ | ||
| 117 | "merge modes fast-forward, squash\n"+ | 117 | "merge modes fast-forward, squash\n"+ | ||
| 118 | - | "failing checks block true\n", res.stdout) | 118 | + | "failing checks block true\n"+ |
| 119 | + | "issues off\n", res.stdout) | |||
| 119 | _, reqs := f.snapshot() | 120 | _, reqs := f.snapshot() | ||
| 120 | assert.Equal(t, "alpha", reqs[0].(*ermite.GetRepoRequest).GetRepo()) | 121 | assert.Equal(t, "alpha", reqs[0].(*ermite.GetRepoRequest).GetRepo()) | ||
| 121 | 122 | ||||
| 122 | res = runCLI(t, envFor(url), "", "repo", "show", "alpha", "--json").ok(t) | 123 | res = runCLI(t, envFor(url), "", "repo", "show", "alpha", "--json").ok(t) | ||
| 123 | - | assert.JSONEq(t, `{"name":"alpha","description":"The first one","topics":["cli","go"],"visibility":"public","default_branch":"main","created_at":"2026-01-02T03:04:05Z","pushed_at":"2026-03-04T05:06:07Z","merge_modes":["fast-forward","squash"],"failing_checks_block":true}`, res.stdout) | 124 | + | assert.JSONEq(t, `{"name":"alpha","description":"The first one","topics":["cli","go"],"visibility":"public","default_branch":"main","created_at":"2026-01-02T03:04:05Z","pushed_at":"2026-03-04T05:06:07Z","merge_modes":["fast-forward","squash"],"failing_checks_block":true,"issues":"off"}`, res.stdout) |
| 124 | } | 125 | } | ||
| 125 | 126 | ||||
| 126 | func TestRepoShowNeedsOneArg(t *testing.T) { | 127 | func TestRepoShowNeedsOneArg(t *testing.T) { | ||
| @@ -176,7 +177,7 @@ func TestRepoCreateJSON(t *testing.T) { | |||||
| 176 | return &ermite.CreateRepoResponse{Repo: repoBeta}, nil | 177 | return &ermite.CreateRepoResponse{Repo: repoBeta}, nil | ||
| 177 | }} | 178 | }} | ||
| 178 | res := runCLI(t, envFor(f.serve(t)), "", "repo", "create", "beta", "--json").ok(t) | 179 | res := runCLI(t, envFor(f.serve(t)), "", "repo", "create", "beta", "--json").ok(t) | ||
| 179 | - | assert.JSONEq(t, `{"name":"beta","description":"","topics":[],"visibility":"private","default_branch":"trunk","created_at":"2026-02-02T00:00:00Z","pushed_at":null,"merge_modes":["squash"],"failing_checks_block":false}`, res.stdout) | 180 | + | assert.JSONEq(t, `{"name":"beta","description":"","topics":[],"visibility":"private","default_branch":"trunk","created_at":"2026-02-02T00:00:00Z","pushed_at":null,"merge_modes":["squash"],"failing_checks_block":false,"issues":"off"}`, res.stdout) |
| 180 | } | 181 | } | ||
| 181 | 182 | ||||
| 182 | func TestRepoEditMask(t *testing.T) { | 183 | func TestRepoEditMask(t *testing.T) { | ||
M internal/app/cli_token.go +17 -0
| @@ -90,6 +90,8 @@ func newTokenCreateCmd(env *cliEnv) *cobra.Command { | |||
| 90 | 90 | " agent repo:write, mr:write\n" + | |
| 91 | 91 | " runner runner:register (a CI runner host)\n" + | |
| 92 | 92 | " ci repo:read\n" + | |
| 93 | + | "Scopes are repo:read, repo:write, repo:admin, mr:write, mr:merge, issue:write and more; give\n" + | |
| 94 | + | "issue:write (a server with the issues feature) to let a token write issues.\n" + | |
| 93 | 95 | "A token cannot be more powerful than the one that creates it. --repo limits it to the named\n" + | |
| 94 | 96 | "repositories (default: all). --expires takes 30d, 2w, 12h or a date; default: never.\n\n" + | |
| 95 | 97 | "--branch-prefix limits the branches the token may write (default: all). Each prefix ends with /\n" + | |
| @@ -131,6 +133,13 @@ func newTokenCreateCmd(env *cliEnv) *cobra.Command { | |||
| 131 | 133 | return err | |
| 132 | 134 | } | |
| 133 | 135 | } | |
| 136 | + | // Only an explicit --scope issue:write is checked: a preset such as cli | |
| 137 | + | // grows with the contract and must keep working on an older server. | |
| 138 | + | if explicitScope(scopes, ermite.Scope_SCOPE_ISSUE_WRITE) { | |
| 139 | + | if err := requireIssues(env.ctx(cmd), c); err != nil { | |
| 140 | + | return fmt.Errorf("%w (needed for the issue:write scope; nothing was created)", err) | |
| 141 | + | } | |
| 142 | + | } | |
| 134 | 143 | res, err := c.Tokens().CreateToken(env.ctx(cmd), connect.NewRequest(req)) | |
| 135 | 144 | if err != nil { | |
| 136 | 145 | return c.Describe(err) | |
| @@ -143,6 +152,14 @@ func newTokenCreateCmd(env *cliEnv) *cobra.Command { | |||
| 143 | 152 | return cmd | |
| 144 | 153 | } | |
| 145 | 154 | ||
| 155 | + | // explicitScope reports whether the --scope flags name want. | |
| 156 | + | func explicitScope(flags []string, want ermite.Scope) bool { | |
| 157 | + | return slices.ContainsFunc(flags, func(f string) bool { | |
| 158 | + | s, err := client.ParseScope(f) | |
| 159 | + | return err == nil && s == want | |
| 160 | + | }) | |
| 161 | + | } | |
| 162 | + | ||
| 146 | 163 | // requireBranchPrefixes refuses, before anything is created, to ask a server | |
| 147 | 164 | // for a branch-limited token when it does not list token.branch_prefixes: such | |
| 148 | 165 | // a server ignores the field. A server whose features cannot be read is refused | |
| @@ -90,6 +90,8 @@ func newTokenCreateCmd(env *cliEnv) *cobra.Command { | |||||
| 90 | " agent repo:write, mr:write\n" + | 90 | " agent repo:write, mr:write\n" + | ||
| 91 | " runner runner:register (a CI runner host)\n" + | 91 | " runner runner:register (a CI runner host)\n" + | ||
| 92 | " ci repo:read\n" + | 92 | " ci repo:read\n" + | ||
| 93 | + | "Scopes are repo:read, repo:write, repo:admin, mr:write, mr:merge, issue:write and more; give\n" + | |||
| 94 | + | "issue:write (a server with the issues feature) to let a token write issues.\n" + | |||
| 93 | "A token cannot be more powerful than the one that creates it. --repo limits it to the named\n" + | 95 | "A token cannot be more powerful than the one that creates it. --repo limits it to the named\n" + | ||
| 94 | "repositories (default: all). --expires takes 30d, 2w, 12h or a date; default: never.\n\n" + | 96 | "repositories (default: all). --expires takes 30d, 2w, 12h or a date; default: never.\n\n" + | ||
| 95 | "--branch-prefix limits the branches the token may write (default: all). Each prefix ends with /\n" + | 97 | "--branch-prefix limits the branches the token may write (default: all). Each prefix ends with /\n" + | ||
| @@ -131,6 +133,13 @@ func newTokenCreateCmd(env *cliEnv) *cobra.Command { | |||||
| 131 | return err | 133 | return err | ||
| 132 | } | 134 | } | ||
| 133 | } | 135 | } | ||
| 136 | + | // Only an explicit --scope issue:write is checked: a preset such as cli | |||
| 137 | + | // grows with the contract and must keep working on an older server. | |||
| 138 | + | if explicitScope(scopes, ermite.Scope_SCOPE_ISSUE_WRITE) { | |||
| 139 | + | if err := requireIssues(env.ctx(cmd), c); err != nil { | |||
| 140 | + | return fmt.Errorf("%w (needed for the issue:write scope; nothing was created)", err) | |||
| 141 | + | } | |||
| 142 | + | } | |||
| 134 | res, err := c.Tokens().CreateToken(env.ctx(cmd), connect.NewRequest(req)) | 143 | res, err := c.Tokens().CreateToken(env.ctx(cmd), connect.NewRequest(req)) | ||
| 135 | if err != nil { | 144 | if err != nil { | ||
| 136 | return c.Describe(err) | 145 | return c.Describe(err) | ||
| @@ -143,6 +152,14 @@ func newTokenCreateCmd(env *cliEnv) *cobra.Command { | |||||
| 143 | return cmd | 152 | return cmd | ||
| 144 | } | 153 | } | ||
| 145 | 154 | ||||
| 155 | + | // explicitScope reports whether the --scope flags name want. | |||
| 156 | + | func explicitScope(flags []string, want ermite.Scope) bool { | |||
| 157 | + | return slices.ContainsFunc(flags, func(f string) bool { | |||
| 158 | + | s, err := client.ParseScope(f) | |||
| 159 | + | return err == nil && s == want | |||
| 160 | + | }) | |||
| 161 | + | } | |||
| 162 | + | ||||
| 146 | // requireBranchPrefixes refuses, before anything is created, to ask a server | 163 | // requireBranchPrefixes refuses, before anything is created, to ask a server | ||
| 147 | // for a branch-limited token when it does not list token.branch_prefixes: such | 164 | // for a branch-limited token when it does not list token.branch_prefixes: such | ||
| 148 | // a server ignores the field. A server whose features cannot be read is refused | 165 | // a server ignores the field. A server whose features cannot be read is refused | ||
M internal/app/cli_version.go +1 -0
| @@ -113,6 +113,7 @@ var featureUse = map[string]string{ | |||
| 113 | 113 | client.FeatureEventsResume: "`ermite watch` uses to resume after a reconnection: events during an outage may be missed", | |
| 114 | 114 | client.FeatureMergeRequests: "`ermite mr` needs: the commands will fail on this server", | |
| 115 | 115 | client.FeatureMergeDeleteBranch: "`ermite mr merge --delete-branch` needs: the merge works but the branch is kept", | |
| 116 | + | client.FeatureIssues: "`ermite issue` needs: the commands will fail on this server, and `repo edit --issues` and `token create --scope issue:write` are refused", | |
| 116 | 117 | client.FeatureSigningKey: "`ermite signing-key show` needs: the command will fail on this server", | |
| 117 | 118 | client.FeatureSession: "`ermite web login` needs: the command will fail on this server", | |
| 118 | 119 | client.FeatureTokenBranchPrefixes: "`ermite token create --branch-prefix` needs: the command is refused, and `token list` does not show the limits", | |
| @@ -113,6 +113,7 @@ var featureUse = map[string]string{ | |||||
| 113 | client.FeatureEventsResume: "`ermite watch` uses to resume after a reconnection: events during an outage may be missed", | 113 | client.FeatureEventsResume: "`ermite watch` uses to resume after a reconnection: events during an outage may be missed", | ||
| 114 | client.FeatureMergeRequests: "`ermite mr` needs: the commands will fail on this server", | 114 | client.FeatureMergeRequests: "`ermite mr` needs: the commands will fail on this server", | ||
| 115 | client.FeatureMergeDeleteBranch: "`ermite mr merge --delete-branch` needs: the merge works but the branch is kept", | 115 | client.FeatureMergeDeleteBranch: "`ermite mr merge --delete-branch` needs: the merge works but the branch is kept", | ||
| 116 | + | client.FeatureIssues: "`ermite issue` needs: the commands will fail on this server, and `repo edit --issues` and `token create --scope issue:write` are refused", | |||
| 116 | client.FeatureSigningKey: "`ermite signing-key show` needs: the command will fail on this server", | 117 | client.FeatureSigningKey: "`ermite signing-key show` needs: the command will fail on this server", | ||
| 117 | client.FeatureSession: "`ermite web login` needs: the command will fail on this server", | 118 | client.FeatureSession: "`ermite web login` needs: the command will fail on this server", | ||
| 118 | client.FeatureTokenBranchPrefixes: "`ermite token create --branch-prefix` needs: the command is refused, and `token list` does not show the limits", | 119 | client.FeatureTokenBranchPrefixes: "`ermite token create --branch-prefix` needs: the command is refused, and `token list` does not show the limits", | ||
M internal/app/cli_version_test.go +5 -5
| @@ -17,7 +17,7 @@ import ( | |||
| 17 | 17 | ) | |
| 18 | 18 | ||
| 19 | 19 | // allFeatures are the features this client uses. | |
| 20 | - | var allFeatures = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "token.branch_prefixes", "ci.runs", "ci.runners", "ci.secrets", "ci.dispatch", "ci.workflows", "ci.artifacts", "commit_signatures", "passkeys"} | |
| 20 | + | var allFeatures = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "signing_key", "session", "token.branch_prefixes", "ci.runs", "ci.runners", "ci.secrets", "ci.dispatch", "ci.workflows", "ci.artifacts", "commit_signatures", "passkeys"} | |
| 21 | 21 | ||
| 22 | 22 | // Every feature the client uses needs a sentence for the version warning. | |
| 23 | 23 | func TestEveryUsedFeatureExplainsWhatBreaks(t *testing.T) { | |
| @@ -45,15 +45,15 @@ func TestVersionWithServer(t *testing.T) { | |||
| 45 | 45 | f := versionFake(allFeatures...) | |
| 46 | 46 | url := f.serve(t) | |
| 47 | 47 | res := runCLI(t, envFor(url), "", "version").ok(t) | |
| 48 | - | assert.Equal(t, "client ermite v0.0.1\nserver v0.4.0 at "+url+"\nfeatures repo.update, events.resume, merge_requests, merge_requests.delete_branch, signing_key, session, token.branch_prefixes, ci.runs, ci.runners, ci.secrets, ci.dispatch, ci.workflows, ci.artifacts, commit_signatures, passkeys\n", res.stdout) | |
| 48 | + | assert.Equal(t, "client ermite v0.0.1\nserver v0.4.0 at "+url+"\nfeatures repo.update, events.resume, merge_requests, merge_requests.delete_branch, issues, signing_key, session, token.branch_prefixes, ci.runs, ci.runners, ci.secrets, ci.dispatch, ci.workflows, ci.artifacts, commit_signatures, passkeys\n", res.stdout) | |
| 49 | 49 | assert.Empty(t, res.stderr, "no warning when every feature is there") | |
| 50 | 50 | ||
| 51 | 51 | res = runCLI(t, envFor(url), "", "version", "--json").ok(t) | |
| 52 | - | assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","events.resume","merge_requests","merge_requests.delete_branch","signing_key","session","token.branch_prefixes","ci.runs","ci.runners","ci.secrets","ci.dispatch","ci.workflows","ci.artifacts","commit_signatures","passkeys"],"missing_features":[]}}`, res.stdout) | |
| 52 | + | assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","events.resume","merge_requests","merge_requests.delete_branch","issues","signing_key","session","token.branch_prefixes","ci.runs","ci.runners","ci.secrets","ci.dispatch","ci.workflows","ci.artifacts","commit_signatures","passkeys"],"missing_features":[]}}`, res.stdout) | |
| 53 | 53 | } | |
| 54 | 54 | ||
| 55 | 55 | func TestVersionWarnsAboutMissingFeatures(t *testing.T) { | |
| 56 | - | f := versionFake("repo.update", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "token.branch_prefixes", "ci.runs", "ci.runners", "ci.secrets", "ci.dispatch", "ci.workflows", "ci.artifacts", "commit_signatures", "passkeys") | |
| 56 | + | f := versionFake("repo.update", "merge_requests", "merge_requests.delete_branch", "issues", "signing_key", "session", "token.branch_prefixes", "ci.runs", "ci.runners", "ci.secrets", "ci.dispatch", "ci.workflows", "ci.artifacts", "commit_signatures", "passkeys") | |
| 57 | 57 | url := f.serve(t) | |
| 58 | 58 | res := runCLI(t, envFor(url), "", "version").ok(t) | |
| 59 | 59 | assert.Contains(t, res.stdout, "v0.4.0") | |
| @@ -61,7 +61,7 @@ func TestVersionWarnsAboutMissingFeatures(t *testing.T) { | |||
| 61 | 61 | assert.NotContains(t, res.stderr, "repo.update") | |
| 62 | 62 | ||
| 63 | 63 | res = runCLI(t, envFor(url), "", "version", "--json").ok(t) | |
| 64 | - | assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","merge_requests","merge_requests.delete_branch","signing_key","session","token.branch_prefixes","ci.runs","ci.runners","ci.secrets","ci.dispatch","ci.workflows","ci.artifacts","commit_signatures","passkeys"],"missing_features":["events.resume"]}}`, res.stdout) | |
| 64 | + | assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","merge_requests","merge_requests.delete_branch","issues","signing_key","session","token.branch_prefixes","ci.runs","ci.runners","ci.secrets","ci.dispatch","ci.workflows","ci.artifacts","commit_signatures","passkeys"],"missing_features":["events.resume"]}}`, res.stdout) | |
| 65 | 65 | assert.Contains(t, res.stderr, "events.resume", "the warning also goes to stderr with --json") | |
| 66 | 66 | ||
| 67 | 67 | f = versionFake() | |
| @@ -17,7 +17,7 @@ import ( | |||||
| 17 | ) | 17 | ) | ||
| 18 | 18 | ||||
| 19 | // allFeatures are the features this client uses. | 19 | // allFeatures are the features this client uses. | ||
| 20 | - | var allFeatures = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "token.branch_prefixes", "ci.runs", "ci.runners", "ci.secrets", "ci.dispatch", "ci.workflows", "ci.artifacts", "commit_signatures", "passkeys"} | 20 | + | var allFeatures = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "signing_key", "session", "token.branch_prefixes", "ci.runs", "ci.runners", "ci.secrets", "ci.dispatch", "ci.workflows", "ci.artifacts", "commit_signatures", "passkeys"} |
| 21 | 21 | ||||
| 22 | // Every feature the client uses needs a sentence for the version warning. | 22 | // Every feature the client uses needs a sentence for the version warning. | ||
| 23 | func TestEveryUsedFeatureExplainsWhatBreaks(t *testing.T) { | 23 | func TestEveryUsedFeatureExplainsWhatBreaks(t *testing.T) { | ||
| @@ -45,15 +45,15 @@ func TestVersionWithServer(t *testing.T) { | |||||
| 45 | f := versionFake(allFeatures...) | 45 | f := versionFake(allFeatures...) | ||
| 46 | url := f.serve(t) | 46 | url := f.serve(t) | ||
| 47 | res := runCLI(t, envFor(url), "", "version").ok(t) | 47 | res := runCLI(t, envFor(url), "", "version").ok(t) | ||
| 48 | - | assert.Equal(t, "client ermite v0.0.1\nserver v0.4.0 at "+url+"\nfeatures repo.update, events.resume, merge_requests, merge_requests.delete_branch, signing_key, session, token.branch_prefixes, ci.runs, ci.runners, ci.secrets, ci.dispatch, ci.workflows, ci.artifacts, commit_signatures, passkeys\n", res.stdout) | 48 | + | assert.Equal(t, "client ermite v0.0.1\nserver v0.4.0 at "+url+"\nfeatures repo.update, events.resume, merge_requests, merge_requests.delete_branch, issues, signing_key, session, token.branch_prefixes, ci.runs, ci.runners, ci.secrets, ci.dispatch, ci.workflows, ci.artifacts, commit_signatures, passkeys\n", res.stdout) |
| 49 | assert.Empty(t, res.stderr, "no warning when every feature is there") | 49 | assert.Empty(t, res.stderr, "no warning when every feature is there") | ||
| 50 | 50 | ||||
| 51 | res = runCLI(t, envFor(url), "", "version", "--json").ok(t) | 51 | res = runCLI(t, envFor(url), "", "version", "--json").ok(t) | ||
| 52 | - | assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","events.resume","merge_requests","merge_requests.delete_branch","signing_key","session","token.branch_prefixes","ci.runs","ci.runners","ci.secrets","ci.dispatch","ci.workflows","ci.artifacts","commit_signatures","passkeys"],"missing_features":[]}}`, res.stdout) | 52 | + | assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","events.resume","merge_requests","merge_requests.delete_branch","issues","signing_key","session","token.branch_prefixes","ci.runs","ci.runners","ci.secrets","ci.dispatch","ci.workflows","ci.artifacts","commit_signatures","passkeys"],"missing_features":[]}}`, res.stdout) |
| 53 | } | 53 | } | ||
| 54 | 54 | ||||
| 55 | func TestVersionWarnsAboutMissingFeatures(t *testing.T) { | 55 | func TestVersionWarnsAboutMissingFeatures(t *testing.T) { | ||
| 56 | - | f := versionFake("repo.update", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "token.branch_prefixes", "ci.runs", "ci.runners", "ci.secrets", "ci.dispatch", "ci.workflows", "ci.artifacts", "commit_signatures", "passkeys") | 56 | + | f := versionFake("repo.update", "merge_requests", "merge_requests.delete_branch", "issues", "signing_key", "session", "token.branch_prefixes", "ci.runs", "ci.runners", "ci.secrets", "ci.dispatch", "ci.workflows", "ci.artifacts", "commit_signatures", "passkeys") |
| 57 | url := f.serve(t) | 57 | url := f.serve(t) | ||
| 58 | res := runCLI(t, envFor(url), "", "version").ok(t) | 58 | res := runCLI(t, envFor(url), "", "version").ok(t) | ||
| 59 | assert.Contains(t, res.stdout, "v0.4.0") | 59 | assert.Contains(t, res.stdout, "v0.4.0") | ||
| @@ -61,7 +61,7 @@ func TestVersionWarnsAboutMissingFeatures(t *testing.T) { | |||||
| 61 | assert.NotContains(t, res.stderr, "repo.update") | 61 | assert.NotContains(t, res.stderr, "repo.update") | ||
| 62 | 62 | ||||
| 63 | res = runCLI(t, envFor(url), "", "version", "--json").ok(t) | 63 | res = runCLI(t, envFor(url), "", "version", "--json").ok(t) | ||
| 64 | - | assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","merge_requests","merge_requests.delete_branch","signing_key","session","token.branch_prefixes","ci.runs","ci.runners","ci.secrets","ci.dispatch","ci.workflows","ci.artifacts","commit_signatures","passkeys"],"missing_features":["events.resume"]}}`, res.stdout) | 64 | + | assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","merge_requests","merge_requests.delete_branch","issues","signing_key","session","token.branch_prefixes","ci.runs","ci.runners","ci.secrets","ci.dispatch","ci.workflows","ci.artifacts","commit_signatures","passkeys"],"missing_features":["events.resume"]}}`, res.stdout) |
| 65 | assert.Contains(t, res.stderr, "events.resume", "the warning also goes to stderr with --json") | 65 | assert.Contains(t, res.stderr, "events.resume", "the warning also goes to stderr with --json") | ||
| 66 | 66 | ||||
| 67 | f = versionFake() | 67 | f = versionFake() | ||
M internal/app/cli_watch_test.go +1 -1
| @@ -95,7 +95,7 @@ func TestWatchNDJSON(t *testing.T) { | |||
| 95 | 95 | assert.True(t, json.Valid([]byte(sc.Text())), "one JSON value per line: %s", sc.Text()) | |
| 96 | 96 | } | |
| 97 | 97 | require.Len(t, lines, 5) | |
| 98 | - | assert.JSONEq(t, `{"id":"1","time":"2026-10-10T12:00:00Z","type":"repo_created","repo":"beta","repository":{"name":"beta","description":"","topics":[],"visibility":"private","default_branch":"trunk","created_at":"2026-02-02T00:00:00Z","pushed_at":null,"merge_modes":["squash"],"failing_checks_block":false}}`, lines[0]) | |
| 98 | + | assert.JSONEq(t, `{"id":"1","time":"2026-10-10T12:00:00Z","type":"repo_created","repo":"beta","repository":{"name":"beta","description":"","topics":[],"visibility":"private","default_branch":"trunk","created_at":"2026-02-02T00:00:00Z","pushed_at":null,"merge_modes":["squash"],"failing_checks_block":false,"issues":"off"}}`, lines[0]) | |
| 99 | 99 | assert.JSONEq(t, `{"id":"3","time":"2026-10-10T12:00:00Z","type":"pushed","repo":"alpha","ref_updates":[ | |
| 100 | 100 | {"ref":"refs/heads/main","old_id":"1111111111111111","new_id":"2222222222222222"}, | |
| 101 | 101 | {"ref":"refs/heads/new","old_id":"","new_id":"3333333333333333"}, | |
| @@ -95,7 +95,7 @@ func TestWatchNDJSON(t *testing.T) { | |||||
| 95 | assert.True(t, json.Valid([]byte(sc.Text())), "one JSON value per line: %s", sc.Text()) | 95 | assert.True(t, json.Valid([]byte(sc.Text())), "one JSON value per line: %s", sc.Text()) | ||
| 96 | } | 96 | } | ||
| 97 | require.Len(t, lines, 5) | 97 | require.Len(t, lines, 5) | ||
| 98 | - | assert.JSONEq(t, `{"id":"1","time":"2026-10-10T12:00:00Z","type":"repo_created","repo":"beta","repository":{"name":"beta","description":"","topics":[],"visibility":"private","default_branch":"trunk","created_at":"2026-02-02T00:00:00Z","pushed_at":null,"merge_modes":["squash"],"failing_checks_block":false}}`, lines[0]) | 98 | + | assert.JSONEq(t, `{"id":"1","time":"2026-10-10T12:00:00Z","type":"repo_created","repo":"beta","repository":{"name":"beta","description":"","topics":[],"visibility":"private","default_branch":"trunk","created_at":"2026-02-02T00:00:00Z","pushed_at":null,"merge_modes":["squash"],"failing_checks_block":false,"issues":"off"}}`, lines[0]) |
| 99 | assert.JSONEq(t, `{"id":"3","time":"2026-10-10T12:00:00Z","type":"pushed","repo":"alpha","ref_updates":[ | 99 | assert.JSONEq(t, `{"id":"3","time":"2026-10-10T12:00:00Z","type":"pushed","repo":"alpha","ref_updates":[ | ||
| 100 | {"ref":"refs/heads/main","old_id":"1111111111111111","new_id":"2222222222222222"}, | 100 | {"ref":"refs/heads/main","old_id":"1111111111111111","new_id":"2222222222222222"}, | ||
| 101 | {"ref":"refs/heads/new","old_id":"","new_id":"3333333333333333"}, | 101 | {"ref":"refs/heads/new","old_id":"","new_id":"3333333333333333"}, | ||
M internal/app/root.go +1 -1
| @@ -31,7 +31,7 @@ func newRootCmdWith(env *cliEnv) *cobra.Command { | |||
| 31 | 31 | root.AddCommand( | |
| 32 | 32 | newServeCmd(), newHookCmd(), | |
| 33 | 33 | newVersionCmd(env), newRepoCmd(env), newTokenCmd(env), newWatchCmd(env), | |
| 34 | - | newMRCmd(env), newSigningKeyCmd(env), newWebCmd(env), newConfigCmd(env), | |
| 34 | + | newMRCmd(env), newIssueCmd(env), newSigningKeyCmd(env), newWebCmd(env), newConfigCmd(env), | |
| 35 | 35 | newRunCmd(env), newWorkflowCmd(env), newRunnerCmd(env), newSecretCmd(env), newVariableCmd(env), newEnvironmentCmd(env), newOIDCCmd(env), | |
| 36 | 36 | ) | |
| 37 | 37 | return root | |
| @@ -31,7 +31,7 @@ func newRootCmdWith(env *cliEnv) *cobra.Command { | |||||
| 31 | root.AddCommand( | 31 | root.AddCommand( | ||
| 32 | newServeCmd(), newHookCmd(), | 32 | newServeCmd(), newHookCmd(), | ||
| 33 | newVersionCmd(env), newRepoCmd(env), newTokenCmd(env), newWatchCmd(env), | 33 | newVersionCmd(env), newRepoCmd(env), newTokenCmd(env), newWatchCmd(env), | ||
| 34 | - | newMRCmd(env), newSigningKeyCmd(env), newWebCmd(env), newConfigCmd(env), | 34 | + | newMRCmd(env), newIssueCmd(env), newSigningKeyCmd(env), newWebCmd(env), newConfigCmd(env), |
| 35 | newRunCmd(env), newWorkflowCmd(env), newRunnerCmd(env), newSecretCmd(env), newVariableCmd(env), newEnvironmentCmd(env), newOIDCCmd(env), | 35 | newRunCmd(env), newWorkflowCmd(env), newRunnerCmd(env), newSecretCmd(env), newVariableCmd(env), newEnvironmentCmd(env), newOIDCCmd(env), | ||
| 36 | ) | 36 | ) | ||
| 37 | return root | 37 | return root | ||