detached 582cf61

feat(cli): add ermite issue commands and repo edit --issues

commit
582cf616e3a61942bfd10e38ffa552d502b86755
author
brice · · 2026-10-19 05:54 UTC
parent
a5d6827
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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 {
9595 return ermiteconnect.NewMergeRequestServiceClient(c.http, c.base, c.opts...)
9696 }
9797
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+
98103 // Secrets returns the SecretService client.
99104 func (c *Client) Secrets() ermiteconnect.SecretServiceClient {
100105 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) {
139139 func TestMissingFeatures(t *testing.T) {
140140 assert.Empty(t, client.MissingFeatures(client.UsedFeatures()))
141141 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},
143143 client.MissingFeatures([]string{client.FeatureRepoUpdate, client.FeatureSession, client.FeaturePasskeys, "other"}))
144144 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,
146146 client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, client.FeatureCIWorkflows,
147147 client.FeatureCIArtifacts, client.FeaturePasskeys, client.FeatureCommitSignatures,
148148 }))
149149 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,
151151 client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, client.FeatureCIWorkflows, client.FeatureCIArtifacts, client.FeatureCommitSignatures,
152152 }))
153153 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 (
1818 // FeatureMergeDeleteBranch is MergeMergeRequest with delete_source_branch
1919 // (`ermite mr merge --delete-branch`).
2020 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"
2124 // FeatureSigningKey is ServerService.GetSigningKey (`ermite signing-key`).
2225 FeatureSigningKey = "signing_key"
2326 // FeatureSession is SessionService (`ermite web login`, `web logout`).
@@ -55,7 +58,7 @@ const (
5558
5659 // UsedFeatures lists the features the CLI uses, in the order they are reported.
5760 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}
5962 }
6063
6164 // MissingFeatures returns the features the CLI uses that have is lacking.
@@ -18,6 +18,9 @@ const (
18 // FeatureMergeDeleteBranch is MergeMergeRequest with delete_source_branch18 // 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 {
191191 MergeRequest *mrOut `json:"merge_request"`
192192 }
193193
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"}
195195
196196 // AC6: the CLI end to end against a real forge.
197197 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 {
3131 ermiteconnect.UnimplementedServerServiceHandler
3232 ermiteconnect.UnimplementedEventServiceHandler
3333 ermiteconnect.UnimplementedMergeRequestServiceHandler
34+ ermiteconnect.UnimplementedIssueServiceHandler
3435 ermiteconnect.UnimplementedSessionServiceHandler
3536 ermiteconnect.UnimplementedRunServiceHandler
3637 ermiteconnect.UnimplementedTrustedKeyServiceHandler
@@ -61,6 +62,12 @@ type fakeAPI struct {
6162 closeMR func(*ermite.CloseMergeRequestRequest) (*ermite.CloseMergeRequestResponse, error)
6263 mergeMR func(*ermite.MergeMergeRequestRequest) (*ermite.MergeMergeRequestResponse, error)
6364 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)
6471 getSigningKey func(*ermite.GetSigningKeyRequest) (*ermite.GetSigningKeyResponse, error)
6572
6673 listPasskeys func(*ermite.ListPasskeysRequest) (*ermite.ListPasskeysResponse, error)
@@ -148,6 +155,24 @@ func (f *fakeAPI) MergeMergeRequest(_ context.Context, r *connect.Request[ermite
148155 func (f *fakeAPI) MarkRead(_ context.Context, r *connect.Request[ermite.MarkReadRequest]) (*connect.Response[ermite.MarkReadResponse], error) {
149156 return respond(f, "MarkRead", r, f.markRead)
150157 }
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+}
151176 func (f *fakeAPI) GetSigningKey(_ context.Context, r *connect.Request[ermite.GetSigningKeyRequest]) (*connect.Response[ermite.GetSigningKeyResponse], error) {
152177 return respond(f, "GetSigningKey", r, f.getSigningKey)
153178 }
@@ -189,6 +214,8 @@ func (f *fakeAPI) serve(t *testing.T) string {
189214 mount(p, h)
190215 p, h = ermiteconnect.NewMergeRequestServiceHandler(f)
191216 mount(p, h)
217+ p, h = ermiteconnect.NewIssueServiceHandler(f)
218+ mount(p, h)
192219 p, h = ermiteconnect.NewSessionServiceHandler(f)
193220 mount(p, h)
194221 p, h = ermiteconnect.NewRunServiceHandler(f)
@@ -31,6 +31,7 @@ type fakeAPI struct {
31 ermiteconnect.UnimplementedServerServiceHandler31 ermiteconnect.UnimplementedServerServiceHandler
32 ermiteconnect.UnimplementedEventServiceHandler32 ermiteconnect.UnimplementedEventServiceHandler
33 ermiteconnect.UnimplementedMergeRequestServiceHandler33 ermiteconnect.UnimplementedMergeRequestServiceHandler
34+ ermiteconnect.UnimplementedIssueServiceHandler
34 ermiteconnect.UnimplementedSessionServiceHandler35 ermiteconnect.UnimplementedSessionServiceHandler
35 ermiteconnect.UnimplementedRunServiceHandler36 ermiteconnect.UnimplementedRunServiceHandler
36 ermiteconnect.UnimplementedTrustedKeyServiceHandler37 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 {
3636 // MergeModes are the allowed merge modes ("fast-forward", "squash").
3737 MergeModes []string `json:"merge_modes"`
3838 FailingChecksBlock bool `json:"failing_checks_block"`
39+ // Issues is the issue mode ("off", "owner" or "public").
40+ Issues string `json:"issues"`
3941 }
4042
4143 func toJSONRepo(r *ermite.Repo) jsonRepo {
@@ -46,6 +48,7 @@ func toJSONRepo(r *ermite.Repo) jsonRepo {
4648 return jsonRepo{
4749 MergeModes: modes,
4850 FailingChecksBlock: r.GetSettings().GetFailingChecksBlock(),
51+ Issues: issuesModeName(r.GetSettings().GetIssues()),
4952 Name: r.GetName(),
5053 Description: r.GetDescription(),
5154 Topics: append([]string{}, r.GetTopics()...),
@@ -56,6 +59,36 @@ func toJSONRepo(r *ermite.Repo) jsonRepo {
5659 }
5760 }
5861
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+
5992 func visibilityName(v ermite.Visibility) string {
6093 switch v {
6194 case ermite.Visibility_VISIBILITY_PUBLIC:
@@ -82,7 +115,8 @@ const jsonRepoHelp = `
82115 JSON fields of a repository (--json): name, description, visibility ("public" or
83116 "private"), default_branch, created_at and pushed_at (RFC 3339 UTC; pushed_at is
84117 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").`
86120
87121 func newRepoCmd(env *cliEnv) *cobra.Command {
88122 repo := &cobra.Command{
@@ -195,6 +229,7 @@ func printRepo(w io.Writer, r *ermite.Repo) error {
195229 {"last push", shortTime(r.GetPushedAt(), "never")},
196230 {"merge modes", orDash(strings.Join(modes, ", "))},
197231 {"failing checks block", strconv.FormatBool(r.GetSettings().GetFailingChecksBlock())},
232+ {"issues", issuesModeName(r.GetSettings().GetIssues())},
198233 })
199234 }
200235
@@ -274,13 +309,16 @@ func newRepoEditCmd(env *cliEnv) *cobra.Command {
274309 var description, visibility, defaultBranch string
275310 var topics, mergeModes []string
276311 var failingChecksBlock bool
312+ var issues string
277313 cmd := &cobra.Command{
278314 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",
280316 Long: "Change the fields given as flags and leave the others alone; --description \"\" clears the\n" +
281317 "description and --topics \"\" clears the topics. The default branch must already exist. --merge-modes lists the modes a merge request\n" +
282318 "may be merged with (fast-forward, squash; default for a new repository: both), and\n" +
283319 "--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" +
284322 "Needs the repo:admin scope." + jsonRepoHelp,
285323 Args: cobra.ExactArgs(1),
286324 ValidArgsFunction: completeRepoNames(env),
@@ -291,13 +329,17 @@ func newRepoEditCmd(env *cliEnv) *cobra.Command {
291329 cmd.Flags().StringVar(&defaultBranch, "default-branch", "", "short name of the new default branch")
292330 cmd.Flags().StringSliceVar(&mergeModes, "merge-modes", nil, "allowed merge modes, comma-separated: fast-forward, squash")
293331 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+ })
294336 _ = cmd.RegisterFlagCompletionFunc("visibility", visibilityCompletion)
295337 _ = cmd.RegisterFlagCompletionFunc("merge-modes", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
296338 return []string{modeFastForward, modeSquash}, cobra.ShellCompDirectiveNoFileComp
297339 })
298340 asJSON := addJSONFlag(cmd)
299341 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)
301343 if err != nil {
302344 return err
303345 }
@@ -305,6 +347,11 @@ func newRepoEditCmd(env *cliEnv) *cobra.Command {
305347 if err != nil {
306348 return err
307349 }
350+ if req.Issues != nil {
351+ if err := requireIssues(env.ctx(cmd), c); err != nil {
352+ return err
353+ }
354+ }
308355 res, err := c.Repos().UpdateRepo(env.ctx(cmd), connect.NewRequest(req))
309356 if err != nil {
310357 return c.Describe(err)
@@ -316,7 +363,7 @@ func newRepoEditCmd(env *cliEnv) *cobra.Command {
316363
317364 // buildUpdateRepo maps the flags of `repo edit` to the request: only the flags
318365 // 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) {
320367 req := &ermite.UpdateRepoRequest{Repo: name}
321368 var paths []string
322369 if cmd.Flags().Changed("description") {
@@ -365,8 +412,16 @@ func buildUpdateRepo(cmd *cobra.Command, name, description string, topics []stri
365412 req.Settings.FailingChecksBlock = failingChecksBlock
366413 paths = append(paths, "settings.failing_checks_block")
367414 }
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")
370425 }
371426 req.UpdateMask = &fieldmaskpb.FieldMask{Paths: paths}
372427 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" or115 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 is116 "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/or117 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 string309 var description, visibility, defaultBranch string
275 var topics, mergeModes []string310 var topics, mergeModes []string
276 var failingChecksBlock bool311 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.ShellCompDirectiveNoFileComp338 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 err344 return err
303 }345 }
@@ -305,6 +347,11 @@ func newRepoEditCmd(env *cliEnv) *cobra.Command {
305 if err != nil {347 if err != nil {
306 return err348 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 flags364 // 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 []string368 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 = failingChecksBlock412 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, nil427 return req, nil
M internal/app/cli_repo_test.go +6 -5
@@ -54,8 +54,8 @@ func TestRepoList(t *testing.T) {
5454 {
5555 name: "json", repos: both, args: []string{"repo", "list", "--json"}, wantJSON: true,
5656 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"}
5959 ]`,
6060 },
6161 {
@@ -115,12 +115,13 @@ func TestRepoShow(t *testing.T) {
115115 "created 2026-01-02 03:04\n"+
116116 "last push 2026-03-04 05:06\n"+
117117 "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)
119120 _, reqs := f.snapshot()
120121 assert.Equal(t, "alpha", reqs[0].(*ermite.GetRepoRequest).GetRepo())
121122
122123 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)
124125 }
125126
126127 func TestRepoShowNeedsOneArg(t *testing.T) {
@@ -176,7 +177,7 @@ func TestRepoCreateJSON(t *testing.T) {
176177 return &ermite.CreateRepoResponse{Repo: repoBeta}, nil
177178 }}
178179 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)
180181 }
181182
182183 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}, nil177 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 {
9090 " agent repo:write, mr:write\n" +
9191 " runner runner:register (a CI runner host)\n" +
9292 " 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" +
9395 "A token cannot be more powerful than the one that creates it. --repo limits it to the named\n" +
9496 "repositories (default: all). --expires takes 30d, 2w, 12h or a date; default: never.\n\n" +
9597 "--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 {
131133 return err
132134 }
133135 }
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+ }
134143 res, err := c.Tokens().CreateToken(env.ctx(cmd), connect.NewRequest(req))
135144 if err != nil {
136145 return c.Describe(err)
@@ -143,6 +152,14 @@ func newTokenCreateCmd(env *cliEnv) *cobra.Command {
143152 return cmd
144153 }
145154
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+
146163 // requireBranchPrefixes refuses, before anything is created, to ask a server
147164 // for a branch-limited token when it does not list token.branch_prefixes: such
148165 // 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 err133 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 cmd152 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 server163 // requireBranchPrefixes refuses, before anything is created, to ask a server
147 // for a branch-limited token when it does not list token.branch_prefixes: such164 // 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 refused165 // 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{
113113 client.FeatureEventsResume: "`ermite watch` uses to resume after a reconnection: events during an outage may be missed",
114114 client.FeatureMergeRequests: "`ermite mr` needs: the commands will fail on this server",
115115 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",
116117 client.FeatureSigningKey: "`ermite signing-key show` needs: the command will fail on this server",
117118 client.FeatureSession: "`ermite web login` needs: the command will fail on this server",
118119 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 (
1717 )
1818
1919 // 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"}
2121
2222 // Every feature the client uses needs a sentence for the version warning.
2323 func TestEveryUsedFeatureExplainsWhatBreaks(t *testing.T) {
@@ -45,15 +45,15 @@ func TestVersionWithServer(t *testing.T) {
4545 f := versionFake(allFeatures...)
4646 url := f.serve(t)
4747 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)
4949 assert.Empty(t, res.stderr, "no warning when every feature is there")
5050
5151 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)
5353 }
5454
5555 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")
5757 url := f.serve(t)
5858 res := runCLI(t, envFor(url), "", "version").ok(t)
5959 assert.Contains(t, res.stdout, "v0.4.0")
@@ -61,7 +61,7 @@ func TestVersionWarnsAboutMissingFeatures(t *testing.T) {
6161 assert.NotContains(t, res.stderr, "repo.update")
6262
6363 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)
6565 assert.Contains(t, res.stderr, "events.resume", "the warning also goes to stderr with --json")
6666
6767 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) {
9595 assert.True(t, json.Valid([]byte(sc.Text())), "one JSON value per line: %s", sc.Text())
9696 }
9797 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])
9999 assert.JSONEq(t, `{"id":"3","time":"2026-10-10T12:00:00Z","type":"pushed","repo":"alpha","ref_updates":[
100100 {"ref":"refs/heads/main","old_id":"1111111111111111","new_id":"2222222222222222"},
101101 {"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 {
3131 root.AddCommand(
3232 newServeCmd(), newHookCmd(),
3333 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),
3535 newRunCmd(env), newWorkflowCmd(env), newRunnerCmd(env), newSecretCmd(env), newVariableCmd(env), newEnvironmentCmd(env), newOIDCCmd(env),
3636 )
3737 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 root37 return root

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 43

↑↓ moveenter openesc close