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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 | |||
| 60 | 60 | ermite mr ready <repo> <n> # removes WIP:/[WIP], needs mr:write | |
| 61 | 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] | |
| 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 | |
| 66 | 67 | ||
| 67 | 68 | ermite run list <repo> [--workflow …] [--branch …] [--status …] | |
| 68 | 69 | ermite run view <repo> <id> [--attempt n] [--log [--job …]] [--summary] | |
| @@ -92,7 +93,7 @@ ermite version # client and server versions | |||
| 92 | 93 | 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 | 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 | 145 | ## 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 | 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 | 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 | 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`. | |
| @@ -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:write | 60 | 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 references | 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 | |||
| 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|fish | 93 | 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 | ## Output | 145 | ## 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 | |||
| 35 | 35 | // always implements, as GetVersion lists them. FeaturePasskeys comes on top | |
| 36 | 36 | // when Config.Passkeys is set, and FeatureCISecrets when the settings services | |
| 37 | 37 | // are, and FeatureCommitSignatures when Config.TrustedKeys is set. | |
| 38 | - | var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "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 | 40 | // FeaturePasskeys is listed by GetVersion when passkeys are available. | |
| 41 | 41 | // 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 top | 35 | // always implements, as GetVersion lists them. FeaturePasskeys comes on top | ||
| 36 | // when Config.Passkeys is set, and FeatureCISecrets when the settings services | 36 | // when Config.Passkeys is set, and FeatureCISecrets when the settings services | ||
| 37 | // are, and FeatureCommitSignatures when Config.TrustedKeys is set. | 37 | // are, and FeatureCommitSignatures when Config.TrustedKeys is set. | ||
| 38 | - | var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "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 { | |||
| 501 | 501 | case domain.IssueOpened: | |
| 502 | 502 | out.Time = timestamp(ev.At) | |
| 503 | 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 | 507 | case domain.IssueClosed: | |
| 505 | 508 | out.Time = timestamp(ev.At) | |
| 506 | 509 | 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) { | |||
| 416 | 416 | assert.True(t, diffToPB(domain.Diff{Truncated: true}).GetTruncated()) | |
| 417 | 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 | + | } | |
| @@ -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 { | |||
| 24 | 24 | switch s { | |
| 25 | 25 | case domain.IssueStateOpen: | |
| 26 | 26 | return pb.IssueState_ISSUE_STATE_OPEN | |
| 27 | + | case domain.IssueStateResolved: | |
| 28 | + | return pb.IssueState_ISSUE_STATE_RESOLVED | |
| 27 | 29 | case domain.IssueStateClosed: | |
| 28 | 30 | return pb.IssueState_ISSUE_STATE_CLOSED | |
| 29 | 31 | } | |
| @@ -38,6 +40,8 @@ func issueStateFromPB(s pb.IssueState) (domain.IssueState, error) { | |||
| 38 | 40 | return "", nil | |
| 39 | 41 | case pb.IssueState_ISSUE_STATE_OPEN: | |
| 40 | 42 | return domain.IssueStateOpen, nil | |
| 43 | + | case pb.IssueState_ISSUE_STATE_RESOLVED: | |
| 44 | + | return domain.IssueStateResolved, nil | |
| 41 | 45 | case pb.IssueState_ISSUE_STATE_CLOSED: | |
| 42 | 46 | return domain.IssueStateClosed, nil | |
| 43 | 47 | } | |
| @@ -139,6 +143,9 @@ func (s *issueService) GetIssue(ctx context.Context, req *connect.Request[pb.Get | |||
| 139 | 143 | for _, c := range d.Comments { | |
| 140 | 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 | 149 | out.References = make([]*pb.IssueReference, 0, len(d.References)) | |
| 143 | 150 | for _, r := range d.References { | |
| 144 | 151 | out.References = append(out.References, &pb.IssueReference{ | |
| @@ -271,7 +278,44 @@ func (s *issueService) RejectIssue(ctx context.Context, req *connect.Request[pb. | |||
| 271 | 278 | 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 | 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 | } | |
| @@ -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_OPEN | 26 | 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_CLOSED | 30 | return pb.IssueState_ISSUE_STATE_CLOSED | ||
| 29 | } | 31 | } | ||
| @@ -38,6 +40,8 @@ func issueStateFromPB(s pb.IssueState) (domain.IssueState, error) { | |||||
| 38 | return "", nil | 40 | return "", nil | ||
| 39 | case pb.IssueState_ISSUE_STATE_OPEN: | 41 | case pb.IssueState_ISSUE_STATE_OPEN: | ||
| 40 | return domain.IssueStateOpen, nil | 42 | 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, nil | 46 | 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{}), nil | 278 | 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. | |||
| 318 | 318 | LastReadId: d.LastReadID.String(), | |
| 319 | 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 | 327 | for _, c := range d.Commits { | |
| 322 | 328 | out.Commits = append(out.Commits, commitToPB(c)) | |
| 323 | 329 | } | |
| @@ -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) { | |||
| 196 | 196 | for _, who := range allPrincipals { | |
| 197 | 197 | resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{})) | |
| 198 | 198 | require.NoError(t, err, who) | |
| 199 | - | assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "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 | 200 | assert.NotEmpty(t, resp.Msg.GetVersion()) | |
| 201 | 201 | } | |
| 202 | 202 | }) | |
| @@ -196,7 +196,7 @@ func TestAC2ReadAccess(t *testing.T) { | |||||
| 196 | for _, who := range allPrincipals { | 196 | for _, who := range allPrincipals { | ||
| 197 | resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{})) | 197 | resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{})) | ||
| 198 | require.NoError(t, err, who) | 198 | require.NoError(t, err, who) | ||
| 199 | - | assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "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) { | |||
| 350 | 350 | ver := decode[versionOut](t, res.stdout) | |
| 351 | 351 | assert.Equal(t, c.url, ver.Server.URL) | |
| 352 | 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 | 354 | assert.Equal(t, []string{"passkeys"}, ver.Server.MissingFeatures) | |
| 355 | 355 | stdout, stderr, code = c.bin(t, secret, "version") | |
| 356 | 356 | require.Equal(t, 0, code, stderr) | |
| @@ -529,3 +529,41 @@ func TestAC6CLIWatchOneRepo(t *testing.T) { | |||
| 529 | 529 | assert.Equal(t, "a", ev.Repo) | |
| 530 | 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 | + | } | |
| @@ -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 { | |||
| 68 | 68 | listIssues func(*ermite.ListIssuesRequest) (*ermite.ListIssuesResponse, error) | |
| 69 | 69 | getIssue func(*ermite.GetIssueRequest) (*ermite.GetIssueResponse, error) | |
| 70 | 70 | addIssueCmt func(*ermite.AddIssueCommentRequest) (*ermite.AddIssueCommentResponse, error) | |
| 71 | + | resolveIssue func(*ermite.ResolveIssueRequest) (*ermite.ResolveIssueResponse, error) | |
| 71 | 72 | closeIssue func(*ermite.CloseIssueRequest) (*ermite.CloseIssueResponse, error) | |
| 72 | 73 | reopenIssue func(*ermite.ReopenIssueRequest) (*ermite.ReopenIssueResponse, error) | |
| 73 | 74 | getSigningKey func(*ermite.GetSigningKeyRequest) (*ermite.GetSigningKeyResponse, error) | |
| @@ -178,6 +179,9 @@ func (f *fakeAPI) AddIssueComment(_ context.Context, r *connect.Request[ermite.A | |||
| 178 | 179 | func (f *fakeAPI) CloseIssue(_ context.Context, r *connect.Request[ermite.CloseIssueRequest]) (*connect.Response[ermite.CloseIssueResponse], error) { | |
| 179 | 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 | 185 | func (f *fakeAPI) ReopenIssue(_ context.Context, r *connect.Request[ermite.ReopenIssueRequest]) (*connect.Response[ermite.ReopenIssueResponse], error) { | |
| 182 | 186 | return respond(f, "ReopenIssue", r, f.reopenIssue) | |
| 183 | 187 | } | |
| @@ -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 ( | |||
| 20 | 20 | ) | |
| 21 | 21 | ||
| 22 | 22 | const statePending = "pending" | |
| 23 | + | const stateResolved = "resolved" | |
| 23 | 24 | ||
| 24 | 25 | func issueStateName(s ermite.IssueState) string { | |
| 25 | 26 | switch s { | |
| 26 | 27 | case ermite.IssueState_ISSUE_STATE_OPEN: | |
| 27 | 28 | return stateOpen | |
| 29 | + | case ermite.IssueState_ISSUE_STATE_RESOLVED: | |
| 30 | + | return stateResolved | |
| 28 | 31 | case ermite.IssueState_ISSUE_STATE_CLOSED: | |
| 29 | 32 | return stateClosed | |
| 30 | 33 | case ermite.IssueState_ISSUE_STATE_PENDING: | |
| @@ -38,12 +41,14 @@ func parseIssueState(s string) (ermite.IssueState, error) { | |||
| 38 | 41 | switch strings.ToLower(strings.TrimSpace(s)) { | |
| 39 | 42 | case stateOpen: | |
| 40 | 43 | return ermite.IssueState_ISSUE_STATE_OPEN, nil | |
| 44 | + | case stateResolved: | |
| 45 | + | return ermite.IssueState_ISSUE_STATE_RESOLVED, nil | |
| 41 | 46 | case stateClosed: | |
| 42 | 47 | return ermite.IssueState_ISSUE_STATE_CLOSED, nil | |
| 43 | 48 | case stateAll: | |
| 44 | 49 | 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 | 54 | // jsonIssue is the stable JSON shape of an issue. | |
| @@ -57,7 +62,7 @@ type jsonIssue struct { | |||
| 57 | 62 | CreatedAt *string `json:"created_at"` | |
| 58 | 63 | UpdatedAt *string `json:"updated_at"` | |
| 59 | 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 | 66 | ClosedByMergeRequest int64 `json:"closed_by_merge_request"` | |
| 62 | 67 | } | |
| 63 | 68 | ||
| @@ -87,6 +92,7 @@ type jsonIssueDetail struct { | |||
| 87 | 92 | jsonIssue | |
| 88 | 93 | Comments []jsonIssueComment `json:"comments"` | |
| 89 | 94 | References []jsonIssueReference `json:"references"` | |
| 95 | + | Events []jsonIssueEvent `json:"events"` | |
| 90 | 96 | } | |
| 91 | 97 | ||
| 92 | 98 | // jsonIssueReference is a merge request that names the issue. | |
| @@ -98,16 +104,16 @@ type jsonIssueReference struct { | |||
| 98 | 104 | } | |
| 99 | 105 | ||
| 100 | 106 | 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 | |
| 102 | 108 | (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 | 111 | const jsonIssueCommentHelp = ` | |
| 106 | 112 | JSON fields of an issue comment: id, author, text, created_at.` | |
| 107 | 113 | ||
| 108 | 114 | const jsonIssueReferenceHelp = ` | |
| 109 | 115 | 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 | 118 | func newIssueCmd(env *cliEnv) *cobra.Command { | |
| 113 | 119 | issue := &cobra.Command{ | |
| @@ -120,7 +126,7 @@ func newIssueCmd(env *cliEnv) *cobra.Command { | |||
| 120 | 126 | } | |
| 121 | 127 | issue.AddCommand( | |
| 122 | 128 | newIssueCreateCmd(env), newIssueListCmd(env), newIssueShowCmd(env), newIssueCommentCmd(env), | |
| 123 | - | newIssueCloseCmd(env), newIssueReopenCmd(env), | |
| 129 | + | newIssueCloseCmd(env), newIssueResolveCmd(env), newIssueReopenCmd(env), | |
| 124 | 130 | ) | |
| 125 | 131 | return issue | |
| 126 | 132 | } | |
| @@ -265,14 +271,14 @@ func newIssueListCmd(env *cliEnv) *cobra.Command { | |||
| 265 | 271 | cmd := &cobra.Command{ | |
| 266 | 272 | Use: "list <repo>", | |
| 267 | 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 | 275 | "(default: open; all shows every state)." + jsonIssueHelp + "\n--json prints an array of issues.", | |
| 270 | 276 | Args: cobra.ExactArgs(1), | |
| 271 | 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 | 280 | _ = 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 | |
| 276 | 282 | }) | |
| 277 | 283 | asJSON := addJSONFlag(cmd) | |
| 278 | 284 | cmd.RunE = func(cmd *cobra.Command, args []string) error { | |
| @@ -316,10 +322,10 @@ func newIssueShowCmd(env *cliEnv) *cobra.Command { | |||
| 316 | 322 | Use: "show <repo> <number>", | |
| 317 | 323 | Short: "Show an issue with its comments", | |
| 318 | 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" + | |
| 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.", | |
| 323 | 329 | Args: cobra.ExactArgs(2), | |
| 324 | 330 | ValidArgsFunction: completeIssueRepoThenNumber(env), | |
| 325 | 331 | } | |
| @@ -338,7 +344,7 @@ func newIssueShowCmd(env *cliEnv) *cobra.Command { | |||
| 338 | 344 | return c.Describe(err) | |
| 339 | 345 | } | |
| 340 | 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 | 348 | for _, cm := range res.Msg.GetComments() { | |
| 343 | 349 | out.Comments = append(out.Comments, toJSONIssueComment(cm)) | |
| 344 | 350 | } | |
| @@ -367,7 +373,7 @@ func printIssueDetail(w io.Writer, d *ermite.GetIssueResponse) error { | |||
| 367 | 373 | {"state", issueStateName(i.GetState())}, | |
| 368 | 374 | } | |
| 369 | 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 | 378 | rows = append(rows, | |
| 373 | 379 | [2]string{"author", i.GetAuthor()}, | |
| @@ -388,7 +394,7 @@ func printIssueDetail(w io.Writer, d *ermite.GetIssueResponse) error { | |||
| 388 | 394 | for _, r := range refs { | |
| 389 | 395 | line := fmt.Sprintf(" !%d %-6s %s", r.GetNumber(), stateName(r.GetState()), clean(r.GetTitle())) | |
| 390 | 396 | if r.GetCloses() { | |
| 391 | - | line += " · closes" | |
| 397 | + | line += " · resolves" | |
| 392 | 398 | } | |
| 393 | 399 | if _, err := fmt.Fprintln(w, line); err != nil { | |
| 394 | 400 | return err | |
| @@ -398,10 +404,17 @@ func printIssueDetail(w io.Writer, d *ermite.GetIssueResponse) error { | |||
| 398 | 404 | if _, err := fmt.Fprintf(w, "\ncomments (%d)\n", len(d.GetComments())); err != nil { | |
| 399 | 405 | return err | |
| 400 | 406 | } | |
| 407 | + | var timeline []issueTimelineRow | |
| 401 | 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 | 412 | 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 | 419 | 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 | 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 | 491 | func(ctx context.Context, c *client.Client, repo string, n int64) (*ermite.Issue, error) { | |
| 468 | 492 | res, err := c.Issues().ReopenIssue(ctx, connect.NewRequest(&ermite.ReopenIssueRequest{Repo: repo, Number: n})) | |
| 469 | 493 | 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 stateOpen | 28 | 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 stateClosed | 32 | 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, nil | 43 | 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, nil | 47 | return ermite.IssueState_ISSUE_STATE_CLOSED, nil | ||
| 43 | case stateAll: | 48 | case stateAll: | ||
| 44 | return ermite.IssueState_ISSUE_STATE_UNSPECIFIED, nil | 49 | 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 | jsonIssue | 92 | 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"), author | 107 | + | 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 of | 108 | (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"), closes | 115 | 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 issue | 131 | 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.ShellCompDirectiveNoFileComp | 281 | + | 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 err | 400 | 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 err | 405 | 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 err | 412 | 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 nil | 419 | 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) { | |||
| 266 | 266 | ||
| 267 | 267 | func TestIssueShowMergeRequestLinks(t *testing.T) { | |
| 268 | 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 | 270 | ClosedByMergeRequest: 5, | |
| 271 | 271 | } | |
| 272 | 272 | f := &fakeAPI{ | |
| @@ -280,12 +280,12 @@ func TestIssueShowMergeRequestLinks(t *testing.T) { | |||
| 280 | 280 | } | |
| 281 | 281 | url := f.serve(t) | |
| 282 | 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) | |
| 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") | |
| 285 | 285 | assert.Less(t, strings.Index(res.stdout, "merge requests (2)"), strings.Index(res.stdout, "comments (0)")) | |
| 286 | 286 | ||
| 287 | 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 | 290 | // No references: no block in text, an empty array in JSON. | |
| 291 | 291 | f.getIssue = func(*ermite.GetIssueRequest) (*ermite.GetIssueResponse, error) { | |
| @@ -293,7 +293,43 @@ func TestIssueShowMergeRequestLinks(t *testing.T) { | |||
| 293 | 293 | } | |
| 294 | 294 | res = runCLI(t, envFor(url), "", "issue", "show", "alpha", "2").ok(t) | |
| 295 | 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 | 297 | res = runCLI(t, envFor(url), "", "issue", "show", "alpha", "2", "--json").ok(t) | |
| 298 | 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 | + | } | |
| @@ -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 { | |||
| 512 | 512 | ||
| 513 | 513 | // jsonMRDetail is the stable JSON shape of `mr show --json`. | |
| 514 | 514 | type jsonMRDetail struct { | |
| 515 | + | Issues []jsonMRIssue `json:"issues"` | |
| 516 | + | IssueEvents []jsonIssueEvent `json:"issue_events"` | |
| 515 | 517 | jsonMergeRequest | |
| 516 | 518 | // Mergeability is null unless the request is open and the server computes it. | |
| 517 | 519 | Mergeability *jsonMergeability `json:"mergeability"` | |
| @@ -527,6 +529,13 @@ type jsonMRDetail struct { | |||
| 527 | 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 | 539 | type jsonAuthorVerdict struct { | |
| 531 | 540 | Author string `json:"author"` | |
| 532 | 541 | Verdict string `json:"verdict"` | |
| @@ -555,8 +564,9 @@ func lineKindName(k ermite.LineKind) string { | |||
| 555 | 564 | func toJSONDetail(d *ermite.GetMergeRequestResponse, withDiff bool, baseURL string) jsonMRDetail { | |
| 556 | 565 | out := jsonMRDetail{ | |
| 557 | 566 | 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(), | |
| 560 | 570 | DiffBaseID: d.GetDiffBaseId(), | |
| 561 | 571 | Stats: jsonStats{ | |
| 562 | 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 | 575 | Files: []jsonFile{}, Comments: []jsonComment{}, Verdicts: []jsonAuthorVerdict{}, | |
| 566 | 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 | 581 | for _, c := range d.GetCommits() { | |
| 569 | 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 | 624 | "whole change is shown and the output says so. --mark-read records the head shown as read; it needs\n" + | |
| 612 | 625 | "the mr:write scope. The mergeability line says whether the branch merges with its target now: up to\n" + | |
| 613 | 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" + | |
| 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" + | |
| 616 | 629 | "diff_base_id (the commit the diff starts from), stats (files_changed, additions, deletions),\n" + | |
| 617 | 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 | 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 | 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 | 770 | _, _ = fmt.Fprintf(w, "\ncomments (%d)\n", len(d.GetComments())) | |
| 771 | + | var timeline []issueTimelineRow | |
| 748 | 772 | var ordered []*ermite.Comment | |
| 749 | 773 | for _, root := range d.GetComments() { | |
| 750 | 774 | if root.GetReplyTo() != "" { | |
| @@ -758,33 +782,40 @@ func printMRDetail(w io.Writer, d *ermite.GetMergeRequestResponse, withDiff, sin | |||
| 758 | 782 | } | |
| 759 | 783 | } | |
| 760 | 784 | 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 | |
| 788 | 819 | } | |
| 789 | 820 | if withDiff { | |
| 790 | 821 | 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 | jsonMergeRequest | 517 | 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.Comment | 772 | 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 | |||
| 380 | 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 | 381 | f.Pushes.WithIssueLinks(links) | |
| 382 | 382 | f.MergeRequests = usecase.NewMergeRequests(usecase.MergeRequestsConfig{ | |
| 383 | + | Issues: store.Issues(), IssueLinks: store.IssueLinks(), | |
| 383 | 384 | Links: links, Meta: store, Store: store.MergeRequests(), Repos: git, Git: git, Signer: signer, | |
| 384 | 385 | Committer: committer, Bus: f.Bus, Clock: clock, Maint: f.maint, | |
| 385 | 386 | 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) { | |||
| 74 | 74 | _, err := c.issue.AddIssueComment(ctx, connect.NewRequest(&pb.AddIssueCommentRequest{Repo: repo, Number: 1, Text: "x"})) | |
| 75 | 75 | 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 | 81 | "CloseIssue": func(c *clients) error { | |
| 78 | 82 | _, err := c.issue.CloseIssue(ctx, connect.NewRequest(&pb.CloseIssueRequest{Repo: repo, Number: 1})) | |
| 79 | 83 | return err | |
| @@ -204,6 +208,7 @@ func TestIssuesFeatureAndSettingOverTheAPI(t *testing.T) { | |||
| 204 | 208 | ver, err := r.anon.server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{})) | |
| 205 | 209 | require.NoError(t, err) | |
| 206 | 210 | assert.Contains(t, ver.Msg.GetFeatures(), "issues") | |
| 211 | + | assert.Contains(t, ver.Msg.GetFeatures(), "issues.resolve") | |
| 207 | 212 | ||
| 208 | 213 | update := func(c *clients, mode pb.IssuesMode) (*pb.Repo, error) { | |
| 209 | 214 | res, err := c.repo.UpdateRepo(ctx, connect.NewRequest(&pb.UpdateRepoRequest{ | |
| @@ -276,13 +281,41 @@ func TestIssueReferencesAndClosedByOverTheAPI(t *testing.T) { | |||
| 276 | 281 | assert.False(t, two.GetReferences()[0].GetCloses()) | |
| 277 | 282 | assert.Zero(t, one.GetIssue().GetClosedByMergeRequest()) | |
| 278 | 283 | ||
| 284 | + | watch := r.watch(r.anon, "", "repo:proj") | |
| 279 | 285 | _, err = r.merger.mr.MergeMergeRequest(ctx, connect.NewRequest(&pb.MergeMergeRequestRequest{Repo: "proj", Number: number, HeadId: r.head})) | |
| 280 | 286 | require.NoError(t, err) | |
| 281 | 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 | 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 | 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 | 319 | require.Len(t, one.GetReferences(), 1) | |
| 287 | 320 | assert.Equal(t, pb.MergeRequestState_MERGE_REQUEST_STATE_MERGED, one.GetReferences()[0].GetState()) | |
| 288 | 321 | two = get(2) | |
| @@ -294,3 +327,28 @@ func TestIssueReferencesAndClosedByOverTheAPI(t *testing.T) { | |||
| 294 | 327 | require.NoError(t, err) | |
| 295 | 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 | + | } | |
| @@ -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 err | 75 | 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 err | 83 | 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: | |||
| 43 | 43 | | `mr:review` | give a verdict (`--approve`, `--request-changes`) | | |
| 44 | 44 | | `mr:merge` | merge, and delete the merged source branch with `--delete-branch` | | |
| 45 | 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 | 47 | | `release:write` | create, edit and delete releases and their assets; included in `cli`; refused for branch-limited tokens | | |
| 48 | 48 | | `token:admin` | create, list and revoke tokens | | |
| 49 | 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 | 87 | | Command | Scope | Notes | | |
| 88 | 88 | |---|---|---| | |
| 89 | 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 | | |
| 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 | | |
| 92 | 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 | 106 | ## Releases | |
| 98 | 107 | ||
| @@ -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 | ## Releases | 106 | ## 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 | |||
| 59 | 59 | ||
| 60 | 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 | 74 | ## Tokens | |
| 63 | 75 | ||
| 64 | 76 | ```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 | ## Tokens | 74 | ## Tokens | ||
| 63 | 75 | ||||
| 64 | ```sh | 76 | ```sh | ||