detached ccabeff

feat(issues): expose resolution and history through RPC and CLI

commit
ccabeff0178ab8445c0f2be7dd0cf76b9aedf7ea
author
brice · · 2026-10-22 18:25 UTC
committer
brice · · 2026-10-22 18:41 UTC
parent
e7289b8
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
17 files · +477 -71
M docs/cli.md +6 -5
@@ -60,9 +60,10 @@ ermite mr resolve <repo> <n> <comment> | edit-comment <repo> <n> <comment> -m
6060 ermite mr ready <repo> <n> # removes WIP:/[WIP], needs mr:write
6161 ermite mr merge <repo> <n> --head <id> [--mode fast-forward|squash|merge] [--delete-branch] [--ignore-checks] | merge <repo> <n> --head <id> --auto [--mode …] | merge <repo> <n> --cancel-auto | close <repo> <n>
6262
63-ermite issue create <repo> --title … [--body … | --body-file …] | list <repo> [--state open|closed|all]
64-ermite issue show <repo> <n> | comment <repo> <n> -m … | close <repo> <n> | reopen <repo> <n>
65-# issue show prints `closed by !N` when a merge closed it, and a `merge requests (K)` block (`!5 open <title> · closes`); --json adds closed_by_merge_request and references
63+ermite issue create <repo> --title … [--body … | --body-file …] | list <repo> [--state open|closed|resolved|all]
64+ermite issue show <repo> <n> | comment <repo> <n> -m … | close <repo> <n> | resolve <repo> <n> | reopen <repo> <n>
65+# issue show prints `resolved by !N` when a merge resolved it, linked merge requests, and timestamped history interleaved with comments; --json adds closed_by_merge_request, references and events
66+# mr show lists linked issues (number, state, resolves marker) and their history; --json adds issues and issue_events
6667
6768 ermite run list <repo> [--workflow …] [--branch …] [--status …]
6869 ermite run view <repo> <id> [--attempt n] [--log [--job …]] [--summary]
@@ -92,7 +93,7 @@ ermite version # client and server versions
9293 ermite completion bash|zsh|fish
9394 ```
9495
95-Issues need a server with the `issues` feature and `ermite repo edit <repo> --issues owner` (or `--issues public`, which also opens a moderated form to visitors of a public repository); they are written with the `issue:write` scope. See [Issues](issues.md).
96+Issues need a server with the `issues` feature and `ermite repo edit <repo> --issues owner` (or `--issues public`, which also opens a moderated form to visitors of a public repository); they are written with the `issue:write` scope. An issue is `open`, `closed` (not resolved), or `resolved`. `issue resolve <repo> <n>` marks an open issue fixed (`issues.resolve` feature); merging a request with `Fixes #N` resolves it too. `issue reopen` accepts closed and resolved issues. History records references, closing, reopening and resolution with the actor and timestamp; removed references keep their history. See [Issues](issues.md).
9697
9798 Mirrors push repositories to a remote over SSH, never forcing. See [Push mirror](mirror.md).
9899
@@ -143,7 +144,7 @@ downloads are removed. Resume is within the command, not across invocations.
143144
144145 ## Output
145146
146-- `--json` prints stable field names (listed in each `--help`) on `repo list|show|create|edit`, `issue create|list|show|comment|close|reopen`, `token create|list`, `run list|view|cancel|rerun`, `runner list`, `mirror key|config|status` and `version`.
147+- `--json` prints stable field names (listed in each `--help`) on `repo list|show|create|edit`, `issue create|list|show|comment|close|resolve|reopen`, `token create|list`, `run list|view|cancel|rerun`, `runner list`, `mirror key|config|status` and `version`.
147148 - `watch --json` prints one object per line. `merge_request_mergeability` events carry `mergeability` (`state`, `behind`, `conflict_paths`…) and print as `repo!n mergeability: behind by 2`.
148149 - A merge needs passing checks on the head (no failed, queued or running checks); a head with no checks can merge. `mr merge --ignore-checks` bypasses this, needs `mr:force-merge`, and cannot be combined with `--auto` or `--cancel-auto`. The `cli` preset includes `mr:force-merge`; `agent` does not. Adding `--scope mr:merge` lets an agent merge only with passing checks.
149150 - `mr merge --mode squash|merge` creates a commit signed by the server's own key (generated on first start); the output names its fingerprint, `ermite signing-key show` prints the key. Every mode is allowed by default; `repo edit --merge-modes fast-forward,squash` disables `merge`.
@@ -60,9 +60,10 @@ ermite mr resolve <repo> <n> <comment> | edit-comment <repo> <n> <comment> -m
60 ermite mr ready <repo> <n> # removes WIP:/[WIP], needs mr:write60 ermite mr ready <repo> <n> # removes WIP:/[WIP], needs mr:write
61 ermite mr merge <repo> <n> --head <id> [--mode fast-forward|squash|merge] [--delete-branch] [--ignore-checks] | merge <repo> <n> --head <id> --auto [--mode …] | merge <repo> <n> --cancel-auto | close <repo> <n>61 ermite mr merge <repo> <n> --head <id> [--mode fast-forward|squash|merge] [--delete-branch] [--ignore-checks] | merge <repo> <n> --head <id> --auto [--mode …] | merge <repo> <n> --cancel-auto | close <repo> <n>
62 62
63-ermite issue create <repo> --title … [--body … | --body-file …] | list <repo> [--state open|closed|all]63+ermite issue create <repo> --title … [--body … | --body-file …] | list <repo> [--state open|closed|resolved|all]
64-ermite issue show <repo> <n> | comment <repo> <n> -m … | close <repo> <n> | reopen <repo> <n>64+ermite issue show <repo> <n> | comment <repo> <n> -m … | close <repo> <n> | resolve <repo> <n> | reopen <repo> <n>
65-# issue show prints `closed by !N` when a merge closed it, and a `merge requests (K)` block (`!5 open <title> · closes`); --json adds closed_by_merge_request and references65+# issue show prints `resolved by !N` when a merge resolved it, linked merge requests, and timestamped history interleaved with comments; --json adds closed_by_merge_request, references and events
66+# mr show lists linked issues (number, state, resolves marker) and their history; --json adds issues and issue_events
66 67
67 ermite run list <repo> [--workflow …] [--branch …] [--status …]68 ermite run list <repo> [--workflow …] [--branch …] [--status …]
68 ermite run view <repo> <id> [--attempt n] [--log [--job …]] [--summary]69 ermite run view <repo> <id> [--attempt n] [--log [--job …]] [--summary]
@@ -92,7 +93,7 @@ ermite version # client and server versions
92 ermite completion bash|zsh|fish93 ermite completion bash|zsh|fish
93 ```94 ```
94 95
95-Issues need a server with the `issues` feature and `ermite repo edit <repo> --issues owner` (or `--issues public`, which also opens a moderated form to visitors of a public repository); they are written with the `issue:write` scope. See [Issues](issues.md).96+Issues need a server with the `issues` feature and `ermite repo edit <repo> --issues owner` (or `--issues public`, which also opens a moderated form to visitors of a public repository); they are written with the `issue:write` scope. An issue is `open`, `closed` (not resolved), or `resolved`. `issue resolve <repo> <n>` marks an open issue fixed (`issues.resolve` feature); merging a request with `Fixes #N` resolves it too. `issue reopen` accepts closed and resolved issues. History records references, closing, reopening and resolution with the actor and timestamp; removed references keep their history. See [Issues](issues.md).
96 97
97 Mirrors push repositories to a remote over SSH, never forcing. See [Push mirror](mirror.md).98 Mirrors push repositories to a remote over SSH, never forcing. See [Push mirror](mirror.md).
98 99
@@ -143,7 +144,7 @@ downloads are removed. Resume is within the command, not across invocations.
143 144
144 ## Output145 ## Output
145 146
146-- `--json` prints stable field names (listed in each `--help`) on `repo list|show|create|edit`, `issue create|list|show|comment|close|reopen`, `token create|list`, `run list|view|cancel|rerun`, `runner list`, `mirror key|config|status` and `version`.147+- `--json` prints stable field names (listed in each `--help`) on `repo list|show|create|edit`, `issue create|list|show|comment|close|resolve|reopen`, `token create|list`, `run list|view|cancel|rerun`, `runner list`, `mirror key|config|status` and `version`.
147 - `watch --json` prints one object per line. `merge_request_mergeability` events carry `mergeability` (`state`, `behind`, `conflict_paths`…) and print as `repo!n mergeability: behind by 2`.148 - `watch --json` prints one object per line. `merge_request_mergeability` events carry `mergeability` (`state`, `behind`, `conflict_paths`…) and print as `repo!n mergeability: behind by 2`.
148 - A merge needs passing checks on the head (no failed, queued or running checks); a head with no checks can merge. `mr merge --ignore-checks` bypasses this, needs `mr:force-merge`, and cannot be combined with `--auto` or `--cancel-auto`. The `cli` preset includes `mr:force-merge`; `agent` does not. Adding `--scope mr:merge` lets an agent merge only with passing checks.149 - A merge needs passing checks on the head (no failed, queued or running checks); a head with no checks can merge. `mr merge --ignore-checks` bypasses this, needs `mr:force-merge`, and cannot be combined with `--auto` or `--cancel-auto`. The `cli` preset includes `mr:force-merge`; `agent` does not. Adding `--scope mr:merge` lets an agent merge only with passing checks.
149 - `mr merge --mode squash|merge` creates a commit signed by the server's own key (generated on first start); the output names its fingerprint, `ermite signing-key show` prints the key. Every mode is allowed by default; `repo edit --merge-modes fast-forward,squash` disables `merge`.150 - `mr merge --mode squash|merge` creates a commit signed by the server's own key (generated on first start); the output names its fingerprint, `ermite signing-key show` prints the key. Every mode is allowed by default; `repo edit --merge-modes fast-forward,squash` disables `merge`.
M internal/adapters/rpc/api.go +1 -1
@@ -35,7 +35,7 @@ const RunnerMaxRequestBytes = MaxRequestBytes + 64<<10
3535 // always implements, as GetVersion lists them. FeaturePasskeys comes on top
3636 // when Config.Passkeys is set, and FeatureCISecrets when the settings services
3737 // are, and FeatureCommitSignatures when Config.TrustedKeys is set.
38-var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "issues.links", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.cache", "ci.workflows", "merge_requests.auto_merge", "merge_requests.ignore_checks", "merge_requests.ready"}
38+var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "issues.links", "issues.resolve", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.cache", "ci.workflows", "merge_requests.auto_merge", "merge_requests.ignore_checks", "merge_requests.ready"}
3939
4040 // FeaturePasskeys is listed by GetVersion when passkeys are available.
4141 // FeatureCIArtifacts (artifact.go) when the artifact services are served.
@@ -35,7 +35,7 @@ const RunnerMaxRequestBytes = MaxRequestBytes + 64<<10
35 // always implements, as GetVersion lists them. FeaturePasskeys comes on top35 // always implements, as GetVersion lists them. FeaturePasskeys comes on top
36 // when Config.Passkeys is set, and FeatureCISecrets when the settings services36 // when Config.Passkeys is set, and FeatureCISecrets when the settings services
37 // are, and FeatureCommitSignatures when Config.TrustedKeys is set.37 // are, and FeatureCommitSignatures when Config.TrustedKeys is set.
38-var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "issues.links", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.cache", "ci.workflows", "merge_requests.auto_merge", "merge_requests.ignore_checks", "merge_requests.ready"}38+var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "issues.links", "issues.resolve", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.cache", "ci.workflows", "merge_requests.auto_merge", "merge_requests.ignore_checks", "merge_requests.ready"}
39 39
40 // FeaturePasskeys is listed by GetVersion when passkeys are available.40 // FeaturePasskeys is listed by GetVersion when passkeys are available.
41 // FeatureCIArtifacts (artifact.go) when the artifact services are served.41 // FeatureCIArtifacts (artifact.go) when the artifact services are served.
M internal/adapters/rpc/convert.go +3 -0
@@ -501,6 +501,9 @@ func watchEventToPB(we ports.WatchEvent) *pb.Event {
501501 case domain.IssueOpened:
502502 out.Time = timestamp(ev.At)
503503 out.Kind = &pb.Event_IssueOpened{IssueOpened: &pb.IssueOpened{Issue: issueToPB(ev.Issue)}}
504+ case domain.IssueResolved:
505+ out.Time = timestamp(ev.At)
506+ out.Kind = &pb.Event_IssueResolved{IssueResolved: &pb.IssueResolved{Issue: issueToPB(ev.Issue)}}
504507 case domain.IssueClosed:
505508 out.Time = timestamp(ev.At)
506509 out.Kind = &pb.Event_IssueClosed{IssueClosed: &pb.IssueClosed{Issue: issueToPB(ev.Issue)}}
@@ -501,6 +501,9 @@ func watchEventToPB(we ports.WatchEvent) *pb.Event {
501 case domain.IssueOpened:501 case domain.IssueOpened:
502 out.Time = timestamp(ev.At)502 out.Time = timestamp(ev.At)
503 out.Kind = &pb.Event_IssueOpened{IssueOpened: &pb.IssueOpened{Issue: issueToPB(ev.Issue)}}503 out.Kind = &pb.Event_IssueOpened{IssueOpened: &pb.IssueOpened{Issue: issueToPB(ev.Issue)}}
504+ case domain.IssueResolved:
505+ out.Time = timestamp(ev.At)
506+ out.Kind = &pb.Event_IssueResolved{IssueResolved: &pb.IssueResolved{Issue: issueToPB(ev.Issue)}}
504 case domain.IssueClosed:507 case domain.IssueClosed:
505 out.Time = timestamp(ev.At)508 out.Time = timestamp(ev.At)
506 out.Kind = &pb.Event_IssueClosed{IssueClosed: &pb.IssueClosed{Issue: issueToPB(ev.Issue)}}509 out.Kind = &pb.Event_IssueClosed{IssueClosed: &pb.IssueClosed{Issue: issueToPB(ev.Issue)}}
M internal/adapters/rpc/convert_test.go +31 -0
@@ -416,3 +416,34 @@ func TestFileDiffOmissionMapping(t *testing.T) {
416416 assert.True(t, diffToPB(domain.Diff{Truncated: true}).GetTruncated())
417417 assert.False(t, diffToPB(domain.Diff{Files: []domain.FileDiff{{}}}).GetTruncated())
418418 }
419+
420+func TestResolvedIssueContract(t *testing.T) {
421+ title, err := domain.ParseMRTitle("Fix")
422+ require.NoError(t, err)
423+ for _, state := range []domain.IssueState{domain.IssueStateOpen, domain.IssueStateClosed, domain.IssueStateResolved} {
424+ back, err := issueStateFromPB(issueStateToPB(state))
425+ require.NoError(t, err)
426+ assert.Equal(t, state, back)
427+ assert.NotEqual(t, pb.MergeRequestIssueState_MERGE_REQUEST_ISSUE_STATE_UNSPECIFIED, mergeRequestIssueToPB(ports.MergeRequestIssue{State: state}).GetState())
428+ }
429+ for kind, want := range map[domain.IssueEventKind]pb.IssueEventKind{
430+ domain.IssueEventReferenced: pb.IssueEventKind_ISSUE_EVENT_KIND_REFERENCED,
431+ domain.IssueEventClosed: pb.IssueEventKind_ISSUE_EVENT_KIND_CLOSED,
432+ domain.IssueEventReopened: pb.IssueEventKind_ISSUE_EVENT_KIND_REOPENED,
433+ domain.IssueEventResolved: pb.IssueEventKind_ISSUE_EVENT_KIND_RESOLVED,
434+ } {
435+ at := time.Now().UTC()
436+ got := issueEventToPB(ports.IssueEventDetail{Event: domain.IssueEvent{ID: 3, Kind: kind, Issue: 4, MergeRequest: 5, Closes: true, Actor: "merger", CreatedAt: at}, MergeRequestTitle: title})
437+ assert.Equal(t, want, got.GetKind())
438+ assert.Equal(t, "3", got.GetId())
439+ assert.EqualValues(t, 4, got.GetIssue())
440+ assert.EqualValues(t, 5, got.GetMergeRequest())
441+ assert.True(t, got.GetCloses())
442+ assert.Equal(t, "merger", got.GetActor())
443+ assert.Equal(t, "Fix", got.GetMergeRequestTitle())
444+ assert.Equal(t, at, got.GetCreatedAt().AsTime())
445+ }
446+ ev := watchEventToPB(ports.WatchEvent{Event: domain.IssueResolved{Issue: domain.Issue{Number: 4, State: domain.IssueStateResolved}}})
447+ require.NotNil(t, ev.GetIssueResolved())
448+ assert.Equal(t, pb.IssueState_ISSUE_STATE_RESOLVED, ev.GetIssueResolved().GetIssue().GetState())
449+}
@@ -416,3 +416,34 @@ func TestFileDiffOmissionMapping(t *testing.T) {
416 assert.True(t, diffToPB(domain.Diff{Truncated: true}).GetTruncated())416 assert.True(t, diffToPB(domain.Diff{Truncated: true}).GetTruncated())
417 assert.False(t, diffToPB(domain.Diff{Files: []domain.FileDiff{{}}}).GetTruncated())417 assert.False(t, diffToPB(domain.Diff{Files: []domain.FileDiff{{}}}).GetTruncated())
418 }418 }
419+
420+func TestResolvedIssueContract(t *testing.T) {
421+ title, err := domain.ParseMRTitle("Fix")
422+ require.NoError(t, err)
423+ for _, state := range []domain.IssueState{domain.IssueStateOpen, domain.IssueStateClosed, domain.IssueStateResolved} {
424+ back, err := issueStateFromPB(issueStateToPB(state))
425+ require.NoError(t, err)
426+ assert.Equal(t, state, back)
427+ assert.NotEqual(t, pb.MergeRequestIssueState_MERGE_REQUEST_ISSUE_STATE_UNSPECIFIED, mergeRequestIssueToPB(ports.MergeRequestIssue{State: state}).GetState())
428+ }
429+ for kind, want := range map[domain.IssueEventKind]pb.IssueEventKind{
430+ domain.IssueEventReferenced: pb.IssueEventKind_ISSUE_EVENT_KIND_REFERENCED,
431+ domain.IssueEventClosed: pb.IssueEventKind_ISSUE_EVENT_KIND_CLOSED,
432+ domain.IssueEventReopened: pb.IssueEventKind_ISSUE_EVENT_KIND_REOPENED,
433+ domain.IssueEventResolved: pb.IssueEventKind_ISSUE_EVENT_KIND_RESOLVED,
434+ } {
435+ at := time.Now().UTC()
436+ got := issueEventToPB(ports.IssueEventDetail{Event: domain.IssueEvent{ID: 3, Kind: kind, Issue: 4, MergeRequest: 5, Closes: true, Actor: "merger", CreatedAt: at}, MergeRequestTitle: title})
437+ assert.Equal(t, want, got.GetKind())
438+ assert.Equal(t, "3", got.GetId())
439+ assert.EqualValues(t, 4, got.GetIssue())
440+ assert.EqualValues(t, 5, got.GetMergeRequest())
441+ assert.True(t, got.GetCloses())
442+ assert.Equal(t, "merger", got.GetActor())
443+ assert.Equal(t, "Fix", got.GetMergeRequestTitle())
444+ assert.Equal(t, at, got.GetCreatedAt().AsTime())
445+ }
446+ ev := watchEventToPB(ports.WatchEvent{Event: domain.IssueResolved{Issue: domain.Issue{Number: 4, State: domain.IssueStateResolved}}})
447+ require.NotNil(t, ev.GetIssueResolved())
448+ assert.Equal(t, pb.IssueState_ISSUE_STATE_RESOLVED, ev.GetIssueResolved().GetIssue().GetState())
449+}
M internal/adapters/rpc/issue.go +46 -2
@@ -24,6 +24,8 @@ func issueStateToPB(s domain.IssueState) pb.IssueState {
2424 switch s {
2525 case domain.IssueStateOpen:
2626 return pb.IssueState_ISSUE_STATE_OPEN
27+ case domain.IssueStateResolved:
28+ return pb.IssueState_ISSUE_STATE_RESOLVED
2729 case domain.IssueStateClosed:
2830 return pb.IssueState_ISSUE_STATE_CLOSED
2931 }
@@ -38,6 +40,8 @@ func issueStateFromPB(s pb.IssueState) (domain.IssueState, error) {
3840 return "", nil
3941 case pb.IssueState_ISSUE_STATE_OPEN:
4042 return domain.IssueStateOpen, nil
43+ case pb.IssueState_ISSUE_STATE_RESOLVED:
44+ return domain.IssueStateResolved, nil
4145 case pb.IssueState_ISSUE_STATE_CLOSED:
4246 return domain.IssueStateClosed, nil
4347 }
@@ -139,6 +143,9 @@ func (s *issueService) GetIssue(ctx context.Context, req *connect.Request[pb.Get
139143 for _, c := range d.Comments {
140144 out.Comments = append(out.Comments, issueCommentToPB(c))
141145 }
146+ for _, e := range d.Events {
147+ out.Events = append(out.Events, issueEventToPB(e))
148+ }
142149 out.References = make([]*pb.IssueReference, 0, len(d.References))
143150 for _, r := range d.References {
144151 out.References = append(out.References, &pb.IssueReference{
@@ -271,7 +278,44 @@ func (s *issueService) RejectIssue(ctx context.Context, req *connect.Request[pb.
271278 return connect.NewResponse(&pb.RejectIssueResponse{}), nil
272279 }
273280
274-// ResolveIssue is reserved for Phase A; the RPC is not implemented yet.
281+// ResolveIssue marks an open issue as resolved.
275282 func (s *issueService) ResolveIssue(ctx context.Context, req *connect.Request[pb.ResolveIssueRequest]) (*connect.Response[pb.ResolveIssueResponse], error) {
276- return nil, connect.NewError(connect.CodeUnimplemented, fmt.Errorf("ResolveIssue is not implemented"))
283+ name, err := domain.ParseRepoName(req.Msg.GetRepo())
284+ if err != nil {
285+ return nil, connectError(ctx, err)
286+ }
287+ issue, err := s.issues.Resolve(ctx, caller(ctx), name, req.Msg.GetNumber())
288+ if err != nil {
289+ return nil, connectError(ctx, err)
290+ }
291+ return connect.NewResponse(&pb.ResolveIssueResponse{Issue: issueToPB(issue)}), nil
292+}
293+
294+func issueEventToPB(d ports.IssueEventDetail) *pb.IssueEvent {
295+ e := d.Event
296+ kind := pb.IssueEventKind_ISSUE_EVENT_KIND_UNSPECIFIED
297+ switch e.Kind {
298+ case domain.IssueEventReferenced:
299+ kind = pb.IssueEventKind_ISSUE_EVENT_KIND_REFERENCED
300+ case domain.IssueEventClosed:
301+ kind = pb.IssueEventKind_ISSUE_EVENT_KIND_CLOSED
302+ case domain.IssueEventReopened:
303+ kind = pb.IssueEventKind_ISSUE_EVENT_KIND_REOPENED
304+ case domain.IssueEventResolved:
305+ kind = pb.IssueEventKind_ISSUE_EVENT_KIND_RESOLVED
306+ }
307+ return &pb.IssueEvent{Id: strconv.FormatInt(e.ID, 10), Kind: kind, Issue: e.Issue, MergeRequest: e.MergeRequest, MergeRequestTitle: d.MergeRequestTitle.String(), Closes: e.Closes, Actor: e.Actor, CreatedAt: timestamp(e.CreatedAt)}
308+}
309+
310+func mergeRequestIssueToPB(i ports.MergeRequestIssue) *pb.MergeRequestIssue {
311+ state := pb.MergeRequestIssueState_MERGE_REQUEST_ISSUE_STATE_UNSPECIFIED
312+ switch i.State {
313+ case domain.IssueStateOpen:
314+ state = pb.MergeRequestIssueState_MERGE_REQUEST_ISSUE_STATE_OPEN
315+ case domain.IssueStateClosed:
316+ state = pb.MergeRequestIssueState_MERGE_REQUEST_ISSUE_STATE_CLOSED
317+ case domain.IssueStateResolved:
318+ state = pb.MergeRequestIssueState_MERGE_REQUEST_ISSUE_STATE_RESOLVED
319+ }
320+ return &pb.MergeRequestIssue{Number: i.Number, Title: i.Title.String(), State: state, Closes: i.Closes}
277321 }
@@ -24,6 +24,8 @@ func issueStateToPB(s domain.IssueState) pb.IssueState {
24 switch s {24 switch s {
25 case domain.IssueStateOpen:25 case domain.IssueStateOpen:
26 return pb.IssueState_ISSUE_STATE_OPEN26 return pb.IssueState_ISSUE_STATE_OPEN
27+ case domain.IssueStateResolved:
28+ return pb.IssueState_ISSUE_STATE_RESOLVED
27 case domain.IssueStateClosed:29 case domain.IssueStateClosed:
28 return pb.IssueState_ISSUE_STATE_CLOSED30 return pb.IssueState_ISSUE_STATE_CLOSED
29 }31 }
@@ -38,6 +40,8 @@ func issueStateFromPB(s pb.IssueState) (domain.IssueState, error) {
38 return "", nil40 return "", nil
39 case pb.IssueState_ISSUE_STATE_OPEN:41 case pb.IssueState_ISSUE_STATE_OPEN:
40 return domain.IssueStateOpen, nil42 return domain.IssueStateOpen, nil
43+ case pb.IssueState_ISSUE_STATE_RESOLVED:
44+ return domain.IssueStateResolved, nil
41 case pb.IssueState_ISSUE_STATE_CLOSED:45 case pb.IssueState_ISSUE_STATE_CLOSED:
42 return domain.IssueStateClosed, nil46 return domain.IssueStateClosed, nil
43 }47 }
@@ -139,6 +143,9 @@ func (s *issueService) GetIssue(ctx context.Context, req *connect.Request[pb.Get
139 for _, c := range d.Comments {143 for _, c := range d.Comments {
140 out.Comments = append(out.Comments, issueCommentToPB(c))144 out.Comments = append(out.Comments, issueCommentToPB(c))
141 }145 }
146+ for _, e := range d.Events {
147+ out.Events = append(out.Events, issueEventToPB(e))
148+ }
142 out.References = make([]*pb.IssueReference, 0, len(d.References))149 out.References = make([]*pb.IssueReference, 0, len(d.References))
143 for _, r := range d.References {150 for _, r := range d.References {
144 out.References = append(out.References, &pb.IssueReference{151 out.References = append(out.References, &pb.IssueReference{
@@ -271,7 +278,44 @@ func (s *issueService) RejectIssue(ctx context.Context, req *connect.Request[pb.
271 return connect.NewResponse(&pb.RejectIssueResponse{}), nil278 return connect.NewResponse(&pb.RejectIssueResponse{}), nil
272 }279 }
273 280
274-// ResolveIssue is reserved for Phase A; the RPC is not implemented yet.281+// ResolveIssue marks an open issue as resolved.
275 func (s *issueService) ResolveIssue(ctx context.Context, req *connect.Request[pb.ResolveIssueRequest]) (*connect.Response[pb.ResolveIssueResponse], error) {282 func (s *issueService) ResolveIssue(ctx context.Context, req *connect.Request[pb.ResolveIssueRequest]) (*connect.Response[pb.ResolveIssueResponse], error) {
276- return nil, connect.NewError(connect.CodeUnimplemented, fmt.Errorf("ResolveIssue is not implemented"))283+ name, err := domain.ParseRepoName(req.Msg.GetRepo())
284+ if err != nil {
285+ return nil, connectError(ctx, err)
286+ }
287+ issue, err := s.issues.Resolve(ctx, caller(ctx), name, req.Msg.GetNumber())
288+ if err != nil {
289+ return nil, connectError(ctx, err)
290+ }
291+ return connect.NewResponse(&pb.ResolveIssueResponse{Issue: issueToPB(issue)}), nil
292+}
293+
294+func issueEventToPB(d ports.IssueEventDetail) *pb.IssueEvent {
295+ e := d.Event
296+ kind := pb.IssueEventKind_ISSUE_EVENT_KIND_UNSPECIFIED
297+ switch e.Kind {
298+ case domain.IssueEventReferenced:
299+ kind = pb.IssueEventKind_ISSUE_EVENT_KIND_REFERENCED
300+ case domain.IssueEventClosed:
301+ kind = pb.IssueEventKind_ISSUE_EVENT_KIND_CLOSED
302+ case domain.IssueEventReopened:
303+ kind = pb.IssueEventKind_ISSUE_EVENT_KIND_REOPENED
304+ case domain.IssueEventResolved:
305+ kind = pb.IssueEventKind_ISSUE_EVENT_KIND_RESOLVED
306+ }
307+ return &pb.IssueEvent{Id: strconv.FormatInt(e.ID, 10), Kind: kind, Issue: e.Issue, MergeRequest: e.MergeRequest, MergeRequestTitle: d.MergeRequestTitle.String(), Closes: e.Closes, Actor: e.Actor, CreatedAt: timestamp(e.CreatedAt)}
308+}
309+
310+func mergeRequestIssueToPB(i ports.MergeRequestIssue) *pb.MergeRequestIssue {
311+ state := pb.MergeRequestIssueState_MERGE_REQUEST_ISSUE_STATE_UNSPECIFIED
312+ switch i.State {
313+ case domain.IssueStateOpen:
314+ state = pb.MergeRequestIssueState_MERGE_REQUEST_ISSUE_STATE_OPEN
315+ case domain.IssueStateClosed:
316+ state = pb.MergeRequestIssueState_MERGE_REQUEST_ISSUE_STATE_CLOSED
317+ case domain.IssueStateResolved:
318+ state = pb.MergeRequestIssueState_MERGE_REQUEST_ISSUE_STATE_RESOLVED
319+ }
320+ return &pb.MergeRequestIssue{Number: i.Number, Title: i.Title.String(), State: state, Closes: i.Closes}
277 }321 }
M internal/adapters/rpc/mergerequest.go +6 -0
@@ -318,6 +318,12 @@ func (s *mergeRequestService) GetMergeRequest(ctx context.Context, req *connect.
318318 LastReadId: d.LastReadID.String(),
319319 LastReadLost: d.LastReadLost,
320320 }
321+ for _, i := range d.Issues {
322+ out.Issues = append(out.Issues, mergeRequestIssueToPB(i))
323+ }
324+ for _, e := range d.IssueEvents {
325+ out.IssueEvents = append(out.IssueEvents, issueEventToPB(e))
326+ }
321327 for _, c := range d.Commits {
322328 out.Commits = append(out.Commits, commitToPB(c))
323329 }
@@ -318,6 +318,12 @@ func (s *mergeRequestService) GetMergeRequest(ctx context.Context, req *connect.
318 LastReadId: d.LastReadID.String(),318 LastReadId: d.LastReadID.String(),
319 LastReadLost: d.LastReadLost,319 LastReadLost: d.LastReadLost,
320 }320 }
321+ for _, i := range d.Issues {
322+ out.Issues = append(out.Issues, mergeRequestIssueToPB(i))
323+ }
324+ for _, e := range d.IssueEvents {
325+ out.IssueEvents = append(out.IssueEvents, issueEventToPB(e))
326+ }
321 for _, c := range d.Commits {327 for _, c := range d.Commits {
322 out.Commits = append(out.Commits, commitToPB(c))328 out.Commits = append(out.Commits, commitToPB(c))
323 }329 }
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", "issues", "issues.links", "merge_requests.mergeability", "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", "mirror", "merge_requests.auto_merge", "merge_requests.ignore_checks", "merge_requests.ready", "releases"}, resp.Msg.GetFeatures(), who)
199+ assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "issues.links", "issues.resolve", "merge_requests.mergeability", "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", "mirror", "merge_requests.auto_merge", "merge_requests.ignore_checks", "merge_requests.ready", "releases"}, 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", "issues", "issues.links", "merge_requests.mergeability", "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", "mirror", "merge_requests.auto_merge", "merge_requests.ignore_checks", "merge_requests.ready", "releases"}, resp.Msg.GetFeatures(), who)199+ assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "issues.links", "issues.resolve", "merge_requests.mergeability", "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", "mirror", "merge_requests.auto_merge", "merge_requests.ignore_checks", "merge_requests.ready", "releases"}, 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 +39 -1
@@ -350,7 +350,7 @@ func TestAC6CLIEndToEnd(t *testing.T) {
350350 ver := decode[versionOut](t, res.stdout)
351351 assert.Equal(t, c.url, ver.Server.URL)
352352 assert.NotEmpty(t, ver.Server.Version)
353- assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "issues.links", "merge_requests.mergeability", "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", "mirror", "merge_requests.auto_merge", "merge_requests.ignore_checks", "merge_requests.ready", "releases"}, ver.Server.Features)
353+ assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "issues.links", "issues.resolve", "merge_requests.mergeability", "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", "mirror", "merge_requests.auto_merge", "merge_requests.ignore_checks", "merge_requests.ready", "releases"}, ver.Server.Features)
354354 assert.Equal(t, []string{"passkeys"}, ver.Server.MissingFeatures)
355355 stdout, stderr, code = c.bin(t, secret, "version")
356356 require.Equal(t, 0, code, stderr)
@@ -529,3 +529,41 @@ func TestAC6CLIWatchOneRepo(t *testing.T) {
529529 assert.Equal(t, "a", ev.Repo)
530530 assert.Equal(t, 0, w.interrupt(t))
531531 }
532+
533+func TestIssueResolvedCLIEndToEnd(t *testing.T) {
534+ r := newMRRig(t, domain.VisibilityPublic)
535+ env := envFor(r.srv.URL)
536+ env[client.EnvToken] = adminToken
537+ cli := func(args ...string) result { return runCLI(t, env, "", args...).ok(t) }
538+ cli("repo", "edit", "proj", "--issues", "owner")
539+ cli("issue", "create", "proj", "--title", "Crash")
540+ cli("mr", "create", "proj", "--source", "feat", "--title", "Fix crash", "--description", "Fixes #1")
541+ before := cli("mr", "show", "proj", "2")
542+ assert.Contains(t, before.stdout, "#1 open Crash · resolves")
543+ cli("mr", "merge", "proj", "2", "--head", r.head)
544+ r.forge.MergeRequests.Wait(r.ctx)
545+ shown := cli("issue", "show", "proj", "1")
546+ assert.Regexp(t, `state\s+resolved`, shown.stdout)
547+ assert.Regexp(t, `resolved by\s+!2`, shown.stdout)
548+ assert.Contains(t, shown.stdout, "root referenced this in !2 (will resolve)")
549+ assert.Contains(t, shown.stdout, "root resolved this by merging !2")
550+ detail := decode[jsonIssueDetail](t, cli("issue", "show", "proj", "1", "--json").stdout)
551+ assert.Equal(t, "resolved", detail.State)
552+ assert.EqualValues(t, 2, detail.ClosedByMergeRequest)
553+ require.Len(t, detail.Events, 2)
554+ assert.Equal(t, "resolved", detail.Events[1].Kind)
555+ mr := cli("mr", "show", "proj", "2")
556+ assert.Contains(t, mr.stdout, "#1 resolved Crash · resolves")
557+ assert.Contains(t, mr.stdout, "root resolved #1 by merging !2")
558+ md := decode[jsonMRDetail](t, cli("mr", "show", "proj", "2", "--json").stdout)
559+ require.Len(t, md.Issues, 1)
560+ require.Len(t, md.IssueEvents, 2)
561+ cli("issue", "reopen", "proj", "1")
562+ cli("issue", "resolve", "proj", "1")
563+ cli("issue", "list", "proj", "--state", "resolved")
564+ cli("issue", "reopen", "proj", "1")
565+ detail = decode[jsonIssueDetail](t, cli("issue", "show", "proj", "1", "--json").stdout)
566+ assert.Equal(t, "open", detail.State)
567+ assert.Zero(t, detail.ClosedByMergeRequest)
568+ require.Len(t, detail.Events, 5)
569+}
@@ -350,7 +350,7 @@ func TestAC6CLIEndToEnd(t *testing.T) {
350 ver := decode[versionOut](t, res.stdout)350 ver := decode[versionOut](t, res.stdout)
351 assert.Equal(t, c.url, ver.Server.URL)351 assert.Equal(t, c.url, ver.Server.URL)
352 assert.NotEmpty(t, ver.Server.Version)352 assert.NotEmpty(t, ver.Server.Version)
353- assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "issues.links", "merge_requests.mergeability", "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", "mirror", "merge_requests.auto_merge", "merge_requests.ignore_checks", "merge_requests.ready", "releases"}, ver.Server.Features)353+ assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "issues.links", "issues.resolve", "merge_requests.mergeability", "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", "mirror", "merge_requests.auto_merge", "merge_requests.ignore_checks", "merge_requests.ready", "releases"}, ver.Server.Features)
354 assert.Equal(t, []string{"passkeys"}, ver.Server.MissingFeatures)354 assert.Equal(t, []string{"passkeys"}, ver.Server.MissingFeatures)
355 stdout, stderr, code = c.bin(t, secret, "version")355 stdout, stderr, code = c.bin(t, secret, "version")
356 require.Equal(t, 0, code, stderr)356 require.Equal(t, 0, code, stderr)
@@ -529,3 +529,41 @@ func TestAC6CLIWatchOneRepo(t *testing.T) {
529 assert.Equal(t, "a", ev.Repo)529 assert.Equal(t, "a", ev.Repo)
530 assert.Equal(t, 0, w.interrupt(t))530 assert.Equal(t, 0, w.interrupt(t))
531 }531 }
532+
533+func TestIssueResolvedCLIEndToEnd(t *testing.T) {
534+ r := newMRRig(t, domain.VisibilityPublic)
535+ env := envFor(r.srv.URL)
536+ env[client.EnvToken] = adminToken
537+ cli := func(args ...string) result { return runCLI(t, env, "", args...).ok(t) }
538+ cli("repo", "edit", "proj", "--issues", "owner")
539+ cli("issue", "create", "proj", "--title", "Crash")
540+ cli("mr", "create", "proj", "--source", "feat", "--title", "Fix crash", "--description", "Fixes #1")
541+ before := cli("mr", "show", "proj", "2")
542+ assert.Contains(t, before.stdout, "#1 open Crash · resolves")
543+ cli("mr", "merge", "proj", "2", "--head", r.head)
544+ r.forge.MergeRequests.Wait(r.ctx)
545+ shown := cli("issue", "show", "proj", "1")
546+ assert.Regexp(t, `state\s+resolved`, shown.stdout)
547+ assert.Regexp(t, `resolved by\s+!2`, shown.stdout)
548+ assert.Contains(t, shown.stdout, "root referenced this in !2 (will resolve)")
549+ assert.Contains(t, shown.stdout, "root resolved this by merging !2")
550+ detail := decode[jsonIssueDetail](t, cli("issue", "show", "proj", "1", "--json").stdout)
551+ assert.Equal(t, "resolved", detail.State)
552+ assert.EqualValues(t, 2, detail.ClosedByMergeRequest)
553+ require.Len(t, detail.Events, 2)
554+ assert.Equal(t, "resolved", detail.Events[1].Kind)
555+ mr := cli("mr", "show", "proj", "2")
556+ assert.Contains(t, mr.stdout, "#1 resolved Crash · resolves")
557+ assert.Contains(t, mr.stdout, "root resolved #1 by merging !2")
558+ md := decode[jsonMRDetail](t, cli("mr", "show", "proj", "2", "--json").stdout)
559+ require.Len(t, md.Issues, 1)
560+ require.Len(t, md.IssueEvents, 2)
561+ cli("issue", "reopen", "proj", "1")
562+ cli("issue", "resolve", "proj", "1")
563+ cli("issue", "list", "proj", "--state", "resolved")
564+ cli("issue", "reopen", "proj", "1")
565+ detail = decode[jsonIssueDetail](t, cli("issue", "show", "proj", "1", "--json").stdout)
566+ assert.Equal(t, "open", detail.State)
567+ assert.Zero(t, detail.ClosedByMergeRequest)
568+ require.Len(t, detail.Events, 5)
569+}
M internal/app/cli_fake_test.go +4 -0
@@ -68,6 +68,7 @@ type fakeAPI struct {
6868 listIssues func(*ermite.ListIssuesRequest) (*ermite.ListIssuesResponse, error)
6969 getIssue func(*ermite.GetIssueRequest) (*ermite.GetIssueResponse, error)
7070 addIssueCmt func(*ermite.AddIssueCommentRequest) (*ermite.AddIssueCommentResponse, error)
71+ resolveIssue func(*ermite.ResolveIssueRequest) (*ermite.ResolveIssueResponse, error)
7172 closeIssue func(*ermite.CloseIssueRequest) (*ermite.CloseIssueResponse, error)
7273 reopenIssue func(*ermite.ReopenIssueRequest) (*ermite.ReopenIssueResponse, error)
7374 getSigningKey func(*ermite.GetSigningKeyRequest) (*ermite.GetSigningKeyResponse, error)
@@ -178,6 +179,9 @@ func (f *fakeAPI) AddIssueComment(_ context.Context, r *connect.Request[ermite.A
178179 func (f *fakeAPI) CloseIssue(_ context.Context, r *connect.Request[ermite.CloseIssueRequest]) (*connect.Response[ermite.CloseIssueResponse], error) {
179180 return respond(f, "CloseIssue", r, f.closeIssue)
180181 }
182+func (f *fakeAPI) ResolveIssue(_ context.Context, r *connect.Request[ermite.ResolveIssueRequest]) (*connect.Response[ermite.ResolveIssueResponse], error) {
183+ return respond(f, "ResolveIssue", r, f.resolveIssue)
184+}
181185 func (f *fakeAPI) ReopenIssue(_ context.Context, r *connect.Request[ermite.ReopenIssueRequest]) (*connect.Response[ermite.ReopenIssueResponse], error) {
182186 return respond(f, "ReopenIssue", r, f.reopenIssue)
183187 }
@@ -68,6 +68,7 @@ type fakeAPI struct {
68 listIssues func(*ermite.ListIssuesRequest) (*ermite.ListIssuesResponse, error)68 listIssues func(*ermite.ListIssuesRequest) (*ermite.ListIssuesResponse, error)
69 getIssue func(*ermite.GetIssueRequest) (*ermite.GetIssueResponse, error)69 getIssue func(*ermite.GetIssueRequest) (*ermite.GetIssueResponse, error)
70 addIssueCmt func(*ermite.AddIssueCommentRequest) (*ermite.AddIssueCommentResponse, error)70 addIssueCmt func(*ermite.AddIssueCommentRequest) (*ermite.AddIssueCommentResponse, error)
71+ resolveIssue func(*ermite.ResolveIssueRequest) (*ermite.ResolveIssueResponse, error)
71 closeIssue func(*ermite.CloseIssueRequest) (*ermite.CloseIssueResponse, error)72 closeIssue func(*ermite.CloseIssueRequest) (*ermite.CloseIssueResponse, error)
72 reopenIssue func(*ermite.ReopenIssueRequest) (*ermite.ReopenIssueResponse, error)73 reopenIssue func(*ermite.ReopenIssueRequest) (*ermite.ReopenIssueResponse, error)
73 getSigningKey func(*ermite.GetSigningKeyRequest) (*ermite.GetSigningKeyResponse, error)74 getSigningKey func(*ermite.GetSigningKeyRequest) (*ermite.GetSigningKeyResponse, error)
@@ -178,6 +179,9 @@ func (f *fakeAPI) AddIssueComment(_ context.Context, r *connect.Request[ermite.A
178 func (f *fakeAPI) CloseIssue(_ context.Context, r *connect.Request[ermite.CloseIssueRequest]) (*connect.Response[ermite.CloseIssueResponse], error) {179 func (f *fakeAPI) CloseIssue(_ context.Context, r *connect.Request[ermite.CloseIssueRequest]) (*connect.Response[ermite.CloseIssueResponse], error) {
179 return respond(f, "CloseIssue", r, f.closeIssue)180 return respond(f, "CloseIssue", r, f.closeIssue)
180 }181 }
182+func (f *fakeAPI) ResolveIssue(_ context.Context, r *connect.Request[ermite.ResolveIssueRequest]) (*connect.Response[ermite.ResolveIssueResponse], error) {
183+ return respond(f, "ResolveIssue", r, f.resolveIssue)
184+}
181 func (f *fakeAPI) ReopenIssue(_ context.Context, r *connect.Request[ermite.ReopenIssueRequest]) (*connect.Response[ermite.ReopenIssueResponse], error) {185 func (f *fakeAPI) ReopenIssue(_ context.Context, r *connect.Request[ermite.ReopenIssueRequest]) (*connect.Response[ermite.ReopenIssueResponse], error) {
182 return respond(f, "ReopenIssue", r, f.reopenIssue)186 return respond(f, "ReopenIssue", r, f.reopenIssue)
183 }187 }
M internal/app/cli_issue.go +43 -19
@@ -20,11 +20,14 @@ import (
2020 )
2121
2222 const statePending = "pending"
23+const stateResolved = "resolved"
2324
2425 func issueStateName(s ermite.IssueState) string {
2526 switch s {
2627 case ermite.IssueState_ISSUE_STATE_OPEN:
2728 return stateOpen
29+ case ermite.IssueState_ISSUE_STATE_RESOLVED:
30+ return stateResolved
2831 case ermite.IssueState_ISSUE_STATE_CLOSED:
2932 return stateClosed
3033 case ermite.IssueState_ISSUE_STATE_PENDING:
@@ -38,12 +41,14 @@ func parseIssueState(s string) (ermite.IssueState, error) {
3841 switch strings.ToLower(strings.TrimSpace(s)) {
3942 case stateOpen:
4043 return ermite.IssueState_ISSUE_STATE_OPEN, nil
44+ case stateResolved:
45+ return ermite.IssueState_ISSUE_STATE_RESOLVED, nil
4146 case stateClosed:
4247 return ermite.IssueState_ISSUE_STATE_CLOSED, nil
4348 case stateAll:
4449 return ermite.IssueState_ISSUE_STATE_UNSPECIFIED, nil
4550 }
46- return 0, fmt.Errorf("invalid state %q: use %s, %s or %s", s, stateOpen, stateClosed, stateAll)
51+ return 0, fmt.Errorf("invalid state %q: use %s, %s, %s or %s", s, stateOpen, stateClosed, stateResolved, stateAll)
4752 }
4853
4954 // jsonIssue is the stable JSON shape of an issue.
@@ -57,7 +62,7 @@ type jsonIssue struct {
5762 CreatedAt *string `json:"created_at"`
5863 UpdatedAt *string `json:"updated_at"`
5964 CommentCount int32 `json:"comment_count"`
60- // ClosedByMergeRequest is the merge request whose merge closed the issue; 0 for none.
65+ // ClosedByMergeRequest is the merge request whose merge resolved the issue; 0 for none.
6166 ClosedByMergeRequest int64 `json:"closed_by_merge_request"`
6267 }
6368
@@ -87,6 +92,7 @@ type jsonIssueDetail struct {
8792 jsonIssue
8893 Comments []jsonIssueComment `json:"comments"`
8994 References []jsonIssueReference `json:"references"`
95+ Events []jsonIssueEvent `json:"events"`
9096 }
9197
9298 // jsonIssueReference is a merge request that names the issue.
@@ -98,16 +104,16 @@ type jsonIssueReference struct {
98104 }
99105
100106 const jsonIssueHelp = `
101-JSON fields of an issue (--json): repo, number, title, body, state ("open" or "closed"), author
107+JSON fields of an issue (--json): repo, number, title, body, state ("open", "closed" or "resolved"), author
102108 (token label), created_at and updated_at (RFC 3339 UTC), comment_count, closed_by_merge_request (number of
103-the merge request whose merge closed the issue, 0 for none).`
109+the merge request whose merge resolved the issue, 0 for none).`
104110
105111 const jsonIssueCommentHelp = `
106112 JSON fields of an issue comment: id, author, text, created_at.`
107113
108114 const jsonIssueReferenceHelp = `
109115 JSON fields of a merge request reference: number, title, state ("open", "merged" or "closed"), closes
110-(true when merging it closes the issue).`
116+(true when merging it resolves the issue).`
111117
112118 func newIssueCmd(env *cliEnv) *cobra.Command {
113119 issue := &cobra.Command{
@@ -120,7 +126,7 @@ func newIssueCmd(env *cliEnv) *cobra.Command {
120126 }
121127 issue.AddCommand(
122128 newIssueCreateCmd(env), newIssueListCmd(env), newIssueShowCmd(env), newIssueCommentCmd(env),
123- newIssueCloseCmd(env), newIssueReopenCmd(env),
129+ newIssueCloseCmd(env), newIssueResolveCmd(env), newIssueReopenCmd(env),
124130 )
125131 return issue
126132 }
@@ -265,14 +271,14 @@ func newIssueListCmd(env *cliEnv) *cobra.Command {
265271 cmd := &cobra.Command{
266272 Use: "list <repo>",
267273 Short: "List the issues of a repository",
268- Long: "List the issues of a repository, newest first. --state restricts them to open or closed ones\n" +
274+ Long: "List the issues of a repository, newest first. --state restricts them to open, closed or resolved ones\n" +
269275 "(default: open; all shows every state)." + jsonIssueHelp + "\n--json prints an array of issues.",
270276 Args: cobra.ExactArgs(1),
271277 ValidArgsFunction: completeRepoNames(env),
272278 }
273- cmd.Flags().StringVar(&state, "state", stateOpen, "state: open, closed or all")
279+ cmd.Flags().StringVar(&state, "state", stateOpen, "state: open, closed, resolved or all")
274280 _ = cmd.RegisterFlagCompletionFunc("state", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
275- return []string{stateOpen, stateClosed, stateAll}, cobra.ShellCompDirectiveNoFileComp
281+ return []string{stateOpen, stateClosed, stateResolved, stateAll}, cobra.ShellCompDirectiveNoFileComp
276282 })
277283 asJSON := addJSONFlag(cmd)
278284 cmd.RunE = func(cmd *cobra.Command, args []string) error {
@@ -316,10 +322,10 @@ func newIssueShowCmd(env *cliEnv) *cobra.Command {
316322 Use: "show <repo> <number>",
317323 Short: "Show an issue with its comments",
318324 Long: "Show an issue: its fields, its body, the merge requests that name it (#N in their title,\n" +
319- "description, notes or commits; \"closes\" when a closing keyword such as \"Fixes #N\" names it) and\n" +
320- "its comments, oldest first. \"closed by\" shows the merge request whose merge closed the issue." +
321- jsonIssueHelp + jsonIssueCommentHelp + jsonIssueReferenceHelp +
322- "\n--json adds comments and references (always present, possibly empty) to the issue fields.",
325+ "description, notes or commits; a closing keyword such as \"Fixes #N\" resolves it on merge) and\n" +
326+ "its comments and history, oldest first. \"resolved by\" shows the merge request whose merge resolved the issue." +
327+ jsonIssueHelp + jsonIssueCommentHelp + jsonIssueReferenceHelp + jsonIssueEventHelp +
328+ "\n--json adds comments, references and events (always present, possibly empty) to the issue fields.",
323329 Args: cobra.ExactArgs(2),
324330 ValidArgsFunction: completeIssueRepoThenNumber(env),
325331 }
@@ -338,7 +344,7 @@ func newIssueShowCmd(env *cliEnv) *cobra.Command {
338344 return c.Describe(err)
339345 }
340346 if *asJSON {
341- out := jsonIssueDetail{jsonIssue: toJSONIssue(res.Msg.GetIssue()), Comments: []jsonIssueComment{}}
347+ out := jsonIssueDetail{jsonIssue: toJSONIssue(res.Msg.GetIssue()), Comments: []jsonIssueComment{}, Events: toJSONIssueEvents(res.Msg.GetEvents())}
342348 for _, cm := range res.Msg.GetComments() {
343349 out.Comments = append(out.Comments, toJSONIssueComment(cm))
344350 }
@@ -367,7 +373,7 @@ func printIssueDetail(w io.Writer, d *ermite.GetIssueResponse) error {
367373 {"state", issueStateName(i.GetState())},
368374 }
369375 if n := i.GetClosedByMergeRequest(); n > 0 {
370- rows = append(rows, [2]string{"closed by", fmt.Sprintf("!%d", n)})
376+ rows = append(rows, [2]string{"resolved by", fmt.Sprintf("!%d", n)})
371377 }
372378 rows = append(rows,
373379 [2]string{"author", i.GetAuthor()},
@@ -388,7 +394,7 @@ func printIssueDetail(w io.Writer, d *ermite.GetIssueResponse) error {
388394 for _, r := range refs {
389395 line := fmt.Sprintf(" !%d %-6s %s", r.GetNumber(), stateName(r.GetState()), clean(r.GetTitle()))
390396 if r.GetCloses() {
391- line += " · closes"
397+ line += " · resolves"
392398 }
393399 if _, err := fmt.Fprintln(w, line); err != nil {
394400 return err
@@ -398,10 +404,17 @@ func printIssueDetail(w io.Writer, d *ermite.GetIssueResponse) error {
398404 if _, err := fmt.Fprintf(w, "\ncomments (%d)\n", len(d.GetComments())); err != nil {
399405 return err
400406 }
407+ var timeline []issueTimelineRow
401408 for _, c := range d.GetComments() {
402- if _, err := fmt.Fprintf(w, " #%s %s, %s\n%s\n", c.GetId(), clean(c.GetAuthor()), shortTime(c.GetCreatedAt(), "-"), indentText(c.GetText())); err != nil {
409+ c := c
410+ timeline = append(timeline, issueTimelineRow{at: c.GetCreatedAt(), print: func(w io.Writer) error {
411+ _, err := fmt.Fprintf(w, " #%s %s, %s\n%s\n", c.GetId(), clean(c.GetAuthor()), shortTime(c.GetCreatedAt(), "-"), indentText(c.GetText()))
403412 return err
404- }
413+ }})
414+ }
415+ timeline = append(timeline, newIssueTimeline(d.GetEvents(), false)...)
416+ if err := printIssueTimeline(w, timeline); err != nil {
417+ return err
405418 }
406419 return nil
407420 }
@@ -462,8 +475,19 @@ func newIssueCloseCmd(env *cliEnv) *cobra.Command {
462475 })
463476 }
464477
478+func newIssueResolveCmd(env *cliEnv) *cobra.Command {
479+ return newIssueStateCmd(env, "resolve", "resolved", "Resolve an issue", "Mark an open issue as resolved (fixed). `ermite issue reopen` opens it again. Needs the issue:write scope.",
480+ func(ctx context.Context, c *client.Client, repo string, n int64) (*ermite.Issue, error) {
481+ res, err := c.Issues().ResolveIssue(ctx, connect.NewRequest(&ermite.ResolveIssueRequest{Repo: repo, Number: n}))
482+ if err != nil {
483+ return nil, err
484+ }
485+ return res.Msg.GetIssue(), nil
486+ })
487+}
488+
465489 func newIssueReopenCmd(env *cliEnv) *cobra.Command {
466- return newIssueStateCmd(env, "reopen", "reopened", "Reopen a closed issue", "Reopen a closed issue. Needs the issue:write scope.",
490+ return newIssueStateCmd(env, "reopen", "reopened", "Reopen a closed or resolved issue", "Reopen a closed or resolved issue. Needs the issue:write scope.",
467491 func(ctx context.Context, c *client.Client, repo string, n int64) (*ermite.Issue, error) {
468492 res, err := c.Issues().ReopenIssue(ctx, connect.NewRequest(&ermite.ReopenIssueRequest{Repo: repo, Number: n}))
469493 if err != nil {
@@ -20,11 +20,14 @@ import (
20 )20 )
21 21
22 const statePending = "pending"22 const statePending = "pending"
23+const stateResolved = "resolved"
23 24
24 func issueStateName(s ermite.IssueState) string {25 func issueStateName(s ermite.IssueState) string {
25 switch s {26 switch s {
26 case ermite.IssueState_ISSUE_STATE_OPEN:27 case ermite.IssueState_ISSUE_STATE_OPEN:
27 return stateOpen28 return stateOpen
29+ case ermite.IssueState_ISSUE_STATE_RESOLVED:
30+ return stateResolved
28 case ermite.IssueState_ISSUE_STATE_CLOSED:31 case ermite.IssueState_ISSUE_STATE_CLOSED:
29 return stateClosed32 return stateClosed
30 case ermite.IssueState_ISSUE_STATE_PENDING:33 case ermite.IssueState_ISSUE_STATE_PENDING:
@@ -38,12 +41,14 @@ func parseIssueState(s string) (ermite.IssueState, error) {
38 switch strings.ToLower(strings.TrimSpace(s)) {41 switch strings.ToLower(strings.TrimSpace(s)) {
39 case stateOpen:42 case stateOpen:
40 return ermite.IssueState_ISSUE_STATE_OPEN, nil43 return ermite.IssueState_ISSUE_STATE_OPEN, nil
44+ case stateResolved:
45+ return ermite.IssueState_ISSUE_STATE_RESOLVED, nil
41 case stateClosed:46 case stateClosed:
42 return ermite.IssueState_ISSUE_STATE_CLOSED, nil47 return ermite.IssueState_ISSUE_STATE_CLOSED, nil
43 case stateAll:48 case stateAll:
44 return ermite.IssueState_ISSUE_STATE_UNSPECIFIED, nil49 return ermite.IssueState_ISSUE_STATE_UNSPECIFIED, nil
45 }50 }
46- return 0, fmt.Errorf("invalid state %q: use %s, %s or %s", s, stateOpen, stateClosed, stateAll)51+ return 0, fmt.Errorf("invalid state %q: use %s, %s, %s or %s", s, stateOpen, stateClosed, stateResolved, stateAll)
47 }52 }
48 53
49 // jsonIssue is the stable JSON shape of an issue.54 // jsonIssue is the stable JSON shape of an issue.
@@ -57,7 +62,7 @@ type jsonIssue struct {
57 CreatedAt *string `json:"created_at"`62 CreatedAt *string `json:"created_at"`
58 UpdatedAt *string `json:"updated_at"`63 UpdatedAt *string `json:"updated_at"`
59 CommentCount int32 `json:"comment_count"`64 CommentCount int32 `json:"comment_count"`
60- // ClosedByMergeRequest is the merge request whose merge closed the issue; 0 for none.65+ // ClosedByMergeRequest is the merge request whose merge resolved the issue; 0 for none.
61 ClosedByMergeRequest int64 `json:"closed_by_merge_request"`66 ClosedByMergeRequest int64 `json:"closed_by_merge_request"`
62 }67 }
63 68
@@ -87,6 +92,7 @@ type jsonIssueDetail struct {
87 jsonIssue92 jsonIssue
88 Comments []jsonIssueComment `json:"comments"`93 Comments []jsonIssueComment `json:"comments"`
89 References []jsonIssueReference `json:"references"`94 References []jsonIssueReference `json:"references"`
95+ Events []jsonIssueEvent `json:"events"`
90 }96 }
91 97
92 // jsonIssueReference is a merge request that names the issue.98 // jsonIssueReference is a merge request that names the issue.
@@ -98,16 +104,16 @@ type jsonIssueReference struct {
98 }104 }
99 105
100 const jsonIssueHelp = `106 const jsonIssueHelp = `
101-JSON fields of an issue (--json): repo, number, title, body, state ("open" or "closed"), author107+JSON fields of an issue (--json): repo, number, title, body, state ("open", "closed" or "resolved"), author
102 (token label), created_at and updated_at (RFC 3339 UTC), comment_count, closed_by_merge_request (number of108 (token label), created_at and updated_at (RFC 3339 UTC), comment_count, closed_by_merge_request (number of
103-the merge request whose merge closed the issue, 0 for none).`109+the merge request whose merge resolved the issue, 0 for none).`
104 110
105 const jsonIssueCommentHelp = `111 const jsonIssueCommentHelp = `
106 JSON fields of an issue comment: id, author, text, created_at.`112 JSON fields of an issue comment: id, author, text, created_at.`
107 113
108 const jsonIssueReferenceHelp = `114 const jsonIssueReferenceHelp = `
109 JSON fields of a merge request reference: number, title, state ("open", "merged" or "closed"), closes115 JSON fields of a merge request reference: number, title, state ("open", "merged" or "closed"), closes
110-(true when merging it closes the issue).`116+(true when merging it resolves the issue).`
111 117
112 func newIssueCmd(env *cliEnv) *cobra.Command {118 func newIssueCmd(env *cliEnv) *cobra.Command {
113 issue := &cobra.Command{119 issue := &cobra.Command{
@@ -120,7 +126,7 @@ func newIssueCmd(env *cliEnv) *cobra.Command {
120 }126 }
121 issue.AddCommand(127 issue.AddCommand(
122 newIssueCreateCmd(env), newIssueListCmd(env), newIssueShowCmd(env), newIssueCommentCmd(env),128 newIssueCreateCmd(env), newIssueListCmd(env), newIssueShowCmd(env), newIssueCommentCmd(env),
123- newIssueCloseCmd(env), newIssueReopenCmd(env),129+ newIssueCloseCmd(env), newIssueResolveCmd(env), newIssueReopenCmd(env),
124 )130 )
125 return issue131 return issue
126 }132 }
@@ -265,14 +271,14 @@ func newIssueListCmd(env *cliEnv) *cobra.Command {
265 cmd := &cobra.Command{271 cmd := &cobra.Command{
266 Use: "list <repo>",272 Use: "list <repo>",
267 Short: "List the issues of a repository",273 Short: "List the issues of a repository",
268- Long: "List the issues of a repository, newest first. --state restricts them to open or closed ones\n" +274+ Long: "List the issues of a repository, newest first. --state restricts them to open, closed or resolved ones\n" +
269 "(default: open; all shows every state)." + jsonIssueHelp + "\n--json prints an array of issues.",275 "(default: open; all shows every state)." + jsonIssueHelp + "\n--json prints an array of issues.",
270 Args: cobra.ExactArgs(1),276 Args: cobra.ExactArgs(1),
271 ValidArgsFunction: completeRepoNames(env),277 ValidArgsFunction: completeRepoNames(env),
272 }278 }
273- cmd.Flags().StringVar(&state, "state", stateOpen, "state: open, closed or all")279+ cmd.Flags().StringVar(&state, "state", stateOpen, "state: open, closed, resolved or all")
274 _ = cmd.RegisterFlagCompletionFunc("state", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {280 _ = cmd.RegisterFlagCompletionFunc("state", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
275- return []string{stateOpen, stateClosed, stateAll}, cobra.ShellCompDirectiveNoFileComp281+ return []string{stateOpen, stateClosed, stateResolved, stateAll}, cobra.ShellCompDirectiveNoFileComp
276 })282 })
277 asJSON := addJSONFlag(cmd)283 asJSON := addJSONFlag(cmd)
278 cmd.RunE = func(cmd *cobra.Command, args []string) error {284 cmd.RunE = func(cmd *cobra.Command, args []string) error {
@@ -316,10 +322,10 @@ func newIssueShowCmd(env *cliEnv) *cobra.Command {
316 Use: "show <repo> <number>",322 Use: "show <repo> <number>",
317 Short: "Show an issue with its comments",323 Short: "Show an issue with its comments",
318 Long: "Show an issue: its fields, its body, the merge requests that name it (#N in their title,\n" +324 Long: "Show an issue: its fields, its body, the merge requests that name it (#N in their title,\n" +
319- "description, notes or commits; \"closes\" when a closing keyword such as \"Fixes #N\" names it) and\n" +325+ "description, notes or commits; a closing keyword such as \"Fixes #N\" resolves it on merge) and\n" +
320- "its comments, oldest first. \"closed by\" shows the merge request whose merge closed the issue." +326+ "its comments and history, oldest first. \"resolved by\" shows the merge request whose merge resolved the issue." +
321- jsonIssueHelp + jsonIssueCommentHelp + jsonIssueReferenceHelp +327+ jsonIssueHelp + jsonIssueCommentHelp + jsonIssueReferenceHelp + jsonIssueEventHelp +
322- "\n--json adds comments and references (always present, possibly empty) to the issue fields.",328+ "\n--json adds comments, references and events (always present, possibly empty) to the issue fields.",
323 Args: cobra.ExactArgs(2),329 Args: cobra.ExactArgs(2),
324 ValidArgsFunction: completeIssueRepoThenNumber(env),330 ValidArgsFunction: completeIssueRepoThenNumber(env),
325 }331 }
@@ -338,7 +344,7 @@ func newIssueShowCmd(env *cliEnv) *cobra.Command {
338 return c.Describe(err)344 return c.Describe(err)
339 }345 }
340 if *asJSON {346 if *asJSON {
341- out := jsonIssueDetail{jsonIssue: toJSONIssue(res.Msg.GetIssue()), Comments: []jsonIssueComment{}}347+ out := jsonIssueDetail{jsonIssue: toJSONIssue(res.Msg.GetIssue()), Comments: []jsonIssueComment{}, Events: toJSONIssueEvents(res.Msg.GetEvents())}
342 for _, cm := range res.Msg.GetComments() {348 for _, cm := range res.Msg.GetComments() {
343 out.Comments = append(out.Comments, toJSONIssueComment(cm))349 out.Comments = append(out.Comments, toJSONIssueComment(cm))
344 }350 }
@@ -367,7 +373,7 @@ func printIssueDetail(w io.Writer, d *ermite.GetIssueResponse) error {
367 {"state", issueStateName(i.GetState())},373 {"state", issueStateName(i.GetState())},
368 }374 }
369 if n := i.GetClosedByMergeRequest(); n > 0 {375 if n := i.GetClosedByMergeRequest(); n > 0 {
370- rows = append(rows, [2]string{"closed by", fmt.Sprintf("!%d", n)})376+ rows = append(rows, [2]string{"resolved by", fmt.Sprintf("!%d", n)})
371 }377 }
372 rows = append(rows,378 rows = append(rows,
373 [2]string{"author", i.GetAuthor()},379 [2]string{"author", i.GetAuthor()},
@@ -388,7 +394,7 @@ func printIssueDetail(w io.Writer, d *ermite.GetIssueResponse) error {
388 for _, r := range refs {394 for _, r := range refs {
389 line := fmt.Sprintf(" !%d %-6s %s", r.GetNumber(), stateName(r.GetState()), clean(r.GetTitle()))395 line := fmt.Sprintf(" !%d %-6s %s", r.GetNumber(), stateName(r.GetState()), clean(r.GetTitle()))
390 if r.GetCloses() {396 if r.GetCloses() {
391- line += " · closes"397+ line += " · resolves"
392 }398 }
393 if _, err := fmt.Fprintln(w, line); err != nil {399 if _, err := fmt.Fprintln(w, line); err != nil {
394 return err400 return err
@@ -398,10 +404,17 @@ func printIssueDetail(w io.Writer, d *ermite.GetIssueResponse) error {
398 if _, err := fmt.Fprintf(w, "\ncomments (%d)\n", len(d.GetComments())); err != nil {404 if _, err := fmt.Fprintf(w, "\ncomments (%d)\n", len(d.GetComments())); err != nil {
399 return err405 return err
400 }406 }
407+ var timeline []issueTimelineRow
401 for _, c := range d.GetComments() {408 for _, c := range d.GetComments() {
402- if _, err := fmt.Fprintf(w, " #%s %s, %s\n%s\n", c.GetId(), clean(c.GetAuthor()), shortTime(c.GetCreatedAt(), "-"), indentText(c.GetText())); err != nil {409+ c := c
410+ timeline = append(timeline, issueTimelineRow{at: c.GetCreatedAt(), print: func(w io.Writer) error {
411+ _, err := fmt.Fprintf(w, " #%s %s, %s\n%s\n", c.GetId(), clean(c.GetAuthor()), shortTime(c.GetCreatedAt(), "-"), indentText(c.GetText()))
403 return err412 return err
404- }413+ }})
414+ }
415+ timeline = append(timeline, newIssueTimeline(d.GetEvents(), false)...)
416+ if err := printIssueTimeline(w, timeline); err != nil {
417+ return err
405 }418 }
406 return nil419 return nil
407 }420 }
@@ -462,8 +475,19 @@ func newIssueCloseCmd(env *cliEnv) *cobra.Command {
462 })475 })
463 }476 }
464 477
478+func newIssueResolveCmd(env *cliEnv) *cobra.Command {
479+ return newIssueStateCmd(env, "resolve", "resolved", "Resolve an issue", "Mark an open issue as resolved (fixed). `ermite issue reopen` opens it again. Needs the issue:write scope.",
480+ func(ctx context.Context, c *client.Client, repo string, n int64) (*ermite.Issue, error) {
481+ res, err := c.Issues().ResolveIssue(ctx, connect.NewRequest(&ermite.ResolveIssueRequest{Repo: repo, Number: n}))
482+ if err != nil {
483+ return nil, err
484+ }
485+ return res.Msg.GetIssue(), nil
486+ })
487+}
488+
465 func newIssueReopenCmd(env *cliEnv) *cobra.Command {489 func newIssueReopenCmd(env *cliEnv) *cobra.Command {
466- return newIssueStateCmd(env, "reopen", "reopened", "Reopen a closed issue", "Reopen a closed issue. Needs the issue:write scope.",490+ return newIssueStateCmd(env, "reopen", "reopened", "Reopen a closed or resolved issue", "Reopen a closed or resolved issue. Needs the issue:write scope.",
467 func(ctx context.Context, c *client.Client, repo string, n int64) (*ermite.Issue, error) {491 func(ctx context.Context, c *client.Client, repo string, n int64) (*ermite.Issue, error) {
468 res, err := c.Issues().ReopenIssue(ctx, connect.NewRequest(&ermite.ReopenIssueRequest{Repo: repo, Number: n}))492 res, err := c.Issues().ReopenIssue(ctx, connect.NewRequest(&ermite.ReopenIssueRequest{Repo: repo, Number: n}))
469 if err != nil {493 if err != nil {
A internal/app/cli_issue_history.go +108 -0
@@ -0,0 +1,108 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "fmt"
7+ "io"
8+ "slices"
9+ "strings"
10+
11+ "bnema.dev/ermite-api/gen/ermite"
12+ "google.golang.org/protobuf/types/known/timestamppb"
13+)
14+
15+const jsonIssueEventHelp = `
16+JSON fields of a history event: id, kind (referenced, closed, reopened, resolved), issue,
17+merge_request (0 for none), merge_request_title, closes, actor, created_at (RFC 3339 UTC).`
18+
19+type jsonIssueEvent struct {
20+ ID string `json:"id"`
21+ Kind string `json:"kind"`
22+ Issue int64 `json:"issue"`
23+ MergeRequest int64 `json:"merge_request"`
24+ MergeRequestTitle string `json:"merge_request_title"`
25+ Closes bool `json:"closes"`
26+ Actor string `json:"actor"`
27+ CreatedAt *string `json:"created_at"`
28+}
29+
30+func toJSONIssueEvents(events []*ermite.IssueEvent) []jsonIssueEvent {
31+ out := make([]jsonIssueEvent, 0, len(events))
32+ for _, e := range events {
33+ out = append(out, jsonIssueEvent{ID: e.GetId(), Kind: strings.ToLower(strings.TrimPrefix(e.GetKind().String(), "ISSUE_EVENT_KIND_")), Issue: e.GetIssue(), MergeRequest: e.GetMergeRequest(), MergeRequestTitle: e.GetMergeRequestTitle(), Closes: e.GetCloses(), Actor: e.GetActor(), CreatedAt: jsonTime(e.GetCreatedAt())})
34+ }
35+ return out
36+}
37+
38+func issueHistoryText(e *ermite.IssueEvent, mrSide bool) string {
39+ subject := "this"
40+ if mrSide {
41+ subject = fmt.Sprintf("#%d", e.GetIssue())
42+ }
43+ var text string
44+ switch e.GetKind() {
45+ case ermite.IssueEventKind_ISSUE_EVENT_KIND_REFERENCED:
46+ text = fmt.Sprintf("referenced %s in !%d", subject, e.GetMergeRequest())
47+ if e.GetCloses() {
48+ text += " (will resolve)"
49+ }
50+ case ermite.IssueEventKind_ISSUE_EVENT_KIND_RESOLVED:
51+ text = "resolved " + subject
52+ if e.GetMergeRequest() > 0 {
53+ text += fmt.Sprintf(" by merging !%d", e.GetMergeRequest())
54+ }
55+ case ermite.IssueEventKind_ISSUE_EVENT_KIND_CLOSED:
56+ text = "closed " + subject
57+ case ermite.IssueEventKind_ISSUE_EVENT_KIND_REOPENED:
58+ text = "reopened " + subject
59+ default:
60+ text = "updated " + subject
61+ }
62+ return clean(e.GetActor()) + " " + text
63+}
64+
65+type issueTimelineRow struct {
66+ at *timestamppb.Timestamp
67+ print func(io.Writer) error
68+}
69+
70+func newIssueTimeline(events []*ermite.IssueEvent, mrSide bool) []issueTimelineRow {
71+ rows := make([]issueTimelineRow, 0, len(events))
72+ for _, e := range events {
73+ rows = append(rows, issueTimelineRow{at: e.GetCreatedAt(), print: func(w io.Writer) error {
74+ _, err := fmt.Fprintf(w, " → %s %s\n", shortTime(e.GetCreatedAt(), "-"), issueHistoryText(e, mrSide))
75+ return err
76+ }})
77+ }
78+ return rows
79+}
80+
81+func printIssueTimeline(w io.Writer, rows []issueTimelineRow) error {
82+ // Stable sorting puts comments before events at the same instant.
83+ slices.SortStableFunc(rows, func(a, b issueTimelineRow) int {
84+ if n := a.at.AsTime().Compare(b.at.AsTime()); n != 0 {
85+ return n
86+ }
87+ return 0
88+ })
89+ for _, row := range rows {
90+ if err := row.print(w); err != nil {
91+ return err
92+ }
93+ }
94+ return nil
95+}
96+
97+func mrIssueStateName(s ermite.MergeRequestIssueState) string {
98+ switch s {
99+ case ermite.MergeRequestIssueState_MERGE_REQUEST_ISSUE_STATE_OPEN:
100+ return stateOpen
101+ case ermite.MergeRequestIssueState_MERGE_REQUEST_ISSUE_STATE_CLOSED:
102+ return stateClosed
103+ case ermite.MergeRequestIssueState_MERGE_REQUEST_ISSUE_STATE_RESOLVED:
104+ return stateResolved
105+ default:
106+ return "unknown"
107+ }
108+}
@@ -0,0 +1,108 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "fmt"
7+ "io"
8+ "slices"
9+ "strings"
10+
11+ "bnema.dev/ermite-api/gen/ermite"
12+ "google.golang.org/protobuf/types/known/timestamppb"
13+)
14+
15+const jsonIssueEventHelp = `
16+JSON fields of a history event: id, kind (referenced, closed, reopened, resolved), issue,
17+merge_request (0 for none), merge_request_title, closes, actor, created_at (RFC 3339 UTC).`
18+
19+type jsonIssueEvent struct {
20+ ID string `json:"id"`
21+ Kind string `json:"kind"`
22+ Issue int64 `json:"issue"`
23+ MergeRequest int64 `json:"merge_request"`
24+ MergeRequestTitle string `json:"merge_request_title"`
25+ Closes bool `json:"closes"`
26+ Actor string `json:"actor"`
27+ CreatedAt *string `json:"created_at"`
28+}
29+
30+func toJSONIssueEvents(events []*ermite.IssueEvent) []jsonIssueEvent {
31+ out := make([]jsonIssueEvent, 0, len(events))
32+ for _, e := range events {
33+ out = append(out, jsonIssueEvent{ID: e.GetId(), Kind: strings.ToLower(strings.TrimPrefix(e.GetKind().String(), "ISSUE_EVENT_KIND_")), Issue: e.GetIssue(), MergeRequest: e.GetMergeRequest(), MergeRequestTitle: e.GetMergeRequestTitle(), Closes: e.GetCloses(), Actor: e.GetActor(), CreatedAt: jsonTime(e.GetCreatedAt())})
34+ }
35+ return out
36+}
37+
38+func issueHistoryText(e *ermite.IssueEvent, mrSide bool) string {
39+ subject := "this"
40+ if mrSide {
41+ subject = fmt.Sprintf("#%d", e.GetIssue())
42+ }
43+ var text string
44+ switch e.GetKind() {
45+ case ermite.IssueEventKind_ISSUE_EVENT_KIND_REFERENCED:
46+ text = fmt.Sprintf("referenced %s in !%d", subject, e.GetMergeRequest())
47+ if e.GetCloses() {
48+ text += " (will resolve)"
49+ }
50+ case ermite.IssueEventKind_ISSUE_EVENT_KIND_RESOLVED:
51+ text = "resolved " + subject
52+ if e.GetMergeRequest() > 0 {
53+ text += fmt.Sprintf(" by merging !%d", e.GetMergeRequest())
54+ }
55+ case ermite.IssueEventKind_ISSUE_EVENT_KIND_CLOSED:
56+ text = "closed " + subject
57+ case ermite.IssueEventKind_ISSUE_EVENT_KIND_REOPENED:
58+ text = "reopened " + subject
59+ default:
60+ text = "updated " + subject
61+ }
62+ return clean(e.GetActor()) + " " + text
63+}
64+
65+type issueTimelineRow struct {
66+ at *timestamppb.Timestamp
67+ print func(io.Writer) error
68+}
69+
70+func newIssueTimeline(events []*ermite.IssueEvent, mrSide bool) []issueTimelineRow {
71+ rows := make([]issueTimelineRow, 0, len(events))
72+ for _, e := range events {
73+ rows = append(rows, issueTimelineRow{at: e.GetCreatedAt(), print: func(w io.Writer) error {
74+ _, err := fmt.Fprintf(w, " → %s %s\n", shortTime(e.GetCreatedAt(), "-"), issueHistoryText(e, mrSide))
75+ return err
76+ }})
77+ }
78+ return rows
79+}
80+
81+func printIssueTimeline(w io.Writer, rows []issueTimelineRow) error {
82+ // Stable sorting puts comments before events at the same instant.
83+ slices.SortStableFunc(rows, func(a, b issueTimelineRow) int {
84+ if n := a.at.AsTime().Compare(b.at.AsTime()); n != 0 {
85+ return n
86+ }
87+ return 0
88+ })
89+ for _, row := range rows {
90+ if err := row.print(w); err != nil {
91+ return err
92+ }
93+ }
94+ return nil
95+}
96+
97+func mrIssueStateName(s ermite.MergeRequestIssueState) string {
98+ switch s {
99+ case ermite.MergeRequestIssueState_MERGE_REQUEST_ISSUE_STATE_OPEN:
100+ return stateOpen
101+ case ermite.MergeRequestIssueState_MERGE_REQUEST_ISSUE_STATE_CLOSED:
102+ return stateClosed
103+ case ermite.MergeRequestIssueState_MERGE_REQUEST_ISSUE_STATE_RESOLVED:
104+ return stateResolved
105+ default:
106+ return "unknown"
107+ }
108+}
M internal/app/cli_issue_test.go +41 -5
@@ -266,7 +266,7 @@ func TestPrintIssueDetailReturnsWriteErrors(t *testing.T) {
266266
267267 func TestIssueShowMergeRequestLinks(t *testing.T) {
268268 closed := &ermite.Issue{
269- Repo: "alpha", Number: 2, Title: "Crash on start", State: ermite.IssueState_ISSUE_STATE_CLOSED,
269+ Repo: "alpha", Number: 2, Title: "Crash on start", State: ermite.IssueState_ISSUE_STATE_RESOLVED,
270270 ClosedByMergeRequest: 5,
271271 }
272272 f := &fakeAPI{
@@ -280,12 +280,12 @@ func TestIssueShowMergeRequestLinks(t *testing.T) {
280280 }
281281 url := f.serve(t)
282282 res := runCLI(t, envFor(url), "", "issue", "show", "alpha", "2").ok(t)
283- assert.Regexp(t, `state\s+closed\n.*closed by\s+!5\n`, res.stdout)
284- assert.Contains(t, res.stdout, "\nmerge requests (2)\n !5 open Fix the parser · closes\n !7 merged Follow-up\n")
283+ assert.Regexp(t, `state\s+resolved\n.*resolved by\s+!5\n`, res.stdout)
284+ assert.Contains(t, res.stdout, "\nmerge requests (2)\n !5 open Fix the parser · resolves\n !7 merged Follow-up\n")
285285 assert.Less(t, strings.Index(res.stdout, "merge requests (2)"), strings.Index(res.stdout, "comments (0)"))
286286
287287 res = runCLI(t, envFor(url), "", "issue", "show", "alpha", "2", "--json").ok(t)
288- assert.JSONEq(t, `{"repo":"alpha","number":2,"title":"Crash on start","body":"","state":"closed","author":"","created_at":null,"updated_at":null,"comment_count":0,"closed_by_merge_request":5,"comments":[],"references":[{"number":5,"title":"Fix the parser","state":"open","closes":true},{"number":7,"title":"Follow-up","state":"merged","closes":false}]}`, res.stdout)
288+ assert.JSONEq(t, `{"repo":"alpha","number":2,"title":"Crash on start","body":"","state":"resolved","author":"","created_at":null,"updated_at":null,"comment_count":0,"closed_by_merge_request":5,"comments":[],"events":[],"references":[{"number":5,"title":"Fix the parser","state":"open","closes":true},{"number":7,"title":"Follow-up","state":"merged","closes":false}]}`, res.stdout)
289289
290290 // No references: no block in text, an empty array in JSON.
291291 f.getIssue = func(*ermite.GetIssueRequest) (*ermite.GetIssueResponse, error) {
@@ -293,7 +293,43 @@ func TestIssueShowMergeRequestLinks(t *testing.T) {
293293 }
294294 res = runCLI(t, envFor(url), "", "issue", "show", "alpha", "2").ok(t)
295295 assert.NotContains(t, res.stdout, "merge requests")
296- assert.NotContains(t, res.stdout, "closed by")
296+ assert.NotContains(t, res.stdout, "resolved by")
297297 res = runCLI(t, envFor(url), "", "issue", "show", "alpha", "2", "--json").ok(t)
298298 assert.Contains(t, res.stdout, `"references": []`)
299299 }
300+
301+func TestIssueResolveAndHistory(t *testing.T) {
302+ events := []*ermite.IssueEvent{
303+ {Id: "1", Kind: ermite.IssueEventKind_ISSUE_EVENT_KIND_REFERENCED, Issue: 2, MergeRequest: 5, MergeRequestTitle: "Fix", Closes: true, Actor: "owner", CreatedAt: ts("2026-10-01T10:00:00Z")},
304+ {Id: "2", Kind: ermite.IssueEventKind_ISSUE_EVENT_KIND_RESOLVED, Issue: 2, MergeRequest: 5, Actor: "merger", CreatedAt: ts("2026-10-03T10:00:00Z")},
305+ }
306+ f := &fakeAPI{getVersion: withIssues,
307+ resolveIssue: func(r *ermite.ResolveIssueRequest) (*ermite.ResolveIssueResponse, error) {
308+ assert.Equal(t, "alpha", r.GetRepo())
309+ assert.EqualValues(t, 2, r.GetNumber())
310+ return &ermite.ResolveIssueResponse{Issue: &ermite.Issue{Repo: "alpha", Number: 2, Title: "Fix", State: ermite.IssueState_ISSUE_STATE_RESOLVED}}, nil
311+ },
312+ getIssue: func(*ermite.GetIssueRequest) (*ermite.GetIssueResponse, error) {
313+ return &ermite.GetIssueResponse{Issue: issueOne, Events: events, Comments: []*ermite.IssueComment{{Id: "1", Author: "reviewer", Text: "checking", CreatedAt: ts("2026-10-02T10:00:00Z")}}}, nil
314+ },
315+ listIssues: func(r *ermite.ListIssuesRequest) (*ermite.ListIssuesResponse, error) {
316+ assert.Equal(t, ermite.IssueState_ISSUE_STATE_RESOLVED, r.GetState())
317+ return &ermite.ListIssuesResponse{}, nil
318+ },
319+ }
320+ url := f.serve(t)
321+ res := runCLI(t, envFor(url), "", "issue", "resolve", "alpha", "#2").ok(t)
322+ assert.Equal(t, "resolved alpha#2 (Fix)\n", res.stdout)
323+ res = runCLI(t, envFor(url), "", "issue", "resolve", "alpha", "2", "--json").ok(t)
324+ assert.Contains(t, res.stdout, `"state": "resolved"`)
325+ runCLI(t, envFor(url), "", "issue", "list", "alpha", "--state", "resolved").ok(t)
326+ res = runCLI(t, envFor(url), "", "issue", "show", "alpha", "2").ok(t)
327+ assert.Contains(t, res.stdout, "owner referenced this in !5 (will resolve)")
328+ assert.Contains(t, res.stdout, "merger resolved this by merging !5")
329+ assert.Less(t, strings.Index(res.stdout, "owner referenced"), strings.Index(res.stdout, "reviewer"))
330+ assert.Less(t, strings.Index(res.stdout, "reviewer"), strings.Index(res.stdout, "merger resolved"))
331+ res = runCLI(t, envFor(url), "", "issue", "show", "alpha", "2", "--json").ok(t)
332+ assert.Contains(t, res.stdout, `"events": [`)
333+ assert.Contains(t, res.stdout, `"merge_request_title": "Fix"`)
334+ assert.Contains(t, res.stdout, `"kind": "resolved"`)
335+}
@@ -266,7 +266,7 @@ func TestPrintIssueDetailReturnsWriteErrors(t *testing.T) {
266 266
267 func TestIssueShowMergeRequestLinks(t *testing.T) {267 func TestIssueShowMergeRequestLinks(t *testing.T) {
268 closed := &ermite.Issue{268 closed := &ermite.Issue{
269- Repo: "alpha", Number: 2, Title: "Crash on start", State: ermite.IssueState_ISSUE_STATE_CLOSED,269+ Repo: "alpha", Number: 2, Title: "Crash on start", State: ermite.IssueState_ISSUE_STATE_RESOLVED,
270 ClosedByMergeRequest: 5,270 ClosedByMergeRequest: 5,
271 }271 }
272 f := &fakeAPI{272 f := &fakeAPI{
@@ -280,12 +280,12 @@ func TestIssueShowMergeRequestLinks(t *testing.T) {
280 }280 }
281 url := f.serve(t)281 url := f.serve(t)
282 res := runCLI(t, envFor(url), "", "issue", "show", "alpha", "2").ok(t)282 res := runCLI(t, envFor(url), "", "issue", "show", "alpha", "2").ok(t)
283- assert.Regexp(t, `state\s+closed\n.*closed by\s+!5\n`, res.stdout)283+ assert.Regexp(t, `state\s+resolved\n.*resolved by\s+!5\n`, res.stdout)
284- assert.Contains(t, res.stdout, "\nmerge requests (2)\n !5 open Fix the parser · closes\n !7 merged Follow-up\n")284+ assert.Contains(t, res.stdout, "\nmerge requests (2)\n !5 open Fix the parser · resolves\n !7 merged Follow-up\n")
285 assert.Less(t, strings.Index(res.stdout, "merge requests (2)"), strings.Index(res.stdout, "comments (0)"))285 assert.Less(t, strings.Index(res.stdout, "merge requests (2)"), strings.Index(res.stdout, "comments (0)"))
286 286
287 res = runCLI(t, envFor(url), "", "issue", "show", "alpha", "2", "--json").ok(t)287 res = runCLI(t, envFor(url), "", "issue", "show", "alpha", "2", "--json").ok(t)
288- assert.JSONEq(t, `{"repo":"alpha","number":2,"title":"Crash on start","body":"","state":"closed","author":"","created_at":null,"updated_at":null,"comment_count":0,"closed_by_merge_request":5,"comments":[],"references":[{"number":5,"title":"Fix the parser","state":"open","closes":true},{"number":7,"title":"Follow-up","state":"merged","closes":false}]}`, res.stdout)288+ assert.JSONEq(t, `{"repo":"alpha","number":2,"title":"Crash on start","body":"","state":"resolved","author":"","created_at":null,"updated_at":null,"comment_count":0,"closed_by_merge_request":5,"comments":[],"events":[],"references":[{"number":5,"title":"Fix the parser","state":"open","closes":true},{"number":7,"title":"Follow-up","state":"merged","closes":false}]}`, res.stdout)
289 289
290 // No references: no block in text, an empty array in JSON.290 // No references: no block in text, an empty array in JSON.
291 f.getIssue = func(*ermite.GetIssueRequest) (*ermite.GetIssueResponse, error) {291 f.getIssue = func(*ermite.GetIssueRequest) (*ermite.GetIssueResponse, error) {
@@ -293,7 +293,43 @@ func TestIssueShowMergeRequestLinks(t *testing.T) {
293 }293 }
294 res = runCLI(t, envFor(url), "", "issue", "show", "alpha", "2").ok(t)294 res = runCLI(t, envFor(url), "", "issue", "show", "alpha", "2").ok(t)
295 assert.NotContains(t, res.stdout, "merge requests")295 assert.NotContains(t, res.stdout, "merge requests")
296- assert.NotContains(t, res.stdout, "closed by")296+ assert.NotContains(t, res.stdout, "resolved by")
297 res = runCLI(t, envFor(url), "", "issue", "show", "alpha", "2", "--json").ok(t)297 res = runCLI(t, envFor(url), "", "issue", "show", "alpha", "2", "--json").ok(t)
298 assert.Contains(t, res.stdout, `"references": []`)298 assert.Contains(t, res.stdout, `"references": []`)
299 }299 }
300+
301+func TestIssueResolveAndHistory(t *testing.T) {
302+ events := []*ermite.IssueEvent{
303+ {Id: "1", Kind: ermite.IssueEventKind_ISSUE_EVENT_KIND_REFERENCED, Issue: 2, MergeRequest: 5, MergeRequestTitle: "Fix", Closes: true, Actor: "owner", CreatedAt: ts("2026-10-01T10:00:00Z")},
304+ {Id: "2", Kind: ermite.IssueEventKind_ISSUE_EVENT_KIND_RESOLVED, Issue: 2, MergeRequest: 5, Actor: "merger", CreatedAt: ts("2026-10-03T10:00:00Z")},
305+ }
306+ f := &fakeAPI{getVersion: withIssues,
307+ resolveIssue: func(r *ermite.ResolveIssueRequest) (*ermite.ResolveIssueResponse, error) {
308+ assert.Equal(t, "alpha", r.GetRepo())
309+ assert.EqualValues(t, 2, r.GetNumber())
310+ return &ermite.ResolveIssueResponse{Issue: &ermite.Issue{Repo: "alpha", Number: 2, Title: "Fix", State: ermite.IssueState_ISSUE_STATE_RESOLVED}}, nil
311+ },
312+ getIssue: func(*ermite.GetIssueRequest) (*ermite.GetIssueResponse, error) {
313+ return &ermite.GetIssueResponse{Issue: issueOne, Events: events, Comments: []*ermite.IssueComment{{Id: "1", Author: "reviewer", Text: "checking", CreatedAt: ts("2026-10-02T10:00:00Z")}}}, nil
314+ },
315+ listIssues: func(r *ermite.ListIssuesRequest) (*ermite.ListIssuesResponse, error) {
316+ assert.Equal(t, ermite.IssueState_ISSUE_STATE_RESOLVED, r.GetState())
317+ return &ermite.ListIssuesResponse{}, nil
318+ },
319+ }
320+ url := f.serve(t)
321+ res := runCLI(t, envFor(url), "", "issue", "resolve", "alpha", "#2").ok(t)
322+ assert.Equal(t, "resolved alpha#2 (Fix)\n", res.stdout)
323+ res = runCLI(t, envFor(url), "", "issue", "resolve", "alpha", "2", "--json").ok(t)
324+ assert.Contains(t, res.stdout, `"state": "resolved"`)
325+ runCLI(t, envFor(url), "", "issue", "list", "alpha", "--state", "resolved").ok(t)
326+ res = runCLI(t, envFor(url), "", "issue", "show", "alpha", "2").ok(t)
327+ assert.Contains(t, res.stdout, "owner referenced this in !5 (will resolve)")
328+ assert.Contains(t, res.stdout, "merger resolved this by merging !5")
329+ assert.Less(t, strings.Index(res.stdout, "owner referenced"), strings.Index(res.stdout, "reviewer"))
330+ assert.Less(t, strings.Index(res.stdout, "reviewer"), strings.Index(res.stdout, "merger resolved"))
331+ res = runCLI(t, envFor(url), "", "issue", "show", "alpha", "2", "--json").ok(t)
332+ assert.Contains(t, res.stdout, `"events": [`)
333+ assert.Contains(t, res.stdout, `"merge_request_title": "Fix"`)
334+ assert.Contains(t, res.stdout, `"kind": "resolved"`)
335+}
M internal/app/cli_mr.go +62 -31
@@ -512,6 +512,8 @@ type jsonCommit struct {
512512
513513 // jsonMRDetail is the stable JSON shape of `mr show --json`.
514514 type jsonMRDetail struct {
515+ Issues []jsonMRIssue `json:"issues"`
516+ IssueEvents []jsonIssueEvent `json:"issue_events"`
515517 jsonMergeRequest
516518 // Mergeability is null unless the request is open and the server computes it.
517519 Mergeability *jsonMergeability `json:"mergeability"`
@@ -527,6 +529,13 @@ type jsonMRDetail struct {
527529 LastReadLost bool `json:"last_read_lost"`
528530 }
529531
532+type jsonMRIssue struct {
533+ Number int64 `json:"number"`
534+ Title string `json:"title"`
535+ State string `json:"state"`
536+ Closes bool `json:"closes"`
537+}
538+
530539 type jsonAuthorVerdict struct {
531540 Author string `json:"author"`
532541 Verdict string `json:"verdict"`
@@ -555,8 +564,9 @@ func lineKindName(k ermite.LineKind) string {
555564 func toJSONDetail(d *ermite.GetMergeRequestResponse, withDiff bool, baseURL string) jsonMRDetail {
556565 out := jsonMRDetail{
557566 jsonMergeRequest: toJSONMergeRequest(d.GetMergeRequest(), baseURL),
558- Mergeability: toJSONMergeability(d.GetMergeRequest().GetMergeability()),
559- Commits: []jsonCommit{}, CommitsTruncated: d.GetCommitsTruncated(),
567+ Issues: []jsonMRIssue{}, IssueEvents: toJSONIssueEvents(d.GetIssueEvents()),
568+ Mergeability: toJSONMergeability(d.GetMergeRequest().GetMergeability()),
569+ Commits: []jsonCommit{}, CommitsTruncated: d.GetCommitsTruncated(),
560570 DiffBaseID: d.GetDiffBaseId(),
561571 Stats: jsonStats{
562572 FilesChanged: d.GetStats().GetFilesChanged(), Additions: d.GetStats().GetAdditions(), Deletions: d.GetStats().GetDeletions(),
@@ -565,6 +575,9 @@ func toJSONDetail(d *ermite.GetMergeRequestResponse, withDiff bool, baseURL stri
565575 Files: []jsonFile{}, Comments: []jsonComment{}, Verdicts: []jsonAuthorVerdict{},
566576 LastReadID: d.GetLastReadId(), LastReadLost: d.GetLastReadLost(),
567577 }
578+ for _, i := range d.GetIssues() {
579+ out.Issues = append(out.Issues, jsonMRIssue{Number: i.GetNumber(), Title: i.GetTitle(), State: mrIssueStateName(i.GetState()), Closes: i.GetCloses()})
580+ }
568581 for _, c := range d.GetCommits() {
569582 out.Commits = append(out.Commits, jsonCommit{ID: c.GetId(), Author: c.GetAuthor().GetName(), Summary: c.GetSummary()})
570583 }
@@ -611,8 +624,8 @@ func newMRShowCmd(env *cliEnv) *cobra.Command {
611624 "whole change is shown and the output says so. --mark-read records the head shown as read; it needs\n" +
612625 "the mr:write scope. The mergeability line says whether the branch merges with its target now: up to\n" +
613626 "date (can be fast-forwarded), behind by N commits (merges without conflicts, squash or merge commit),\n" +
614- "or in conflict with the paths that conflict (rebase needed)." + jsonMRHelp + jsonMergeabilityHelp + jsonCommentHelp + "\n" +
615- "--json adds to the merge request fields: commits (id, author, summary), commits_truncated,\n" +
627+ "or in conflict with the paths that conflict (rebase needed)." + jsonMRHelp + jsonMergeabilityHelp + jsonCommentHelp + jsonIssueEventHelp + "\n" +
628+ "--json adds issues (number, title, state: open/closed/resolved, closes), issue_events and commits (id, author, summary), commits_truncated,\n" +
616629 "diff_base_id (the commit the diff starts from), stats (files_changed, additions, deletions),\n" +
617630 "diff_truncated, files (old_path, new_path, status, additions, deletions, binary, truncated, omitted (generated, large, budget or unavailable; absent when the hunks are\n" +
618631 "not omitted) and, with --diff, hunks of lines with kind, old_number, new_number and text), comments, last_read_id\n" +
@@ -744,7 +757,18 @@ func printMRDetail(w io.Writer, d *ermite.GetMergeRequestResponse, withDiff, sin
744757 _, _ = fmt.Fprintln(w, " (the diff is truncated)")
745758 }
746759
760+ if len(d.GetIssues()) > 0 {
761+ _, _ = fmt.Fprintf(w, "\nissues (%d)\n", len(d.GetIssues()))
762+ for _, i := range d.GetIssues() {
763+ marker := ""
764+ if i.GetCloses() {
765+ marker = " · resolves"
766+ }
767+ _, _ = fmt.Fprintf(w, " #%d %s %s%s\n", i.GetNumber(), mrIssueStateName(i.GetState()), clean(i.GetTitle()), marker)
768+ }
769+ }
747770 _, _ = fmt.Fprintf(w, "\ncomments (%d)\n", len(d.GetComments()))
771+ var timeline []issueTimelineRow
748772 var ordered []*ermite.Comment
749773 for _, root := range d.GetComments() {
750774 if root.GetReplyTo() != "" {
@@ -758,33 +782,40 @@ func printMRDetail(w io.Writer, d *ermite.GetMergeRequestResponse, withDiff, sin
758782 }
759783 }
760784 for _, c := range ordered {
761- where := "general"
762- if c.GetPath() != "" {
763- where = fmt.Sprintf("%s:%d (%s)", clean(c.GetPath()), c.GetLine(), sideName(c.GetSide()))
764- }
765- if c.GetStartLine() > 0 {
766- where = fmt.Sprintf("%s:lines %d-%d (%s)", clean(c.GetPath()), c.GetStartLine(), c.GetLine(), sideName(c.GetSide()))
767- }
768- mark := ""
769- if c.GetEditedAt() != nil {
770- mark += " (edited)"
771- }
772- if c.GetDeleted() {
773- mark += " [deleted]"
774- }
775- if c.GetVerdict() != ermite.Verdict_VERDICT_UNSPECIFIED {
776- mark += " [" + verdictName(c.GetVerdict()) + "]"
777- }
778- if c.GetResolved() {
779- mark += " [resolved]"
780- }
781- prefix := " "
782- body := indentText(c.GetText())
783- if c.GetReplyTo() != "" {
784- prefix += " "
785- body = " " + strings.ReplaceAll(body, "\n", "\n ")
786- }
787- _, _ = fmt.Fprintf(w, "%s#%s %s, %s, %s%s\n%s\n", prefix, c.GetId(), clean(c.GetAuthor()), shortTime(c.GetCreatedAt(), "-"), where, mark, body)
785+ timeline = append(timeline, issueTimelineRow{at: c.GetCreatedAt(), print: func(w io.Writer) error {
786+ where := "general"
787+ if c.GetPath() != "" {
788+ where = fmt.Sprintf("%s:%d (%s)", clean(c.GetPath()), c.GetLine(), sideName(c.GetSide()))
789+ }
790+ if c.GetStartLine() > 0 {
791+ where = fmt.Sprintf("%s:lines %d-%d (%s)", clean(c.GetPath()), c.GetStartLine(), c.GetLine(), sideName(c.GetSide()))
792+ }
793+ mark := ""
794+ if c.GetEditedAt() != nil {
795+ mark += " (edited)"
796+ }
797+ if c.GetDeleted() {
798+ mark += " [deleted]"
799+ }
800+ if c.GetVerdict() != ermite.Verdict_VERDICT_UNSPECIFIED {
801+ mark += " [" + verdictName(c.GetVerdict()) + "]"
802+ }
803+ if c.GetResolved() {
804+ mark += " [resolved]"
805+ }
806+ prefix := " "
807+ body := indentText(c.GetText())
808+ if c.GetReplyTo() != "" {
809+ prefix += " "
810+ body = " " + strings.ReplaceAll(body, "\n", "\n ")
811+ }
812+ _, err := fmt.Fprintf(w, "%s#%s %s, %s, %s%s\n%s\n", prefix, c.GetId(), clean(c.GetAuthor()), shortTime(c.GetCreatedAt(), "-"), where, mark, body)
813+ return err
814+ }})
815+ }
816+ timeline = append(timeline, newIssueTimeline(d.GetIssueEvents(), true)...)
817+ if err := printIssueTimeline(w, timeline); err != nil {
818+ return err
788819 }
789820 if withDiff {
790821 printDiff(w, d.GetDiff())
@@ -512,6 +512,8 @@ type jsonCommit struct {
512 512
513 // jsonMRDetail is the stable JSON shape of `mr show --json`.513 // jsonMRDetail is the stable JSON shape of `mr show --json`.
514 type jsonMRDetail struct {514 type jsonMRDetail struct {
515+ Issues []jsonMRIssue `json:"issues"`
516+ IssueEvents []jsonIssueEvent `json:"issue_events"`
515 jsonMergeRequest517 jsonMergeRequest
516 // Mergeability is null unless the request is open and the server computes it.518 // Mergeability is null unless the request is open and the server computes it.
517 Mergeability *jsonMergeability `json:"mergeability"`519 Mergeability *jsonMergeability `json:"mergeability"`
@@ -527,6 +529,13 @@ type jsonMRDetail struct {
527 LastReadLost bool `json:"last_read_lost"`529 LastReadLost bool `json:"last_read_lost"`
528 }530 }
529 531
532+type jsonMRIssue struct {
533+ Number int64 `json:"number"`
534+ Title string `json:"title"`
535+ State string `json:"state"`
536+ Closes bool `json:"closes"`
537+}
538+
530 type jsonAuthorVerdict struct {539 type jsonAuthorVerdict struct {
531 Author string `json:"author"`540 Author string `json:"author"`
532 Verdict string `json:"verdict"`541 Verdict string `json:"verdict"`
@@ -555,8 +564,9 @@ func lineKindName(k ermite.LineKind) string {
555 func toJSONDetail(d *ermite.GetMergeRequestResponse, withDiff bool, baseURL string) jsonMRDetail {564 func toJSONDetail(d *ermite.GetMergeRequestResponse, withDiff bool, baseURL string) jsonMRDetail {
556 out := jsonMRDetail{565 out := jsonMRDetail{
557 jsonMergeRequest: toJSONMergeRequest(d.GetMergeRequest(), baseURL),566 jsonMergeRequest: toJSONMergeRequest(d.GetMergeRequest(), baseURL),
558- Mergeability: toJSONMergeability(d.GetMergeRequest().GetMergeability()),567+ Issues: []jsonMRIssue{}, IssueEvents: toJSONIssueEvents(d.GetIssueEvents()),
559- Commits: []jsonCommit{}, CommitsTruncated: d.GetCommitsTruncated(),568+ Mergeability: toJSONMergeability(d.GetMergeRequest().GetMergeability()),
569+ Commits: []jsonCommit{}, CommitsTruncated: d.GetCommitsTruncated(),
560 DiffBaseID: d.GetDiffBaseId(),570 DiffBaseID: d.GetDiffBaseId(),
561 Stats: jsonStats{571 Stats: jsonStats{
562 FilesChanged: d.GetStats().GetFilesChanged(), Additions: d.GetStats().GetAdditions(), Deletions: d.GetStats().GetDeletions(),572 FilesChanged: d.GetStats().GetFilesChanged(), Additions: d.GetStats().GetAdditions(), Deletions: d.GetStats().GetDeletions(),
@@ -565,6 +575,9 @@ func toJSONDetail(d *ermite.GetMergeRequestResponse, withDiff bool, baseURL stri
565 Files: []jsonFile{}, Comments: []jsonComment{}, Verdicts: []jsonAuthorVerdict{},575 Files: []jsonFile{}, Comments: []jsonComment{}, Verdicts: []jsonAuthorVerdict{},
566 LastReadID: d.GetLastReadId(), LastReadLost: d.GetLastReadLost(),576 LastReadID: d.GetLastReadId(), LastReadLost: d.GetLastReadLost(),
567 }577 }
578+ for _, i := range d.GetIssues() {
579+ out.Issues = append(out.Issues, jsonMRIssue{Number: i.GetNumber(), Title: i.GetTitle(), State: mrIssueStateName(i.GetState()), Closes: i.GetCloses()})
580+ }
568 for _, c := range d.GetCommits() {581 for _, c := range d.GetCommits() {
569 out.Commits = append(out.Commits, jsonCommit{ID: c.GetId(), Author: c.GetAuthor().GetName(), Summary: c.GetSummary()})582 out.Commits = append(out.Commits, jsonCommit{ID: c.GetId(), Author: c.GetAuthor().GetName(), Summary: c.GetSummary()})
570 }583 }
@@ -611,8 +624,8 @@ func newMRShowCmd(env *cliEnv) *cobra.Command {
611 "whole change is shown and the output says so. --mark-read records the head shown as read; it needs\n" +624 "whole change is shown and the output says so. --mark-read records the head shown as read; it needs\n" +
612 "the mr:write scope. The mergeability line says whether the branch merges with its target now: up to\n" +625 "the mr:write scope. The mergeability line says whether the branch merges with its target now: up to\n" +
613 "date (can be fast-forwarded), behind by N commits (merges without conflicts, squash or merge commit),\n" +626 "date (can be fast-forwarded), behind by N commits (merges without conflicts, squash or merge commit),\n" +
614- "or in conflict with the paths that conflict (rebase needed)." + jsonMRHelp + jsonMergeabilityHelp + jsonCommentHelp + "\n" +627+ "or in conflict with the paths that conflict (rebase needed)." + jsonMRHelp + jsonMergeabilityHelp + jsonCommentHelp + jsonIssueEventHelp + "\n" +
615- "--json adds to the merge request fields: commits (id, author, summary), commits_truncated,\n" +628+ "--json adds issues (number, title, state: open/closed/resolved, closes), issue_events and commits (id, author, summary), commits_truncated,\n" +
616 "diff_base_id (the commit the diff starts from), stats (files_changed, additions, deletions),\n" +629 "diff_base_id (the commit the diff starts from), stats (files_changed, additions, deletions),\n" +
617 "diff_truncated, files (old_path, new_path, status, additions, deletions, binary, truncated, omitted (generated, large, budget or unavailable; absent when the hunks are\n" +630 "diff_truncated, files (old_path, new_path, status, additions, deletions, binary, truncated, omitted (generated, large, budget or unavailable; absent when the hunks are\n" +
618 "not omitted) and, with --diff, hunks of lines with kind, old_number, new_number and text), comments, last_read_id\n" +631 "not omitted) and, with --diff, hunks of lines with kind, old_number, new_number and text), comments, last_read_id\n" +
@@ -744,7 +757,18 @@ func printMRDetail(w io.Writer, d *ermite.GetMergeRequestResponse, withDiff, sin
744 _, _ = fmt.Fprintln(w, " (the diff is truncated)")757 _, _ = fmt.Fprintln(w, " (the diff is truncated)")
745 }758 }
746 759
760+ if len(d.GetIssues()) > 0 {
761+ _, _ = fmt.Fprintf(w, "\nissues (%d)\n", len(d.GetIssues()))
762+ for _, i := range d.GetIssues() {
763+ marker := ""
764+ if i.GetCloses() {
765+ marker = " · resolves"
766+ }
767+ _, _ = fmt.Fprintf(w, " #%d %s %s%s\n", i.GetNumber(), mrIssueStateName(i.GetState()), clean(i.GetTitle()), marker)
768+ }
769+ }
747 _, _ = fmt.Fprintf(w, "\ncomments (%d)\n", len(d.GetComments()))770 _, _ = fmt.Fprintf(w, "\ncomments (%d)\n", len(d.GetComments()))
771+ var timeline []issueTimelineRow
748 var ordered []*ermite.Comment772 var ordered []*ermite.Comment
749 for _, root := range d.GetComments() {773 for _, root := range d.GetComments() {
750 if root.GetReplyTo() != "" {774 if root.GetReplyTo() != "" {
@@ -758,33 +782,40 @@ func printMRDetail(w io.Writer, d *ermite.GetMergeRequestResponse, withDiff, sin
758 }782 }
759 }783 }
760 for _, c := range ordered {784 for _, c := range ordered {
761- where := "general"785+ timeline = append(timeline, issueTimelineRow{at: c.GetCreatedAt(), print: func(w io.Writer) error {
762- if c.GetPath() != "" {786+ where := "general"
763- where = fmt.Sprintf("%s:%d (%s)", clean(c.GetPath()), c.GetLine(), sideName(c.GetSide()))787+ if c.GetPath() != "" {
764- }788+ where = fmt.Sprintf("%s:%d (%s)", clean(c.GetPath()), c.GetLine(), sideName(c.GetSide()))
765- if c.GetStartLine() > 0 {789+ }
766- where = fmt.Sprintf("%s:lines %d-%d (%s)", clean(c.GetPath()), c.GetStartLine(), c.GetLine(), sideName(c.GetSide()))790+ if c.GetStartLine() > 0 {
767- }791+ where = fmt.Sprintf("%s:lines %d-%d (%s)", clean(c.GetPath()), c.GetStartLine(), c.GetLine(), sideName(c.GetSide()))
768- mark := ""792+ }
769- if c.GetEditedAt() != nil {793+ mark := ""
770- mark += " (edited)"794+ if c.GetEditedAt() != nil {
771- }795+ mark += " (edited)"
772- if c.GetDeleted() {796+ }
773- mark += " [deleted]"797+ if c.GetDeleted() {
774- }798+ mark += " [deleted]"
775- if c.GetVerdict() != ermite.Verdict_VERDICT_UNSPECIFIED {799+ }
776- mark += " [" + verdictName(c.GetVerdict()) + "]"800+ if c.GetVerdict() != ermite.Verdict_VERDICT_UNSPECIFIED {
777- }801+ mark += " [" + verdictName(c.GetVerdict()) + "]"
778- if c.GetResolved() {802+ }
779- mark += " [resolved]"803+ if c.GetResolved() {
780- }804+ mark += " [resolved]"
781- prefix := " "805+ }
782- body := indentText(c.GetText())806+ prefix := " "
783- if c.GetReplyTo() != "" {807+ body := indentText(c.GetText())
784- prefix += " "808+ if c.GetReplyTo() != "" {
785- body = " " + strings.ReplaceAll(body, "\n", "\n ")809+ prefix += " "
786- }810+ body = " " + strings.ReplaceAll(body, "\n", "\n ")
787- _, _ = fmt.Fprintf(w, "%s#%s %s, %s, %s%s\n%s\n", prefix, c.GetId(), clean(c.GetAuthor()), shortTime(c.GetCreatedAt(), "-"), where, mark, body)811+ }
812+ _, err := fmt.Fprintf(w, "%s#%s %s, %s, %s%s\n%s\n", prefix, c.GetId(), clean(c.GetAuthor()), shortTime(c.GetCreatedAt(), "-"), where, mark, body)
813+ return err
814+ }})
815+ }
816+ timeline = append(timeline, newIssueTimeline(d.GetIssueEvents(), true)...)
817+ if err := printIssueTimeline(w, timeline); err != nil {
818+ return err
788 }819 }
789 if withDiff {820 if withDiff {
790 printDiff(w, d.GetDiff())821 printDiff(w, d.GetDiff())
M internal/app/forge.go +1 -0
@@ -380,6 +380,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
380380 links := usecase.NewIssueLinks(usecase.IssueLinksConfig{Meta: store, Issues: store.Issues(), MRs: store.MergeRequests(), Links: store.IssueLinks(), Git: git, Bus: f.Bus, Clock: clock})
381381 f.Pushes.WithIssueLinks(links)
382382 f.MergeRequests = usecase.NewMergeRequests(usecase.MergeRequestsConfig{
383+ Issues: store.Issues(), IssueLinks: store.IssueLinks(),
383384 Links: links, Meta: store, Store: store.MergeRequests(), Repos: git, Git: git, Signer: signer,
384385 Committer: committer, Bus: f.Bus, Clock: clock, Maint: f.maint,
385386 CI: f.CI, Checks: f.CI, PullRequests: f.CI, RefTips: store.CI(), Mergeability: f.Mergeability,
@@ -380,6 +380,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
380 links := usecase.NewIssueLinks(usecase.IssueLinksConfig{Meta: store, Issues: store.Issues(), MRs: store.MergeRequests(), Links: store.IssueLinks(), Git: git, Bus: f.Bus, Clock: clock})380 links := usecase.NewIssueLinks(usecase.IssueLinksConfig{Meta: store, Issues: store.Issues(), MRs: store.MergeRequests(), Links: store.IssueLinks(), Git: git, Bus: f.Bus, Clock: clock})
381 f.Pushes.WithIssueLinks(links)381 f.Pushes.WithIssueLinks(links)
382 f.MergeRequests = usecase.NewMergeRequests(usecase.MergeRequestsConfig{382 f.MergeRequests = usecase.NewMergeRequests(usecase.MergeRequestsConfig{
383+ Issues: store.Issues(), IssueLinks: store.IssueLinks(),
383 Links: links, Meta: store, Store: store.MergeRequests(), Repos: git, Git: git, Signer: signer,384 Links: links, Meta: store, Store: store.MergeRequests(), Repos: git, Git: git, Signer: signer,
384 Committer: committer, Bus: f.Bus, Clock: clock, Maint: f.maint,385 Committer: committer, Bus: f.Bus, Clock: clock, Maint: f.maint,
385 CI: f.CI, Checks: f.CI, PullRequests: f.CI, RefTips: store.CI(), Mergeability: f.Mergeability,386 CI: f.CI, Checks: f.CI, PullRequests: f.CI, RefTips: store.CI(), Mergeability: f.Mergeability,
M internal/app/issue_api_test.go +59 -1
@@ -74,6 +74,10 @@ func TestIssueAccessMatrixOverTheAPI(t *testing.T) {
7474 _, err := c.issue.AddIssueComment(ctx, connect.NewRequest(&pb.AddIssueCommentRequest{Repo: repo, Number: 1, Text: "x"}))
7575 return err
7676 },
77+ "ResolveIssue": func(c *clients) error {
78+ _, err := c.issue.ResolveIssue(ctx, connect.NewRequest(&pb.ResolveIssueRequest{Repo: repo, Number: 1}))
79+ return err
80+ },
7781 "CloseIssue": func(c *clients) error {
7882 _, err := c.issue.CloseIssue(ctx, connect.NewRequest(&pb.CloseIssueRequest{Repo: repo, Number: 1}))
7983 return err
@@ -204,6 +208,7 @@ func TestIssuesFeatureAndSettingOverTheAPI(t *testing.T) {
204208 ver, err := r.anon.server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{}))
205209 require.NoError(t, err)
206210 assert.Contains(t, ver.Msg.GetFeatures(), "issues")
211+ assert.Contains(t, ver.Msg.GetFeatures(), "issues.resolve")
207212
208213 update := func(c *clients, mode pb.IssuesMode) (*pb.Repo, error) {
209214 res, err := c.repo.UpdateRepo(ctx, connect.NewRequest(&pb.UpdateRepoRequest{
@@ -276,13 +281,41 @@ func TestIssueReferencesAndClosedByOverTheAPI(t *testing.T) {
276281 assert.False(t, two.GetReferences()[0].GetCloses())
277282 assert.Zero(t, one.GetIssue().GetClosedByMergeRequest())
278283
284+ watch := r.watch(r.anon, "", "repo:proj")
279285 _, err = r.merger.mr.MergeMergeRequest(ctx, connect.NewRequest(&pb.MergeMergeRequestRequest{Repo: "proj", Number: number, HeadId: r.head}))
280286 require.NoError(t, err)
281287 r.forge.MergeRequests.Wait(ctx)
288+ var streamed *pb.IssueResolved
289+ for range 8 {
290+ event := watch.next(t, watchWait)
291+ if streamed = event.GetIssueResolved(); streamed != nil {
292+ break
293+ }
294+ }
295+ require.NotNil(t, streamed, "merge publishes issue_resolved")
296+ assert.EqualValues(t, 1, streamed.GetIssue().GetNumber())
297+ assert.Equal(t, pb.IssueState_ISSUE_STATE_RESOLVED, streamed.GetIssue().GetState())
282298
283299 one = get(1)
284- assert.Equal(t, pb.IssueState_ISSUE_STATE_CLOSED, one.GetIssue().GetState())
300+ assert.Equal(t, pb.IssueState_ISSUE_STATE_RESOLVED, one.GetIssue().GetState())
285301 assert.Equal(t, number, one.GetIssue().GetClosedByMergeRequest())
302+ require.Len(t, one.GetEvents(), 2)
303+ referenced, resolved := one.GetEvents()[0], one.GetEvents()[1]
304+ assert.Equal(t, pb.IssueEventKind_ISSUE_EVENT_KIND_REFERENCED, referenced.GetKind())
305+ assert.True(t, referenced.GetCloses())
306+ assert.Equal(t, "Change a", referenced.GetMergeRequestTitle())
307+ assert.Equal(t, pb.IssueEventKind_ISSUE_EVENT_KIND_RESOLVED, resolved.GetKind())
308+ assert.Equal(t, number, resolved.GetMergeRequest())
309+ assert.NotEmpty(t, resolved.GetActor())
310+ assert.Equal(t, referenced.GetActor(), resolved.GetActor(), "same token opened and merged")
311+ detail, err := r.anon.mr.GetMergeRequest(ctx, connect.NewRequest(&pb.GetMergeRequestRequest{Repo: "proj", Number: number}))
312+ require.NoError(t, err)
313+ require.Len(t, detail.Msg.GetIssues(), 2)
314+ assert.EqualValues(t, 1, detail.Msg.GetIssues()[0].GetNumber())
315+ assert.Equal(t, pb.MergeRequestIssueState_MERGE_REQUEST_ISSUE_STATE_RESOLVED, detail.Msg.GetIssues()[0].GetState())
316+ assert.True(t, detail.Msg.GetIssues()[0].GetCloses())
317+ require.Len(t, detail.Msg.GetIssueEvents(), 3)
318+
286319 require.Len(t, one.GetReferences(), 1)
287320 assert.Equal(t, pb.MergeRequestState_MERGE_REQUEST_STATE_MERGED, one.GetReferences()[0].GetState())
288321 two = get(2)
@@ -294,3 +327,28 @@ func TestIssueReferencesAndClosedByOverTheAPI(t *testing.T) {
294327 require.NoError(t, err)
295328 assert.Zero(t, reopened.Msg.GetIssue().GetClosedByMergeRequest())
296329 }
330+
331+func TestIssueResolveRoundTripOverTheAPI(t *testing.T) {
332+ r := newIssueRig(t)
333+ r.mode("pub", domain.IssuesOwner)
334+ _, err := r.root.issue.CreateIssue(r.ctx, connect.NewRequest(&pb.CreateIssueRequest{Repo: "pub", Title: "Fix"}))
335+ require.NoError(t, err)
336+ resolved, err := r.root.issue.ResolveIssue(r.ctx, connect.NewRequest(&pb.ResolveIssueRequest{Repo: "pub", Number: 1}))
337+ require.NoError(t, err)
338+ assert.Equal(t, pb.IssueState_ISSUE_STATE_RESOLVED, resolved.Msg.GetIssue().GetState())
339+ assert.Zero(t, resolved.Msg.GetIssue().GetClosedByMergeRequest())
340+ _, err = r.root.issue.ResolveIssue(r.ctx, connect.NewRequest(&pb.ResolveIssueRequest{Repo: "pub", Number: 1}))
341+ assert.Equal(t, "failed_precondition", codeName(err))
342+ list, err := r.anon.issue.ListIssues(r.ctx, connect.NewRequest(&pb.ListIssuesRequest{Repo: "pub", State: pb.IssueState_ISSUE_STATE_RESOLVED}))
343+ require.NoError(t, err)
344+ require.Len(t, list.Msg.GetIssues(), 1)
345+ reopened, err := r.root.issue.ReopenIssue(r.ctx, connect.NewRequest(&pb.ReopenIssueRequest{Repo: "pub", Number: 1}))
346+ require.NoError(t, err)
347+ assert.Equal(t, pb.IssueState_ISSUE_STATE_OPEN, reopened.Msg.GetIssue().GetState())
348+ d, err := r.anon.issue.GetIssue(r.ctx, connect.NewRequest(&pb.GetIssueRequest{Repo: "pub", Number: 1}))
349+ require.NoError(t, err)
350+ require.Len(t, d.Msg.GetEvents(), 2)
351+ assert.Equal(t, pb.IssueEventKind_ISSUE_EVENT_KIND_RESOLVED, d.Msg.GetEvents()[0].GetKind())
352+ assert.Equal(t, "root", d.Msg.GetEvents()[0].GetActor())
353+ assert.Equal(t, pb.IssueEventKind_ISSUE_EVENT_KIND_REOPENED, d.Msg.GetEvents()[1].GetKind())
354+}
@@ -74,6 +74,10 @@ func TestIssueAccessMatrixOverTheAPI(t *testing.T) {
74 _, err := c.issue.AddIssueComment(ctx, connect.NewRequest(&pb.AddIssueCommentRequest{Repo: repo, Number: 1, Text: "x"}))74 _, err := c.issue.AddIssueComment(ctx, connect.NewRequest(&pb.AddIssueCommentRequest{Repo: repo, Number: 1, Text: "x"}))
75 return err75 return err
76 },76 },
77+ "ResolveIssue": func(c *clients) error {
78+ _, err := c.issue.ResolveIssue(ctx, connect.NewRequest(&pb.ResolveIssueRequest{Repo: repo, Number: 1}))
79+ return err
80+ },
77 "CloseIssue": func(c *clients) error {81 "CloseIssue": func(c *clients) error {
78 _, err := c.issue.CloseIssue(ctx, connect.NewRequest(&pb.CloseIssueRequest{Repo: repo, Number: 1}))82 _, err := c.issue.CloseIssue(ctx, connect.NewRequest(&pb.CloseIssueRequest{Repo: repo, Number: 1}))
79 return err83 return err
@@ -204,6 +208,7 @@ func TestIssuesFeatureAndSettingOverTheAPI(t *testing.T) {
204 ver, err := r.anon.server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{}))208 ver, err := r.anon.server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{}))
205 require.NoError(t, err)209 require.NoError(t, err)
206 assert.Contains(t, ver.Msg.GetFeatures(), "issues")210 assert.Contains(t, ver.Msg.GetFeatures(), "issues")
211+ assert.Contains(t, ver.Msg.GetFeatures(), "issues.resolve")
207 212
208 update := func(c *clients, mode pb.IssuesMode) (*pb.Repo, error) {213 update := func(c *clients, mode pb.IssuesMode) (*pb.Repo, error) {
209 res, err := c.repo.UpdateRepo(ctx, connect.NewRequest(&pb.UpdateRepoRequest{214 res, err := c.repo.UpdateRepo(ctx, connect.NewRequest(&pb.UpdateRepoRequest{
@@ -276,13 +281,41 @@ func TestIssueReferencesAndClosedByOverTheAPI(t *testing.T) {
276 assert.False(t, two.GetReferences()[0].GetCloses())281 assert.False(t, two.GetReferences()[0].GetCloses())
277 assert.Zero(t, one.GetIssue().GetClosedByMergeRequest())282 assert.Zero(t, one.GetIssue().GetClosedByMergeRequest())
278 283
284+ watch := r.watch(r.anon, "", "repo:proj")
279 _, err = r.merger.mr.MergeMergeRequest(ctx, connect.NewRequest(&pb.MergeMergeRequestRequest{Repo: "proj", Number: number, HeadId: r.head}))285 _, err = r.merger.mr.MergeMergeRequest(ctx, connect.NewRequest(&pb.MergeMergeRequestRequest{Repo: "proj", Number: number, HeadId: r.head}))
280 require.NoError(t, err)286 require.NoError(t, err)
281 r.forge.MergeRequests.Wait(ctx)287 r.forge.MergeRequests.Wait(ctx)
288+ var streamed *pb.IssueResolved
289+ for range 8 {
290+ event := watch.next(t, watchWait)
291+ if streamed = event.GetIssueResolved(); streamed != nil {
292+ break
293+ }
294+ }
295+ require.NotNil(t, streamed, "merge publishes issue_resolved")
296+ assert.EqualValues(t, 1, streamed.GetIssue().GetNumber())
297+ assert.Equal(t, pb.IssueState_ISSUE_STATE_RESOLVED, streamed.GetIssue().GetState())
282 298
283 one = get(1)299 one = get(1)
284- assert.Equal(t, pb.IssueState_ISSUE_STATE_CLOSED, one.GetIssue().GetState())300+ assert.Equal(t, pb.IssueState_ISSUE_STATE_RESOLVED, one.GetIssue().GetState())
285 assert.Equal(t, number, one.GetIssue().GetClosedByMergeRequest())301 assert.Equal(t, number, one.GetIssue().GetClosedByMergeRequest())
302+ require.Len(t, one.GetEvents(), 2)
303+ referenced, resolved := one.GetEvents()[0], one.GetEvents()[1]
304+ assert.Equal(t, pb.IssueEventKind_ISSUE_EVENT_KIND_REFERENCED, referenced.GetKind())
305+ assert.True(t, referenced.GetCloses())
306+ assert.Equal(t, "Change a", referenced.GetMergeRequestTitle())
307+ assert.Equal(t, pb.IssueEventKind_ISSUE_EVENT_KIND_RESOLVED, resolved.GetKind())
308+ assert.Equal(t, number, resolved.GetMergeRequest())
309+ assert.NotEmpty(t, resolved.GetActor())
310+ assert.Equal(t, referenced.GetActor(), resolved.GetActor(), "same token opened and merged")
311+ detail, err := r.anon.mr.GetMergeRequest(ctx, connect.NewRequest(&pb.GetMergeRequestRequest{Repo: "proj", Number: number}))
312+ require.NoError(t, err)
313+ require.Len(t, detail.Msg.GetIssues(), 2)
314+ assert.EqualValues(t, 1, detail.Msg.GetIssues()[0].GetNumber())
315+ assert.Equal(t, pb.MergeRequestIssueState_MERGE_REQUEST_ISSUE_STATE_RESOLVED, detail.Msg.GetIssues()[0].GetState())
316+ assert.True(t, detail.Msg.GetIssues()[0].GetCloses())
317+ require.Len(t, detail.Msg.GetIssueEvents(), 3)
318+
286 require.Len(t, one.GetReferences(), 1)319 require.Len(t, one.GetReferences(), 1)
287 assert.Equal(t, pb.MergeRequestState_MERGE_REQUEST_STATE_MERGED, one.GetReferences()[0].GetState())320 assert.Equal(t, pb.MergeRequestState_MERGE_REQUEST_STATE_MERGED, one.GetReferences()[0].GetState())
288 two = get(2)321 two = get(2)
@@ -294,3 +327,28 @@ func TestIssueReferencesAndClosedByOverTheAPI(t *testing.T) {
294 require.NoError(t, err)327 require.NoError(t, err)
295 assert.Zero(t, reopened.Msg.GetIssue().GetClosedByMergeRequest())328 assert.Zero(t, reopened.Msg.GetIssue().GetClosedByMergeRequest())
296 }329 }
330+
331+func TestIssueResolveRoundTripOverTheAPI(t *testing.T) {
332+ r := newIssueRig(t)
333+ r.mode("pub", domain.IssuesOwner)
334+ _, err := r.root.issue.CreateIssue(r.ctx, connect.NewRequest(&pb.CreateIssueRequest{Repo: "pub", Title: "Fix"}))
335+ require.NoError(t, err)
336+ resolved, err := r.root.issue.ResolveIssue(r.ctx, connect.NewRequest(&pb.ResolveIssueRequest{Repo: "pub", Number: 1}))
337+ require.NoError(t, err)
338+ assert.Equal(t, pb.IssueState_ISSUE_STATE_RESOLVED, resolved.Msg.GetIssue().GetState())
339+ assert.Zero(t, resolved.Msg.GetIssue().GetClosedByMergeRequest())
340+ _, err = r.root.issue.ResolveIssue(r.ctx, connect.NewRequest(&pb.ResolveIssueRequest{Repo: "pub", Number: 1}))
341+ assert.Equal(t, "failed_precondition", codeName(err))
342+ list, err := r.anon.issue.ListIssues(r.ctx, connect.NewRequest(&pb.ListIssuesRequest{Repo: "pub", State: pb.IssueState_ISSUE_STATE_RESOLVED}))
343+ require.NoError(t, err)
344+ require.Len(t, list.Msg.GetIssues(), 1)
345+ reopened, err := r.root.issue.ReopenIssue(r.ctx, connect.NewRequest(&pb.ReopenIssueRequest{Repo: "pub", Number: 1}))
346+ require.NoError(t, err)
347+ assert.Equal(t, pb.IssueState_ISSUE_STATE_OPEN, reopened.Msg.GetIssue().GetState())
348+ d, err := r.anon.issue.GetIssue(r.ctx, connect.NewRequest(&pb.GetIssueRequest{Repo: "pub", Number: 1}))
349+ require.NoError(t, err)
350+ require.Len(t, d.Msg.GetEvents(), 2)
351+ assert.Equal(t, pb.IssueEventKind_ISSUE_EVENT_KIND_RESOLVED, d.Msg.GetEvents()[0].GetKind())
352+ assert.Equal(t, "root", d.Msg.GetEvents()[0].GetActor())
353+ assert.Equal(t, pb.IssueEventKind_ISSUE_EVENT_KIND_REOPENED, d.Msg.GetEvents()[1].GetKind())
354+}
M skills/ermite/CLI.md +14 -5
@@ -43,7 +43,7 @@ Scopes:
4343 | `mr:review` | give a verdict (`--approve`, `--request-changes`) |
4444 | `mr:merge` | merge, and delete the merged source branch with `--delete-branch` |
4545 | `mr:force-merge` | bypass failing or unfinished checks with `--ignore-checks` |
46-| `issue:write` | open, comment on, close and reopen issues |
46+| `issue:write` | open, comment on, close, resolve and reopen issues |
4747 | `release:write` | create, edit and delete releases and their assets; included in `cli`; refused for branch-limited tokens |
4848 | `token:admin` | create, list and revoke tokens |
4949 | `ci:write`, `secrets:write`, `runner:register` | reserved for CI |
@@ -87,12 +87,21 @@ Issues share the numbering of merge requests and must be enabled per repository:
8787 | Command | Scope | Notes |
8888 |---|---|---|
8989 | `issue create <repo> --title T [--body TEXT\|-] [--body-file F\|-]` | `issue:write` | title of 120 characters max |
90-| `issue list <repo> [--state open\|closed\|all] [--json]` | read access (public repository, or `repo:read` on a private one) | default `open`; `issue:write` alone does not grant reading |
91-| `issue show <repo> <n> [--json]` | read access (public repository, or `repo:read` on a private one) | issue and comments; `issue:write` alone does not grant reading |
90+| `issue list <repo> [--state open\|closed\|resolved\|all] [--json]` | read access (public repository, or `repo:read` on a private one) | default `open`; `issue:write` alone does not grant reading |
91+| `issue show <repo> <n> [--json]` | read access (public repository, or `repo:read` on a private one) | issue, comments and history; `issue:write` alone does not grant reading |
9292 | `issue comment <repo> <n> -m TEXT\|-` | `issue:write` | |
93-| `issue close <repo> <n>` / `issue reopen <repo> <n>` | `issue:write` | |
93+| `issue close <repo> <n>` / `issue resolve <repo> <n>` / `issue reopen <repo> <n>` | `issue:write` | |
9494
95-Issue JSON: `repo`, `number`, `title`, `body`, `state`, `author` (token label), `created_at`, `updated_at`, `comment_count`. `issue show --json` adds `comments` (`id`, `author`, `text`, `created_at`). `repo show --json` has `issues`.
95+Issue JSON: `repo`, `number`, `title`, `body`, `state`, `author` (token label), `created_at`, `updated_at`, `comment_count`, `closed_by_merge_request` (0 for none). `issue show --json` adds `comments` (`id`, `author`, `text`, `created_at`), `references`, and `events` (`id`, `kind`, `issue`, `merge_request`, `merge_request_title`, `closes`, `actor`, `created_at`). `repo show --json` has `issues`.
96+
97+
98+Issues have three states: `open`, `closed` (not resolved), and `resolved` (fixed).
99+`issue resolve` needs the `issues.resolve` server feature; `issue reopen` opens a closed or resolved issue.
100+Merging an MR with `Fixes #N` resolves the open issue. `issue show` prints `resolved by !N`
101+and timestamped reference/close/reopen/resolve history interleaved with comments.
102+`mr show` lists linked issues with their state and a `resolves` marker, plus history rows;
103+its JSON adds `issues` (number, title, state, closes) and `issue_events` (the same event fields).
104+History remains when a reference is removed; MR history can name issues absent from its current links.
96105
97106 ## Releases
98107
@@ -43,7 +43,7 @@ Scopes:
43 | `mr:review` | give a verdict (`--approve`, `--request-changes`) |43 | `mr:review` | give a verdict (`--approve`, `--request-changes`) |
44 | `mr:merge` | merge, and delete the merged source branch with `--delete-branch` |44 | `mr:merge` | merge, and delete the merged source branch with `--delete-branch` |
45 | `mr:force-merge` | bypass failing or unfinished checks with `--ignore-checks` |45 | `mr:force-merge` | bypass failing or unfinished checks with `--ignore-checks` |
46-| `issue:write` | open, comment on, close and reopen issues |46+| `issue:write` | open, comment on, close, resolve and reopen issues |
47 | `release:write` | create, edit and delete releases and their assets; included in `cli`; refused for branch-limited tokens |47 | `release:write` | create, edit and delete releases and their assets; included in `cli`; refused for branch-limited tokens |
48 | `token:admin` | create, list and revoke tokens |48 | `token:admin` | create, list and revoke tokens |
49 | `ci:write`, `secrets:write`, `runner:register` | reserved for CI |49 | `ci:write`, `secrets:write`, `runner:register` | reserved for CI |
@@ -87,12 +87,21 @@ Issues share the numbering of merge requests and must be enabled per repository:
87 | Command | Scope | Notes |87 | Command | Scope | Notes |
88 |---|---|---|88 |---|---|---|
89 | `issue create <repo> --title T [--body TEXT\|-] [--body-file F\|-]` | `issue:write` | title of 120 characters max |89 | `issue create <repo> --title T [--body TEXT\|-] [--body-file F\|-]` | `issue:write` | title of 120 characters max |
90-| `issue list <repo> [--state open\|closed\|all] [--json]` | read access (public repository, or `repo:read` on a private one) | default `open`; `issue:write` alone does not grant reading |90+| `issue list <repo> [--state open\|closed\|resolved\|all] [--json]` | read access (public repository, or `repo:read` on a private one) | default `open`; `issue:write` alone does not grant reading |
91-| `issue show <repo> <n> [--json]` | read access (public repository, or `repo:read` on a private one) | issue and comments; `issue:write` alone does not grant reading |91+| `issue show <repo> <n> [--json]` | read access (public repository, or `repo:read` on a private one) | issue, comments and history; `issue:write` alone does not grant reading |
92 | `issue comment <repo> <n> -m TEXT\|-` | `issue:write` | |92 | `issue comment <repo> <n> -m TEXT\|-` | `issue:write` | |
93-| `issue close <repo> <n>` / `issue reopen <repo> <n>` | `issue:write` | |93+| `issue close <repo> <n>` / `issue resolve <repo> <n>` / `issue reopen <repo> <n>` | `issue:write` | |
94 94
95-Issue JSON: `repo`, `number`, `title`, `body`, `state`, `author` (token label), `created_at`, `updated_at`, `comment_count`. `issue show --json` adds `comments` (`id`, `author`, `text`, `created_at`). `repo show --json` has `issues`.95+Issue JSON: `repo`, `number`, `title`, `body`, `state`, `author` (token label), `created_at`, `updated_at`, `comment_count`, `closed_by_merge_request` (0 for none). `issue show --json` adds `comments` (`id`, `author`, `text`, `created_at`), `references`, and `events` (`id`, `kind`, `issue`, `merge_request`, `merge_request_title`, `closes`, `actor`, `created_at`). `repo show --json` has `issues`.
96+
97+
98+Issues have three states: `open`, `closed` (not resolved), and `resolved` (fixed).
99+`issue resolve` needs the `issues.resolve` server feature; `issue reopen` opens a closed or resolved issue.
100+Merging an MR with `Fixes #N` resolves the open issue. `issue show` prints `resolved by !N`
101+and timestamped reference/close/reopen/resolve history interleaved with comments.
102+`mr show` lists linked issues with their state and a `resolves` marker, plus history rows;
103+its JSON adds `issues` (number, title, state, closes) and `issue_events` (the same event fields).
104+History remains when a reference is removed; MR history can name issues absent from its current links.
96 105
97 ## Releases106 ## Releases
98 107
M skills/ermite/SKILL.md +12 -0
@@ -59,6 +59,18 @@ merge request web URL on its own line. With `--json`, merge request objects
5959
6060 Open one: `ermite mr create <repo> --source <branch> --title "..."`. After new pushes, `mr show --since-last-read` shows only what changed since `--mark-read`.
6161
62+## Issues
63+
64+Enable issues with `ermite repo edit <repo> --issues owner` (or `public`).
65+Use `issue create <repo> --title "..."`, `issue list <repo> --state open|closed|resolved|all`,
66+and `issue show <repo> <n> --json`. `issue close` means closed without resolution;
67+`issue resolve <repo> <n>` marks an open issue fixed (needs `issues.resolve`), and
68+`issue reopen <repo> <n>` reopens a closed or resolved issue. Writes need `issue:write`.
69+Merging an MR with `Fixes #N` resolves the open issue. Issue detail shows `resolved by !N`
70+and timestamped history with actors alongside comments; JSON includes `events` and `references`.
71+`mr show` lists linked issues, their states and the resolves marker, with history rows;
72+JSON includes `issues` and `issue_events`. Removed references retain their history.
73+
6274 ## Tokens
6375
6476 ```sh
@@ -59,6 +59,18 @@ merge request web URL on its own line. With `--json`, merge request objects
59 59
60 Open one: `ermite mr create <repo> --source <branch> --title "..."`. After new pushes, `mr show --since-last-read` shows only what changed since `--mark-read`.60 Open one: `ermite mr create <repo> --source <branch> --title "..."`. After new pushes, `mr show --since-last-read` shows only what changed since `--mark-read`.
61 61
62+## Issues
63+
64+Enable issues with `ermite repo edit <repo> --issues owner` (or `public`).
65+Use `issue create <repo> --title "..."`, `issue list <repo> --state open|closed|resolved|all`,
66+and `issue show <repo> <n> --json`. `issue close` means closed without resolution;
67+`issue resolve <repo> <n>` marks an open issue fixed (needs `issues.resolve`), and
68+`issue reopen <repo> <n>` reopens a closed or resolved issue. Writes need `issue:write`.
69+Merging an MR with `Fixes #N` resolves the open issue. Issue detail shows `resolved by !N`
70+and timestamped history with actors alongside comments; JSON includes `events` and `references`.
71+`mr show` lists linked issues, their states and the resolves marker, with history rows;
72+JSON includes `issues` and `issue_events`. Removed references retain their history.
73+
62 ## Tokens74 ## Tokens
63 75
64 ```sh76 ```sh

Keyboard shortcuts

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

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

Branches 4

Tags 45

↑↓ moveenter openesc close