detached 67508cc

#4 feat(issues): native issues, owner inbox and moderated public form

state
merged · 67508cc · fast-forward
branch
feat/issue-form → main
opened
bnema ·
updated

Implements ADR 0031: native issues, the owner Inbox, and the moderated public issue form. It contains the three stacked branches feat/issues → feat/inbox → feat/issue-form, and each phase was reviewed before the next.

What it does

  • Issues:
    • Issues share the per-repo number with merge requests, so #n is one or the other and links redirect both ways.
    • New scope issue:write. Agents can create, comment, close and reopen issues without moderation.
    • Per-repo setting issues: off|owner|public, default off. A repo with issues off answers the same 404 as an unknown repo.
    • CLI ermite issue create|list|show|comment|close|reopen and repo edit --issues.
    • Web: an Issues tab, live updates, and owner forms.
  • Inbox (/-/inbox, owner only):
    • Counters at the top, then the sections Awaiting moderation, Open merge requests and Open issues.
    • Repository filter, and an owner live stream across all repositories.
    • Button at the top left of the home page, shown only when signed in.
  • Public form (mode public):
    • JavaScript required. A same-origin worker solves a proof of work signed by the server (18 to 21 bits, adaptive).
    • Honeypot field and a minimum fill time of 3 s.
    • Single-use challenge and form token.
    • Limits of 3/hour and 10/day per network, and a cap of 200 pending issues, both reserved atomically.
    • Bans last 30 days and are keyed on an HMAC of the address. No IP is written to disk.
    • Until the proof of work is valid, every refusal is the same generic 400, so nothing reveals a ban, a limit or a full queue.
    • Moderation (approve, reject, reject and ban) happens in the Inbox and over the API.
    • Approved anonymous issues render untrusted Markdown. Pending issues render as plain text.
  • Storage: schema 3 → 4. The upgrade adds issue:write to stored tokens that already hold every scope, so they keep their owner rights.

Verification

  • Phase reviews (go-reviewer) and a JS review (ts-reviewer). All findings are fixed, including:
    • comment loss after retries;
    • issue events delivered after issues were turned off;
    • the bypass of the per-network limit and of the cap under concurrency;
    • the ban oracle;
    • the emoji/NFC counters;
    • restoring the page from bfcache.
  • Final wrap-up review: every point is fixed except the deferred one listed below.
  • End-to-end test in headless Chromium at production difficulty, measured as:
    • a proof-of-work solve of 0.25 to 4.4 s;
    • moderation, then approval as #1;
    • 403 without Origin, 400 on token reuse, 429 with Retry-After;
    • the no-JS message shown instead of the form.
  • go vet, go test -race ./... (full suite), hexcheck, templ drift, buf generate, the mocks and go mod tidy all pass. All 155 JS tests pass. All commits are signed.

Before leaving draft

  • Merge MR !1 (feat/mr-notes) first. Then rebase this branch on main and extend the v3 → v4 upgrade so that it grants mr:review and issue:write to tokens holding the 9 original scopes. Also restore mr:review in client.AllScopes() and drop the temporary Unimplemented MR note handlers in rpc. Do not deploy main between the two merges: existing all-scope tokens would lose their owner rights.
  • Merge the ermite-api MR (feat/issue-form) and tag it. Replace the local replace bnema.dev/ermite-api with go get bnema.dev/ermite-api@<tag>, then run go mod tidy.
  • Deferred (GO-004, scaling): the home badge and every Inbox event compute the full Overview. This is fine on a solo instance; a lighter count-only path can come later.
  • Behind a reverse proxy, set ERMITE_TRUSTED_PROXIES. Without it, every visitor shares the proxy's per-network limit.
CommitSubjectAuthorDateSignatureStatus
67508cctest(usecase): run the issue moderation subtests in parallelbriceGPG Verified
f6f76c8build(deps): ermite-api v0.9.0briceGPG Verified
7e0e489feat(kvstore): grant mr:review and issue:write to full tokens in the schema 4 upgradebriceGPG Verified
fa37646merge: main with merge request notes into feat/issue-formbriceGPG Verified
346e37fdocs: correct issue access, limiter and comment docsbriceGPG Verified
e53302brefactor(kvstore): keep the hexcheck waiver on issue approvalbriceGPG Verified
ddab9a2fix(rpc): answer not found to non-owners before parsing moderation requestsbriceGPG Verified
f8b1e92refactor: drop the unused pending issue state, ListOpen and InboxMergeRequestbriceGPG Verified
1486f6drefactor(domain): build approved issues in the domainbriceGPG Verified
0284609perf(kvstore): page issues without decoding the whole historybriceGPG Verified
17b5f65fix(kvstore): bound the numbering locksbriceGPG Verified
b0db7adfix(usecase): refresh the inbox after a rejectionbriceGPG Verified
03a1ab8Merge branch 'feat/inbox' into feat/issue-formbriceGPG Verified
0092b37Merge branch 'feat/issues' into feat/inboxbriceGPG Verified
30f7beafix(rpc): build against the api with merge request notes and reject scope mr:reviewbriceGPG Verified
b5344bffix(usecase): verify the proof of work before any admission check and reserve quota and queue atomicallybriceGPG Verified
bc78630fix(web): count issue form limits like the server and refresh the challenge after back navigationbriceGPG Verified
e687d9cdocs: public issue form and moderationbriceGPG Verified
cce8cfafeat(web): public issue form and moderation in the inboxbriceGPG Verified
093dc62feat(web): proof of work solverbriceGPG Verified
3f9aa10feat(web): untrusted markdown for external issuesbriceGPG Verified
99db356feat(usecase): allow public issuesbriceGPG Verified
a26798bfeat(usecase): anonymous issue submission with adaptive proof of workbriceGPG Verified
ee8d39cfeat(rpc): serve pending issue moderation and wire it in the forgebriceGPG Verified
51f0135feat(usecase): add issue moderation, the form subkeys and the owner-only issue.submitted eventbriceGPG Verified
2576c64fix(clientlimit): keep the submission limits free of the domain importbriceGPG Verified
8d2ab49feat(clientlimit): add the sliding window submission limiterbriceGPG Verified
6a3e65dfeat(kvstore): store pending issues, bans and the issue form keybriceGPG Verified
dca1fe7feat(ports): add the pending issue, key, ban, submission limiter and moderation portsbriceGPG Verified
784ea9ffeat(domain): add pending issues, the issue submitted event and the form limitsbriceGPG Verified
babd8c2build(deps): use the local ermite-api issue form branchbriceGPG Verified
d66b108test(events): owner topic access matrix, token end, bearer refusal, stale indexbriceGPG Verified
063865bfix(events): deliver repo.updated and repo.deleted to owner watchers and refresh the inboxbriceGPG Verified
bc6524cfeat(web): owner inbox page and home buttonbriceGPG Verified
ab38ed2feat(events): owner topic across repositoriesbriceGPG Verified
974935dfeat(usecase): owner inbox overview across repositoriesbriceGPG Verified
e17a0e5feat(kvstore): list open merge requests and issues across repositoriesbriceGPG Verified
68460c1fix(kvstore): serialize numbering per repository so concurrent creates never exhaust retriesbriceGPG Verified
b2212f2test(app): merge the declaration with the assignment in the issue e2e test (staticcheck S1021)briceGPG Verified
2794dd4fix(kvstore): guard the repository key when updating an issue so a repo delete leaves no open-issue index keysbriceGPG Verified
02692c4fix(events): stop streaming issue events and views once issues are off or the repo is not visiblebriceGPG Verified
4f0da00fix(app): keep token preset cli working on servers without issues; return issue show write errors; correct issue help limitsbriceGPG Verified
a4f8ebdfix(usecase): report a conflict when every issue comment save conflicts instead of publishing IssueCommentedbriceGPG Verified
e3b6724merge(web): issue pages into feat/issuesbriceGPG Verified
68db1e6feat(web): annotate the issue tab and transition for hexcheckbriceGPG Verified
fd07554docs: document issuesbriceGPG Verified
582cf61feat(cli): add ermite issue commands and repo edit --issuesbriceGPG Verified
931429bfeat(web): add the issue pages, tab, redirects and live noticesbriceGPG Verified
a5d6827feat(rpc): serve IssueService and the issues setting, wire issuesbriceGPG Verified
4c8ce48feat(usecase): add the issue use cases and the issues settingbriceGPG Verified
c52c40ffeat(storage): store issues on the merge request sequence, schema 4briceGPG Verified
b41adf0feat(domain): add issues, the issues mode, text parsers and the issue:write scopebriceGPG Verified
6815c6cbuild(deps): use the local ermite-api issues branchbriceGPG Verified

Checks 8

176 files · +18416 -1025
7/8
M README.md +1 -0
M docs/cli.md +7 -2
M docs/configuration.md +1 -1
A docs/issues.md +55 -0
M docs/tokens.md +2 -1
A gen/ermite/storage/issue.pb.go +541 -0
M gen/ermite/storage/merge_request.pb.go +109 -38
M go.mod +2 -2
M go.sum +2 -2
M internal/adapters/client/client.go +5 -0
M internal/adapters/client/client_test.go +3 -3
M internal/adapters/client/features.go +4 -1
M internal/adapters/client/scopes_test.go +2 -0
M internal/adapters/kvstore/export_test.go +41 -1
A internal/adapters/kvstore/issues.go +437 -0
A internal/adapters/kvstore/issues_pending.go +408 -0
A internal/adapters/kvstore/issues_pending_test.go +271 -0
A internal/adapters/kvstore/issues_test.go +561 -0
M internal/adapters/kvstore/mergerequests.go +123 -26
M internal/adapters/kvstore/mergerequests_test.go +1 -1
M internal/adapters/kvstore/repos.go +3 -1
M internal/adapters/kvstore/store.go +75 -4
M internal/adapters/kvstore/store_test.go +4 -4
M internal/adapters/rpc/api.go +8 -3
M internal/adapters/rpc/convert.go +19 -1
M internal/adapters/rpc/convert_test.go +40 -0
A internal/adapters/rpc/issue.go +262 -0
M internal/adapters/rpc/mergerequest.go +3 -1
M internal/adapters/rpc/mergerequest_test.go +11 -11
M internal/adapters/rpc/repo.go +10 -2
M internal/adapters/web/ASSETS.md +2 -0
M internal/adapters/web/assets.go +3 -1
M internal/adapters/web/events.go +59 -3
M internal/adapters/web/events_test.go +38 -1
M internal/adapters/web/fragments.go +36 -6
M internal/adapters/web/fragments.templ +1 -1
M internal/adapters/web/fragments_templ.go +1 -1
M internal/adapters/web/home.go +3 -0
A internal/adapters/web/inbox.go +294 -0
A internal/adapters/web/inbox.templ +115 -0
A internal/adapters/web/inbox_moderation_test.go +148 -0
A internal/adapters/web/inbox_templ.go +759 -0
A internal/adapters/web/inbox_test.go +310 -0
A internal/adapters/web/issues.go +363 -0
A internal/adapters/web/issues.templ +187 -0
A internal/adapters/web/issues_form.go +276 -0
A internal/adapters/web/issues_form.templ +67 -0
A internal/adapters/web/issues_form_templ.go +421 -0
A internal/adapters/web/issues_form_test.go +345 -0
A internal/adapters/web/issues_post.go +86 -0
A internal/adapters/web/issues_templ.go +885 -0
A internal/adapters/web/issues_test.go +332 -0
A internal/adapters/web/jstest/issue-form.test.mjs +183 -0
M internal/adapters/web/keys.templ +1 -0
M internal/adapters/web/keys_templ.go +1 -1
M internal/adapters/web/markdown.go +73 -7
M internal/adapters/web/markdown_test.go +67 -0
M internal/adapters/web/mr.go +13 -8
M internal/adapters/web/mr.templ +10 -3
M internal/adapters/web/mr_post.go +8 -1
M internal/adapters/web/mr_templ.go +289 -265
M internal/adapters/web/overlays.templ +1 -1
M internal/adapters/web/overlays_templ.go +1 -1
M internal/adapters/web/pages.templ +8 -0
M internal/adapters/web/pages_templ.go +409 -363
M internal/adapters/web/repo.go +5 -0
M internal/adapters/web/session.go +16 -1
M internal/adapters/web/shell.templ +3 -0
M internal/adapters/web/shell_templ.go +180 -161
M internal/adapters/web/static/css/60-mr.css +25 -0
M internal/adapters/web/static/js/ermite.js +2 -2
A internal/adapters/web/static/js/issue-form.js +175 -0
A internal/adapters/web/static/js/pow-worker.js +72 -0
M internal/adapters/web/view.go +12 -0
M internal/adapters/web/web.go +46 -1
M internal/app/api_helpers_test.go +2 -0
@@ -31,6 +31,7 @@ type clients struct {
3131 event ermiteconnect.EventServiceClient
3232 server ermiteconnect.ServerServiceClient
3333 mr ermiteconnect.MergeRequestServiceClient
34+ issue ermiteconnect.IssueServiceClient
3435
3536 session ermiteconnect.SessionServiceClient
3637 runner ermiteconnect.RunnerServiceClient
@@ -85,6 +86,7 @@ func newClientsOn(hc connect.HTTPClient, base string, opts ...connect.ClientOpti
8586 event: ermiteconnect.NewEventServiceClient(hc, base, opts...),
8687 server: ermiteconnect.NewServerServiceClient(hc, base, opts...),
8788 mr: ermiteconnect.NewMergeRequestServiceClient(hc, base, opts...),
89+ issue: ermiteconnect.NewIssueServiceClient(hc, base, opts...),
8890
8991 session: ermiteconnect.NewSessionServiceClient(hc, base, opts...),
9092 runner: ermiteconnect.NewRunnerServiceClient(hc, base, opts...),
@@ -31,6 +31,7 @@ type clients struct {
31 event ermiteconnect.EventServiceClient31 event ermiteconnect.EventServiceClient
32 server ermiteconnect.ServerServiceClient32 server ermiteconnect.ServerServiceClient
33 mr ermiteconnect.MergeRequestServiceClient33 mr ermiteconnect.MergeRequestServiceClient
34+ issue ermiteconnect.IssueServiceClient
34 35
35 session ermiteconnect.SessionServiceClient36 session ermiteconnect.SessionServiceClient
36 runner ermiteconnect.RunnerServiceClient37 runner ermiteconnect.RunnerServiceClient
@@ -85,6 +86,7 @@ func newClientsOn(hc connect.HTTPClient, base string, opts ...connect.ClientOpti
85 event: ermiteconnect.NewEventServiceClient(hc, base, opts...),86 event: ermiteconnect.NewEventServiceClient(hc, base, opts...),
86 server: ermiteconnect.NewServerServiceClient(hc, base, opts...),87 server: ermiteconnect.NewServerServiceClient(hc, base, opts...),
87 mr: ermiteconnect.NewMergeRequestServiceClient(hc, base, opts...),88 mr: ermiteconnect.NewMergeRequestServiceClient(hc, base, opts...),
89+ issue: ermiteconnect.NewIssueServiceClient(hc, base, opts...),
88 90
89 session: ermiteconnect.NewSessionServiceClient(hc, base, opts...),91 session: ermiteconnect.NewSessionServiceClient(hc, base, opts...),
90 runner: ermiteconnect.NewRunnerServiceClient(hc, base, opts...),92 runner: ermiteconnect.NewRunnerServiceClient(hc, base, opts...),
M internal/app/api_test.go +1 -1
@@ -196,7 +196,7 @@ func TestAC2ReadAccess(t *testing.T) {
196196 for _, who := range allPrincipals {
197197 resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{}))
198198 require.NoError(t, err, who)
199- assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures"}, resp.Msg.GetFeatures(), who)
199+ assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures"}, resp.Msg.GetFeatures(), who)
200200 assert.NotEmpty(t, resp.Msg.GetVersion())
201201 }
202202 })
@@ -196,7 +196,7 @@ func TestAC2ReadAccess(t *testing.T) {
196 for _, who := range allPrincipals {196 for _, who := range allPrincipals {
197 resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{}))197 resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{}))
198 require.NoError(t, err, who)198 require.NoError(t, err, who)
199- assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures"}, resp.Msg.GetFeatures(), who)199+ assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures"}, resp.Msg.GetFeatures(), who)
200 assert.NotEmpty(t, resp.Msg.GetVersion())200 assert.NotEmpty(t, resp.Msg.GetVersion())
201 }201 }
202 })202 })
M internal/app/cli_e2e_test.go +2 -2
@@ -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) {
@@ -338,7 +338,7 @@ func TestAC6CLIEndToEnd(t *testing.T) {
338338 ver := decode[versionOut](t, res.stdout)
339339 assert.Equal(t, c.url, ver.Server.URL)
340340 assert.NotEmpty(t, ver.Server.Version)
341- assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures"}, ver.Server.Features)
341+ assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures"}, ver.Server.Features)
342342 assert.Equal(t, []string{"passkeys"}, ver.Server.MissingFeatures)
343343 stdout, stderr, code = c.bin(t, secret, "version")
344344 require.Equal(t, 0, code, stderr)
@@ -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) {
@@ -338,7 +338,7 @@ func TestAC6CLIEndToEnd(t *testing.T) {
338 ver := decode[versionOut](t, res.stdout)338 ver := decode[versionOut](t, res.stdout)
339 assert.Equal(t, c.url, ver.Server.URL)339 assert.Equal(t, c.url, ver.Server.URL)
340 assert.NotEmpty(t, ver.Server.Version)340 assert.NotEmpty(t, ver.Server.Version)
341- assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures"}, ver.Server.Features)341+ assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures"}, ver.Server.Features)
342 assert.Equal(t, []string{"passkeys"}, ver.Server.MissingFeatures)342 assert.Equal(t, []string{"passkeys"}, ver.Server.MissingFeatures)
343 stdout, stderr, code = c.bin(t, secret, "version")343 stdout, stderr, code = c.bin(t, secret, "version")
344 require.Equal(t, 0, code, stderr)344 require.Equal(t, 0, code, stderr)
M internal/app/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 +455 -0
@@ -0,0 +1,455 @@
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+// errNoIssues marks a server that lacks the issues feature.
114+var errNoIssues = errors.New("issues are not available")
115+
116+func requireIssues(ctx context.Context, c *client.Client) error {
117+ res, err := c.Server().GetVersion(ctx, connect.NewRequest(&ermite.GetVersionRequest{}))
118+ if err != nil {
119+ return c.Describe(err)
120+ }
121+ if !slices.Contains(res.Msg.GetFeatures(), client.FeatureIssues) {
122+ return fmt.Errorf("%w: the server does not support %s, so it has no issues (upgrade the server, see `ermite version`)", errNoIssues, client.FeatureIssues)
123+ }
124+ return nil
125+}
126+
127+// issueClient returns a client for a server that supports issues.
128+func (e *cliEnv) issueClient(cmd *cobra.Command) (*client.Client, error) {
129+ c, err := e.client(cmd)
130+ if err != nil {
131+ return nil, err
132+ }
133+ if err := requireIssues(e.ctx(cmd), c); err != nil {
134+ return nil, err
135+ }
136+ return c, nil
137+}
138+
139+// parseIssueNumber reads the number of an issue, written 3 or #3.
140+func parseIssueNumber(s string) (int64, error) {
141+ n, err := strconv.ParseInt(strings.TrimPrefix(strings.TrimSpace(s), "#"), 10, 64)
142+ if err != nil || n < 1 {
143+ return 0, fmt.Errorf("invalid issue number %q: use a positive number such as 3 or #3", s)
144+ }
145+ return n, nil
146+}
147+
148+func completeIssueRepoThenNumber(env *cliEnv) func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
149+ return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
150+ switch len(args) {
151+ case 0:
152+ return repoNames(env, cmd, toComplete), cobra.ShellCompDirectiveNoFileComp
153+ case 1:
154+ c, err := env.completionClient(cmd)
155+ if err != nil {
156+ return nil, cobra.ShellCompDirectiveNoFileComp
157+ }
158+ issues, err := listIssues(env.ctx(cmd), c, args[0], ermite.IssueState_ISSUE_STATE_UNSPECIFIED)
159+ if err != nil {
160+ return nil, cobra.ShellCompDirectiveNoFileComp
161+ }
162+ var out []string
163+ for _, i := range issues {
164+ if n := strconv.FormatInt(i.GetNumber(), 10); strings.HasPrefix(n, toComplete) {
165+ out = append(out, n+"\t"+issueStateName(i.GetState())+", "+clean(i.GetTitle()))
166+ }
167+ }
168+ return out, cobra.ShellCompDirectiveNoFileComp
169+ }
170+ return nil, cobra.ShellCompDirectiveNoFileComp
171+ }
172+}
173+
174+func listIssues(ctx context.Context, c *client.Client, repo string, state ermite.IssueState) ([]*ermite.Issue, error) {
175+ return collect(ctx, func(token string) ([]*ermite.Issue, string, error) {
176+ res, err := c.Issues().ListIssues(ctx, connect.NewRequest(&ermite.ListIssuesRequest{
177+ Repo: repo, State: state, PageToken: token,
178+ }))
179+ if err != nil {
180+ return nil, "", c.Describe(err)
181+ }
182+ return res.Msg.GetIssues(), res.Msg.GetNextPageToken(), nil
183+ })
184+}
185+
186+// outputIssue prints an issue as JSON, or as one confirmation line.
187+func outputIssue(cmd *cobra.Command, i *ermite.Issue, asJSON bool, verb string) error {
188+ if asJSON {
189+ return writeJSON(cmd.OutOrStdout(), toJSONIssue(i))
190+ }
191+ _, err := fmt.Fprintf(cmd.OutOrStdout(), "%s %s#%d (%s)\n", verb, clean(i.GetRepo()), i.GetNumber(), clean(i.GetTitle()))
192+ return err
193+}
194+
195+func newIssueCreateCmd(env *cliEnv) *cobra.Command {
196+ var title, body, bodyFile string
197+ cmd := &cobra.Command{
198+ Use: "create <repo>",
199+ Short: "Open an issue",
200+ Long: "Open an issue. The title is one line of at most 120 characters, the body Markdown of at most\n" +
201+ "10,000 characters; disallowed control characters are stripped (a body may hold line breaks).\n" +
202+ "--body - and --body-file - read the body from standard input; --body-file reads it\n" +
203+ "from a file otherwise. Needs the issue:write scope." + jsonIssueHelp,
204+ Args: cobra.ExactArgs(1),
205+ ValidArgsFunction: completeRepoNames(env),
206+ }
207+ cmd.Flags().StringVar(&title, "title", "", "title of the issue (required)")
208+ cmd.Flags().StringVar(&body, "body", "", "body, or - to read standard input")
209+ cmd.Flags().StringVar(&bodyFile, "body-file", "", "read the body from this file, or - for standard input")
210+ cmd.MarkFlagsMutuallyExclusive("body", "body-file")
211+ _ = cmd.MarkFlagRequired("title")
212+ _ = cmd.MarkFlagFilename("body-file")
213+ asJSON := addJSONFlag(cmd)
214+ cmd.RunE = func(cmd *cobra.Command, args []string) error {
215+ text := body
216+ if cmd.Flags().Changed("body-file") {
217+ if bodyFile == "-" {
218+ text = "-"
219+ } else {
220+ b, err := os.ReadFile(bodyFile) //nolint:gosec // a path typed by the user
221+ if err != nil {
222+ return fmt.Errorf("read --body-file: %w", err)
223+ }
224+ text = strings.TrimRight(string(b), "\r\n")
225+ }
226+ }
227+ text, err := readText(cmd, text)
228+ if err != nil {
229+ return err
230+ }
231+ c, err := env.issueClient(cmd)
232+ if err != nil {
233+ return err
234+ }
235+ res, err := c.Issues().CreateIssue(env.ctx(cmd), connect.NewRequest(&ermite.CreateIssueRequest{
236+ Repo: args[0], Title: title, Body: text,
237+ }))
238+ if err != nil {
239+ return c.Describe(err)
240+ }
241+ return outputIssue(cmd, res.Msg.GetIssue(), *asJSON, "opened")
242+ }
243+ return cmd
244+}
245+
246+func newIssueListCmd(env *cliEnv) *cobra.Command {
247+ var state string
248+ cmd := &cobra.Command{
249+ Use: "list <repo>",
250+ Short: "List the issues of a repository",
251+ Long: "List the issues of a repository, newest first. --state restricts them to open or closed ones\n" +
252+ "(default: open; all shows every state)." + jsonIssueHelp + "\n--json prints an array of issues.",
253+ Args: cobra.ExactArgs(1),
254+ ValidArgsFunction: completeRepoNames(env),
255+ }
256+ cmd.Flags().StringVar(&state, "state", stateOpen, "state: open, closed or all")
257+ _ = cmd.RegisterFlagCompletionFunc("state", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
258+ return []string{stateOpen, stateClosed, stateAll}, cobra.ShellCompDirectiveNoFileComp
259+ })
260+ asJSON := addJSONFlag(cmd)
261+ cmd.RunE = func(cmd *cobra.Command, args []string) error {
262+ st, err := parseIssueState(state)
263+ if err != nil {
264+ return err
265+ }
266+ c, err := env.issueClient(cmd)
267+ if err != nil {
268+ return err
269+ }
270+ issues, err := listIssues(env.ctx(cmd), c, args[0], st)
271+ if err != nil {
272+ return err
273+ }
274+ if *asJSON {
275+ out := make([]jsonIssue, 0, len(issues))
276+ for _, i := range issues {
277+ out = append(out, toJSONIssue(i))
278+ }
279+ return writeJSON(cmd.OutOrStdout(), out)
280+ }
281+ if len(issues) == 0 {
282+ noticef(cmd, "no issues")
283+ return nil
284+ }
285+ rows := make([][]string, 0, len(issues))
286+ for _, i := range issues {
287+ rows = append(rows, []string{
288+ "#" + strconv.FormatInt(i.GetNumber(), 10), issueStateName(i.GetState()), i.GetAuthor(),
289+ strconv.Itoa(int(i.GetCommentCount())), shortTime(i.GetUpdatedAt(), "-"), i.GetTitle(),
290+ })
291+ }
292+ return table(cmd.OutOrStdout(), []string{"NUMBER", "STATE", "AUTHOR", "COMMENTS", "UPDATED", "TITLE"}, rows)
293+ }
294+ return cmd
295+}
296+
297+func newIssueShowCmd(env *cliEnv) *cobra.Command {
298+ cmd := &cobra.Command{
299+ Use: "show <repo> <number>",
300+ Short: "Show an issue with its comments",
301+ Long: "Show an issue: its fields, its body and its comments, oldest first." + jsonIssueHelp + jsonIssueCommentHelp +
302+ "\n--json adds comments to the issue fields.",
303+ Args: cobra.ExactArgs(2),
304+ ValidArgsFunction: completeIssueRepoThenNumber(env),
305+ }
306+ asJSON := addJSONFlag(cmd)
307+ cmd.RunE = func(cmd *cobra.Command, args []string) error {
308+ n, err := parseIssueNumber(args[1])
309+ if err != nil {
310+ return err
311+ }
312+ c, err := env.issueClient(cmd)
313+ if err != nil {
314+ return err
315+ }
316+ res, err := c.Issues().GetIssue(env.ctx(cmd), connect.NewRequest(&ermite.GetIssueRequest{Repo: args[0], Number: n}))
317+ if err != nil {
318+ return c.Describe(err)
319+ }
320+ if *asJSON {
321+ out := jsonIssueDetail{jsonIssue: toJSONIssue(res.Msg.GetIssue()), Comments: []jsonIssueComment{}}
322+ for _, cm := range res.Msg.GetComments() {
323+ out.Comments = append(out.Comments, toJSONIssueComment(cm))
324+ }
325+ return writeJSON(cmd.OutOrStdout(), out)
326+ }
327+ return printIssueDetail(cmd.OutOrStdout(), res.Msg)
328+ }
329+ return cmd
330+}
331+
332+func printIssueDetail(w io.Writer, d *ermite.GetIssueResponse) error {
333+ i := d.GetIssue()
334+ body := i.GetBody()
335+ if body == "" {
336+ body = "-"
337+ }
338+ err := fields(w, [][2]string{
339+ {"issue", fmt.Sprintf("%s#%d", i.GetRepo(), i.GetNumber())},
340+ {"title", i.GetTitle()},
341+ {"state", issueStateName(i.GetState())},
342+ {"author", i.GetAuthor()},
343+ {"created", shortTime(i.GetCreatedAt(), "-")},
344+ {"updated", shortTime(i.GetUpdatedAt(), "-")},
345+ })
346+ if err != nil {
347+ return err
348+ }
349+ if _, err := fmt.Fprintf(w, "\n%s\n", indentText(body)); err != nil {
350+ return err
351+ }
352+ if _, err := fmt.Fprintf(w, "\ncomments (%d)\n", len(d.GetComments())); err != nil {
353+ return err
354+ }
355+ for _, c := range d.GetComments() {
356+ if _, err := fmt.Fprintf(w, " #%s %s, %s\n%s\n", c.GetId(), clean(c.GetAuthor()), shortTime(c.GetCreatedAt(), "-"), indentText(c.GetText())); err != nil {
357+ return err
358+ }
359+ }
360+ return nil
361+}
362+
363+func newIssueCommentCmd(env *cliEnv) *cobra.Command {
364+ var message string
365+ cmd := &cobra.Command{
366+ Use: "comment <repo> <number>",
367+ Short: "Comment on an issue",
368+ Long: "Add a plain-text comment (at most 10,000 characters; disallowed control characters are stripped).\n" +
369+ "-m - reads it from standard input. Needs the issue:write scope." + jsonIssueCommentHelp,
370+ Args: cobra.ExactArgs(2),
371+ ValidArgsFunction: completeIssueRepoThenNumber(env),
372+ }
373+ cmd.Flags().StringVarP(&message, "message", "m", "", "text of the comment, or - to read standard input (required)")
374+ cmd.Flags().StringVar(&message, "text", "", "alias of --message")
375+ _ = cmd.Flags().MarkHidden("text")
376+ asJSON := addJSONFlag(cmd)
377+ cmd.RunE = func(cmd *cobra.Command, args []string) error {
378+ n, err := parseIssueNumber(args[1])
379+ if err != nil {
380+ return err
381+ }
382+ if !cmd.Flags().Changed("message") && !cmd.Flags().Changed("text") {
383+ return errors.New("the comment is required: give it with -m, or -m - for standard input")
384+ }
385+ text, err := readText(cmd, message)
386+ if err != nil {
387+ return err
388+ }
389+ c, err := env.issueClient(cmd)
390+ if err != nil {
391+ return err
392+ }
393+ res, err := c.Issues().AddIssueComment(env.ctx(cmd), connect.NewRequest(&ermite.AddIssueCommentRequest{
394+ Repo: args[0], Number: n, Text: text,
395+ }))
396+ if err != nil {
397+ return c.Describe(err)
398+ }
399+ if *asJSON {
400+ return writeJSON(cmd.OutOrStdout(), toJSONIssueComment(res.Msg.GetComment()))
401+ }
402+ _, err = fmt.Fprintf(cmd.OutOrStdout(), "comment %s added to %s#%d\n", res.Msg.GetComment().GetId(), clean(args[0]), n)
403+ return err
404+ }
405+ return cmd
406+}
407+
408+func newIssueCloseCmd(env *cliEnv) *cobra.Command {
409+ return newIssueStateCmd(env, "close", "closed", "Close an issue", "Close an open issue. `ermite issue reopen` opens it again. Needs the issue:write scope.",
410+ func(ctx context.Context, c *client.Client, repo string, n int64) (*ermite.Issue, error) {
411+ res, err := c.Issues().CloseIssue(ctx, connect.NewRequest(&ermite.CloseIssueRequest{Repo: repo, Number: n}))
412+ if err != nil {
413+ return nil, err
414+ }
415+ return res.Msg.GetIssue(), nil
416+ })
417+}
418+
419+func newIssueReopenCmd(env *cliEnv) *cobra.Command {
420+ return newIssueStateCmd(env, "reopen", "reopened", "Reopen a closed issue", "Reopen a closed issue. Needs the issue:write scope.",
421+ func(ctx context.Context, c *client.Client, repo string, n int64) (*ermite.Issue, error) {
422+ res, err := c.Issues().ReopenIssue(ctx, connect.NewRequest(&ermite.ReopenIssueRequest{Repo: repo, Number: n}))
423+ if err != nil {
424+ return nil, err
425+ }
426+ return res.Msg.GetIssue(), nil
427+ })
428+}
429+
430+func newIssueStateCmd(env *cliEnv, use, verb, short, long string, do func(context.Context, *client.Client, string, int64) (*ermite.Issue, error)) *cobra.Command {
431+ cmd := &cobra.Command{
432+ Use: use + " <repo> <number>",
433+ Short: short,
434+ Long: long + jsonIssueHelp,
435+ Args: cobra.ExactArgs(2),
436+ ValidArgsFunction: completeIssueRepoThenNumber(env),
437+ }
438+ asJSON := addJSONFlag(cmd)
439+ cmd.RunE = func(cmd *cobra.Command, args []string) error {
440+ n, err := parseIssueNumber(args[1])
441+ if err != nil {
442+ return err
443+ }
444+ c, err := env.issueClient(cmd)
445+ if err != nil {
446+ return err
447+ }
448+ i, err := do(env.ctx(cmd), c, args[0], n)
449+ if err != nil {
450+ return c.Describe(err)
451+ }
452+ return outputIssue(cmd, i, *asJSON, verb)
453+ }
454+ return cmd
455+}
@@ -0,0 +1,455 @@
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+// errNoIssues marks a server that lacks the issues feature.
114+var errNoIssues = errors.New("issues are not available")
115+
116+func requireIssues(ctx context.Context, c *client.Client) error {
117+ res, err := c.Server().GetVersion(ctx, connect.NewRequest(&ermite.GetVersionRequest{}))
118+ if err != nil {
119+ return c.Describe(err)
120+ }
121+ if !slices.Contains(res.Msg.GetFeatures(), client.FeatureIssues) {
122+ return fmt.Errorf("%w: the server does not support %s, so it has no issues (upgrade the server, see `ermite version`)", errNoIssues, client.FeatureIssues)
123+ }
124+ return nil
125+}
126+
127+// issueClient returns a client for a server that supports issues.
128+func (e *cliEnv) issueClient(cmd *cobra.Command) (*client.Client, error) {
129+ c, err := e.client(cmd)
130+ if err != nil {
131+ return nil, err
132+ }
133+ if err := requireIssues(e.ctx(cmd), c); err != nil {
134+ return nil, err
135+ }
136+ return c, nil
137+}
138+
139+// parseIssueNumber reads the number of an issue, written 3 or #3.
140+func parseIssueNumber(s string) (int64, error) {
141+ n, err := strconv.ParseInt(strings.TrimPrefix(strings.TrimSpace(s), "#"), 10, 64)
142+ if err != nil || n < 1 {
143+ return 0, fmt.Errorf("invalid issue number %q: use a positive number such as 3 or #3", s)
144+ }
145+ return n, nil
146+}
147+
148+func completeIssueRepoThenNumber(env *cliEnv) func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
149+ return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
150+ switch len(args) {
151+ case 0:
152+ return repoNames(env, cmd, toComplete), cobra.ShellCompDirectiveNoFileComp
153+ case 1:
154+ c, err := env.completionClient(cmd)
155+ if err != nil {
156+ return nil, cobra.ShellCompDirectiveNoFileComp
157+ }
158+ issues, err := listIssues(env.ctx(cmd), c, args[0], ermite.IssueState_ISSUE_STATE_UNSPECIFIED)
159+ if err != nil {
160+ return nil, cobra.ShellCompDirectiveNoFileComp
161+ }
162+ var out []string
163+ for _, i := range issues {
164+ if n := strconv.FormatInt(i.GetNumber(), 10); strings.HasPrefix(n, toComplete) {
165+ out = append(out, n+"\t"+issueStateName(i.GetState())+", "+clean(i.GetTitle()))
166+ }
167+ }
168+ return out, cobra.ShellCompDirectiveNoFileComp
169+ }
170+ return nil, cobra.ShellCompDirectiveNoFileComp
171+ }
172+}
173+
174+func listIssues(ctx context.Context, c *client.Client, repo string, state ermite.IssueState) ([]*ermite.Issue, error) {
175+ return collect(ctx, func(token string) ([]*ermite.Issue, string, error) {
176+ res, err := c.Issues().ListIssues(ctx, connect.NewRequest(&ermite.ListIssuesRequest{
177+ Repo: repo, State: state, PageToken: token,
178+ }))
179+ if err != nil {
180+ return nil, "", c.Describe(err)
181+ }
182+ return res.Msg.GetIssues(), res.Msg.GetNextPageToken(), nil
183+ })
184+}
185+
186+// outputIssue prints an issue as JSON, or as one confirmation line.
187+func outputIssue(cmd *cobra.Command, i *ermite.Issue, asJSON bool, verb string) error {
188+ if asJSON {
189+ return writeJSON(cmd.OutOrStdout(), toJSONIssue(i))
190+ }
191+ _, err := fmt.Fprintf(cmd.OutOrStdout(), "%s %s#%d (%s)\n", verb, clean(i.GetRepo()), i.GetNumber(), clean(i.GetTitle()))
192+ return err
193+}
194+
195+func newIssueCreateCmd(env *cliEnv) *cobra.Command {
196+ var title, body, bodyFile string
197+ cmd := &cobra.Command{
198+ Use: "create <repo>",
199+ Short: "Open an issue",
200+ Long: "Open an issue. The title is one line of at most 120 characters, the body Markdown of at most\n" +
201+ "10,000 characters; disallowed control characters are stripped (a body may hold line breaks).\n" +
202+ "--body - and --body-file - read the body from standard input; --body-file reads it\n" +
203+ "from a file otherwise. Needs the issue:write scope." + jsonIssueHelp,
204+ Args: cobra.ExactArgs(1),
205+ ValidArgsFunction: completeRepoNames(env),
206+ }
207+ cmd.Flags().StringVar(&title, "title", "", "title of the issue (required)")
208+ cmd.Flags().StringVar(&body, "body", "", "body, or - to read standard input")
209+ cmd.Flags().StringVar(&bodyFile, "body-file", "", "read the body from this file, or - for standard input")
210+ cmd.MarkFlagsMutuallyExclusive("body", "body-file")
211+ _ = cmd.MarkFlagRequired("title")
212+ _ = cmd.MarkFlagFilename("body-file")
213+ asJSON := addJSONFlag(cmd)
214+ cmd.RunE = func(cmd *cobra.Command, args []string) error {
215+ text := body
216+ if cmd.Flags().Changed("body-file") {
217+ if bodyFile == "-" {
218+ text = "-"
219+ } else {
220+ b, err := os.ReadFile(bodyFile) //nolint:gosec // a path typed by the user
221+ if err != nil {
222+ return fmt.Errorf("read --body-file: %w", err)
223+ }
224+ text = strings.TrimRight(string(b), "\r\n")
225+ }
226+ }
227+ text, err := readText(cmd, text)
228+ if err != nil {
229+ return err
230+ }
231+ c, err := env.issueClient(cmd)
232+ if err != nil {
233+ return err
234+ }
235+ res, err := c.Issues().CreateIssue(env.ctx(cmd), connect.NewRequest(&ermite.CreateIssueRequest{
236+ Repo: args[0], Title: title, Body: text,
237+ }))
238+ if err != nil {
239+ return c.Describe(err)
240+ }
241+ return outputIssue(cmd, res.Msg.GetIssue(), *asJSON, "opened")
242+ }
243+ return cmd
244+}
245+
246+func newIssueListCmd(env *cliEnv) *cobra.Command {
247+ var state string
248+ cmd := &cobra.Command{
249+ Use: "list <repo>",
250+ Short: "List the issues of a repository",
251+ Long: "List the issues of a repository, newest first. --state restricts them to open or closed ones\n" +
252+ "(default: open; all shows every state)." + jsonIssueHelp + "\n--json prints an array of issues.",
253+ Args: cobra.ExactArgs(1),
254+ ValidArgsFunction: completeRepoNames(env),
255+ }
256+ cmd.Flags().StringVar(&state, "state", stateOpen, "state: open, closed or all")
257+ _ = cmd.RegisterFlagCompletionFunc("state", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
258+ return []string{stateOpen, stateClosed, stateAll}, cobra.ShellCompDirectiveNoFileComp
259+ })
260+ asJSON := addJSONFlag(cmd)
261+ cmd.RunE = func(cmd *cobra.Command, args []string) error {
262+ st, err := parseIssueState(state)
263+ if err != nil {
264+ return err
265+ }
266+ c, err := env.issueClient(cmd)
267+ if err != nil {
268+ return err
269+ }
270+ issues, err := listIssues(env.ctx(cmd), c, args[0], st)
271+ if err != nil {
272+ return err
273+ }
274+ if *asJSON {
275+ out := make([]jsonIssue, 0, len(issues))
276+ for _, i := range issues {
277+ out = append(out, toJSONIssue(i))
278+ }
279+ return writeJSON(cmd.OutOrStdout(), out)
280+ }
281+ if len(issues) == 0 {
282+ noticef(cmd, "no issues")
283+ return nil
284+ }
285+ rows := make([][]string, 0, len(issues))
286+ for _, i := range issues {
287+ rows = append(rows, []string{
288+ "#" + strconv.FormatInt(i.GetNumber(), 10), issueStateName(i.GetState()), i.GetAuthor(),
289+ strconv.Itoa(int(i.GetCommentCount())), shortTime(i.GetUpdatedAt(), "-"), i.GetTitle(),
290+ })
291+ }
292+ return table(cmd.OutOrStdout(), []string{"NUMBER", "STATE", "AUTHOR", "COMMENTS", "UPDATED", "TITLE"}, rows)
293+ }
294+ return cmd
295+}
296+
297+func newIssueShowCmd(env *cliEnv) *cobra.Command {
298+ cmd := &cobra.Command{
299+ Use: "show <repo> <number>",
300+ Short: "Show an issue with its comments",
301+ Long: "Show an issue: its fields, its body and its comments, oldest first." + jsonIssueHelp + jsonIssueCommentHelp +
302+ "\n--json adds comments to the issue fields.",
303+ Args: cobra.ExactArgs(2),
304+ ValidArgsFunction: completeIssueRepoThenNumber(env),
305+ }
306+ asJSON := addJSONFlag(cmd)
307+ cmd.RunE = func(cmd *cobra.Command, args []string) error {
308+ n, err := parseIssueNumber(args[1])
309+ if err != nil {
310+ return err
311+ }
312+ c, err := env.issueClient(cmd)
313+ if err != nil {
314+ return err
315+ }
316+ res, err := c.Issues().GetIssue(env.ctx(cmd), connect.NewRequest(&ermite.GetIssueRequest{Repo: args[0], Number: n}))
317+ if err != nil {
318+ return c.Describe(err)
319+ }
320+ if *asJSON {
321+ out := jsonIssueDetail{jsonIssue: toJSONIssue(res.Msg.GetIssue()), Comments: []jsonIssueComment{}}
322+ for _, cm := range res.Msg.GetComments() {
323+ out.Comments = append(out.Comments, toJSONIssueComment(cm))
324+ }
325+ return writeJSON(cmd.OutOrStdout(), out)
326+ }
327+ return printIssueDetail(cmd.OutOrStdout(), res.Msg)
328+ }
329+ return cmd
330+}
331+
332+func printIssueDetail(w io.Writer, d *ermite.GetIssueResponse) error {
333+ i := d.GetIssue()
334+ body := i.GetBody()
335+ if body == "" {
336+ body = "-"
337+ }
338+ err := fields(w, [][2]string{
339+ {"issue", fmt.Sprintf("%s#%d", i.GetRepo(), i.GetNumber())},
340+ {"title", i.GetTitle()},
341+ {"state", issueStateName(i.GetState())},
342+ {"author", i.GetAuthor()},
343+ {"created", shortTime(i.GetCreatedAt(), "-")},
344+ {"updated", shortTime(i.GetUpdatedAt(), "-")},
345+ })
346+ if err != nil {
347+ return err
348+ }
349+ if _, err := fmt.Fprintf(w, "\n%s\n", indentText(body)); err != nil {
350+ return err
351+ }
352+ if _, err := fmt.Fprintf(w, "\ncomments (%d)\n", len(d.GetComments())); err != nil {
353+ return err
354+ }
355+ for _, c := range d.GetComments() {
356+ if _, err := fmt.Fprintf(w, " #%s %s, %s\n%s\n", c.GetId(), clean(c.GetAuthor()), shortTime(c.GetCreatedAt(), "-"), indentText(c.GetText())); err != nil {
357+ return err
358+ }
359+ }
360+ return nil
361+}
362+
363+func newIssueCommentCmd(env *cliEnv) *cobra.Command {
364+ var message string
365+ cmd := &cobra.Command{
366+ Use: "comment <repo> <number>",
367+ Short: "Comment on an issue",
368+ Long: "Add a plain-text comment (at most 10,000 characters; disallowed control characters are stripped).\n" +
369+ "-m - reads it from standard input. Needs the issue:write scope." + jsonIssueCommentHelp,
370+ Args: cobra.ExactArgs(2),
371+ ValidArgsFunction: completeIssueRepoThenNumber(env),
372+ }
373+ cmd.Flags().StringVarP(&message, "message", "m", "", "text of the comment, or - to read standard input (required)")
374+ cmd.Flags().StringVar(&message, "text", "", "alias of --message")
375+ _ = cmd.Flags().MarkHidden("text")
376+ asJSON := addJSONFlag(cmd)
377+ cmd.RunE = func(cmd *cobra.Command, args []string) error {
378+ n, err := parseIssueNumber(args[1])
379+ if err != nil {
380+ return err
381+ }
382+ if !cmd.Flags().Changed("message") && !cmd.Flags().Changed("text") {
383+ return errors.New("the comment is required: give it with -m, or -m - for standard input")
384+ }
385+ text, err := readText(cmd, message)
386+ if err != nil {
387+ return err
388+ }
389+ c, err := env.issueClient(cmd)
390+ if err != nil {
391+ return err
392+ }
393+ res, err := c.Issues().AddIssueComment(env.ctx(cmd), connect.NewRequest(&ermite.AddIssueCommentRequest{
394+ Repo: args[0], Number: n, Text: text,
395+ }))
396+ if err != nil {
397+ return c.Describe(err)
398+ }
399+ if *asJSON {
400+ return writeJSON(cmd.OutOrStdout(), toJSONIssueComment(res.Msg.GetComment()))
401+ }
402+ _, err = fmt.Fprintf(cmd.OutOrStdout(), "comment %s added to %s#%d\n", res.Msg.GetComment().GetId(), clean(args[0]), n)
403+ return err
404+ }
405+ return cmd
406+}
407+
408+func newIssueCloseCmd(env *cliEnv) *cobra.Command {
409+ return newIssueStateCmd(env, "close", "closed", "Close an issue", "Close an open issue. `ermite issue reopen` opens it again. Needs the issue:write scope.",
410+ func(ctx context.Context, c *client.Client, repo string, n int64) (*ermite.Issue, error) {
411+ res, err := c.Issues().CloseIssue(ctx, connect.NewRequest(&ermite.CloseIssueRequest{Repo: repo, Number: n}))
412+ if err != nil {
413+ return nil, err
414+ }
415+ return res.Msg.GetIssue(), nil
416+ })
417+}
418+
419+func newIssueReopenCmd(env *cliEnv) *cobra.Command {
420+ return newIssueStateCmd(env, "reopen", "reopened", "Reopen a closed issue", "Reopen a closed issue. Needs the issue:write scope.",
421+ func(ctx context.Context, c *client.Client, repo string, n int64) (*ermite.Issue, error) {
422+ res, err := c.Issues().ReopenIssue(ctx, connect.NewRequest(&ermite.ReopenIssueRequest{Repo: repo, Number: n}))
423+ if err != nil {
424+ return nil, err
425+ }
426+ return res.Msg.GetIssue(), nil
427+ })
428+}
429+
430+func newIssueStateCmd(env *cliEnv, use, verb, short, long string, do func(context.Context, *client.Client, string, int64) (*ermite.Issue, error)) *cobra.Command {
431+ cmd := &cobra.Command{
432+ Use: use + " <repo> <number>",
433+ Short: short,
434+ Long: long + jsonIssueHelp,
435+ Args: cobra.ExactArgs(2),
436+ ValidArgsFunction: completeIssueRepoThenNumber(env),
437+ }
438+ asJSON := addJSONFlag(cmd)
439+ cmd.RunE = func(cmd *cobra.Command, args []string) error {
440+ n, err := parseIssueNumber(args[1])
441+ if err != nil {
442+ return err
443+ }
444+ c, err := env.issueClient(cmd)
445+ if err != nil {
446+ return err
447+ }
448+ i, err := do(env.ctx(cmd), c, args[0], n)
449+ if err != nil {
450+ return c.Describe(err)
451+ }
452+ return outputIssue(cmd, i, *asJSON, verb)
453+ }
454+ return cmd
455+}
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", "--json").ok(t)
63+ assert.Equal(t, "public", decode[struct {
64+ Issues string `json:"issues"`
65+ }](t, res.stdout).Issues)
66+ c.cli(t, adminToken, "repo", "edit", "proj", "--issues", "owner").ok(t)
67+
68+ // A merge request first: the issue takes the next number.
69+ wt := c.workTree("main", "README.md")
70+ url := c.urlAuth(adminToken, "/proj.git")
71+ c.mustGit(wt, "push", "-q", url, "main")
72+ c.mustGit(wt, "checkout", "-q", "-b", "feat/x")
73+ c.writeFile(wt, "x.txt", []byte("x\n"))
74+ c.mustGit(wt, "push", "-q", url, "feat/x")
75+ c.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/x", "--title", "First").ok(t)
76+
77+ res = c.cli(t, agent, "issue", "create", "proj", "--title", "Crash on start", "--body", "It crashes.\nSecond line.", "--json").ok(t)
78+ assert.Equal(t, issueKeys, keys(t, res.stdout))
79+ issue := decode[issueOut](t, res.stdout)
80+ assert.EqualValues(t, 2, issue.Number, "shared numbering with merge requests")
81+ assert.Equal(t, "open", issue.State)
82+ assert.Equal(t, "agent-7", issue.Author)
83+ assert.Equal(t, "It crashes.\nSecond line.", issue.Body)
84+
85+ res = runCLI(t, c.vars(agent), "from stdin\n", "issue", "create", "proj", "--title", "Second", "--body-file", "-").ok(t)
86+ assert.Equal(t, "opened proj#3 (Second)\n", res.stdout)
87+
88+ // list.
89+ res = c.cli(t, adminToken, "issue", "list", "proj", "--json").ok(t)
90+ list := decode[[]issueOut](t, res.stdout)
91+ require.Len(t, list, 2)
92+ assert.EqualValues(t, 3, list[0].Number, "highest number first")
93+ res = c.cli(t, adminToken, "issue", "list", "proj").ok(t)
94+ lines := strings.Split(strings.TrimSpace(res.stdout), "\n")
95+ require.Len(t, lines, 3)
96+ assert.Regexp(t, `^NUMBER\s+STATE\s+AUTHOR\s+COMMENTS\s+UPDATED\s+TITLE$`, lines[0])
97+ 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])
98+
99+ // comment, then show with it.
100+ res = c.cli(t, agent, "issue", "comment", "proj", "2", "-m", "Reproduced.", "--json").ok(t)
101+ cm := decode[issueCommentOut](t, res.stdout)
102+ assert.Equal(t, "agent-7", cm.Author)
103+ assert.Equal(t, "Reproduced.", cm.Text)
104+ res = c.cli(t, adminToken, "issue", "comment", "proj", "#2", "-m", "Thanks.").ok(t)
105+ assert.Regexp(t, `^comment \S+ added to proj#2\n$`, res.stdout)
106+ res = c.cli(t, adminToken, "issue", "show", "proj", "2", "--json").ok(t)
107+ type showOut struct {
108+ issueOut
109+ Comments []issueCommentOut `json:"comments"`
110+ }
111+ show := decode[showOut](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", "--json").ok(t)
63+ assert.Equal(t, "public", decode[struct {
64+ Issues string `json:"issues"`
65+ }](t, res.stdout).Issues)
66+ c.cli(t, adminToken, "repo", "edit", "proj", "--issues", "owner").ok(t)
67+
68+ // A merge request first: the issue takes the next number.
69+ wt := c.workTree("main", "README.md")
70+ url := c.urlAuth(adminToken, "/proj.git")
71+ c.mustGit(wt, "push", "-q", url, "main")
72+ c.mustGit(wt, "checkout", "-q", "-b", "feat/x")
73+ c.writeFile(wt, "x.txt", []byte("x\n"))
74+ c.mustGit(wt, "push", "-q", url, "feat/x")
75+ c.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/x", "--title", "First").ok(t)
76+
77+ res = c.cli(t, agent, "issue", "create", "proj", "--title", "Crash on start", "--body", "It crashes.\nSecond line.", "--json").ok(t)
78+ assert.Equal(t, issueKeys, keys(t, res.stdout))
79+ issue := decode[issueOut](t, res.stdout)
80+ assert.EqualValues(t, 2, issue.Number, "shared numbering with merge requests")
81+ assert.Equal(t, "open", issue.State)
82+ assert.Equal(t, "agent-7", issue.Author)
83+ assert.Equal(t, "It crashes.\nSecond line.", issue.Body)
84+
85+ res = runCLI(t, c.vars(agent), "from stdin\n", "issue", "create", "proj", "--title", "Second", "--body-file", "-").ok(t)
86+ assert.Equal(t, "opened proj#3 (Second)\n", res.stdout)
87+
88+ // list.
89+ res = c.cli(t, adminToken, "issue", "list", "proj", "--json").ok(t)
90+ list := decode[[]issueOut](t, res.stdout)
91+ require.Len(t, list, 2)
92+ assert.EqualValues(t, 3, list[0].Number, "highest number first")
93+ res = c.cli(t, adminToken, "issue", "list", "proj").ok(t)
94+ lines := strings.Split(strings.TrimSpace(res.stdout), "\n")
95+ require.Len(t, lines, 3)
96+ assert.Regexp(t, `^NUMBER\s+STATE\s+AUTHOR\s+COMMENTS\s+UPDATED\s+TITLE$`, lines[0])
97+ 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])
98+
99+ // comment, then show with it.
100+ res = c.cli(t, agent, "issue", "comment", "proj", "2", "-m", "Reproduced.", "--json").ok(t)
101+ cm := decode[issueCommentOut](t, res.stdout)
102+ assert.Equal(t, "agent-7", cm.Author)
103+ assert.Equal(t, "Reproduced.", cm.Text)
104+ res = c.cli(t, adminToken, "issue", "comment", "proj", "#2", "-m", "Thanks.").ok(t)
105+ assert.Regexp(t, `^comment \S+ added to proj#2\n$`, res.stdout)
106+ res = c.cli(t, adminToken, "issue", "show", "proj", "2", "--json").ok(t)
107+ type showOut struct {
108+ issueOut
109+ Comments []issueCommentOut `json:"comments"`
110+ }
111+ show := decode[showOut](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 +264 -0
@@ -0,0 +1,264 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "errors"
7+ "os"
8+ "path/filepath"
9+ "testing"
10+
11+ "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/require"
13+
14+ "bnema.dev/ermite-api/gen/ermite"
15+)
16+
17+var issueOne = &ermite.Issue{
18+ Repo: "alpha", Number: 2, Title: "Crash on start", Body: "It crashes.", State: ermite.IssueState_ISSUE_STATE_OPEN,
19+ Author: "owner", CreatedAt: ts("2026-10-01T10:00:00Z"), UpdatedAt: ts("2026-10-02T11:30:00Z"), CommentCount: 1,
20+}
21+
22+func withIssues(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) {
23+ return &ermite.GetVersionResponse{Version: "v0.5.0", Features: []string{"issues"}}, nil
24+}
25+
26+func TestIssueCreateMapsFlags(t *testing.T) {
27+ f := &fakeAPI{getVersion: withIssues, createIssue: func(*ermite.CreateIssueRequest) (*ermite.CreateIssueResponse, error) {
28+ return &ermite.CreateIssueResponse{Issue: issueOne}, nil
29+ }}
30+ url := f.serve(t)
31+ res := runCLI(t, envFor(url), "", "issue", "create", "alpha", "--title", "Crash on start", "--body", "It crashes.").ok(t)
32+ assert.Equal(t, "opened alpha#2 (Crash on start)\n", res.stdout)
33+ _, reqs := f.snapshot()
34+ got := reqs[1].(*ermite.CreateIssueRequest)
35+ assert.Equal(t, "alpha", got.GetRepo())
36+ assert.Equal(t, "Crash on start", got.GetTitle())
37+ assert.Equal(t, "It crashes.", got.GetBody())
38+
39+ runCLI(t, envFor(url), "from\nstdin\n", "issue", "create", "alpha", "--title", "t", "--body", "-").ok(t)
40+ runCLI(t, envFor(url), "piped\n", "issue", "create", "alpha", "--title", "t", "--body-file", "-").ok(t)
41+ file := filepath.Join(t.TempDir(), "body.txt")
42+ require.NoError(t, os.WriteFile(file, []byte("from file\n"), 0o600))
43+ runCLI(t, envFor(url), "", "issue", "create", "alpha", "--title", "t", "--body-file", file).ok(t)
44+ _, reqs = f.snapshot()
45+ var bodies []string
46+ for _, r := range reqs {
47+ if c, ok := r.(*ermite.CreateIssueRequest); ok {
48+ bodies = append(bodies, c.GetBody())
49+ }
50+ }
51+ assert.Equal(t, []string{"It crashes.", "from\nstdin", "piped", "from file"}, bodies)
52+
53+ res = runCLI(t, envFor(url), "", "issue", "create", "alpha", "--title", "t", "--json").ok(t)
54+ 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)
55+
56+ for _, args := range [][]string{
57+ {"issue", "create", "alpha"},
58+ {"issue", "create", "alpha", "--title", "t", "--body", "a", "--body-file", file},
59+ } {
60+ require.Error(t, runCLI(t, envFor(url), "", args...).err, "%v", args)
61+ }
62+}
63+
64+func TestIssueListTableJSONAndPages(t *testing.T) {
65+ closed := &ermite.Issue{Repo: "alpha", Number: 1, Title: "Old", State: ermite.IssueState_ISSUE_STATE_CLOSED, Author: "bot", UpdatedAt: ts("2026-09-01T08:00:00Z")}
66+ f := &fakeAPI{getVersion: withIssues, listIssues: func(r *ermite.ListIssuesRequest) (*ermite.ListIssuesResponse, error) {
67+ if r.GetPageToken() == "" {
68+ return &ermite.ListIssuesResponse{Issues: []*ermite.Issue{issueOne}, NextPageToken: "p2"}, nil
69+ }
70+ return &ermite.ListIssuesResponse{Issues: []*ermite.Issue{closed}}, nil
71+ }}
72+ url := f.serve(t)
73+ res := runCLI(t, envFor(url), "", "issue", "list", "alpha", "--state", "all").ok(t)
74+ 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)
75+ _, reqs := f.snapshot()
76+ require.Len(t, reqs, 3) // GetVersion, two pages
77+ assert.Equal(t, ermite.IssueState_ISSUE_STATE_UNSPECIFIED, reqs[1].(*ermite.ListIssuesRequest).GetState())
78+ assert.Equal(t, "p2", reqs[2].(*ermite.ListIssuesRequest).GetPageToken())
79+
80+ runCLI(t, envFor(url), "", "issue", "list", "alpha").ok(t)
81+ _, reqs = f.snapshot()
82+ assert.Equal(t, ermite.IssueState_ISSUE_STATE_OPEN, reqs[len(reqs)-2].(*ermite.ListIssuesRequest).GetState())
83+
84+ res = runCLI(t, envFor(url), "", "issue", "list", "alpha", "--json").ok(t)
85+ assert.Contains(t, res.stdout, `"state": "closed"`)
86+
87+ require.Error(t, runCLI(t, envFor(url), "", "issue", "list", "alpha", "--state", "merged").err)
88+
89+ empty := &fakeAPI{getVersion: withIssues, listIssues: func(*ermite.ListIssuesRequest) (*ermite.ListIssuesResponse, error) {
90+ return &ermite.ListIssuesResponse{}, nil
91+ }}
92+ res = runCLI(t, envFor(empty.serve(t)), "", "issue", "list", "alpha", "--json").ok(t)
93+ assert.Equal(t, "[]\n", res.stdout)
94+ res = runCLI(t, envFor(empty.serve(t)), "", "issue", "list", "alpha").ok(t)
95+ assert.Equal(t, "ermite: no issues\n", res.stderr)
96+}
97+
98+func TestIssueShowCommentCloseReopen(t *testing.T) {
99+ cmt := &ermite.IssueComment{Id: "1", Author: "agent-7", Text: "On it.", CreatedAt: ts("2026-10-02T11:30:00Z")}
100+ reopened := &ermite.Issue{Repo: "alpha", Number: 2, Title: "Crash on start", State: ermite.IssueState_ISSUE_STATE_OPEN}
101+ f := &fakeAPI{
102+ getVersion: withIssues,
103+ getIssue: func(*ermite.GetIssueRequest) (*ermite.GetIssueResponse, error) {
104+ return &ermite.GetIssueResponse{Issue: issueOne, Comments: []*ermite.IssueComment{cmt}}, nil
105+ },
106+ addIssueCmt: func(*ermite.AddIssueCommentRequest) (*ermite.AddIssueCommentResponse, error) {
107+ return &ermite.AddIssueCommentResponse{Comment: cmt}, nil
108+ },
109+ closeIssue: func(*ermite.CloseIssueRequest) (*ermite.CloseIssueResponse, error) {
110+ c := &ermite.Issue{Repo: "alpha", Number: 2, Title: "Crash on start", State: ermite.IssueState_ISSUE_STATE_CLOSED}
111+ return &ermite.CloseIssueResponse{Issue: c}, nil
112+ },
113+ reopenIssue: func(*ermite.ReopenIssueRequest) (*ermite.ReopenIssueResponse, error) {
114+ return &ermite.ReopenIssueResponse{Issue: reopened}, nil
115+ },
116+ }
117+ url := f.serve(t)
118+
119+ res := runCLI(t, envFor(url), "", "issue", "show", "alpha", "#2").ok(t)
120+ assert.Contains(t, res.stdout, "issue alpha#2")
121+ assert.Contains(t, res.stdout, "It crashes.")
122+ assert.Contains(t, res.stdout, "comments (1)")
123+ assert.Contains(t, res.stdout, "#1 agent-7")
124+ res = runCLI(t, envFor(url), "", "issue", "show", "alpha", "2", "--json").ok(t)
125+ assert.Contains(t, res.stdout, `"comments"`)
126+ assert.Contains(t, res.stdout, `"text": "On it."`)
127+
128+ res = runCLI(t, envFor(url), "", "issue", "comment", "alpha", "2", "-m", "On it.").ok(t)
129+ assert.Equal(t, "comment 1 added to alpha#2\n", res.stdout)
130+ runCLI(t, envFor(url), "multi\nline\n", "issue", "comment", "alpha", "2", "--text", "-").ok(t)
131+ res = runCLI(t, envFor(url), "", "issue", "comment", "alpha", "2", "-m", "x", "--json").ok(t)
132+ assert.JSONEq(t, `{"id":"1","author":"agent-7","text":"On it.","created_at":"2026-10-02T11:30:00Z"}`, res.stdout)
133+ _, reqs := f.snapshot()
134+ var texts []string
135+ for _, r := range reqs {
136+ if c, ok := r.(*ermite.AddIssueCommentRequest); ok {
137+ assert.EqualValues(t, 2, c.GetNumber())
138+ texts = append(texts, c.GetText())
139+ }
140+ }
141+ assert.Equal(t, []string{"On it.", "multi\nline", "x"}, texts)
142+ require.Error(t, runCLI(t, envFor(url), "", "issue", "comment", "alpha", "2").err)
143+
144+ res = runCLI(t, envFor(url), "", "issue", "close", "alpha", "2").ok(t)
145+ assert.Equal(t, "closed alpha#2 (Crash on start)\n", res.stdout)
146+ res = runCLI(t, envFor(url), "", "issue", "reopen", "alpha", "2", "--json").ok(t)
147+ assert.Contains(t, res.stdout, `"state": "open"`)
148+
149+ for _, n := range []string{"0", "x", "-1"} {
150+ require.Error(t, runCLI(t, envFor(url), "", "issue", "show", "alpha", n).err, n)
151+ }
152+}
153+
154+func TestIssueNeedsTheFeature(t *testing.T) {
155+ f := &fakeAPI{getVersion: func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) {
156+ return &ermite.GetVersionResponse{Version: "v0.4.0", Features: []string{"merge_requests"}}, nil
157+ }}
158+ url := f.serve(t)
159+ for _, args := range [][]string{
160+ {"issue", "list", "alpha"}, {"issue", "show", "alpha", "1"}, {"issue", "create", "alpha", "--title", "t"},
161+ {"issue", "comment", "alpha", "1", "-m", "x"}, {"issue", "close", "alpha", "1"}, {"issue", "reopen", "alpha", "1"},
162+ {"repo", "edit", "alpha", "--issues", "owner"},
163+ } {
164+ res := runCLI(t, envFor(url), "", args...)
165+ require.Error(t, res.err, "%v", args)
166+ assert.Contains(t, res.err.Error(), "does not support issues", "%v", args)
167+ }
168+ calls, _ := f.snapshot()
169+ for _, c := range calls {
170+ assert.Equal(t, "GetVersion", c)
171+ }
172+}
173+
174+func TestRepoEditIssues(t *testing.T) {
175+ updated := &ermite.Repo{Name: "alpha", Settings: &ermite.RepoSettings{Issues: ermite.IssuesMode_ISSUES_MODE_OWNER}}
176+ f := &fakeAPI{getVersion: withIssues, updateRepo: func(*ermite.UpdateRepoRequest) (*ermite.UpdateRepoResponse, error) {
177+ return &ermite.UpdateRepoResponse{Repo: updated}, nil
178+ }}
179+ url := f.serve(t)
180+ for value, want := range map[string]ermite.IssuesMode{
181+ "off": ermite.IssuesMode_ISSUES_MODE_OFF, "owner": ermite.IssuesMode_ISSUES_MODE_OWNER, "PUBLIC": ermite.IssuesMode_ISSUES_MODE_PUBLIC,
182+ } {
183+ res := runCLI(t, envFor(url), "", "repo", "edit", "alpha", "--issues", value, "--json").ok(t)
184+ assert.Contains(t, res.stdout, `"issues": "owner"`)
185+ _, reqs := f.snapshot()
186+ got := reqs[len(reqs)-1].(*ermite.UpdateRepoRequest)
187+ require.NotNil(t, got.Issues, value)
188+ assert.Equal(t, want, got.GetIssues())
189+ }
190+ res := runCLI(t, envFor(url), "", "repo", "edit", "alpha", "--issues", "all")
191+ require.Error(t, res.err)
192+ assert.Contains(t, res.err.Error(), "off, owner or public")
193+ res = runCLI(t, envFor(url), "", "repo", "show", "alpha")
194+ require.Error(t, res.err) // getRepo not set: unimplemented
195+}
196+
197+func TestRepoShowIssues(t *testing.T) {
198+ r := &ermite.Repo{Name: "alpha", Settings: &ermite.RepoSettings{Issues: ermite.IssuesMode_ISSUES_MODE_OWNER}}
199+ f := &fakeAPI{getRepo: func(*ermite.GetRepoRequest) (*ermite.GetRepoResponse, error) {
200+ return &ermite.GetRepoResponse{Repo: r}, nil
201+ }}
202+ url := f.serve(t)
203+ res := runCLI(t, envFor(url), "", "repo", "show", "alpha").ok(t)
204+ assert.Contains(t, res.stdout, "issues owner\n")
205+ res = runCLI(t, envFor(url), "", "repo", "show", "alpha", "--json").ok(t)
206+ assert.Contains(t, res.stdout, `"issues": "owner"`)
207+}
208+
209+func TestTokenCreateIssueWriteNeedsTheFeature(t *testing.T) {
210+ echo := func(r *ermite.CreateTokenRequest) (*ermite.CreateTokenResponse, error) {
211+ return &ermite.CreateTokenResponse{Token: &ermite.Token{Id: "t", Label: r.GetLabel(), Scopes: r.GetScopes()}, Secret: "ermite_S"}, nil
212+ }
213+ f := &fakeAPI{createToken: echo, getVersion: func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) {
214+ return &ermite.GetVersionResponse{Version: "v0.4.0"}, nil
215+ }}
216+ res := runCLI(t, envFor(f.serve(t)), "", "token", "create", "x", "--scope", "issue:write")
217+ require.Error(t, res.err)
218+ assert.Contains(t, res.err.Error(), "does not support issues")
219+ calls, _ := f.snapshot()
220+ assert.NotContains(t, calls, "CreateToken")
221+
222+ f = &fakeAPI{createToken: echo, getVersion: withIssues}
223+ runCLI(t, envFor(f.serve(t)), "", "token", "create", "x", "--preset", "agent", "--scope", "issue:write").ok(t)
224+ assert.Contains(t, lastCreateToken(t, f).GetScopes(), ermite.Scope_SCOPE_ISSUE_WRITE)
225+}
226+
227+func TestTokenCreatePresetCLIOnAnOldServerDropsIssueWrite(t *testing.T) {
228+ f := &fakeAPI{getVersion: func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) {
229+ return &ermite.GetVersionResponse{Version: "v0.4.0"}, nil
230+ }, createToken: func(r *ermite.CreateTokenRequest) (*ermite.CreateTokenResponse, error) {
231+ return &ermite.CreateTokenResponse{Token: &ermite.Token{Id: "t", Label: r.GetLabel(), Scopes: r.GetScopes()}, Secret: "ermite_S"}, nil
232+ }}
233+ res := runCLI(t, envFor(f.serve(t)), "", "token", "create", "x", "--preset", "cli").ok(t)
234+ got := lastCreateToken(t, f).GetScopes()
235+ assert.NotContains(t, got, ermite.Scope_SCOPE_ISSUE_WRITE)
236+ assert.NotEmpty(t, got)
237+ assert.Contains(t, res.stderr, "no issue:write")
238+
239+ res = runCLI(t, envFor(f.serve(t)), "", "token", "create", "x", "--preset", "cli", "--scope", "issue:write")
240+ require.Error(t, res.err)
241+ assert.Contains(t, res.err.Error(), "does not support issues")
242+}
243+
244+type failingWriter struct{ after int }
245+
246+func (w *failingWriter) Write(p []byte) (int, error) {
247+ if w.after <= 0 {
248+ return 0, errors.New("write failed")
249+ }
250+ w.after--
251+ return len(p), nil
252+}
253+
254+func TestPrintIssueDetailReturnsWriteErrors(t *testing.T) {
255+ d := &ermite.GetIssueResponse{Issue: &ermite.Issue{Repo: "alpha", Number: 1, Title: "t"}, Comments: []*ermite.IssueComment{{Id: "1", Text: "c"}}}
256+ for after := range 60 {
257+ err := printIssueDetail(&failingWriter{after: after}, d)
258+ if err == nil {
259+ return // all writes succeeded: every earlier failure was reported
260+ }
261+ assert.ErrorContains(t, err, "write failed", after)
262+ }
263+ t.Fatal("never completed")
264+}
@@ -0,0 +1,264 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "errors"
7+ "os"
8+ "path/filepath"
9+ "testing"
10+
11+ "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/require"
13+
14+ "bnema.dev/ermite-api/gen/ermite"
15+)
16+
17+var issueOne = &ermite.Issue{
18+ Repo: "alpha", Number: 2, Title: "Crash on start", Body: "It crashes.", State: ermite.IssueState_ISSUE_STATE_OPEN,
19+ Author: "owner", CreatedAt: ts("2026-10-01T10:00:00Z"), UpdatedAt: ts("2026-10-02T11:30:00Z"), CommentCount: 1,
20+}
21+
22+func withIssues(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) {
23+ return &ermite.GetVersionResponse{Version: "v0.5.0", Features: []string{"issues"}}, nil
24+}
25+
26+func TestIssueCreateMapsFlags(t *testing.T) {
27+ f := &fakeAPI{getVersion: withIssues, createIssue: func(*ermite.CreateIssueRequest) (*ermite.CreateIssueResponse, error) {
28+ return &ermite.CreateIssueResponse{Issue: issueOne}, nil
29+ }}
30+ url := f.serve(t)
31+ res := runCLI(t, envFor(url), "", "issue", "create", "alpha", "--title", "Crash on start", "--body", "It crashes.").ok(t)
32+ assert.Equal(t, "opened alpha#2 (Crash on start)\n", res.stdout)
33+ _, reqs := f.snapshot()
34+ got := reqs[1].(*ermite.CreateIssueRequest)
35+ assert.Equal(t, "alpha", got.GetRepo())
36+ assert.Equal(t, "Crash on start", got.GetTitle())
37+ assert.Equal(t, "It crashes.", got.GetBody())
38+
39+ runCLI(t, envFor(url), "from\nstdin\n", "issue", "create", "alpha", "--title", "t", "--body", "-").ok(t)
40+ runCLI(t, envFor(url), "piped\n", "issue", "create", "alpha", "--title", "t", "--body-file", "-").ok(t)
41+ file := filepath.Join(t.TempDir(), "body.txt")
42+ require.NoError(t, os.WriteFile(file, []byte("from file\n"), 0o600))
43+ runCLI(t, envFor(url), "", "issue", "create", "alpha", "--title", "t", "--body-file", file).ok(t)
44+ _, reqs = f.snapshot()
45+ var bodies []string
46+ for _, r := range reqs {
47+ if c, ok := r.(*ermite.CreateIssueRequest); ok {
48+ bodies = append(bodies, c.GetBody())
49+ }
50+ }
51+ assert.Equal(t, []string{"It crashes.", "from\nstdin", "piped", "from file"}, bodies)
52+
53+ res = runCLI(t, envFor(url), "", "issue", "create", "alpha", "--title", "t", "--json").ok(t)
54+ 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)
55+
56+ for _, args := range [][]string{
57+ {"issue", "create", "alpha"},
58+ {"issue", "create", "alpha", "--title", "t", "--body", "a", "--body-file", file},
59+ } {
60+ require.Error(t, runCLI(t, envFor(url), "", args...).err, "%v", args)
61+ }
62+}
63+
64+func TestIssueListTableJSONAndPages(t *testing.T) {
65+ closed := &ermite.Issue{Repo: "alpha", Number: 1, Title: "Old", State: ermite.IssueState_ISSUE_STATE_CLOSED, Author: "bot", UpdatedAt: ts("2026-09-01T08:00:00Z")}
66+ f := &fakeAPI{getVersion: withIssues, listIssues: func(r *ermite.ListIssuesRequest) (*ermite.ListIssuesResponse, error) {
67+ if r.GetPageToken() == "" {
68+ return &ermite.ListIssuesResponse{Issues: []*ermite.Issue{issueOne}, NextPageToken: "p2"}, nil
69+ }
70+ return &ermite.ListIssuesResponse{Issues: []*ermite.Issue{closed}}, nil
71+ }}
72+ url := f.serve(t)
73+ res := runCLI(t, envFor(url), "", "issue", "list", "alpha", "--state", "all").ok(t)
74+ 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)
75+ _, reqs := f.snapshot()
76+ require.Len(t, reqs, 3) // GetVersion, two pages
77+ assert.Equal(t, ermite.IssueState_ISSUE_STATE_UNSPECIFIED, reqs[1].(*ermite.ListIssuesRequest).GetState())
78+ assert.Equal(t, "p2", reqs[2].(*ermite.ListIssuesRequest).GetPageToken())
79+
80+ runCLI(t, envFor(url), "", "issue", "list", "alpha").ok(t)
81+ _, reqs = f.snapshot()
82+ assert.Equal(t, ermite.IssueState_ISSUE_STATE_OPEN, reqs[len(reqs)-2].(*ermite.ListIssuesRequest).GetState())
83+
84+ res = runCLI(t, envFor(url), "", "issue", "list", "alpha", "--json").ok(t)
85+ assert.Contains(t, res.stdout, `"state": "closed"`)
86+
87+ require.Error(t, runCLI(t, envFor(url), "", "issue", "list", "alpha", "--state", "merged").err)
88+
89+ empty := &fakeAPI{getVersion: withIssues, listIssues: func(*ermite.ListIssuesRequest) (*ermite.ListIssuesResponse, error) {
90+ return &ermite.ListIssuesResponse{}, nil
91+ }}
92+ res = runCLI(t, envFor(empty.serve(t)), "", "issue", "list", "alpha", "--json").ok(t)
93+ assert.Equal(t, "[]\n", res.stdout)
94+ res = runCLI(t, envFor(empty.serve(t)), "", "issue", "list", "alpha").ok(t)
95+ assert.Equal(t, "ermite: no issues\n", res.stderr)
96+}
97+
98+func TestIssueShowCommentCloseReopen(t *testing.T) {
99+ cmt := &ermite.IssueComment{Id: "1", Author: "agent-7", Text: "On it.", CreatedAt: ts("2026-10-02T11:30:00Z")}
100+ reopened := &ermite.Issue{Repo: "alpha", Number: 2, Title: "Crash on start", State: ermite.IssueState_ISSUE_STATE_OPEN}
101+ f := &fakeAPI{
102+ getVersion: withIssues,
103+ getIssue: func(*ermite.GetIssueRequest) (*ermite.GetIssueResponse, error) {
104+ return &ermite.GetIssueResponse{Issue: issueOne, Comments: []*ermite.IssueComment{cmt}}, nil
105+ },
106+ addIssueCmt: func(*ermite.AddIssueCommentRequest) (*ermite.AddIssueCommentResponse, error) {
107+ return &ermite.AddIssueCommentResponse{Comment: cmt}, nil
108+ },
109+ closeIssue: func(*ermite.CloseIssueRequest) (*ermite.CloseIssueResponse, error) {
110+ c := &ermite.Issue{Repo: "alpha", Number: 2, Title: "Crash on start", State: ermite.IssueState_ISSUE_STATE_CLOSED}
111+ return &ermite.CloseIssueResponse{Issue: c}, nil
112+ },
113+ reopenIssue: func(*ermite.ReopenIssueRequest) (*ermite.ReopenIssueResponse, error) {
114+ return &ermite.ReopenIssueResponse{Issue: reopened}, nil
115+ },
116+ }
117+ url := f.serve(t)
118+
119+ res := runCLI(t, envFor(url), "", "issue", "show", "alpha", "#2").ok(t)
120+ assert.Contains(t, res.stdout, "issue alpha#2")
121+ assert.Contains(t, res.stdout, "It crashes.")
122+ assert.Contains(t, res.stdout, "comments (1)")
123+ assert.Contains(t, res.stdout, "#1 agent-7")
124+ res = runCLI(t, envFor(url), "", "issue", "show", "alpha", "2", "--json").ok(t)
125+ assert.Contains(t, res.stdout, `"comments"`)
126+ assert.Contains(t, res.stdout, `"text": "On it."`)
127+
128+ res = runCLI(t, envFor(url), "", "issue", "comment", "alpha", "2", "-m", "On it.").ok(t)
129+ assert.Equal(t, "comment 1 added to alpha#2\n", res.stdout)
130+ runCLI(t, envFor(url), "multi\nline\n", "issue", "comment", "alpha", "2", "--text", "-").ok(t)
131+ res = runCLI(t, envFor(url), "", "issue", "comment", "alpha", "2", "-m", "x", "--json").ok(t)
132+ assert.JSONEq(t, `{"id":"1","author":"agent-7","text":"On it.","created_at":"2026-10-02T11:30:00Z"}`, res.stdout)
133+ _, reqs := f.snapshot()
134+ var texts []string
135+ for _, r := range reqs {
136+ if c, ok := r.(*ermite.AddIssueCommentRequest); ok {
137+ assert.EqualValues(t, 2, c.GetNumber())
138+ texts = append(texts, c.GetText())
139+ }
140+ }
141+ assert.Equal(t, []string{"On it.", "multi\nline", "x"}, texts)
142+ require.Error(t, runCLI(t, envFor(url), "", "issue", "comment", "alpha", "2").err)
143+
144+ res = runCLI(t, envFor(url), "", "issue", "close", "alpha", "2").ok(t)
145+ assert.Equal(t, "closed alpha#2 (Crash on start)\n", res.stdout)
146+ res = runCLI(t, envFor(url), "", "issue", "reopen", "alpha", "2", "--json").ok(t)
147+ assert.Contains(t, res.stdout, `"state": "open"`)
148+
149+ for _, n := range []string{"0", "x", "-1"} {
150+ require.Error(t, runCLI(t, envFor(url), "", "issue", "show", "alpha", n).err, n)
151+ }
152+}
153+
154+func TestIssueNeedsTheFeature(t *testing.T) {
155+ f := &fakeAPI{getVersion: func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) {
156+ return &ermite.GetVersionResponse{Version: "v0.4.0", Features: []string{"merge_requests"}}, nil
157+ }}
158+ url := f.serve(t)
159+ for _, args := range [][]string{
160+ {"issue", "list", "alpha"}, {"issue", "show", "alpha", "1"}, {"issue", "create", "alpha", "--title", "t"},
161+ {"issue", "comment", "alpha", "1", "-m", "x"}, {"issue", "close", "alpha", "1"}, {"issue", "reopen", "alpha", "1"},
162+ {"repo", "edit", "alpha", "--issues", "owner"},
163+ } {
164+ res := runCLI(t, envFor(url), "", args...)
165+ require.Error(t, res.err, "%v", args)
166+ assert.Contains(t, res.err.Error(), "does not support issues", "%v", args)
167+ }
168+ calls, _ := f.snapshot()
169+ for _, c := range calls {
170+ assert.Equal(t, "GetVersion", c)
171+ }
172+}
173+
174+func TestRepoEditIssues(t *testing.T) {
175+ updated := &ermite.Repo{Name: "alpha", Settings: &ermite.RepoSettings{Issues: ermite.IssuesMode_ISSUES_MODE_OWNER}}
176+ f := &fakeAPI{getVersion: withIssues, updateRepo: func(*ermite.UpdateRepoRequest) (*ermite.UpdateRepoResponse, error) {
177+ return &ermite.UpdateRepoResponse{Repo: updated}, nil
178+ }}
179+ url := f.serve(t)
180+ for value, want := range map[string]ermite.IssuesMode{
181+ "off": ermite.IssuesMode_ISSUES_MODE_OFF, "owner": ermite.IssuesMode_ISSUES_MODE_OWNER, "PUBLIC": ermite.IssuesMode_ISSUES_MODE_PUBLIC,
182+ } {
183+ res := runCLI(t, envFor(url), "", "repo", "edit", "alpha", "--issues", value, "--json").ok(t)
184+ assert.Contains(t, res.stdout, `"issues": "owner"`)
185+ _, reqs := f.snapshot()
186+ got := reqs[len(reqs)-1].(*ermite.UpdateRepoRequest)
187+ require.NotNil(t, got.Issues, value)
188+ assert.Equal(t, want, got.GetIssues())
189+ }
190+ res := runCLI(t, envFor(url), "", "repo", "edit", "alpha", "--issues", "all")
191+ require.Error(t, res.err)
192+ assert.Contains(t, res.err.Error(), "off, owner or public")
193+ res = runCLI(t, envFor(url), "", "repo", "show", "alpha")
194+ require.Error(t, res.err) // getRepo not set: unimplemented
195+}
196+
197+func TestRepoShowIssues(t *testing.T) {
198+ r := &ermite.Repo{Name: "alpha", Settings: &ermite.RepoSettings{Issues: ermite.IssuesMode_ISSUES_MODE_OWNER}}
199+ f := &fakeAPI{getRepo: func(*ermite.GetRepoRequest) (*ermite.GetRepoResponse, error) {
200+ return &ermite.GetRepoResponse{Repo: r}, nil
201+ }}
202+ url := f.serve(t)
203+ res := runCLI(t, envFor(url), "", "repo", "show", "alpha").ok(t)
204+ assert.Contains(t, res.stdout, "issues owner\n")
205+ res = runCLI(t, envFor(url), "", "repo", "show", "alpha", "--json").ok(t)
206+ assert.Contains(t, res.stdout, `"issues": "owner"`)
207+}
208+
209+func TestTokenCreateIssueWriteNeedsTheFeature(t *testing.T) {
210+ echo := func(r *ermite.CreateTokenRequest) (*ermite.CreateTokenResponse, error) {
211+ return &ermite.CreateTokenResponse{Token: &ermite.Token{Id: "t", Label: r.GetLabel(), Scopes: r.GetScopes()}, Secret: "ermite_S"}, nil
212+ }
213+ f := &fakeAPI{createToken: echo, getVersion: func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) {
214+ return &ermite.GetVersionResponse{Version: "v0.4.0"}, nil
215+ }}
216+ res := runCLI(t, envFor(f.serve(t)), "", "token", "create", "x", "--scope", "issue:write")
217+ require.Error(t, res.err)
218+ assert.Contains(t, res.err.Error(), "does not support issues")
219+ calls, _ := f.snapshot()
220+ assert.NotContains(t, calls, "CreateToken")
221+
222+ f = &fakeAPI{createToken: echo, getVersion: withIssues}
223+ runCLI(t, envFor(f.serve(t)), "", "token", "create", "x", "--preset", "agent", "--scope", "issue:write").ok(t)
224+ assert.Contains(t, lastCreateToken(t, f).GetScopes(), ermite.Scope_SCOPE_ISSUE_WRITE)
225+}
226+
227+func TestTokenCreatePresetCLIOnAnOldServerDropsIssueWrite(t *testing.T) {
228+ f := &fakeAPI{getVersion: func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) {
229+ return &ermite.GetVersionResponse{Version: "v0.4.0"}, nil
230+ }, createToken: func(r *ermite.CreateTokenRequest) (*ermite.CreateTokenResponse, error) {
231+ return &ermite.CreateTokenResponse{Token: &ermite.Token{Id: "t", Label: r.GetLabel(), Scopes: r.GetScopes()}, Secret: "ermite_S"}, nil
232+ }}
233+ res := runCLI(t, envFor(f.serve(t)), "", "token", "create", "x", "--preset", "cli").ok(t)
234+ got := lastCreateToken(t, f).GetScopes()
235+ assert.NotContains(t, got, ermite.Scope_SCOPE_ISSUE_WRITE)
236+ assert.NotEmpty(t, got)
237+ assert.Contains(t, res.stderr, "no issue:write")
238+
239+ res = runCLI(t, envFor(f.serve(t)), "", "token", "create", "x", "--preset", "cli", "--scope", "issue:write")
240+ require.Error(t, res.err)
241+ assert.Contains(t, res.err.Error(), "does not support issues")
242+}
243+
244+type failingWriter struct{ after int }
245+
246+func (w *failingWriter) Write(p []byte) (int, error) {
247+ if w.after <= 0 {
248+ return 0, errors.New("write failed")
249+ }
250+ w.after--
251+ return len(p), nil
252+}
253+
254+func TestPrintIssueDetailReturnsWriteErrors(t *testing.T) {
255+ d := &ermite.GetIssueResponse{Issue: &ermite.Issue{Repo: "alpha", Number: 1, Title: "t"}, Comments: []*ermite.IssueComment{{Id: "1", Text: "c"}}}
256+ for after := range 60 {
257+ err := printIssueDetail(&failingWriter{after: after}, d)
258+ if err == nil {
259+ return // all writes succeeded: every earlier failure was reported
260+ }
261+ assert.ErrorContains(t, err, "write failed", after)
262+ }
263+ t.Fatal("never completed")
264+}
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 (visitors of a public repository may also propose issues through a moderated form).\nNeeds 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 (visitors of a public repository may also propose issues through a moderated form).\nNeeds 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 +22 -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,18 @@ func newTokenCreateCmd(env *cliEnv) *cobra.Command {
131133 return err
132134 }
133135 }
136+ // A preset such as cli grows with the contract and must keep working on an
137+ // older server: issue:write from a preset alone is dropped there, while an
138+ // explicit --scope issue:write is refused.
139+ if slices.Contains(req.Scopes, ermite.Scope_SCOPE_ISSUE_WRITE) {
140+ if err := requireIssues(env.ctx(cmd), c); err != nil {
141+ if !errors.Is(err, errNoIssues) || explicitScope(scopes, ermite.Scope_SCOPE_ISSUE_WRITE) {
142+ return fmt.Errorf("%w (needed for the issue:write scope; nothing was created)", err)
143+ }
144+ req.Scopes = slices.DeleteFunc(req.Scopes, func(s ermite.Scope) bool { return s == ermite.Scope_SCOPE_ISSUE_WRITE })
145+ _, _ = fmt.Fprintln(cmd.ErrOrStderr(), "note: the server has no issues feature, so the token has no issue:write scope")
146+ }
147+ }
134148 res, err := c.Tokens().CreateToken(env.ctx(cmd), connect.NewRequest(req))
135149 if err != nil {
136150 return c.Describe(err)
@@ -143,6 +157,14 @@ func newTokenCreateCmd(env *cliEnv) *cobra.Command {
143157 return cmd
144158 }
145159
160+// explicitScope reports whether the --scope flags name want.
161+func explicitScope(flags []string, want ermite.Scope) bool {
162+ return slices.ContainsFunc(flags, func(f string) bool {
163+ s, err := client.ParseScope(f)
164+ return err == nil && s == want
165+ })
166+}
167+
146168 // requireBranchPrefixes refuses, before anything is created, to ask a server
147169 // for a branch-limited token when it does not list token.branch_prefixes: such
148170 // 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,18 @@ func newTokenCreateCmd(env *cliEnv) *cobra.Command {
131 return err133 return err
132 }134 }
133 }135 }
136+ // A preset such as cli grows with the contract and must keep working on an
137+ // older server: issue:write from a preset alone is dropped there, while an
138+ // explicit --scope issue:write is refused.
139+ if slices.Contains(req.Scopes, ermite.Scope_SCOPE_ISSUE_WRITE) {
140+ if err := requireIssues(env.ctx(cmd), c); err != nil {
141+ if !errors.Is(err, errNoIssues) || explicitScope(scopes, ermite.Scope_SCOPE_ISSUE_WRITE) {
142+ return fmt.Errorf("%w (needed for the issue:write scope; nothing was created)", err)
143+ }
144+ req.Scopes = slices.DeleteFunc(req.Scopes, func(s ermite.Scope) bool { return s == ermite.Scope_SCOPE_ISSUE_WRITE })
145+ _, _ = fmt.Fprintln(cmd.ErrOrStderr(), "note: the server has no issues feature, so the token has no issue:write scope")
146+ }
147+ }
134 res, err := c.Tokens().CreateToken(env.ctx(cmd), connect.NewRequest(req))148 res, err := c.Tokens().CreateToken(env.ctx(cmd), connect.NewRequest(req))
135 if err != nil {149 if err != nil {
136 return c.Describe(err)150 return c.Describe(err)
@@ -143,6 +157,14 @@ func newTokenCreateCmd(env *cliEnv) *cobra.Command {
143 return cmd157 return cmd
144 }158 }
145 159
160+// explicitScope reports whether the --scope flags name want.
161+func explicitScope(flags []string, want ermite.Scope) bool {
162+ return slices.ContainsFunc(flags, func(f string) bool {
163+ s, err := client.ParseScope(f)
164+ return err == nil && s == want
165+ })
166+}
167+
146 // requireBranchPrefixes refuses, before anything is created, to ask a server168 // requireBranchPrefixes refuses, before anything is created, to ask a server
147 // for a branch-limited token when it does not list token.branch_prefixes: such169 // 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 refused170 // a server ignores the field. A server whose features cannot be read is refused
M internal/app/cli_token_test.go +2 -3
@@ -174,12 +174,11 @@ func TestTokenCreatePresetExpansionOnTheWire(t *testing.T) {
174174 "cli": client.AllScopes(),
175175 } {
176176 t.Run(preset, func(t *testing.T) {
177- f := &fakeAPI{createToken: func(r *ermite.CreateTokenRequest) (*ermite.CreateTokenResponse, error) {
177+ f := &fakeAPI{getVersion: withIssues, createToken: func(r *ermite.CreateTokenRequest) (*ermite.CreateTokenResponse, error) {
178178 return &ermite.CreateTokenResponse{Token: &ermite.Token{Id: "t", Scopes: r.GetScopes()}, Secret: "s"}, nil
179179 }}
180180 runCLI(t, envFor(f.serve(t)), "", "token", "create", "x", "--preset", preset).ok(t)
181- _, reqs := f.snapshot()
182- got := reqs[0].(*ermite.CreateTokenRequest)
181+ got := lastCreateToken(t, f)
183182 assert.ElementsMatch(t, want, got.GetScopes())
184183 assert.Empty(t, got.GetRepos())
185184 assert.Nil(t, got.GetExpiresAt())
@@ -174,12 +174,11 @@ func TestTokenCreatePresetExpansionOnTheWire(t *testing.T) {
174 "cli": client.AllScopes(),174 "cli": client.AllScopes(),
175 } {175 } {
176 t.Run(preset, func(t *testing.T) {176 t.Run(preset, func(t *testing.T) {
177- f := &fakeAPI{createToken: func(r *ermite.CreateTokenRequest) (*ermite.CreateTokenResponse, error) {177+ f := &fakeAPI{getVersion: withIssues, createToken: func(r *ermite.CreateTokenRequest) (*ermite.CreateTokenResponse, error) {
178 return &ermite.CreateTokenResponse{Token: &ermite.Token{Id: "t", Scopes: r.GetScopes()}, Secret: "s"}, nil178 return &ermite.CreateTokenResponse{Token: &ermite.Token{Id: "t", Scopes: r.GetScopes()}, Secret: "s"}, nil
179 }}179 }}
180 runCLI(t, envFor(f.serve(t)), "", "token", "create", "x", "--preset", preset).ok(t)180 runCLI(t, envFor(f.serve(t)), "", "token", "create", "x", "--preset", preset).ok(t)
181- _, reqs := f.snapshot()181+ got := lastCreateToken(t, f)
182- got := reqs[0].(*ermite.CreateTokenRequest)
183 assert.ElementsMatch(t, want, got.GetScopes())182 assert.ElementsMatch(t, want, got.GetScopes())
184 assert.Empty(t, got.GetRepos())183 assert.Empty(t, got.GetRepos())
185 assert.Nil(t, got.GetExpiresAt())184 assert.Nil(t, got.GetExpiresAt())
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/forge.go +71 -22
@@ -75,6 +75,10 @@ type options struct {
7575 // oidcKey generates the signing keys of the ID tokens; nil generates RSA
7676 // keys (tests give prepared ones: a 3072-bit key takes a while).
7777 oidcKey func() (*rsa.PrivateKey, error)
78+ // issuePoW lowers the proof of work of the public issue form (the base
79+ // and the highest difficulty, in bits) when positive, and shortens the
80+ // minimum fill time to a nanosecond; tests only.
81+ issuePoW int
7882 }
7983
8084 // remote returns the lister of github.com refs: the test's, else def.
@@ -115,7 +119,21 @@ type forge struct {
115119 // MergeRequests and SigningKey serve MergeRequestService and
116120 // ServerService.GetSigningKey.
117121 MergeRequests *usecase.MergeRequests
118- SigningKey *usecase.SigningKey
122+ // Issues serves IssueService, and tells what a number names.
123+ Issues *usecase.Issues
124+ // IssueModeration serves the moderation calls of IssueService.
125+ IssueModeration *usecase.IssueModeration
126+ // IssueKeys are the subkeys of the public issue form, and the stores and
127+ // the limiter its submissions use (the form itself comes later).
128+ IssueKeys *usecase.IssueKeys
129+ // IssueSubmissions serves the anonymous submission of issues.
130+ IssueSubmissions *usecase.IssueSubmissions
131+ SubmissionLimiter *clientlimit.SubmissionLimiter
132+ PendingIssues ports.PendingIssueStore
133+ IssueBans ports.IssueBanStore
134+ // Inbox serves the owner's inbox of the web.
135+ Inbox *usecase.Inbox
136+ SigningKey *usecase.SigningKey
119137 // Signatures are the trusted keys and the commit signature checks
120138 // (ADR 0030).
121139 Signatures *usecase.Signatures
@@ -316,6 +334,26 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
316334 CI: f.CI, Checks: f.CI, PullRequests: f.CI, RefTips: store.CI(),
317335 })
318336 f.MergeRequests.WithVerifier(f.Signatures)
337+ f.Issues = usecase.NewIssues(usecase.IssuesConfig{
338+ Meta: store, Store: store.Issues(), MergeRequests: store.MergeRequests(), Bus: f.Bus, Clock: clock,
339+ })
340+ f.IssueModeration = usecase.NewIssueModeration(usecase.IssueModerationConfig{
341+ Meta: store, Pending: store.PendingIssues(), Bans: store.IssueBans(), Bus: f.Bus, Clock: clock,
342+ })
343+ f.SubmissionLimiter = clientlimit.NewSubmissionLimiter(nil)
344+ f.PendingIssues, f.IssueBans = store.PendingIssues(), store.IssueBans()
345+ f.IssueKeys, err = usecase.NewIssueKeys(ctx, store.IssueKeys())
346+ if err != nil {
347+ return nil, err
348+ }
349+ f.IssueSubmissions = usecase.NewIssueSubmissions(usecase.IssueSubmissionsConfig{
350+ Meta: store, Pending: f.PendingIssues, Bans: f.IssueBans, Limiter: f.SubmissionLimiter,
351+ Keys: f.IssueKeys, Bus: f.Bus, Clock: clock,
352+ BaseDifficulty: opts.issuePoW, MaxDifficulty: opts.issuePoW, MinFillTime: fastFill(opts.issuePoW),
353+ })
354+ f.Inbox = usecase.NewInbox(usecase.InboxConfig{
355+ Meta: store, MergeRequests: store.MergeRequests(), Issues: store.Issues(), Pending: store.PendingIssues(),
356+ })
319357 f.CI.SetPullGate(f.MergeRequests)
320358 log.Info().Str("fingerprint", signer.PublicKey().Fingerprint).Msg("commit signing key ready")
321359
@@ -356,27 +394,29 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
356394 jobArtifacts, artifacts = f.CI, f.CI
357395 }
358396 f.API = rpc.New(rpc.Config{
359- Auth: f.Auth,
360- Repos: f.Repos,
361- Tokens: f.Tokens,
362- Browse: f.Browse,
363- Events: f.EventLog,
364- MergeRequests: f.MergeRequests,
365- SigningKey: f.SigningKey,
366- TrustedKeys: f.Signatures,
367- Sessions: f.Sessions,
368- Passkeys: apiPasskeys,
369- Runner: f.CI,
370- Runs: f.CI,
371- Secrets: f.Settings,
372- Variables: f.Settings,
373- Environments: f.Settings,
374- OIDCKeys: apiOIDC,
375- JobArtifacts: jobArtifacts,
376- Artifacts: artifacts,
377- Version: serverVersion(),
378- TrustedProxies: cfg.TrustedProxies,
379- Limiter: limiter,
397+ Auth: f.Auth,
398+ Repos: f.Repos,
399+ Tokens: f.Tokens,
400+ Browse: f.Browse,
401+ Events: f.EventLog,
402+ MergeRequests: f.MergeRequests,
403+ Issues: f.Issues,
404+ IssueModeration: f.IssueModeration,
405+ SigningKey: f.SigningKey,
406+ TrustedKeys: f.Signatures,
407+ Sessions: f.Sessions,
408+ Passkeys: apiPasskeys,
409+ Runner: f.CI,
410+ Runs: f.CI,
411+ Secrets: f.Settings,
412+ Variables: f.Settings,
413+ Environments: f.Settings,
414+ OIDCKeys: apiOIDC,
415+ JobArtifacts: jobArtifacts,
416+ Artifacts: artifacts,
417+ Version: serverVersion(),
418+ TrustedProxies: cfg.TrustedProxies,
419+ Limiter: limiter,
380420 })
381421
382422 gh := githttp.New(githttp.Config{
@@ -536,3 +576,12 @@ func (f *forge) signatures(store *kvstore.Store, reader ports.RepoReader, key do
536576 f.Browse.WithVerifier(sigs)
537577 return nil
538578 }
579+
580+// fastFill is the minimum fill time of the public issue form for a test that
581+// lowers the proof of work: next to none. Zero keeps the production value.
582+func fastFill(issuePoW int) time.Duration {
583+ if issuePoW > 0 {
584+ return time.Nanosecond
585+ }
586+ return 0
587+}
@@ -75,6 +75,10 @@ type options struct {
75 // oidcKey generates the signing keys of the ID tokens; nil generates RSA75 // oidcKey generates the signing keys of the ID tokens; nil generates RSA
76 // keys (tests give prepared ones: a 3072-bit key takes a while).76 // keys (tests give prepared ones: a 3072-bit key takes a while).
77 oidcKey func() (*rsa.PrivateKey, error)77 oidcKey func() (*rsa.PrivateKey, error)
78+ // issuePoW lowers the proof of work of the public issue form (the base
79+ // and the highest difficulty, in bits) when positive, and shortens the
80+ // minimum fill time to a nanosecond; tests only.
81+ issuePoW int
78 }82 }
79 83
80 // remote returns the lister of github.com refs: the test's, else def.84 // remote returns the lister of github.com refs: the test's, else def.
@@ -115,7 +119,21 @@ type forge struct {
115 // MergeRequests and SigningKey serve MergeRequestService and119 // MergeRequests and SigningKey serve MergeRequestService and
116 // ServerService.GetSigningKey.120 // ServerService.GetSigningKey.
117 MergeRequests *usecase.MergeRequests121 MergeRequests *usecase.MergeRequests
118- SigningKey *usecase.SigningKey122+ // Issues serves IssueService, and tells what a number names.
123+ Issues *usecase.Issues
124+ // IssueModeration serves the moderation calls of IssueService.
125+ IssueModeration *usecase.IssueModeration
126+ // IssueKeys are the subkeys of the public issue form, and the stores and
127+ // the limiter its submissions use (the form itself comes later).
128+ IssueKeys *usecase.IssueKeys
129+ // IssueSubmissions serves the anonymous submission of issues.
130+ IssueSubmissions *usecase.IssueSubmissions
131+ SubmissionLimiter *clientlimit.SubmissionLimiter
132+ PendingIssues ports.PendingIssueStore
133+ IssueBans ports.IssueBanStore
134+ // Inbox serves the owner's inbox of the web.
135+ Inbox *usecase.Inbox
136+ SigningKey *usecase.SigningKey
119 // Signatures are the trusted keys and the commit signature checks137 // Signatures are the trusted keys and the commit signature checks
120 // (ADR 0030).138 // (ADR 0030).
121 Signatures *usecase.Signatures139 Signatures *usecase.Signatures
@@ -316,6 +334,26 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
316 CI: f.CI, Checks: f.CI, PullRequests: f.CI, RefTips: store.CI(),334 CI: f.CI, Checks: f.CI, PullRequests: f.CI, RefTips: store.CI(),
317 })335 })
318 f.MergeRequests.WithVerifier(f.Signatures)336 f.MergeRequests.WithVerifier(f.Signatures)
337+ f.Issues = usecase.NewIssues(usecase.IssuesConfig{
338+ Meta: store, Store: store.Issues(), MergeRequests: store.MergeRequests(), Bus: f.Bus, Clock: clock,
339+ })
340+ f.IssueModeration = usecase.NewIssueModeration(usecase.IssueModerationConfig{
341+ Meta: store, Pending: store.PendingIssues(), Bans: store.IssueBans(), Bus: f.Bus, Clock: clock,
342+ })
343+ f.SubmissionLimiter = clientlimit.NewSubmissionLimiter(nil)
344+ f.PendingIssues, f.IssueBans = store.PendingIssues(), store.IssueBans()
345+ f.IssueKeys, err = usecase.NewIssueKeys(ctx, store.IssueKeys())
346+ if err != nil {
347+ return nil, err
348+ }
349+ f.IssueSubmissions = usecase.NewIssueSubmissions(usecase.IssueSubmissionsConfig{
350+ Meta: store, Pending: f.PendingIssues, Bans: f.IssueBans, Limiter: f.SubmissionLimiter,
351+ Keys: f.IssueKeys, Bus: f.Bus, Clock: clock,
352+ BaseDifficulty: opts.issuePoW, MaxDifficulty: opts.issuePoW, MinFillTime: fastFill(opts.issuePoW),
353+ })
354+ f.Inbox = usecase.NewInbox(usecase.InboxConfig{
355+ Meta: store, MergeRequests: store.MergeRequests(), Issues: store.Issues(), Pending: store.PendingIssues(),
356+ })
319 f.CI.SetPullGate(f.MergeRequests)357 f.CI.SetPullGate(f.MergeRequests)
320 log.Info().Str("fingerprint", signer.PublicKey().Fingerprint).Msg("commit signing key ready")358 log.Info().Str("fingerprint", signer.PublicKey().Fingerprint).Msg("commit signing key ready")
321 359
@@ -356,27 +394,29 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
356 jobArtifacts, artifacts = f.CI, f.CI394 jobArtifacts, artifacts = f.CI, f.CI
357 }395 }
358 f.API = rpc.New(rpc.Config{396 f.API = rpc.New(rpc.Config{
359- Auth: f.Auth,397+ Auth: f.Auth,
360- Repos: f.Repos,398+ Repos: f.Repos,
361- Tokens: f.Tokens,399+ Tokens: f.Tokens,
362- Browse: f.Browse,400+ Browse: f.Browse,
363- Events: f.EventLog,401+ Events: f.EventLog,
364- MergeRequests: f.MergeRequests,402+ MergeRequests: f.MergeRequests,
365- SigningKey: f.SigningKey,403+ Issues: f.Issues,
366- TrustedKeys: f.Signatures,404+ IssueModeration: f.IssueModeration,
367- Sessions: f.Sessions,405+ SigningKey: f.SigningKey,
368- Passkeys: apiPasskeys,406+ TrustedKeys: f.Signatures,
369- Runner: f.CI,407+ Sessions: f.Sessions,
370- Runs: f.CI,408+ Passkeys: apiPasskeys,
371- Secrets: f.Settings,409+ Runner: f.CI,
372- Variables: f.Settings,410+ Runs: f.CI,
373- Environments: f.Settings,411+ Secrets: f.Settings,
374- OIDCKeys: apiOIDC,412+ Variables: f.Settings,
375- JobArtifacts: jobArtifacts,413+ Environments: f.Settings,
376- Artifacts: artifacts,414+ OIDCKeys: apiOIDC,
377- Version: serverVersion(),415+ JobArtifacts: jobArtifacts,
378- TrustedProxies: cfg.TrustedProxies,416+ Artifacts: artifacts,
379- Limiter: limiter,417+ Version: serverVersion(),
418+ TrustedProxies: cfg.TrustedProxies,
419+ Limiter: limiter,
380 })420 })
381 421
382 gh := githttp.New(githttp.Config{422 gh := githttp.New(githttp.Config{
@@ -536,3 +576,12 @@ func (f *forge) signatures(store *kvstore.Store, reader ports.RepoReader, key do
536 f.Browse.WithVerifier(sigs)576 f.Browse.WithVerifier(sigs)
537 return nil577 return nil
538 }578 }
579+
580+// fastFill is the minimum fill time of the public issue form for a test that
581+// lowers the proof of work: next to none. Zero keeps the production value.
582+func fastFill(issuePoW int) time.Duration {
583+ if issuePoW > 0 {
584+ return time.Nanosecond
585+ }
586+ return 0
587+}
A internal/app/issue_api_test.go +245 -0
@@ -0,0 +1,245 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "testing"
7+
8+ "connectrpc.com/connect"
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+ "google.golang.org/protobuf/types/known/fieldmaskpb"
12+
13+ pb "bnema.dev/ermite-api/gen/ermite"
14+ "bnema.dev/ermite/internal/domain"
15+ "bnema.dev/ermite/internal/ports"
16+)
17+
18+// issueRig is a harness with the repositories pub and priv, and one caller per
19+// kind of principal.
20+type issueRig struct {
21+ *harness
22+ anon, root, reader, writer, blind, branch *clients
23+}
24+
25+func newIssueRig(t *testing.T) *issueRig {
26+ t.Helper()
27+ h := newHarness(t, harnessOpts{})
28+ r := &issueRig{harness: h}
29+ h.seed("pub", domain.VisibilityPublic)
30+ h.seed("priv", domain.VisibilityPrivate)
31+ r.anon, r.root = h.api(""), h.api(adminToken)
32+ // reader has no issue:write; writer reads and writes; blind holds only
33+ // issue:write; branch is writer limited to branch prefixes.
34+ r.reader = h.api(h.token(scopes(domain.ScopeRepoRead)))
35+ r.writer = h.api(h.token(scopes(domain.ScopeRepoRead, domain.ScopeIssueWrite)))
36+ r.blind = h.api(h.token(scopes(domain.ScopeIssueWrite)))
37+ secret, _ := h.branchToken(scopes(domain.ScopeRepoRead, domain.ScopeRepoWrite, domain.ScopeIssueWrite), []string{"agent/"})
38+ r.branch = h.api(secret)
39+ return r
40+}
41+
42+func (r *issueRig) mode(repo string, mode domain.IssuesMode) {
43+ r.t.Helper()
44+ _, err := r.forge.Repos.Update(r.ctx, domain.Root(), r.name(repo), ports.UpdateRepoParams{Issues: &mode})
45+ require.NoError(r.t, err)
46+}
47+
48+func TestIssueAccessMatrixOverTheAPI(t *testing.T) {
49+ r := newIssueRig(t)
50+ ctx := r.ctx
51+ type who struct {
52+ name string
53+ c *clients
54+ }
55+ all := []who{{"anonymous", r.anon}, {"reader", r.reader}, {"writer", r.writer}, {"blind", r.blind}, {"branch-limited", r.branch}, {"root", r.root}}
56+
57+ calls := func(repo string) (reads, writes map[string]func(c *clients) error) {
58+ reads = map[string]func(c *clients) error{
59+ "ListIssues": func(c *clients) error {
60+ _, err := c.issue.ListIssues(ctx, connect.NewRequest(&pb.ListIssuesRequest{Repo: repo}))
61+ return err
62+ },
63+ "GetIssue": func(c *clients) error {
64+ _, err := c.issue.GetIssue(ctx, connect.NewRequest(&pb.GetIssueRequest{Repo: repo, Number: 1}))
65+ return err
66+ },
67+ }
68+ writes = map[string]func(c *clients) error{
69+ "CreateIssue": func(c *clients) error {
70+ _, err := c.issue.CreateIssue(ctx, connect.NewRequest(&pb.CreateIssueRequest{Repo: repo, Title: "t", Body: "b"}))
71+ return err
72+ },
73+ "AddIssueComment": func(c *clients) error {
74+ _, err := c.issue.AddIssueComment(ctx, connect.NewRequest(&pb.AddIssueCommentRequest{Repo: repo, Number: 1, Text: "x"}))
75+ return err
76+ },
77+ "CloseIssue": func(c *clients) error {
78+ _, err := c.issue.CloseIssue(ctx, connect.NewRequest(&pb.CloseIssueRequest{Repo: repo, Number: 1}))
79+ return err
80+ },
81+ "ReopenIssue": func(c *clients) error {
82+ _, err := c.issue.ReopenIssue(ctx, connect.NewRequest(&pb.ReopenIssueRequest{Repo: repo, Number: 1}))
83+ return err
84+ },
85+ }
86+ return reads, writes
87+ }
88+ // setState puts issue 1 in the state a call needs to succeed.
89+ setState := func(repo, call string) {
90+ name := r.name(repo)
91+ if call == "ReopenIssue" {
92+ _, _ = r.forge.Issues.Close(ctx, domain.Root(), name, 1)
93+ } else {
94+ _, _ = r.forge.Issues.Reopen(ctx, domain.Root(), name, 1)
95+ }
96+ }
97+
98+ // Mode owner: the first issue of each repository is #1.
99+ for _, repo := range []string{"pub", "priv"} {
100+ r.mode(repo, domain.IssuesOwner)
101+ _, err := r.forge.Issues.Create(ctx, domain.Root(), r.name(repo), ports.CreateIssueParams{Title: "first"})
102+ require.NoError(t, err)
103+ }
104+
105+ readWant := map[string]map[string]string{
106+ "priv": {"anonymous": "not_found", "reader": "ok", "writer": "ok", "blind": "not_found", "branch-limited": "ok", "root": "ok"},
107+ "pub": {"anonymous": "ok", "reader": "ok", "writer": "ok", "blind": "ok", "branch-limited": "ok", "root": "ok"},
108+ }
109+ writeWant := map[string]map[string]string{
110+ // A token with issue:write alone sees the repository for the action,
111+ // as mr:write does; a reader without the scope is refused.
112+ "priv": {"anonymous": "not_found", "reader": "permission_denied", "writer": "ok", "blind": "ok", "branch-limited": "ok", "root": "ok"},
113+ "pub": {"anonymous": "unauthenticated", "reader": "permission_denied", "writer": "ok", "blind": "ok", "branch-limited": "ok", "root": "ok"},
114+ }
115+ for _, repo := range []string{"priv", "pub"} {
116+ reads, writes := calls(repo)
117+ for _, w := range all {
118+ for name, call := range reads {
119+ assert.Equal(t, readWant[repo][w.name], codeName(call(w.c)), "%s %s %s", repo, w.name, name)
120+ }
121+ for name, call := range writes {
122+ setState(repo, name)
123+ assert.Equal(t, writeWant[repo][w.name], codeName(call(w.c)), "%s %s %s", repo, w.name, name)
124+ }
125+ }
126+ }
127+
128+ // Issues off, an unknown repository: not found for everyone, root included.
129+ r.mode("priv", domain.IssuesOff)
130+ r.mode("pub", domain.IssuesOff)
131+ for _, repo := range []string{"priv", "pub", "nope"} {
132+ reads, writes := calls(repo)
133+ for _, w := range all {
134+ for name, call := range reads {
135+ assert.Equal(t, "not_found", codeName(call(w.c)), "%s %s %s", repo, w.name, name)
136+ }
137+ for name, call := range writes {
138+ assert.Equal(t, "not_found", codeName(call(w.c)), "%s %s %s", repo, w.name, name)
139+ }
140+ }
141+ }
142+}
143+
144+func TestIssueLifecycleAndSharedNumbersOverTheAPI(t *testing.T) {
145+ r := newMRRig(t, domain.VisibilityPublic)
146+ ctx := r.ctx
147+ _, err := r.forge.Repos.Update(ctx, domain.Root(), r.name("proj"), ports.UpdateRepoParams{Issues: new(domain.IssuesOwner)})
148+ require.NoError(t, err)
149+
150+ mr, err := r.create(r.root)
151+ require.NoError(t, err)
152+ assert.EqualValues(t, 1, mr.GetNumber())
153+
154+ created, err := r.root.issue.CreateIssue(ctx, connect.NewRequest(&pb.CreateIssueRequest{Repo: "proj", Title: "Crash", Body: "it crashes"}))
155+ require.NoError(t, err)
156+ issue := created.Msg.GetIssue()
157+ assert.EqualValues(t, 2, issue.GetNumber(), "numbers are shared with merge requests")
158+ assert.Equal(t, pb.IssueState_ISSUE_STATE_OPEN, issue.GetState())
159+ assert.Equal(t, "root", issue.GetAuthor())
160+
161+ // An issue is not a merge request, and the other way round.
162+ _, err = r.root.mr.GetMergeRequest(ctx, connect.NewRequest(&pb.GetMergeRequestRequest{Repo: "proj", Number: 2}))
163+ assert.Equal(t, "not_found", codeName(err))
164+ _, err = r.root.issue.GetIssue(ctx, connect.NewRequest(&pb.GetIssueRequest{Repo: "proj", Number: 1}))
165+ assert.Equal(t, "not_found", codeName(err))
166+
167+ _, err = r.root.issue.AddIssueComment(ctx, connect.NewRequest(&pb.AddIssueCommentRequest{Repo: "proj", Number: 2, Text: "seen"}))
168+ require.NoError(t, err)
169+ _, err = r.root.issue.AddIssueComment(ctx, connect.NewRequest(&pb.AddIssueCommentRequest{Repo: "proj", Number: 2, Text: " "}))
170+ assert.Equal(t, "invalid_argument", codeName(err), "a blank comment")
171+ _, err = r.root.issue.CreateIssue(ctx, connect.NewRequest(&pb.CreateIssueRequest{Repo: "proj", Title: ""}))
172+ assert.Equal(t, "invalid_argument", codeName(err), "an empty title")
173+
174+ _, err = r.root.issue.ReopenIssue(ctx, connect.NewRequest(&pb.ReopenIssueRequest{Repo: "proj", Number: 2}))
175+ assert.Equal(t, "failed_precondition", codeName(err), "reopening an open issue")
176+ closed, err := r.root.issue.CloseIssue(ctx, connect.NewRequest(&pb.CloseIssueRequest{Repo: "proj", Number: 2}))
177+ require.NoError(t, err)
178+ assert.Equal(t, pb.IssueState_ISSUE_STATE_CLOSED, closed.Msg.GetIssue().GetState())
179+ _, err = r.root.issue.CloseIssue(ctx, connect.NewRequest(&pb.CloseIssueRequest{Repo: "proj", Number: 2}))
180+ assert.Equal(t, "failed_precondition", codeName(err), "closing a closed issue")
181+
182+ got, err := r.anon.issue.GetIssue(ctx, connect.NewRequest(&pb.GetIssueRequest{Repo: "proj", Number: 2}))
183+ require.NoError(t, err)
184+ assert.Equal(t, pb.IssueState_ISSUE_STATE_CLOSED, got.Msg.GetIssue().GetState())
185+ assert.EqualValues(t, 1, got.Msg.GetIssue().GetCommentCount())
186+ require.Len(t, got.Msg.GetComments(), 1)
187+ assert.Equal(t, "seen", got.Msg.GetComments()[0].GetText())
188+
189+ reopened, err := r.root.issue.ReopenIssue(ctx, connect.NewRequest(&pb.ReopenIssueRequest{Repo: "proj", Number: 2}))
190+ require.NoError(t, err)
191+ assert.Equal(t, pb.IssueState_ISSUE_STATE_OPEN, reopened.Msg.GetIssue().GetState())
192+
193+ list, err := r.anon.issue.ListIssues(ctx, connect.NewRequest(&pb.ListIssuesRequest{Repo: "proj", State: pb.IssueState_ISSUE_STATE_OPEN}))
194+ require.NoError(t, err)
195+ require.Len(t, list.Msg.GetIssues(), 1)
196+ list, err = r.anon.issue.ListIssues(ctx, connect.NewRequest(&pb.ListIssuesRequest{Repo: "proj", State: pb.IssueState_ISSUE_STATE_CLOSED}))
197+ require.NoError(t, err)
198+ assert.Empty(t, list.Msg.GetIssues())
199+}
200+
201+func TestIssuesFeatureAndSettingOverTheAPI(t *testing.T) {
202+ r := newIssueRig(t)
203+ ctx := r.ctx
204+ ver, err := r.anon.server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{}))
205+ require.NoError(t, err)
206+ assert.Contains(t, ver.Msg.GetFeatures(), "issues")
207+
208+ update := func(c *clients, mode pb.IssuesMode) (*pb.Repo, error) {
209+ res, err := c.repo.UpdateRepo(ctx, connect.NewRequest(&pb.UpdateRepoRequest{
210+ Repo: "priv", UpdateMask: &fieldmaskpb.FieldMask{}, Issues: &mode,
211+ }))
212+ if err != nil {
213+ return nil, err
214+ }
215+ return res.Msg.GetRepo(), nil
216+ }
217+ repo, err := r.root.repo.GetRepo(ctx, connect.NewRequest(&pb.GetRepoRequest{Repo: "priv"}))
218+ require.NoError(t, err)
219+ assert.Equal(t, pb.IssuesMode_ISSUES_MODE_OFF, repo.Msg.GetRepo().GetSettings().GetIssues())
220+
221+ updated, err := update(r.root, pb.IssuesMode_ISSUES_MODE_OWNER)
222+ require.NoError(t, err)
223+ assert.Equal(t, pb.IssuesMode_ISSUES_MODE_OWNER, updated.GetSettings().GetIssues())
224+ assert.True(t, updated.GetSettings().GetFailingChecksBlock(), "the other settings are kept")
225+ _, err = r.root.issue.CreateIssue(ctx, connect.NewRequest(&pb.CreateIssueRequest{Repo: "priv", Title: "t"}))
226+ require.NoError(t, err)
227+
228+ updated, err = update(r.root, pb.IssuesMode_ISSUES_MODE_PUBLIC)
229+ require.NoError(t, err, "public issues are accepted")
230+ assert.Equal(t, pb.IssuesMode_ISSUES_MODE_PUBLIC, updated.GetSettings().GetIssues())
231+ _, err = update(r.root, pb.IssuesMode_ISSUES_MODE_OWNER)
232+ require.NoError(t, err)
233+ _, err = update(r.root, pb.IssuesMode_ISSUES_MODE_UNSPECIFIED)
234+ assert.Equal(t, "invalid_argument", codeName(err))
235+ repo, err = r.root.repo.GetRepo(ctx, connect.NewRequest(&pb.GetRepoRequest{Repo: "priv"}))
236+ require.NoError(t, err)
237+ assert.Equal(t, pb.IssuesMode_ISSUES_MODE_OWNER, repo.Msg.GetRepo().GetSettings().GetIssues())
238+
239+ _, err = update(r.writer, pb.IssuesMode_ISSUES_MODE_OFF)
240+ assert.Equal(t, "permission_denied", codeName(err), "repo:admin is needed")
241+ _, err = update(r.anon, pb.IssuesMode_ISSUES_MODE_OFF)
242+ assert.Equal(t, "not_found", codeName(err), "anonymous cannot see the private repository")
243+ _, err = update(r.root, pb.IssuesMode_ISSUES_MODE_OFF)
244+ require.NoError(t, err)
245+}
@@ -0,0 +1,245 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "testing"
7+
8+ "connectrpc.com/connect"
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+ "google.golang.org/protobuf/types/known/fieldmaskpb"
12+
13+ pb "bnema.dev/ermite-api/gen/ermite"
14+ "bnema.dev/ermite/internal/domain"
15+ "bnema.dev/ermite/internal/ports"
16+)
17+
18+// issueRig is a harness with the repositories pub and priv, and one caller per
19+// kind of principal.
20+type issueRig struct {
21+ *harness
22+ anon, root, reader, writer, blind, branch *clients
23+}
24+
25+func newIssueRig(t *testing.T) *issueRig {
26+ t.Helper()
27+ h := newHarness(t, harnessOpts{})
28+ r := &issueRig{harness: h}
29+ h.seed("pub", domain.VisibilityPublic)
30+ h.seed("priv", domain.VisibilityPrivate)
31+ r.anon, r.root = h.api(""), h.api(adminToken)
32+ // reader has no issue:write; writer reads and writes; blind holds only
33+ // issue:write; branch is writer limited to branch prefixes.
34+ r.reader = h.api(h.token(scopes(domain.ScopeRepoRead)))
35+ r.writer = h.api(h.token(scopes(domain.ScopeRepoRead, domain.ScopeIssueWrite)))
36+ r.blind = h.api(h.token(scopes(domain.ScopeIssueWrite)))
37+ secret, _ := h.branchToken(scopes(domain.ScopeRepoRead, domain.ScopeRepoWrite, domain.ScopeIssueWrite), []string{"agent/"})
38+ r.branch = h.api(secret)
39+ return r
40+}
41+
42+func (r *issueRig) mode(repo string, mode domain.IssuesMode) {
43+ r.t.Helper()
44+ _, err := r.forge.Repos.Update(r.ctx, domain.Root(), r.name(repo), ports.UpdateRepoParams{Issues: &mode})
45+ require.NoError(r.t, err)
46+}
47+
48+func TestIssueAccessMatrixOverTheAPI(t *testing.T) {
49+ r := newIssueRig(t)
50+ ctx := r.ctx
51+ type who struct {
52+ name string
53+ c *clients
54+ }
55+ all := []who{{"anonymous", r.anon}, {"reader", r.reader}, {"writer", r.writer}, {"blind", r.blind}, {"branch-limited", r.branch}, {"root", r.root}}
56+
57+ calls := func(repo string) (reads, writes map[string]func(c *clients) error) {
58+ reads = map[string]func(c *clients) error{
59+ "ListIssues": func(c *clients) error {
60+ _, err := c.issue.ListIssues(ctx, connect.NewRequest(&pb.ListIssuesRequest{Repo: repo}))
61+ return err
62+ },
63+ "GetIssue": func(c *clients) error {
64+ _, err := c.issue.GetIssue(ctx, connect.NewRequest(&pb.GetIssueRequest{Repo: repo, Number: 1}))
65+ return err
66+ },
67+ }
68+ writes = map[string]func(c *clients) error{
69+ "CreateIssue": func(c *clients) error {
70+ _, err := c.issue.CreateIssue(ctx, connect.NewRequest(&pb.CreateIssueRequest{Repo: repo, Title: "t", Body: "b"}))
71+ return err
72+ },
73+ "AddIssueComment": func(c *clients) error {
74+ _, err := c.issue.AddIssueComment(ctx, connect.NewRequest(&pb.AddIssueCommentRequest{Repo: repo, Number: 1, Text: "x"}))
75+ return err
76+ },
77+ "CloseIssue": func(c *clients) error {
78+ _, err := c.issue.CloseIssue(ctx, connect.NewRequest(&pb.CloseIssueRequest{Repo: repo, Number: 1}))
79+ return err
80+ },
81+ "ReopenIssue": func(c *clients) error {
82+ _, err := c.issue.ReopenIssue(ctx, connect.NewRequest(&pb.ReopenIssueRequest{Repo: repo, Number: 1}))
83+ return err
84+ },
85+ }
86+ return reads, writes
87+ }
88+ // setState puts issue 1 in the state a call needs to succeed.
89+ setState := func(repo, call string) {
90+ name := r.name(repo)
91+ if call == "ReopenIssue" {
92+ _, _ = r.forge.Issues.Close(ctx, domain.Root(), name, 1)
93+ } else {
94+ _, _ = r.forge.Issues.Reopen(ctx, domain.Root(), name, 1)
95+ }
96+ }
97+
98+ // Mode owner: the first issue of each repository is #1.
99+ for _, repo := range []string{"pub", "priv"} {
100+ r.mode(repo, domain.IssuesOwner)
101+ _, err := r.forge.Issues.Create(ctx, domain.Root(), r.name(repo), ports.CreateIssueParams{Title: "first"})
102+ require.NoError(t, err)
103+ }
104+
105+ readWant := map[string]map[string]string{
106+ "priv": {"anonymous": "not_found", "reader": "ok", "writer": "ok", "blind": "not_found", "branch-limited": "ok", "root": "ok"},
107+ "pub": {"anonymous": "ok", "reader": "ok", "writer": "ok", "blind": "ok", "branch-limited": "ok", "root": "ok"},
108+ }
109+ writeWant := map[string]map[string]string{
110+ // A token with issue:write alone sees the repository for the action,
111+ // as mr:write does; a reader without the scope is refused.
112+ "priv": {"anonymous": "not_found", "reader": "permission_denied", "writer": "ok", "blind": "ok", "branch-limited": "ok", "root": "ok"},
113+ "pub": {"anonymous": "unauthenticated", "reader": "permission_denied", "writer": "ok", "blind": "ok", "branch-limited": "ok", "root": "ok"},
114+ }
115+ for _, repo := range []string{"priv", "pub"} {
116+ reads, writes := calls(repo)
117+ for _, w := range all {
118+ for name, call := range reads {
119+ assert.Equal(t, readWant[repo][w.name], codeName(call(w.c)), "%s %s %s", repo, w.name, name)
120+ }
121+ for name, call := range writes {
122+ setState(repo, name)
123+ assert.Equal(t, writeWant[repo][w.name], codeName(call(w.c)), "%s %s %s", repo, w.name, name)
124+ }
125+ }
126+ }
127+
128+ // Issues off, an unknown repository: not found for everyone, root included.
129+ r.mode("priv", domain.IssuesOff)
130+ r.mode("pub", domain.IssuesOff)
131+ for _, repo := range []string{"priv", "pub", "nope"} {
132+ reads, writes := calls(repo)
133+ for _, w := range all {
134+ for name, call := range reads {
135+ assert.Equal(t, "not_found", codeName(call(w.c)), "%s %s %s", repo, w.name, name)
136+ }
137+ for name, call := range writes {
138+ assert.Equal(t, "not_found", codeName(call(w.c)), "%s %s %s", repo, w.name, name)
139+ }
140+ }
141+ }
142+}
143+
144+func TestIssueLifecycleAndSharedNumbersOverTheAPI(t *testing.T) {
145+ r := newMRRig(t, domain.VisibilityPublic)
146+ ctx := r.ctx
147+ _, err := r.forge.Repos.Update(ctx, domain.Root(), r.name("proj"), ports.UpdateRepoParams{Issues: new(domain.IssuesOwner)})
148+ require.NoError(t, err)
149+
150+ mr, err := r.create(r.root)
151+ require.NoError(t, err)
152+ assert.EqualValues(t, 1, mr.GetNumber())
153+
154+ created, err := r.root.issue.CreateIssue(ctx, connect.NewRequest(&pb.CreateIssueRequest{Repo: "proj", Title: "Crash", Body: "it crashes"}))
155+ require.NoError(t, err)
156+ issue := created.Msg.GetIssue()
157+ assert.EqualValues(t, 2, issue.GetNumber(), "numbers are shared with merge requests")
158+ assert.Equal(t, pb.IssueState_ISSUE_STATE_OPEN, issue.GetState())
159+ assert.Equal(t, "root", issue.GetAuthor())
160+
161+ // An issue is not a merge request, and the other way round.
162+ _, err = r.root.mr.GetMergeRequest(ctx, connect.NewRequest(&pb.GetMergeRequestRequest{Repo: "proj", Number: 2}))
163+ assert.Equal(t, "not_found", codeName(err))
164+ _, err = r.root.issue.GetIssue(ctx, connect.NewRequest(&pb.GetIssueRequest{Repo: "proj", Number: 1}))
165+ assert.Equal(t, "not_found", codeName(err))
166+
167+ _, err = r.root.issue.AddIssueComment(ctx, connect.NewRequest(&pb.AddIssueCommentRequest{Repo: "proj", Number: 2, Text: "seen"}))
168+ require.NoError(t, err)
169+ _, err = r.root.issue.AddIssueComment(ctx, connect.NewRequest(&pb.AddIssueCommentRequest{Repo: "proj", Number: 2, Text: " "}))
170+ assert.Equal(t, "invalid_argument", codeName(err), "a blank comment")
171+ _, err = r.root.issue.CreateIssue(ctx, connect.NewRequest(&pb.CreateIssueRequest{Repo: "proj", Title: ""}))
172+ assert.Equal(t, "invalid_argument", codeName(err), "an empty title")
173+
174+ _, err = r.root.issue.ReopenIssue(ctx, connect.NewRequest(&pb.ReopenIssueRequest{Repo: "proj", Number: 2}))
175+ assert.Equal(t, "failed_precondition", codeName(err), "reopening an open issue")
176+ closed, err := r.root.issue.CloseIssue(ctx, connect.NewRequest(&pb.CloseIssueRequest{Repo: "proj", Number: 2}))
177+ require.NoError(t, err)
178+ assert.Equal(t, pb.IssueState_ISSUE_STATE_CLOSED, closed.Msg.GetIssue().GetState())
179+ _, err = r.root.issue.CloseIssue(ctx, connect.NewRequest(&pb.CloseIssueRequest{Repo: "proj", Number: 2}))
180+ assert.Equal(t, "failed_precondition", codeName(err), "closing a closed issue")
181+
182+ got, err := r.anon.issue.GetIssue(ctx, connect.NewRequest(&pb.GetIssueRequest{Repo: "proj", Number: 2}))
183+ require.NoError(t, err)
184+ assert.Equal(t, pb.IssueState_ISSUE_STATE_CLOSED, got.Msg.GetIssue().GetState())
185+ assert.EqualValues(t, 1, got.Msg.GetIssue().GetCommentCount())
186+ require.Len(t, got.Msg.GetComments(), 1)
187+ assert.Equal(t, "seen", got.Msg.GetComments()[0].GetText())
188+
189+ reopened, err := r.root.issue.ReopenIssue(ctx, connect.NewRequest(&pb.ReopenIssueRequest{Repo: "proj", Number: 2}))
190+ require.NoError(t, err)
191+ assert.Equal(t, pb.IssueState_ISSUE_STATE_OPEN, reopened.Msg.GetIssue().GetState())
192+
193+ list, err := r.anon.issue.ListIssues(ctx, connect.NewRequest(&pb.ListIssuesRequest{Repo: "proj", State: pb.IssueState_ISSUE_STATE_OPEN}))
194+ require.NoError(t, err)
195+ require.Len(t, list.Msg.GetIssues(), 1)
196+ list, err = r.anon.issue.ListIssues(ctx, connect.NewRequest(&pb.ListIssuesRequest{Repo: "proj", State: pb.IssueState_ISSUE_STATE_CLOSED}))
197+ require.NoError(t, err)
198+ assert.Empty(t, list.Msg.GetIssues())
199+}
200+
201+func TestIssuesFeatureAndSettingOverTheAPI(t *testing.T) {
202+ r := newIssueRig(t)
203+ ctx := r.ctx
204+ ver, err := r.anon.server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{}))
205+ require.NoError(t, err)
206+ assert.Contains(t, ver.Msg.GetFeatures(), "issues")
207+
208+ update := func(c *clients, mode pb.IssuesMode) (*pb.Repo, error) {
209+ res, err := c.repo.UpdateRepo(ctx, connect.NewRequest(&pb.UpdateRepoRequest{
210+ Repo: "priv", UpdateMask: &fieldmaskpb.FieldMask{}, Issues: &mode,
211+ }))
212+ if err != nil {
213+ return nil, err
214+ }
215+ return res.Msg.GetRepo(), nil
216+ }
217+ repo, err := r.root.repo.GetRepo(ctx, connect.NewRequest(&pb.GetRepoRequest{Repo: "priv"}))
218+ require.NoError(t, err)
219+ assert.Equal(t, pb.IssuesMode_ISSUES_MODE_OFF, repo.Msg.GetRepo().GetSettings().GetIssues())
220+
221+ updated, err := update(r.root, pb.IssuesMode_ISSUES_MODE_OWNER)
222+ require.NoError(t, err)
223+ assert.Equal(t, pb.IssuesMode_ISSUES_MODE_OWNER, updated.GetSettings().GetIssues())
224+ assert.True(t, updated.GetSettings().GetFailingChecksBlock(), "the other settings are kept")
225+ _, err = r.root.issue.CreateIssue(ctx, connect.NewRequest(&pb.CreateIssueRequest{Repo: "priv", Title: "t"}))
226+ require.NoError(t, err)
227+
228+ updated, err = update(r.root, pb.IssuesMode_ISSUES_MODE_PUBLIC)
229+ require.NoError(t, err, "public issues are accepted")
230+ assert.Equal(t, pb.IssuesMode_ISSUES_MODE_PUBLIC, updated.GetSettings().GetIssues())
231+ _, err = update(r.root, pb.IssuesMode_ISSUES_MODE_OWNER)
232+ require.NoError(t, err)
233+ _, err = update(r.root, pb.IssuesMode_ISSUES_MODE_UNSPECIFIED)
234+ assert.Equal(t, "invalid_argument", codeName(err))
235+ repo, err = r.root.repo.GetRepo(ctx, connect.NewRequest(&pb.GetRepoRequest{Repo: "priv"}))
236+ require.NoError(t, err)
237+ assert.Equal(t, pb.IssuesMode_ISSUES_MODE_OWNER, repo.Msg.GetRepo().GetSettings().GetIssues())
238+
239+ _, err = update(r.writer, pb.IssuesMode_ISSUES_MODE_OFF)
240+ assert.Equal(t, "permission_denied", codeName(err), "repo:admin is needed")
241+ _, err = update(r.anon, pb.IssuesMode_ISSUES_MODE_OFF)
242+ assert.Equal(t, "not_found", codeName(err), "anonymous cannot see the private repository")
243+ _, err = update(r.root, pb.IssuesMode_ISSUES_MODE_OFF)
244+ require.NoError(t, err)
245+}
A internal/app/issue_moderation_api_test.go +139 -0
@@ -0,0 +1,139 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "crypto/rand"
7+ "testing"
8+ "time"
9+
10+ "connectrpc.com/connect"
11+ "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/require"
13+
14+ pb "bnema.dev/ermite-api/gen/ermite"
15+ "bnema.dev/ermite/internal/clientlimit"
16+ "bnema.dev/ermite/internal/domain"
17+ "bnema.dev/ermite/internal/ports"
18+)
19+
20+func (r *issueRig) pending(repo, title string) domain.PendingIssue {
21+ r.t.Helper()
22+ id, err := domain.NewPendingIssueID()
23+ require.NoError(r.t, err)
24+ ti, err := domain.ParseIssueTitle(title)
25+ require.NoError(r.t, err)
26+ body, err := domain.ParseIssueBody("body of " + title)
27+ require.NoError(r.t, err)
28+ hash := make([]byte, domain.IPHashLen)
29+ _, err = rand.Read(hash)
30+ require.NoError(r.t, err)
31+ p := domain.PendingIssue{
32+ ID: id, Repo: r.name(repo), Title: ti, Body: body, Author: "visitor",
33+ CreatedAt: time.Now().UTC(), IPHash: hash,
34+ }
35+ require.NoError(r.t, r.forge.PendingIssues.Put(r.ctx, p))
36+ return p
37+}
38+
39+func TestIssueModerationOverTheAPI(t *testing.T) {
40+ r := newIssueRig(t)
41+ ctx := r.ctx
42+ r.mode("pub", domain.IssuesOwner)
43+ owner := r.api(r.token(domain.AllScopes()))
44+ p1 := r.pending("pub", "first")
45+ p2 := r.pending("pub", "second")
46+
47+ // Only owner rights see anything: everyone else gets NotFound.
48+ for name, c := range map[string]*clients{"anonymous": r.anon, "reader": r.reader, "writer": r.writer, "blind": r.blind, "branch": r.branch} {
49+ _, err := c.issue.ListPendingIssues(ctx, connect.NewRequest(&pb.ListPendingIssuesRequest{}))
50+ assert.Equal(t, "not_found", codeName(err), "%s list", name)
51+ _, err = c.issue.ApproveIssue(ctx, connect.NewRequest(&pb.ApproveIssueRequest{Repo: "pub", Id: p1.ID.String()}))
52+ assert.Equal(t, "not_found", codeName(err), "%s approve", name)
53+ _, err = c.issue.RejectIssue(ctx, connect.NewRequest(&pb.RejectIssueRequest{Repo: "pub", Id: p1.ID.String(), Ban: true}))
54+ assert.Equal(t, "not_found", codeName(err), "%s reject", name)
55+ // A malformed repository tells a non-owner nothing more.
56+ _, err = c.issue.ListPendingIssues(ctx, connect.NewRequest(&pb.ListPendingIssuesRequest{Repo: "Bad Repo!"}))
57+ assert.Equal(t, "not_found", codeName(err), "%s list, malformed repo", name)
58+ _, err = c.issue.ApproveIssue(ctx, connect.NewRequest(&pb.ApproveIssueRequest{Repo: "Bad Repo!", Id: p1.ID.String()}))
59+ assert.Equal(t, "not_found", codeName(err), "%s approve, malformed repo", name)
60+ _, err = c.issue.RejectIssue(ctx, connect.NewRequest(&pb.RejectIssueRequest{Repo: "Bad Repo!", Id: "nope"}))
61+ assert.Equal(t, "not_found", codeName(err), "%s reject, malformed repo", name)
62+ }
63+ still, err := r.forge.PendingIssues.Count(ctx)
64+ require.NoError(t, err)
65+ assert.Equal(t, 2, still, "nothing changed")
66+
67+ for name, c := range map[string]*clients{"root": r.root, "owner token": owner} {
68+ res, err := c.issue.ListPendingIssues(ctx, connect.NewRequest(&pb.ListPendingIssuesRequest{}))
69+ require.NoError(t, err, name)
70+ require.Len(t, res.Msg.GetIssues(), 2, name)
71+ assert.Equal(t, p1.ID.String(), res.Msg.GetIssues()[0].GetId(), "oldest first")
72+ assert.Equal(t, "visitor", res.Msg.GetIssues()[0].GetAuthor())
73+ }
74+ res, err := owner.issue.ListPendingIssues(ctx, connect.NewRequest(&pb.ListPendingIssuesRequest{Repo: "priv"}))
75+ require.NoError(t, err)
76+ assert.Empty(t, res.Msg.GetIssues())
77+
78+ // An owner does learn that a repository name is malformed.
79+ _, err = owner.issue.ApproveIssue(ctx, connect.NewRequest(&pb.ApproveIssueRequest{Repo: "Bad Repo!", Id: p1.ID.String()}))
80+ assert.Equal(t, "invalid_argument", codeName(err))
81+
82+ // A malformed or unknown id is NotFound.
83+ _, err = owner.issue.ApproveIssue(ctx, connect.NewRequest(&pb.ApproveIssueRequest{Repo: "pub", Id: "nope"}))
84+ assert.Equal(t, "not_found", codeName(err))
85+
86+ // Approval takes the next number shared with the merge requests and issues.
87+ made, err := owner.issue.CreateIssue(ctx, connect.NewRequest(&pb.CreateIssueRequest{Repo: "pub", Title: "by owner"}))
88+ require.NoError(t, err)
89+ assert.Equal(t, int64(1), made.Msg.GetIssue().GetNumber())
90+ ap, err := owner.issue.ApproveIssue(ctx, connect.NewRequest(&pb.ApproveIssueRequest{Repo: "pub", Id: p1.ID.String()}))
91+ require.NoError(t, err)
92+ assert.Equal(t, int64(2), ap.Msg.GetIssue().GetNumber())
93+ assert.Equal(t, pb.IssueState_ISSUE_STATE_OPEN, ap.Msg.GetIssue().GetState())
94+ assert.Equal(t, "first", ap.Msg.GetIssue().GetTitle())
95+ got, err := r.reader.issue.GetIssue(ctx, connect.NewRequest(&pb.GetIssueRequest{Repo: "pub", Number: 2}))
96+ require.NoError(t, err)
97+ assert.Equal(t, "visitor", got.Msg.GetIssue().GetAuthor())
98+ _, err = owner.issue.ApproveIssue(ctx, connect.NewRequest(&pb.ApproveIssueRequest{Repo: "pub", Id: p1.ID.String()}))
99+ assert.Equal(t, "not_found", codeName(err), "approved once")
100+
101+ // Rejecting with a ban stores the ban and removes the record.
102+ _, err = owner.issue.RejectIssue(ctx, connect.NewRequest(&pb.RejectIssueRequest{Repo: "pub", Id: p2.ID.String(), Ban: true}))
103+ require.NoError(t, err)
104+ banned, err := r.forge.IssueBans.Banned(ctx, p2.IPHash, time.Now())
105+ require.NoError(t, err)
106+ assert.True(t, banned)
107+ banned, err = r.forge.IssueBans.Banned(ctx, p2.IPHash, time.Now().Add(domain.BanDuration+time.Hour))
108+ require.NoError(t, err)
109+ assert.False(t, banned, "the ban expires")
110+ _, err = r.forge.PendingIssues.Get(ctx, p2.Repo, p2.ID)
111+ assert.ErrorIs(t, err, domain.ErrNotFound)
112+
113+ // Rejecting without a ban bans nobody.
114+ p3 := r.pending("pub", "third")
115+ _, err = owner.issue.RejectIssue(ctx, connect.NewRequest(&pb.RejectIssueRequest{Repo: "pub", Id: p3.ID.String()}))
116+ require.NoError(t, err)
117+ banned, err = r.forge.IssueBans.Banned(ctx, p3.IPHash, time.Now())
118+ require.NoError(t, err)
119+ assert.False(t, banned)
120+
121+ // The inbox lists what awaits.
122+ r.pending("pub", "fourth")
123+ ov, err := r.forge.Inbox.Overview(ctx, domain.Root(), domain.RepoName{})
124+ require.NoError(t, err)
125+ assert.Equal(t, 1, ov.Counts.Pending)
126+ require.Len(t, ov.Pending, 1)
127+ assert.Nil(t, ov.Pending[0].IPHash)
128+
129+ // The public mode is accepted.
130+ pub := domain.IssuesPublic
131+ _, err = r.forge.Repos.Update(ctx, domain.Root(), r.name("pub"), ports.UpdateRepoParams{Issues: &pub})
132+ assert.NoError(t, err)
133+}
134+
135+// clientlimit may not import the domain: its copies of the limits must match.
136+func TestSubmissionLimiterUsesTheDomainLimits(t *testing.T) {
137+ assert.Equal(t, domain.SubmitPerHour, clientlimit.PerHour)
138+ assert.Equal(t, domain.SubmitPerDay, clientlimit.PerDay)
139+}
@@ -0,0 +1,139 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "crypto/rand"
7+ "testing"
8+ "time"
9+
10+ "connectrpc.com/connect"
11+ "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/require"
13+
14+ pb "bnema.dev/ermite-api/gen/ermite"
15+ "bnema.dev/ermite/internal/clientlimit"
16+ "bnema.dev/ermite/internal/domain"
17+ "bnema.dev/ermite/internal/ports"
18+)
19+
20+func (r *issueRig) pending(repo, title string) domain.PendingIssue {
21+ r.t.Helper()
22+ id, err := domain.NewPendingIssueID()
23+ require.NoError(r.t, err)
24+ ti, err := domain.ParseIssueTitle(title)
25+ require.NoError(r.t, err)
26+ body, err := domain.ParseIssueBody("body of " + title)
27+ require.NoError(r.t, err)
28+ hash := make([]byte, domain.IPHashLen)
29+ _, err = rand.Read(hash)
30+ require.NoError(r.t, err)
31+ p := domain.PendingIssue{
32+ ID: id, Repo: r.name(repo), Title: ti, Body: body, Author: "visitor",
33+ CreatedAt: time.Now().UTC(), IPHash: hash,
34+ }
35+ require.NoError(r.t, r.forge.PendingIssues.Put(r.ctx, p))
36+ return p
37+}
38+
39+func TestIssueModerationOverTheAPI(t *testing.T) {
40+ r := newIssueRig(t)
41+ ctx := r.ctx
42+ r.mode("pub", domain.IssuesOwner)
43+ owner := r.api(r.token(domain.AllScopes()))
44+ p1 := r.pending("pub", "first")
45+ p2 := r.pending("pub", "second")
46+
47+ // Only owner rights see anything: everyone else gets NotFound.
48+ for name, c := range map[string]*clients{"anonymous": r.anon, "reader": r.reader, "writer": r.writer, "blind": r.blind, "branch": r.branch} {
49+ _, err := c.issue.ListPendingIssues(ctx, connect.NewRequest(&pb.ListPendingIssuesRequest{}))
50+ assert.Equal(t, "not_found", codeName(err), "%s list", name)
51+ _, err = c.issue.ApproveIssue(ctx, connect.NewRequest(&pb.ApproveIssueRequest{Repo: "pub", Id: p1.ID.String()}))
52+ assert.Equal(t, "not_found", codeName(err), "%s approve", name)
53+ _, err = c.issue.RejectIssue(ctx, connect.NewRequest(&pb.RejectIssueRequest{Repo: "pub", Id: p1.ID.String(), Ban: true}))
54+ assert.Equal(t, "not_found", codeName(err), "%s reject", name)
55+ // A malformed repository tells a non-owner nothing more.
56+ _, err = c.issue.ListPendingIssues(ctx, connect.NewRequest(&pb.ListPendingIssuesRequest{Repo: "Bad Repo!"}))
57+ assert.Equal(t, "not_found", codeName(err), "%s list, malformed repo", name)
58+ _, err = c.issue.ApproveIssue(ctx, connect.NewRequest(&pb.ApproveIssueRequest{Repo: "Bad Repo!", Id: p1.ID.String()}))
59+ assert.Equal(t, "not_found", codeName(err), "%s approve, malformed repo", name)
60+ _, err = c.issue.RejectIssue(ctx, connect.NewRequest(&pb.RejectIssueRequest{Repo: "Bad Repo!", Id: "nope"}))
61+ assert.Equal(t, "not_found", codeName(err), "%s reject, malformed repo", name)
62+ }
63+ still, err := r.forge.PendingIssues.Count(ctx)
64+ require.NoError(t, err)
65+ assert.Equal(t, 2, still, "nothing changed")
66+
67+ for name, c := range map[string]*clients{"root": r.root, "owner token": owner} {
68+ res, err := c.issue.ListPendingIssues(ctx, connect.NewRequest(&pb.ListPendingIssuesRequest{}))
69+ require.NoError(t, err, name)
70+ require.Len(t, res.Msg.GetIssues(), 2, name)
71+ assert.Equal(t, p1.ID.String(), res.Msg.GetIssues()[0].GetId(), "oldest first")
72+ assert.Equal(t, "visitor", res.Msg.GetIssues()[0].GetAuthor())
73+ }
74+ res, err := owner.issue.ListPendingIssues(ctx, connect.NewRequest(&pb.ListPendingIssuesRequest{Repo: "priv"}))
75+ require.NoError(t, err)
76+ assert.Empty(t, res.Msg.GetIssues())
77+
78+ // An owner does learn that a repository name is malformed.
79+ _, err = owner.issue.ApproveIssue(ctx, connect.NewRequest(&pb.ApproveIssueRequest{Repo: "Bad Repo!", Id: p1.ID.String()}))
80+ assert.Equal(t, "invalid_argument", codeName(err))
81+
82+ // A malformed or unknown id is NotFound.
83+ _, err = owner.issue.ApproveIssue(ctx, connect.NewRequest(&pb.ApproveIssueRequest{Repo: "pub", Id: "nope"}))
84+ assert.Equal(t, "not_found", codeName(err))
85+
86+ // Approval takes the next number shared with the merge requests and issues.
87+ made, err := owner.issue.CreateIssue(ctx, connect.NewRequest(&pb.CreateIssueRequest{Repo: "pub", Title: "by owner"}))
88+ require.NoError(t, err)
89+ assert.Equal(t, int64(1), made.Msg.GetIssue().GetNumber())
90+ ap, err := owner.issue.ApproveIssue(ctx, connect.NewRequest(&pb.ApproveIssueRequest{Repo: "pub", Id: p1.ID.String()}))
91+ require.NoError(t, err)
92+ assert.Equal(t, int64(2), ap.Msg.GetIssue().GetNumber())
93+ assert.Equal(t, pb.IssueState_ISSUE_STATE_OPEN, ap.Msg.GetIssue().GetState())
94+ assert.Equal(t, "first", ap.Msg.GetIssue().GetTitle())
95+ got, err := r.reader.issue.GetIssue(ctx, connect.NewRequest(&pb.GetIssueRequest{Repo: "pub", Number: 2}))
96+ require.NoError(t, err)
97+ assert.Equal(t, "visitor", got.Msg.GetIssue().GetAuthor())
98+ _, err = owner.issue.ApproveIssue(ctx, connect.NewRequest(&pb.ApproveIssueRequest{Repo: "pub", Id: p1.ID.String()}))
99+ assert.Equal(t, "not_found", codeName(err), "approved once")
100+
101+ // Rejecting with a ban stores the ban and removes the record.
102+ _, err = owner.issue.RejectIssue(ctx, connect.NewRequest(&pb.RejectIssueRequest{Repo: "pub", Id: p2.ID.String(), Ban: true}))
103+ require.NoError(t, err)
104+ banned, err := r.forge.IssueBans.Banned(ctx, p2.IPHash, time.Now())
105+ require.NoError(t, err)
106+ assert.True(t, banned)
107+ banned, err = r.forge.IssueBans.Banned(ctx, p2.IPHash, time.Now().Add(domain.BanDuration+time.Hour))
108+ require.NoError(t, err)
109+ assert.False(t, banned, "the ban expires")
110+ _, err = r.forge.PendingIssues.Get(ctx, p2.Repo, p2.ID)
111+ assert.ErrorIs(t, err, domain.ErrNotFound)
112+
113+ // Rejecting without a ban bans nobody.
114+ p3 := r.pending("pub", "third")
115+ _, err = owner.issue.RejectIssue(ctx, connect.NewRequest(&pb.RejectIssueRequest{Repo: "pub", Id: p3.ID.String()}))
116+ require.NoError(t, err)
117+ banned, err = r.forge.IssueBans.Banned(ctx, p3.IPHash, time.Now())
118+ require.NoError(t, err)
119+ assert.False(t, banned)
120+
121+ // The inbox lists what awaits.
122+ r.pending("pub", "fourth")
123+ ov, err := r.forge.Inbox.Overview(ctx, domain.Root(), domain.RepoName{})
124+ require.NoError(t, err)
125+ assert.Equal(t, 1, ov.Counts.Pending)
126+ require.Len(t, ov.Pending, 1)
127+ assert.Nil(t, ov.Pending[0].IPHash)
128+
129+ // The public mode is accepted.
130+ pub := domain.IssuesPublic
131+ _, err = r.forge.Repos.Update(ctx, domain.Root(), r.name("pub"), ports.UpdateRepoParams{Issues: &pub})
132+ assert.NoError(t, err)
133+}
134+
135+// clientlimit may not import the domain: its copies of the limits must match.
136+func TestSubmissionLimiterUsesTheDomainLimits(t *testing.T) {
137+ assert.Equal(t, domain.SubmitPerHour, clientlimit.PerHour)
138+ assert.Equal(t, domain.SubmitPerDay, clientlimit.PerDay)
139+}
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
M internal/app/testdata/web/blob-binary.html +3 -1
@@ -227,8 +227,9 @@
227227 <kbd>c</kbd>
228228 <kbd>r</kbd>
229229 <kbd>m</kbd>
230+<kbd>i</kbd>
230231 </dt>
231-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
232+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
232233 </div>
233234 <div>
234235 <dt>
@@ -410,6 +411,7 @@
410411 k-c from:body -> ermite.tab('c');
411412 k-r from:body -> ermite.tab('r');
412413 k-m from:body -> ermite.tab('m');
414+ k-i from:body -> ermite.tab('i');
413415 k-next from:body -> ermite.nextNote();
414416 k-split from:body -> ermite.layout('split');
415417 k-unified from:body -> ermite.layout('unified');
@@ -227,8 +227,9 @@
227 <kbd>c</kbd>227 <kbd>c</kbd>
228 <kbd>r</kbd>228 <kbd>r</kbd>
229 <kbd>m</kbd>229 <kbd>m</kbd>
230+<kbd>i</kbd>
230 </dt>231 </dt>
231-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>232+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
232 </div>233 </div>
233 <div>234 <div>
234 <dt>235 <dt>
@@ -410,6 +411,7 @@
410 k-c from:body -> ermite.tab('c');411 k-c from:body -> ermite.tab('c');
411 k-r from:body -> ermite.tab('r');412 k-r from:body -> ermite.tab('r');
412 k-m from:body -> ermite.tab('m');413 k-m from:body -> ermite.tab('m');
414+ k-i from:body -> ermite.tab('i');
413 k-next from:body -> ermite.nextNote();415 k-next from:body -> ermite.nextNote();
414 k-split from:body -> ermite.layout('split');416 k-split from:body -> ermite.layout('split');
415 k-unified from:body -> ermite.layout('unified');417 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/blob-code.html +3 -1
@@ -297,8 +297,9 @@
297297 <kbd>c</kbd>
298298 <kbd>r</kbd>
299299 <kbd>m</kbd>
300+<kbd>i</kbd>
300301 </dt>
301-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
302+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
302303 </div>
303304 <div>
304305 <dt>
@@ -480,6 +481,7 @@
480481 k-c from:body -> ermite.tab('c');
481482 k-r from:body -> ermite.tab('r');
482483 k-m from:body -> ermite.tab('m');
484+ k-i from:body -> ermite.tab('i');
483485 k-next from:body -> ermite.nextNote();
484486 k-split from:body -> ermite.layout('split');
485487 k-unified from:body -> ermite.layout('unified');
@@ -297,8 +297,9 @@
297 <kbd>c</kbd>297 <kbd>c</kbd>
298 <kbd>r</kbd>298 <kbd>r</kbd>
299 <kbd>m</kbd>299 <kbd>m</kbd>
300+<kbd>i</kbd>
300 </dt>301 </dt>
301-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>302+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
302 </div>303 </div>
303 <div>304 <div>
304 <dt>305 <dt>
@@ -480,6 +481,7 @@
480 k-c from:body -> ermite.tab('c');481 k-c from:body -> ermite.tab('c');
481 k-r from:body -> ermite.tab('r');482 k-r from:body -> ermite.tab('r');
482 k-m from:body -> ermite.tab('m');483 k-m from:body -> ermite.tab('m');
484+ k-i from:body -> ermite.tab('i');
483 k-next from:body -> ermite.nextNote();485 k-next from:body -> ermite.nextNote();
484 k-split from:body -> ermite.layout('split');486 k-split from:body -> ermite.layout('split');
485 k-unified from:body -> ermite.layout('unified');487 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/blob-empty.html +3 -1
@@ -227,8 +227,9 @@
227227 <kbd>c</kbd>
228228 <kbd>r</kbd>
229229 <kbd>m</kbd>
230+<kbd>i</kbd>
230231 </dt>
231-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
232+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
232233 </div>
233234 <div>
234235 <dt>
@@ -410,6 +411,7 @@
410411 k-c from:body -> ermite.tab('c');
411412 k-r from:body -> ermite.tab('r');
412413 k-m from:body -> ermite.tab('m');
414+ k-i from:body -> ermite.tab('i');
413415 k-next from:body -> ermite.nextNote();
414416 k-split from:body -> ermite.layout('split');
415417 k-unified from:body -> ermite.layout('unified');
@@ -227,8 +227,9 @@
227 <kbd>c</kbd>227 <kbd>c</kbd>
228 <kbd>r</kbd>228 <kbd>r</kbd>
229 <kbd>m</kbd>229 <kbd>m</kbd>
230+<kbd>i</kbd>
230 </dt>231 </dt>
231-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>232+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
232 </div>233 </div>
233 <div>234 <div>
234 <dt>235 <dt>
@@ -410,6 +411,7 @@
410 k-c from:body -> ermite.tab('c');411 k-c from:body -> ermite.tab('c');
411 k-r from:body -> ermite.tab('r');412 k-r from:body -> ermite.tab('r');
412 k-m from:body -> ermite.tab('m');413 k-m from:body -> ermite.tab('m');
414+ k-i from:body -> ermite.tab('i');
413 k-next from:body -> ermite.nextNote();415 k-next from:body -> ermite.nextNote();
414 k-split from:body -> ermite.layout('split');416 k-split from:body -> ermite.layout('split');
415 k-unified from:body -> ermite.layout('unified');417 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/blob-image.html +3 -1
@@ -229,8 +229,9 @@
229229 <kbd>c</kbd>
230230 <kbd>r</kbd>
231231 <kbd>m</kbd>
232+<kbd>i</kbd>
232233 </dt>
233-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
234+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
234235 </div>
235236 <div>
236237 <dt>
@@ -412,6 +413,7 @@
412413 k-c from:body -> ermite.tab('c');
413414 k-r from:body -> ermite.tab('r');
414415 k-m from:body -> ermite.tab('m');
416+ k-i from:body -> ermite.tab('i');
415417 k-next from:body -> ermite.nextNote();
416418 k-split from:body -> ermite.layout('split');
417419 k-unified from:body -> ermite.layout('unified');
@@ -229,8 +229,9 @@
229 <kbd>c</kbd>229 <kbd>c</kbd>
230 <kbd>r</kbd>230 <kbd>r</kbd>
231 <kbd>m</kbd>231 <kbd>m</kbd>
232+<kbd>i</kbd>
232 </dt>233 </dt>
233-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>234+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
234 </div>235 </div>
235 <div>236 <div>
236 <dt>237 <dt>
@@ -412,6 +413,7 @@
412 k-c from:body -> ermite.tab('c');413 k-c from:body -> ermite.tab('c');
413 k-r from:body -> ermite.tab('r');414 k-r from:body -> ermite.tab('r');
414 k-m from:body -> ermite.tab('m');415 k-m from:body -> ermite.tab('m');
416+ k-i from:body -> ermite.tab('i');
415 k-next from:body -> ermite.nextNote();417 k-next from:body -> ermite.nextNote();
416 k-split from:body -> ermite.layout('split');418 k-split from:body -> ermite.layout('split');
417 k-unified from:body -> ermite.layout('unified');419 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/blob-markdown.html +3 -1
@@ -294,8 +294,9 @@
294294 <kbd>c</kbd>
295295 <kbd>r</kbd>
296296 <kbd>m</kbd>
297+<kbd>i</kbd>
297298 </dt>
298-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
299+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
299300 </div>
300301 <div>
301302 <dt>
@@ -477,6 +478,7 @@
477478 k-c from:body -> ermite.tab('c');
478479 k-r from:body -> ermite.tab('r');
479480 k-m from:body -> ermite.tab('m');
481+ k-i from:body -> ermite.tab('i');
480482 k-next from:body -> ermite.nextNote();
481483 k-split from:body -> ermite.layout('split');
482484 k-unified from:body -> ermite.layout('unified');
@@ -294,8 +294,9 @@
294 <kbd>c</kbd>294 <kbd>c</kbd>
295 <kbd>r</kbd>295 <kbd>r</kbd>
296 <kbd>m</kbd>296 <kbd>m</kbd>
297+<kbd>i</kbd>
297 </dt>298 </dt>
298-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>299+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
299 </div>300 </div>
300 <div>301 <div>
301 <dt>302 <dt>
@@ -477,6 +478,7 @@
477 k-c from:body -> ermite.tab('c');478 k-c from:body -> ermite.tab('c');
478 k-r from:body -> ermite.tab('r');479 k-r from:body -> ermite.tab('r');
479 k-m from:body -> ermite.tab('m');480 k-m from:body -> ermite.tab('m');
481+ k-i from:body -> ermite.tab('i');
480 k-next from:body -> ermite.nextNote();482 k-next from:body -> ermite.nextNote();
481 k-split from:body -> ermite.layout('split');483 k-split from:body -> ermite.layout('split');
482 k-unified from:body -> ermite.layout('unified');484 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/blob-svg.html +3 -1
@@ -252,8 +252,9 @@
252252 <kbd>c</kbd>
253253 <kbd>r</kbd>
254254 <kbd>m</kbd>
255+<kbd>i</kbd>
255256 </dt>
256-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
257+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
257258 </div>
258259 <div>
259260 <dt>
@@ -435,6 +436,7 @@
435436 k-c from:body -> ermite.tab('c');
436437 k-r from:body -> ermite.tab('r');
437438 k-m from:body -> ermite.tab('m');
439+ k-i from:body -> ermite.tab('i');
438440 k-next from:body -> ermite.nextNote();
439441 k-split from:body -> ermite.layout('split');
440442 k-unified from:body -> ermite.layout('unified');
@@ -252,8 +252,9 @@
252 <kbd>c</kbd>252 <kbd>c</kbd>
253 <kbd>r</kbd>253 <kbd>r</kbd>
254 <kbd>m</kbd>254 <kbd>m</kbd>
255+<kbd>i</kbd>
255 </dt>256 </dt>
256-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>257+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
257 </div>258 </div>
258 <div>259 <div>
259 <dt>260 <dt>
@@ -435,6 +436,7 @@
435 k-c from:body -> ermite.tab('c');436 k-c from:body -> ermite.tab('c');
436 k-r from:body -> ermite.tab('r');437 k-r from:body -> ermite.tab('r');
437 k-m from:body -> ermite.tab('m');438 k-m from:body -> ermite.tab('m');
439+ k-i from:body -> ermite.tab('i');
438 k-next from:body -> ermite.nextNote();440 k-next from:body -> ermite.nextNote();
439 k-split from:body -> ermite.layout('split');441 k-split from:body -> ermite.layout('split');
440 k-unified from:body -> ermite.layout('unified');442 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/blob-text.html +3 -1
@@ -247,8 +247,9 @@
247247 <kbd>c</kbd>
248248 <kbd>r</kbd>
249249 <kbd>m</kbd>
250+<kbd>i</kbd>
250251 </dt>
251-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
252+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
252253 </div>
253254 <div>
254255 <dt>
@@ -430,6 +431,7 @@
430431 k-c from:body -> ermite.tab('c');
431432 k-r from:body -> ermite.tab('r');
432433 k-m from:body -> ermite.tab('m');
434+ k-i from:body -> ermite.tab('i');
433435 k-next from:body -> ermite.nextNote();
434436 k-split from:body -> ermite.layout('split');
435437 k-unified from:body -> ermite.layout('unified');
@@ -247,8 +247,9 @@
247 <kbd>c</kbd>247 <kbd>c</kbd>
248 <kbd>r</kbd>248 <kbd>r</kbd>
249 <kbd>m</kbd>249 <kbd>m</kbd>
250+<kbd>i</kbd>
250 </dt>251 </dt>
251-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>252+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
252 </div>253 </div>
253 <div>254 <div>
254 <dt>255 <dt>
@@ -430,6 +431,7 @@
430 k-c from:body -> ermite.tab('c');431 k-c from:body -> ermite.tab('c');
431 k-r from:body -> ermite.tab('r');432 k-r from:body -> ermite.tab('r');
432 k-m from:body -> ermite.tab('m');433 k-m from:body -> ermite.tab('m');
434+ k-i from:body -> ermite.tab('i');
433 k-next from:body -> ermite.nextNote();435 k-next from:body -> ermite.nextNote();
434 k-split from:body -> ermite.layout('split');436 k-split from:body -> ermite.layout('split');
435 k-unified from:body -> ermite.layout('unified');437 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/blob-too-large.html +3 -1
M internal/app/testdata/web/blob-trojan.html +3 -1
M internal/app/testdata/web/commit-big.html +3 -1
M internal/app/testdata/web/commit-binary.html +3 -1
M internal/app/testdata/web/commit-merge.html +3 -1
M internal/app/testdata/web/commit-modify-split.html +3 -1
M internal/app/testdata/web/commit-modify.html +3 -1
M internal/app/testdata/web/commit-rename.html +3 -1
M internal/app/testdata/web/commit-root.html +3 -1
M internal/app/testdata/web/commit-trojan.html +3 -1
M internal/app/testdata/web/commits-page2.html +3 -1
M internal/app/testdata/web/commits-path.html +3 -1
M internal/app/testdata/web/commits-root.html +4 -2
M internal/app/testdata/web/commits-small.html +4 -2
M internal/app/testdata/web/empty.html +3 -1
M internal/app/testdata/web/home.html +4 -2
M internal/app/testdata/web/not-found-page.html +3 -1
M internal/app/testdata/web/not-found.html +3 -1
M internal/app/testdata/web/refs.html +4 -2
M internal/app/testdata/web/summary.html +4 -2
M internal/app/testdata/web/tree-nested.html +3 -1
M internal/app/testdata/web/tree-root.html +3 -1
M internal/app/testdata/web/tree-slash-ref.html +3 -1
M internal/app/testdata/web/tree-subdir.html +3 -1
M internal/app/web.go +5 -0
A internal/app/web_inbox_test.go +152 -0
A internal/app/web_issue_form_test.go +231 -0
A internal/app/web_issue_test.go +139 -0
M internal/clientlimit/export_test.go +7 -0
A internal/clientlimit/submissions.go +178 -0
A internal/clientlimit/submissions_test.go +148 -0
M internal/domain/event.go +72 -0
A internal/domain/issue.go +106 -0
A internal/domain/issue_test.go +138 -0
M internal/domain/mergerequest.go +3 -1
A internal/domain/pending_issue.go +122 -0
A internal/domain/pending_issue_test.go +90 -0
A internal/domain/submission.go +42 -0
M internal/domain/text.go +102 -0
M internal/domain/token.go +5 -1
M internal/domain/token_test.go +6 -1
M internal/ports/dto.go +69 -0
A internal/ports/mocks/InboxService.go +121 -0
A internal/ports/mocks/IssueBanStore.go +183 -0
A internal/ports/mocks/IssueKeyStore.go +109 -0
A internal/ports/mocks/IssueModerationService.go +280 -0
A internal/ports/mocks/IssueNumberResolver.go +126 -0
A internal/ports/mocks/IssueService.go +523 -0
A internal/ports/mocks/IssueStore.go +515 -0
A internal/ports/mocks/IssueSubmissionService.go +190 -0
M internal/ports/mocks/MergeRequestStore.go +62 -0
A internal/ports/mocks/PendingIssueStore.go +452 -0
A internal/ports/mocks/SubmissionLimiter.go +188 -0
M internal/ports/ports.go +81 -0
M internal/ports/services.go +94 -0
M internal/usecase/events.go +97 -3
A internal/usecase/events_owner_test.go +200 -0
M internal/usecase/events_test.go +43 -0
M internal/usecase/export_test.go +34 -0
A internal/usecase/inbox.go +164 -0
A internal/usecase/inbox_test.go +110 -0
A internal/usecase/issue_keys.go +62 -0
A internal/usecase/issue_moderation.go +119 -0
A internal/usecase/issue_moderation_test.go +251 -0
A internal/usecase/issue_pow_vector_test.go +39 -0
A internal/usecase/issue_submissions.go +383 -0
A internal/usecase/issue_submissions_test.go +670 -0
A internal/usecase/issues.go +291 -0
A internal/usecase/issues_integration_test.go +87 -0
A internal/usecase/issues_test.go +276 -0
M internal/usecase/mergerequests_integration_test.go +1 -1
M internal/usecase/repos_write.go +13 -3
M internal/usecase/repos_write_test.go +42 -0
A proto/ermite/storage/issue.proto +71 -0
M proto/ermite/storage/merge_request.proto +10 -0
M skills/ermite/CLI.md +15 -0

Comments0 open / 0

no comments yet.

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
l
merge request: the list of files (j k move, enter opens, esc back)
M
merge request: the merge menu; there 1–3 pick the mode, D deletes the branch, enter merges
R
merge request: mark the head read
esc
close a dialog, leave a field

Branches 2

Tags 45

↑↓ moveenter openesc close