From 7812ce08c1d90fbc2173576cbf51201fdb04eadb Mon Sep 17 00:00:00 2001 From: Peter Schilling Date: Mon, 5 Oct 2026 10:20:35 -0700 Subject: [PATCH 1/5] Rewrite the CLI in Rust --- .cargo/config.toml | 2 + .editorconfig | 3 + .git-blame-ignore-revs | 2 - .gitattributes | 3 +- .github/ISSUE_TEMPLATE/bug-report.yml | 4 +- .github/build-setup.yml | 11 +- .github/workflows/ci.yaml | 64 - .github/workflows/ci.yml | 65 + .github/workflows/publish.yaml | 23 - .github/workflows/release.yml | 8 +- .gitignore | 5 +- .graphqlrc.yaml | 3 - .zed/settings.json | 31 - CONVENTIONS.md | 1 - Cargo.lock | 2815 ++++++++++++++++ Cargo.toml | 16 + clippy.toml | 4 + codegen.ts | 21 - crates/linear-cli/Cargo.toml | 70 + crates/linear-cli/build.rs | 18 + crates/linear-cli/examples/skill_docs.rs | 216 ++ crates/linear-cli/src/app.rs | 154 + crates/linear-cli/src/auth/file.rs | 155 + crates/linear-cli/src/auth/format.rs | 297 ++ crates/linear-cli/src/auth/format/tests.rs | 154 + crates/linear-cli/src/auth/keyring/mod.rs | 105 + crates/linear-cli/src/auth/keyring/process.rs | 230 ++ .../src/auth/keyring/process/tests.rs | 268 ++ .../src/auth/keyring/secret_tool.rs | 87 + .../linear-cli/src/auth/keyring/security.rs | 88 + crates/linear-cli/src/auth/keyring/tests.rs | 29 + crates/linear-cli/src/auth/keyring/windows.rs | 58 + crates/linear-cli/src/auth/mod.rs | 22 + crates/linear-cli/src/auth/mutation.rs | 268 ++ crates/linear-cli/src/auth/mutation/tests.rs | 325 ++ crates/linear-cli/src/auth/path.rs | 78 + crates/linear-cli/src/auth/resolve.rs | 172 + crates/linear-cli/src/auth/resolve/tests.rs | 256 ++ crates/linear-cli/src/auth/test_support.rs | 56 + crates/linear-cli/src/cli/api.rs | 25 + crates/linear-cli/src/cli/auth.rs | 66 + crates/linear-cli/src/cli/completions.rs | 29 + crates/linear-cli/src/cli/config.rs | 13 + crates/linear-cli/src/cli/cycle.rs | 48 + crates/linear-cli/src/cli/document.rs | 227 ++ crates/linear-cli/src/cli/initiative.rs | 255 ++ .../linear-cli/src/cli/initiative_update.rs | 42 + crates/linear-cli/src/cli/issue.rs | 705 ++++ crates/linear-cli/src/cli/label.rs | 72 + crates/linear-cli/src/cli/limit.rs | 78 + crates/linear-cli/src/cli/markdown.rs | 4 + crates/linear-cli/src/cli/markdown.txt | 40 + crates/linear-cli/src/cli/milestone.rs | 116 + crates/linear-cli/src/cli/mod.rs | 317 ++ crates/linear-cli/src/cli/project.rs | 276 ++ crates/linear-cli/src/cli/project_update.rs | 72 + crates/linear-cli/src/cli/schema.rs | 13 + crates/linear-cli/src/cli/team.rs | 115 + crates/linear-cli/src/cli/template.rs | 52 + crates/linear-cli/src/cli/tests.rs | 200 ++ crates/linear-cli/src/cli/user.rs | 31 + crates/linear-cli/src/cli/values.rs | 270 ++ crates/linear-cli/src/client.rs | 224 ++ crates/linear-cli/src/client/config.rs | 362 +++ crates/linear-cli/src/client/error.rs | 393 +++ crates/linear-cli/src/client/http.rs | 258 ++ crates/linear-cli/src/client/tests.rs | 798 +++++ .../linear-cli/src/client/tests/download.rs | 217 ++ crates/linear-cli/src/client/tests/server.rs | 288 ++ crates/linear-cli/src/commands/api.rs | 291 ++ .../linear-cli/src/commands/auth/default.rs | 56 + crates/linear-cli/src/commands/auth/list.rs | 150 + crates/linear-cli/src/commands/auth/login.rs | 193 ++ crates/linear-cli/src/commands/auth/logout.rs | 60 + .../linear-cli/src/commands/auth/migrate.rs | 38 + crates/linear-cli/src/commands/auth/mod.rs | 47 + crates/linear-cli/src/commands/auth/token.rs | 12 + crates/linear-cli/src/commands/auth/whoami.rs | 33 + crates/linear-cli/src/commands/bulk.rs | 355 +++ crates/linear-cli/src/commands/color.rs | 37 + crates/linear-cli/src/commands/comment_add.rs | 285 ++ .../src/commands/comment_add/tests.rs | 134 + crates/linear-cli/src/commands/comments.rs | 359 +++ crates/linear-cli/src/commands/completions.rs | 56 + .../src/commands/config_generate.rs | 179 ++ crates/linear-cli/src/commands/confirm.rs | 17 + crates/linear-cli/src/commands/cycle/list.rs | 131 + crates/linear-cli/src/commands/cycle/mod.rs | 14 + crates/linear-cli/src/commands/cycle/view.rs | 215 ++ .../src/commands/document/comment/add.rs | 57 + .../src/commands/document/comment/list.rs | 38 + .../src/commands/document/comment/mod.rs | 14 + .../src/commands/document/common.rs | 63 + .../src/commands/document/create.rs | 291 ++ .../src/commands/document/delete.rs | 117 + .../linear-cli/src/commands/document/list.rs | 145 + .../linear-cli/src/commands/document/mod.rs | 24 + .../src/commands/document/target.rs | 194 ++ .../src/commands/document/update.rs | 154 + .../linear-cli/src/commands/document/view.rs | 169 + .../src/commands/initiative/add_project.rs | 45 + .../src/commands/initiative/archive.rs | 17 + .../commands/initiative/archive_or_delete.rs | 260 ++ .../src/commands/initiative/comment/add.rs | 59 + .../src/commands/initiative/comment/list.rs | 41 + .../src/commands/initiative/comment/mod.rs | 14 + .../src/commands/initiative/common.rs | 83 + .../src/commands/initiative/create.rs | 196 ++ .../src/commands/initiative/delete.rs | 17 + .../src/commands/initiative/list.rs | 260 ++ .../linear-cli/src/commands/initiative/mod.rs | 32 + .../src/commands/initiative/remove_project.rs | 46 + .../src/commands/initiative/unarchive.rs | 60 + .../src/commands/initiative/update.rs | 201 ++ .../src/commands/initiative/view.rs | 282 ++ .../src/commands/initiative_update/create.rs | 10 + .../src/commands/initiative_update/list.rs | 80 + .../src/commands/initiative_update/mod.rs | 14 + .../src/commands/issue/agent_session/list.rs | 111 + .../src/commands/issue/agent_session/mod.rs | 26 + .../src/commands/issue/agent_session/view.rs | 198 ++ .../issue/agent_session/view/tests.rs | 85 + .../linear-cli/src/commands/issue/archive.rs | 16 + .../src/commands/issue/archive_or_delete.rs | 243 ++ .../linear-cli/src/commands/issue/attach.rs | 121 + .../src/commands/issue/comment/add.rs | 101 + .../src/commands/issue/comment/delete.rs | 49 + .../src/commands/issue/comment/list.rs | 38 + .../src/commands/issue/comment/mod.rs | 18 + .../src/commands/issue/comment/update.rs | 89 + .../commands/issue/comment/update/tests.rs | 65 + .../linear-cli/src/commands/issue/commits.rs | 96 + .../linear-cli/src/commands/issue/create.rs | 501 +++ .../src/commands/issue/create_prompt.rs | 330 ++ .../src/commands/issue/create_prompt/tests.rs | 704 ++++ .../linear-cli/src/commands/issue/delete.rs | 16 + .../linear-cli/src/commands/issue/describe.rs | 36 + .../linear-cli/src/commands/issue/details.rs | 13 + .../linear-cli/src/commands/issue/filter.rs | 362 +++ crates/linear-cli/src/commands/issue/id.rs | 30 + crates/linear-cli/src/commands/issue/link.rs | 68 + crates/linear-cli/src/commands/issue/list.rs | 110 + .../src/commands/issue/list_view.rs | 222 ++ crates/linear-cli/src/commands/issue/mod.rs | 96 + .../src/commands/issue/pull_request.rs | 142 + .../src/commands/issue/pull_request/tests.rs | 42 + crates/linear-cli/src/commands/issue/query.rs | 218 ++ crates/linear-cli/src/commands/issue/read.rs | 140 + .../src/commands/issue/relation/add.rs | 35 + .../src/commands/issue/relation/delete.rs | 60 + .../src/commands/issue/relation/list.rs | 113 + .../src/commands/issue/relation/mod.rs | 55 + crates/linear-cli/src/commands/issue/start.rs | 343 ++ .../src/commands/issue/start/tests.rs | 29 + crates/linear-cli/src/commands/issue/title.rs | 19 + .../linear-cli/src/commands/issue/update.rs | 355 +++ crates/linear-cli/src/commands/issue/url.rs | 15 + crates/linear-cli/src/commands/issue/view.rs | 639 ++++ .../src/commands/issue/view/tests.rs | 51 + crates/linear-cli/src/commands/issue/write.rs | 290 ++ .../src/commands/issue/write_network.rs | 359 +++ crates/linear-cli/src/commands/json.rs | 20 + .../linear-cli/src/commands/label/create.rs | 182 ++ .../linear-cli/src/commands/label/delete.rs | 137 + crates/linear-cli/src/commands/label/list.rs | 156 + crates/linear-cli/src/commands/label/mod.rs | 18 + .../linear-cli/src/commands/lookup_prompt.rs | 75 + .../src/commands/milestone/create.rs | 133 + .../src/commands/milestone/delete.rs | 39 + .../linear-cli/src/commands/milestone/list.rs | 96 + .../linear-cli/src/commands/milestone/mod.rs | 38 + .../src/commands/milestone/update.rs | 61 + .../linear-cli/src/commands/milestone/view.rs | 248 ++ crates/linear-cli/src/commands/mod.rs | 33 + crates/linear-cli/src/commands/outcome.rs | 67 + .../src/commands/project/collections.rs | 184 ++ .../src/commands/project/comment/add.rs | 63 + .../src/commands/project/comment/list.rs | 41 + .../src/commands/project/comment/mod.rs | 14 + .../linear-cli/src/commands/project/common.rs | 204 ++ .../linear-cli/src/commands/project/create.rs | 342 ++ .../linear-cli/src/commands/project/delete.rs | 41 + .../linear-cli/src/commands/project/list.rs | 295 ++ crates/linear-cli/src/commands/project/mod.rs | 24 + .../linear-cli/src/commands/project/update.rs | 465 +++ .../linear-cli/src/commands/project/view.rs | 585 ++++ .../src/commands/project_update/create.rs | 10 + .../src/commands/project_update/list.rs | 79 + .../src/commands/project_update/mod.rs | 14 + crates/linear-cli/src/commands/prosemirror.rs | 446 +++ .../src/commands/prosemirror/tests.rs | 287 ++ .../linear-cli/src/commands/relative_time.rs | 68 + crates/linear-cli/src/commands/schema.rs | 292 ++ .../linear-cli/src/commands/status_update.rs | 427 +++ crates/linear-cli/src/commands/table.rs | 412 +++ .../linear-cli/src/commands/team/autolinks.rs | 31 + crates/linear-cli/src/commands/team/create.rs | 98 + crates/linear-cli/src/commands/team/delete.rs | 180 ++ crates/linear-cli/src/commands/team/id.rs | 17 + crates/linear-cli/src/commands/team/list.rs | 84 + .../linear-cli/src/commands/team/members.rs | 54 + crates/linear-cli/src/commands/team/mod.rs | 56 + crates/linear-cli/src/commands/team/states.rs | 66 + crates/linear-cli/src/commands/team_key.rs | 22 + .../linear-cli/src/commands/template/json.rs | 60 + .../linear-cli/src/commands/template/list.rs | 116 + .../linear-cli/src/commands/template/mod.rs | 19 + .../linear-cli/src/commands/template/scope.rs | 177 ++ .../linear-cli/src/commands/template/view.rs | 333 ++ crates/linear-cli/src/commands/text_input.rs | 60 + crates/linear-cli/src/commands/upload.rs | 228 ++ crates/linear-cli/src/commands/user/list.rs | 139 + crates/linear-cli/src/commands/user/mod.rs | 12 + crates/linear-cli/src/config/discover.rs | 71 + .../linear-cli/src/config/discover/tests.rs | 95 + crates/linear-cli/src/config/dotenv.rs | 431 +++ crates/linear-cli/src/config/dotenv/tests.rs | 296 ++ crates/linear-cli/src/config/mod.rs | 34 + crates/linear-cli/src/config/network.rs | 96 + crates/linear-cli/src/config/options.rs | 738 +++++ crates/linear-cli/src/config/options/tests.rs | 424 +++ crates/linear-cli/src/config/parse.rs | 97 + crates/linear-cli/src/config/parse/tests.rs | 103 + crates/linear-cli/src/config/runtime.rs | 114 + crates/linear-cli/src/config/runtime/tests.rs | 92 + crates/linear-cli/src/config/source.rs | 148 + crates/linear-cli/src/config/source/tests.rs | 55 + crates/linear-cli/src/config/startup.rs | 305 ++ crates/linear-cli/src/config/startup/tests.rs | 352 ++ crates/linear-cli/src/config/test_support.rs | 51 + crates/linear-cli/src/ctx.rs | 558 ++++ crates/linear-cli/src/error.rs | 191 ++ crates/linear-cli/src/graphql/edit.rs | 178 ++ crates/linear-cli/src/graphql/envelope.rs | 238 ++ .../linear-cli/src/graphql/envelope/tests.rs | 279 ++ crates/linear-cli/src/graphql/mod.rs | 16 + .../src/graphql/operations/agent_session.rs | 337 ++ .../graphql/operations/agent_session/tests.rs | 204 ++ .../src/graphql/operations/comment.rs | 371 +++ .../src/graphql/operations/common.rs | 19 + .../src/graphql/operations/cycle.rs | 182 ++ .../src/graphql/operations/document.rs | 518 +++ .../src/graphql/operations/initiative.rs | 717 +++++ .../src/graphql/operations/issue.rs | 799 +++++ .../src/graphql/operations/issue_read.rs | 892 ++++++ .../src/graphql/operations/label.rs | 176 + .../src/graphql/operations/milestone.rs | 311 ++ .../linear-cli/src/graphql/operations/mod.rs | 21 + .../src/graphql/operations/project.rs | 954 ++++++ .../src/graphql/operations/release.rs | 37 + .../src/graphql/operations/status_update.rs | 232 ++ .../linear-cli/src/graphql/operations/team.rs | 371 +++ .../src/graphql/operations/template.rs | 69 + .../src/graphql/operations/upload.rs | 106 + .../linear-cli/src/graphql/operations/user.rs | 183 ++ crates/linear-cli/src/graphql/pagination.rs | 169 + .../src/graphql/pagination/tests.rs | 184 ++ crates/linear-cli/src/graphql/scalars.rs | 237 ++ crates/linear-cli/src/lib.rs | 19 + crates/linear-cli/src/main.rs | 11 + crates/linear-cli/src/platform/collation.rs | 15 + crates/linear-cli/src/platform/editor.rs | 166 + crates/linear-cli/src/platform/gh.rs | 27 + crates/linear-cli/src/platform/interrupt.rs | 175 + crates/linear-cli/src/platform/line_input.rs | 573 ++++ .../src/platform/markdown_assets.rs | 561 ++++ .../src/platform/markdown_terminal.rs | 1288 ++++++++ crates/linear-cli/src/platform/mod.rs | 20 + crates/linear-cli/src/platform/opener.rs | 68 + crates/linear-cli/src/platform/output.rs | 51 + crates/linear-cli/src/platform/pager.rs | 175 + .../linear-cli/src/platform/private_file.rs | 194 ++ crates/linear-cli/src/platform/process.rs | 175 + crates/linear-cli/src/platform/prompt.rs | 698 ++++ crates/linear-cli/src/platform/spinner.rs | 132 + crates/linear-cli/src/platform/style.rs | 96 + .../linear-cli/src/platform/terminal_text.rs | 172 + crates/linear-cli/src/platform/vcs.rs | 105 + crates/linear-cli/src/refs/cycle.rs | 233 ++ crates/linear-cli/src/refs/cycle/number.rs | 143 + crates/linear-cli/src/refs/document.rs | 21 + crates/linear-cli/src/refs/initiative.rs | 134 + crates/linear-cli/src/refs/issue.rs | 83 + crates/linear-cli/src/refs/issue/tests.rs | 60 + crates/linear-cli/src/refs/mod.rs | 38 + crates/linear-cli/src/refs/project.rs | 130 + crates/linear-cli/src/refs/release.rs | 53 + crates/linear-cli/src/refs/team.rs | 210 ++ crates/linear-cli/src/refs/test_support.rs | 42 + crates/linear-cli/src/refs/url.rs | 367 +++ crates/linear-cli/src/refs/url/tests.rs | 120 + crates/linear-cli/src/refs/user.rs | 132 + crates/linear-cli/src/refs/uuid.rs | 32 + crates/linear-cli/src/refs/workflow_states.rs | 227 ++ crates/linear-cli/src/refs/workspace.rs | 131 + crates/linear-cli/src/refs/workspace/tests.rs | 311 ++ crates/linear-cli/tests/cli/api.rs | 459 +++ crates/linear-cli/tests/cli/auth.rs | 480 +++ crates/linear-cli/tests/cli/config.rs | 203 ++ crates/linear-cli/tests/cli/cycle.rs | 406 +++ crates/linear-cli/tests/cli/document.rs | 1057 ++++++ crates/linear-cli/tests/cli/errors.rs | 150 + crates/linear-cli/tests/cli/harness.rs | 271 ++ crates/linear-cli/tests/cli/initiative.rs | 1150 +++++++ crates/linear-cli/tests/cli/issue_attach.rs | 663 ++++ crates/linear-cli/tests/cli/issue_comment.rs | 793 +++++ crates/linear-cli/tests/cli/issue_read.rs | 1380 ++++++++ crates/linear-cli/tests/cli/issue_vcs.rs | 802 +++++ crates/linear-cli/tests/cli/issue_write.rs | 1304 ++++++++ crates/linear-cli/tests/cli/label.rs | 342 ++ crates/linear-cli/tests/cli/main.rs | 30 + crates/linear-cli/tests/cli/milestone.rs | 626 ++++ crates/linear-cli/tests/cli/misc.rs | 151 + crates/linear-cli/tests/cli/network.rs | 181 ++ crates/linear-cli/tests/cli/project.rs | 1042 ++++++ crates/linear-cli/tests/cli/prompting.rs | 192 ++ crates/linear-cli/tests/cli/status_update.rs | 693 ++++ crates/linear-cli/tests/cli/support/json.rs | 51 + crates/linear-cli/tests/cli/support/mock.rs | 392 +++ crates/linear-cli/tests/cli/support/mod.rs | 7 + .../linear-cli/tests/cli/support/sandbox.rs | 424 +++ crates/linear-cli/tests/cli/team.rs | 746 +++++ crates/linear-cli/tests/cli/template.rs | 257 ++ crates/linear-cli/tests/cli/user.rs | 170 + crates/linear-cli/tests/cli/web.rs | 171 + .../tests/fixtures/agent_session.json | 94 + .../tests/fixtures/team_references.json | 1241 ++++++++ crates/linear-cli/tests/fixtures/tls/leaf.key | 5 + crates/linear-cli/tests/fixtures/tls/leaf.pem | 13 + .../linear-cli/tests/fixtures/tls/test-ca.pem | 11 + .../tests/fixtures/tls/wrong-ca.pem | 12 + crates/linear-schema/Cargo.toml | 21 + crates/linear-schema/build.rs | 18 + crates/linear-schema/src/lib.rs | 6 + deno.json | 85 - deno.lock | 2712 ---------------- dist-workspace.toml | 30 +- evals/linear-cli-skill/README.md | 95 - evals/linear-cli-skill/cases.ts | 373 --- .../linear-cli-skill/claude-markdown-cases.ts | 54 - .../linear-cli-skill/claude-markdown-grade.ts | 54 - .../fixtures/alternate-comment.md | 6 - .../fixtures/alternate-description.md | 11 - evals/linear-cli-skill/fixtures/comment.md | 7 - .../fixtures/issue-description.md | 11 - .../linear-cli-skill/fixtures/screenshot.png | Bin 350 -> 0 bytes evals/linear-cli-skill/fixtures/server.log | 6 - evals/linear-cli-skill/grade.ts | 590 ---- evals/linear-cli-skill/results/baseline.jsonl | 36 - .../results/baseline.meta.json | 26 - evals/linear-cli-skill/results/comparison.md | 66 - .../results/image-baseline.jsonl | 45 - .../results/image-baseline.meta.json | 29 - .../results/image-comparison.md | 95 - .../results/image-gold-labels.jsonl | 18 - .../results/image-post-change.jsonl | 45 - .../results/image-post-change.meta.json | 29 - .../results/mention-baseline.jsonl | 4 - .../results/mention-comparison.md | 13 - .../results/mention-post-change.jsonl | 4 - .../results/post-change.jsonl | 36 - .../results/post-change.meta.json | 26 - evals/linear-cli-skill/run-claude-markdown.ts | 215 -- evals/linear-cli-skill/run.ts | 469 --- evals/linear-cli-skill/shims/curl | 10 - evals/linear-cli-skill/shims/linear | 408 --- evals/linear-cli-skill/shims/npx | 10 - justfile | 72 +- lefthook.yaml | 17 - .../alloc-stdlib-BSD-3-Clause.txt | 12 + licenses/supplemental/cynic-MPL-2.0.txt | 373 +++ licenses/supplemental/inquire-LICENSE-MIT.txt | 21 + licenses/supplemental/sources.json | 55 + .../supplemental/winapi-LICENSE-APACHE.txt | 201 ++ licenses/supplemental/winapi-LICENSE-MIT.txt | 19 + mise.toml | 3 +- rust-toolchain.toml | 5 + src/cli.ts | 78 - src/commands/api.ts | 454 --- src/commands/auth/auth-default.ts | 59 - src/commands/auth/auth-list.ts | 131 - src/commands/auth/auth-login.ts | 169 - src/commands/auth/auth-logout.ts | 70 - src/commands/auth/auth-migrate.ts | 41 - src/commands/auth/auth-status.ts | 67 - src/commands/auth/auth-token.ts | 22 - src/commands/auth/auth-whoami.ts | 52 - src/commands/auth/auth.ts | 22 - src/commands/config.ts | 147 - src/commands/cycle/cycle-list.ts | 193 -- src/commands/cycle/cycle-view.ts | 204 -- src/commands/cycle/cycle.ts | 11 - src/commands/document/attachment-target.ts | 260 -- src/commands/document/document-comment-add.ts | 84 - .../document/document-comment-list.ts | 69 - src/commands/document/document-comment.ts | 11 - src/commands/document/document-create.ts | 390 --- src/commands/document/document-delete.ts | 260 -- src/commands/document/document-list.ts | 256 -- src/commands/document/document-update.ts | 407 --- src/commands/document/document-view.ts | 302 -- src/commands/document/document.ts | 22 - .../initiative-update-create.ts | 376 --- .../initiative-update-list.ts | 264 -- .../initiative-update/initiative-update.ts | 14 - .../initiative/initiative-add-project.ts | 270 -- src/commands/initiative/initiative-archive.ts | 369 --- .../initiative/initiative-comment-add.ts | 43 - .../initiative/initiative-comment-list.ts | 83 - src/commands/initiative/initiative-comment.ts | 11 - src/commands/initiative/initiative-create.ts | 257 -- src/commands/initiative/initiative-delete.ts | 379 --- src/commands/initiative/initiative-list.ts | 317 -- .../initiative/initiative-remove-project.ts | 320 -- .../initiative/initiative-unarchive.ts | 196 -- src/commands/initiative/initiative-update.ts | 298 -- src/commands/initiative/initiative-view.ts | 324 -- src/commands/initiative/initiative.ts | 29 - .../issue/issue-agent-session-list.ts | 183 -- .../issue/issue-agent-session-view.ts | 181 -- src/commands/issue/issue-agent-session.ts | 11 - src/commands/issue/issue-archive.ts | 266 -- src/commands/issue/issue-attach.ts | 128 - src/commands/issue/issue-comment-add.ts | 152 - src/commands/issue/issue-comment-delete.ts | 34 - src/commands/issue/issue-comment-list.ts | 74 - src/commands/issue/issue-comment-update.ts | 117 - src/commands/issue/issue-comment.ts | 15 - src/commands/issue/issue-commits.ts | 86 - src/commands/issue/issue-create.ts | 985 ------ src/commands/issue/issue-delete.ts | 267 -- src/commands/issue/issue-describe.ts | 35 - src/commands/issue/issue-id.ts | 25 - src/commands/issue/issue-link.ts | 118 - src/commands/issue/issue-mine.ts | 497 --- src/commands/issue/issue-pull-request.ts | 177 -- src/commands/issue/issue-query.ts | 649 ---- src/commands/issue/issue-relation.ts | 395 --- src/commands/issue/issue-start.ts | 90 - src/commands/issue/issue-title.ts | 23 - src/commands/issue/issue-update.ts | 508 --- src/commands/issue/issue-url.ts | 23 - src/commands/issue/issue-view.ts | 661 ---- src/commands/issue/issue.ts | 48 - src/commands/label/label-create.ts | 226 -- src/commands/label/label-delete.ts | 214 -- src/commands/label/label-list.ts | 224 -- src/commands/label/label.ts | 13 - src/commands/markdown.ts | 12 - src/commands/milestone/milestone-create.ts | 76 - src/commands/milestone/milestone-delete.ts | 70 - src/commands/milestone/milestone-list.ts | 180 -- src/commands/milestone/milestone-update.ts | 109 - src/commands/milestone/milestone-view.ts | 254 -- src/commands/milestone/milestone.ts | 17 - .../project-update/project-update-create.ts | 298 -- .../project-update/project-update-list.ts | 183 -- src/commands/project-update/project-update.ts | 12 - src/commands/project/project-comment-add.ts | 43 - src/commands/project/project-comment-list.ts | 84 - src/commands/project/project-comment.ts | 11 - src/commands/project/project-create.ts | 585 ---- src/commands/project/project-delete.ts | 69 - src/commands/project/project-description.ts | 51 - src/commands/project/project-list.ts | 406 --- src/commands/project/project-update.ts | 857 ----- src/commands/project/project-view.ts | 1029 ------ src/commands/project/project.ts | 19 - src/commands/schema.ts | 47 - src/commands/team/team-autolinks.ts | 50 - src/commands/team/team-create.ts | 157 - src/commands/team/team-delete.ts | 236 -- src/commands/team/team-id.ts | 22 - src/commands/team/team-list.ts | 196 -- src/commands/team/team-members.ts | 76 - src/commands/team/team-states.ts | 88 - src/commands/team/team.ts | 22 - src/commands/template/template-list.ts | 138 - src/commands/template/template-view.ts | 223 -- src/commands/template/template.ts | 13 - src/commands/user/user-list.ts | 57 - src/commands/user/user.ts | 10 - src/config.ts | 578 ---- src/const.ts | 11 - src/credentials.ts | 471 --- src/keyring/index.ts | 60 - src/keyring/linux.ts | 122 - src/keyring/macos.ts | 80 - src/keyring/windows.ts | 162 - src/main.ts | 5 - src/utils/actions.ts | 109 - src/utils/bulk.ts | 280 -- src/utils/charmd-hyperlink-extension.ts | 40 - src/utils/comments.ts | 390 --- src/utils/display.ts | 187 -- src/utils/editor.ts | 70 - src/utils/errors.ts | 291 -- src/utils/git.ts | 71 - src/utils/graphql.ts | 118 - src/utils/hyperlink.ts | 89 - src/utils/issue-identifier.ts | 65 - src/utils/jj.ts | 155 - src/utils/linear-url.ts | 464 --- src/utils/linear.ts | 2827 ----------------- src/utils/markdown-help.ts | 71 - src/utils/markdown-images.ts | 189 -- src/utils/member-display.ts | 69 - src/utils/pager.ts | 133 - src/utils/prosemirror.ts | 271 -- src/utils/styling.ts | 19 - src/utils/templates.ts | 307 -- src/utils/upload.ts | 345 -- src/utils/vcs.ts | 170 - test/commands/__snapshots__/api.test.ts.snap | 280 -- test/commands/api.test.ts | 948 ------ test/commands/auth/auth-migrate.test.ts | 174 - .../__snapshots__/cycle-list.test.ts.snap | 154 - .../__snapshots__/cycle-view.test.ts.snap | 261 -- test/commands/cycle/cycle-list.test.ts | 393 --- test/commands/cycle/cycle-view.test.ts | 409 --- .../document-comment-add.test.ts.snap | 54 - .../document-comment-list.test.ts.snap | 162 - .../document-comment.test.ts.snap | 24 - .../document-create.test.ts.snap | 146 - .../document-delete.test.ts.snap | 58 - .../__snapshots__/document-list.test.ts.snap | 248 -- .../document-update.test.ts.snap | 182 -- .../__snapshots__/document-view.test.ts.snap | 144 - .../document/attachment-target.test.ts | 120 - .../document/document-comment-add.test.ts | 300 -- .../document/document-comment-list.test.ts | 237 -- .../document/document-comment.test.ts | 16 - .../commands/document/document-create.test.ts | 682 ---- .../commands/document/document-delete.test.ts | 192 -- test/commands/document/document-list.test.ts | 602 ---- .../commands/document/document-update.test.ts | 828 ----- test/commands/document/document-view.test.ts | 429 --- test/commands/fixtures/api-filter.json | 1 - .../initiative-update-create.test.ts.snap | 28 - .../initiative-update-list.test.ts.snap | 26 - .../initiative-update-create.test.ts | 16 - .../initiative-update-list.test.ts | 57 - .../initiative-comment-add.test.ts.snap | 45 - .../initiative-comment-list.test.ts.snap | 74 - .../initiative-comment.test.ts.snap | 24 - .../initiative-list.test.ts.snap | 74 - .../initiative-view.test.ts.snap | 41 - .../initiative/initiative-comment-add.test.ts | 121 - .../initiative-comment-list.test.ts | 146 - .../initiative/initiative-comment.test.ts | 16 - .../initiative/initiative-list.test.ts | 98 - .../initiative/initiative-unarchive.test.ts | 87 - .../initiative/initiative-view.test.ts | 69 - .../issue-agent-session-list.test.ts.snap | 72 - .../issue-agent-session-view.test.ts.snap | 107 - .../__snapshots__/issue-archive.test.ts.snap | 121 - .../__snapshots__/issue-attach.test.ts.snap | 67 - .../issue-comment-add.test.ts.snap | 67 - .../issue-comment-delete.test.ts.snap | 9 - .../issue-comment-list.test.ts.snap | 205 -- .../issue-comment-update.test.ts.snap | 35 - .../__snapshots__/issue-commits.test.ts.snap | 19 - .../__snapshots__/issue-create.test.ts.snap | 145 - .../__snapshots__/issue-describe.test.ts.snap | 50 - .../__snapshots__/issue-link.test.ts.snap | 59 - .../__snapshots__/issue-list.test.ts.snap | 36 - .../__snapshots__/issue-mine.test.ts.snap | 70 - .../issue-pull-request.test.ts.snap | 27 - .../__snapshots__/issue-query.test.ts.snap | 439 --- .../__snapshots__/issue-relation.test.ts.snap | 42 - .../__snapshots__/issue-update.test.ts.snap | 389 --- .../__snapshots__/issue-view.test.ts.snap | 561 ---- test/commands/issue/image-download.test.ts | 234 -- .../issue/issue-agent-session-list.test.ts | 193 -- .../issue/issue-agent-session-view.test.ts | 212 -- test/commands/issue/issue-archive.test.ts | 404 --- test/commands/issue/issue-attach.test.ts | 259 -- test/commands/issue/issue-comment-add.test.ts | 398 --- .../issue/issue-comment-delete.test.ts | 35 - .../commands/issue/issue-comment-list.test.ts | 549 ---- .../issue/issue-comment-update.test.ts | 56 - test/commands/issue/issue-commits.test.ts | 17 - test/commands/issue/issue-create.test.ts | 1911 ----------- test/commands/issue/issue-describe.test.ts | 135 - test/commands/issue/issue-link.test.ts | 151 - test/commands/issue/issue-list.test.ts | 176 - test/commands/issue/issue-mine.test.ts | 1185 ------- .../commands/issue/issue-pull-request.test.ts | 113 - test/commands/issue/issue-query.test.ts | 1081 ------- test/commands/issue/issue-relation.test.ts | 109 - test/commands/issue/issue-start.test.ts | 55 - test/commands/issue/issue-update.test.ts | 2135 ------------- .../issue/issue-url-reference.test.ts | 101 - test/commands/issue/issue-view.test.ts | 1432 --------- .../__snapshots__/label-create.test.ts.snap | 34 - .../__snapshots__/label-delete.test.ts.snap | 29 - .../__snapshots__/label-list.test.ts.snap | 72 - test/commands/label/label-create.test.ts | 61 - test/commands/label/label-delete.test.ts | 71 - test/commands/label/label-list.test.ts | 152 - test/commands/markdown.test.ts | 53 - .../milestone-create.test.ts.snap | 54 - .../milestone-delete.test.ts.snap | 28 - .../__snapshots__/milestone-list.test.ts.snap | 144 - .../milestone-update.test.ts.snap | 84 - .../__snapshots__/milestone-view.test.ts.snap | 360 --- .../milestone/milestone-create.test.ts | 212 -- .../milestone/milestone-delete.test.ts | 57 - .../commands/milestone/milestone-list.test.ts | 347 -- .../milestone/milestone-update.test.ts | 265 -- .../commands/milestone/milestone-view.test.ts | 543 ---- .../project-update-create.test.ts.snap | 28 - .../project-update-list.test.ts.snap | 31 - .../project-update-create.test.ts | 16 - .../project-update-list.test.ts | 62 - .../project-comment-add.test.ts.snap | 45 - .../project-comment-list.test.ts.snap | 124 - .../project-comment.test.ts.snap | 24 - .../__snapshots__/project-create.test.ts.snap | 130 - .../__snapshots__/project-delete.test.ts.snap | 28 - .../__snapshots__/project-list.test.ts.snap | 193 -- .../__snapshots__/project-update.test.ts.snap | 170 - .../__snapshots__/project-view.test.ts.snap | 362 --- .../project/project-comment-add.test.ts | 113 - .../project/project-comment-list.test.ts | 238 -- test/commands/project/project-comment.test.ts | 16 - test/commands/project/project-create.test.ts | 778 ----- test/commands/project/project-delete.test.ts | 58 - .../project/project-description.test.ts | 75 - test/commands/project/project-list.test.ts | 866 ----- test/commands/project/project-update.test.ts | 1383 -------- test/commands/project/project-view.test.ts | 824 ----- .../__snapshots__/team-autolinks.test.ts.snap | 28 - .../__snapshots__/team-delete.test.ts.snap | 38 - .../team/__snapshots__/team-id.test.ts.snap | 28 - .../team/__snapshots__/team-list.test.ts.snap | 138 - .../__snapshots__/team-members.test.ts.snap | 246 -- .../__snapshots__/team-states.test.ts.snap | 206 -- test/commands/team/team-autolinks.test.ts | 39 - test/commands/team/team-create.test.ts | 24 - test/commands/team/team-delete.test.ts | 113 - test/commands/team/team-id.test.ts | 38 - test/commands/team/team-list.test.ts | 476 --- test/commands/team/team-members.test.ts | 268 -- test/commands/team/team-states.test.ts | 301 -- .../__snapshots__/template-list.test.ts.snap | 224 -- .../__snapshots__/template-view.test.ts.snap | 115 - test/commands/template/template-list.test.ts | 178 -- test/commands/template/template-view.test.ts | 242 -- .../user/__snapshots__/user-list.test.ts.snap | 248 -- test/commands/user/user-list.test.ts | 243 -- test/config.test.ts | 1217 ------- test/credentials.test.ts | 1042 ------ ...ar-cli-skill-claude-markdown-grade.test.ts | 77 - test/evals/linear-cli-skill-grade.test.ts | 575 ---- test/keyring.integration.test.ts | 119 - test/keyring.test.ts | 110 - test/main_test.ts | 162 - test/utils/comments.test.ts | 183 -- test/utils/display.test.ts | 103 - test/utils/errors.test.ts | 111 - test/utils/git.test.ts | 119 - test/utils/graphql.test.ts | 55 - test/utils/hyperlink.test.ts | 178 -- test/utils/issue-identifier.test.ts | 118 - test/utils/jj.test.ts | 127 - test/utils/linear-url.property.test.ts | 440 --- test/utils/linear-url.test.ts | 391 --- test/utils/linear.test.ts | 1400 -------- test/utils/markdown-help.test.ts | 135 - test/utils/mock_linear_server.ts | 222 -- test/utils/pager.test.ts | 94 - test/utils/prosemirror.test.ts | 189 -- test/utils/snapshot_with_fake_time.ts | 308 -- test/utils/templates.test.ts | 294 -- test/utils/test-helpers.ts | 97 - test/utils/upload.test.ts | 47 - test/utils/vcs.test.ts | 187 -- xtask/Cargo.toml | 20 + xtask/src/licenses.rs | 520 +++ xtask/src/licenses/tests.rs | 404 +++ xtask/src/main.rs | 58 + 682 files changed, 68274 insertions(+), 74337 deletions(-) create mode 100644 .cargo/config.toml delete mode 100644 .git-blame-ignore-revs delete mode 100644 .github/workflows/ci.yaml create mode 100644 .github/workflows/ci.yml delete mode 100644 .github/workflows/publish.yaml delete mode 100644 .graphqlrc.yaml delete mode 100644 .zed/settings.json delete mode 100644 CONVENTIONS.md create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 clippy.toml delete mode 100644 codegen.ts create mode 100644 crates/linear-cli/Cargo.toml create mode 100644 crates/linear-cli/build.rs create mode 100644 crates/linear-cli/examples/skill_docs.rs create mode 100644 crates/linear-cli/src/app.rs create mode 100644 crates/linear-cli/src/auth/file.rs create mode 100644 crates/linear-cli/src/auth/format.rs create mode 100644 crates/linear-cli/src/auth/format/tests.rs create mode 100644 crates/linear-cli/src/auth/keyring/mod.rs create mode 100644 crates/linear-cli/src/auth/keyring/process.rs create mode 100644 crates/linear-cli/src/auth/keyring/process/tests.rs create mode 100644 crates/linear-cli/src/auth/keyring/secret_tool.rs create mode 100644 crates/linear-cli/src/auth/keyring/security.rs create mode 100644 crates/linear-cli/src/auth/keyring/tests.rs create mode 100644 crates/linear-cli/src/auth/keyring/windows.rs create mode 100644 crates/linear-cli/src/auth/mod.rs create mode 100644 crates/linear-cli/src/auth/mutation.rs create mode 100644 crates/linear-cli/src/auth/mutation/tests.rs create mode 100644 crates/linear-cli/src/auth/path.rs create mode 100644 crates/linear-cli/src/auth/resolve.rs create mode 100644 crates/linear-cli/src/auth/resolve/tests.rs create mode 100644 crates/linear-cli/src/auth/test_support.rs create mode 100644 crates/linear-cli/src/cli/api.rs create mode 100644 crates/linear-cli/src/cli/auth.rs create mode 100644 crates/linear-cli/src/cli/completions.rs create mode 100644 crates/linear-cli/src/cli/config.rs create mode 100644 crates/linear-cli/src/cli/cycle.rs create mode 100644 crates/linear-cli/src/cli/document.rs create mode 100644 crates/linear-cli/src/cli/initiative.rs create mode 100644 crates/linear-cli/src/cli/initiative_update.rs create mode 100644 crates/linear-cli/src/cli/issue.rs create mode 100644 crates/linear-cli/src/cli/label.rs create mode 100644 crates/linear-cli/src/cli/limit.rs create mode 100644 crates/linear-cli/src/cli/markdown.rs create mode 100644 crates/linear-cli/src/cli/markdown.txt create mode 100644 crates/linear-cli/src/cli/milestone.rs create mode 100644 crates/linear-cli/src/cli/mod.rs create mode 100644 crates/linear-cli/src/cli/project.rs create mode 100644 crates/linear-cli/src/cli/project_update.rs create mode 100644 crates/linear-cli/src/cli/schema.rs create mode 100644 crates/linear-cli/src/cli/team.rs create mode 100644 crates/linear-cli/src/cli/template.rs create mode 100644 crates/linear-cli/src/cli/tests.rs create mode 100644 crates/linear-cli/src/cli/user.rs create mode 100644 crates/linear-cli/src/cli/values.rs create mode 100644 crates/linear-cli/src/client.rs create mode 100644 crates/linear-cli/src/client/config.rs create mode 100644 crates/linear-cli/src/client/error.rs create mode 100644 crates/linear-cli/src/client/http.rs create mode 100644 crates/linear-cli/src/client/tests.rs create mode 100644 crates/linear-cli/src/client/tests/download.rs create mode 100644 crates/linear-cli/src/client/tests/server.rs create mode 100644 crates/linear-cli/src/commands/api.rs create mode 100644 crates/linear-cli/src/commands/auth/default.rs create mode 100644 crates/linear-cli/src/commands/auth/list.rs create mode 100644 crates/linear-cli/src/commands/auth/login.rs create mode 100644 crates/linear-cli/src/commands/auth/logout.rs create mode 100644 crates/linear-cli/src/commands/auth/migrate.rs create mode 100644 crates/linear-cli/src/commands/auth/mod.rs create mode 100644 crates/linear-cli/src/commands/auth/token.rs create mode 100644 crates/linear-cli/src/commands/auth/whoami.rs create mode 100644 crates/linear-cli/src/commands/bulk.rs create mode 100644 crates/linear-cli/src/commands/color.rs create mode 100644 crates/linear-cli/src/commands/comment_add.rs create mode 100644 crates/linear-cli/src/commands/comment_add/tests.rs create mode 100644 crates/linear-cli/src/commands/comments.rs create mode 100644 crates/linear-cli/src/commands/completions.rs create mode 100644 crates/linear-cli/src/commands/config_generate.rs create mode 100644 crates/linear-cli/src/commands/confirm.rs create mode 100644 crates/linear-cli/src/commands/cycle/list.rs create mode 100644 crates/linear-cli/src/commands/cycle/mod.rs create mode 100644 crates/linear-cli/src/commands/cycle/view.rs create mode 100644 crates/linear-cli/src/commands/document/comment/add.rs create mode 100644 crates/linear-cli/src/commands/document/comment/list.rs create mode 100644 crates/linear-cli/src/commands/document/comment/mod.rs create mode 100644 crates/linear-cli/src/commands/document/common.rs create mode 100644 crates/linear-cli/src/commands/document/create.rs create mode 100644 crates/linear-cli/src/commands/document/delete.rs create mode 100644 crates/linear-cli/src/commands/document/list.rs create mode 100644 crates/linear-cli/src/commands/document/mod.rs create mode 100644 crates/linear-cli/src/commands/document/target.rs create mode 100644 crates/linear-cli/src/commands/document/update.rs create mode 100644 crates/linear-cli/src/commands/document/view.rs create mode 100644 crates/linear-cli/src/commands/initiative/add_project.rs create mode 100644 crates/linear-cli/src/commands/initiative/archive.rs create mode 100644 crates/linear-cli/src/commands/initiative/archive_or_delete.rs create mode 100644 crates/linear-cli/src/commands/initiative/comment/add.rs create mode 100644 crates/linear-cli/src/commands/initiative/comment/list.rs create mode 100644 crates/linear-cli/src/commands/initiative/comment/mod.rs create mode 100644 crates/linear-cli/src/commands/initiative/common.rs create mode 100644 crates/linear-cli/src/commands/initiative/create.rs create mode 100644 crates/linear-cli/src/commands/initiative/delete.rs create mode 100644 crates/linear-cli/src/commands/initiative/list.rs create mode 100644 crates/linear-cli/src/commands/initiative/mod.rs create mode 100644 crates/linear-cli/src/commands/initiative/remove_project.rs create mode 100644 crates/linear-cli/src/commands/initiative/unarchive.rs create mode 100644 crates/linear-cli/src/commands/initiative/update.rs create mode 100644 crates/linear-cli/src/commands/initiative/view.rs create mode 100644 crates/linear-cli/src/commands/initiative_update/create.rs create mode 100644 crates/linear-cli/src/commands/initiative_update/list.rs create mode 100644 crates/linear-cli/src/commands/initiative_update/mod.rs create mode 100644 crates/linear-cli/src/commands/issue/agent_session/list.rs create mode 100644 crates/linear-cli/src/commands/issue/agent_session/mod.rs create mode 100644 crates/linear-cli/src/commands/issue/agent_session/view.rs create mode 100644 crates/linear-cli/src/commands/issue/agent_session/view/tests.rs create mode 100644 crates/linear-cli/src/commands/issue/archive.rs create mode 100644 crates/linear-cli/src/commands/issue/archive_or_delete.rs create mode 100644 crates/linear-cli/src/commands/issue/attach.rs create mode 100644 crates/linear-cli/src/commands/issue/comment/add.rs create mode 100644 crates/linear-cli/src/commands/issue/comment/delete.rs create mode 100644 crates/linear-cli/src/commands/issue/comment/list.rs create mode 100644 crates/linear-cli/src/commands/issue/comment/mod.rs create mode 100644 crates/linear-cli/src/commands/issue/comment/update.rs create mode 100644 crates/linear-cli/src/commands/issue/comment/update/tests.rs create mode 100644 crates/linear-cli/src/commands/issue/commits.rs create mode 100644 crates/linear-cli/src/commands/issue/create.rs create mode 100644 crates/linear-cli/src/commands/issue/create_prompt.rs create mode 100644 crates/linear-cli/src/commands/issue/create_prompt/tests.rs create mode 100644 crates/linear-cli/src/commands/issue/delete.rs create mode 100644 crates/linear-cli/src/commands/issue/describe.rs create mode 100644 crates/linear-cli/src/commands/issue/details.rs create mode 100644 crates/linear-cli/src/commands/issue/filter.rs create mode 100644 crates/linear-cli/src/commands/issue/id.rs create mode 100644 crates/linear-cli/src/commands/issue/link.rs create mode 100644 crates/linear-cli/src/commands/issue/list.rs create mode 100644 crates/linear-cli/src/commands/issue/list_view.rs create mode 100644 crates/linear-cli/src/commands/issue/mod.rs create mode 100644 crates/linear-cli/src/commands/issue/pull_request.rs create mode 100644 crates/linear-cli/src/commands/issue/pull_request/tests.rs create mode 100644 crates/linear-cli/src/commands/issue/query.rs create mode 100644 crates/linear-cli/src/commands/issue/read.rs create mode 100644 crates/linear-cli/src/commands/issue/relation/add.rs create mode 100644 crates/linear-cli/src/commands/issue/relation/delete.rs create mode 100644 crates/linear-cli/src/commands/issue/relation/list.rs create mode 100644 crates/linear-cli/src/commands/issue/relation/mod.rs create mode 100644 crates/linear-cli/src/commands/issue/start.rs create mode 100644 crates/linear-cli/src/commands/issue/start/tests.rs create mode 100644 crates/linear-cli/src/commands/issue/title.rs create mode 100644 crates/linear-cli/src/commands/issue/update.rs create mode 100644 crates/linear-cli/src/commands/issue/url.rs create mode 100644 crates/linear-cli/src/commands/issue/view.rs create mode 100644 crates/linear-cli/src/commands/issue/view/tests.rs create mode 100644 crates/linear-cli/src/commands/issue/write.rs create mode 100644 crates/linear-cli/src/commands/issue/write_network.rs create mode 100644 crates/linear-cli/src/commands/json.rs create mode 100644 crates/linear-cli/src/commands/label/create.rs create mode 100644 crates/linear-cli/src/commands/label/delete.rs create mode 100644 crates/linear-cli/src/commands/label/list.rs create mode 100644 crates/linear-cli/src/commands/label/mod.rs create mode 100644 crates/linear-cli/src/commands/lookup_prompt.rs create mode 100644 crates/linear-cli/src/commands/milestone/create.rs create mode 100644 crates/linear-cli/src/commands/milestone/delete.rs create mode 100644 crates/linear-cli/src/commands/milestone/list.rs create mode 100644 crates/linear-cli/src/commands/milestone/mod.rs create mode 100644 crates/linear-cli/src/commands/milestone/update.rs create mode 100644 crates/linear-cli/src/commands/milestone/view.rs create mode 100644 crates/linear-cli/src/commands/mod.rs create mode 100644 crates/linear-cli/src/commands/outcome.rs create mode 100644 crates/linear-cli/src/commands/project/collections.rs create mode 100644 crates/linear-cli/src/commands/project/comment/add.rs create mode 100644 crates/linear-cli/src/commands/project/comment/list.rs create mode 100644 crates/linear-cli/src/commands/project/comment/mod.rs create mode 100644 crates/linear-cli/src/commands/project/common.rs create mode 100644 crates/linear-cli/src/commands/project/create.rs create mode 100644 crates/linear-cli/src/commands/project/delete.rs create mode 100644 crates/linear-cli/src/commands/project/list.rs create mode 100644 crates/linear-cli/src/commands/project/mod.rs create mode 100644 crates/linear-cli/src/commands/project/update.rs create mode 100644 crates/linear-cli/src/commands/project/view.rs create mode 100644 crates/linear-cli/src/commands/project_update/create.rs create mode 100644 crates/linear-cli/src/commands/project_update/list.rs create mode 100644 crates/linear-cli/src/commands/project_update/mod.rs create mode 100644 crates/linear-cli/src/commands/prosemirror.rs create mode 100644 crates/linear-cli/src/commands/prosemirror/tests.rs create mode 100644 crates/linear-cli/src/commands/relative_time.rs create mode 100644 crates/linear-cli/src/commands/schema.rs create mode 100644 crates/linear-cli/src/commands/status_update.rs create mode 100644 crates/linear-cli/src/commands/table.rs create mode 100644 crates/linear-cli/src/commands/team/autolinks.rs create mode 100644 crates/linear-cli/src/commands/team/create.rs create mode 100644 crates/linear-cli/src/commands/team/delete.rs create mode 100644 crates/linear-cli/src/commands/team/id.rs create mode 100644 crates/linear-cli/src/commands/team/list.rs create mode 100644 crates/linear-cli/src/commands/team/members.rs create mode 100644 crates/linear-cli/src/commands/team/mod.rs create mode 100644 crates/linear-cli/src/commands/team/states.rs create mode 100644 crates/linear-cli/src/commands/team_key.rs create mode 100644 crates/linear-cli/src/commands/template/json.rs create mode 100644 crates/linear-cli/src/commands/template/list.rs create mode 100644 crates/linear-cli/src/commands/template/mod.rs create mode 100644 crates/linear-cli/src/commands/template/scope.rs create mode 100644 crates/linear-cli/src/commands/template/view.rs create mode 100644 crates/linear-cli/src/commands/text_input.rs create mode 100644 crates/linear-cli/src/commands/upload.rs create mode 100644 crates/linear-cli/src/commands/user/list.rs create mode 100644 crates/linear-cli/src/commands/user/mod.rs create mode 100644 crates/linear-cli/src/config/discover.rs create mode 100644 crates/linear-cli/src/config/discover/tests.rs create mode 100644 crates/linear-cli/src/config/dotenv.rs create mode 100644 crates/linear-cli/src/config/dotenv/tests.rs create mode 100644 crates/linear-cli/src/config/mod.rs create mode 100644 crates/linear-cli/src/config/network.rs create mode 100644 crates/linear-cli/src/config/options.rs create mode 100644 crates/linear-cli/src/config/options/tests.rs create mode 100644 crates/linear-cli/src/config/parse.rs create mode 100644 crates/linear-cli/src/config/parse/tests.rs create mode 100644 crates/linear-cli/src/config/runtime.rs create mode 100644 crates/linear-cli/src/config/runtime/tests.rs create mode 100644 crates/linear-cli/src/config/source.rs create mode 100644 crates/linear-cli/src/config/source/tests.rs create mode 100644 crates/linear-cli/src/config/startup.rs create mode 100644 crates/linear-cli/src/config/startup/tests.rs create mode 100644 crates/linear-cli/src/config/test_support.rs create mode 100644 crates/linear-cli/src/ctx.rs create mode 100644 crates/linear-cli/src/error.rs create mode 100644 crates/linear-cli/src/graphql/edit.rs create mode 100644 crates/linear-cli/src/graphql/envelope.rs create mode 100644 crates/linear-cli/src/graphql/envelope/tests.rs create mode 100644 crates/linear-cli/src/graphql/mod.rs create mode 100644 crates/linear-cli/src/graphql/operations/agent_session.rs create mode 100644 crates/linear-cli/src/graphql/operations/agent_session/tests.rs create mode 100644 crates/linear-cli/src/graphql/operations/comment.rs create mode 100644 crates/linear-cli/src/graphql/operations/common.rs create mode 100644 crates/linear-cli/src/graphql/operations/cycle.rs create mode 100644 crates/linear-cli/src/graphql/operations/document.rs create mode 100644 crates/linear-cli/src/graphql/operations/initiative.rs create mode 100644 crates/linear-cli/src/graphql/operations/issue.rs create mode 100644 crates/linear-cli/src/graphql/operations/issue_read.rs create mode 100644 crates/linear-cli/src/graphql/operations/label.rs create mode 100644 crates/linear-cli/src/graphql/operations/milestone.rs create mode 100644 crates/linear-cli/src/graphql/operations/mod.rs create mode 100644 crates/linear-cli/src/graphql/operations/project.rs create mode 100644 crates/linear-cli/src/graphql/operations/release.rs create mode 100644 crates/linear-cli/src/graphql/operations/status_update.rs create mode 100644 crates/linear-cli/src/graphql/operations/team.rs create mode 100644 crates/linear-cli/src/graphql/operations/template.rs create mode 100644 crates/linear-cli/src/graphql/operations/upload.rs create mode 100644 crates/linear-cli/src/graphql/operations/user.rs create mode 100644 crates/linear-cli/src/graphql/pagination.rs create mode 100644 crates/linear-cli/src/graphql/pagination/tests.rs create mode 100644 crates/linear-cli/src/graphql/scalars.rs create mode 100644 crates/linear-cli/src/lib.rs create mode 100644 crates/linear-cli/src/main.rs create mode 100644 crates/linear-cli/src/platform/collation.rs create mode 100644 crates/linear-cli/src/platform/editor.rs create mode 100644 crates/linear-cli/src/platform/gh.rs create mode 100644 crates/linear-cli/src/platform/interrupt.rs create mode 100644 crates/linear-cli/src/platform/line_input.rs create mode 100644 crates/linear-cli/src/platform/markdown_assets.rs create mode 100644 crates/linear-cli/src/platform/markdown_terminal.rs create mode 100644 crates/linear-cli/src/platform/mod.rs create mode 100644 crates/linear-cli/src/platform/opener.rs create mode 100644 crates/linear-cli/src/platform/output.rs create mode 100644 crates/linear-cli/src/platform/pager.rs create mode 100644 crates/linear-cli/src/platform/private_file.rs create mode 100644 crates/linear-cli/src/platform/process.rs create mode 100644 crates/linear-cli/src/platform/prompt.rs create mode 100644 crates/linear-cli/src/platform/spinner.rs create mode 100644 crates/linear-cli/src/platform/style.rs create mode 100644 crates/linear-cli/src/platform/terminal_text.rs create mode 100644 crates/linear-cli/src/platform/vcs.rs create mode 100644 crates/linear-cli/src/refs/cycle.rs create mode 100644 crates/linear-cli/src/refs/cycle/number.rs create mode 100644 crates/linear-cli/src/refs/document.rs create mode 100644 crates/linear-cli/src/refs/initiative.rs create mode 100644 crates/linear-cli/src/refs/issue.rs create mode 100644 crates/linear-cli/src/refs/issue/tests.rs create mode 100644 crates/linear-cli/src/refs/mod.rs create mode 100644 crates/linear-cli/src/refs/project.rs create mode 100644 crates/linear-cli/src/refs/release.rs create mode 100644 crates/linear-cli/src/refs/team.rs create mode 100644 crates/linear-cli/src/refs/test_support.rs create mode 100644 crates/linear-cli/src/refs/url.rs create mode 100644 crates/linear-cli/src/refs/url/tests.rs create mode 100644 crates/linear-cli/src/refs/user.rs create mode 100644 crates/linear-cli/src/refs/uuid.rs create mode 100644 crates/linear-cli/src/refs/workflow_states.rs create mode 100644 crates/linear-cli/src/refs/workspace.rs create mode 100644 crates/linear-cli/src/refs/workspace/tests.rs create mode 100644 crates/linear-cli/tests/cli/api.rs create mode 100644 crates/linear-cli/tests/cli/auth.rs create mode 100644 crates/linear-cli/tests/cli/config.rs create mode 100644 crates/linear-cli/tests/cli/cycle.rs create mode 100644 crates/linear-cli/tests/cli/document.rs create mode 100644 crates/linear-cli/tests/cli/errors.rs create mode 100644 crates/linear-cli/tests/cli/harness.rs create mode 100644 crates/linear-cli/tests/cli/initiative.rs create mode 100644 crates/linear-cli/tests/cli/issue_attach.rs create mode 100644 crates/linear-cli/tests/cli/issue_comment.rs create mode 100644 crates/linear-cli/tests/cli/issue_read.rs create mode 100644 crates/linear-cli/tests/cli/issue_vcs.rs create mode 100644 crates/linear-cli/tests/cli/issue_write.rs create mode 100644 crates/linear-cli/tests/cli/label.rs create mode 100644 crates/linear-cli/tests/cli/main.rs create mode 100644 crates/linear-cli/tests/cli/milestone.rs create mode 100644 crates/linear-cli/tests/cli/misc.rs create mode 100644 crates/linear-cli/tests/cli/network.rs create mode 100644 crates/linear-cli/tests/cli/project.rs create mode 100644 crates/linear-cli/tests/cli/prompting.rs create mode 100644 crates/linear-cli/tests/cli/status_update.rs create mode 100644 crates/linear-cli/tests/cli/support/json.rs create mode 100644 crates/linear-cli/tests/cli/support/mock.rs create mode 100644 crates/linear-cli/tests/cli/support/mod.rs create mode 100644 crates/linear-cli/tests/cli/support/sandbox.rs create mode 100644 crates/linear-cli/tests/cli/team.rs create mode 100644 crates/linear-cli/tests/cli/template.rs create mode 100644 crates/linear-cli/tests/cli/user.rs create mode 100644 crates/linear-cli/tests/cli/web.rs create mode 100644 crates/linear-cli/tests/fixtures/agent_session.json create mode 100644 crates/linear-cli/tests/fixtures/team_references.json create mode 100644 crates/linear-cli/tests/fixtures/tls/leaf.key create mode 100644 crates/linear-cli/tests/fixtures/tls/leaf.pem create mode 100644 crates/linear-cli/tests/fixtures/tls/test-ca.pem create mode 100644 crates/linear-cli/tests/fixtures/tls/wrong-ca.pem create mode 100644 crates/linear-schema/Cargo.toml create mode 100644 crates/linear-schema/build.rs create mode 100644 crates/linear-schema/src/lib.rs delete mode 100644 deno.json delete mode 100644 deno.lock delete mode 100644 evals/linear-cli-skill/README.md delete mode 100644 evals/linear-cli-skill/cases.ts delete mode 100644 evals/linear-cli-skill/claude-markdown-cases.ts delete mode 100644 evals/linear-cli-skill/claude-markdown-grade.ts delete mode 100644 evals/linear-cli-skill/fixtures/alternate-comment.md delete mode 100644 evals/linear-cli-skill/fixtures/alternate-description.md delete mode 100644 evals/linear-cli-skill/fixtures/comment.md delete mode 100644 evals/linear-cli-skill/fixtures/issue-description.md delete mode 100644 evals/linear-cli-skill/fixtures/screenshot.png delete mode 100644 evals/linear-cli-skill/fixtures/server.log delete mode 100644 evals/linear-cli-skill/grade.ts delete mode 100644 evals/linear-cli-skill/results/baseline.jsonl delete mode 100644 evals/linear-cli-skill/results/baseline.meta.json delete mode 100644 evals/linear-cli-skill/results/comparison.md delete mode 100644 evals/linear-cli-skill/results/image-baseline.jsonl delete mode 100644 evals/linear-cli-skill/results/image-baseline.meta.json delete mode 100644 evals/linear-cli-skill/results/image-comparison.md delete mode 100644 evals/linear-cli-skill/results/image-gold-labels.jsonl delete mode 100644 evals/linear-cli-skill/results/image-post-change.jsonl delete mode 100644 evals/linear-cli-skill/results/image-post-change.meta.json delete mode 100644 evals/linear-cli-skill/results/mention-baseline.jsonl delete mode 100644 evals/linear-cli-skill/results/mention-comparison.md delete mode 100644 evals/linear-cli-skill/results/mention-post-change.jsonl delete mode 100644 evals/linear-cli-skill/results/post-change.jsonl delete mode 100644 evals/linear-cli-skill/results/post-change.meta.json delete mode 100644 evals/linear-cli-skill/run-claude-markdown.ts delete mode 100644 evals/linear-cli-skill/run.ts delete mode 100755 evals/linear-cli-skill/shims/curl delete mode 100755 evals/linear-cli-skill/shims/linear delete mode 100755 evals/linear-cli-skill/shims/npx delete mode 100644 lefthook.yaml create mode 100644 licenses/supplemental/alloc-stdlib-BSD-3-Clause.txt create mode 100644 licenses/supplemental/cynic-MPL-2.0.txt create mode 100644 licenses/supplemental/inquire-LICENSE-MIT.txt create mode 100644 licenses/supplemental/sources.json create mode 100644 licenses/supplemental/winapi-LICENSE-APACHE.txt create mode 100644 licenses/supplemental/winapi-LICENSE-MIT.txt create mode 100644 rust-toolchain.toml delete mode 100644 src/cli.ts delete mode 100644 src/commands/api.ts delete mode 100644 src/commands/auth/auth-default.ts delete mode 100644 src/commands/auth/auth-list.ts delete mode 100644 src/commands/auth/auth-login.ts delete mode 100644 src/commands/auth/auth-logout.ts delete mode 100644 src/commands/auth/auth-migrate.ts delete mode 100644 src/commands/auth/auth-status.ts delete mode 100644 src/commands/auth/auth-token.ts delete mode 100644 src/commands/auth/auth-whoami.ts delete mode 100644 src/commands/auth/auth.ts delete mode 100644 src/commands/config.ts delete mode 100644 src/commands/cycle/cycle-list.ts delete mode 100644 src/commands/cycle/cycle-view.ts delete mode 100644 src/commands/cycle/cycle.ts delete mode 100644 src/commands/document/attachment-target.ts delete mode 100644 src/commands/document/document-comment-add.ts delete mode 100644 src/commands/document/document-comment-list.ts delete mode 100644 src/commands/document/document-comment.ts delete mode 100644 src/commands/document/document-create.ts delete mode 100644 src/commands/document/document-delete.ts delete mode 100644 src/commands/document/document-list.ts delete mode 100644 src/commands/document/document-update.ts delete mode 100644 src/commands/document/document-view.ts delete mode 100644 src/commands/document/document.ts delete mode 100644 src/commands/initiative-update/initiative-update-create.ts delete mode 100644 src/commands/initiative-update/initiative-update-list.ts delete mode 100644 src/commands/initiative-update/initiative-update.ts delete mode 100644 src/commands/initiative/initiative-add-project.ts delete mode 100644 src/commands/initiative/initiative-archive.ts delete mode 100644 src/commands/initiative/initiative-comment-add.ts delete mode 100644 src/commands/initiative/initiative-comment-list.ts delete mode 100644 src/commands/initiative/initiative-comment.ts delete mode 100644 src/commands/initiative/initiative-create.ts delete mode 100644 src/commands/initiative/initiative-delete.ts delete mode 100644 src/commands/initiative/initiative-list.ts delete mode 100644 src/commands/initiative/initiative-remove-project.ts delete mode 100644 src/commands/initiative/initiative-unarchive.ts delete mode 100644 src/commands/initiative/initiative-update.ts delete mode 100644 src/commands/initiative/initiative-view.ts delete mode 100644 src/commands/initiative/initiative.ts delete mode 100644 src/commands/issue/issue-agent-session-list.ts delete mode 100644 src/commands/issue/issue-agent-session-view.ts delete mode 100644 src/commands/issue/issue-agent-session.ts delete mode 100644 src/commands/issue/issue-archive.ts delete mode 100644 src/commands/issue/issue-attach.ts delete mode 100644 src/commands/issue/issue-comment-add.ts delete mode 100644 src/commands/issue/issue-comment-delete.ts delete mode 100644 src/commands/issue/issue-comment-list.ts delete mode 100644 src/commands/issue/issue-comment-update.ts delete mode 100644 src/commands/issue/issue-comment.ts delete mode 100644 src/commands/issue/issue-commits.ts delete mode 100644 src/commands/issue/issue-create.ts delete mode 100644 src/commands/issue/issue-delete.ts delete mode 100644 src/commands/issue/issue-describe.ts delete mode 100644 src/commands/issue/issue-id.ts delete mode 100644 src/commands/issue/issue-link.ts delete mode 100644 src/commands/issue/issue-mine.ts delete mode 100644 src/commands/issue/issue-pull-request.ts delete mode 100644 src/commands/issue/issue-query.ts delete mode 100644 src/commands/issue/issue-relation.ts delete mode 100644 src/commands/issue/issue-start.ts delete mode 100644 src/commands/issue/issue-title.ts delete mode 100644 src/commands/issue/issue-update.ts delete mode 100644 src/commands/issue/issue-url.ts delete mode 100644 src/commands/issue/issue-view.ts delete mode 100644 src/commands/issue/issue.ts delete mode 100644 src/commands/label/label-create.ts delete mode 100644 src/commands/label/label-delete.ts delete mode 100644 src/commands/label/label-list.ts delete mode 100644 src/commands/label/label.ts delete mode 100644 src/commands/markdown.ts delete mode 100644 src/commands/milestone/milestone-create.ts delete mode 100644 src/commands/milestone/milestone-delete.ts delete mode 100644 src/commands/milestone/milestone-list.ts delete mode 100644 src/commands/milestone/milestone-update.ts delete mode 100644 src/commands/milestone/milestone-view.ts delete mode 100644 src/commands/milestone/milestone.ts delete mode 100644 src/commands/project-update/project-update-create.ts delete mode 100644 src/commands/project-update/project-update-list.ts delete mode 100644 src/commands/project-update/project-update.ts delete mode 100644 src/commands/project/project-comment-add.ts delete mode 100644 src/commands/project/project-comment-list.ts delete mode 100644 src/commands/project/project-comment.ts delete mode 100644 src/commands/project/project-create.ts delete mode 100644 src/commands/project/project-delete.ts delete mode 100644 src/commands/project/project-description.ts delete mode 100644 src/commands/project/project-list.ts delete mode 100644 src/commands/project/project-update.ts delete mode 100644 src/commands/project/project-view.ts delete mode 100644 src/commands/project/project.ts delete mode 100644 src/commands/schema.ts delete mode 100644 src/commands/team/team-autolinks.ts delete mode 100644 src/commands/team/team-create.ts delete mode 100644 src/commands/team/team-delete.ts delete mode 100644 src/commands/team/team-id.ts delete mode 100644 src/commands/team/team-list.ts delete mode 100644 src/commands/team/team-members.ts delete mode 100644 src/commands/team/team-states.ts delete mode 100644 src/commands/team/team.ts delete mode 100644 src/commands/template/template-list.ts delete mode 100644 src/commands/template/template-view.ts delete mode 100644 src/commands/template/template.ts delete mode 100644 src/commands/user/user-list.ts delete mode 100644 src/commands/user/user.ts delete mode 100644 src/config.ts delete mode 100644 src/const.ts delete mode 100644 src/credentials.ts delete mode 100644 src/keyring/index.ts delete mode 100644 src/keyring/linux.ts delete mode 100644 src/keyring/macos.ts delete mode 100644 src/keyring/windows.ts delete mode 100644 src/main.ts delete mode 100644 src/utils/actions.ts delete mode 100644 src/utils/bulk.ts delete mode 100644 src/utils/charmd-hyperlink-extension.ts delete mode 100644 src/utils/comments.ts delete mode 100644 src/utils/display.ts delete mode 100644 src/utils/editor.ts delete mode 100644 src/utils/errors.ts delete mode 100644 src/utils/git.ts delete mode 100644 src/utils/graphql.ts delete mode 100644 src/utils/hyperlink.ts delete mode 100644 src/utils/issue-identifier.ts delete mode 100644 src/utils/jj.ts delete mode 100644 src/utils/linear-url.ts delete mode 100644 src/utils/linear.ts delete mode 100644 src/utils/markdown-help.ts delete mode 100644 src/utils/markdown-images.ts delete mode 100644 src/utils/member-display.ts delete mode 100644 src/utils/pager.ts delete mode 100644 src/utils/prosemirror.ts delete mode 100644 src/utils/styling.ts delete mode 100644 src/utils/templates.ts delete mode 100644 src/utils/upload.ts delete mode 100644 src/utils/vcs.ts delete mode 100644 test/commands/__snapshots__/api.test.ts.snap delete mode 100644 test/commands/api.test.ts delete mode 100644 test/commands/auth/auth-migrate.test.ts delete mode 100644 test/commands/cycle/__snapshots__/cycle-list.test.ts.snap delete mode 100644 test/commands/cycle/__snapshots__/cycle-view.test.ts.snap delete mode 100644 test/commands/cycle/cycle-list.test.ts delete mode 100644 test/commands/cycle/cycle-view.test.ts delete mode 100644 test/commands/document/__snapshots__/document-comment-add.test.ts.snap delete mode 100644 test/commands/document/__snapshots__/document-comment-list.test.ts.snap delete mode 100644 test/commands/document/__snapshots__/document-comment.test.ts.snap delete mode 100644 test/commands/document/__snapshots__/document-create.test.ts.snap delete mode 100644 test/commands/document/__snapshots__/document-delete.test.ts.snap delete mode 100644 test/commands/document/__snapshots__/document-list.test.ts.snap delete mode 100644 test/commands/document/__snapshots__/document-update.test.ts.snap delete mode 100644 test/commands/document/__snapshots__/document-view.test.ts.snap delete mode 100644 test/commands/document/attachment-target.test.ts delete mode 100644 test/commands/document/document-comment-add.test.ts delete mode 100644 test/commands/document/document-comment-list.test.ts delete mode 100644 test/commands/document/document-comment.test.ts delete mode 100644 test/commands/document/document-create.test.ts delete mode 100644 test/commands/document/document-delete.test.ts delete mode 100644 test/commands/document/document-list.test.ts delete mode 100644 test/commands/document/document-update.test.ts delete mode 100644 test/commands/document/document-view.test.ts delete mode 100644 test/commands/fixtures/api-filter.json delete mode 100644 test/commands/initiative-update/__snapshots__/initiative-update-create.test.ts.snap delete mode 100644 test/commands/initiative-update/__snapshots__/initiative-update-list.test.ts.snap delete mode 100644 test/commands/initiative-update/initiative-update-create.test.ts delete mode 100644 test/commands/initiative-update/initiative-update-list.test.ts delete mode 100644 test/commands/initiative/__snapshots__/initiative-comment-add.test.ts.snap delete mode 100644 test/commands/initiative/__snapshots__/initiative-comment-list.test.ts.snap delete mode 100644 test/commands/initiative/__snapshots__/initiative-comment.test.ts.snap delete mode 100644 test/commands/initiative/__snapshots__/initiative-list.test.ts.snap delete mode 100644 test/commands/initiative/__snapshots__/initiative-view.test.ts.snap delete mode 100644 test/commands/initiative/initiative-comment-add.test.ts delete mode 100644 test/commands/initiative/initiative-comment-list.test.ts delete mode 100644 test/commands/initiative/initiative-comment.test.ts delete mode 100644 test/commands/initiative/initiative-list.test.ts delete mode 100644 test/commands/initiative/initiative-unarchive.test.ts delete mode 100644 test/commands/initiative/initiative-view.test.ts delete mode 100644 test/commands/issue/__snapshots__/issue-agent-session-list.test.ts.snap delete mode 100644 test/commands/issue/__snapshots__/issue-agent-session-view.test.ts.snap delete mode 100644 test/commands/issue/__snapshots__/issue-archive.test.ts.snap delete mode 100644 test/commands/issue/__snapshots__/issue-attach.test.ts.snap delete mode 100644 test/commands/issue/__snapshots__/issue-comment-add.test.ts.snap delete mode 100644 test/commands/issue/__snapshots__/issue-comment-delete.test.ts.snap delete mode 100644 test/commands/issue/__snapshots__/issue-comment-list.test.ts.snap delete mode 100644 test/commands/issue/__snapshots__/issue-comment-update.test.ts.snap delete mode 100644 test/commands/issue/__snapshots__/issue-commits.test.ts.snap delete mode 100644 test/commands/issue/__snapshots__/issue-create.test.ts.snap delete mode 100644 test/commands/issue/__snapshots__/issue-describe.test.ts.snap delete mode 100644 test/commands/issue/__snapshots__/issue-link.test.ts.snap delete mode 100644 test/commands/issue/__snapshots__/issue-list.test.ts.snap delete mode 100644 test/commands/issue/__snapshots__/issue-mine.test.ts.snap delete mode 100644 test/commands/issue/__snapshots__/issue-pull-request.test.ts.snap delete mode 100644 test/commands/issue/__snapshots__/issue-query.test.ts.snap delete mode 100644 test/commands/issue/__snapshots__/issue-relation.test.ts.snap delete mode 100644 test/commands/issue/__snapshots__/issue-update.test.ts.snap delete mode 100644 test/commands/issue/__snapshots__/issue-view.test.ts.snap delete mode 100644 test/commands/issue/image-download.test.ts delete mode 100644 test/commands/issue/issue-agent-session-list.test.ts delete mode 100644 test/commands/issue/issue-agent-session-view.test.ts delete mode 100644 test/commands/issue/issue-archive.test.ts delete mode 100644 test/commands/issue/issue-attach.test.ts delete mode 100644 test/commands/issue/issue-comment-add.test.ts delete mode 100644 test/commands/issue/issue-comment-delete.test.ts delete mode 100644 test/commands/issue/issue-comment-list.test.ts delete mode 100644 test/commands/issue/issue-comment-update.test.ts delete mode 100644 test/commands/issue/issue-commits.test.ts delete mode 100644 test/commands/issue/issue-create.test.ts delete mode 100644 test/commands/issue/issue-describe.test.ts delete mode 100644 test/commands/issue/issue-link.test.ts delete mode 100644 test/commands/issue/issue-list.test.ts delete mode 100644 test/commands/issue/issue-mine.test.ts delete mode 100644 test/commands/issue/issue-pull-request.test.ts delete mode 100644 test/commands/issue/issue-query.test.ts delete mode 100644 test/commands/issue/issue-relation.test.ts delete mode 100644 test/commands/issue/issue-start.test.ts delete mode 100644 test/commands/issue/issue-update.test.ts delete mode 100644 test/commands/issue/issue-url-reference.test.ts delete mode 100644 test/commands/issue/issue-view.test.ts delete mode 100644 test/commands/label/__snapshots__/label-create.test.ts.snap delete mode 100644 test/commands/label/__snapshots__/label-delete.test.ts.snap delete mode 100644 test/commands/label/__snapshots__/label-list.test.ts.snap delete mode 100644 test/commands/label/label-create.test.ts delete mode 100644 test/commands/label/label-delete.test.ts delete mode 100644 test/commands/label/label-list.test.ts delete mode 100644 test/commands/markdown.test.ts delete mode 100644 test/commands/milestone/__snapshots__/milestone-create.test.ts.snap delete mode 100644 test/commands/milestone/__snapshots__/milestone-delete.test.ts.snap delete mode 100644 test/commands/milestone/__snapshots__/milestone-list.test.ts.snap delete mode 100644 test/commands/milestone/__snapshots__/milestone-update.test.ts.snap delete mode 100644 test/commands/milestone/__snapshots__/milestone-view.test.ts.snap delete mode 100644 test/commands/milestone/milestone-create.test.ts delete mode 100644 test/commands/milestone/milestone-delete.test.ts delete mode 100644 test/commands/milestone/milestone-list.test.ts delete mode 100644 test/commands/milestone/milestone-update.test.ts delete mode 100644 test/commands/milestone/milestone-view.test.ts delete mode 100644 test/commands/project-update/__snapshots__/project-update-create.test.ts.snap delete mode 100644 test/commands/project-update/__snapshots__/project-update-list.test.ts.snap delete mode 100644 test/commands/project-update/project-update-create.test.ts delete mode 100644 test/commands/project-update/project-update-list.test.ts delete mode 100644 test/commands/project/__snapshots__/project-comment-add.test.ts.snap delete mode 100644 test/commands/project/__snapshots__/project-comment-list.test.ts.snap delete mode 100644 test/commands/project/__snapshots__/project-comment.test.ts.snap delete mode 100644 test/commands/project/__snapshots__/project-create.test.ts.snap delete mode 100644 test/commands/project/__snapshots__/project-delete.test.ts.snap delete mode 100644 test/commands/project/__snapshots__/project-list.test.ts.snap delete mode 100644 test/commands/project/__snapshots__/project-update.test.ts.snap delete mode 100644 test/commands/project/__snapshots__/project-view.test.ts.snap delete mode 100644 test/commands/project/project-comment-add.test.ts delete mode 100644 test/commands/project/project-comment-list.test.ts delete mode 100644 test/commands/project/project-comment.test.ts delete mode 100644 test/commands/project/project-create.test.ts delete mode 100644 test/commands/project/project-delete.test.ts delete mode 100644 test/commands/project/project-description.test.ts delete mode 100644 test/commands/project/project-list.test.ts delete mode 100644 test/commands/project/project-update.test.ts delete mode 100644 test/commands/project/project-view.test.ts delete mode 100644 test/commands/team/__snapshots__/team-autolinks.test.ts.snap delete mode 100644 test/commands/team/__snapshots__/team-delete.test.ts.snap delete mode 100644 test/commands/team/__snapshots__/team-id.test.ts.snap delete mode 100644 test/commands/team/__snapshots__/team-list.test.ts.snap delete mode 100644 test/commands/team/__snapshots__/team-members.test.ts.snap delete mode 100644 test/commands/team/__snapshots__/team-states.test.ts.snap delete mode 100644 test/commands/team/team-autolinks.test.ts delete mode 100644 test/commands/team/team-create.test.ts delete mode 100644 test/commands/team/team-delete.test.ts delete mode 100644 test/commands/team/team-id.test.ts delete mode 100644 test/commands/team/team-list.test.ts delete mode 100644 test/commands/team/team-members.test.ts delete mode 100644 test/commands/team/team-states.test.ts delete mode 100644 test/commands/template/__snapshots__/template-list.test.ts.snap delete mode 100644 test/commands/template/__snapshots__/template-view.test.ts.snap delete mode 100644 test/commands/template/template-list.test.ts delete mode 100644 test/commands/template/template-view.test.ts delete mode 100644 test/commands/user/__snapshots__/user-list.test.ts.snap delete mode 100644 test/commands/user/user-list.test.ts delete mode 100644 test/config.test.ts delete mode 100644 test/credentials.test.ts delete mode 100644 test/evals/linear-cli-skill-claude-markdown-grade.test.ts delete mode 100644 test/evals/linear-cli-skill-grade.test.ts delete mode 100644 test/keyring.integration.test.ts delete mode 100644 test/keyring.test.ts delete mode 100644 test/main_test.ts delete mode 100644 test/utils/comments.test.ts delete mode 100644 test/utils/display.test.ts delete mode 100644 test/utils/errors.test.ts delete mode 100644 test/utils/git.test.ts delete mode 100644 test/utils/graphql.test.ts delete mode 100644 test/utils/hyperlink.test.ts delete mode 100644 test/utils/issue-identifier.test.ts delete mode 100644 test/utils/jj.test.ts delete mode 100644 test/utils/linear-url.property.test.ts delete mode 100644 test/utils/linear-url.test.ts delete mode 100644 test/utils/linear.test.ts delete mode 100644 test/utils/markdown-help.test.ts delete mode 100644 test/utils/mock_linear_server.ts delete mode 100644 test/utils/pager.test.ts delete mode 100644 test/utils/prosemirror.test.ts delete mode 100644 test/utils/snapshot_with_fake_time.ts delete mode 100644 test/utils/templates.test.ts delete mode 100644 test/utils/test-helpers.ts delete mode 100644 test/utils/upload.test.ts delete mode 100644 test/utils/vcs.test.ts create mode 100644 xtask/Cargo.toml create mode 100644 xtask/src/licenses.rs create mode 100644 xtask/src/licenses/tests.rs create mode 100644 xtask/src/main.rs diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 000000000..856707a79 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,2 @@ +[alias] +xtask = "run --locked --package xtask --" diff --git a/.editorconfig b/.editorconfig index 96d47452f..b29c9ab3d 100644 --- a/.editorconfig +++ b/.editorconfig @@ -10,3 +10,6 @@ indent_size = 2 [**.{md,markdown}] trim_trailing_whitespace = false + +[*.rs] +indent_size = 4 diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs deleted file mode 100644 index e3b4524d7..000000000 --- a/.git-blame-ignore-revs +++ /dev/null @@ -1,2 +0,0 @@ -# Ignore formatting commits -4bb3bef0c7c9c42cd942fdc2acf03c69e4cac97b \ No newline at end of file diff --git a/.gitattributes b/.gitattributes index ca68e034e..8a1eb65f3 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,3 +1,2 @@ graphql/schema.graphql linguist-generated=true -__generated__/** -diff linguist-generated=true -graphql/** -diff linguist-generated=true \ No newline at end of file +graphql/** -diff linguist-generated=true diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml index 2653f3e15..ed6d9b0b1 100644 --- a/.github/ISSUE_TEMPLATE/bug-report.yml +++ b/.github/ISSUE_TEMPLATE/bug-report.yml @@ -31,7 +31,7 @@ body: attributes: label: Version description: Output of `linear --version`. - placeholder: linear 2.5.0 + placeholder: linear 3.0.0 validations: required: true @@ -48,5 +48,5 @@ body: id: output attributes: label: Relevant output - description: Error messages or logs. Run with `LINEAR_DEBUG=1` for a stack trace. Remove API keys and other sensitive data. + description: Error messages or logs. Run with `LINEAR_DEBUG=1` to include the underlying causes of an error. Remove API keys and other sensitive data. render: shell diff --git a/.github/build-setup.yml b/.github/build-setup.yml index fab685b89..3131a32e4 100644 --- a/.github/build-setup.yml +++ b/.github/build-setup.yml @@ -1,6 +1,5 @@ -- name: Setup deno - uses: denoland/setup-deno@v2 - with: - deno-version: 2.7.9 -- name: Generate GraphQL code - run: deno task codegen +# Steps dist runs before `dist build` in each build-local-artifacts job of +# release.yml (`github-build-setup` in dist-workspace.toml). Edit this file, +# then run `just dist-generate`. +- name: Generate third-party license notices + run: cargo xtask licenses diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml deleted file mode 100644 index ce92b82ca..000000000 --- a/.github/workflows/ci.yaml +++ /dev/null @@ -1,64 +0,0 @@ -name: CI - -on: - pull_request: - push: - -jobs: - check: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - - uses: denoland/setup-deno@v2 - with: - deno-version: "2.7.9" - - - name: Generate GraphQL code - run: deno task codegen - - - name: Check formatting - run: deno fmt --check - - - name: Lint code - run: deno lint - - - name: Type check - run: deno task check - - - name: Run tests - run: deno task test --ignore=test/keyring.integration.test.ts - - - name: Install linear-cli for skill generation - run: deno task install - - - name: Verify skill docs generate successfully - run: deno task generate-skill-docs - - keyring-integration: - strategy: - matrix: - include: - - os: macos-latest - - os: ubuntu-latest - - os: windows-latest - runs-on: ${{ matrix.os }} - steps: - - uses: actions/checkout@v3 - - - uses: denoland/setup-deno@v2 - with: - deno-version: "2.7.9" - - - name: Set up Secret Service - if: runner.os == 'Linux' - run: | - sudo apt-get update && sudo apt-get install -y gnome-keyring libsecret-tools dbus-x11 - eval "$(dbus-launch --sh-syntax)" - echo "DBUS_SESSION_BUS_ADDRESS=$DBUS_SESSION_BUS_ADDRESS" >> $GITHUB_ENV - echo "test-password" | gnome-keyring-daemon --unlock --components=secrets - - - name: Keyring Integration - env: - LINEAR_KEYRING_INTEGRATION: "1" - run: deno task test test/keyring.integration.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000..63ba4dfc9 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,65 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + test: + name: test (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + optional: false + - os: macos-latest + optional: false + - os: windows-latest + optional: true + runs-on: ${{ matrix.os }} + continue-on-error: ${{ matrix.optional }} + defaults: + run: + shell: bash + env: + CARGO_BUILD_JOBS: "2" + steps: + - uses: actions/checkout@v6 + # rust-toolchain.toml pins the version and components. + - name: Install Rust + run: rustup toolchain install + - name: Check formatting + if: runner.os == 'Linux' + run: cargo fmt --all -- --check + - name: Clippy + run: cargo clippy --locked --workspace --all-targets -- -D warnings + - name: Test + run: cargo test --locked --workspace --no-fail-fast + - name: Set up Secret Service + if: runner.os == 'Linux' + run: | + sudo apt-get update && sudo apt-get install -y gnome-keyring libsecret-tools dbus-x11 + eval "$(dbus-launch --sh-syntax)" + echo "DBUS_SESSION_BUS_ADDRESS=$DBUS_SESSION_BUS_ADDRESS" >> "$GITHUB_ENV" + echo "test-password" | gnome-keyring-daemon --unlock --components=secrets + - name: System keyring round trip + run: cargo test --locked --workspace --lib -- --ignored system_keyring_round_trip + - name: Check skill docs are current + if: runner.os == 'Linux' + run: | + cargo run --locked --example skill_docs + if [ -n "$(git status --porcelain skills)" ]; then + git status --short skills + echo "skills/linear-cli is out of date; run 'just skill-docs'" >&2 + exit 1 + fi + # Releases generate these notices; this catches a crate without a + # license file before release time. + - name: Generate third-party license notices + if: runner.os == 'Linux' + run: cargo xtask licenses --output "$RUNNER_TEMP/THIRD_PARTY_LICENSES.md" diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml deleted file mode 100644 index df12f387e..000000000 --- a/.github/workflows/publish.yaml +++ /dev/null @@ -1,23 +0,0 @@ -# .github/workflows/publish.yml - -name: Publish - -on: - push: - tags: - - "v*" - -jobs: - publish: - runs-on: ubuntu-latest - permissions: - contents: read - id-token: write # The OIDC ID token is used for authentication with JSR. - steps: - - uses: actions/checkout@v4 - - uses: denoland/setup-deno@v2 - with: - deno-version: 2.7.9 - - name: Generate GraphQL code - run: deno task codegen - - run: npx jsr publish diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index af08484a6..e4f097e82 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -127,12 +127,8 @@ jobs: curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y echo "$HOME/.cargo/bin" >> $GITHUB_PATH fi - - name: "Setup deno" - uses: "denoland/setup-deno@v2" - with: - "deno-version": "2.7.9" - - name: "Generate GraphQL code" - run: "deno task codegen" + - name: "Generate third-party license notices" + run: "cargo xtask licenses" - name: Install dist run: ${{ matrix.install_dist.run }} # Get the dist-manifest diff --git a/.gitignore b/.gitignore index 814b580ae..960225e58 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,4 @@ -.aider.* .config/linear.toml .idea/ -src/__codegen__/ -npm +/target/ +/THIRD_PARTY_LICENSES.md diff --git a/.graphqlrc.yaml b/.graphqlrc.yaml deleted file mode 100644 index 4e4ea063b..000000000 --- a/.graphqlrc.yaml +++ /dev/null @@ -1,3 +0,0 @@ -# vscode extension: GraphQL.vscode-graphql -schema: "graphql/schema.graphql" -documents: "**/*.{ts,graphql}" diff --git a/.zed/settings.json b/.zed/settings.json deleted file mode 100644 index 66234f799..000000000 --- a/.zed/settings.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "lsp": { - "deno": { - "settings": { - "deno": { - "enable": true - } - } - } - }, - "languages": { - "TypeScript": { - "language_servers": [ - "deno", - "!typescript-language-server", - "!vtsls", - "!eslint" - ], - "formatter": "language_server" - }, - "TSX": { - "language_servers": [ - "deno", - "!typescript-language-server", - "!vtsls", - "!eslint" - ], - "formatter": "language_server" - } - } -} diff --git a/CONVENTIONS.md b/CONVENTIONS.md deleted file mode 100644 index 30454fa77..000000000 --- a/CONVENTIONS.md +++ /dev/null @@ -1 +0,0 @@ -- this is a deno project diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 000000000..86a8a468d --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2815 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "addr2line" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aliasable" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "250f629c0161ad8107cf89319e990051fae62832fd343083bea452d93e2205fd" + +[[package]] +name = "alloc-no-stdlib" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2fb6cfd47bf496ff64095c20eaba0c201404ee38714d4142fcfa1dc334fcc7a" + +[[package]] +name = "alloc-stdlib" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5c1865780388bfa186411ab5f247819487fc4864c6e9c3106611fa347586e1" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "async-compression" +version = "0.4.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee19bd99b43e3691acbad4e840420a4881cea6c0b66a208125a824f8fd53f5a1" +dependencies = [ + "compression-codecs", + "compression-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "backtrace" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide 0.8.9", + "object", + "rustc-demangle", + "windows-link", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "beef" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a8241f3ebb85c056b509d4327ad0358fbbba6ffb340bf388f26350aeda225b1" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "brotli" +version = "9.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8b851b75c23ca7873623d612fe49bd1989aeb03d08fb9432187eb253d3d4c6b" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "6.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "941cd9bd4ddab83cb46fa5a2d428f1c857b24ac78cb876cf7beb710840934bd7" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytecheck" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26333eeac754f0ad8a6bcd0eb0ac012156302e4e16b852b72ee399aea4f12c29" +dependencies = [ + "bytecheck_derive", + "ptr_meta", + "rancor", + "simdutf8", +] + +[[package]] +name = "bytecheck_derive" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46d07918caa9eeaaf06b7873925c53a61daac173539b4f7715090745e44e4e69" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54413ede23c2daf518f35156dfde027feb2374004d63bd497f983c8db9c0e313" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures", + "rand_core", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "windows-link", +] + +[[package]] +name = "clap" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" +dependencies = [ + "anstyle", + "clap_lex", + "strsim 0.11.1", + "terminal_size", +] + +[[package]] +name = "clap_complete" +version = "4.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037e2a1a92236d0aff7e845093f64661d6df4c02c9fcc61a60e9e1d736fa392f" +dependencies = [ + "clap", + "clap_lex", + "is_executable", + "shlex", +] + +[[package]] +name = "clap_derive" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "clap_lex" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" + +[[package]] +name = "compression-codecs" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98fc98460ba0ad5317075d3632b8dfc45d0be8c4a49347c2a38272019717614a" +dependencies = [ + "brotli", + "compression-core", + "flate2", + "memchr", +] + +[[package]] +name = "compression-core" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e8ccc4ea9f6acc32d102c0f6d471d11d913ad15f20c04de743374861fa1d414" + +[[package]] +name = "console" +version = "0.16.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e96a4956774c13c126a8b5af4daa79384f4d826534c95a02d76afb39e2ab64e3" +dependencies = [ + "encode_unicode", + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags", + "crossterm_winapi", + "derive_more", + "document-features", + "mio", + "parking_lot", + "rustix", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "cynic" +version = "3.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6260314e64d59ff1a962d465573ac6775fbf4624aa67613e220eb61f4a3d9a5c" +dependencies = [ + "cynic-proc-macros", + "ref-cast", + "serde", + "static_assertions", + "thiserror 1.0.69", +] + +[[package]] +name = "cynic-codegen" +version = "3.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04e68e191fc65c3b8a5b467368ef8e3d5f610ef770df664d5cca51de573c3010" +dependencies = [ + "cynic-parser", + "darling", + "once_cell", + "ouroboros", + "proc-macro2", + "quote", + "rkyv", + "strsim 0.10.0", + "syn 2.0.119", + "thiserror 1.0.69", +] + +[[package]] +name = "cynic-introspection" +version = "3.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4ef515dd3878d116f8a7f03e467a0aa1a36d44e64fe58cc5047993aa91c37f0" +dependencies = [ + "cynic", + "cynic-codegen", + "indenter", + "thiserror 1.0.69", +] + +[[package]] +name = "cynic-parser" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7793e157a59a0bd2142a5723860aeaef3aa459feae1467b597351e5f58251904" +dependencies = [ + "indexmap", + "lalrpop-util", + "logos", +] + +[[package]] +name = "cynic-proc-macros" +version = "3.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca74b7363c9c675b6539aaf151c695cac83d3e7abc80dfbfbfdbfef27e4ec20" +dependencies = [ + "cynic-codegen", + "darling", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim 0.11.1", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "find-msvc-tools" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25905e51abafe4dcea6c15fec58c57b601cdbd0ee53d22ea1d3016c587d39b" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide 0.9.1", + "zlib-rs", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix", + "windows-link", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "rand_core", + "wasm-bindgen", +] + +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hybrid-array" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" +dependencies = [ + "typenum", +] + +[[package]] +name = "hyper" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-native-certs", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collator" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08984ed58ac439ebf3e13d2cf26b0c46a60afcd21721c1d14087c0b240344dda" +dependencies = [ + "icu_collator_data", + "icu_collections", + "icu_locale_core", + "icu_locale_fallback", + "icu_normalizer", + "icu_properties", + "icu_provider", + "smallvec", + "utf16_iter", + "utf8_iter", + "zerovec", +] + +[[package]] +name = "icu_collator_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7d7e54efdddeb1208c08dd5d32b53a879ac00d2d3d051b2255fd26821d16368" + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "serde", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_locale_fallback" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "251af8e57c9400e3eb58242fe5b8b1152b2a64fdf4cf632f923c38ccee6f2fa9" +dependencies = [ + "icu_locale_core", + "icu_locale_fallback_data", + "icu_provider", + "potential_utf", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_locale_fallback_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "decf2a22ec8fa68f1a0c1129a3f8583f8f8bc24e8b9ccbe98ead99f62a4dc3a8" + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "utf16_iter", + "utf8_iter", + "write16", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "serde", + "stable_deref_trait", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indenter" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5" + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "inquire" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6654738b8024300cf062d04a1c13c10c8e2cea598ec1c47dc9b6641159429756" +dependencies = [ + "bitflags", + "crossterm", + "dyn-clone", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "io-uring" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3bd0ecfbb87805f538bb7b32e5239ca0763890c623e349860ecba69469f2bb" +dependencies = [ + "bitflags", + "cfg-if", + "libc", +] + +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + +[[package]] +name = "is_executable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82cb6a9f675da968c63b6208c641b9dca58fc0133ae53375736b1767b0cab8bd" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "keyring" +version = "3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" +dependencies = [ + "byteorder", + "log", + "windows-sys 0.60.2", + "zeroize", +] + +[[package]] +name = "lalrpop-util" +version = "0.22.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5baa5e9ff84f1aefd264e6869907646538a52147a755d494517a8007fb48733" +dependencies = [ + "rustversion", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "linear" +version = "3.0.0-alpha.1" +dependencies = [ + "chrono", + "clap", + "clap_complete", + "console", + "crossterm", + "cynic", + "cynic-codegen", + "cynic-introspection", + "futures-util", + "gethostname", + "icu_collator", + "inquire", + "keyring", + "libc", + "linear-schema", + "pulldown-cmark", + "reqwest", + "serde", + "serde_json", + "sha2", + "signal-hook", + "tempfile", + "terminal_size", + "tokio", + "tokio-rustls", + "toml", + "unicode-width", +] + +[[package]] +name = "linear-schema" +version = "3.0.0-alpha.1" +dependencies = [ + "cynic", + "cynic-codegen", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "logos" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7251356ef8cb7aec833ddf598c6cb24d17b689d20b993f9d11a3d764e34e6458" +dependencies = [ + "logos-derive", +] + +[[package]] +name = "logos-codegen" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59f80069600c0d66734f5ff52cc42f2dabd6b29d205f333d61fd7832e9e9963f" +dependencies = [ + "beef", + "fnv", + "lazy_static", + "proc-macro2", + "quote", + "regex-syntax", + "syn 2.0.119", +] + +[[package]] +name = "logos-derive" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24fb722b06a9dc12adb0963ed585f19fc61dc5413e6a9be9422ef92c091e731d" +dependencies = [ + "logos-codegen", +] + +[[package]] +name = "lru-slab" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", +] + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "munge" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e17401f259eba956ca16491461b6e8f72913a0a114e39736ce404410f915a0c" +dependencies = [ + "munge_macro", +] + +[[package]] +name = "munge_macro" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4568f25ccbd45ab5d5603dc34318c1ec56b117531781260002151b8530a9f931" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "ouroboros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0f050db9c44b97a94723127e6be766ac5c340c48f2c4bb3ffa11713744be59" +dependencies = [ + "aliasable", + "ouroboros_macro", + "static_assertions", +] + +[[package]] +name = "ouroboros_macro" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c7028bdd3d43083f6d8d4d5187680d0d3560d54df4cc9d752005268b41e64d0" +dependencies = [ + "heck 0.4.1", + "proc-macro2", + "proc-macro2-diagnostics", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "serde_core", + "writeable", + "zerovec", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "proc-macro2-diagnostics" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "version_check", + "yansi", +] + +[[package]] +name = "ptr_meta" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743da816b98c921cdbe8628ef7381b76f25ecf4da599fc80aca90eae7ef70cc0" +dependencies = [ + "ptr_meta_derive", +] + +[[package]] +name = "ptr_meta_derive" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c8d9ca532f185d5d4db7a7c9d51420b452168ea1c2b913953281bd6fe1fcbd0" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "pulldown-cmark" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e" +dependencies = [ + "bitflags", + "memchr", + "unicase", +] + +[[package]] +name = "quinn" +version = "0.11.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.21", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9746dbde176634f4f2f1faf2404e30a31b2bc1e9cafb5329c95d8177a18c9fc" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.21", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rancor" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b534442d0fcdb55d66f373d9cac6d33b6293a2335bc2136dbd06ce0e87d2572" +dependencies = [ + "ptr_meta", +] + +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "ref-cast" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rend" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "663ba70707f96e871406fe10d68128412e619b06d1d47cb91c3a4c6501176240" +dependencies = [ + "bytecheck", +] + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rkyv" +version = "0.8.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9776093b7ca170454ab1406954f7b7d97a57c51dc6c0642957fb2ef25c2d399" +dependencies = [ + "bytecheck", + "bytes", + "hashbrown", + "indexmap", + "munge", + "ptr_meta", + "rancor", + "rend", + "rkyv_derive", + "tinyvec", + "uuid", +] + +[[package]] +name = "rkyv_derive" +version = "0.8.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c25ef604ac7dd839d44d64648952ea23c97866f124ff671b0ed2cf3ad9bb06e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb" + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "indexmap", + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "terminal_size" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" +dependencies = [ + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl 2.0.21", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "serde_core", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + +[[package]] +name = "tokio" +version = "1.47.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2c4f7c65d84fedd1f7593fd4f45d021c957555b981cbb8fdb0bad95ac9e5d10" +dependencies = [ + "backtrace", + "bytes", + "io-uring", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "slab", + "socket2", + "tokio-macros", + "windows-sys 0.59.0", +] + +[[package]] +name = "tokio-macros" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "1.0.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7614eaf19ad818347db24addfa201729cf2a9b6fdfd9eb0ab870fcacc606c0c" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.0.0+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32c2555c699578a4f59f0cc68e5116c8d7cabbd45e1409b989d4be085b53f13e" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.0.9+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.0.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "async-compression", + "bitflags", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "pin-project-lite", + "tokio", + "tokio-util", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf16_iter" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.78" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.6", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.7.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" + +[[package]] +name = "write16" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "xtask" +version = "0.0.0" +dependencies = [ + "clap", + "serde", + "serde_json", + "sha2", + "tempfile", +] + +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "serde", + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 000000000..90d14aaec --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,16 @@ +[workspace] +members = ["crates/linear-cli", "crates/linear-schema", "xtask"] +# Keeps plain `cargo run` and `cargo build` on the cli; `--workspace` includes xtask. +default-members = ["crates/linear-cli", "crates/linear-schema"] +resolver = "3" + +[workspace.package] +version = "3.0.0-alpha.1" +edition = "2024" +rust-version = "1.93" +license = "ISC" +repository = "https://github.com/schpet/linear-cli" +homepage = "https://github.com/schpet/linear-cli" + +[profile.dist] +inherits = "release" diff --git a/clippy.toml b/clippy.toml new file mode 100644 index 000000000..99362e253 --- /dev/null +++ b/clippy.toml @@ -0,0 +1,4 @@ +# Tests may index, unwrap and panic freely; production code may not (see lib.rs). +allow-indexing-slicing-in-tests = true +allow-panic-in-tests = true +allow-unwrap-in-tests = true diff --git a/codegen.ts b/codegen.ts deleted file mode 100644 index f47c88e9d..000000000 --- a/codegen.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { CodegenConfig } from "@graphql-codegen/cli" - -const config: CodegenConfig = { - schema: "graphql/schema.graphql", - documents: ["src/**/*.ts"], - generates: { - "src/__codegen__/": { - preset: "client", - plugins: [], - config: { - enumsAsTypes: true, - }, - presetConfig: { - gqlTagName: "gql", - fragmentMasking: false, - }, - }, - }, -} - -export default config diff --git a/crates/linear-cli/Cargo.toml b/crates/linear-cli/Cargo.toml new file mode 100644 index 000000000..e99d6b880 --- /dev/null +++ b/crates/linear-cli/Cargo.toml @@ -0,0 +1,70 @@ +[package] +name = "linear" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +publish = false +license.workspace = true +repository.workspace = true +homepage.workspace = true +description = "Work with Linear issues, projects and teams from the command line" + +[dependencies] +linear-schema = { path = "../linear-schema" } +clap = { version = "4.6.7", default-features = false, features = ["std", "help", "usage", "string", "error-context", "derive", "suggestions", "wrap_help"] } +clap_complete = { version = "4.6.11", default-features = false, features = ["unstable-dynamic"] } +unicode-width = "0.2.2" +chrono = { version = "0.4.45", default-features = false, features = ["std", "clock"] } +icu_collator = { version = "2.3.1", default-features = false, features = ["compiled_data"] } +terminal_size = "0.4.4" +console = { version = "0.16.6", default-features = false, features = ["std"] } +pulldown-cmark = { version = "0.13.4", default-features = false } +gethostname = "1.1.0" +cynic = { version = "3.14.0", default-features = false, features = ["rkyv"] } +cynic-introspection = "3.14" +serde = { version = "1", features = ["derive"] } +serde_json = { version = "1", features = ["preserve_order", "float_roundtrip"] } +toml = { version = "1.0.3", default-features = false, features = ["parse", "display", "std", "serde", "preserve_order"] } +reqwest = { version = "0.12.28", default-features = false, features = ["rustls-tls-native-roots", "rustls-tls-webpki-roots", "gzip", "brotli", "deflate"] } +tokio = { version = "1.47.4", default-features = false, features = ["rt", "time", "macros", "net", "process", "io-util"] } + +sha2 = { version = "0.11.0", default-features = false } + +futures-util = { version = "0.3.34", default-features = false, features = ["std"] } +inquire = { version = "0.9", default-features = false, features = ["crossterm"] } +crossterm = "0.29" + +[target.'cfg(unix)'.dependencies] +libc = "0.2.189" +signal-hook = { version = "0.3.18", default-features = false } + +[dev-dependencies] +tempfile = "3" +tokio = { version = "1.47.4", default-features = false, features = ["process", "io-util"] } +tokio-rustls = { version = "0.26.5", default-features = false, features = ["ring", "tls12"] } + +[lib] +name = "linear_cli" +path = "src/lib.rs" +doctest = false + +[[bin]] +name = "linear" +path = "src/main.rs" + +[build-dependencies] +cynic-codegen = { version = "3.14.0", features = ["rkyv"] } + +[[test]] +name = "cli" +path = "tests/cli/main.rs" + +[target.'cfg(windows)'.dependencies] +keyring = { version = "3.6.3", default-features = false, features = ["windows-native"] } + +[package.metadata.dist] +dist = true +# Ship the third-party license notices next to the README, CHANGELOG and +# LICENSE that dist includes on its own. Release builds generate the file +# first (see .github/build-setup.yml); locally run `cargo xtask licenses`. +include = ["../../THIRD_PARTY_LICENSES.md"] diff --git a/crates/linear-cli/build.rs b/crates/linear-cli/build.rs new file mode 100644 index 000000000..4e0329d0b --- /dev/null +++ b/crates/linear-cli/build.rs @@ -0,0 +1,18 @@ +//! Registers the Linear GraphQL schema in `graphql/schema.graphql` with cynic. + +use std::path::PathBuf; + +fn main() { + let manifest_dir = PathBuf::from( + std::env::var_os("CARGO_MANIFEST_DIR").expect("cargo sets CARGO_MANIFEST_DIR"), + ); + let schema_path = manifest_dir.join("../../graphql/schema.graphql"); + println!("cargo::rerun-if-changed={}", schema_path.display()); + println!("cargo::rerun-if-changed=build.rs"); + if let Err(error) = cynic_codegen::register_schema("linear").from_sdl_file(&schema_path) { + println!( + "cargo::error=failed to register Linear schema from {}: {error}", + schema_path.display() + ); + } +} diff --git a/crates/linear-cli/examples/skill_docs.rs b/crates/linear-cli/examples/skill_docs.rs new file mode 100644 index 000000000..a9945a7f7 --- /dev/null +++ b/crates/linear-cli/examples/skill_docs.rs @@ -0,0 +1,216 @@ +//! Regenerates the agent skill in `skills/linear-cli/` from the CLI's own help: +//! `SKILL.md` (from `SKILL.template.md`) and one reference file per command. +//! +//! Run with `cargo run --example skill_docs`. + +use std::error::Error; +use std::fs; +use std::path::{Path, PathBuf}; + +/// Hand-written references kept alongside the generated ones. +const PRESERVED: &[&str] = &["organization-features.md"]; + +/// Help is wrapped at a fixed width so the output does not depend on the +/// terminal that runs the generator. +const HELP_WIDTH: usize = 100; + +struct CommandDoc { + name: String, + path: Vec, + description: String, + help: String, + subcommands: Vec, +} + +fn main() -> Result<(), Box> { + let skill_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../skills/linear-cli"); + let mut root = linear_cli::cli::command().term_width(HELP_WIDTH); + root.build(); + + let mut commands = documented_subcommands(&root) + .filter(|command| command.get_name() != "completions") + .map(|command| document(&root, command, Vec::new())) + .collect::, _>>()?; + commands.sort_by(|a, b| a.name.cmp(&b.name)); + + // Render everything before touching the skill directory, so a failure + // leaves the committed docs intact. + let template = fs::read_to_string(skill_dir.join("SKILL.template.md"))?; + for placeholder in ["{{COMMANDS}}", "{{REFERENCE_TOC}}"] { + if !template.contains(placeholder) { + return Err(format!("SKILL.template.md is missing {placeholder}").into()); + } + } + let skill = template + .replace("{{COMMANDS}}", &command_list(&commands)) + .replace("{{REFERENCE_TOC}}", &reference_toc(&commands)); + let mut references: Vec<(String, String)> = commands + .iter() + .map(|command| (format!("{}.md", command.name), reference(command))) + .collect(); + references.push(("commands.md".to_owned(), index(&commands))); + + let references_dir = skill_dir.join("references"); + fs::create_dir_all(&references_dir)?; + for (file, content) in &references { + fs::write(references_dir.join(file), content)?; + } + for stale in stale_references(&references_dir, &references)? { + fs::remove_file(stale)?; + } + fs::write(skill_dir.join("SKILL.md"), skill)?; + eprintln!( + "Wrote skills/linear-cli/SKILL.md and {} reference files", + references.len() + ); + Ok(()) +} + +fn documented_subcommands(command: &clap::Command) -> impl Iterator { + command + .get_subcommands() + .filter(|child| !child.is_hide_set() && child.get_name() != "help") +} + +fn document( + root: &clap::Command, + command: &clap::Command, + parent: Vec, +) -> Result> { + let mut path = parent; + path.push(command.get_name().to_owned()); + let subcommands = documented_subcommands(command) + .map(|child| document(root, child, path.clone())) + .collect::, _>>()?; + Ok(CommandDoc { + name: command.get_name().to_owned(), + description: command + .get_about() + .map(|about| about.to_string().replace('\n', " ")) + .unwrap_or_default(), + help: help(root, &path)?, + path, + subcommands, + }) +} + +/// The `--help` output for `linear `, exactly as a user sees it. +fn help(root: &clap::Command, path: &[String]) -> Result> { + let arguments = std::iter::once("linear") + .chain(path.iter().map(String::as_str)) + .chain(std::iter::once("--help")); + match root.clone().try_get_matches_from(arguments) { + Err(error) if error.kind() == clap::error::ErrorKind::DisplayHelp => { + Ok(error.render().to_string().trim_end().to_owned()) + } + Err(error) => Err(error.into()), + Ok(_) => Err(format!("`linear {} --help` did not print help", path.join(" ")).into()), + } +} + +fn full_name(command: &CommandDoc) -> String { + format!("linear {}", command.path.join(" ")) +} + +fn command_paths(command: &CommandDoc) -> Vec { + std::iter::once(full_name(command)) + .chain(command.subcommands.iter().flat_map(command_paths)) + .collect() +} + +fn command_list(commands: &[CommandDoc]) -> String { + commands + .iter() + .map(|command| command_paths(command).join("\n")) + .collect::>() + .join("\n\n") +} + +fn reference_toc(commands: &[CommandDoc]) -> String { + commands + .iter() + .map(|command| { + format!( + "- [{0}](references/{0}.md) - {1}", + command.name, command.description + ) + }) + .collect::>() + .join("\n") +} + +fn fenced(content: &str) -> String { + format!("```\n{content}\n```\n") +} + +fn heading(level: usize, text: &str) -> String { + format!("{} {text}\n", "#".repeat(level.min(6))) +} + +fn reference(command: &CommandDoc) -> String { + let mut out = heading(1, &command.name); + out.push_str(&format!("\n> {}\n\n", command.description)); + out.push_str(&heading(2, "Usage")); + out.push('\n'); + out.push_str(&fenced(&command.help)); + if !command.subcommands.is_empty() { + out.push('\n'); + out.push_str(&heading(2, "Subcommands")); + for subcommand in &command.subcommands { + subcommand_section(&mut out, subcommand, 3); + } + } + out +} + +fn subcommand_section(out: &mut String, command: &CommandDoc, level: usize) { + out.push('\n'); + out.push_str(&heading(level, &command.name)); + out.push('\n'); + if !command.description.is_empty() { + out.push_str(&format!("> {}\n\n", command.description)); + } + out.push_str(&fenced(&command.help)); + if !command.subcommands.is_empty() { + out.push('\n'); + out.push_str(&heading( + level + 1, + &format!("{} subcommands", command.name), + )); + for subcommand in &command.subcommands { + subcommand_section(out, subcommand, level + 2); + } + } +} + +fn index(commands: &[CommandDoc]) -> String { + let entries = commands + .iter() + .map(|command| format!("- [{0}](./{0}.md) - {1}", command.name, command.description)) + .collect::>() + .join("\n"); + format!( + "# Linear CLI Command Reference\n\n## Commands\n\n{entries}\n\n## Quick Reference\n\n\ + ```bash\n# Get help for any command\nlinear --help\n\ + linear --help\n```\n" + ) +} + +/// Markdown files in `dir` that this run neither generated nor preserves. +fn stale_references( + dir: &Path, + generated: &[(String, String)], +) -> Result, Box> { + let mut stale = Vec::new(); + for entry in fs::read_dir(dir)? { + let path = entry?.path(); + let Some(file) = path.file_name().and_then(|name| name.to_str()) else { + continue; + }; + let keep = generated.iter().any(|(name, _)| name == file) || PRESERVED.contains(&file); + if file.ends_with(".md") && !keep { + stale.push(path); + } + } + Ok(stale) +} diff --git a/crates/linear-cli/src/app.rs b/crates/linear-cli/src/app.rs new file mode 100644 index 000000000..d705e969a --- /dev/null +++ b/crates/linear-cli/src/app.rs @@ -0,0 +1,154 @@ +//! The process entry point: load configuration, build the [`Ctx`], run the +//! selected command and report its error. + +use std::error::Error as StdError; + +use crate::auth::credentials_path; +use crate::cli::{self, Cli, RootCommand}; +use crate::commands; +use crate::commands::completions; +use crate::config::{ + DisplaySettings, OsFamily, ProcessEnvSnapshot, RealFileSource, load_startup, render_diagnostic, +}; +use crate::ctx::{Ctx, CtxInit, Terminal}; +use crate::error::{Error, ErrorKind, Result}; +use crate::platform::output::{self, Stdout}; +use crate::platform::{style, terminal_text}; + +/// Runs a parsed command line and returns the process exit status. +pub fn main(cli: Cli) -> u8 { + let mut settings = DisplaySettings { + debug: false, + no_color: false, + }; + let result = run(cli, &mut settings); + match result { + Ok(()) => 0, + Err(error) => { + report(&error, settings); + error.exit_code() + } + } +} + +fn run(cli: Cli, settings: &mut DisplaySettings) -> Result<()> { + let Cli { global, command } = cli; + let cli::GlobalArgs { + workspace, + no_input, + } = global; + let command = match command { + RootCommand::Completions(action) => return completions_command(&action), + RootCommand::Markdown(_) => return markdown(), + command => command, + }; + let cwd = std::env::current_dir().map_err(|error| { + Error::new(format!("Failed to read the working directory: {error}")).with_source(error) + })?; + let os = if cfg!(windows) { + OsFamily::Windows + } else { + OsFamily::Unix + }; + let process = ProcessEnvSnapshot::capture(cwd.clone(), os) + .map_err(|error| Error::new(format!("Invalid environment: {error}")))?; + let report = load_startup(&process, &RealFileSource); + *settings = report.settings; + let terminal = Terminal::detect(report.settings.no_color); + for diagnostic in &report.diagnostics { + output::eprint(render_diagnostic(diagnostic, terminal.stderr_color()).as_bytes())?; + } + let config = report.result?; + let env = |name| process.inputs.env(name); + let ctx = Ctx::new(CtxInit { + config, + workspace, + no_input, + cwd, + terminal, + credentials_path: credentials_path(os, env("XDG_CONFIG_HOME"), env("HOME"), env("APPDATA")), + })?; + let result = dispatch(&ctx, command); + // Output written before a failure still reaches the reader. + let flushed = ctx.flush(); + result.and(flushed) +} + +/// Prints `error` to stderr: `✗ message`, an optional hint, and under +/// `LINEAR_DEBUG` the debug detail and source chain. +fn report(error: &Error, settings: DisplaySettings) { + let lines = match error.kind() { + ErrorKind::Exit(_) | ErrorKind::BrokenPipe => return, + // The same word as declining a confirmation, though the status differs. + ErrorKind::Cancelled => "Canceled.\n".to_owned(), + ErrorKind::Usage(usage) => usage.render().to_string(), + ErrorKind::Other | ErrorKind::Invalid => { + let terminal = Terminal::detect(settings.no_color); + let color = terminal.stderr_color(); + // On a terminal, lines wrap at spaces with their indent kept. + let columns = terminal + .stderr_tty + .then(|| crossterm::terminal::size().ok()) + .flatten() + .map(|(columns, _)| usize::from(columns)) + .filter(|&columns| columns > 0); + let fit = |text: &str, first: &str| match columns { + Some(columns) => terminal_text::wrap(text, columns, first, " "), + None => format!("{first}{text}"), + }; + let mut lines = format!("{}\n", style::red(&fit(&error.to_string(), "✗ "), color)); + if let Some(hint) = error.hint() { + lines.push_str(&format!("{}\n", style::gray(&fit(hint, " "), color))); + } + if settings.debug { + if let Some(detail) = error.debug_detail() { + lines.push_str(&format!(" debug: {detail}\n")); + } + let mut source = error.source(); + while let Some(cause) = source { + lines.push_str(&format!(" caused by: {cause}\n")); + source = cause.source(); + } + } + lines + } + }; + // Nothing is left to report a failure to. + let _ignored = output::eprint(lines.as_bytes()); +} + +fn completions_command(args: &cli::completions::Completions) -> Result<()> { + let stdout = Stdout::new(); + stdout.write(&completions::script(args)?)?; + stdout.flush() +} + +fn markdown() -> Result<()> { + let stdout = Stdout::new(); + stdout.write(include_str!("cli/markdown.txt").as_bytes())?; + stdout.flush() +} + +fn dispatch(ctx: &Ctx, command: RootCommand) -> Result<()> { + match command { + RootCommand::Auth(args) => commands::auth::run(ctx, &args.command), + RootCommand::Issue(args) => commands::issue::run(ctx, &args.command), + RootCommand::Team(args) => commands::team::run(ctx, &args.command), + RootCommand::User(args) => commands::user::run(ctx, &args.command), + RootCommand::Project(args) => commands::project::run(ctx, &args.command), + RootCommand::ProjectUpdate(args) => commands::project_update::run(ctx, &args.command), + RootCommand::Cycle(args) => commands::cycle::run(ctx, &args.command), + RootCommand::Milestone(args) => commands::milestone::run(ctx, &args.command), + RootCommand::Initiative(args) => commands::initiative::run(ctx, &args.command), + RootCommand::InitiativeUpdate(args) => commands::initiative_update::run(ctx, &args.command), + RootCommand::Label(args) => commands::label::run(ctx, &args.command), + RootCommand::Template(args) => commands::template::run(ctx, &args.command), + RootCommand::Document(args) => commands::document::run(ctx, &args.command), + RootCommand::Config(args) => commands::config_generate::run(ctx, &args), + RootCommand::Schema(args) => commands::schema::run(ctx, &args), + RootCommand::Api(args) => commands::api::run(ctx, &args), + RootCommand::Completions(_) | RootCommand::Markdown(_) => { + unreachable!("handled before configuration loads") + } + } +} diff --git a/crates/linear-cli/src/auth/file.rs b/crates/linear-cli/src/auth/file.rs new file mode 100644 index 000000000..95e68092c --- /dev/null +++ b/crates/linear-cli/src/auth/file.rs @@ -0,0 +1,155 @@ +//! Reading the credentials file, with a size limit. +use std::fs::OpenOptions; +use std::io::{self, Read}; +use std::path::Path; + +use crate::auth::keyring::Keyring; +use crate::auth::{ + CredentialFormatErrorKind, CredentialManifest, CredentialStore, parse_credentials, +}; +use crate::config::{MAX_CONFIG_BYTES, RawConfigFile, parse_config_tier}; +use crate::error::{Error, Result}; + +/// Reads and parses the credentials file at `path`. A missing file (or no +/// known path) is an empty store; keyring entries are read later, on demand. +pub fn load(path: Option<&Path>, keyring: Box) -> Result { + let manifest = match path { + Some(path) => match read(path).map_err(|error| invalid(path, error))? { + Some(bytes) => { + let tier = parse_config_tier(RawConfigFile { + path: path.to_path_buf(), + bytes, + }) + .map_err(|error| invalid(path, error.kind.to_string()))?; + parse_credentials(tier).map_err(|error| { + invalid( + path, + match error.kind { + CredentialFormatErrorKind::MixedFormat => "mixed credential formats", + CredentialFormatErrorKind::WrongType => "invalid value type", + CredentialFormatErrorKind::EmptyWorkspace => "empty workspace name", + CredentialFormatErrorKind::TooManyWorkspaces => "too many workspaces", + }, + ) + })? + } + None => CredentialManifest::empty(), + }, + None => CredentialManifest::empty(), + }; + Ok(CredentialStore::new(manifest, keyring)) +} + +fn invalid(path: &Path, detail: impl std::fmt::Display) -> Error { + Error::new(format!( + "invalid credentials file {}: {detail}", + path.display() + )) + .with_hint("Fix or remove the credentials file, then run `linear auth login`.") +} + +/// The file's bytes, or `None` when it does not exist. +fn read(path: &Path) -> std::result::Result>, String> { + let mut options = OpenOptions::new(); + options.read(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + // A FIFO in place of the file must not block startup. + options.custom_flags(libc::O_NONBLOCK); + } + let file = match options.open(path) { + Ok(file) => file, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(format!("read failed: {}", error.kind())), + }; + let metadata = file + .metadata() + .map_err(|error| format!("read failed: {}", error.kind()))?; + if !metadata.is_file() { + return Err("not a regular file".to_owned()); + } + if metadata.len() > MAX_CONFIG_BYTES { + return Err("too large".to_owned()); + } + let mut bytes = Vec::new(); + file.take(MAX_CONFIG_BYTES + 1) + .read_to_end(&mut bytes) + .map_err(|error| format!("read failed: {}", error.kind()))?; + if u64::try_from(bytes.len()).is_ok_and(|len| len > MAX_CONFIG_BYTES) { + return Err("too large".to_owned()); + } + Ok(Some(bytes)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::auth::CredentialWarning; + use crate::auth::keyring::{LookupFailureCategory, LookupResult}; + use crate::auth::test_support::keyring; + + /// Reads `path` with a keyring that cannot be reached. + fn read(path: &Path) -> Result { + let unavailable = LookupResult::Failed(LookupFailureCategory::Unavailable); + load( + Some(path), + keyring(&[("a", unavailable.clone()), ("b", unavailable)]), + ) + } + + fn failure(path: &Path) -> String { + read(path).expect_err("unreadable file").to_string() + } + + #[test] + fn a_missing_file_is_an_empty_store_and_other_unreadable_paths_fail() { + let dir = tempfile::tempdir().expect("temp dir"); + let missing = read(&dir.path().join("missing")).expect("missing file"); + assert!(missing.workspaces().is_empty()); + let file = dir.path().join("credentials.toml"); + std::fs::write(&file, b"default = 'demo'\n").expect("write"); + assert!(read(&file).is_ok()); + // Windows refuses to open a directory before checking its file kind. + let directory_error = if cfg!(windows) { + "read failed" + } else { + "not a regular file" + }; + assert!(failure(dir.path()).contains(directory_error)); + #[cfg(unix)] + assert!(failure(&file.join("child")).contains("read failed")); + std::fs::write(&file, vec![b'x'; 1024 * 1024 + 1]).expect("write"); + assert!(failure(&file).contains("too large")); + } + + #[cfg(unix)] + #[test] + fn a_fifo_is_refused_without_blocking() { + let dir = tempfile::tempdir().expect("temp dir"); + let fifo = dir.path().join("fifo"); + let status = std::process::Command::new("/usr/bin/mkfifo") + .arg(&fifo) + .status() + .expect("run mkfifo"); + assert!(status.success()); + assert!(failure(&fifo).contains("not a regular file")); + } + + #[test] + fn keyring_entries_are_read_only_when_a_key_is_needed() { + let dir = tempfile::tempdir().expect("temp dir"); + let file = dir.path().join("credentials.toml"); + std::fs::write(&file, b"default = 'a'\nworkspaces = ['a', 'b']\n").expect("write"); + let store = read(&file).expect("store"); + assert!(store.take_warnings().is_empty()); + assert!(store.key("b").is_none()); + assert_eq!( + store.take_warnings(), + [CredentialWarning::LookupFailed { + workspace: "b".to_owned(), + category: LookupFailureCategory::Unavailable, + }] + ); + } +} diff --git a/crates/linear-cli/src/auth/format.rs b/crates/linear-cli/src/auth/format.rs new file mode 100644 index 000000000..af1254264 --- /dev/null +++ b/crates/linear-cli/src/auth/format.rs @@ -0,0 +1,297 @@ +use std::cell::{OnceCell, RefCell}; +use std::collections::BTreeMap; +use std::error::Error as StdError; +use std::fmt; +use std::path::PathBuf; + +use serde::Deserialize; + +use crate::auth::keyring::{Keyring, LookupFailureCategory, LookupResult}; +use crate::config::{ConfigSecret, ConfigTier}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum CredentialFormat { + Inline, + Metadata, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum CredentialFormatErrorKind { + MixedFormat, + WrongType, + EmptyWorkspace, + TooManyWorkspaces, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CredentialFormatError { + pub path: PathBuf, + pub kind: CredentialFormatErrorKind, +} +impl fmt::Display for CredentialFormatError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "invalid credentials file at {}: {:?}", + self.path.display(), + self.kind + ) + } +} +impl StdError for CredentialFormatError {} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum CredentialWarning { + InvalidDefault { + workspace: String, + }, + LookupMiss { + workspace: String, + }, + LookupFailed { + workspace: String, + category: LookupFailureCategory, + }, +} + +pub struct CredentialManifest { + format: CredentialFormat, + workspaces: Vec, + default: Option, + inline_keys: Vec<(String, ConfigSecret)>, + warnings: Vec, +} +impl fmt::Debug for CredentialManifest { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("CredentialManifest") + .field("format", &self.format) + .field("workspaces", &self.workspaces) + .field("default", &self.default) + .field("inline_keys", &"") + .field("warnings", &self.warnings) + .finish() + } +} +impl CredentialManifest { + pub fn empty() -> Self { + Self { + format: CredentialFormat::Metadata, + workspaces: Vec::new(), + default: None, + inline_keys: Vec::new(), + warnings: Vec::new(), + } + } +} + +#[cfg(test)] +impl CredentialManifest { + pub fn format(&self) -> CredentialFormat { + self.format + } + pub fn workspaces(&self) -> &[String] { + &self.workspaces + } + pub fn default(&self) -> Option<&str> { + self.default.as_deref() + } + pub fn warnings(&self) -> &[CredentialWarning] { + &self.warnings + } +} + +/// The most workspaces a metadata credentials file may list. +const MAX_WORKSPACES: usize = 256; + +/// Reads a parsed credentials file. Two layouts exist: +/// +/// - inline: ` = ""` entries, plus an optional +/// `default = ""`; +/// - metadata: `default = ""` and `workspaces = [...]`, with the +/// keys stored in the system keyring. +/// +/// No keyring lookup happens here. +pub fn parse_credentials(tier: ConfigTier) -> Result { + let ConfigTier { path, mut table } = tier; + let fail = |kind| CredentialFormatError { + path: path.clone(), + kind, + }; + let mut default = match table.remove("default") { + None => None, + Some(toml::Value::String(workspace)) => Some(workspace), + Some(_) => return Err(fail(CredentialFormatErrorKind::WrongType)), + }; + let listed = table + .remove("workspaces") + .map(|value| { + Vec::::deserialize(value) + .map_err(|_| fail(CredentialFormatErrorKind::WrongType)) + }) + .transpose()?; + let inline_keys = table + .into_iter() + .map(|(workspace, value)| match value { + toml::Value::String(key) => Ok((workspace, ConfigSecret::new(key))), + _ => Err(fail(CredentialFormatErrorKind::WrongType)), + }) + .collect::, _>>()?; + let (format, workspaces) = match listed { + Some(_) if !inline_keys.is_empty() => { + return Err(fail(CredentialFormatErrorKind::MixedFormat)); + } + Some(listed) => { + if listed.iter().any(String::is_empty) { + return Err(fail(CredentialFormatErrorKind::EmptyWorkspace)); + } + if listed.len() > MAX_WORKSPACES { + return Err(fail(CredentialFormatErrorKind::TooManyWorkspaces)); + } + let mut workspaces = Vec::new(); + for workspace in listed { + if !workspaces.contains(&workspace) { + workspaces.push(workspace); + } + } + (CredentialFormat::Metadata, workspaces) + } + None if inline_keys.is_empty() => (CredentialFormat::Metadata, Vec::new()), + None => ( + CredentialFormat::Inline, + inline_keys.iter().map(|(name, _)| name.clone()).collect(), + ), + }; + if workspaces.iter().any(String::is_empty) { + return Err(fail(CredentialFormatErrorKind::EmptyWorkspace)); + } + let mut warnings = Vec::new(); + if format == CredentialFormat::Metadata + && let Some(workspace) = &default + && !workspaces.contains(workspace) + { + warnings.push(CredentialWarning::InvalidDefault { + workspace: workspace.clone(), + }); + default = None; + } + Ok(CredentialManifest { + format, + workspaces, + default, + inline_keys, + warnings, + }) +} + +/// Stored credentials. Keyring-backed keys are looked up lazily, at most once +/// per workspace, so a command reads only the entry it uses. +pub struct CredentialStore { + format: CredentialFormat, + workspaces: Vec, + default: Option, + inline_keys: BTreeMap, + keyring: Box, + lookups: BTreeMap>, + warnings: RefCell>, +} +impl fmt::Debug for CredentialStore { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("CredentialStore") + .field("format", &self.format) + .field("workspaces", &self.workspaces) + .field("default", &self.default) + .finish_non_exhaustive() + } +} +impl CredentialStore { + pub fn new(manifest: CredentialManifest, keyring: Box) -> Self { + let lookups = match manifest.format { + CredentialFormat::Metadata => manifest + .workspaces + .iter() + .map(|workspace| (workspace.clone(), OnceCell::new())) + .collect(), + CredentialFormat::Inline => BTreeMap::new(), + }; + Self { + format: manifest.format, + workspaces: manifest.workspaces, + default: manifest.default, + inline_keys: manifest.inline_keys.into_iter().collect(), + keyring, + lookups, + warnings: RefCell::new(manifest.warnings), + } + } + + pub fn workspaces(&self) -> &[String] { + &self.workspaces + } + /// The default workspace; a store with a single workspace needs none set. + pub fn default(&self) -> Option<&str> { + self.default + .as_deref() + .or(match self.workspaces.as_slice() { + [only] => Some(only.as_str()), + _ => None, + }) + } + + /// The key stored for `workspace`, reading the keyring on first use. + pub fn key(&self, workspace: &str) -> Option<&ConfigSecret> { + if let Some(key) = self.inline_keys.get(workspace) { + return Some(key); + } + let cell = self.lookups.get(workspace)?; + let result = cell.get_or_init(|| { + let result = self.keyring.get(workspace); + let warning = match &result { + LookupResult::Hit(_) => None, + LookupResult::Miss => Some(CredentialWarning::LookupMiss { + workspace: workspace.to_owned(), + }), + LookupResult::Failed(category) => Some(CredentialWarning::LookupFailed { + workspace: workspace.to_owned(), + category: *category, + }), + }; + self.warnings.borrow_mut().extend(warning); + result + }); + match result { + LookupResult::Hit(secret) => Some(secret), + LookupResult::Miss | LookupResult::Failed(_) => None, + } + } + + /// The keyring keys are read from, for storing and deleting them too. + pub fn keyring(&self) -> &dyn Keyring { + self.keyring.as_ref() + } + + /// Warnings gathered so far (an invalid default, keyring misses), each returned once. + pub fn take_warnings(&self) -> Vec { + self.warnings.take() + } + + /// The file as read: its format, workspaces, the default it names (if + /// any) and its plaintext keys. No keyring entry is read. + pub(crate) fn file_parts( + &self, + ) -> ( + CredentialFormat, + Vec, + Option, + BTreeMap, + ) { + ( + self.format, + self.workspaces.clone(), + self.default.clone(), + self.inline_keys.clone(), + ) + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/linear-cli/src/auth/format/tests.rs b/crates/linear-cli/src/auth/format/tests.rs new file mode 100644 index 000000000..4b6e7cdae --- /dev/null +++ b/crates/linear-cli/src/auth/format/tests.rs @@ -0,0 +1,154 @@ +use super::*; +use crate::auth::test_support::{manifest, store}; + +#[test] +fn inline_file_order_and_metadata_dedup() { + let inline = + manifest("\"10\"='k10'\n\"2\"='k2'\n\"01\"='k01'\n\"-1\"='km'\na='ka'\ndefault='a'") + .expect("inline"); + assert_eq!(inline.format(), CredentialFormat::Inline); + assert_eq!(inline.workspaces(), ["10", "2", "01", "-1", "a"]); + + let store = store(inline, &[]); + assert_eq!(store.default(), Some("a")); + assert_eq!(store.key("2").expect("key").expose(), "k2"); + let meta = manifest("workspaces=['a','a','b']\ndefault='b'").expect("metadata"); + assert_eq!(meta.format(), CredentialFormat::Metadata); + assert_eq!(meta.workspaces(), ["a", "b"]); +} + +#[test] +fn shape_errors_have_fixed_priority_and_no_secret_text() { + for text in [ + "a='lin_api_fake'\nworkspaces=['a']", + "workspaces=['a']\na='lin_api_fake'", + ] { + let err = manifest(text).expect_err("mixed"); + assert_eq!(err.kind, CredentialFormatErrorKind::MixedFormat); + assert!(!format!("{err:?} {err}").contains("lin_api_fake")); + } + for text in [ + "workspaces='x'", + "workspaces=[1]", + "default=1", + "a=1\nb='key'", + "a='key'\ndefault=1", + "workspaces=['a']\n[t]\nx=1", + "workspaces=['',1]", + "a='k'\nworkspaces=1\nx=1", + ] { + assert_eq!( + manifest(text).expect_err("wrong type").kind, + CredentialFormatErrorKind::WrongType + ); + } + assert_eq!( + manifest("\"\"='key'").expect_err("empty inline").kind, + CredentialFormatErrorKind::EmptyWorkspace + ); + assert_eq!( + manifest("workspaces=['']") + .expect_err("empty metadata") + .kind, + CredentialFormatErrorKind::EmptyWorkspace + ); + let names = |count: usize| { + (0..count) + .map(|i| format!("'w{i}'")) + .collect::>() + .join(",") + }; + assert_eq!( + manifest(&format!("workspaces=['',{}]", names(256))) + .expect_err("empty beats cap") + .kind, + CredentialFormatErrorKind::EmptyWorkspace + ); + assert_eq!( + manifest(&format!("workspaces=[{}]", names(257))) + .expect_err("cap") + .kind, + CredentialFormatErrorKind::TooManyWorkspaces + ); + assert_eq!( + manifest(&format!("workspaces=[{}]", names(256))) + .expect("at cap") + .workspaces() + .len(), + 256 + ); +} + +#[test] +fn default_warning_precedes_lookup_warnings() { + let manifest = manifest("default='missing'\nworkspaces=['a','b','c']").expect("metadata"); + assert_eq!(manifest.default(), None); + assert_eq!( + manifest.warnings(), + &[CredentialWarning::InvalidDefault { + workspace: "missing".to_owned() + }] + ); + let store = store( + manifest, + &[ + ( + "c", + LookupResult::Failed(LookupFailureCategory::Unavailable), + ), + ("a", LookupResult::Hit(ConfigSecret::new(String::new()))), + ], + ); + assert_eq!(store.key("a").expect("empty cached hit").expose(), ""); + assert!(store.key("b").is_none()); + assert!(store.key("c").is_none()); + assert_eq!( + store.take_warnings(), + &[ + CredentialWarning::InvalidDefault { + workspace: "missing".to_owned() + }, + CredentialWarning::LookupMiss { + workspace: "b".to_owned() + }, + CredentialWarning::LookupFailed { + workspace: "c".to_owned(), + category: LookupFailureCategory::Unavailable + }, + ] + ); +} + +#[test] +fn inline_defaults_are_not_checked_but_metadata_defaults_are() { + let inline = manifest("a='lin_api_fake'\ndefault='missing'").expect("inline"); + assert_eq!(inline.default(), Some("missing")); + assert!(inline.warnings().is_empty()); + let only_default = manifest("default='missing'").expect("default only"); + assert_eq!(only_default.workspaces().len(), 0); + assert_eq!( + only_default.warnings(), + &[CredentialWarning::InvalidDefault { + workspace: "missing".to_owned() + }] + ); + let empty_default = manifest("default=''\nworkspaces=['a']").expect("empty default"); + assert_eq!(empty_default.default(), None); + assert_eq!( + empty_default.warnings(), + &[CredentialWarning::InvalidDefault { + workspace: String::new() + }] + ); +} + +#[test] +fn debug_output_redacts_every_secret() { + let marker = "lin_api_fake_unique_marker"; + let inline = manifest(&format!("a='{marker}'")).expect("inline"); + assert!(!format!("{inline:?}").contains(marker)); + let store = store(inline, &[]); + assert!(!format!("{store:?}").contains(marker)); + let reply = LookupResult::Hit(ConfigSecret::new(marker.to_owned())); + assert!(!format!("{reply:?}").contains(marker)); +} diff --git a/crates/linear-cli/src/auth/keyring/mod.rs b/crates/linear-cli/src/auth/keyring/mod.rs new file mode 100644 index 000000000..fb277c861 --- /dev/null +++ b/crates/linear-cli/src/auth/keyring/mod.rs @@ -0,0 +1,105 @@ +//! The system keyring, which holds each workspace's API key when the +//! credentials file lists workspaces without keys. +//! +//! Entries are named by service `linear-cli` and the workspace as account +//! (on Windows, target `linear-cli:`), so keys stored by earlier +//! releases stay readable. +#[cfg(any(target_os = "linux", target_os = "macos", all(test, unix)))] +mod process; +#[cfg(any(target_os = "linux", all(test, unix)))] +mod secret_tool; +#[cfg(any(target_os = "macos", all(test, unix)))] +mod security; +#[cfg(windows)] +mod windows; + +use crate::config::{ChildEnvOverlay, ConfigSecret}; +use crate::error::Result; + +/// A workspace's entry in the keyring. +#[derive(Clone, Debug)] +pub enum LookupResult { + Hit(ConfigSecret), + Miss, + Failed(LookupFailureCategory), +} + +/// Why a keyring entry could not be read. Never carries the keyring's +/// output, which may include the secret. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum LookupFailureCategory { + /// There is no keyring to ask: the tool is missing, no keyring session + /// exists, or the platform has none. + Unavailable, + /// The keyring tool could not be run. + #[cfg(any(target_os = "linux", target_os = "macos", all(test, unix)))] + Permission, + Other, +} + +/// Reads, stores and deletes API keys by workspace. +pub trait Keyring { + /// The key stored for `workspace`. A missing or empty entry is a miss. + fn get(&self, workspace: &str) -> LookupResult; + /// Stores `secret` for `workspace`, replacing any existing entry. + fn set(&self, workspace: &str, secret: &ConfigSecret) -> Result<()>; + /// Deletes `workspace`'s entry; deleting a missing entry succeeds. + fn delete(&self, workspace: &str) -> Result<()>; + /// Whether a keyring is there to use, checked before asking for a key + /// so a missing keyring is reported before any other work. + fn available(&self) -> bool { + true + } +} + +/// The keyring for this platform. Keyring tools run with the `.env` +/// overlay, like every other subprocess. +pub fn native(overlay: &ChildEnvOverlay) -> Box { + #[cfg(target_os = "linux")] + { + Box::new(secret_tool::SecretTool::new(overlay.clone())) + } + #[cfg(target_os = "macos")] + { + Box::new(security::Security::new(overlay.clone())) + } + #[cfg(windows)] + { + let _ = overlay; + Box::new(windows::CredentialManager) + } + #[cfg(not(any(target_os = "linux", target_os = "macos", windows)))] + { + let _ = overlay; + Box::new(Unsupported) + } +} + +/// No system keyring on this platform. +#[cfg(not(any(target_os = "linux", target_os = "macos", windows)))] +struct Unsupported; + +#[cfg(not(any(target_os = "linux", target_os = "macos", windows)))] +impl Keyring for Unsupported { + fn get(&self, _: &str) -> LookupResult { + LookupResult::Failed(LookupFailureCategory::Unavailable) + } + fn set(&self, _: &str, _: &ConfigSecret) -> Result<()> { + Err(unsupported()) + } + fn delete(&self, _: &str) -> Result<()> { + Err(unsupported()) + } + fn available(&self) -> bool { + false + } +} + +#[cfg(not(any(target_os = "linux", target_os = "macos", windows)))] +fn unsupported() -> crate::error::Error { + crate::error::Error::new("System keyring is unsupported on this platform") + .with_hint("Pass --plaintext to store the key in the credentials file.") +} + +#[cfg(test)] +mod tests; diff --git a/crates/linear-cli/src/auth/keyring/process.rs b/crates/linear-cli/src/auth/keyring/process.rs new file mode 100644 index 000000000..7c32b9d16 --- /dev/null +++ b/crates/linear-cli/src/auth/keyring/process.rs @@ -0,0 +1,230 @@ +//! Running a keyring command-line tool (`secret-tool`, `security`) with a +//! deadline. +use std::ffi::OsString; +use std::io; +use std::process::{Output, Stdio}; +use std::time::Duration; + +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt}; +use tokio::process::Command; + +use super::{LookupFailureCategory, LookupResult}; +use crate::config::{ChildEnvOverlay, ConfigSecret}; +use crate::error::Error; + +/// How long one keyring command may run before it is killed. +const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30); + +/// How long output may keep arriving after the tool exits. A pipe stays open +/// past that only when another process inherited it, and that process never +/// writes the tool's output. +const PIPE_GRACE: Duration = Duration::from_millis(500); + +#[derive(Debug)] +pub enum RunError { + Runtime(io::Error), + Spawn(io::Error), + Io(io::Error), + Timeout, +} + +impl RunError { + pub fn category(&self) -> LookupFailureCategory { + match self { + Self::Spawn(error) if error.kind() == io::ErrorKind::NotFound => { + LookupFailureCategory::Unavailable + } + Self::Spawn(error) if error.kind() == io::ErrorKind::PermissionDenied => { + LookupFailureCategory::Permission + } + Self::Runtime(_) | Self::Spawn(_) | Self::Io(_) | Self::Timeout => { + LookupFailureCategory::Other + } + } + } +} + +/// A keyring tool: the executable and the environment it runs with. +pub struct Tool { + pub name: &'static str, + pub executable: OsString, + pub overlay: ChildEnvOverlay, + pub timeout: Duration, +} + +impl Tool { + pub fn new(name: &'static str, executable: &str, overlay: ChildEnvOverlay) -> Self { + Self { + name, + executable: OsString::from(executable), + overlay, + timeout: DEFAULT_TIMEOUT, + } + } + + /// Runs the tool to completion with `args`, feeding `input` on stdin. + /// + /// Callers may or may not be inside the command's async runtime, so the + /// tool runs on a private runtime on its own thread. The child is killed + /// if it outlives the timeout. + pub fn run(&self, args: &[String], input: Option<&[u8]>) -> Result { + std::thread::scope(|scope| { + scope + .spawn(|| { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(RunError::Runtime)?; + let mut command = Command::new(&self.executable); + command.args(args).envs(self.overlay.iter()); + runtime.block_on(run(&mut command, input, self.timeout)) + }) + .join() + .unwrap_or_else(|panic| std::panic::resume_unwind(panic)) + }) + } + + /// [`Tool::run`] for storing or deleting, with failures as user-facing + /// errors. + pub fn run_change( + &self, + args: &[String], + input: Option<&[u8]>, + install_hint: &str, + ) -> Result { + if args.iter().any(|arg| arg.contains('\0')) { + return Err(Error::new( + "Keyring arguments cannot contain a NUL character", + )); + } + self.run(args, input).map_err(|error| match error { + RunError::Spawn(error) => Error::new(format!( + "Could not run {}: {error}", + self.executable.to_string_lossy() + )) + .with_hint(install_hint) + .with_source(error), + RunError::Runtime(error) | RunError::Io(error) => { + Error::new(format!("{} failed: {error}", self.name)).with_source(error) + } + RunError::Timeout => Error::new(format!( + "{} did not finish within {} seconds", + self.name, + self.timeout.as_secs() + )), + }) + } + + /// Fails unless the tool exited with one of `accepted`. + pub fn check(&self, output: &Output, action: &str, accepted: &[i32]) -> Result<(), Error> { + let code = output.status.code(); + if code.is_some_and(|code| accepted.contains(&code)) { + return Ok(()); + } + let status = code.map_or_else(|| output.status.to_string(), |code| format!("exit {code}")); + let stderr = String::from_utf8_lossy(&output.stderr); + Err(Error::new(format!( + "{} {action} failed ({status}): {}", + self.name, + stderr.trim() + ))) + } +} + +/// Runs `command` to completion, feeding `input` on stdin. +/// +/// The child is killed if it outlives `timeout` or the caller is dropped. +async fn run( + command: &mut Command, + input: Option<&[u8]>, + timeout: Duration, +) -> Result { + command + .kill_on_drop(true) + .stdin(if input.is_some() { + Stdio::piped() + } else { + Stdio::null() + }) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + let mut child = command.spawn().map_err(RunError::Spawn)?; + let stdin = child.stdin.take(); + let stdout = child.stdout.take(); + let stderr = child.stderr.take(); + let exchange = async { + if let (Some(input), Some(mut stdin)) = (input, stdin) { + stdin.write_all(input).await.map_err(RunError::Io)?; + } + let mut stdout_bytes = Vec::new(); + let mut stderr_bytes = Vec::new(); + let status = { + let reads = async { + tokio::try_join!( + read_into(stdout, &mut stdout_bytes), + read_into(stderr, &mut stderr_bytes) + ) + }; + tokio::pin!(reads); + let mut drained = false; + let status = tokio::select! { + status = child.wait() => status, + output = &mut reads => { + output.map_err(RunError::Io)?; + drained = true; + child.wait().await + } + } + .map_err(RunError::Io)?; + if !drained && let Ok(output) = tokio::time::timeout(PIPE_GRACE, reads).await { + output.map_err(RunError::Io)?; + } + status + }; + Ok(Output { + status, + stdout: stdout_bytes, + stderr: stderr_bytes, + }) + }; + match tokio::time::timeout(timeout, exchange).await { + Ok(result) => result, + Err(_elapsed) => { + // Kill and reap so no zombie outlives the lookup. + let _ = child.start_kill(); + let _ = child.wait().await; + Err(RunError::Timeout) + } + } +} + +/// Appends everything read from `pipe` to `bytes`, which keeps whatever +/// arrived if this future is dropped early. +async fn read_into(pipe: Option, bytes: &mut Vec) -> io::Result<()> { + let Some(mut pipe) = pipe else { + return Ok(()); + }; + let mut chunk = [0_u8; 4096]; + loop { + let count = pipe.read(&mut chunk).await?; + if count == 0 { + return Ok(()); + } + bytes.extend_from_slice(chunk.get(..count).unwrap_or_default()); + } +} + +/// The key a successful lookup printed. Surrounding whitespace (such as +/// `security -w`'s trailing newline) is dropped, and nothing left is a miss. +pub fn printed_key(stdout: Vec) -> LookupResult { + let Ok(stdout) = String::from_utf8(stdout) else { + return LookupResult::Failed(LookupFailureCategory::Other); + }; + match stdout.trim() { + "" => LookupResult::Miss, + key => LookupResult::Hit(ConfigSecret::new(key.to_owned())), + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/linear-cli/src/auth/keyring/process/tests.rs b/crates/linear-cli/src/auth/keyring/process/tests.rs new file mode 100644 index 000000000..0b97afc02 --- /dev/null +++ b/crates/linear-cli/src/auth/keyring/process/tests.rs @@ -0,0 +1,268 @@ +//! Each test runs a private fake keyring tool, never the real one. +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::path::PathBuf; +use std::process::Command; +use std::time::{Duration, Instant}; + +use super::*; +use crate::auth::keyring::Keyring; +use crate::auth::keyring::secret_tool::SecretTool; +use crate::auth::keyring::security::Security; + +/// A shell script standing in for the keyring tool. `$TRACE` names a file +/// the script may write for the test to inspect. +struct FakeTool { + dir: tempfile::TempDir, +} + +impl FakeTool { + fn new(script: &str) -> Self { + let dir = tempfile::tempdir().expect("temp dir"); + let tool = Self { dir }; + let staged = tool.dir.path().join(".tool.new"); + // A child writer keeps sibling test children from inheriting a script's writable fd. + let status = Command::new("/bin/sh") + .args(["-c", "printf '%s' \"$2\" > \"$1\"", "write-fake-tool"]) + .arg(&staged) + .arg(format!( + "#!/bin/sh\nTRACE='{}'\n{script}\n", + tool.trace_path().display() + )) + .status() + .expect("run fake tool writer"); + assert!(status.success(), "write {}: {status}", staged.display()); + fs::set_permissions(&staged, fs::Permissions::from_mode(0o700)) + .expect("make fake tool executable"); + fs::rename(&staged, tool.executable()).expect("publish fake tool"); + tool + } + + fn executable(&self) -> PathBuf { + self.dir.path().join("tool") + } + + fn trace_path(&self) -> PathBuf { + self.dir.path().join("trace") + } + + fn trace(&self) -> String { + fs::read_to_string(self.trace_path()).expect("fake tool wrote its trace") + } + + fn stdin(&self) -> String { + fs::read_to_string(self.dir.path().join("trace.stdin")).expect("fake tool saved stdin") + } + + fn tool(&self, name: &'static str) -> Tool { + let mut tool = Tool::new(name, "unused", ChildEnvOverlay::empty()); + tool.executable = self.executable().into_os_string(); + tool.timeout = Duration::from_secs(30); + tool + } + + fn secret_tool(&self) -> SecretTool { + SecretTool::with_tool(self.tool("secret-tool")) + } + + fn security(&self) -> Security { + Security::with_tool(self.tool("security")) + } +} + +/// What a lookup found, with the key spelled out. +fn shown(result: LookupResult) -> String { + match result { + LookupResult::Hit(key) => format!("hit {}", key.expose()), + LookupResult::Miss => "miss".to_owned(), + LookupResult::Failed(category) => format!("failed {category:?}"), + } +} + +const RECORD_ARGS_AND_STDIN: &str = + "printf '%s\\n' \"$@\" > \"$TRACE\"; /bin/cat > \"$TRACE.stdin\""; + +#[test] +fn secret_tool_lookup_passes_the_workspace_and_no_stdin() { + let tool = FakeTool::new( + "printf '%s\\n' \"$@\" > \"$TRACE\"; if IFS= read -r line; then exit 8; fi; printf 'key'", + ); + assert_eq!(shown(tool.secret_tool().get("dummy space;'中")), "hit key"); + assert_eq!( + tool.trace(), + "lookup\nservice\nlinear-cli\naccount\ndummy space;'中\n" + ); +} + +#[test] +fn printed_keys_are_trimmed_and_stderr_ignored() { + let tool = FakeTool::new("printf 'noisy' >&2; printf ' \\tkey\\r\\n'"); + assert_eq!(shown(tool.secret_tool().get("demo")), "hit key"); + assert_eq!(shown(tool.security().get("demo")), "hit key"); +} + +#[test] +fn secret_tool_misses_and_failures() { + let lookup = |script| shown(FakeTool::new(script).secret_tool().get("demo")); + assert_eq!(lookup("exit 0"), "miss"); + assert_eq!(lookup("printf ' \\n'"), "miss"); + // Exit 1 with nothing on stderr is how secret-tool reports no match. + assert_eq!(lookup("printf ' \\n' >&2; exit 1"), "miss"); + assert_eq!(lookup("printf '\\377'; exit 1"), "miss"); + assert_eq!(lookup("printf 'denied' >&2; exit 1"), "failed Other"); + assert_eq!(lookup("exit 4"), "failed Other"); + assert_eq!(lookup("printf '\\377'"), "failed Other"); +} + +#[test] +fn security_lookup_names_the_item_and_treats_exit_44_as_a_miss() { + let tool = FakeTool::new( + "printf '%s\\n' \"$@\" > \"$TRACE\"; if IFS= read -r line; then exit 8; fi; printf 'key\\n'", + ); + assert_eq!(shown(tool.security().get("demo")), "hit key"); + assert_eq!( + tool.trace(), + "find-generic-password\n-a\ndemo\n-s\nlinear-cli\n-w\n" + ); + + let lookup = |script| shown(FakeTool::new(script).security().get("demo")); + assert_eq!( + lookup("printf '\\377' >&2; printf ignored; exit 44"), + "miss" + ); + assert_eq!(lookup("printf ' \\t\\r\\n'"), "miss"); + assert_eq!(lookup("exit 1"), "failed Other"); +} + +#[test] +fn failures_never_carry_the_tool_output() { + let result = FakeTool::new("printf 'lin_api_fake_secret' >&2; exit 5") + .security() + .get("demo"); + assert!(!format!("{result:?}").contains("lin_api_fake_secret")); +} + +#[test] +fn a_missing_or_unrunnable_tool_is_classified() { + let mut missing = FakeTool::new("exit 0").tool("secret-tool"); + missing.executable = "/definitely/absent/secret-tool".into(); + let missing = SecretTool::with_tool(missing); + assert_eq!(shown(missing.get("demo")), "failed Unavailable"); + assert!(!missing.available()); + let error = missing + .set("demo", &ConfigSecret::new("lin_secret".to_owned())) + .expect_err("cannot run"); + assert!(error.to_string().contains("Could not run"), "{error}"); + assert!(error.hint().is_some_and(|hint| hint.contains("libsecret"))); + + let denied = FakeTool::new("exit 0"); + fs::set_permissions(denied.executable(), fs::Permissions::from_mode(0o600)) + .expect("drop execute permission"); + assert_eq!(shown(denied.secret_tool().get("demo")), "failed Permission"); +} + +#[test] +fn a_hung_tool_is_killed_at_the_deadline() { + let hung = FakeTool::new("printf '%s' \"$$\" > \"$TRACE\"; exec /bin/sleep 60"); + let mut tool = hung.tool("secret-tool"); + tool.timeout = Duration::from_secs(5); + assert_eq!( + shown(SecretTool::with_tool(tool).get("demo")), + "failed Other" + ); + let pid = hung.trace(); + let deadline = Instant::now() + Duration::from_secs(30); + while Command::new("/bin/kill") + .arg("-0") + .arg(&pid) + .output() + .expect("run kill") + .status + .success() + { + assert!(Instant::now() < deadline, "child {pid} survived"); + std::thread::sleep(Duration::from_millis(20)); + } +} + +#[test] +fn output_held_open_by_a_descendant_does_not_delay_the_result() { + let tool = + FakeTool::new("/bin/sleep 60 & printf '%s' \"$!\" > \"$TRACE\"; printf 'key'; exit 0"); + let started = Instant::now(); + let result = tool.secret_tool().get("demo"); + let _ = Command::new("/bin/kill").arg(tool.trace()).status(); + assert_eq!(shown(result), "hit key"); + assert!(started.elapsed() < Duration::from_secs(30)); +} + +#[test] +fn lookups_work_inside_an_async_runtime() { + let tool = FakeTool::new("printf 'key'"); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("runtime"); + let result = runtime.block_on(async { tool.secret_tool().get("demo") }); + assert_eq!(shown(result), "hit key"); +} + +#[test] +fn security_store_sends_the_command_on_stdin() { + let tool = FakeTool::new(RECORD_ARGS_AND_STDIN); + tool.security() + .set("acme-co", &ConfigSecret::new("lin_api_ab-1.2".to_owned())) + .expect("store"); + assert_eq!(tool.trace(), "-i\n"); + assert_eq!( + tool.stdin(), + "add-generic-password -U -a acme-co -s linear-cli -w lin_api_ab-1.2\n" + ); +} + +#[test] +fn security_store_refuses_values_that_would_need_quoting() { + let tool = FakeTool::new("printf 'ran' > \"$TRACE\""); + for (workspace, secret) in [("demo", "two words"), ("dummy space", "lin_secret")] { + let error = tool + .security() + .set(workspace, &ConfigSecret::new(secret.to_owned())) + .expect_err("unquotable"); + assert!(error.to_string().contains("keychain tool"), "{error}"); + } + assert!(!tool.trace_path().exists()); +} + +#[test] +fn security_delete_of_a_missing_entry_succeeds() { + let tool = FakeTool::new("printf '%s\\n' \"$@\" > \"$TRACE\"; exit 44"); + tool.security().delete("demo").expect("delete"); + assert_eq!( + tool.trace(), + "delete-generic-password\n-a\ndemo\n-s\nlinear-cli\n" + ); +} + +#[test] +fn secret_tool_store_reads_the_secret_from_stdin() { + let tool = FakeTool::new(RECORD_ARGS_AND_STDIN); + tool.secret_tool() + .set("demo", &ConfigSecret::new("dummy key\n中".to_owned())) + .expect("store"); + assert_eq!( + tool.trace(), + "store\n--label\nlinear-cli: demo\nservice\nlinear-cli\naccount\ndemo\n" + ); + assert_eq!(tool.stdin(), "dummy key\n中"); +} + +#[test] +fn secret_tool_clear_reports_the_exit_status_and_stderr() { + let tool = FakeTool::new("printf '%s\\n' \"$@\" > \"$TRACE\"; echo 'locked' >&2; exit 4"); + let error = tool.secret_tool().delete("demo").expect_err("refused"); + assert_eq!( + error.to_string(), + "secret-tool clear failed (exit 4): locked" + ); + assert_eq!(tool.trace(), "clear\nservice\nlinear-cli\naccount\ndemo\n"); +} diff --git a/crates/linear-cli/src/auth/keyring/secret_tool.rs b/crates/linear-cli/src/auth/keyring/secret_tool.rs new file mode 100644 index 000000000..79a2765a8 --- /dev/null +++ b/crates/linear-cli/src/auth/keyring/secret_tool.rs @@ -0,0 +1,87 @@ +//! The Secret Service keyring through libsecret's `secret-tool`. +use super::process::{Tool, printed_key}; +use super::{Keyring, LookupFailureCategory, LookupResult}; +use crate::config::ConfigSecret; +use crate::error::Result; + +const INSTALL_HINT: &str = "Install libsecret (e.g. `apt install libsecret-tools` or `pacman -S libsecret`), or set LINEAR_API_KEY."; + +pub struct SecretTool { + tool: Tool, +} + +impl SecretTool { + #[cfg(target_os = "linux")] + pub fn new(overlay: crate::config::ChildEnvOverlay) -> Self { + Self { + tool: Tool::new("secret-tool", "secret-tool", overlay), + } + } + + #[cfg(test)] + pub fn with_tool(tool: Tool) -> Self { + Self { tool } + } +} + +fn attributes(workspace: &str) -> [String; 4] { + [ + "service".to_owned(), + "linear-cli".to_owned(), + "account".to_owned(), + workspace.to_owned(), + ] +} + +fn arguments(action: &str, workspace: &str) -> Vec { + std::iter::once(action.to_owned()) + .chain(attributes(workspace)) + .collect() +} + +impl Keyring for SecretTool { + fn get(&self, workspace: &str) -> LookupResult { + let output = match self.tool.run(&arguments("lookup", workspace), None) { + Ok(output) => output, + Err(error) => return LookupResult::Failed(error.category()), + }; + if output.status.success() { + return printed_key(output.stdout); + } + // secret-tool exits 1 without a message when nothing matches. + let missing = output.status.code() == Some(1) + && String::from_utf8(output.stderr).is_ok_and(|stderr| stderr.trim().is_empty()); + if missing { + LookupResult::Miss + } else { + LookupResult::Failed(LookupFailureCategory::Other) + } + } + + /// The secret goes to stdin, never to the arguments, where other users + /// could see it. + fn set(&self, workspace: &str, secret: &ConfigSecret) -> Result<()> { + let args: Vec = ["store".to_owned(), "--label".to_owned()] + .into_iter() + .chain([format!("linear-cli: {workspace}")]) + .chain(attributes(workspace)) + .collect(); + let output = self + .tool + .run_change(&args, Some(secret.expose().as_bytes()), INSTALL_HINT)?; + self.tool.check(&output, "store", &[0]) + } + + fn delete(&self, workspace: &str) -> Result<()> { + let output = self + .tool + .run_change(&arguments("clear", workspace), None, INSTALL_HINT)?; + self.tool.check(&output, "clear", &[0]) + } + + /// Whether `secret-tool` runs at all; without arguments it only prints + /// its usage. + fn available(&self) -> bool { + self.tool.run(&[], None).is_ok() + } +} diff --git a/crates/linear-cli/src/auth/keyring/security.rs b/crates/linear-cli/src/auth/keyring/security.rs new file mode 100644 index 000000000..e7b5b4c19 --- /dev/null +++ b/crates/linear-cli/src/auth/keyring/security.rs @@ -0,0 +1,88 @@ +//! The macOS login keychain through `/usr/bin/security`. Going through that +//! tool keeps existing keychain items readable without a new access prompt, +//! because their access lists already trust it. +use super::process::{Tool, printed_key}; +use super::{Keyring, LookupFailureCategory, LookupResult}; +use crate::config::ConfigSecret; +use crate::error::{Error, Result}; + +const HINT: &str = "Set LINEAR_API_KEY instead."; + +/// `security` exits with this status when no matching item exists. +const NOT_FOUND: i32 = 44; + +pub struct Security { + tool: Tool, +} + +impl Security { + #[cfg(target_os = "macos")] + pub fn new(overlay: crate::config::ChildEnvOverlay) -> Self { + Self { + tool: Tool::new("security", "/usr/bin/security", overlay), + } + } + + #[cfg(test)] + pub fn with_tool(tool: Tool) -> Self { + Self { tool } + } +} + +fn arguments(action: &str, workspace: &str) -> Vec { + [action, "-a", workspace, "-s", "linear-cli"] + .into_iter() + .map(str::to_owned) + .collect() +} + +/// Whether `value` needs no quoting in a `security -i` command line, which +/// is split on whitespace. API keys and workspace slugs never do. +fn plain(value: &str) -> bool { + !value.is_empty() + && value + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.')) +} + +impl Keyring for Security { + fn get(&self, workspace: &str) -> LookupResult { + let mut args = arguments("find-generic-password", workspace); + args.push("-w".to_owned()); + match self.tool.run(&args, None) { + Ok(output) if output.status.success() => printed_key(output.stdout), + Ok(output) if output.status.code() == Some(NOT_FOUND) => LookupResult::Miss, + Ok(_) => LookupResult::Failed(LookupFailureCategory::Other), + Err(error) => LookupResult::Failed(error.category()), + } + } + + /// `security -i` reads the command from stdin, so the secret never + /// appears in the arguments, where other users could see it. + fn set(&self, workspace: &str, secret: &ConfigSecret) -> Result<()> { + if !plain(workspace) || !plain(secret.expose()) { + return Err(Error::new( + "The API key or workspace name has characters the keychain tool cannot take", + ) + .with_hint( + "Linear API keys and workspace names use only letters, digits, '_', '-' and '.'.", + )); + } + let input = format!( + "add-generic-password -U -a {workspace} -s linear-cli -w {}\n", + secret.expose() + ); + let output = self + .tool + .run_change(&["-i".to_owned()], Some(input.as_bytes()), HINT)?; + self.tool.check(&output, "add-generic-password", &[0]) + } + + fn delete(&self, workspace: &str) -> Result<()> { + let output = + self.tool + .run_change(&arguments("delete-generic-password", workspace), None, HINT)?; + self.tool + .check(&output, "delete-generic-password", &[0, NOT_FOUND]) + } +} diff --git a/crates/linear-cli/src/auth/keyring/tests.rs b/crates/linear-cli/src/auth/keyring/tests.rs new file mode 100644 index 000000000..777afec75 --- /dev/null +++ b/crates/linear-cli/src/auth/keyring/tests.rs @@ -0,0 +1,29 @@ +//! A round trip through the real system keyring. Ignored by default because +//! it needs an unlocked keyring; CI runs it with `--ignored`. + +use super::{LookupResult, native}; +use crate::config::{ChildEnvOverlay, ConfigSecret}; + +#[test] +#[ignore = "uses the system keyring"] +fn system_keyring_round_trip() { + let keyring = native(&ChildEnvOverlay::empty()); + let workspace = format!("linear-cli-integration-test-{}", std::process::id()); + assert!(keyring.available(), "no system keyring is available"); + assert!(matches!(keyring.get(&workspace), LookupResult::Miss)); + + keyring + .set( + &workspace, + &ConfigSecret::new("lin_api_test_secret".to_owned()), + ) + .expect("store the key"); + let stored = keyring.get(&workspace); + keyring.delete(&workspace).expect("delete the key"); + + match stored { + LookupResult::Hit(secret) => assert_eq!(secret.expose(), "lin_api_test_secret"), + other => panic!("expected the stored key, got {other:?}"), + } + assert!(matches!(keyring.get(&workspace), LookupResult::Miss)); +} diff --git a/crates/linear-cli/src/auth/keyring/windows.rs b/crates/linear-cli/src/auth/keyring/windows.rs new file mode 100644 index 000000000..33982a244 --- /dev/null +++ b/crates/linear-cli/src/auth/keyring/windows.rs @@ -0,0 +1,58 @@ +//! Windows Credential Manager through the `keyring` crate: generic +//! credentials with target `linear-cli:` and user ``, +//! built directly with `WinCredential` so no other attributes are read or +//! written. Secrets are stored as UTF-16, the platform's string encoding. +use keyring::credential::CredentialApi; +use keyring::windows::WinCredential; + +use super::{Keyring, LookupFailureCategory, LookupResult}; +use crate::config::ConfigSecret; +use crate::error::{Error, Result}; + +pub struct CredentialManager; + +fn credential(workspace: &str) -> WinCredential { + WinCredential { + username: workspace.to_owned(), + target_name: format!("linear-cli:{workspace}"), + target_alias: String::new(), + comment: String::new(), + } +} + +fn failure(error: keyring::Error) -> Error { + match error { + keyring::Error::BadEncoding(_) => { + Error::new("System keyring returned an invalid secret encoding") + } + other => Error::new(other.to_string()), + } +} + +impl Keyring for CredentialManager { + fn get(&self, workspace: &str) -> LookupResult { + match credential(workspace).get_password() { + Ok(secret) if secret.is_empty() => LookupResult::Miss, + Ok(secret) => LookupResult::Hit(ConfigSecret::new(secret)), + Err(keyring::Error::NoEntry) => LookupResult::Miss, + Err(keyring::Error::NoStorageAccess(_)) => { + LookupResult::Failed(LookupFailureCategory::Unavailable) + } + Err(_) => LookupResult::Failed(LookupFailureCategory::Other), + } + } + + /// The `keyring` crate checks the names' lengths and rejects NULs. + fn set(&self, workspace: &str, secret: &ConfigSecret) -> Result<()> { + credential(workspace) + .set_password(secret.expose()) + .map_err(failure) + } + + fn delete(&self, workspace: &str) -> Result<()> { + match credential(workspace).delete_credential() { + Ok(()) | Err(keyring::Error::NoEntry) => Ok(()), + Err(error) => Err(failure(error)), + } + } +} diff --git a/crates/linear-cli/src/auth/mod.rs b/crates/linear-cli/src/auth/mod.rs new file mode 100644 index 000000000..906a30eeb --- /dev/null +++ b/crates/linear-cli/src/auth/mod.rs @@ -0,0 +1,22 @@ +//! Stored credentials: the credentials file and its two formats, the system +//! keyring that holds keys for the keyring format, choosing which key a +//! command uses, and changing what is stored. +pub mod file; +mod format; +pub mod keyring; +pub mod mutation; +mod path; +mod resolve; + +pub use format::{ + CredentialFormat, CredentialFormatErrorKind, CredentialManifest, CredentialStore, + CredentialWarning, parse_credentials, +}; +pub use keyring::LookupFailureCategory; +pub use path::credentials_path; +pub use resolve::{ + ApiKeyInput, CredentialSelection, CredentialSelectionInputs, WorkspaceChoice, resolve, +}; + +#[cfg(test)] +pub(crate) mod test_support; diff --git a/crates/linear-cli/src/auth/mutation.rs b/crates/linear-cli/src/auth/mutation.rs new file mode 100644 index 000000000..549a4d798 --- /dev/null +++ b/crates/linear-cli/src/auth/mutation.rs @@ -0,0 +1,268 @@ +//! Changing stored credentials: keyring writes plus rewriting the credentials +//! file in one of its two formats. +use std::collections::BTreeMap; +use std::io; +use std::path::{Path, PathBuf}; + +use serde::Serialize; + +use crate::auth::keyring::Keyring; +use crate::auth::{CredentialFormat, CredentialStore}; +use crate::config::ConfigSecret; +use crate::error::{Error, Result, ResultExt}; +use crate::platform::private_file; + +/// The credentials file, edited in memory and written back whole after each +/// change. +pub struct Credentials { + path: PathBuf, + format: CredentialFormat, + workspaces: Vec, + default: Option, + /// Every workspace's key in a plaintext file; empty when the keys live in + /// the keyring. + keys: BTreeMap, +} + +impl std::fmt::Debug for Credentials { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Credentials") + .field("path", &self.path) + .field("format", &self.format) + .field("workspaces", &self.workspaces) + .field("default", &self.default) + .finish_non_exhaustive() + } +} + +impl Credentials { + /// The credentials in `store`, which was read from `path`. No keyring + /// entry is read. + pub fn new(store: &CredentialStore, path: &Path) -> Self { + let (format, workspaces, default, keys) = store.file_parts(); + Self { + path: path.to_owned(), + format, + workspaces, + default, + keys, + } + } + + pub fn format(&self) -> CredentialFormat { + self.format + } + + pub fn workspaces(&self) -> &[String] { + &self.workspaces + } + + /// The default workspace; with a single workspace, that one. + pub fn default(&self) -> Option<&str> { + self.default + .as_deref() + .or(match self.workspaces.as_slice() { + [only] => Some(only.as_str()), + _ => None, + }) + } + + pub fn has_workspace(&self, workspace: &str) -> bool { + self.workspaces.iter().any(|name| name == workspace) + } + + /// Whether `add` keeps the key in the file: with `plaintext`, or when the + /// file already holds plaintext keys. + pub fn stores_plaintext(&self, plaintext: bool) -> bool { + plaintext || (self.format == CredentialFormat::Inline && !self.workspaces.is_empty()) + } + + /// Adds or replaces `workspace`'s key. A plaintext key added to a + /// keyring-backed file turns it into a plaintext file: the other keys are + /// read from `store`'s keyring and written to the file. + pub fn add( + &mut self, + workspace: &str, + secret: ConfigSecret, + plaintext: bool, + store: &CredentialStore, + keyring: &dyn Keyring, + ) -> Result<()> { + if self.stores_plaintext(plaintext) { + if self.format == CredentialFormat::Metadata { + for name in self.workspaces.iter().filter(|name| *name != workspace) { + let key = store.key(name).ok_or_else(|| { + Error::new(format!( + "Could not read the API key for workspace \"{name}\" from the system keyring" + )) + .with_hint(format!( + "Log in without --plaintext, or run `linear auth logout {name}` first." + )) + })?; + self.keys.insert(name.clone(), key.clone()); + } + self.format = CredentialFormat::Inline; + } + self.keys.insert(workspace.to_owned(), secret); + } else { + keyring.set(workspace, &secret).context(format!( + "Failed to store API key in system keyring for workspace \"{workspace}\"" + ))?; + } + if !self.has_workspace(workspace) { + self.workspaces.push(workspace.to_owned()); + } + if self.workspaces.len() == 1 { + self.default = Some(workspace.to_owned()); + } + self.save() + } + + /// Removes `workspace`, deleting its keyring entry first. The next + /// workspace becomes the default when the default is removed. + pub fn remove(&mut self, workspace: &str, keyring: &dyn Keyring) -> Result<()> { + if self.format == CredentialFormat::Metadata { + keyring.delete(workspace).context(format!( + "Failed to remove API key from system keyring for workspace \"{workspace}\"" + ))?; + } + self.keys.remove(workspace); + self.workspaces.retain(|name| name != workspace); + if self.default.as_deref() == Some(workspace) { + self.default = self.workspaces.first().cloned(); + } + self.save() + } + + /// Moves every plaintext key to the keyring and returns the migrated + /// workspaces. If one store fails, the entries this call wrote are + /// deleted again and the file is left as it was. + pub fn migrate(&mut self, keyring: &dyn Keyring) -> Result> { + if self.format != CredentialFormat::Inline { + return Ok(Vec::new()); + } + let mut migrated: Vec = Vec::new(); + for name in &self.workspaces { + let key = self + .keys + .get(name) + .expect("a plaintext credentials file has a key for every workspace"); + if let Err(error) = keyring.set(name, key) { + let mut error = error.context(format!( + "Failed to store API key in system keyring for workspace \"{name}\"" + )); + for written in &migrated { + if let Err(cleanup) = keyring.delete(written) { + error.push_message(&format!( + "; could not remove the new keyring entry for \"{written}\": {cleanup}" + )); + } + } + return Err(error); + } + migrated.push(name.clone()); + } + self.format = CredentialFormat::Metadata; + self.keys.clear(); + // If saving fails, the keys already stored in the keyring stay there. + self.save()?; + Ok(migrated) + } + + /// Makes `workspace`, which must be stored, the default. + pub fn set_default(&mut self, workspace: &str) -> Result<()> { + assert!( + self.has_workspace(workspace), + "only a stored workspace can be the default" + ); + self.default = Some(workspace.to_owned()); + self.save() + } + + fn save(&self) -> Result<()> { + let contents = self.render()?; + write_private(&self.path, contents.as_bytes()).map_err(|error| { + Error::new(format!( + "Failed to write credentials file at {}: {error}", + self.path.display() + )) + .with_source(error) + }) + } + + /// The file's TOML: `default` first, then either the workspace list or + /// one ` = ""` entry per workspace, sorted by name. + fn render(&self) -> Result { + let mut names: Vec<&str> = self.workspaces.iter().map(String::as_str).collect(); + names.sort_unstable(); + let rendered = match self.format { + CredentialFormat::Metadata => toml::to_string(&KeyringFile { + default: self.default.as_deref(), + workspaces: names, + }), + CredentialFormat::Inline => { + if let Some(reserved) = names + .iter() + .find(|name| matches!(**name, "default" | "workspaces")) + { + return Err(Error::new(format!( + "A workspace named \"{reserved}\" cannot be stored in a plaintext credentials file" + ))); + } + let keys = names + .into_iter() + .map(|name| { + let key = self + .keys + .get(name) + .expect("a plaintext credentials file has a key for every workspace"); + (name, key.expose()) + }) + .collect(); + toml::to_string(&PlaintextFile { + default: self.default.as_deref(), + keys, + }) + } + }; + Ok(rendered.expect("credentials always serialize as TOML")) + } +} + +#[derive(Serialize)] +struct KeyringFile<'a> { + #[serde(skip_serializing_if = "Option::is_none")] + default: Option<&'a str>, + workspaces: Vec<&'a str>, +} + +#[derive(Serialize)] +struct PlaintextFile<'a> { + #[serde(skip_serializing_if = "Option::is_none")] + default: Option<&'a str>, + #[serde(flatten)] + keys: BTreeMap<&'a str, &'a str>, +} + +/// Replaces the file at `path` with `contents` atomically, readable only by +/// its owner, creating its directory if needed. A symlinked credentials file +/// (as dotfile managers make) keeps its link: the file it points to is +/// replaced. +fn write_private(path: &Path, contents: &[u8]) -> io::Result<()> { + let target = match std::fs::symlink_metadata(path) { + Ok(metadata) if metadata.file_type().is_symlink() => std::fs::canonicalize(path)?, + Ok(_) => path.to_owned(), + Err(error) if error.kind() == io::ErrorKind::NotFound => path.to_owned(), + Err(error) => return Err(error), + }; + if let Some(parent) = target + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + { + private_file::create_dir_all(parent)?; + } + private_file::write_atomic(&target, contents) +} + +#[cfg(test)] +mod tests; diff --git a/crates/linear-cli/src/auth/mutation/tests.rs b/crates/linear-cli/src/auth/mutation/tests.rs new file mode 100644 index 000000000..53dacfe9b --- /dev/null +++ b/crates/linear-cli/src/auth/mutation/tests.rs @@ -0,0 +1,325 @@ +//! Adding, removing and migrating stored credentials, and the files written. +use std::cell::RefCell; +use std::collections::BTreeMap; + +use super::*; +use crate::auth::CredentialManifest; +use crate::auth::keyring::LookupResult; +use crate::auth::test_support::{hit, manifest, store}; + +/// A credentials file in a fresh temporary directory. +struct File { + _dir: tempfile::TempDir, + path: PathBuf, +} + +impl File { + fn new(contents: Option<&str>) -> Self { + let dir = tempfile::tempdir().expect("temp dir"); + let path = dir.path().join("linear").join("credentials.toml"); + if let Some(contents) = contents { + std::fs::create_dir_all(path.parent().expect("parent")).expect("create dir"); + std::fs::write(&path, contents).expect("write credentials"); + } + Self { _dir: dir, path } + } + + fn read(&self) -> String { + std::fs::read_to_string(&self.path).expect("read credentials") + } + + /// The file as the CLI reads it, with `keyring` answering lookups. + fn store(&self, keyring: &[(&str, &str)]) -> CredentialStore { + let manifest = match std::fs::read_to_string(&self.path) { + Ok(text) => manifest(&text).expect("valid credentials"), + Err(_) => CredentialManifest::empty(), + }; + let replies: Vec<(&str, LookupResult)> = keyring + .iter() + .map(|(workspace, key)| (*workspace, hit(key))) + .collect(); + store(manifest, &replies) + } + + fn credentials(&self, store: &CredentialStore) -> Credentials { + Credentials::new(store, &self.path) + } +} + +fn secret(value: &str) -> ConfigSecret { + ConfigSecret::new(value.to_owned()) +} + +/// A keyring that records calls and can fail stores and deletes for one workspace. +#[derive(Default)] +struct FakeKeyring { + calls: RefCell>, + entries: RefCell>, + fail_store: Option<&'static str>, + fail_delete: Option<&'static str>, +} + +impl Keyring for FakeKeyring { + fn get(&self, _: &str) -> LookupResult { + unreachable!("keys are read through the credential store") + } + fn set(&self, workspace: &str, secret: &ConfigSecret) -> Result<()> { + self.calls.borrow_mut().push(format!("store {workspace}")); + if self.fail_store == Some(workspace) { + return Err(Error::new("store refused")); + } + self.entries + .borrow_mut() + .insert(workspace.to_owned(), secret.expose().to_owned()); + Ok(()) + } + fn delete(&self, workspace: &str) -> Result<()> { + self.calls.borrow_mut().push(format!("delete {workspace}")); + if self.fail_delete == Some(workspace) { + return Err(Error::new("delete refused")); + } + self.entries.borrow_mut().remove(workspace); + Ok(()) + } +} + +const INLINE: &str = "default = \"beta\"\nacme = \"key-acme\"\nbeta = \"key-beta\"\n"; +const KEYRING: &str = "default = \"beta\"\nworkspaces = [\"acme\", \"beta\"]\n"; + +#[test] +fn first_login_stores_the_key_in_the_keyring_and_lists_the_workspace() { + let file = File::new(None); + let store = file.store(&[]); + let keyring = FakeKeyring::default(); + let mut credentials = file.credentials(&store); + credentials + .add("acme", secret("key-acme"), false, &store, &keyring) + .expect("should succeed"); + assert_eq!(file.read(), "default = \"acme\"\nworkspaces = [\"acme\"]\n"); + assert_eq!( + keyring + .entries + .borrow() + .get("acme") + .expect("stored") + .as_str(), + "key-acme" + ); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(&file.path) + .expect("should succeed") + .permissions() + .mode(); + assert_eq!(mode & 0o777, 0o600); + } +} + +#[test] +fn plaintext_files_stay_plaintext_and_are_sorted_with_quoted_names() { + let file = File::new(Some("z = \"key-z\"\n")); + let store = file.store(&[]); + let keyring = FakeKeyring::default(); + let mut credentials = file.credentials(&store); + assert!(credentials.stores_plaintext(false)); + for (name, key) in [("10", "key-10"), ("2", "key-2"), ("中", "key-unicode")] { + credentials + .add(name, secret(key), false, &store, &keyring) + .expect("should succeed"); + } + assert_eq!( + file.read(), + "10 = \"key-10\"\n2 = \"key-2\"\nz = \"key-z\"\n\"中\" = \"key-unicode\"\n" + ); + assert!(keyring.calls.borrow().is_empty()); + for reserved in ["default", "workspaces"] { + credentials + .add(reserved, secret("key"), false, &store, &keyring) + .expect_err("should fail"); + } +} + +#[test] +fn written_files_read_back_the_same() { + for contents in [INLINE, KEYRING] { + let file = File::new(Some(contents)); + let store = file.store(&[]); + let mut credentials = file.credentials(&store); + credentials.set_default("acme").expect("should succeed"); + let expected = contents.replace("default = \"beta\"", "default = \"acme\""); + assert_eq!(file.read(), expected); + let reread = file.store(&[]); + assert_eq!(reread.default(), Some("acme")); + assert_eq!(reread.workspaces(), ["acme", "beta"]); + } +} + +#[test] +fn plaintext_key_added_to_a_keyring_file_moves_every_key_into_the_file() { + let file = File::new(Some(KEYRING)); + let store = file.store(&[("acme", "key-acme"), ("beta", "key-beta")]); + let keyring = FakeKeyring::default(); + let mut credentials = file.credentials(&store); + credentials + .add("gamma", secret("key-gamma"), true, &store, &keyring) + .expect("should succeed"); + assert_eq!(credentials.format(), CredentialFormat::Inline); + assert_eq!( + file.read(), + "default = \"beta\"\nacme = \"key-acme\"\nbeta = \"key-beta\"\ngamma = \"key-gamma\"\n" + ); + assert!(keyring.calls.borrow().is_empty()); +} + +#[test] +fn plaintext_conversion_fails_without_writing_when_a_keyring_key_is_unreadable() { + let file = File::new(Some(KEYRING)); + let store = file.store(&[("acme", "key-acme")]); + let mut credentials = file.credentials(&store); + let error = credentials + .add( + "gamma", + secret("key-gamma"), + true, + &store, + &FakeKeyring::default(), + ) + .expect_err("should fail"); + assert!(error.to_string().contains("\"beta\""), "{error}"); + assert_eq!(file.read(), KEYRING); +} + +#[test] +fn keyring_failure_on_add_leaves_the_file_alone() { + let file = File::new(Some(KEYRING)); + let store = file.store(&[]); + let keyring = FakeKeyring { + fail_store: Some("gamma"), + ..FakeKeyring::default() + }; + let error = file + .credentials(&store) + .add("gamma", secret("key-gamma"), false, &store, &keyring) + .expect_err("should fail"); + assert!( + error + .to_string() + .starts_with("Failed to store API key in system keyring for workspace \"gamma\""), + "{error}" + ); + assert_eq!(file.read(), KEYRING); +} + +#[test] +fn removing_the_default_picks_the_next_workspace() { + let file = File::new(Some(KEYRING)); + let store = file.store(&[]); + let keyring = FakeKeyring::default(); + let mut credentials = file.credentials(&store); + credentials + .remove("beta", &keyring) + .expect("should succeed"); + assert_eq!(*keyring.calls.borrow(), ["delete beta"]); + assert_eq!(file.read(), "default = \"acme\"\nworkspaces = [\"acme\"]\n"); + + let file = File::new(Some(INLINE)); + let store = file.store(&[]); + let mut credentials = file.credentials(&store); + credentials + .remove("acme", &keyring) + .expect("should succeed"); + assert_eq!(file.read(), "default = \"beta\"\nbeta = \"key-beta\"\n"); + credentials + .remove("beta", &keyring) + .expect("should succeed"); + assert_eq!(file.read(), ""); + assert_eq!(*keyring.calls.borrow(), ["delete beta"]); +} + +#[test] +fn keyring_failure_on_remove_leaves_the_file_alone() { + let file = File::new(Some(KEYRING)); + let store = file.store(&[]); + let keyring = FakeKeyring { + fail_delete: Some("acme"), + ..FakeKeyring::default() + }; + file.credentials(&store) + .remove("acme", &keyring) + .expect_err("should fail"); + assert_eq!(file.read(), KEYRING); +} + +#[test] +fn migrate_moves_plaintext_keys_into_the_keyring() { + let file = File::new(Some(INLINE)); + let store = file.store(&[]); + let keyring = FakeKeyring::default(); + let mut credentials = file.credentials(&store); + let migrated = credentials.migrate(&keyring).expect("should succeed"); + assert_eq!(migrated, ["acme", "beta"]); + assert_eq!(file.read(), KEYRING); + assert_eq!( + keyring + .entries + .borrow() + .get("acme") + .expect("stored") + .as_str(), + "key-acme" + ); + assert_eq!( + keyring + .entries + .borrow() + .get("beta") + .expect("stored") + .as_str(), + "key-beta" + ); +} + +#[test] +fn failed_migration_removes_the_entries_it_wrote() { + let file = File::new(Some(INLINE)); + let store = file.store(&[]); + let keyring = FakeKeyring { + fail_store: Some("beta"), + ..FakeKeyring::default() + }; + let error = file + .credentials(&store) + .migrate(&keyring) + .expect_err("should fail"); + assert!(error.to_string().contains("\"beta\""), "{error}"); + assert_eq!( + *keyring.calls.borrow(), + ["store acme", "store beta", "delete acme"] + ); + assert!(keyring.entries.borrow().is_empty()); + assert_eq!(file.read(), INLINE); +} + +#[cfg(unix)] +#[test] +fn saving_closes_an_open_file_to_others_and_keeps_a_symlink() { + use std::os::unix::fs::PermissionsExt; + let file = File::new(Some(INLINE)); + let real = file.path.with_file_name("real.toml"); + std::fs::rename(&file.path, &real).expect("move"); + std::fs::set_permissions(&real, std::fs::Permissions::from_mode(0o644)).expect("chmod"); + std::os::unix::fs::symlink(&real, &file.path).expect("symlink"); + let store = file.store(&[]); + let mut credentials = file.credentials(&store); + credentials.set_default("acme").expect("save"); + assert!( + std::fs::symlink_metadata(&file.path) + .expect("stat") + .file_type() + .is_symlink() + ); + assert!(file.read().starts_with("default = \"acme\"\n")); + let mode = std::fs::metadata(&real).expect("stat").permissions().mode(); + assert_eq!(mode & 0o777, 0o600); +} diff --git a/crates/linear-cli/src/auth/path.rs b/crates/linear-cli/src/auth/path.rs new file mode 100644 index 000000000..c1989bb71 --- /dev/null +++ b/crates/linear-cli/src/auth/path.rs @@ -0,0 +1,78 @@ +use std::path::PathBuf; + +use crate::config::{OsFamily, lexical_config_path}; + +/// Where the credentials file lives; no file is read. +pub fn credentials_path( + os: OsFamily, + xdg: Option<&str>, + home: Option<&str>, + appdata: Option<&str>, +) -> Option { + let base = match os { + OsFamily::Unix => xdg + .filter(|value| !value.is_empty()) + .map(|value| PathBuf::from(value).join("linear")) + .or_else(|| { + home.filter(|value| !value.is_empty()) + .map(|value| PathBuf::from(value).join(".config/linear")) + }), + OsFamily::Windows => appdata + .filter(|value| !value.is_empty()) + .map(|value| PathBuf::from(value).join("linear")), + }?; + Some(lexical_config_path(&base.join("credentials.toml"))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn os_bases_are_used_in_order_and_the_join_is_normalized() { + assert_eq!( + credentials_path(OsFamily::Unix, Some("/tmp/a/../b/"), Some("/home/x"), None), + Some(PathBuf::from("/tmp/b/linear/credentials.toml")) + ); + assert_eq!( + credentials_path(OsFamily::Unix, Some(""), Some("/home/x"), None), + Some(PathBuf::from("/home/x/.config/linear/credentials.toml")) + ); + assert_eq!( + credentials_path( + OsFamily::Windows, + Some("/unused"), + None, + Some("C:/Users/x/AppData/Roaming") + ), + Some(PathBuf::from( + "C:/Users/x/AppData/Roaming/linear/credentials.toml" + )) + ); + assert_eq!( + credentials_path( + OsFamily::Windows, + Some("/unused"), + Some("/unused"), + Some("") + ), + None + ); + assert_eq!(credentials_path(OsFamily::Unix, None, None, None), None); + } + + #[test] + fn root_is_clamped_and_unresolved_relative_parents_are_kept() { + for (base, expected) in [ + ("/..", "/linear/credentials.toml"), + ("../../", "../../linear/credentials.toml"), + ("a/../../b", "../b/linear/credentials.toml"), + ] { + assert_eq!( + credentials_path(OsFamily::Unix, Some(base), None, None), + Some(PathBuf::from(expected)), + "base {base}" + ); + } + } +} diff --git a/crates/linear-cli/src/auth/resolve.rs b/crates/linear-cli/src/auth/resolve.rs new file mode 100644 index 000000000..2ea94c2ad --- /dev/null +++ b/crates/linear-cli/src/auth/resolve.rs @@ -0,0 +1,172 @@ +use std::fmt; + +use crate::config::{ConfigOptions, ConfigSecret, OptionSource}; + +use super::format::CredentialStore; + +#[derive(Clone)] +pub enum ApiKeyInput<'a> { + Raw { + value: &'a ConfigSecret, + source: OptionSource, + }, + Sourced { + value: &'a ConfigSecret, + }, + Absent, +} + +impl<'a> ApiKeyInput<'a> { + /// Classifies the configured API key by where it came from, without + /// copying the secret. + pub fn from_options(options: &'a ConfigOptions) -> Self { + let Some(selected) = options.api_key() else { + return Self::Absent; + }; + match selected.source() { + source @ (OptionSource::Env | OptionSource::ProjectEnv { .. }) => Self::Raw { + value: selected.value(), + source: source.clone(), + }, + OptionSource::ProjectConfig { .. } | OptionSource::GlobalConfig { .. } => { + Self::Sourced { + value: selected.value(), + } + } + OptionSource::Cli => unreachable!("no command-line flag sets the API key"), + } + } +} + +pub struct CredentialSelectionInputs<'a> { + pub api_key: ApiKeyInput<'a>, + pub cli_workspace: Option<&'a str>, + pub sourced_workspace: Option<(&'a str, OptionSource)>, +} + +/// How the workspace whose stored key a command uses was chosen. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum WorkspaceChoice { + /// The global `--workspace` flag. + Flag, + /// The `workspace` option, from the environment or a config file. + Configured(OptionSource), + /// The credentials file's default workspace. + Default, +} + +pub enum CredentialSelection<'a> { + Selected { + secret: &'a ConfigSecret, + /// The stored credential's workspace; `None` for an API key from the + /// environment or a config file, whose workspace is not known locally. + workspace: Option<&'a str>, + }, + NoKey, + /// `--workspace` was passed while `LINEAR_API_KEY` is set (from `source`). + EnvWorkspaceConflict { + source: OptionSource, + }, + /// The chosen workspace has no usable key: it is not stored, or its + /// keyring entry is missing or unreadable. + Unavailable { + workspace: &'a str, + choice: WorkspaceChoice, + stored: bool, + }, +} +impl fmt::Debug for CredentialSelection<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Selected { workspace, .. } => f + .debug_struct("Selected") + .field("secret", &"") + .field("workspace", workspace) + .finish(), + Self::NoKey => f.write_str("NoKey"), + Self::EnvWorkspaceConflict { source } => f + .debug_struct("EnvWorkspaceConflict") + .field("source", source) + .finish(), + Self::Unavailable { + workspace, + choice, + stored, + } => f + .debug_struct("Unavailable") + .field("workspace", workspace) + .field("choice", choice) + .field("stored", stored) + .finish(), + } + } +} + +/// Chooses the key a command uses: an API key from the environment or a +/// config file, else the stored key of the `--workspace` workspace, the +/// configured workspace, or the default workspace, in that order. Once a +/// workspace is chosen its key must be usable; there is no fallback to +/// another workspace's key. +pub fn resolve<'a>( + inputs: &CredentialSelectionInputs<'a>, + store: &'a CredentialStore, +) -> CredentialSelection<'a> { + let present = |value: &&str| !value.is_empty(); + let cli = inputs.cli_workspace.filter(present); + match &inputs.api_key { + ApiKeyInput::Raw { value, source } if !value.expose().is_empty() => { + if cli.is_some() { + return CredentialSelection::EnvWorkspaceConflict { + source: source.clone(), + }; + } + return CredentialSelection::Selected { + secret: value, + workspace: None, + }; + } + ApiKeyInput::Sourced { value, .. } if !value.expose().is_empty() => { + return CredentialSelection::Selected { + secret: value, + workspace: None, + }; + } + ApiKeyInput::Raw { .. } | ApiKeyInput::Sourced { .. } | ApiKeyInput::Absent => {} + } + let chosen = cli + .map(|workspace| (workspace, WorkspaceChoice::Flag)) + .or_else(|| { + inputs + .sourced_workspace + .as_ref() + .filter(|(workspace, _)| present(workspace)) + .map(|(workspace, source)| { + (*workspace, WorkspaceChoice::Configured(source.clone())) + }) + }) + .or_else(|| { + store + .default() + .map(|workspace| (workspace, WorkspaceChoice::Default)) + }); + let Some((workspace, choice)) = chosen else { + return CredentialSelection::NoKey; + }; + match store + .key(workspace) + .filter(|secret| !secret.expose().is_empty()) + { + Some(secret) => CredentialSelection::Selected { + secret, + workspace: Some(workspace), + }, + None => CredentialSelection::Unavailable { + workspace, + choice, + stored: store.workspaces().iter().any(|name| name == workspace), + }, + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/linear-cli/src/auth/resolve/tests.rs b/crates/linear-cli/src/auth/resolve/tests.rs new file mode 100644 index 000000000..c0e4c489e --- /dev/null +++ b/crates/linear-cli/src/auth/resolve/tests.rs @@ -0,0 +1,256 @@ +use std::collections::BTreeMap; +use std::path::PathBuf; + +use super::*; +use crate::auth::CredentialStore; +use crate::auth::keyring::LookupResult; +use crate::auth::test_support::{hit, manifest, store as canned_store}; +use crate::config::{ + ConfigInputs, OptionInputs, OsFamily, RawConfigFile, SelectedEnv, fixture_path, + parse_config_tier, +}; + +/// Workspaces `a` (key `ka`, the default), `b` (no keyring entry) and `c` +/// (an empty keyring entry). +fn store() -> CredentialStore { + canned_store( + manifest("workspaces=['a','b','c']\ndefault='a'").expect("manifest"), + &[("a", hit("ka")), ("b", LookupResult::Miss), ("c", hit(""))], + ) +} + +fn inputs<'a>( + api_key: ApiKeyInput<'a>, + cli_workspace: Option<&'a str>, + sourced_workspace: Option<&'a str>, +) -> CredentialSelectionInputs<'a> { + CredentialSelectionInputs { + api_key, + cli_workspace, + sourced_workspace: sourced_workspace.map(|workspace| (workspace, OptionSource::Env)), + } +} + +/// The selection, with the selected key spelled out. +fn outcome(inputs: &CredentialSelectionInputs<'_>, store: &CredentialStore) -> String { + match resolve(inputs, store) { + CredentialSelection::Selected { secret, workspace } => { + format!("{} from {workspace:?}", secret.expose()) + } + CredentialSelection::NoKey => "no key".to_owned(), + CredentialSelection::EnvWorkspaceConflict { source } => format!("conflict ({source:?})"), + CredentialSelection::Unavailable { + workspace, + choice, + stored, + } => format!("{workspace} unavailable ({choice:?}, stored: {stored})"), + } +} + +#[test] +fn configured_api_keys_come_first() { + let store = store(); + let raw = ConfigSecret::new("raw".to_owned()); + let raw = || ApiKeyInput::Raw { + value: &raw, + source: OptionSource::Env, + }; + let sourced = ConfigSecret::new("sourced".to_owned()); + let sourced = || ApiKeyInput::Sourced { value: &sourced }; + assert_eq!( + outcome(&inputs(raw(), None, Some("b")), &store), + "raw from None" + ); + assert_eq!( + outcome(&inputs(raw(), Some("a"), None), &store), + "conflict (Env)" + ); + // A key from a config file outranks --workspace. + assert_eq!( + outcome(&inputs(sourced(), Some("b"), None), &store), + "sourced from None" + ); +} + +#[test] +fn empty_api_keys_fall_through_to_stored_credentials() { + let store = store(); + let empty = ConfigSecret::new(String::new()); + let raw = ApiKeyInput::Raw { + value: &empty, + source: OptionSource::Env, + }; + assert_eq!( + outcome(&inputs(raw, None, None), &store), + "ka from Some(\"a\")" + ); + let sourced = ApiKeyInput::Sourced { value: &empty }; + assert_eq!( + outcome(&inputs(sourced, Some("a"), None), &store), + "ka from Some(\"a\")" + ); +} + +#[test] +fn the_flag_then_the_configured_then_the_default_workspace_is_used() { + let store = store(); + let absent = || ApiKeyInput::Absent; + assert_eq!( + outcome(&inputs(absent(), Some("a"), Some("b")), &store), + "ka from Some(\"a\")" + ); + assert_eq!( + outcome(&inputs(absent(), None, Some("a")), &store), + "ka from Some(\"a\")" + ); + assert_eq!( + outcome(&inputs(absent(), None, None), &store), + "ka from Some(\"a\")" + ); + // Empty names count as unset. + assert_eq!( + outcome(&inputs(absent(), Some(""), Some("")), &store), + "ka from Some(\"a\")" + ); +} + +#[test] +fn a_chosen_workspace_without_a_usable_key_never_falls_back() { + let store = store(); + let absent = || ApiKeyInput::Absent; + assert_eq!( + outcome(&inputs(absent(), Some("b"), None), &store), + "b unavailable (Flag, stored: true)" + ); + assert_eq!( + outcome(&inputs(absent(), Some("A"), None), &store), + "A unavailable (Flag, stored: false)" + ); + assert_eq!( + outcome(&inputs(absent(), None, Some("b")), &store), + "b unavailable (Configured(Env), stored: true)" + ); + assert_eq!( + outcome(&inputs(absent(), None, Some("zzz")), &store), + "zzz unavailable (Configured(Env), stored: false)" + ); + assert_eq!( + outcome(&inputs(absent(), Some("c"), None), &store), + "c unavailable (Flag, stored: true)" + ); + let default_c = canned_store( + manifest("workspaces=['c']").expect("manifest"), + &[("c", hit(""))], + ); + assert_eq!( + outcome(&inputs(absent(), None, None), &default_c), + "c unavailable (Default, stored: true)" + ); +} + +#[test] +fn no_stored_workspace_is_no_key() { + let empty = canned_store(manifest("").expect("manifest"), &[]); + assert_eq!( + outcome(&inputs(ApiKeyInput::Absent, None, None), &empty), + "no key" + ); + let no_default = canned_store( + manifest("workspaces=['a','b']").expect("manifest"), + &[("a", hit("ka"))], + ); + assert_eq!( + outcome(&inputs(ApiKeyInput::Absent, None, None), &no_default), + "no key" + ); +} + +#[test] +fn debug_output_redacts_the_key() { + let store = store(); + let raw = ConfigSecret::new("lin_api_fake_unique_marker".to_owned()); + let raw = ApiKeyInput::Raw { + value: &raw, + source: OptionSource::Env, + }; + let selection = resolve(&inputs(raw, None, None), &store); + assert!(!format!("{selection:?}").contains("lin_api_fake_unique_marker")); +} + +#[test] +fn configured_secret_maps_to_raw_or_sourced_without_copying() { + let make_env = |pairs: &[(&str, &str)]| ConfigInputs { + cwd: PathBuf::from(fixture_path("/repo")), + os: OsFamily::Unix, + process_env: pairs + .iter() + .map(|(key, value)| ((*key).to_owned(), (*value).to_owned())) + .collect(), + }; + let none = SelectedEnv { + applied: BTreeMap::new(), + source_path: None, + diagnostics: vec![], + }; + let process = make_env(&[("LINEAR_API_KEY", "process")]); + let options = ConfigOptions::from_inputs(OptionInputs { + env: &process, + dotenv: &none, + project: None, + global: None, + }) + .expect("process options"); + assert!(matches!( + ApiKeyInput::from_options(&options), + ApiKeyInput::Raw { + source: OptionSource::Env, + .. + } + )); + let applied = SelectedEnv { + applied: BTreeMap::from([("LINEAR_API_KEY".to_owned(), "dotenv".to_owned())]), + source_path: Some(PathBuf::from("/repo/.env")), + diagnostics: vec![], + }; + let options = ConfigOptions::from_inputs(OptionInputs { + env: &make_env(&[]), + dotenv: &applied, + project: None, + global: None, + }) + .expect("dotenv options"); + assert!(matches!( + ApiKeyInput::from_options(&options), + ApiKeyInput::Raw { + source: OptionSource::ProjectEnv { .. }, + .. + } + )); + let project = parse_config_tier(RawConfigFile { + path: PathBuf::from("/repo/linear.toml"), + bytes: b"api_key='project'".to_vec(), + }) + .expect("TOML"); + let options = ConfigOptions::from_inputs(OptionInputs { + env: &make_env(&[]), + dotenv: &none, + project: Some(&project), + global: None, + }) + .expect("project options"); + assert!(matches!( + ApiKeyInput::from_options(&options), + ApiKeyInput::Sourced { .. } + )); + let options = ConfigOptions::from_inputs(OptionInputs { + env: &make_env(&[]), + dotenv: &none, + project: None, + global: Some(&project), + }) + .expect("global options"); + assert!(matches!( + ApiKeyInput::from_options(&options), + ApiKeyInput::Sourced { .. } + )); +} diff --git a/crates/linear-cli/src/auth/test_support.rs b/crates/linear-cli/src/auth/test_support.rs new file mode 100644 index 000000000..58cf9c0f2 --- /dev/null +++ b/crates/linear-cli/src/auth/test_support.rs @@ -0,0 +1,56 @@ +//! Credential stores built from TOML text with a canned, read-only keyring. +use std::path::PathBuf; + +use super::format::CredentialFormatError; +use crate::auth::keyring::{Keyring, LookupResult}; +use crate::auth::{CredentialManifest, CredentialStore, parse_credentials}; +use crate::config::{ConfigSecret, RawConfigFile, parse_config_tier}; +use crate::error::Result; + +/// A credentials file parsed from `text`. +pub(crate) fn manifest(text: &str) -> Result { + let tier = parse_config_tier(RawConfigFile { + path: PathBuf::from("/fake/credentials.toml"), + bytes: text.as_bytes().to_vec(), + }) + .expect("valid TOML fixture"); + parse_credentials(tier) +} + +/// A store whose keyring answers each listed workspace and misses the rest. +pub(crate) fn store( + manifest: CredentialManifest, + replies: &[(&str, LookupResult)], +) -> CredentialStore { + CredentialStore::new(manifest, keyring(replies)) +} + +/// A keyring that answers each listed workspace and misses the rest. +pub(crate) fn keyring(replies: &[(&str, LookupResult)]) -> Box { + let replies = replies + .iter() + .map(|(workspace, result)| ((*workspace).to_owned(), result.clone())) + .collect(); + Box::new(CannedKeyring(replies)) +} + +pub(crate) fn hit(key: &str) -> LookupResult { + LookupResult::Hit(ConfigSecret::new(key.to_owned())) +} + +struct CannedKeyring(Vec<(String, LookupResult)>); + +impl Keyring for CannedKeyring { + fn get(&self, workspace: &str) -> LookupResult { + self.0 + .iter() + .find(|(name, _)| name == workspace) + .map_or(LookupResult::Miss, |(_, result)| result.clone()) + } + fn set(&self, _: &str, _: &ConfigSecret) -> Result<()> { + unreachable!("a canned keyring is only read") + } + fn delete(&self, _: &str) -> Result<()> { + unreachable!("a canned keyring is only read") + } +} diff --git a/crates/linear-cli/src/cli/api.rs b/crates/linear-cli/src/cli/api.rs new file mode 100644 index 000000000..b40d0eb6c --- /dev/null +++ b/crates/linear-cli/src/cli/api.rs @@ -0,0 +1,25 @@ +use clap::Args; + +use super::values::NonBlank; + +#[derive(Debug, Args)] +pub struct Api { + /// GraphQL query or mutation; read from stdin when omitted + #[arg(value_name = "DOCUMENT")] + pub graphql_document: Option, + /// Set a variable; repeatable + /// + /// A value that looks like a boolean, number, or null is sent as one, and + /// `@path` reads the value from a file. + #[arg(long, value_name = "KEY=VALUE", value_parser = super::variable_assignment)] + pub variable: Vec, + /// Variables as a JSON object; --variable takes precedence + #[arg(long, value_name = "JSON", value_parser = NonBlank)] + pub variables_json: Option, + /// Follow the cursor of the one connection in the response and print every page + #[arg(long)] + pub paginate: bool, + /// Print nothing; the exit status still reports errors + #[arg(long)] + pub silent: bool, +} diff --git a/crates/linear-cli/src/cli/auth.rs b/crates/linear-cli/src/cli/auth.rs new file mode 100644 index 000000000..cfea8fc5f --- /dev/null +++ b/crates/linear-cli/src/cli/auth.rs @@ -0,0 +1,66 @@ +use clap::{Args, Subcommand}; + +use super::values::NonBlank; + +#[derive(Debug, Args)] +#[command(arg_required_else_help = true)] +pub struct Auth { + #[command(subcommand)] + pub command: AuthCommand, +} + +#[derive(Debug, Subcommand)] +pub enum AuthCommand { + /// Log in to a workspace with an API key + Login(AuthLogin), + /// Remove a workspace's credential + Logout(AuthLogout), + /// List the workspaces you are logged in to + List(AuthList), + /// Set the workspace used when none is named + Default(AuthDefault), + /// Print the API key in use + Token(AuthToken), + /// Show who you are logged in as + Whoami(AuthWhoami), + /// Move API keys from the credentials file to the system keyring + Migrate(AuthMigrate), +} + +#[derive(Debug, Args)] +pub struct AuthLogin { + /// API key; asked for, or read from stdin when it is piped + #[arg(long, short, value_parser = NonBlank)] + pub key: Option, + /// Store the API key in the credentials file instead of the system keyring + #[arg(long)] + pub plaintext: bool, +} + +#[derive(Debug, Args)] +pub struct AuthLogout { + /// Workspace to log out of [default: --workspace, or asked for when several are stored] + #[arg(value_name = "WORKSPACE")] + pub workspace_name: Option, + #[command(flatten)] + pub confirm: super::ConfirmArgs, +} + +#[derive(Debug, Args)] +pub struct AuthList {} + +#[derive(Debug, Args)] +pub struct AuthDefault { + /// Workspace to make the default [default: --workspace, or asked for] + #[arg(value_name = "WORKSPACE")] + pub workspace_name: Option, +} + +#[derive(Debug, Args)] +pub struct AuthToken {} + +#[derive(Debug, Args)] +pub struct AuthWhoami {} + +#[derive(Debug, Args)] +pub struct AuthMigrate {} diff --git a/crates/linear-cli/src/cli/completions.rs b/crates/linear-cli/src/cli/completions.rs new file mode 100644 index 000000000..c9ca8c371 --- /dev/null +++ b/crates/linear-cli/src/cli/completions.rs @@ -0,0 +1,29 @@ +use clap::Args; +use clap_complete::Shell; + +#[derive(Debug, Args)] +#[command(arg_required_else_help = true)] +pub struct Completions { + /// Shell to print completions for + pub shell: Shell, + /// Command name to register the completions for, if not `linear` + #[arg(long, short, value_parser = command_name)] + pub name: Option, +} + +/// A program name that is safe to embed in every shell's completion script. +fn command_name(name: &str) -> Result { + let mut chars = name.chars(); + let valid = chars + .next() + .is_some_and(|first| first.is_ascii_alphanumeric() || first == '_') + && chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.')); + if valid { + Ok(name.to_owned()) + } else { + Err( + "use ASCII letters, digits, '_', '-' or '.', starting with a letter, digit or '_'" + .to_owned(), + ) + } +} diff --git a/crates/linear-cli/src/cli/config.rs b/crates/linear-cli/src/cli/config.rs new file mode 100644 index 000000000..8b67f3e65 --- /dev/null +++ b/crates/linear-cli/src/cli/config.rs @@ -0,0 +1,13 @@ +use clap::Args; + +use super::values::NonBlank; + +#[derive(Debug, Args)] +pub struct Config { + /// Default team (key, name, or ID); asked for when omitted + #[arg(long, value_parser = NonBlank)] + pub team: Option, + /// Default issue sort order; asked for when omitted + #[arg(long)] + pub sort: Option, +} diff --git a/crates/linear-cli/src/cli/cycle.rs b/crates/linear-cli/src/cli/cycle.rs new file mode 100644 index 000000000..5306e5767 --- /dev/null +++ b/crates/linear-cli/src/cli/cycle.rs @@ -0,0 +1,48 @@ +use clap::{Args, Subcommand}; + +use super::values::NonBlank; + +#[derive(Debug, Args)] +#[command(arg_required_else_help = true)] +pub struct Cycle { + #[command(subcommand)] + pub command: CycleCommand, +} + +#[derive(Debug, Subcommand)] +pub enum CycleCommand { + /// List a team's cycles + List(CycleList), + /// Show a cycle and its issues + #[command(alias = "v")] + View(CycleView), +} + +#[derive(Debug, Args)] +pub struct CycleList { + /// Team key, name, or ID; defaults to the configured team + #[arg(long, value_parser = NonBlank)] + pub team: Option, + /// Maximum number of cycles to show, newest first (a number or `all`) + #[arg(long, value_parser = super::limit::parse, default_value = "all")] + pub limit: super::Limit, + /// Print JSON + #[arg(long, short)] + pub json: bool, +} + +#[derive(Debug, Args)] +pub struct CycleView { + /// Cycle name, number, `active`, `next`, `previous`, or an offset like +1 or -1 + #[arg(value_name = "CYCLE", allow_negative_numbers = true)] + pub cycle_ref: String, + /// Team key, name, or ID; defaults to the configured team + #[arg(long, value_parser = NonBlank)] + pub team: Option, + /// Print JSON + #[arg(long, short)] + pub json: bool, + /// Do not page long output + #[arg(long)] + pub no_pager: bool, +} diff --git a/crates/linear-cli/src/cli/document.rs b/crates/linear-cli/src/cli/document.rs new file mode 100644 index 000000000..576a92d2b --- /dev/null +++ b/crates/linear-cli/src/cli/document.rs @@ -0,0 +1,227 @@ +use clap::{Args, Subcommand, ValueHint}; + +use super::LINEAR_MARKDOWN; +use super::values::{NonBlank, TextSource}; + +#[derive(Debug, Args)] +#[command(arg_required_else_help = true)] +pub struct Document { + #[command(subcommand)] + pub command: DocumentCommand, +} + +#[derive(Debug, Subcommand)] +pub enum DocumentCommand { + /// List documents + #[command(alias = "l")] + List(DocumentList), + /// Show a document + #[command(alias = "v")] + View(DocumentView), + /// Create a document + #[command(alias = "c", after_long_help = LINEAR_MARKDOWN)] + Create(DocumentCreate), + /// Update a document + #[command(alias = "u", after_long_help = LINEAR_MARKDOWN)] + Update(DocumentUpdate), + /// Delete a document (moves it to the trash) + #[command(alias = "d")] + Delete(DocumentDelete), + /// Add and list comments on a document + Comment(DocumentComment), +} + +#[derive(Debug, Args)] +pub struct DocumentList { + /// Show this project's documents (ID, slug, or name) + #[arg(long, conflicts_with_all = ["issue", "initiative", "team", "cycle", "release"], value_parser = NonBlank)] + pub project: Option, + /// Show this issue's documents (like ENG-123) + #[arg(long, conflicts_with_all = ["initiative", "team", "cycle", "release"], value_parser = NonBlank)] + pub issue: Option, + /// Show this initiative's documents (ID, slug, or name) + #[arg(long, conflicts_with_all = ["team", "cycle", "release"], value_parser = NonBlank)] + pub initiative: Option, + /// Show this team's documents (key, name, or ID); with --cycle, the cycle's team + #[arg(long, conflicts_with = "release", value_parser = NonBlank)] + pub team: Option, + /// Show this cycle's documents: a name, number, `active`, `next`, `previous`, or an offset like +1 or -1 + #[arg(long, conflicts_with = "release", allow_negative_numbers = true, value_parser = NonBlank)] + pub cycle: Option, + /// Show this release's documents (ID, name, or version) + #[arg(long, value_parser = NonBlank)] + pub release: Option, + /// Print JSON + #[arg(long, short)] + pub json: bool, + /// Maximum number of documents to show (a number or `all`) + #[arg(long, value_parser = super::limit::parse, default_value = "50")] + pub limit: super::Limit, +} + +#[derive(Debug, Args)] +pub struct DocumentView { + /// Document ID or slug + #[arg(value_name = "DOCUMENT", value_parser = NonBlank)] + pub id: String, + /// Print the Markdown source instead of rendering it + #[arg(long)] + pub raw: bool, + /// Open the document in the browser + #[arg(long, short)] + pub web: bool, + /// Print JSON + #[arg(long, short)] + pub json: bool, + /// Do not page long output + #[arg(long)] + pub no_pager: bool, + /// Keep remote image and file URLs instead of downloading them + #[arg(long)] + pub no_download: bool, +} + +#[derive(Debug, Args)] +pub struct DocumentCreate { + /// Document title + #[arg(long, short, value_parser = NonBlank)] + pub title: Option, + /// Document text, in Markdown + #[arg(long, short, value_name = "MARKDOWN", value_parser = NonBlank)] + pub content: Option, + /// Read the document from a Markdown file (- for stdin) + #[arg(long, short = 'f', value_name = "FILE", value_hint = ValueHint::FilePath)] + pub content_file: Option, + /// Attach the document to a project (ID, slug, or name) + #[arg(long, conflicts_with_all = ["issue", "initiative", "team", "cycle", "release"], value_parser = NonBlank)] + pub project: Option, + /// Attach the document to an issue (like ENG-123) + #[arg(long, conflicts_with_all = ["initiative", "team", "cycle", "release"], value_parser = NonBlank)] + pub issue: Option, + /// Attach the document to an initiative (ID, slug, or name) + #[arg(long, conflicts_with_all = ["team", "cycle", "release"], value_parser = NonBlank)] + pub initiative: Option, + /// Attach the document to a team (key, name, or ID); with --cycle, the cycle's team + #[arg(long, conflicts_with = "release", value_parser = NonBlank)] + pub team: Option, + /// Attach the document to a cycle: a name, number, `active`, `next`, `previous`, or an offset like +1 or -1 + #[arg(long, conflicts_with = "release", allow_negative_numbers = true, value_parser = NonBlank)] + pub cycle: Option, + /// Attach the document to a release (ID, name, or version) + #[arg(long, value_parser = NonBlank)] + pub release: Option, + /// Document icon (an emoji) + #[arg(long, value_parser = NonBlank)] + pub icon: Option, + /// Also prompt for the optional fields + #[arg(long, short)] + pub interactive: bool, + #[command(flatten)] + pub confirm: super::YesArgs, +} + +#[derive(Debug, Args)] +pub struct DocumentUpdate { + /// Document ID or slug + #[arg(value_name = "DOCUMENT", value_parser = NonBlank)] + pub document_id: String, + /// New title + #[arg(long, short, value_parser = NonBlank)] + pub title: Option, + /// New text, in Markdown + #[arg(long, short, value_name = "MARKDOWN", value_parser = NonBlank)] + pub content: Option, + /// Read the new text from a Markdown file (- for stdin) + #[arg(long, short = 'f', value_name = "FILE", value_hint = ValueHint::FilePath)] + pub content_file: Option, + /// New icon (an emoji) + #[arg(long, value_parser = NonBlank)] + pub icon: Option, + /// Move the document to a project (ID, slug, or name) + #[arg(long, conflicts_with_all = ["issue", "initiative", "team", "cycle", "release"], value_parser = NonBlank)] + pub project: Option, + /// Move the document to an issue (like ENG-123) + #[arg(long, conflicts_with_all = ["initiative", "team", "cycle", "release"], value_parser = NonBlank)] + pub issue: Option, + /// Move the document to an initiative (ID, slug, or name) + #[arg(long, conflicts_with_all = ["team", "cycle", "release"], value_parser = NonBlank)] + pub initiative: Option, + /// Move the document to a team (key, name, or ID); with --cycle, the cycle's team + #[arg(long, conflicts_with = "release", value_parser = NonBlank)] + pub team: Option, + /// Move the document to a cycle: a name, number, `active`, `next`, `previous`, or an offset like +1 or -1 + #[arg(long, conflicts_with = "release", allow_negative_numbers = true, value_parser = NonBlank)] + pub cycle: Option, + /// Move the document to a release (ID, name, or version) + #[arg(long, value_parser = NonBlank)] + pub release: Option, + /// Edit the current text in $EDITOR + #[arg(long, short)] + pub edit: bool, + /// Replace the text even if inline comments may lose their anchors + #[arg(long)] + pub force: bool, + #[command(flatten)] + pub confirm: super::YesArgs, +} + +#[derive(Debug, Args)] +pub struct DocumentDelete { + /// Document ID or slug + #[arg(value_name = "DOCUMENT", value_parser = NonBlank, conflicts_with_all = ["bulk", "bulk_file", "bulk_stdin"])] + pub document_id: Option, + #[command(flatten)] + pub confirm: super::ConfirmArgs, + #[command(flatten)] + pub bulk: super::BulkArgs, +} + +#[derive(Debug, Args)] +#[command(arg_required_else_help = true)] +pub struct DocumentComment { + #[command(subcommand)] + pub command: DocumentCommentCommand, +} + +#[derive(Debug, Subcommand)] +pub enum DocumentCommentCommand { + /// Comment on a document, or reply to a comment + #[command(after_long_help = LINEAR_MARKDOWN)] + Add(DocumentCommentAdd), + /// List a document's comments + List(DocumentCommentList), +} + +#[derive(Debug, Args)] +pub struct DocumentCommentAdd { + /// Document ID or slug + #[arg(value_parser = NonBlank)] + pub document: String, + /// Comment text, in Markdown + #[arg(long, short, value_name = "TEXT", value_parser = NonBlank)] + pub body: Option, + /// Read the comment from a Markdown file (- for stdin) + #[arg(long, value_name = "FILE", value_hint = ValueHint::FilePath)] + pub body_file: Option, + /// Reply to this top-level comment (by ID) + #[arg(long, short = 'p', visible_alias = "parent", value_name = "COMMENT", value_parser = NonBlank)] + pub reply_to: Option, + #[command(flatten)] + pub confirm: super::ConfirmArgs, +} + +#[derive(Debug, Args)] +pub struct DocumentCommentList { + /// Document ID or slug + #[arg(value_parser = NonBlank)] + pub document: String, + /// Maximum number of comments to show (a number or `all`) + #[arg(long, value_parser = super::limit::parse, default_value = "all")] + pub limit: super::Limit, + /// Print JSON + #[arg(long, short)] + pub json: bool, + /// Do not page long output + #[arg(long)] + pub no_pager: bool, +} diff --git a/crates/linear-cli/src/cli/initiative.rs b/crates/linear-cli/src/cli/initiative.rs new file mode 100644 index 000000000..59526a4e7 --- /dev/null +++ b/crates/linear-cli/src/cli/initiative.rs @@ -0,0 +1,255 @@ +use chrono::NaiveDate; +use clap::{Args, Subcommand, ValueHint}; + +use super::LINEAR_MARKDOWN; +use super::values::{HexColor, InitiativeStatus, NonBlank, TextSource, UserRef}; +use crate::graphql::scalars::Float; + +#[derive(Debug, Args)] +#[command(arg_required_else_help = true)] +pub struct Initiative { + #[command(subcommand)] + pub command: InitiativeCommand, +} + +#[derive(Debug, Subcommand)] +pub enum InitiativeCommand { + /// List initiatives + #[command(alias = "ls")] + List(InitiativeList), + /// Show an initiative + #[command(alias = "v")] + View(InitiativeView), + /// Create an initiative + Create(InitiativeCreate), + /// Update an initiative + Update(InitiativeUpdate), + /// Archive an initiative + Archive(InitiativeArchive), + /// Restore an archived initiative + Unarchive(InitiativeUnarchive), + /// Delete an initiative permanently + Delete(InitiativeDelete), + /// Add a project to an initiative + AddProject(InitiativeAddProject), + /// Remove a project from an initiative + RemoveProject(InitiativeRemoveProject), + /// Add and list comments on an initiative + Comment(InitiativeComment), +} + +#[derive(Debug, Args)] +pub struct InitiativeList { + /// Show only initiatives with this status [default: active] + #[arg(long, short, ignore_case = true, conflicts_with = "all_statuses")] + pub status: Option, + /// Show initiatives of every status + #[arg(long)] + pub all_statuses: bool, + /// Show only initiatives owned by this user: a username, email, name, or @me + #[arg(long, short, value_name = "USER")] + pub owner: Option, + /// Open the initiatives page in the browser + #[arg(long, short)] + pub web: bool, + /// Open the initiatives page in the Linear app + #[arg(long, short)] + pub app: bool, + /// Maximum number of initiatives to show (a number or `all`) + #[arg(long, value_parser = super::limit::parse, default_value = "all")] + pub limit: super::Limit, + /// Print JSON + #[arg(long, short)] + pub json: bool, + /// Include archived initiatives + #[arg(long)] + pub archived: bool, +} + +#[derive(Debug, Args)] +pub struct InitiativeView { + /// Initiative ID, slug, or name + #[arg(value_name = "INITIATIVE", value_parser = NonBlank)] + pub initiative_id: String, + /// Open the initiative in the browser + #[arg(long, short)] + pub web: bool, + /// Open the initiative in the Linear app + #[arg(long, short)] + pub app: bool, + /// Print JSON + #[arg(long, short)] + pub json: bool, + /// Do not page long output + #[arg(long)] + pub no_pager: bool, +} + +#[derive(Debug, Args)] +pub struct InitiativeCreate { + /// Initiative name + #[arg(long, short, value_parser = NonBlank)] + pub name: Option, + /// Initiative description + #[arg(long, short, value_parser = NonBlank)] + pub description: Option, + /// Initiative status [default: planned] + #[arg(long, short, ignore_case = true)] + pub status: Option, + /// Owner: a username, email, name, or @me + #[arg(long, short, value_name = "USER")] + pub owner: Option, + /// Target date (YYYY-MM-DD) + #[arg(long, value_name = "DATE", value_parser = super::values::date)] + pub target_date: Option, + /// Color, like #5E6AD2 + #[arg(long, short)] + pub color: Option, + /// Icon name + #[arg(long, value_parser = NonBlank)] + pub icon: Option, + /// Also prompt for the optional fields + #[arg(long, short)] + pub interactive: bool, + #[command(flatten)] + pub confirm: super::ConfirmArgs, +} + +#[derive(Debug, Args)] +pub struct InitiativeArchive { + /// Initiative ID, slug, or name + #[arg(value_name = "INITIATIVE", value_parser = NonBlank, conflicts_with_all = ["bulk", "bulk_file", "bulk_stdin"])] + pub initiative_id: Option, + #[command(flatten)] + pub confirm: super::ConfirmArgs, + #[command(flatten)] + pub bulk: super::BulkArgs, +} + +#[derive(Debug, Args)] +pub struct InitiativeUpdate { + /// Initiative ID, slug, or name + #[arg(value_name = "INITIATIVE", value_parser = NonBlank)] + pub initiative_id: String, + /// New name + #[arg(long, short, value_parser = NonBlank)] + pub name: Option, + /// New description + #[arg(long, short, value_parser = NonBlank)] + pub description: Option, + /// New status + #[arg(long, ignore_case = true)] + pub status: Option, + /// New owner: a username, email, name, or @me + #[arg(long, value_name = "USER")] + pub owner: Option, + /// New target date (YYYY-MM-DD) + #[arg(long, value_name = "DATE", value_parser = super::values::date)] + pub target_date: Option, + /// New color, like #5E6AD2 + #[arg(long)] + pub color: Option, + /// New icon name + #[arg(long, value_parser = NonBlank)] + pub icon: Option, + /// Prompt for the fields to change + #[arg(long, short)] + pub interactive: bool, + #[command(flatten)] + pub confirm: super::ConfirmArgs, +} + +#[derive(Debug, Args)] +pub struct InitiativeUnarchive { + /// Initiative ID, slug, or name + #[arg(value_name = "INITIATIVE", value_parser = NonBlank)] + pub initiative_id: String, + #[command(flatten)] + pub confirm: super::ConfirmArgs, +} + +#[derive(Debug, Args)] +pub struct InitiativeDelete { + /// Initiative ID, slug, or name + #[arg(value_name = "INITIATIVE", value_parser = NonBlank, conflicts_with_all = ["bulk", "bulk_file", "bulk_stdin"])] + pub initiative_id: Option, + #[command(flatten)] + pub confirm: super::ConfirmArgs, + #[command(flatten)] + pub bulk: super::BulkArgs, +} + +#[derive(Debug, Args)] +pub struct InitiativeAddProject { + /// Initiative ID, slug, or name + #[arg(value_parser = NonBlank)] + pub initiative: String, + /// Project ID, slug, or name + #[arg(value_parser = NonBlank)] + pub project: String, + /// Position among the initiative's projects + #[arg(long, value_name = "NUMBER", value_parser = super::values::sort_order, allow_negative_numbers = true)] + pub sort_order: Option, +} + +#[derive(Debug, Args)] +pub struct InitiativeRemoveProject { + /// Initiative ID, slug, or name + #[arg(value_parser = NonBlank)] + pub initiative: String, + /// Project ID, slug, or name + #[arg(value_parser = NonBlank)] + pub project: String, + #[command(flatten)] + pub confirm: super::ConfirmArgs, +} + +#[derive(Debug, Args)] +#[command(arg_required_else_help = true)] +pub struct InitiativeComment { + #[command(subcommand)] + pub command: InitiativeCommentCommand, +} + +#[derive(Debug, Subcommand)] +pub enum InitiativeCommentCommand { + /// Comment on an initiative, or reply to a comment + #[command(after_long_help = LINEAR_MARKDOWN)] + Add(InitiativeCommentAdd), + /// List an initiative's comments + List(InitiativeCommentList), +} + +#[derive(Debug, Args)] +pub struct InitiativeCommentAdd { + /// Initiative ID, slug, or name + #[arg(value_parser = NonBlank)] + pub initiative: String, + /// Comment text, in Markdown + #[arg(long, short, value_name = "TEXT", value_parser = NonBlank)] + pub body: Option, + /// Read the comment from a Markdown file (- for stdin) + #[arg(long, value_name = "FILE", value_hint = ValueHint::FilePath)] + pub body_file: Option, + /// Reply to this top-level comment (by ID) + #[arg(long, short = 'p', visible_alias = "parent", value_name = "COMMENT", value_parser = NonBlank)] + pub reply_to: Option, + #[command(flatten)] + pub confirm: super::ConfirmArgs, +} + +#[derive(Debug, Args)] +pub struct InitiativeCommentList { + /// Initiative ID, slug, or name + #[arg(value_parser = NonBlank)] + pub initiative: String, + /// Maximum number of comments to show (a number or `all`) + #[arg(long, value_parser = super::limit::parse, default_value = "all")] + pub limit: super::Limit, + /// Print JSON + #[arg(long, short)] + pub json: bool, + /// Do not page long output + #[arg(long)] + pub no_pager: bool, +} diff --git a/crates/linear-cli/src/cli/initiative_update.rs b/crates/linear-cli/src/cli/initiative_update.rs new file mode 100644 index 000000000..1606834e2 --- /dev/null +++ b/crates/linear-cli/src/cli/initiative_update.rs @@ -0,0 +1,42 @@ +use clap::{Args, Subcommand}; + +use super::LINEAR_MARKDOWN; + +#[derive(Debug, Args)] +#[command(arg_required_else_help = true)] +pub struct InitiativeUpdate { + #[command(subcommand)] + pub command: InitiativeUpdateCommand, +} + +#[derive(Debug, Subcommand)] +pub enum InitiativeUpdateCommand { + /// Post a status update on an initiative + #[command(alias = "c", after_long_help = LINEAR_MARKDOWN)] + Create(InitiativeUpdateCreate), + /// List an initiative's status updates + #[command(aliases = ["l", "ls"])] + List(InitiativeUpdateList), +} + +#[derive(Debug, Args)] +pub struct InitiativeUpdateCreate { + /// Initiative ID, slug, or name + #[arg(value_name = "INITIATIVE")] + pub initiative_id: String, + #[command(flatten)] + pub update: super::project_update::StatusUpdateArgs, +} + +#[derive(Debug, Args)] +pub struct InitiativeUpdateList { + /// Initiative ID, slug, or name + #[arg(value_name = "INITIATIVE")] + pub initiative_id: String, + /// Print JSON + #[arg(long, short)] + pub json: bool, + /// Maximum number of updates to show, newest first (a number or `all`) + #[arg(long, value_parser = super::limit::parse, default_value = "10")] + pub limit: super::Limit, +} diff --git a/crates/linear-cli/src/cli/issue.rs b/crates/linear-cli/src/cli/issue.rs new file mode 100644 index 000000000..c5236c107 --- /dev/null +++ b/crates/linear-cli/src/cli/issue.rs @@ -0,0 +1,705 @@ +use std::path::PathBuf; + +use chrono::{DateTime, NaiveDate, Utc}; +use clap::{Args, Subcommand, ValueEnum, ValueHint}; + +use super::LINEAR_MARKDOWN; +use super::values::{NonBlank, TextSource, UserRef}; + +#[derive(Debug, Args)] +#[command(arg_required_else_help = true)] +pub struct Issue { + #[command(subcommand)] + pub command: IssueCommand, +} + +#[derive(Debug, Subcommand)] +pub enum IssueCommand { + /// List issues, assigned to you by default + #[command(aliases = ["mine", "l"])] + List(IssueList), + /// Find issues by filters or full-text search + #[command(alias = "q")] + Query(IssueQuery), + /// Show an issue + #[command(alias = "v")] + View(IssueView), + /// Create an issue + #[command(after_long_help = LINEAR_MARKDOWN)] + Create(IssueCreate), + /// Update an issue + #[command(after_long_help = LINEAR_MARKDOWN)] + Update(IssueUpdate), + /// Delete an issue (moves it to the trash) + #[command(alias = "d")] + Delete(IssueDelete), + /// Archive an issue + /// + /// Linear archives closed issues on its own, so prefer closing an issue + /// (`issue update --state`) and letting auto-archive run, or `issue delete` + /// to trash it. Archived issues drop out of list, query, and search results + /// unless --include-archived is passed. See + /// https://linear.app/docs/delete-archive-issues + Archive(IssueArchive), + /// Start an issue: switch to its branch and mark it started + Start(IssueStart), + /// Print the issue ID of the current branch or jj change + Id(IssueId), + /// Print an issue's title + Title(IssueTitle), + /// Print an issue's URL + Url(IssueUrl), + /// Print an issue's title and a Linear-issue trailer, for commit messages + Describe(IssueDescribe), + /// List the commits that reference an issue (jj only) + Commits(IssueCommits), + /// Open a GitHub pull request for an issue + #[command(alias = "pr")] + PullRequest(IssuePullRequest), + /// Add, list, edit, and delete comments on an issue + Comment(IssueComment), + /// Upload a file and attach it to an issue + /// + /// The file is listed in the issue's sidebar; images do not render inline. + /// To show an image in the conversation, use `issue comment add --attach`. + Attach(IssueAttach), + /// Link a URL to an issue + Link(IssueLink), + /// Manage relations between issues, like blocks and duplicates + Relation(IssueRelation), + /// Inspect agent sessions on an issue + AgentSession(IssueAgentSession), +} + +#[derive(Debug, Args)] +pub struct IssueId {} + +#[derive(Debug, Args)] +pub struct IssueList { + /// Show issues in this state: a type (triage, backlog, unstarted, started, + /// completed, canceled), name, or ID; repeatable + #[arg(long, short, value_parser = NonBlank, default_values = ["unstarted"])] + pub state: Vec, + /// Show issues in every state + #[arg(long, conflicts_with = "state")] + pub all_states: bool, + /// Sort order [default: the issue_sort setting, or priority] + #[arg(long)] + pub sort: Option, + /// Show this team's issues (key, name, or ID); defaults to the configured team + #[arg(long, value_parser = NonBlank)] + pub team: Option, + #[command(flatten)] + pub filters: IssueFilters, + /// Open the list in the browser + #[arg(long, short)] + pub web: bool, + /// Open the list in the Linear app + #[arg(long, short)] + pub app: bool, + /// Do not page long output + #[arg(long)] + pub no_pager: bool, + /// Print JSON + #[arg(long, short, conflicts_with_all = ["web", "app"])] + pub json: bool, +} + +/// Filters `issue list` and `issue query` share. +#[derive(Debug, Args)] +pub struct IssueFilters { + /// Show only issues assigned to this user: a username, email, name, or @me + #[arg(long, value_name = "USER")] + pub assignee: Option, + /// Show issues of every assignee + #[arg(long, short = 'A', conflicts_with_all = ["assignee", "unassigned"])] + pub all_assignees: bool, + /// Show only unassigned issues + #[arg(long, short = 'U', conflicts_with = "assignee")] + pub unassigned: bool, + /// Show only this project's issues (ID, slug, or name) + #[arg(long, value_parser = NonBlank)] + pub project: Option, + /// Show only issues in projects with this project label + #[arg(long, value_name = "LABEL", conflicts_with_all = ["project", "milestone"], value_parser = NonBlank)] + pub project_label: Option, + /// Show only this cycle's issues: a name, number, `active`, `next`, + /// `previous`, or an offset like +1 or -1 + #[arg(long, allow_negative_numbers = true, value_parser = NonBlank)] + pub cycle: Option, + /// Show only this milestone's issues (ID, or name with --project) + #[arg(long, value_parser = NonBlank)] + pub milestone: Option, + /// Show only issues with this label; repeat to require several + #[arg(long, short, value_parser = NonBlank)] + pub label: Vec, + /// Show only issues created after this date (YYYY-MM-DD or RFC 3339) + #[arg(long, value_name = "DATE", value_parser = super::values::date_or_datetime)] + pub created_after: Option>, + /// Show only issues updated after this date (YYYY-MM-DD or RFC 3339) + #[arg(long, value_name = "DATE", value_parser = super::values::date_or_datetime)] + pub updated_after: Option>, + /// Maximum number of issues to show (a number or `all`) + #[arg(long, value_parser = super::limit::parse, default_value = "50")] + pub limit: super::Limit, +} + +// Every assignee and every state are what `issue query` shows by default; +// the flags stay accepted but are not offered. +#[derive(Debug, Args)] +#[command( + mut_arg("all_assignees", |arg| arg.hide(true)), + mut_arg("all_states", |arg| arg.hide(true)), +)] +pub struct IssueQuery { + /// Search issue titles and descriptions for this text + #[arg(long, value_name = "TEXT", value_parser = NonBlank, conflicts_with = "milestone")] + pub search: Option, + /// Also search comments (with --search) + #[arg(long, requires = "search")] + pub search_comments: bool, + /// Show this team's issues (key, name, or ID); repeatable [default: the configured team] + #[arg(long, value_parser = NonBlank)] + pub team: Vec, + /// Show every team's issues + #[arg(long, conflicts_with = "team")] + pub all_teams: bool, + /// Show issues in this state: a type (triage, backlog, unstarted, started, + /// completed, canceled), name, or ID; repeatable + #[arg(long, short, value_parser = NonBlank)] + pub state: Vec, + #[arg(long, conflicts_with = "state")] + pub all_states: bool, + /// Sort order, except with --search [default: the issue_sort setting, or priority] + #[arg(long, conflicts_with = "search")] + pub sort: Option, + #[command(flatten)] + pub filters: IssueFilters, + /// Include archived issues + #[arg(long)] + pub include_archived: bool, + /// Print JSON + #[arg(long, short)] + pub json: bool, + /// Do not page long output + #[arg(long)] + pub no_pager: bool, +} + +#[derive(Debug, Args)] +pub struct IssueTitle { + /// Issue ID like ENG-123, or a URL; defaults to the current branch's issue + #[arg(value_name = "ISSUE")] + pub issue_id: Option, +} + +#[derive(Debug, Args)] +pub struct IssueStart { + /// Issue ID like ENG-123, or a URL; asked for when omitted + #[arg(value_name = "ISSUE", value_parser = NonBlank)] + pub issue_id: Option, + /// Team to pick from, and the team of a bare issue number (key, name, or + /// ID); defaults to the configured team + #[arg(long, value_parser = NonBlank)] + pub team: Option, + /// Offer issues of every assignee in the picker + #[arg(long, short = 'A')] + pub all_assignees: bool, + /// Offer only unassigned issues in the picker + #[arg(long, short = 'U', conflicts_with = "all_assignees")] + pub unassigned: bool, + /// Git ref to create the branch from + #[arg(long, short, value_name = "REF", value_parser = NonBlank)] + pub from_ref: Option, + /// Branch name to use instead of the issue's + #[arg(long, short, value_parser = NonBlank)] + pub branch: Option, +} + +#[derive(Debug, Args)] +pub struct IssueView { + /// Issue ID like ENG-123, or a URL; defaults to the current branch's issue + #[arg(value_name = "ISSUE")] + pub issue_id: Option, + /// Open the issue in the browser + #[arg(long, short)] + pub web: bool, + /// Open the issue in the Linear app + #[arg(long, short)] + pub app: bool, + /// Leave out comments + #[arg(long)] + pub no_comments: bool, + /// Include resolved comment threads + #[arg(long)] + pub show_resolved_threads: bool, + /// Do not page long output + #[arg(long)] + pub no_pager: bool, + /// Print JSON + #[arg(long, short)] + pub json: bool, + /// Keep remote image and file URLs instead of downloading them + #[arg(long)] + pub no_download: bool, +} + +#[derive(Debug, Args)] +pub struct IssueUrl { + /// Issue ID like ENG-123, or a URL; defaults to the current branch's issue + #[arg(value_name = "ISSUE")] + pub issue_id: Option, +} + +#[derive(Debug, Args)] +pub struct IssueDescribe { + /// Issue ID like ENG-123, or a URL; defaults to the current branch's issue + #[arg(value_name = "ISSUE")] + pub issue_id: Option, + /// Write "References" instead of "Fixes" in the trailer + #[arg(long, short, visible_alias = "ref")] + pub references: bool, +} + +#[derive(Debug, Args)] +pub struct IssueCommits { + /// Issue ID like ENG-123, or a URL; defaults to the current change's issue + #[arg(value_name = "ISSUE")] + pub issue_id: Option, +} + +#[derive(Debug, Args)] +pub struct IssuePullRequest { + /// Issue ID like ENG-123, or a URL; defaults to the current branch's issue + #[arg(value_name = "ISSUE")] + pub issue_id: Option, + /// Branch to merge into + #[arg(long, value_name = "BRANCH")] + pub base: Option, + /// Open the pull request as a draft + #[arg(long)] + pub draft: bool, + /// Pull request title, after the issue ID [default: the issue title] + #[arg(long, short)] + pub title: Option, + /// Open the pull request in the browser + #[arg(long)] + pub web: bool, + /// Branch that holds the commits + #[arg(long, value_name = "BRANCH")] + pub head: Option, + /// Start the body from this template file; the issue URL is appended + #[arg(long, short = 'T', value_name = "FILE", value_hint = ValueHint::FilePath, value_parser = NonBlank)] + pub template: Option, + /// Ignore the pr_template setting + #[arg(long)] + pub no_template: bool, +} + +#[derive(Debug, Args)] +pub struct IssueArchive { + /// Issue ID like ENG-123, or a URL; defaults to the current branch's issue + #[arg(value_name = "ISSUE", conflicts_with_all = ["bulk", "bulk_file", "bulk_stdin"])] + pub issue_id: Option, + #[command(flatten)] + pub confirm: super::ConfirmArgs, + #[command(flatten)] + pub bulk: super::BulkArgs, +} + +#[derive(Debug, Args)] +pub struct IssueDelete { + /// Issue ID like ENG-123, or a URL; defaults to the current branch's issue + #[arg(value_name = "ISSUE", conflicts_with_all = ["bulk", "bulk_file", "bulk_stdin"])] + pub issue_id: Option, + #[command(flatten)] + pub confirm: super::ConfirmArgs, + #[command(flatten)] + pub bulk: super::BulkArgs, +} + +#[derive(Debug, Args)] +pub struct IssueCreate { + /// Issue title + #[arg(long, short, value_parser = NonBlank)] + pub title: Option, + /// Issue description, in Markdown + #[arg(long, short)] + pub description: Option, + /// Read the description from a Markdown file (- for stdin) + #[arg(long, value_name = "FILE", value_hint = ValueHint::FilePath)] + pub description_file: Option, + /// Team (key, name, or ID); defaults to the configured team + #[arg(long, value_parser = NonBlank)] + pub team: Option, + /// Assignee: a username, email, name, or @me + #[arg(long, short, value_name = "USER")] + pub assignee: Option, + /// Workflow state, by name or type + #[arg(long, short, value_parser = NonBlank)] + pub state: Option, + /// Priority, by name or number (0 none, 1 urgent to 4 low) + #[arg(long, short, ignore_case = true)] + pub priority: Option, + /// Estimate, in points + #[arg(long, value_name = "POINTS", value_parser = super::values::estimate, allow_negative_numbers = true)] + pub estimate: Option, + /// Label; repeat for several labels + #[arg(long, short, value_parser = NonBlank)] + pub label: Vec, + /// Due date (YYYY-MM-DD) + #[arg(long, value_name = "DATE", value_parser = super::values::date)] + pub due_date: Option, + /// Parent issue, like ENG-123 + #[arg(long, value_name = "ISSUE", value_parser = NonBlank)] + pub parent: Option, + /// Project (ID, slug, or name) + #[arg(long, value_parser = NonBlank)] + pub project: Option, + /// Project milestone (ID, or name with --project) + #[arg(long, value_parser = NonBlank)] + pub milestone: Option, + /// Cycle: a name, number, `active`, `next`, `previous`, or an offset like + /// +1 or -1 + #[arg(long, allow_negative_numbers = true, value_parser = NonBlank)] + pub cycle: Option, + /// Start from this issue template (name or ID) instead of the team's default + /// + /// The team's templates and workspace templates are searched. The template + /// fills in anything you do not pass: flags override it, --label adds to + /// its labels, and --description replaces its body. With a template, + /// --title is optional. + #[arg(long, value_parser = NonBlank)] + pub template: Option, + /// Do not apply the team's default template + #[arg(long)] + pub no_use_default_template: bool, + /// Start the issue after creating it + #[arg(long)] + pub start: bool, + /// Ask for every field instead of taking them as flags + /// + /// Only --parent and --project can be combined with it. + #[arg(long, short, conflicts_with_all = [ + "title", "description", "description_file", "team", "assignee", "state", "priority", + "estimate", "label", "due_date", "milestone", "cycle", "template", "start", + ])] + pub interactive: bool, + #[command(flatten)] + pub confirm: super::ConfirmArgs, +} + +#[derive(Debug, Args)] +pub struct IssueUpdate { + /// Issue ID like ENG-123, or a URL; defaults to the current branch's issue + #[arg(value_name = "ISSUE")] + pub issue_id: Option, + /// New title + #[arg(long, short, value_parser = NonBlank)] + pub title: Option, + /// New description, in Markdown + #[arg(long, short)] + pub description: Option, + /// Read the new description from a Markdown file (- for stdin) + #[arg(long, value_name = "FILE", value_hint = ValueHint::FilePath)] + pub description_file: Option, + /// Move the issue to this team (key, name, or ID) + #[arg(long, value_parser = NonBlank)] + pub team: Option, + /// Assignee: a username, email, name, or @me + #[arg(long, short, value_name = "USER")] + pub assignee: Option, + /// Remove the assignee + #[arg(long)] + pub unassign: bool, + /// Workflow state, by name or type + #[arg(long, short, value_parser = NonBlank)] + pub state: Option, + /// Priority, by name or number (0 none, 1 urgent to 4 low) + #[arg(long, short, ignore_case = true)] + pub priority: Option, + /// Estimate, in points + #[arg(long, value_name = "POINTS", value_parser = super::values::estimate, allow_negative_numbers = true)] + pub estimate: Option, + /// Remove the estimate + #[arg(long)] + pub clear_estimate: bool, + /// Set the labels, replacing the current ones; repeatable + #[arg(long, short, value_parser = NonBlank)] + pub label: Vec, + /// Add a label, keeping the others; repeatable + #[arg(long, value_name = "LABEL", value_parser = NonBlank)] + pub add_label: Vec, + /// Remove a label, keeping the others; repeatable + #[arg(long, value_name = "LABEL", value_parser = NonBlank)] + pub remove_label: Vec, + /// Due date (YYYY-MM-DD) + #[arg(long, value_name = "DATE", value_parser = super::values::date)] + pub due_date: Option, + /// Remove the due date + #[arg(long)] + pub clear_due_date: bool, + /// Parent issue, like ENG-123 + #[arg(long, value_name = "ISSUE", value_parser = NonBlank)] + pub parent: Option, + /// Remove the parent + #[arg(long)] + pub clear_parent: bool, + /// Project (ID, slug, or name) + #[arg(long, value_parser = NonBlank)] + pub project: Option, + /// Remove the issue from its project + #[arg(long)] + pub clear_project: bool, + /// Project milestone (ID, or name within --project or the issue's project) + #[arg(long, value_parser = NonBlank)] + pub milestone: Option, + /// Remove the issue from its milestone + #[arg(long)] + pub clear_milestone: bool, + /// Cycle: a name, number, `active`, `next`, `previous`, or an offset like + /// +1 or -1 + #[arg(long, allow_negative_numbers = true, value_parser = NonBlank)] + pub cycle: Option, + /// Remove the issue from its cycle + #[arg(long)] + pub clear_cycle: bool, +} + +#[derive(Debug, Args)] +#[command(arg_required_else_help = true)] +pub struct IssueComment { + #[command(subcommand)] + pub command: IssueCommentCommand, +} + +#[derive(Debug, Subcommand)] +pub enum IssueCommentCommand { + /// Comment on an issue, or reply to a comment + /// + /// Images uploaded with --attach render inline. + #[command(after_long_help = LINEAR_MARKDOWN)] + Add(IssueCommentAdd), + /// List an issue's comments + List(IssueCommentList), + /// Edit a comment + #[command(after_long_help = LINEAR_MARKDOWN)] + Update(IssueCommentUpdate), + /// Delete a comment + Delete(IssueCommentDelete), +} + +#[derive(Debug, Args)] +pub struct IssueCommentAdd { + /// Issue ID like ENG-123, or a URL; defaults to the current branch's issue + #[arg(value_name = "ISSUE")] + pub issue_id: Option, + /// Comment text, in Markdown + #[arg(long, short, value_name = "TEXT", value_parser = NonBlank)] + pub body: Option, + /// Read the comment from a Markdown file (- for stdin) + #[arg(long, value_name = "FILE", value_hint = ValueHint::FilePath)] + pub body_file: Option, + /// Reply to this top-level comment (by ID) + #[arg(long, short = 'p', visible_alias = "parent", value_name = "COMMENT", value_parser = NonBlank)] + pub reply_to: Option, + #[command(flatten)] + pub confirm: super::ConfirmArgs, + /// ID for the new comment (a UUID you choose) + #[arg(long, hide = true, value_name = "UUID", value_parser = NonBlank)] + pub id: Option, + /// Upload a file and link it in the comment (images render inline); repeatable + #[arg(long, short, value_name = "FILE", value_hint = ValueHint::FilePath)] + pub attach: Vec, + /// Make uploaded files public instead of visible to workspace members only + #[arg(long)] + pub public: bool, +} + +#[derive(Debug, Args)] +pub struct IssueCommentDelete { + /// Comment ID + #[arg(value_name = "COMMENT")] + pub comment_id: String, + #[command(flatten)] + pub confirm: super::ConfirmArgs, +} + +#[derive(Debug, Args)] +pub struct IssueCommentUpdate { + /// Comment ID + #[arg(value_name = "COMMENT")] + pub comment_id: String, + /// New text, in Markdown + #[arg(long, short, value_name = "TEXT", value_parser = NonBlank)] + pub body: Option, + /// Read the new text from a Markdown file (- for stdin) + #[arg(long, value_name = "FILE", value_hint = ValueHint::FilePath)] + pub body_file: Option, + #[command(flatten)] + pub confirm: super::ConfirmArgs, +} + +#[derive(Debug, Args)] +pub struct IssueCommentList { + /// Issue ID like ENG-123, or a URL; defaults to the current branch's issue + #[arg(value_name = "ISSUE")] + pub issue_id: Option, + /// Maximum number of comments to show (a number or `all`) + #[arg(long, value_parser = super::limit::parse, default_value = "all")] + pub limit: super::Limit, + /// Print JSON + #[arg(long, short)] + pub json: bool, + /// Do not page long output + #[arg(long)] + pub no_pager: bool, +} + +#[derive(Debug, Args)] +pub struct IssueAttach { + /// Issue ID like ENG-123, or a URL + #[arg(value_name = "ISSUE")] + pub issue_id: String, + /// File to upload + #[arg(value_name = "FILE", value_hint = ValueHint::FilePath)] + pub filepath: PathBuf, + /// Attachment title [default: the file name] + #[arg(long, short, value_parser = NonBlank)] + pub title: Option, + /// Also add a comment with this text, linked to the attachment + #[arg(long, short, value_name = "TEXT", value_parser = NonBlank)] + pub comment: Option, + /// Make the upload public instead of visible to workspace members only + #[arg(long)] + pub public: bool, +} + +#[derive(Debug, Args)] +pub struct IssueLink { + /// Issue ID like ENG-123; or, alone, the URL to link to the current branch's issue + #[arg(value_name = "ISSUE|URL")] + pub url_or_issue_id: String, + /// URL to link, when the issue is given first + pub url: Option, + /// Link title + #[arg(long, short, value_parser = NonBlank)] + pub title: Option, +} + +#[derive(Debug, Args)] +#[command(arg_required_else_help = true)] +pub struct IssueRelation { + #[command(subcommand)] + pub command: IssueRelationCommand, +} + +#[derive(Debug, Subcommand)] +pub enum IssueRelationCommand { + /// Relate two issues + Add(IssueRelationAdd), + /// Remove a relation between two issues + Delete(IssueRelationDelete), + /// List an issue's relations + List(IssueRelationList), +} + +/// How one issue relates to another. +#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)] +pub enum RelationType { + Blocks, + BlockedBy, + Related, + Duplicate, +} + +impl RelationType { + pub fn spelling(self) -> &'static str { + match self { + Self::Blocks => "blocks", + Self::BlockedBy => "blocked-by", + Self::Related => "related", + Self::Duplicate => "duplicate", + } + } +} + +#[derive(Debug, Args)] +pub struct IssueRelationAdd { + /// Issue ID like ENG-123, or a URL + #[arg(value_name = "ISSUE")] + pub issue_id: String, + /// How ISSUE relates to RELATED + #[arg(value_name = "RELATION", ignore_case = true)] + pub relation_type: RelationType, + /// The other issue + #[arg(value_name = "RELATED")] + pub related_issue_id: String, +} + +#[derive(Debug, Args)] +pub struct IssueRelationDelete { + /// Issue ID like ENG-123, or a URL + #[arg(value_name = "ISSUE")] + pub issue_id: String, + /// How ISSUE relates to RELATED + #[arg(value_name = "RELATION", ignore_case = true)] + pub relation_type: RelationType, + /// The other issue + #[arg(value_name = "RELATED")] + pub related_issue_id: String, + #[command(flatten)] + pub confirm: super::ConfirmArgs, +} + +#[derive(Debug, Args)] +pub struct IssueRelationList { + /// Issue ID like ENG-123, or a URL; defaults to the current branch's issue + #[arg(value_name = "ISSUE")] + pub issue_id: Option, +} + +#[derive(Debug, Args)] +#[command(arg_required_else_help = true)] +pub struct IssueAgentSession { + #[command(subcommand)] + pub command: IssueAgentSessionCommand, +} + +#[derive(Debug, Subcommand)] +pub enum IssueAgentSessionCommand { + /// List an issue's agent sessions + List(IssueAgentSessionList), + /// Show an agent session and its activity + #[command(alias = "v")] + View(IssueAgentSessionView), +} + +#[derive(Debug, Args)] +pub struct IssueAgentSessionList { + /// Issue ID like ENG-123, or a URL; defaults to the current branch's issue + #[arg(value_name = "ISSUE")] + pub issue_id: Option, + /// Maximum number of sessions to show (a number or `all`) + #[arg(long, value_parser = super::limit::parse, default_value = "all")] + pub limit: super::Limit, + /// Print JSON + #[arg(long, short)] + pub json: bool, + /// Show only sessions with this status + #[arg(long)] + pub status: Option, +} + +#[derive(Debug, Args)] +pub struct IssueAgentSessionView { + /// Agent session ID + #[arg(value_name = "SESSION")] + pub session_id: String, + /// Print JSON + #[arg(long, short)] + pub json: bool, + /// Do not page long output + #[arg(long)] + pub no_pager: bool, +} diff --git a/crates/linear-cli/src/cli/label.rs b/crates/linear-cli/src/cli/label.rs new file mode 100644 index 000000000..36b32bd4a --- /dev/null +++ b/crates/linear-cli/src/cli/label.rs @@ -0,0 +1,72 @@ +use clap::{Args, Subcommand}; + +use super::values::{HexColor, NonBlank}; + +#[derive(Debug, Args)] +#[command(arg_required_else_help = true)] +pub struct Label { + #[command(subcommand)] + pub command: LabelCommand, +} + +#[derive(Debug, Subcommand)] +pub enum LabelCommand { + /// List issue labels + List(LabelList), + /// Create an issue label + Create(LabelCreate), + /// Delete an issue label + Delete(LabelDelete), +} + +#[derive(Debug, Args)] +pub struct LabelList { + /// Show this team's labels (key, name, or ID) plus workspace labels + #[arg(long, value_parser = NonBlank)] + pub team: Option, + /// Show only workspace labels + #[arg(long, conflicts_with_all = ["team", "all_teams"])] + pub workspace_only: bool, + /// Show workspace labels and every team's labels + #[arg(long, alias = "all", conflicts_with = "team")] + pub all_teams: bool, + /// Maximum number of labels to show (a number or `all`) + #[arg(long, value_parser = super::limit::parse, default_value = "all")] + pub limit: super::Limit, + /// Print JSON + #[arg(long, short)] + pub json: bool, +} + +#[derive(Debug, Args)] +pub struct LabelCreate { + /// Label name + #[arg(long, short, value_parser = NonBlank)] + pub name: Option, + /// Color, like #EB5757 + #[arg(long, short)] + pub color: Option, + /// Label description + #[arg(long, short, value_parser = NonBlank)] + pub description: Option, + /// Team (key, name, or ID) for a team label; omit for a workspace label + #[arg(long, short, value_parser = NonBlank)] + pub team: Option, + /// Also prompt for the optional fields + #[arg(long, short)] + pub interactive: bool, + #[command(flatten)] + pub confirm: super::ConfirmArgs, +} + +#[derive(Debug, Args)] +pub struct LabelDelete { + /// Label name or ID + #[arg(value_name = "LABEL")] + pub name_or_id: String, + /// Team (key, name, or ID) whose label to delete, when names repeat + #[arg(long, short, value_parser = NonBlank)] + pub team: Option, + #[command(flatten)] + pub confirm: super::ConfirmArgs, +} diff --git a/crates/linear-cli/src/cli/limit.rs b/crates/linear-cli/src/cli/limit.rs new file mode 100644 index 000000000..ecaaf4a71 --- /dev/null +++ b/crates/linear-cli/src/cli/limit.rs @@ -0,0 +1,78 @@ +//! `--limit`, shared by every list command: a positive number of rows, or +//! `all`. +use std::fmt; +use std::num::NonZeroU32; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Limit { + All, + At(NonZeroU32), +} + +impl Limit { + /// The most rows to show; `None` for no limit. + pub fn max(self) -> Option { + match self { + Self::All => None, + Self::At(count) => Some(count), + } + } + + /// Keeps the first rows of an already fetched, sorted list. + pub fn apply(self, rows: &mut Vec) { + if let Self::At(count) = self { + rows.truncate(usize::try_from(count.get()).unwrap_or(usize::MAX)); + } + } +} + +impl fmt::Display for Limit { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::All => f.write_str("all"), + Self::At(count) => write!(f, "{count}"), + } + } +} + +pub(crate) fn parse(value: &str) -> Result { + if value == "all" { + return Ok(Limit::All); + } + if !value.is_empty() + && value.bytes().all(|byte| byte.is_ascii_digit()) + && let Ok(count) = value.parse::() + { + return Ok(Limit::At(count)); + } + Err(format!( + "expected a positive number or `all`, got {value:?}" + )) +} + +#[cfg(test)] +mod tests { + use super::{Limit, parse}; + use std::num::NonZeroU32; + + #[test] + fn accepts_positive_numbers_and_all() { + assert_eq!(parse("all"), Ok(Limit::All)); + assert_eq!( + parse("25"), + Ok(Limit::At(NonZeroU32::new(25).expect("nonzero"))) + ); + for bad in ["0", "-1", "+5", "1.5", "", "ALL", "99999999999"] { + assert!(parse(bad).is_err(), "{bad}"); + } + } + + #[test] + fn apply_keeps_the_first_rows() { + let mut rows = vec![1, 2, 3]; + Limit::At(NonZeroU32::new(2).expect("nonzero")).apply(&mut rows); + assert_eq!(rows, [1, 2]); + Limit::All.apply(&mut rows); + assert_eq!(rows, [1, 2]); + } +} diff --git a/crates/linear-cli/src/cli/markdown.rs b/crates/linear-cli/src/cli/markdown.rs new file mode 100644 index 000000000..c692997ab --- /dev/null +++ b/crates/linear-cli/src/cli/markdown.rs @@ -0,0 +1,4 @@ +use clap::Args; + +#[derive(Debug, Args)] +pub struct Markdown {} diff --git a/crates/linear-cli/src/cli/markdown.txt b/crates/linear-cli/src/cli/markdown.txt new file mode 100644 index 000000000..84f4b07ab --- /dev/null +++ b/crates/linear-cli/src/cli/markdown.txt @@ -0,0 +1,40 @@ +Linear-flavored Markdown: mentions and collapsible sections + +These rules apply to comment bodies, issue descriptions, document content, +project overviews, and status update bodies. + +MENTIONS + +A resource's plain Linear URL becomes a linked mention. A literal `@name`, an +`@[Name](id)`, or a Markdown link such as `[Name](url)` does not — it stays +plain text and notifies nobody. Put the bare URL in the body: + +https://linear.app/acme/profiles/someuser can you take a look? + +RESOLVING PEOPLE + +Look the person up in the relevant team first. The team can usually be +inferred from the issue identifier or the current directory: + +linear team members ENG --json + +Paste the selected member's `url` field verbatim. If the intended person is +not a member of that team, stop and confirm before searching the whole +workspace with `linear user list --json`; mentioning someone outside the team +is likely accidental. + +To mention an issue, use its URL the same way: + +linear issue url ENG-123 + +COLLAPSIBLE SECTIONS + +Open a section with `+++ [title]` and close it with `+++`: + ++++ [Server log] + +Markdown content that is initially hidden. + ++++ + +The square brackets around the title and the closing `+++` are both required. diff --git a/crates/linear-cli/src/cli/milestone.rs b/crates/linear-cli/src/cli/milestone.rs new file mode 100644 index 000000000..5c5f917c8 --- /dev/null +++ b/crates/linear-cli/src/cli/milestone.rs @@ -0,0 +1,116 @@ +use chrono::NaiveDate; +use clap::{ArgGroup, Args, Subcommand}; + +use super::values::NonBlank; + +use crate::graphql::scalars::Float; + +#[derive(Debug, Args)] +#[command(arg_required_else_help = true)] +pub struct Milestone { + #[command(subcommand)] + pub command: MilestoneCommand, +} + +#[derive(Debug, Subcommand)] +pub enum MilestoneCommand { + /// List a project's milestones + List(MilestoneList), + /// Show a milestone and its issues + #[command(alias = "v")] + View(MilestoneView), + /// Create a project milestone + Create(MilestoneCreate), + /// Update a project milestone + Update(MilestoneUpdate), + /// Delete a project milestone + Delete(MilestoneDelete), +} + +#[derive(Debug, Args)] +pub struct MilestoneList { + /// Project ID, slug, or name + #[arg(long, value_parser = NonBlank)] + pub project: String, + /// Maximum number of milestones to show (a number or `all`) + #[arg(long, value_parser = super::limit::parse, default_value = "all")] + pub limit: super::Limit, + /// Print JSON + #[arg(long, short)] + pub json: bool, +} + +#[derive(Debug, Args)] +pub struct MilestoneView { + /// Milestone ID, or its name with --project + pub milestone: String, + /// List every issue instead of the first 10 + #[arg(long)] + pub all: bool, + /// Project (ID, slug, or name) to find the milestone name in + #[arg(long, value_parser = NonBlank)] + pub project: Option, + /// Print JSON + #[arg(long, short)] + pub json: bool, + /// Do not page long output + #[arg(long)] + pub no_pager: bool, +} + +#[derive(Debug, Args)] +pub struct MilestoneCreate { + /// Project ID, slug, or name; picked from a list on a terminal when not given + #[arg(long, value_parser = NonBlank)] + pub project: Option, + /// Milestone name; asked for on a terminal when not given + #[arg(long, value_parser = NonBlank)] + pub name: Option, + /// Milestone description + #[arg(long, value_parser = NonBlank)] + pub description: Option, + /// Target date (YYYY-MM-DD) + #[arg(long, value_name = "DATE", value_parser = super::values::date)] + pub target_date: Option, + /// Also prompt for the optional fields + #[arg(long, short)] + pub interactive: bool, + #[command(flatten)] + pub confirm: super::ConfirmArgs, +} + +#[derive(Debug, Args)] +#[command(override_usage = "linear milestone update [OPTIONS] ")] +#[command(group( + ArgGroup::new("changes") + .required(true) + .multiple(true) + .args(["name", "description", "target_date", "sort_order", "project"]) +))] +pub struct MilestoneUpdate { + /// Milestone ID + pub id: String, + /// New name + #[arg(long, value_parser = NonBlank)] + pub name: Option, + /// New description + #[arg(long, value_parser = NonBlank)] + pub description: Option, + /// New target date (YYYY-MM-DD) + #[arg(long, value_name = "DATE", value_parser = super::values::date)] + pub target_date: Option, + /// Position among the project's milestones + #[arg(long, value_name = "NUMBER", value_parser = super::values::sort_order, allow_negative_numbers = true)] + pub sort_order: Option, + /// Move the milestone to this project (ID, slug, or name) + #[arg(long, value_parser = NonBlank)] + pub project: Option, +} + +#[derive(Debug, Args)] +pub struct MilestoneDelete { + /// Milestone ID + pub id: String, + #[command(flatten)] + pub confirm: super::ConfirmArgs, +} diff --git a/crates/linear-cli/src/cli/mod.rs b/crates/linear-cli/src/cli/mod.rs new file mode 100644 index 000000000..59b3e4c79 --- /dev/null +++ b/crates/linear-cli/src/cli/mod.rs @@ -0,0 +1,317 @@ +//! The command-line grammar. Each command owns its typed arguments; values are +//! parsed here, at the boundary, so commands receive checked types. +use std::path::PathBuf; + +use clap::{Args, Command, CommandFactory, Parser, Subcommand, ValueEnum, ValueHint}; + +use values::NonBlank; + +pub(crate) use limit::Limit; +pub(crate) mod api; +pub(crate) mod auth; +pub(crate) mod completions; +pub(crate) mod config; +pub(crate) mod cycle; +pub(crate) mod document; +pub(crate) mod initiative; +pub(crate) mod initiative_update; +pub(crate) mod issue; +pub(crate) mod label; +mod limit; +pub(crate) mod markdown; +pub(crate) mod milestone; +pub(crate) mod project; +pub(crate) mod project_update; +pub(crate) mod schema; +pub(crate) mod team; +pub(crate) mod template; +pub(crate) mod user; +pub(crate) mod values; + +#[cfg(test)] +mod tests; + +/// Shown under `--help` for commands that take Markdown text. +const LINEAR_MARKDOWN: &str = "\ +Linear Markdown: a plain Linear URL creates a mention; `@name`, `@[Name](id)`, +and `[Name](url)` do not. Get a person's URL from the `url` field of +`linear team members --json`, or an issue's from `linear issue url `. +Run `linear markdown` for collapsible sections and the full reference."; + +const ENVIRONMENT: &str = "\ +Environment: + LINEAR_API_KEY API key to use instead of a stored credential + LINEAR_TEAM_ID Default team key + LINEAR_DEBUG=1 Show the details and causes of errors + LINEAR_IGNORE_ENV_FILE=1 Do not load .env files + +Every .linear.toml setting can also be set with a LINEAR_* variable; run +`linear config` to write one for the current repository."; + +/// Work with Linear from the command line +#[derive(Debug, Parser)] +#[command( + name = "linear", + version, + arg_required_else_help = true, + max_term_width = 100, + after_long_help = ENVIRONMENT, +)] +pub struct Cli { + #[command(flatten)] + pub global: GlobalArgs, + #[command(subcommand)] + pub command: RootCommand, +} + +/// Options every command accepts. +#[derive(Debug, Args)] +#[command(next_help_heading = "Global options")] +pub struct GlobalArgs { + /// Workspace to use, by the name its credential is stored under + #[arg( + long, + global = true, + value_name = "SLUG", + value_parser = NonBlank, + display_order = 1000 + )] + pub workspace: Option, + /// Never prompt; fail instead when a required value is missing + #[arg(long, global = true, alias = "no-interactive", display_order = 1001)] + pub no_input: bool, +} + +#[derive(Debug, Subcommand)] +pub enum RootCommand { + /// Manage issues + #[command(alias = "i")] + Issue(issue::Issue), + /// Manage projects + #[command(alias = "p")] + Project(project::Project), + /// Post and list project status updates + #[command(alias = "pu")] + ProjectUpdate(project_update::ProjectUpdate), + /// Manage project milestones + #[command(alias = "m")] + Milestone(milestone::Milestone), + /// View team cycles + #[command(alias = "cy")] + Cycle(cycle::Cycle), + /// Manage initiatives + #[command(alias = "init")] + Initiative(initiative::Initiative), + /// Post and list initiative status updates + #[command(alias = "iu")] + InitiativeUpdate(initiative_update::InitiativeUpdate), + /// Manage documents + #[command(aliases = ["docs", "doc"])] + Document(document::Document), + /// Manage issue labels + #[command(alias = "l")] + Label(label::Label), + /// Browse issue, project and document templates + /// + /// Apply one with `linear issue create --template` or + /// `linear project create --template`. + Template(template::Template), + /// Manage teams + #[command(alias = "t")] + Team(team::Team), + /// List workspace members + #[command(alias = "u")] + User(user::User), + /// Log in to workspaces and manage their credentials + Auth(auth::Auth), + /// Write a .linear.toml for the current repository + /// + /// Asks for the settings the flags leave out. + #[command(alias = "configure")] + Config(config::Config), + /// Send a raw GraphQL request to the Linear API + /// + /// Pass the GraphQL document as one quoted argument or on stdin. A leading + /// `query` or `mutation` keyword belongs inside that document. + Api(api::Api), + /// Print the Linear GraphQL schema + Schema(schema::Schema), + /// Print shell completions + /// + /// Load them from your shell's startup file: + /// + /// bash (~/.bashrc): source <(linear completions bash) + /// zsh (~/.zshrc): source <(linear completions zsh) + /// fish (~/.config/fish/config.fish): linear completions fish | source + /// elvish (rc.elv): eval (linear completions elvish | slurp) + /// powershell ($PROFILE): linear completions powershell | Out-String | Invoke-Expression + /// + /// The script asks linear for candidates as you type, so load it at shell + /// startup rather than saving it to a file. + #[command(verbatim_doc_comment)] + Completions(completions::Completions), + /// Explain Linear-flavored Markdown: mentions and collapsible sections + #[command(long_about = include_str!("markdown.txt").trim_end())] + Markdown(markdown::Markdown), +} + +/// Skips the confirmation prompt of a destructive command. +#[derive(Debug, Args)] +pub struct ConfirmArgs { + /// Do not ask for confirmation + #[arg(long, short = 'y', aliases = ["force", "confirm"], short_alias = 'f')] + pub yes: bool, +} + +/// [`ConfirmArgs`] without the `-f`/`--force` spellings, for commands where +/// those mean something else. +#[derive(Debug, Args)] +pub struct YesArgs { + /// Do not ask for confirmation + #[arg(long, short = 'y', alias = "confirm")] + pub yes: bool, +} + +/// Several targets at once instead of the positional argument. +#[derive(Debug, Args)] +pub struct BulkArgs { + /// Act on several at once instead of one + #[arg(long, value_name = "IDS", num_args = 0.., value_parser = NonBlank)] + pub bulk: Option>, + /// Read the IDs from a file, one per line + #[arg(long, value_name = "FILE", value_hint = ValueHint::FilePath)] + pub bulk_file: Option, + /// Read the IDs from stdin, one per line + #[arg(long)] + pub bulk_stdin: bool, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)] +pub enum AgentSessionStatus { + Pending, + Active, + Complete, + #[value(alias = "awaitingInput")] + AwaitingInput, + Error, + Stale, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)] +pub enum TemplateType { + Issue, + Project, + Document, +} + +pub fn command() -> Command { + Cli::command() +} + +/// Parses the process arguments, exiting with a usage error (or help) when +/// they do not parse. +pub fn parse() -> Cli { + let args: Vec = std::env::args_os().collect(); + Cli::try_parse_from(&args).unwrap_or_else(|error| { + let command = command(); + let error = suggest_names(error, &command, &args); + suggest_flag_names(error, &command, &args).exit() + }) +} + +/// The commands `args` names, from the root down to the deepest one found. +fn command_path<'a>(root: &'a Command, args: &[std::ffi::OsString]) -> Vec<&'a Command> { + let mut path = vec![root]; + for arg in args.iter().skip(1).filter_map(|arg| arg.to_str()) { + let current = *path.last().expect("the path starts at the root"); + if let Some(subcommand) = current.find_subcommand(arg) { + path.push(subcommand); + } + } + path +} + +/// An unknown flag's "similar argument" tip names the flag itself, not a +/// hidden alias of it (`--confirm` is kept for old scripts; `--yes` is the +/// flag the usage line and help show). +fn suggest_flag_names( + mut error: clap::Error, + root: &Command, + args: &[std::ffi::OsString], +) -> clap::Error { + use clap::error::{ContextKind, ContextValue}; + + let Some(ContextValue::String(suggested)) = error.get(ContextKind::SuggestedArg) else { + return error; + }; + let Some(alias) = suggested.strip_prefix("--") else { + return error; + }; + let canonical = command_path(root, args) + .into_iter() + .flat_map(Command::get_arguments) + .find(|arg| { + arg.get_all_aliases() + .is_some_and(|aliases| aliases.contains(&alias)) + }) + .and_then(clap::Arg::get_long); + if let Some(long) = canonical { + error.insert( + ContextKind::SuggestedArg, + ContextValue::String(format!("--{long}")), + ); + } + error +} + +/// An unknown subcommand's "similar subcommand" tip names only real +/// subcommands: clap also matches the short aliases, which make poor +/// suggestions (`lst` is not a typo of `l`). +fn suggest_names( + mut error: clap::Error, + root: &Command, + args: &[std::ffi::OsString], +) -> clap::Error { + use clap::error::{ContextKind, ContextValue}; + + let Some(ContextValue::Strings(suggested)) = error.get(ContextKind::SuggestedSubcommand) else { + return error; + }; + let parent = *command_path(root, args) + .last() + .expect("the path starts at the root"); + let names: Vec = suggested + .iter() + .filter(|name| { + parent + .get_subcommands() + .any(|subcommand| subcommand.get_name() == name.as_str()) + }) + .cloned() + .collect(); + if names.is_empty() { + error.remove(ContextKind::SuggestedSubcommand); + } else { + error.insert( + ContextKind::SuggestedSubcommand, + ContextValue::Strings(names), + ); + } + error +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct VariableAssignment { + pub key: String, + pub value: String, +} + +pub(crate) fn variable_assignment(value: &str) -> Result { + let (key, value) = value + .split_once('=') + .ok_or_else(|| format!("expected key=value, got {value:?}"))?; + Ok(VariableAssignment { + key: key.to_owned(), + value: value.to_owned(), + }) +} diff --git a/crates/linear-cli/src/cli/project.rs b/crates/linear-cli/src/cli/project.rs new file mode 100644 index 000000000..956830b26 --- /dev/null +++ b/crates/linear-cli/src/cli/project.rs @@ -0,0 +1,276 @@ +use chrono::NaiveDate; +use clap::{Args, Subcommand, ValueEnum, ValueHint}; + +use super::LINEAR_MARKDOWN; +use super::values::{HexColor, NonBlank, TextSource, UserRef}; +use crate::graphql::operations::project::ProjectStatusType; + +#[derive(Debug, Args)] +#[command(arg_required_else_help = true)] +pub struct Project { + #[command(subcommand)] + pub command: ProjectCommand, +} + +#[derive(Debug, Subcommand)] +pub enum ProjectCommand { + /// List projects + List(ProjectList), + /// Show a project + #[command(alias = "v")] + View(ProjectView), + /// Create a project + #[command(after_long_help = LINEAR_MARKDOWN)] + Create(ProjectCreate), + /// Update a project + #[command(after_long_help = LINEAR_MARKDOWN)] + Update(ProjectUpdate), + /// Delete a project (moves it to the trash) + Delete(ProjectDelete), + /// Add and list comments on a project + Comment(ProjectComment), +} + +#[derive(Debug, Args)] +pub struct ProjectList { + /// Show this team's projects (key, name, or ID); defaults to the configured team + #[arg(long, value_parser = NonBlank)] + pub team: Option, + /// Show every team's projects + #[arg(long, conflicts_with = "team")] + pub all_teams: bool, + /// Show only projects with this status + #[arg(long, ignore_case = true)] + pub status: Option, + /// Open the projects page in the browser + #[arg(long, short)] + pub web: bool, + /// Open the projects page in the Linear app + #[arg(long, short)] + pub app: bool, + /// Maximum number of projects to show (a number or `all`) + #[arg(long, value_parser = super::limit::parse, default_value = "all")] + pub limit: super::Limit, + /// Print JSON + #[arg(long, short)] + pub json: bool, +} + +#[derive(Debug, Args)] +pub struct ProjectView { + /// Project ID, slug, or name; asked for when omitted + #[arg(value_name = "PROJECT")] + pub project_id: Option, + /// Open the project in the browser + #[arg(long, short)] + pub web: bool, + /// Open the project in the Linear app + #[arg(long, short)] + pub app: bool, + /// Print JSON + #[arg(long, short)] + pub json: bool, + /// Do not page long output + #[arg(long)] + pub no_pager: bool, +} + +/// The fields `project create` and `project update` share. +#[derive(Debug, Args)] +pub struct ProjectFields { + /// Project name + #[arg(long, short, value_parser = NonBlank)] + pub name: Option, + /// Short summary, up to 255 characters + #[arg(long, short, conflicts_with = "description_file")] + pub description: Option, + /// Read the summary from a file (- for stdin) + #[arg(long, short = 'f', value_name = "FILE", value_hint = ValueHint::FilePath)] + pub description_file: Option, + /// Project overview, in Markdown + #[arg(long, value_name = "MARKDOWN", conflicts_with = "content_file")] + pub content: Option, + /// Read the overview from a Markdown file (- for stdin) + #[arg(long, value_name = "FILE", value_hint = ValueHint::FilePath)] + pub content_file: Option, + /// Project status + #[arg(long, short, ignore_case = true)] + pub status: Option, + /// Project lead: a username, email, name, or @me + #[arg(long, short, value_name = "USER")] + pub lead: Option, + /// Start date (YYYY-MM-DD) + #[arg(long, value_name = "DATE", value_parser = super::values::date)] + pub start_date: Option, + /// Target date (YYYY-MM-DD) + #[arg(long, value_name = "DATE", value_parser = super::values::date)] + pub target_date: Option, + /// Project priority, by name or number (0 none, 1 urgent to 4 low) + #[arg(long, ignore_case = true)] + pub priority: Option, +} + +/// A project status, by its kind. +#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)] +pub enum Status { + Backlog, + Planned, + #[value(aliases = ["in-progress", "in progress"])] + Started, + Paused, + Completed, + Canceled, +} + +impl From for ProjectStatusType { + fn from(status: Status) -> Self { + match status { + Status::Backlog => Self::Backlog, + Status::Planned => Self::Planned, + Status::Started => Self::Started, + Status::Paused => Self::Paused, + Status::Completed => Self::Completed, + Status::Canceled => Self::Canceled, + } + } +} + +#[derive(Debug, Args)] +pub struct ProjectCreate { + #[command(flatten)] + pub fields: ProjectFields, + /// Team (key, name, or ID); repeat for several teams + #[arg(long, short, value_parser = NonBlank)] + pub team: Vec, + /// Project label; repeat for several labels + #[arg(long, value_parser = NonBlank)] + pub label: Vec, + /// Project member: a username, email, name, or @me; repeatable + #[arg(long, value_name = "USER")] + pub member: Vec, + /// Project icon + #[arg(long)] + pub icon: Option, + /// Color, like #5E6AD2 + #[arg(long)] + pub color: Option, + /// Add the project to this initiative (ID, slug, or name) + #[arg(long, value_parser = NonBlank)] + pub initiative: Option, + /// Start from this project template (name or ID) + /// + /// Workspace templates and those of the project's teams are searched. The + /// template fills in anything you do not pass; flags override it. + #[arg(long, value_parser = NonBlank)] + pub template: Option, + /// Also prompt for the optional fields + #[arg(long, short)] + pub interactive: bool, + /// Print the created project as JSON + #[arg(long, short)] + pub json: bool, + #[command(flatten)] + pub confirm: super::YesArgs, +} + +#[derive(Debug, Args)] +pub struct ProjectUpdate { + /// Project ID, slug, or name + #[arg(value_name = "PROJECT")] + pub project_id: String, + #[command(flatten)] + pub fields: ProjectFields, + /// Remove the project's lead + #[arg(long, conflicts_with = "lead")] + pub clear_lead: bool, + /// Remove the project's start date + #[arg(long, conflicts_with = "start_date")] + pub clear_start_date: bool, + /// Remove the project's target date + #[arg(long, conflicts_with = "target_date")] + pub clear_target_date: bool, + /// Set the project's teams (key, name, or ID), replacing the current ones; repeatable + #[arg(long, short, value_parser = NonBlank, conflicts_with_all = ["add_team", "remove_team"])] + pub team: Vec, + /// Add a team to the project; repeatable + #[arg(long, value_name = "TEAM", value_parser = NonBlank)] + pub add_team: Vec, + /// Remove a team from the project; repeatable + #[arg(long, value_name = "TEAM", value_parser = NonBlank)] + pub remove_team: Vec, + /// Set the project's labels, replacing the current ones; repeatable + #[arg(long, value_parser = NonBlank, conflicts_with_all = ["add_label", "remove_label"])] + pub label: Vec, + /// Add a label to the project; repeatable + #[arg(long, value_name = "LABEL", value_parser = NonBlank)] + pub add_label: Vec, + /// Remove a label from the project (the label itself stays); repeatable + #[arg(long, value_name = "LABEL", value_parser = NonBlank)] + pub remove_label: Vec, + /// Set the project's initiatives (ID, slug, or name), replacing the current ones; repeatable + #[arg(long, value_parser = NonBlank, conflicts_with_all = ["add_initiative", "remove_initiative"])] + pub initiative: Vec, + /// Add the project to an initiative; repeatable + #[arg(long, value_name = "INITIATIVE", value_parser = NonBlank)] + pub add_initiative: Vec, + /// Remove the project from an initiative (the initiative itself stays); repeatable + #[arg(long, value_name = "INITIATIVE", value_parser = NonBlank)] + pub remove_initiative: Vec, +} + +#[derive(Debug, Args)] +pub struct ProjectDelete { + /// Project ID, slug, or name + #[arg(value_name = "PROJECT")] + pub project_id: String, + #[command(flatten)] + pub confirm: super::ConfirmArgs, +} + +#[derive(Debug, Args)] +#[command(arg_required_else_help = true)] +pub struct ProjectComment { + #[command(subcommand)] + pub command: ProjectCommentCommand, +} + +#[derive(Debug, Subcommand)] +pub enum ProjectCommentCommand { + /// Comment on a project, or reply to a comment + #[command(after_long_help = LINEAR_MARKDOWN)] + Add(ProjectCommentAdd), + /// List a project's comments + List(ProjectCommentList), +} + +#[derive(Debug, Args)] +pub struct ProjectCommentAdd { + /// Project ID, slug, or name + pub project: String, + /// Comment text, in Markdown + #[arg(long, short, value_name = "TEXT", value_parser = NonBlank)] + pub body: Option, + /// Read the comment from a Markdown file (- for stdin) + #[arg(long, value_name = "FILE", value_hint = ValueHint::FilePath)] + pub body_file: Option, + /// Reply to this top-level comment (by ID) + #[arg(long, short = 'p', visible_alias = "parent", value_name = "COMMENT", value_parser = NonBlank)] + pub reply_to: Option, + #[command(flatten)] + pub confirm: super::ConfirmArgs, +} + +#[derive(Debug, Args)] +pub struct ProjectCommentList { + /// Project ID, slug, or name + pub project: String, + /// Maximum number of comments to show (a number or `all`) + #[arg(long, value_parser = super::limit::parse, default_value = "all")] + pub limit: super::Limit, + /// Print JSON + #[arg(long, short)] + pub json: bool, + /// Do not page long output + #[arg(long)] + pub no_pager: bool, +} diff --git a/crates/linear-cli/src/cli/project_update.rs b/crates/linear-cli/src/cli/project_update.rs new file mode 100644 index 000000000..fe550e4d8 --- /dev/null +++ b/crates/linear-cli/src/cli/project_update.rs @@ -0,0 +1,72 @@ +use clap::{Args, Subcommand, ValueEnum, ValueHint}; + +use super::LINEAR_MARKDOWN; +use super::values::{NonBlank, TextSource}; + +#[derive(Debug, Args)] +#[command(arg_required_else_help = true)] +pub struct ProjectUpdate { + #[command(subcommand)] + pub command: ProjectUpdateCommand, +} + +#[derive(Debug, Subcommand)] +pub enum ProjectUpdateCommand { + /// Post a status update on a project + #[command(alias = "c", after_long_help = LINEAR_MARKDOWN)] + Create(ProjectUpdateCreate), + /// List a project's status updates + #[command(alias = "l")] + List(ProjectUpdateList), +} + +#[derive(Debug, Args)] +pub struct ProjectUpdateCreate { + /// Project ID, slug, or name + #[arg(value_name = "PROJECT")] + pub project_id: String, + #[command(flatten)] + pub update: StatusUpdateArgs, +} + +/// The content of a new project or initiative status update. +#[derive(Debug, Args)] +pub struct StatusUpdateArgs { + /// Update text, in Markdown + #[arg(long, value_name = "TEXT", value_parser = NonBlank, conflicts_with = "body_file")] + pub body: Option, + /// Read the update from a Markdown file (- for stdin) + #[arg(long, value_name = "FILE", value_hint = ValueHint::FilePath)] + pub body_file: Option, + /// How the work is going + #[arg(long)] + pub health: Option, + /// Also prompt for the optional fields + #[arg(long, short)] + pub interactive: bool, + #[command(flatten)] + pub confirm: super::ConfirmArgs, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)] +pub enum Health { + #[value(alias = "onTrack")] + OnTrack, + #[value(alias = "atRisk")] + AtRisk, + #[value(alias = "offTrack")] + OffTrack, +} + +#[derive(Debug, Args)] +pub struct ProjectUpdateList { + /// Project ID, slug, or name + #[arg(value_name = "PROJECT")] + pub project_id: String, + /// Print JSON + #[arg(long, short)] + pub json: bool, + /// Maximum number of updates to show, newest first (a number or `all`) + #[arg(long, value_parser = super::limit::parse, default_value = "10")] + pub limit: super::Limit, +} diff --git a/crates/linear-cli/src/cli/schema.rs b/crates/linear-cli/src/cli/schema.rs new file mode 100644 index 000000000..9d285d4a6 --- /dev/null +++ b/crates/linear-cli/src/cli/schema.rs @@ -0,0 +1,13 @@ +use std::path::PathBuf; + +use clap::{Args, ValueHint}; + +#[derive(Debug, Args)] +pub struct Schema { + /// Print the introspection result as JSON instead of SDL + #[arg(long, short)] + pub json: bool, + /// Write the schema to this file instead of stdout + #[arg(long, short, value_name = "FILE", value_hint = ValueHint::FilePath)] + pub output: Option, +} diff --git a/crates/linear-cli/src/cli/team.rs b/crates/linear-cli/src/cli/team.rs new file mode 100644 index 000000000..2f7d2dbc3 --- /dev/null +++ b/crates/linear-cli/src/cli/team.rs @@ -0,0 +1,115 @@ +use clap::{Args, Subcommand}; + +use super::values::NonBlank; + +#[derive(Debug, Args)] +#[command(arg_required_else_help = true)] +pub struct Team { + #[command(subcommand)] + pub command: TeamCommand, +} + +#[derive(Debug, Subcommand)] +pub enum TeamCommand { + /// List teams + List(TeamList), + /// Create a team + Create(TeamCreate), + /// Delete a team + Delete(TeamDelete), + /// List a team's members + Members(TeamMembers), + /// List a team's workflow states + States(TeamStates), + /// Print the configured team key + Id(TeamId), + /// Link the configured team's issue IDs in the current GitHub repository + /// + /// Adds a GitHub autolink so that references like ENG-123 in commits, + /// issues and pull requests link to Linear. Needs the `gh` CLI. + Autolinks(TeamAutolinks), +} + +#[derive(Debug, Args)] +pub struct TeamCreate { + /// Team name + #[arg(long, short, value_parser = NonBlank)] + pub name: Option, + /// Team description + #[arg(long, short, value_parser = NonBlank)] + pub description: Option, + /// Team key, like ENG; derived from the name when omitted + #[arg(long, short, value_parser = NonBlank)] + pub key: Option, + /// Make the team private + #[arg(long)] + pub private: bool, + /// Also prompt for the optional fields + #[arg(long, short)] + pub interactive: bool, + #[command(flatten)] + pub confirm: super::ConfirmArgs, +} + +#[derive(Debug, Args)] +pub struct TeamDelete { + /// Team key, name, or ID + #[arg(value_parser = NonBlank)] + pub team: String, + /// Move the team's issues to this team (key, name, or ID) first + #[arg(long, value_name = "TEAM", value_parser = NonBlank)] + pub move_issues: Option, + #[command(flatten)] + pub confirm: super::ConfirmArgs, +} + +#[derive(Debug, Args)] +pub struct TeamList { + /// Open the teams page in the browser + #[arg(long, short, conflicts_with_all = ["app", "json"])] + pub web: bool, + /// Open the teams page in the Linear app + #[arg(long, short, conflicts_with = "json")] + pub app: bool, + /// Maximum number of teams to show (a number or `all`) + #[arg(long, value_parser = super::limit::parse, default_value = "all")] + pub limit: super::Limit, + /// Print JSON + #[arg(long, short)] + pub json: bool, +} + +#[derive(Debug, Args)] +pub struct TeamId {} + +#[derive(Debug, Args)] +pub struct TeamAutolinks {} + +#[derive(Debug, Args)] +pub struct TeamMembers { + /// Team key, name, or ID; defaults to the configured team + #[arg(value_parser = NonBlank)] + pub team: Option, + /// Include deactivated members + #[arg(long, short)] + pub all: bool, + /// Maximum number of members to show (a number or `all`) + #[arg(long, value_parser = super::limit::parse, default_value = "all")] + pub limit: super::Limit, + /// Print JSON; a member's `url` mentions them when pasted into Markdown + #[arg(long, short)] + pub json: bool, +} + +#[derive(Debug, Args)] +pub struct TeamStates { + /// Team key, name, or ID; defaults to the configured team + #[arg(value_parser = NonBlank)] + pub team: Option, + /// Maximum number of states to show (a number or `all`) + #[arg(long, value_parser = super::limit::parse, default_value = "all")] + pub limit: super::Limit, + /// Print JSON + #[arg(long, short)] + pub json: bool, +} diff --git a/crates/linear-cli/src/cli/template.rs b/crates/linear-cli/src/cli/template.rs new file mode 100644 index 000000000..64fe3dfee --- /dev/null +++ b/crates/linear-cli/src/cli/template.rs @@ -0,0 +1,52 @@ +use clap::{Args, Subcommand}; + +use super::values::NonBlank; + +#[derive(Debug, Args)] +#[command(arg_required_else_help = true)] +pub struct Template { + #[command(subcommand)] + pub command: TemplateCommand, +} + +#[derive(Debug, Subcommand)] +pub enum TemplateCommand { + /// List templates + /// + /// Without --team, every template in the workspace is listed. + List(TemplateList), + /// Show a template and the fields it fills in + #[command(alias = "v")] + View(TemplateView), +} + +#[derive(Debug, Args)] +pub struct TemplateList { + /// Show only templates of this type + #[arg(long = "type")] + pub r#type: Option, + /// Show this team's templates (key, name, or ID) plus workspace templates + #[arg(long, value_parser = NonBlank)] + pub team: Option, + /// Maximum number of templates to show (a number or `all`) + #[arg(long, value_parser = super::limit::parse, default_value = "all")] + pub limit: super::Limit, + /// Print JSON + #[arg(long, short)] + pub json: bool, +} + +#[derive(Debug, Args)] +pub struct TemplateView { + /// Template name or ID + pub template: String, + /// Print JSON + /// + /// `templateData` stays a JSON-encoded string; decode it with + /// `jq '.templateData | fromjson'`. + #[arg(long, short)] + pub json: bool, + /// Do not page long output + #[arg(long)] + pub no_pager: bool, +} diff --git a/crates/linear-cli/src/cli/tests.rs b/crates/linear-cli/src/cli/tests.rs new file mode 100644 index 000000000..e6966043a --- /dev/null +++ b/crates/linear-cli/src/cli/tests.rs @@ -0,0 +1,200 @@ +use clap::{CommandFactory, Parser}; + +use super::Cli; + +fn parse(args: &[&str]) -> Result { + Cli::try_parse_from(std::iter::once("linear").chain(args.iter().copied())) +} + +#[track_caller] +fn parses(args: &[&str]) -> Cli { + parse(args).unwrap_or_else(|error| panic!("{args:?} should parse: {error}")) +} + +#[test] +fn the_grammar_is_consistent() { + Cli::command().debug_assert(); +} + +#[test] +fn enum_values_are_kebab_case_and_keep_their_camel_case_spellings() { + for health in [ + "on-track", + "onTrack", + "at-risk", + "atRisk", + "off-track", + "offTrack", + ] { + parses(&["project-update", "create", "P", "--health", health]); + } + for status in ["awaiting-input", "awaitingInput"] { + parses(&["issue", "agent-session", "list", "--status", status]); + } +} + +/// Whether `--yes` (or an old spelling of it) was given to a destructive command. +fn confirmed(cli: &Cli) -> bool { + use super::RootCommand as Root; + use super::{auth, document, initiative, issue, label, milestone, project, team}; + match &cli.command { + Root::Auth(group) => match &group.command { + auth::AuthCommand::Logout(args) => args.confirm.yes, + other => panic!("not a destructive command: {other:?}"), + }, + Root::Issue(group) => match &group.command { + issue::IssueCommand::Archive(args) => args.confirm.yes, + issue::IssueCommand::Delete(args) => args.confirm.yes, + issue::IssueCommand::Comment(comment) => match &comment.command { + issue::IssueCommentCommand::Delete(args) => args.confirm.yes, + other => panic!("not a destructive command: {other:?}"), + }, + issue::IssueCommand::Relation(relation) => match &relation.command { + issue::IssueRelationCommand::Delete(args) => args.confirm.yes, + other => panic!("not a destructive command: {other:?}"), + }, + other => panic!("not a destructive command: {other:?}"), + }, + Root::Label(group) => match &group.command { + label::LabelCommand::Delete(args) => args.confirm.yes, + other => panic!("not a destructive command: {other:?}"), + }, + Root::Milestone(group) => match &group.command { + milestone::MilestoneCommand::Delete(args) => args.confirm.yes, + other => panic!("not a destructive command: {other:?}"), + }, + Root::Project(group) => match &group.command { + project::ProjectCommand::Delete(args) => args.confirm.yes, + other => panic!("not a destructive command: {other:?}"), + }, + Root::Team(group) => match &group.command { + team::TeamCommand::Delete(args) => args.confirm.yes, + other => panic!("not a destructive command: {other:?}"), + }, + Root::Initiative(group) => match &group.command { + initiative::InitiativeCommand::Archive(args) => args.confirm.yes, + initiative::InitiativeCommand::Unarchive(args) => args.confirm.yes, + initiative::InitiativeCommand::Delete(args) => args.confirm.yes, + initiative::InitiativeCommand::RemoveProject(args) => args.confirm.yes, + other => panic!("not a destructive command: {other:?}"), + }, + Root::Document(group) => match &group.command { + document::DocumentCommand::Delete(args) => args.confirm.yes, + other => panic!("not a destructive command: {other:?}"), + }, + other => panic!("not a destructive command: {other:?}"), + } +} + +#[test] +fn destructive_commands_take_yes_and_every_old_spelling() { + let commands: [&[&str]; 14] = [ + &["auth", "logout", "acme"], + &["issue", "archive", "ENG-1"], + &["issue", "delete", "ENG-1"], + &["issue", "comment", "delete", "c-1"], + &["issue", "relation", "delete", "ENG-1", "blocks", "ENG-2"], + &["label", "delete", "Bug"], + &["milestone", "delete", "m-1"], + &["project", "delete", "p-1"], + &["team", "delete", "ENG"], + &["initiative", "archive", "i-1"], + &["initiative", "unarchive", "i-1"], + &["initiative", "delete", "i-1"], + &["initiative", "remove-project", "i-1", "p-1"], + &["document", "delete", "d-1"], + ]; + for command in commands { + assert!(!confirmed(&parses(command)), "{command:?}"); + for flag in ["--yes", "-y", "--force", "-f", "--confirm"] { + let mut args = command.to_vec(); + args.push(flag); + assert!(confirmed(&parses(&args)), "{args:?}"); + } + } +} + +#[test] +fn issue_list_answers_to_its_old_names() { + use super::RootCommand; + use super::issue::IssueCommand; + for name in ["list", "mine", "l"] { + let cli = parses(&["issue", name, "-A"]); + let RootCommand::Issue(group) = cli.command else { + panic!("{name} is an issue command"); + }; + let IssueCommand::List(args) = group.command else { + panic!("{name} is issue list"); + }; + assert!(args.filters.all_assignees, "{name}"); + } +} + +#[test] +fn renamed_and_hidden_flags_still_parse() { + parses(&["label", "list", "--all"]); + parses(&["issue", "query", "--all-assignees", "--all-states"]); + parses(&["issue", "create", "--no-interactive", "-t", "x"]); + parses(&["team", "create", "--no-interactive"]); + parses(&["--no-interactive", "issue", "list"]); +} + +/// Every command, depth first, with its path. +fn commands() -> Vec<(String, clap::Command)> { + fn walk(path: String, command: clap::Command, out: &mut Vec<(String, clap::Command)>) { + for sub in command.get_subcommands() { + walk(format!("{path} {}", sub.get_name()), sub.clone(), out); + } + out.push((path, command)); + } + let mut command = Cli::command(); + command.build(); + let mut out = Vec::new(); + walk("linear".to_owned(), command, &mut out); + out +} + +#[test] +fn reserved_short_flags_keep_one_meaning_everywhere() { + for (path, command) in commands() { + for arg in command.get_arguments() { + let Some(long) = arg.get_long() else { continue }; + let expected = match long { + "yes" => Some('y'), + "interactive" => Some('i'), + "json" => Some('j'), + _ => None, + }; + if let Some(short) = arg.get_short() { + for (reserved, owner) in [('y', "yes"), ('i', "interactive"), ('j', "json")] { + assert!( + short != reserved || long == owner, + "{path}: -{short} is --{long}, but -{reserved} is reserved for --{owner}" + ); + } + } + if let Some(expected) = expected { + assert_eq!(arg.get_short(), Some(expected), "{path} --{long}"); + } + } + } +} + +#[test] +fn every_argument_and_command_has_help() { + for (path, command) in commands() { + if path != "linear" { + assert!(command.get_about().is_some(), "{path} has no about"); + } + for arg in command.get_arguments() { + if arg.is_hide_set() || ["help", "version"].contains(&arg.get_id().as_str()) { + continue; + } + assert!( + arg.get_help().is_some(), + "{path} {} has no help", + arg.get_id() + ); + } + } +} diff --git a/crates/linear-cli/src/cli/user.rs b/crates/linear-cli/src/cli/user.rs new file mode 100644 index 000000000..b411ac78c --- /dev/null +++ b/crates/linear-cli/src/cli/user.rs @@ -0,0 +1,31 @@ +use clap::{Args, Subcommand}; + +#[derive(Debug, Args)] +#[command(arg_required_else_help = true)] +pub struct User { + #[command(subcommand)] + pub command: UserCommand, +} + +#[derive(Debug, Subcommand)] +pub enum UserCommand { + /// List the workspace's members + List(UserList), +} + +#[derive(Debug, Args)] +pub struct UserList { + /// Include deactivated members + #[arg(long, short)] + pub all: bool, + /// Maximum number of members to show (a number or `all`) + #[arg(long, value_parser = super::limit::parse, default_value = "all")] + pub limit: super::Limit, + /// Print JSON; a member's `url` mentions them when pasted into Markdown + /// + /// This lists the whole workspace. To find someone to mention, prefer + /// `linear team members `, and confirm before mentioning someone + /// outside the team. + #[arg(long, short)] + pub json: bool, +} diff --git a/crates/linear-cli/src/cli/values.rs b/crates/linear-cli/src/cli/values.rs new file mode 100644 index 000000000..91b77d50a --- /dev/null +++ b/crates/linear-cli/src/cli/values.rs @@ -0,0 +1,270 @@ +//! Flag value types and parsers shared across commands. +use std::ffi::OsStr; +use std::fmt; +use std::path::PathBuf; +use std::str::FromStr; + +use chrono::{DateTime, NaiveDate, Utc}; +use clap::ValueEnum; +use clap::builder::{NonEmptyStringValueParser, TypedValueParser}; + +use crate::graphql::scalars::Float; +use crate::refs::reject_linear_url; + +/// A user: yourself, or someone to look up. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum UserRef { + /// `@me`, also spelled `self`. + Me, + /// An email, a username (display name), or a name or part of one. + Query(String), +} + +impl FromStr for UserRef { + type Err = String; + + fn from_str(value: &str) -> Result { + match value { + "" => Err("expected a user".to_owned()), + "@me" | "self" => Ok(Self::Me), + _ => { + reject_linear_url(value, "an email, username, name, or @me") + .map_err(|error| error.message().to_owned())?; + Ok(Self::Query(value.to_owned())) + } + } + } +} + +impl fmt::Display for UserRef { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Me => f.write_str("@me"), + Self::Query(query) => f.write_str(query), + } + } +} + +/// Text that must not be empty or only whitespace, such as a title, name, or +/// reference. An empty value gets clap's "a value is required" error. +#[derive(Clone, Copy, Debug)] +pub struct NonBlank; + +impl TypedValueParser for NonBlank { + type Value = String; + + fn parse_ref( + &self, + cmd: &clap::Command, + arg: Option<&clap::Arg>, + value: &OsStr, + ) -> Result { + NonEmptyStringValueParser::new().parse_ref(cmd, arg, value)?; + let not_blank = |text: &str| { + if text.trim().is_empty() { + Err("the value is only whitespace") + } else { + Ok(text.to_owned()) + } + }; + not_blank.parse_ref(cmd, arg, value) + } +} + +/// Where to read a body or description from: a file, or `-` for stdin. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum TextSource { + Stdin, + File(PathBuf), +} + +impl FromStr for TextSource { + type Err = String; + + fn from_str(value: &str) -> Result { + match value { + "" => Err("expected a file path, or - for stdin".to_owned()), + "-" => Ok(Self::Stdin), + path => Ok(Self::File(PathBuf::from(path))), + } + } +} + +impl fmt::Display for TextSource { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Stdin => f.write_str("stdin"), + Self::File(path) => write!(f, "{}", path.display()), + } + } +} + +/// A `YYYY-MM-DD` calendar date. +pub fn date(value: &str) -> Result { + NaiveDate::parse_from_str(value, "%Y-%m-%d") + .map_err(|_| format!("expected a YYYY-MM-DD date, got {value:?}")) +} + +/// A `YYYY-MM-DD` date (midnight UTC) or an RFC 3339 date-time such as +/// `2026-01-31T09:00:00Z`. +pub fn date_or_datetime(value: &str) -> Result, String> { + if let Ok(date) = NaiveDate::parse_from_str(value, "%Y-%m-%d") { + return Ok(date.and_time(chrono::NaiveTime::MIN).and_utc()); + } + DateTime::parse_from_rfc3339(value) + .map(|datetime| datetime.with_timezone(&Utc)) + .map_err(|_| format!("expected a YYYY-MM-DD date or an RFC 3339 date-time, got {value:?}")) +} + +/// A `#RRGGBB` color, as written. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct HexColor(String); + +impl FromStr for HexColor { + type Err = String; + + fn from_str(value: &str) -> Result { + let digits = value + .strip_prefix('#') + .filter(|digits| digits.len() == 6 && digits.bytes().all(|b| b.is_ascii_hexdigit())); + match digits { + Some(_) => Ok(Self(value.to_owned())), + None => Err(format!("expected a hex color like #5E6AD2, got {value:?}")), + } + } +} + +impl From for String { + fn from(color: HexColor) -> Self { + color.0 + } +} + +/// A finite number for a `sortOrder`; whole values are sent as integers. +pub fn sort_order(value: &str) -> Result { + serde_json::from_str(value).map_err(|_| format!("expected a number, got {value:?}")) +} + +/// An issue estimate in points. +pub fn estimate(value: &str) -> Result { + value + .parse::() + .ok() + .filter(|points| *points >= 0) + .ok_or_else(|| format!("expected a whole number of points, got {value:?}")) +} + +/// An issue or project priority, by name or by Linear's number (0-4). +#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)] +pub enum Priority { + #[value(name = "none", alias = "0")] + None, + #[value(name = "urgent", alias = "1")] + Urgent, + #[value(name = "high", alias = "2")] + High, + #[value(name = "medium", alias = "3")] + Medium, + #[value(name = "low", alias = "4")] + Low, +} + +impl Priority { + /// Linear's number for the priority. + pub fn number(self) -> i32 { + match self { + Self::None => 0, + Self::Urgent => 1, + Self::High => 2, + Self::Medium => 3, + Self::Low => 4, + } + } +} + +/// An initiative status. +#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)] +pub enum InitiativeStatus { + #[value(name = "planned")] + Planned, + #[value(name = "active")] + Active, + #[value(name = "completed")] + Completed, +} + +impl From for crate::graphql::operations::initiative::InitiativeStatus { + fn from(status: InitiativeStatus) -> Self { + match status { + InitiativeStatus::Planned => Self::Planned, + InitiativeStatus::Active => Self::Active, + InitiativeStatus::Completed => Self::Completed, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn date_or_datetime_normalizes_to_utc() { + let parse = |value| date_or_datetime(value).map(|datetime| datetime.to_rfc3339()); + assert_eq!( + parse("2024-02-29"), + Ok("2024-02-29T00:00:00+00:00".to_owned()) + ); + assert_eq!( + parse("2026-01-02T03:04:05+02:30"), + Ok("2026-01-02T00:34:05+00:00".to_owned()) + ); + for invalid in ["2025-02-29", "yesterday", "2026-01-02T03:04:05", "2026-01"] { + assert!(parse(invalid).is_err(), "{invalid}"); + } + } + + #[test] + fn sort_order_keeps_whole_numbers_integral() { + let json = |value| { + sort_order(value) + .map(|order| serde_json::to_string(&order).expect("a Float serializes")) + }; + assert_eq!(json("2"), Ok("2".to_owned())); + assert_eq!(json("2.0"), Ok("2".to_owned())); + assert_eq!(json("-1.5"), Ok("-1.5".to_owned())); + for invalid in ["", "abc", "NaN", "inf", "1e999"] { + assert!(sort_order(invalid).is_err(), "{invalid}"); + } + } + + #[test] + fn user_refs_spell_yourself_two_ways_and_refuse_urls() { + assert_eq!("@me".parse(), Ok(UserRef::Me)); + assert_eq!("self".parse(), Ok(UserRef::Me)); + assert_eq!( + "ada@example.com".parse(), + Ok(UserRef::Query("ada@example.com".to_owned())) + ); + for invalid in ["", "https://linear.app/acme/profiles/ada"] { + assert!(invalid.parse::().is_err(), "{invalid}"); + } + } + + #[test] + fn text_sources_read_stdin_for_a_dash() { + assert_eq!("-".parse(), Ok(TextSource::Stdin)); + assert_eq!( + "notes.md".parse(), + Ok(TextSource::File(PathBuf::from("notes.md"))) + ); + assert!("".parse::().is_err()); + } + + #[test] + fn hex_color_needs_six_digits() { + assert_eq!("#5e6AD2".parse(), Ok(HexColor("#5e6AD2".to_owned()))); + for invalid in ["5E6AD2", "#5E6AD", "#5E6AD2F", "#GGGGGG"] { + assert!(invalid.parse::().is_err(), "{invalid}"); + } + } +} diff --git a/crates/linear-cli/src/client.rs b/crates/linear-cli/src/client.rs new file mode 100644 index 000000000..60798b624 --- /dev/null +++ b/crates/linear-cli/src/client.rs @@ -0,0 +1,224 @@ +//! The Linear API client: GraphQL operations plus the other HTTP requests the +//! CLI makes with the same connection settings (the raw `api` command, signed +//! uploads and Markdown image or attachment downloads, in [`http`]). +//! +//! Each [`LinearClient`] owns one `reqwest` client. Proxies come from the +//! standard `HTTP_PROXY`/`HTTPS_PROXY`/`ALL_PROXY`/`NO_PROXY` variables, which +//! reqwest reads itself. Certificates are verified against the operating +//! system's roots and the bundled Mozilla roots, plus any bundle named in +//! [`ClientConfig`]. +//! +//! GraphQL responses are captured whole (status, headers, body up to a cap) +//! and then classified: GraphQL `errors` win over the HTTP status, and a +//! non-2xx body without GraphQL errors is an HTTP failure that keeps its bytes. +//! +//! Secrets: every stored `reqwest::Error` has its URL stripped, and failures +//! only print the endpoint's scheme/host/port, never its path or query. +//! `ApiKey` redacts itself in `Debug` and `Display`. + +mod config; +mod error; +mod http; + +use reqwest::header::{AUTHORIZATION, CONTENT_TYPE, HeaderMap, HeaderValue}; +use serde::Serialize; +use serde::de::DeserializeOwned; + +use cynic::Operation; + +use crate::graphql::envelope::{GraphQlRequest, ResponseError, parse_response}; + +pub use config::{ApiKey, ClientBuildError, ClientConfig, Deadline, EndpointUrl, ResponseCap}; +pub use error::{HttpBodyShape, RawHttpResponse, RequestError}; + +use config::build_client; +use error::redact; +use http::{ExchangeFailure, classify_network, collect}; + +/// `Content-Type` sent on every GraphQL POST. +pub const CONTENT_TYPE_VALUE: &str = "application/json"; + +/// Classifies a captured response for a typed operation. +/// +/// GraphQL errors take precedence at any status; other non-2xx responses are +/// HTTP failures. A body that is not JSON at all is reported with its status +/// and content type rather than as a parser error. +pub fn classify_typed(response: RawHttpResponse) -> Result { + let parsed = match parse_response::(&response.body) { + Err(ResponseError::MalformedJson(source)) if !declares_json(&response.headers) => { + Err(ResponseError::NotJson { + status: response.status, + content_type: content_type(&response.headers), + source, + }) + } + other => other, + }; + match (parsed, response.status.is_success()) { + (Ok(data), true) => Ok(data), + (Ok(_), false) => Err(RequestError::Http { + response: Box::new(response), + body: HttpBodyShape::Data, + }), + ( + Err(ResponseError::GraphQl { + errors, + partial_data, + }), + _, + ) => Err(RequestError::GraphQl { + status: response.status, + headers: response.headers, + errors, + partial_data, + }), + (Err(error), true) => Err(RequestError::Response(error)), + (Err(error), false) => Err(RequestError::Http { + response: Box::new(response), + body: HttpBodyShape::Unusable(error), + }), + } +} + +pub(super) fn content_type(headers: &HeaderMap) -> Option { + headers + .get(CONTENT_TYPE) + .map(|value| String::from_utf8_lossy(value.as_bytes()).into_owned()) +} + +fn declares_json(headers: &HeaderMap) -> bool { + content_type(headers).is_some_and(|value| value.to_ascii_lowercase().contains("json")) +} + +/// One configured HTTP client bound to an endpoint and key. +#[derive(Clone, Debug)] +pub struct LinearClient { + http: reqwest::Client, + endpoint: EndpointUrl, + api_key: ApiKey, + deadline: Deadline, + max_response_bytes: ResponseCap, + download_deadline: Deadline, + max_download_bytes: ResponseCap, +} + +impl LinearClient { + pub fn new( + endpoint: EndpointUrl, + api_key: ApiKey, + config: ClientConfig, + ) -> Result { + Ok(Self { + http: build_client(&config)?, + endpoint, + api_key, + deadline: config.deadline, + max_response_bytes: config.max_response_bytes, + download_deadline: config.download_deadline, + max_download_bytes: config.max_download_bytes, + }) + } + + pub fn endpoint(&self) -> &EndpointUrl { + &self.endpoint + } + + /// Runs a query operation; `Q`'s derive fixes the document and the + /// variables type. + pub async fn query(&self, variables: V) -> Result + where + Q: cynic::QueryBuilder + DeserializeOwned, + V: Serialize, + { + self.execute(Q::build(variables)).await + } + + /// Runs a mutation operation. Mutations are sent once and never retried. + pub async fn mutate(&self, variables: V) -> Result + where + M: cynic::MutationBuilder + DeserializeOwned, + V: Serialize, + { + self.execute(M::build(variables)).await + } + + /// Sends a built operation and decodes its data as `T`, usually the + /// operation's own type. + pub async fn execute(&self, operation: Operation) -> Result + where + T: DeserializeOwned, + V: Serialize, + { + let request = GraphQlRequest::new(operation).map_err(RequestError::RequestBody)?; + self.execute_request(&request).await + } + + /// Sends a request body and decodes its data as `T`. For documents built + /// at run time, such as `linear schema`'s batched type queries. + pub async fn execute_request( + &self, + request: &GraphQlRequest, + ) -> Result { + let response = self.send_request(request).await?; + self.classify(response) + } + + /// Sends a request body and returns the exact response. + pub async fn send_request( + &self, + request: &GraphQlRequest, + ) -> Result { + let body = serde_json::to_vec(request).map_err(RequestError::RequestBody)?; + self.post(body).await + } + + async fn post(&self, body: Vec) -> Result { + let response = self + .http + .post(self.endpoint.url.clone()) + .timeout(self.deadline.duration()) + .header(AUTHORIZATION, self.api_key.header_value()) + .header(CONTENT_TYPE, HeaderValue::from_static(CONTENT_TYPE_VALUE)) + .body(body) + .send() + .await + .map_err(|error| self.failure(classify_network(error)))?; + collect(response, self.max_response_bytes) + .await + .map_err(|failure| self.failure(failure)) + } + + /// [`classify_typed`], with the API key redacted from retained HTTP bodies. + fn classify(&self, response: RawHttpResponse) -> Result { + classify_typed(response).map_err(|failure| match failure { + RequestError::Http { mut response, body } => { + response.body = redact(&response.body, self.api_key.value.as_bytes()); + RequestError::Http { response, body } + } + other => other, + }) + } + + fn failure(&self, failure: ExchangeFailure) -> RequestError { + match failure { + ExchangeFailure::ResponseTooLarge { status, limit } => { + RequestError::ResponseTooLarge { status, limit } + } + ExchangeFailure::Timeout => RequestError::Timeout { + origin: self.endpoint.origin.clone(), + deadline: self.deadline, + }, + ExchangeFailure::Network { phase, source } => RequestError::Network { + origin: self.endpoint.origin.clone(), + phase, + source, + }, + } + } +} + +#[cfg(test)] +mod tests; + +#[cfg(test)] +pub(crate) use tests::{client_to, server}; diff --git a/crates/linear-cli/src/client/config.rs b/crates/linear-cli/src/client/config.rs new file mode 100644 index 000000000..8d3a75e2d --- /dev/null +++ b/crates/linear-cli/src/client/config.rs @@ -0,0 +1,362 @@ +//! Client settings: the endpoint, the API key, deadlines, response caps and +//! extra trusted certificates, and building the `reqwest` client from them. + +use std::error::Error as StdError; +use std::fmt; +use std::fs; +use std::io; +use std::num::NonZeroUsize; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use reqwest::header::HeaderValue; +use reqwest::redirect::Policy; +use reqwest::tls::Certificate; +use reqwest::{Client, Url}; + +use super::error::SanitizedReqwestError; +use crate::error::Error; + +/// The `User-Agent` sent on every request. +pub const USER_AGENT_VALUE: &str = concat!("schpet-linear-cli/", env!("CARGO_PKG_VERSION")); + +/// Redirects followed per request before giving up. +const MAX_REDIRECTS: usize = 20; + +/// A validated `http` or `https` GraphQL endpoint. +/// +/// The full URL (including any path and query) is used for requests; only the +/// origin (`scheme://host[:port]`) is ever displayed, because signed URLs can +/// carry tokens in their query string. +#[derive(Clone, PartialEq, Eq)] +pub struct EndpointUrl { + pub(super) url: Url, + pub(super) origin: String, +} + +/// Why a string is not an acceptable [`EndpointUrl`]. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum EndpointUrlError { + /// The text is not a URL; carries the parser's description (never the input). + Invalid(String), + /// The scheme is neither `http` nor `https`. + Scheme(String), + /// The URL has no host. + MissingHost, + /// The URL carries a username or password. + Credentials, + /// The URL carries a fragment. + Fragment, +} + +impl fmt::Display for EndpointUrlError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Invalid(detail) => write!(f, "endpoint URL is not valid: {detail}"), + Self::Scheme(scheme) => { + write!(f, "endpoint URL scheme must be http or https, not {scheme}") + } + Self::MissingHost => write!(f, "endpoint URL has no host"), + Self::Credentials => write!(f, "endpoint URL must not carry credentials"), + Self::Fragment => write!(f, "endpoint URL must not carry a fragment"), + } + } +} + +impl StdError for EndpointUrlError {} + +impl EndpointUrl { + /// Parses and validates an endpoint. Path and query are preserved. + pub fn parse(text: &str) -> Result { + let url = Url::parse(text).map_err(|error| EndpointUrlError::Invalid(error.to_string()))?; + Self::from_url(url) + } + + pub(super) fn from_url(url: Url) -> Result { + match url.scheme() { + "http" | "https" => {} + other => return Err(EndpointUrlError::Scheme(other.to_owned())), + } + let Some(host) = url.host_str() else { + return Err(EndpointUrlError::MissingHost); + }; + if !url.username().is_empty() || url.password().is_some() { + return Err(EndpointUrlError::Credentials); + } + if url.fragment().is_some() { + return Err(EndpointUrlError::Fragment); + } + let origin = match url.port() { + Some(port) => format!("{}://{host}:{port}", url.scheme()), + None => format!("{}://{host}", url.scheme()), + }; + Ok(Self { url, origin }) + } + + /// The full request URL. + pub fn url(&self) -> &Url { + &self.url + } +} + +impl fmt::Display for EndpointUrl { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.origin) + } +} + +impl fmt::Debug for EndpointUrl { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_tuple("EndpointUrl").field(&self.origin).finish() + } +} + +/// A Linear API key, sent verbatim as the `Authorization` header value. +/// +/// Redacted in `Debug` and `Display`; the header value is marked sensitive. +#[derive(Clone)] +pub struct ApiKey { + pub(super) value: HeaderValue, +} + +/// Why a string is not an acceptable [`ApiKey`]. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ApiKeyError { + Empty, + /// A byte outside visible ASCII and space (control characters, CR/LF, tab, + /// non-ASCII) at this index. + InvalidByte { + index: usize, + }, +} + +impl fmt::Display for ApiKeyError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Empty => write!(f, "API key is empty"), + Self::InvalidByte { index } => write!( + f, + "API key contains a byte at index {index} that is not printable ASCII" + ), + } + } +} + +impl StdError for ApiKeyError {} + +impl ApiKey { + /// Trims surrounding whitespace, which is never part of a key (keyring + /// tools and files often end it with a newline). What remains must be + /// non-empty visible ASCII, spaces allowed, so the value can never split + /// or inject a header. + pub fn new(text: &str) -> Result { + let text = text.trim_matches([' ', '\t', '\r', '\n']); + if text.is_empty() { + return Err(ApiKeyError::Empty); + } + if let Some(index) = text.bytes().position(|byte| !(0x20..=0x7e).contains(&byte)) { + return Err(ApiKeyError::InvalidByte { index }); + } + #[allow(clippy::expect_used, reason = "every byte was checked above")] + let mut value = HeaderValue::from_str(text).expect("visible ASCII is a valid header value"); + value.set_sensitive(true); + Ok(Self { value }) + } + + pub(super) fn header_value(&self) -> HeaderValue { + self.value.clone() + } +} + +impl fmt::Debug for ApiKey { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("ApiKey()") + } +} + +impl fmt::Display for ApiKey { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("") + } +} + +// --------------------------------------------------------------------------- +// Configuration + +/// A non-zero total deadline for one request: connect, send and body. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Deadline(pub(super) Duration); + +impl Deadline { + /// API requests, including `linear api`. + pub const DEFAULT: Self = Self(Duration::from_secs(30)); + /// Image and attachment downloads, which can be large. + pub const DOWNLOAD: Self = Self(Duration::from_secs(300)); + + pub fn duration(self) -> Duration { + self.0 + } +} + +/// A cap on collected response bytes. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ResponseCap(pub(super) NonZeroUsize); + +impl ResponseCap { + /// 64 MiB: far above any real Linear page, small enough to stop a runaway body. + pub const DEFAULT: Self = Self(NonZeroUsize::MIN.saturating_add(64 * 1024 * 1024 - 1)); + /// 256 MiB for one downloaded image or attachment, which is held in memory. + pub const DOWNLOAD: Self = Self(NonZeroUsize::MIN.saturating_add(256 * 1024 * 1024 - 1)); + + pub fn bytes(self) -> usize { + self.0.get() + } +} + +/// Everything the client needs beyond endpoint and key. +#[derive(Clone, Debug)] +pub struct ClientConfig { + /// A PEM bundle whose certificates are trusted in addition to the + /// built-in roots (`SSL_CERT_FILE`). + pub ca_bundle: Option, + /// API requests: GraphQL operations and `linear api`. + pub deadline: Deadline, + pub max_response_bytes: ResponseCap, + /// Image and attachment downloads. + pub download_deadline: Deadline, + pub max_download_bytes: ResponseCap, +} + +impl Default for ClientConfig { + fn default() -> Self { + Self { + ca_bundle: None, + deadline: Deadline::DEFAULT, + max_response_bytes: ResponseCap::DEFAULT, + download_deadline: Deadline::DOWNLOAD, + max_download_bytes: ResponseCap::DOWNLOAD, + } + } +} + +/// Why a [`LinearClient`] could not be built. +#[derive(Debug)] +pub enum ClientBuildError { + CaRead { + path: PathBuf, + source: io::Error, + }, + CaInvalid { + path: PathBuf, + source: SanitizedReqwestError, + }, + CaEmpty { + path: PathBuf, + }, + Client(SanitizedReqwestError), +} + +impl fmt::Display for ClientBuildError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::CaRead { path, .. } => { + write!(f, "CA bundle {} could not be read", path.display()) + } + Self::CaInvalid { path, .. } => { + write!(f, "CA bundle {} is not a valid PEM bundle", path.display()) + } + Self::CaEmpty { path } => { + write!(f, "CA bundle {} contains no certificates", path.display()) + } + Self::Client(_) => write!(f, "HTTP client could not be built"), + } + } +} + +impl StdError for ClientBuildError { + fn source(&self) -> Option<&(dyn StdError + 'static)> { + match self { + Self::CaRead { source, .. } => Some(source), + Self::CaInvalid { source, .. } | Self::Client(source) => Some(source), + Self::CaEmpty { .. } => None, + } + } +} + +impl From for Error { + fn from(error: ClientBuildError) -> Self { + let message = match &error { + ClientBuildError::CaRead { .. } + | ClientBuildError::CaInvalid { .. } + | ClientBuildError::CaEmpty { .. } => format!("SSL_CERT_FILE: {error}"), + ClientBuildError::Client(_) => error.to_string(), + }; + Error::new(message).with_source(error) + } +} + +fn load_pem_bundle(path: &Path) -> Result, ClientBuildError> { + let bytes = fs::read(path).map_err(|source| ClientBuildError::CaRead { + path: path.to_path_buf(), + source, + })?; + let invalid = |error| ClientBuildError::CaInvalid { + path: path.to_path_buf(), + source: SanitizedReqwestError::new(error), + }; + let certificates = Certificate::from_pem_bundle(&bytes).map_err(invalid)?; + if certificates.is_empty() { + return Err(ClientBuildError::CaEmpty { + path: path.to_path_buf(), + }); + } + // `from_pem_bundle` only checks the PEM framing; the DER inside is parsed + // when a root store is built. Build a throwaway store per certificate so + // a bad certificate is attributed to the bundle, not to the client. + for certificate in &certificates { + Client::builder() + .tls_built_in_root_certs(false) + .add_root_certificate(certificate.clone()) + .build() + .map_err(invalid)?; + } + Ok(certificates) +} + +/// Follows up to [`MAX_REDIRECTS`] redirects, never from HTTPS to plain +/// HTTP. reqwest itself drops the API key when a redirect changes host or +/// port; refusing downgrades keeps it off the wire on the same host too. +fn follow_redirect(attempt: reqwest::redirect::Attempt<'_>) -> reqwest::redirect::Action { + let downgrade = attempt.url().scheme() != "https" + && attempt + .previous() + .iter() + .any(|previous| previous.scheme() == "https"); + if downgrade { + attempt.error("refusing to follow a redirect from HTTPS to plain HTTP") + } else if attempt.previous().len() > MAX_REDIRECTS { + attempt.error("too many redirects") + } else { + attempt.follow() + } +} + +/// Builds the `reqwest` client: HTTP/1.1, bounded redirects, no retries, +/// gzip/brotli/deflate responses, proxies from the environment, and trusted +/// roots from the operating system, the bundled Mozilla set and the optional +/// bundle. +pub(super) fn build_client(config: &ClientConfig) -> Result { + let mut builder = Client::builder() + .user_agent(USER_AGENT_VALUE) + .redirect(Policy::custom(follow_redirect)) + .referer(false) + .retry(reqwest::retry::never()); + if let Some(path) = &config.ca_bundle { + for certificate in load_pem_bundle(path)? { + builder = builder.add_root_certificate(certificate); + } + } + builder + .build() + .map_err(|error| ClientBuildError::Client(SanitizedReqwestError::new(error))) +} diff --git a/crates/linear-cli/src/client/error.rs b/crates/linear-cli/src/client/error.rs new file mode 100644 index 000000000..ae7928cde --- /dev/null +++ b/crates/linear-cli/src/client/error.rs @@ -0,0 +1,393 @@ +//! How requests fail: sanitized `reqwest` errors, captured responses and the +//! classified [`RequestError`]. + +use std::error::Error as StdError; +use std::fmt; + +use reqwest::StatusCode; +use reqwest::header::HeaderMap; + +use super::config::{Deadline, ResponseCap}; +use super::content_type; +use crate::error::Error; +use crate::graphql::envelope::{ + ResponseError, ResponseGraphQlError, graphql_message, is_not_found, +}; + +/// A `reqwest::Error` with its URL removed before it is stored or chained. +/// +/// This is the only way a `reqwest::Error` enters this module's error types. +#[derive(Debug)] +pub struct SanitizedReqwestError(reqwest::Error); + +impl SanitizedReqwestError { + pub(super) fn new(error: reqwest::Error) -> Self { + Self(error.without_url()) + } + + /// The innermost message of the source chain (for example the OS + /// connection error), used to give failures a concrete reason. + pub(super) fn root_message(&self) -> String { + let mut current: &(dyn StdError + 'static) = &self.0; + while let Some(next) = current.source() { + current = next; + } + current.to_string() + } +} + +impl fmt::Display for SanitizedReqwestError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Display::fmt(&self.0, f) + } +} + +impl StdError for SanitizedReqwestError { + fn source(&self) -> Option<&(dyn StdError + 'static)> { + self.0.source() + } +} + +/// One HTTP response exactly as received: status, headers and body bytes. +/// +/// `Debug` prints sizes, not header values or body bytes. +#[derive(Clone, PartialEq, Eq)] +pub struct RawHttpResponse { + pub status: StatusCode, + pub headers: HeaderMap, + pub body: Vec, +} + +impl fmt::Debug for RawHttpResponse { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("RawHttpResponse") + .field("status", &self.status) + .field("headers", &format_args!("<{} headers>", self.headers.len())) + .field("body", &format_args!("<{} bytes>", self.body.len())) + .finish() + } +} + +impl RawHttpResponse { + /// The body as display-safe text: invalid UTF-8 replaced, control + /// characters (including terminal escapes) dropped and whitespace runs + /// collapsed to one space. + pub(super) fn body_text(&self) -> String { + String::from_utf8_lossy(&self.body) + .split_whitespace() + .map(|word| word.chars().filter(|c| !c.is_control()).collect::()) + .filter(|word| !word.is_empty()) + .collect::>() + .join(" ") + } +} + +/// Longest body excerpt shown in an HTTP failure message; `LINEAR_DEBUG` +/// shows the whole body. +const BODY_EXCERPT_CHARS: usize = 200; + +/// A short excerpt of a non-2xx body worth showing next to its status: plain +/// text or unusable JSON (say, a proxy's or rate limiter's explanation). +/// Omitted for HTML pages, well-formed data envelopes, empty bodies and +/// bodies that only repeat the status reason. +fn body_excerpt(response: &RawHttpResponse, body: &HttpBodyShape) -> Option { + match body { + HttpBodyShape::Data => return None, + HttpBodyShape::Unusable(_) => {} + } + if content_type(&response.headers) + .is_some_and(|value| value.to_ascii_lowercase().contains("html")) + { + return None; + } + let text = response.body_text(); + if text.is_empty() + || response + .status + .canonical_reason() + .is_some_and(|reason| reason.eq_ignore_ascii_case(&text)) + { + return None; + } + let mut chars = text.chars(); + let excerpt: String = chars.by_ref().take(BODY_EXCERPT_CHARS).collect(); + Some(match chars.next() { + Some(_) => format!("{excerpt}…"), + None => excerpt, + }) +} + +/// Replaces every occurrence of `secret` in `body`, so a server or proxy +/// that echoes the request cannot leak the key into an error message. +pub(super) fn redact(body: &[u8], secret: &[u8]) -> Vec { + let mut redacted = Vec::with_capacity(body.len()); + let mut rest = body; + while let Some(at) = rest + .windows(secret.len()) + .position(|window| window == secret) + { + let (before, matched) = rest.split_at(at); + redacted.extend_from_slice(before); + redacted.extend_from_slice(b""); + rest = matched + .get(secret.len()..) + .expect("the match starts a window of the secret's length"); + } + redacted.extend_from_slice(rest); + redacted +} + +/// What a non-2xx body without GraphQL errors contained. +#[derive(Debug)] +pub enum HttpBodyShape { + /// A well-formed envelope carrying `data` and no errors. + Data, + /// Malformed JSON, a non-envelope shape, or neither `data` nor `errors`. + Unusable(ResponseError), +} + +/// Which stage of the exchange a network error came from. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum NetworkPhase { + Connect, + Request, + Body, + Other, +} + +impl NetworkPhase { + fn describe(self) -> &'static str { + match self { + Self::Connect => "connection to", + Self::Request => "request to", + Self::Body => "reading the response from", + Self::Other => "exchange with", + } + } +} + +/// Every way a GraphQL exchange can fail after construction. +/// +/// `Debug` prints the response header count, never header names or values, +/// like [`RawHttpResponse`]. +pub enum RequestError { + /// The request envelope (usually its variables) could not be serialized. + RequestBody(serde_json::Error), + /// The body carried a non-empty GraphQL `errors` array, whatever the status. + GraphQl { + status: StatusCode, + headers: HeaderMap, + errors: Vec, + partial_data: bool, + }, + /// Non-2xx without GraphQL errors; the exact response is retained (boxed + /// to keep the error small). + Http { + response: Box, + body: HttpBodyShape, + }, + /// 2xx whose body is not JSON, not an envelope, has neither `data` nor + /// `errors`, or does not match the operation's types. + Response(ResponseError), + /// The body exceeded the configured cap; nothing partial is returned. + ResponseTooLarge { + status: StatusCode, + limit: ResponseCap, + }, + /// The total deadline elapsed before the body was fully read. + Timeout { origin: String, deadline: Deadline }, + /// Connection, request or body-read failure below HTTP. + Network { + origin: String, + phase: NetworkPhase, + source: SanitizedReqwestError, + }, +} + +impl fmt::Debug for RequestError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::RequestBody(source) => f.debug_tuple("RequestBody").field(source).finish(), + Self::GraphQl { + status, + headers, + errors, + partial_data, + } => f + .debug_struct("GraphQl") + .field("status", status) + .field("headers", &format_args!("<{} headers>", headers.len())) + .field("errors", errors) + .field("partial_data", partial_data) + .finish(), + Self::Http { response, body } => f + .debug_struct("Http") + .field("response", response) + .field("body", body) + .finish(), + Self::Response(source) => f.debug_tuple("Response").field(source).finish(), + Self::ResponseTooLarge { status, limit } => f + .debug_struct("ResponseTooLarge") + .field("status", status) + .field("limit", limit) + .finish(), + Self::Timeout { origin, deadline } => f + .debug_struct("Timeout") + .field("origin", origin) + .field("deadline", deadline) + .finish(), + Self::Network { + origin, + phase, + source, + } => f + .debug_struct("Network") + .field("origin", origin) + .field("phase", phase) + .field("source", source) + .finish(), + } + } +} + +impl fmt::Display for RequestError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::RequestBody(source) => { + write!(f, "request could not be serialized: {source}") + } + Self::GraphQl { errors, .. } => match graphql_message(errors) { + Some(message) => f.write_str(&message), + None => write!(f, "GraphQL request failed without an error message"), + }, + Self::Http { response, body } => { + write!(f, "unexpected HTTP status {}", response.status)?; + match body_excerpt(response, body) { + Some(excerpt) => write!(f, ": {excerpt}"), + None => Ok(()), + } + } + Self::Response(source) => fmt::Display::fmt(source, f), + Self::ResponseTooLarge { status, limit } => write!( + f, + "response body exceeds the {} byte limit (HTTP {status})", + limit.bytes() + ), + Self::Timeout { origin, deadline } => write!( + f, + "request to {origin} did not complete within {:?}", + deadline.duration() + ), + Self::Network { + origin, + phase, + source, + } => write!( + f, + "{} {origin} failed: {}", + phase.describe(), + source.root_message() + ), + } + } +} + +impl StdError for RequestError { + fn source(&self) -> Option<&(dyn StdError + 'static)> { + match self { + Self::RequestBody(source) => Some(source), + Self::Response(source) => Some(source), + Self::Http { + body: HttpBodyShape::Unusable(source), + .. + } => Some(source), + Self::Network { source, .. } => Some(source), + Self::Http { + body: HttpBodyShape::Data, + .. + } + | Self::GraphQl { .. } + | Self::ResponseTooLarge { .. } + | Self::Timeout { .. } => None, + } + } +} + +impl RequestError { + /// Whether the request may have reached Linear and taken effect anyway: a + /// timeout, a network failure after connecting (a reset after the request + /// was written surfaces as a request-phase error), or an undecodable + /// success response. Errors Linear reported mean nothing changed. + pub fn outcome_unknown(&self) -> bool { + match self { + Self::Timeout { .. } | Self::Response(_) => true, + Self::Network { phase, .. } => !matches!(phase, NetworkPhase::Connect), + Self::ResponseTooLarge { status, .. } => status.is_success(), + Self::RequestBody(_) | Self::GraphQl { .. } | Self::Http { .. } => false, + } + } + + /// Whether Linear answered that the requested entity does not exist. + pub fn is_not_found(&self) -> bool { + matches!(self, Self::GraphQl { errors, .. } if is_not_found(errors)) + } + + /// [`Error::not_found`] for `entity` `identifier` when Linear answered + /// that it does not exist, else this failure as an error. + pub fn or_not_found(self, entity: &str, identifier: &str) -> Error { + if self.is_not_found() { + Error::not_found(entity, identifier) + } else { + Error::from(self) + } + } + + /// The error for a failed create, noting that `entity` may already exist + /// when the outcome is unknown. Creates are never retried. + pub fn into_create_error(self, entity: &str) -> Error { + let uncertain = self.outcome_unknown(); + let mut error = Error::from(self); + if uncertain { + error.push_message(&format!("; {entity} may already exist")); + } + error + } +} + +impl From for Error { + fn from(failure: RequestError) -> Self { + let message = failure.to_string(); + match failure { + RequestError::RequestBody(source) => Error::new(message).with_source(source), + RequestError::GraphQl { + status, + errors, + partial_data, + .. + } => { + // The summary omits arbitrary response extensions, headers and + // the request URL. + Error::new(message).with_debug_detail(format!( + "GraphQL HTTP {status}; errors={}; partial_data={partial_data}", + errors.len() + )) + } + RequestError::Response(source) => Error::from(source), + RequestError::Http { response, body } => { + let error = Error::new(message).with_debug_detail(format!( + "HTTP {} body: {}", + response.status, + response.body_text() + )); + match body { + HttpBodyShape::Unusable(source) => error.with_source(source), + HttpBodyShape::Data => error, + } + } + RequestError::ResponseTooLarge { .. } | RequestError::Timeout { .. } => { + Error::new(message) + } + RequestError::Network { source, .. } => Error::new(message).with_source(source), + } + } +} diff --git a/crates/linear-cli/src/client/http.rs b/crates/linear-cli/src/client/http.rs new file mode 100644 index 000000000..31e4a70cd --- /dev/null +++ b/crates/linear-cli/src/client/http.rs @@ -0,0 +1,258 @@ +//! Requests outside GraphQL operations: Markdown image and attachment +//! downloads, the raw `api` command and signed uploads. They share the +//! client's connection settings and read bodies through the same bounded +//! [`collect`]: `linear api` with the API deadline and cap, downloads with +//! their own larger ones. + +use reqwest::header::{AUTHORIZATION, CONTENT_TYPE, HeaderMap, HeaderValue}; +use reqwest::{StatusCode, Url}; + +use super::config::{Deadline, EndpointUrl, ResponseCap}; +use super::error::{NetworkPhase, RawHttpResponse, SanitizedReqwestError}; +use super::{CONTENT_TYPE_VALUE, LinearClient}; +use crate::error::Error; + +/// A failure below HTTP classification, before the client attaches its +/// origin. +#[derive(Debug)] +pub(super) enum ExchangeFailure { + ResponseTooLarge { + status: StatusCode, + limit: ResponseCap, + }, + Timeout, + Network { + phase: NetworkPhase, + source: SanitizedReqwestError, + }, +} + +pub(super) fn classify_network(error: reqwest::Error) -> ExchangeFailure { + if error.is_timeout() { + return ExchangeFailure::Timeout; + } + let phase = if error.is_connect() { + NetworkPhase::Connect + } else if error.is_request() { + NetworkPhase::Request + } else if error.is_body() || error.is_decode() { + NetworkPhase::Body + } else { + NetworkPhase::Other + }; + ExchangeFailure::Network { + phase, + source: SanitizedReqwestError::new(error), + } +} + +/// Reads the body chunk by chunk, stopping before the cap is exceeded. +pub(super) async fn collect( + mut response: reqwest::Response, + limit: ResponseCap, +) -> Result { + let status = response.status(); + let headers = std::mem::take(response.headers_mut()); + let too_large = || ExchangeFailure::ResponseTooLarge { status, limit }; + if let Some(declared) = response.content_length() + && u64::try_from(limit.bytes()).is_ok_and(|cap| declared > cap) + { + return Err(too_large()); + } + let mut body = Vec::new(); + while let Some(chunk) = response.chunk().await.map_err(classify_network)? { + if body.len() + chunk.len() > limit.bytes() { + return Err(too_large()); + } + body.extend_from_slice(&chunk); + } + Ok(RawHttpResponse { + status, + headers, + body, + }) +} + +/// A short, display-safe description of a failed non-GraphQL request. +fn bounded_failure(prefix: &str, failure: ExchangeFailure, deadline: Deadline) -> Error { + match failure { + ExchangeFailure::ResponseTooLarge { limit, .. } => Error::new(format!( + "{prefix}: response exceeds the {} byte limit", + limit.bytes() + )), + ExchangeFailure::Timeout => Error::new(format!( + "{prefix}: did not complete within {:?}", + deadline.duration() + )), + ExchangeFailure::Network { source, .. } => { + Error::new(format!("{prefix}: {}", source.root_message())).with_source(source) + } + } +} + +/// Whether a download from `url` carries the API key: only for exactly +/// `https://uploads.linear.app` (default port, no user name or password), +/// where Linear serves private uploads. +fn authenticates(url: &Url) -> bool { + url.scheme() == "https" + && url.host_str() == Some("uploads.linear.app") + && url.port().is_none() + && url.username().is_empty() + && url.password().is_none() +} + +impl LinearClient { + /// Downloads an image referenced from Markdown. + pub async fn download_markdown_image(&self, url: &str) -> Result, Error> { + self.download(url, "Failed to download image").await + } + + /// Downloads an issue attachment. + pub async fn download_issue_attachment(&self, url: &str) -> Result, Error> { + self.download(url, "Failed to download").await + } + + /// GETs an `http(s)` URL within the download deadline and size cap. The + /// API key is sent only to Linear's private upload origin (see + /// [`authenticates`]); a redirect elsewhere drops it, and no redirect may + /// leave HTTPS. + async fn download(&self, original: &str, failure_prefix: &str) -> Result, Error> { + let url = Url::parse(original) + .map_err(|error| Error::new(format!("Invalid URL: '{original}'")).with_source(error))?; + if !matches!(url.scheme(), "http" | "https") { + return Err(Error::new(format!( + "{failure_prefix}: unsupported URL scheme '{}'", + url.scheme() + ))); + } + let authenticated = authenticates(&url); + let mut request = self + .http + .get(url) + .timeout(self.download_deadline.duration()); + if authenticated { + request = request.header(AUTHORIZATION, self.api_key.header_value()); + } + let failed = |failure| bounded_failure(failure_prefix, failure, self.download_deadline); + let response = request + .send() + .await + .map_err(|error| failed(classify_network(error)))?; + let status = response.status(); + if !status.is_success() { + return Err(Error::new(format!("{failure_prefix}: {status}"))); + } + let response = collect(response, self.max_download_bytes) + .await + .map_err(failed)?; + Ok(response.body) + } + + /// POSTs a raw GraphQL body for the `api` command and returns the status + /// and body text unclassified, within the API deadline and size cap. + pub async fn fetch_api(&self, body: String) -> Result<(u16, String), Error> { + let response = self + .http + .post(self.endpoint.url.clone()) + .timeout(self.deadline.duration()) + .header(AUTHORIZATION, self.api_key.header_value()) + .header(CONTENT_TYPE, HeaderValue::from_static(CONTENT_TYPE_VALUE)) + .body(body) + .send() + .await + .map_err(|error| { + bounded_failure( + &format!("Request to {} failed", self.endpoint), + classify_network(error), + self.deadline, + ) + })?; + let response = collect(response, self.max_response_bytes) + .await + .map_err(|failure| { + bounded_failure( + "Failed to read API response; the request was sent and may have taken effect", + failure, + self.deadline, + ) + })?; + Ok(( + response.status.as_u16(), + String::from_utf8_lossy(&response.body).into_owned(), + )) + } + + /// PUTs a file to a pre-signed upload URL with exactly the headers Linear + /// returned for it; the API key is never sent. There is no total deadline + /// because uploads can be large. + pub async fn put_signed( + &self, + url: &str, + headers: HeaderMap, + body: Vec, + ) -> Result<(), Error> { + let invalid = || Error::new("Invalid signed upload URL"); + let mut url = Url::parse(url).map_err(|_| invalid())?; + url.set_fragment(None); + let target = EndpointUrl::from_url(url).map_err(|_| invalid())?; + let failed = |reason: String| { + Error::new(format!( + "Signed upload to {target} failed: {reason}; the object may already be \ + stored remotely; no comment or attachment was created" + )) + }; + let response = self + .http + .put(target.url.clone()) + .headers(headers) + .body(body) + .send() + .await + .map_err(|error| { + let error = SanitizedReqwestError::new(error); + failed(error.root_message()).with_source(error) + })?; + if response.status().is_success() { + return Ok(()); + } + let response = collect(response, self.max_response_bytes) + .await + .map_err(|failure| match failure { + ExchangeFailure::ResponseTooLarge { limit, .. } => { + failed(format!("response exceeded {} bytes", limit.bytes())) + } + ExchangeFailure::Timeout => failed("timed out".to_owned()), + ExchangeFailure::Network { source, .. } => { + failed(source.root_message()).with_source(source) + } + })?; + Err(Error::new(format!( + "Failed to upload file: {} - {}", + response.status, + String::from_utf8_lossy(&response.body) + ))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn only_the_exact_https_upload_origin_gets_the_api_key() { + let authenticated = |url: &str| authenticates(&Url::parse(url).expect("URL")); + assert!(authenticated("https://uploads.linear.app/org/file.png")); + assert!(authenticated("https://uploads.linear.app:443/org/file.png")); + for url in [ + "http://uploads.linear.app/org/file.png", + "http://uploads.linear.app:443/org/file.png", + "https://uploads.linear.app:8443/org/file.png", + "https://user@uploads.linear.app/org/file.png", + "https://:secret@uploads.linear.app/org/file.png", + "https://uploads.linear.app.example.com/file.png", + "https://public.linear.app/file.png", + ] { + assert!(!authenticated(url), "{url}"); + } + } +} diff --git a/crates/linear-cli/src/client/tests.rs b/crates/linear-cli/src/client/tests.rs new file mode 100644 index 000000000..7ff73e880 --- /dev/null +++ b/crates/linear-cli/src/client/tests.rs @@ -0,0 +1,798 @@ +//! Client behavior against loopback servers: request bytes, response +//! classification, size caps, deadlines, cancellation and redaction. + +mod download; +pub(crate) mod server; + +use std::num::NonZeroUsize; +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; + +use cynic::QueryBuilder; +use reqwest::StatusCode; +use reqwest::header::{HeaderMap, HeaderValue}; +use serde_json::{Map, Value, json}; + +use self::server::{Reply, Server}; +use super::config::{ApiKeyError, EndpointUrlError, USER_AGENT_VALUE}; +use super::error::NetworkPhase; +use super::{ + ApiKey, CONTENT_TYPE_VALUE, ClientBuildError, ClientConfig, Deadline, EndpointUrl, + HttpBodyShape, LinearClient, RawHttpResponse, RequestError, ResponseCap, classify_typed, +}; +use crate::graphql::envelope::{GraphQlRequest, ResponseError}; +use crate::graphql::operations::team::{GetTeams, GetTeamsVariables}; + +const FAKE_KEY: &str = "lin_api_fake"; + +fn tls_fixture(name: &str) -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/tls") + .join(name) +} + +fn config(deadline: Duration, cap: usize) -> ClientConfig { + ClientConfig { + deadline: Deadline(deadline), + max_response_bytes: ResponseCap(NonZeroUsize::new(cap).expect("nonzero cap")), + ..ClientConfig::default() + } +} + +fn client_for(endpoint: &str, config: ClientConfig) -> LinearClient { + LinearClient::new( + EndpointUrl::parse(endpoint).expect("endpoint"), + ApiKey::new(FAKE_KEY).expect("fake key"), + config, + ) + .expect("client") +} + +/// A client for `server` with a generous deadline and cap. +pub(crate) fn client_to(server: &Server) -> LinearClient { + client_for( + &server.url("/graphql"), + config(Duration::from_secs(10), 1024 * 1024), + ) +} + +fn raw(document: &str, variables: Option>) -> GraphQlRequest { + GraphQlRequest { + query: document.to_owned(), + variables: variables.map(Value::Object), + operation_name: None, + } +} + +fn teams_variables() -> GetTeamsVariables { + GetTeamsVariables { + filter: None, + first: Some(100), + after: None, + } +} + +fn teams_request() -> GraphQlRequest { + GraphQlRequest::new(GetTeams::build(teams_variables())).expect("variables serialize") +} + +fn teams_data() -> Value { + json!({"teams": { + "nodes": [{ + "id": "team-1", + "name": "Engineering", + "key": "ENG", + "description": null, + "icon": null, + "color": "#0000ff", + "cyclesEnabled": false, + "createdAt": "2026-01-01T00:00:00.000Z", + "updatedAt": "2026-01-02T00:00:00.000Z", + "archivedAt": null, + "organization": {"id": "org-1", "name": "Acme"} + }], + "pageInfo": {"hasNextPage": false, "endCursor": null} + }}) +} + +#[test] +fn endpoint_url_keeps_path_and_query_but_displays_only_the_origin() { + assert_eq!( + EndpointUrl::parse("https://api.linear.app/graphql?sig=SECRET#frag") + .expect_err("fragment rejected"), + EndpointUrlError::Fragment + ); + let endpoint = EndpointUrl::parse("https://api.linear.app/graphql?sig=SECRET").expect("ok"); + assert_eq!( + endpoint.url().as_str(), + "https://api.linear.app/graphql?sig=SECRET" + ); + assert_eq!(endpoint.to_string(), "https://api.linear.app"); + assert_eq!( + format!("{endpoint:?}"), + "EndpointUrl(\"https://api.linear.app\")" + ); + let loopback = EndpointUrl::parse("http://127.0.0.1:4321/graphql").expect("ok"); + assert_eq!(loopback.to_string(), "http://127.0.0.1:4321"); + assert_eq!( + EndpointUrl::parse("ftp://api.linear.app/graphql").expect_err("scheme"), + EndpointUrlError::Scheme("ftp".to_owned()) + ); + assert_eq!( + EndpointUrl::parse("https://user:pw@api.linear.app/graphql").expect_err("credentials"), + EndpointUrlError::Credentials + ); + assert!(matches!( + EndpointUrl::parse("not a url").expect_err("invalid"), + EndpointUrlError::Invalid(_) + )); +} + +#[test] +fn api_key_accepts_visible_ascii_with_spaces_and_redacts_itself() { + let key = ApiKey::new("lin_api_abc DEF!~").expect("spaces allowed"); + assert_eq!(format!("{key:?}"), "ApiKey()"); + assert_eq!(key.to_string(), ""); + assert_eq!(ApiKey::new("").expect_err("empty"), ApiKeyError::Empty); + assert_eq!( + ApiKey::new(" \t\r\n ").expect_err("only whitespace"), + ApiKeyError::Empty + ); + ApiKey::new(" \t\r\nlin_api_fake\n ").expect("surrounding whitespace is trimmed"); + for (text, index) in [ + ("lin_api\r\nX-Injected: 1", 7), + ("lin_api\tx", 7), + // Indexes count from the first byte after trimmed whitespace. + (" \tab\tc", 2), + ("\u{1b}[31m", 0), + ("lin_api_é", 8), + ("lin_api\u{7f}", 7), + ] { + assert_eq!( + ApiKey::new(text).expect_err("rejected"), + ApiKeyError::InvalidByte { index }, + "{text:?}" + ); + } +} + +#[test] +fn ca_bundle_must_be_a_readable_pem_file_with_certificates() { + let dir = tempfile::TempDir::new().expect("temp dir"); + let build = |path: PathBuf| { + LinearClient::new( + EndpointUrl::parse("https://uploads.linear.app/x").expect("endpoint"), + ApiKey::new(FAKE_KEY).expect("fake key"), + ClientConfig { + ca_bundle: Some(path), + ..ClientConfig::default() + }, + ) + }; + build(tls_fixture("test-ca.pem")).expect("valid bundle"); + assert!(matches!( + build(dir.path().join("missing.pem")).expect_err("missing"), + ClientBuildError::CaRead { .. } + )); + assert!(matches!( + build(dir.path().to_owned()).expect_err("directory"), + ClientBuildError::CaRead { .. } + )); + for (name, contents) in [("empty.pem", " \n"), ("garbage.pem", "not a certificate")] { + let path = dir.path().join(name); + std::fs::write(&path, contents).expect("write"); + assert!(matches!( + build(path).expect_err(name), + ClientBuildError::CaEmpty { .. } + )); + } + let bogus = dir.path().join("bogus.pem"); + std::fs::write( + &bogus, + "-----BEGIN CERTIFICATE-----\nAAAA\n-----END CERTIFICATE-----\n", + ) + .expect("write"); + let error = build(bogus).expect_err("bad DER"); + assert!( + matches!(error, ClientBuildError::CaInvalid { .. }), + "{error:?}" + ); + let app = crate::error::Error::from(error); + assert!( + app.to_string().starts_with("SSL_CERT_FILE: CA bundle "), + "{app}" + ); +} + +#[tokio::test(flavor = "current_thread")] +async fn network_failures_never_expose_the_path_query_or_api_key() { + // Nothing listens on port 1, so the connection is refused. + let endpoint = EndpointUrl::parse("http://127.0.0.1:1/graphql?signature=SIGNED-SECRET-TOKEN") + .expect("endpoint"); + let key = ApiKey::new("lin_api_SECRET_KEY_VALUE").expect("key"); + let client = + LinearClient::new(endpoint, key, config(Duration::from_secs(5), 1024)).expect("client"); + assert!(!format!("{client:?}").contains("SECRET")); + let failure = client + .send_request(&raw("{ viewer { id } }", None)) + .await + .expect_err("connection refused"); + let RequestError::Network { origin, phase, .. } = &failure else { + panic!("expected Network, got {failure:?}"); + }; + assert_eq!(origin, "http://127.0.0.1:1"); + // Whether reqwest reports this as a connection or a request failure + // varies; both get the same redaction. + let action = match phase { + NetworkPhase::Connect => "connection to", + NetworkPhase::Request => "request to", + NetworkPhase::Body | NetworkPhase::Other => { + panic!("expected connect/request failure, got {failure:?}"); + } + }; + let display = failure.to_string(); + assert!( + display.starts_with(&format!("{action} http://127.0.0.1:1 failed: ")), + "{display}" + ); + let mut chain = Vec::new(); + let mut source: Option<&(dyn std::error::Error + 'static)> = + std::error::Error::source(&failure); + while let Some(error) = source { + chain.push(error.to_string()); + source = error.source(); + } + assert!( + chain.len() >= 2, + "reqwest error and its io source: {chain:?}" + ); + for text in [display, format!("{failure:?}")].iter().chain(&chain) { + for secret in ["SIGNED-SECRET-TOKEN", "signature", "SECRET_KEY", "/graphql"] { + assert!(!text.contains(secret), "{secret} leaked into {text}"); + } + } + let app: crate::error::Error = failure.into(); + assert!(std::error::Error::source(&app).is_some()); +} + +#[tokio::test(flavor = "current_thread")] +async fn request_carries_exact_headers_and_envelope_bytes() { + let server = Server::start(vec![Reply::status(200, "application/json", "")]); + let mut variables = Map::new(); + variables.insert("after".to_owned(), Value::Null); + let request = GraphQlRequest { + operation_name: Some("Q".to_owned()), + ..raw("query($after: String) { x }", Some(variables)) + }; + client_to(&server) + .send_request(&request) + .await + .expect("empty 200"); + let requests = server.finish(); + let [request] = requests.as_slice() else { + panic!("{requests:?}"); + }; + assert_eq!( + (request.method.as_str(), request.path.as_str()), + ("POST", "/graphql") + ); + assert_eq!(request.header("authorization"), Some(FAKE_KEY)); + assert_eq!(request.headers_named("authorization"), 1); + assert_eq!(request.header("user-agent"), Some(USER_AGENT_VALUE)); + assert_eq!(request.header("content-type"), Some(CONTENT_TYPE_VALUE)); + assert_eq!(request.header("referer"), None); + assert_eq!( + String::from_utf8_lossy(&request.body), + r#"{"query":"query($after: String) { x }","variables":{"after":null},"operationName":"Q"}"# + ); +} + +#[tokio::test(flavor = "current_thread")] +async fn declared_oversized_body_is_rejected_before_reading() { + let server = Server::start(vec![Reply::status( + 200, + "application/json", + vec![b'x'; 4096], + )]); + let failure = client_for( + &server.url("/graphql"), + config(Duration::from_secs(5), 1024), + ) + .send_request(&raw("{ x }", None)) + .await + .expect_err("too large"); + assert!( + matches!(&failure, RequestError::ResponseTooLarge { status, limit } + if status.as_u16() == 200 && limit.bytes() == 1024), + "{failure:?}" + ); + assert_eq!( + failure.to_string(), + "response body exceeds the 1024 byte limit (HTTP 200 OK)" + ); + server.finish(); +} + +#[tokio::test(flavor = "current_thread")] +async fn body_exactly_at_the_cap_is_kept_intact() { + let server = Server::start(vec![Reply::status( + 502, + "application/json", + vec![b'x'; 1024], + )]); + let response = client_for( + &server.url("/graphql"), + config(Duration::from_secs(5), 1024), + ) + .send_request(&raw("{ x }", None)) + .await + .expect("at cap"); + assert_eq!(response.status.as_u16(), 502); + assert_eq!(response.body, vec![b'x'; 1024]); + assert_eq!( + format!("{response:?}"), + "RawHttpResponse { status: 502, headers: <3 headers>, body: <1024 bytes> }" + ); + server.finish(); +} + +#[tokio::test(flavor = "current_thread")] +async fn endless_chunked_body_stops_at_the_cap() { + let server = Server::start(vec![Reply::EndlessChunks]); + let client = client_for( + &server.url("/graphql"), + config(Duration::from_secs(120), 2048), + ); + let started = Instant::now(); + let failure = client + .send_request(&raw("{ x }", None)) + .await + .expect_err("too large"); + assert!( + matches!(failure, RequestError::ResponseTooLarge { .. }), + "{failure:?}" + ); + assert!( + started.elapsed() < Duration::from_secs(30), + "stopped before the deadline" + ); + drop(client); + assert_eq!(server.finish().len(), 1); +} + +#[tokio::test(flavor = "current_thread")] +async fn silent_server_hits_the_total_deadline() { + let server = Server::start(vec![Reply::Silent]); + let started = Instant::now(); + let failure = client_for( + &server.url("/graphql"), + config(Duration::from_millis(300), 1024), + ) + .send_request(&raw("{ x }", None)) + .await + .expect_err("timeout"); + let elapsed = started.elapsed(); + assert!( + matches!(failure, RequestError::Timeout { .. }), + "{failure:?}" + ); + assert!( + elapsed >= Duration::from_millis(250) && elapsed < Duration::from_secs(30), + "{elapsed:?}" + ); + assert_eq!( + failure.to_string(), + format!( + "request to http://127.0.0.1:{} did not complete within 300ms", + server.port() + ) + ); + server.finish(); +} + +#[tokio::test(flavor = "current_thread")] +async fn stalled_body_hits_the_total_deadline_without_partial_data() { + let server = Server::start(vec![Reply::Stall]); + let started = Instant::now(); + let failure = client_for( + &server.url("/graphql"), + config(Duration::from_millis(300), 4096), + ) + .send_request(&raw("{ x }", None)) + .await + .expect_err("timeout"); + assert!( + matches!(failure, RequestError::Timeout { .. }), + "{failure:?}" + ); + assert!(started.elapsed() < Duration::from_secs(30)); + server.finish(); +} + +#[test] +fn cancelled_request_completes_promptly_and_releases_the_connection() { + let server = Server::start(vec![Reply::Stall]); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("runtime"); + let client = client_for( + &server.url("/graphql"), + config(Duration::from_secs(120), 4096), + ); + let started = Instant::now(); + runtime.block_on(async { + let cancelled = tokio::time::timeout( + Duration::from_millis(150), + client.send_request(&raw("{ x }", None)), + ) + .await; + assert!( + cancelled.is_err(), + "outer cancellation wins over the 120 s deadline" + ); + assert!( + started.elapsed() < Duration::from_secs(30), + "cancellation returned promptly" + ); + drop(client); + // A current-thread runtime only drives the connection's shutdown + // while it runs, so wait for the release here. + let window = Instant::now(); + while window.elapsed() < Duration::from_secs(30) && !server.is_done() { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }); + drop(runtime); + let window = Instant::now(); + while window.elapsed() < Duration::from_secs(30) && !server.is_done() { + std::thread::sleep(Duration::from_millis(10)); + } + let requests = server.finish(); + assert!( + requests + .first() + .is_some_and(|request| request.client_closed), + "server saw the client close the connection: {requests:?}" + ); +} + +/// Response headers can carry session cookies or trace ids; neither a +/// `GraphQl` nor an `Http` failure may print them through `Debug`. +#[test] +fn failure_debug_prints_header_counts_not_header_values() { + let secret_headers = || { + let mut headers = HeaderMap::new(); + headers.insert("content-type", HeaderValue::from_static("application/json")); + headers.insert( + "set-cookie", + HeaderValue::from_static("session=COOKIE-SECRET-VALUE; HttpOnly"), + ); + headers.insert( + "x-linear-trace", + HeaderValue::from_static("TRACE-SECRET-VALUE"), + ); + headers + }; + let leaks = [ + "COOKIE-SECRET-VALUE", + "TRACE-SECRET-VALUE", + "HttpOnly", + "set-cookie", + "x-linear-trace", + ]; + + let failure = classify_typed::(RawHttpResponse { + status: StatusCode::UNAUTHORIZED, + headers: secret_headers(), + body: br#"{"errors":[{"message":"Authentication required"}]}"#.to_vec(), + }) + .expect_err("errors array"); + let debug = format!("{failure:?}"); + assert!( + debug.starts_with("GraphQl { status: 401, headers: <3 headers>, errors: ["), + "{debug}" + ); + assert!(debug.contains("Authentication required"), "{debug}"); + assert!(debug.ends_with("partial_data: false }"), "{debug}"); + for leak in leaks { + assert!(!debug.contains(leak), "{leak} leaked into {debug}"); + } + + let failure = classify_typed::(RawHttpResponse { + status: StatusCode::BAD_GATEWAY, + headers: secret_headers(), + body: b"".to_vec(), + }) + .expect_err("non-2xx without errors"); + let debug = format!("{failure:?}"); + assert!( + debug.starts_with( + "Http { response: RawHttpResponse { status: 502, headers: <3 headers>, body: <6 bytes> }, body: Unusable(" + ), + "{debug}" + ); + for leak in leaks { + assert!(!debug.contains(leak), "{leak} leaked into {debug}"); + } +} + +#[tokio::test(flavor = "current_thread")] +async fn graphql_errors_classify_ahead_of_http_status() { + let internal = json!({ + "message": "Something went wrong", + "extensions": { + "code": "INTERNAL_ERROR", + "userPresentableMessage": "Something went wrong. Please try again." + } + }); + let unauthenticated = r#"{"errors":[{"message":"Authentication required, not authenticated","extensions":{"code":"AUTHENTICATION_ERROR"}}]}"#; + let server = Server::start(vec![ + Reply::json(&json!({"data": null, "errors": [internal]})), + Reply::json(&json!({"data": teams_data(), "errors": [internal]})), + Reply::status( + 400, + "application/json", + json!({"data": null, "errors": [{ + "message": "Argument Validation Error", + "path": ["teams"], + "locations": [{"line": 2, "column": 3}], + "extensions": {"userPresentableMessage": "The request was invalid."} + }]}) + .to_string(), + ), + Reply::status(401, "application/json", unauthenticated), + ]); + let client = client_to(&server); + let request = teams_request(); + + let failure = client + .query::(teams_variables()) + .await + .expect_err("errors only"); + let RequestError::GraphQl { + status, + errors, + partial_data, + .. + } = &failure + else { + panic!("{failure:?}"); + }; + assert_eq!(status.as_u16(), 200); + assert!(!partial_data); + assert_eq!(errors[0].message, "Something went wrong"); + assert_eq!( + failure.to_string(), + "Something went wrong. Please try again." + ); + + let failure = client + .query::(teams_variables()) + .await + .expect_err("partial data"); + assert!( + matches!( + &failure, + RequestError::GraphQl { status, partial_data: true, .. } if status.as_u16() == 200 + ), + "partial data is never returned as success: {failure:?}" + ); + + let failure = client + .query::(teams_variables()) + .await + .expect_err("400 errors"); + let RequestError::GraphQl { + status, + errors, + headers, + .. + } = &failure + else { + panic!("{failure:?}"); + }; + assert_eq!(status.as_u16(), 400); + assert_eq!( + headers.get("content-type").and_then(|v| v.to_str().ok()), + Some("application/json") + ); + assert_eq!(errors[0].path.as_ref().map(Vec::len), Some(1)); + assert!(errors[0].locations.is_some()); + assert_eq!(failure.to_string(), "The request was invalid."); + + let raw = client + .send_request(&request) + .await + .expect("401 bytes captured"); + assert_eq!(raw.status.as_u16(), 401); + assert_eq!(raw.body, unauthenticated.as_bytes()); + let failure = classify_typed::(raw).expect_err("401 with errors"); + assert!( + matches!(&failure, RequestError::GraphQl { status, errors, .. } + if status.as_u16() == 401 + && errors[0].message == "Authentication required, not authenticated"), + "{failure:?}" + ); + + assert_eq!(server.finish().len(), 4); +} + +#[tokio::test(flavor = "current_thread")] +async fn http_failures_keep_raw_bytes_and_bad_bodies_classify_separately() { + let data = json!({"data": teams_data()}).to_string(); + let server = Server::start(vec![ + Reply::status(429, "text/plain", "Too Many Requests\n").header("retry-after", "7"), + Reply::status(502, "text/html", "bad gateway"), + Reply::status(500, "application/json", data), + Reply::status(200, "text/html", "maintenance"), + Reply::status(200, "application/json", r#"{"data":"#), + Reply::status(200, "application/json", ""), + Reply::status(200, "application/json", r#"{"data":null}"#), + Reply::status(200, "application/json", r#"{"data":{"teams":"nope"}}"#), + ]); + let client = client_to(&server); + let next = || client.query::(teams_variables()); + + let failure = next().await.expect_err("429"); + let RequestError::Http { response, body } = &failure else { + panic!("{failure:?}"); + }; + assert_eq!(response.status.as_u16(), 429); + assert_eq!(response.body, b"Too Many Requests\n"); + assert_eq!( + response + .headers + .get("retry-after") + .and_then(|v| v.to_str().ok()), + Some("7") + ); + assert!( + matches!(body, HttpBodyShape::Unusable(ResponseError::NotJson { .. })), + "{body:?}" + ); + assert_eq!( + failure.to_string(), + "unexpected HTTP status 429 Too Many Requests" + ); + let debug = format!("{failure:?}"); + assert!( + !debug.contains("Too Many Requests\\n"), + "body bytes stay out of Debug: {debug}" + ); + + let failure = next().await.expect_err("502"); + assert!( + matches!(&failure, RequestError::Http { response, body: HttpBodyShape::Unusable(ResponseError::NotJson { .. }) } + if response.status.as_u16() == 502 + && response.body == b"bad gateway"), + "{failure:?}" + ); + + let failure = next().await.expect_err("500 with data"); + assert!( + matches!( + &failure, + RequestError::Http { response, body: HttpBodyShape::Data } + if response.status.as_u16() == 500 + ), + "valid data under 500 is still an HTTP failure: {failure:?}" + ); + + let failure = next().await.expect_err("HTML under 200"); + assert!( + matches!( + &failure, + RequestError::Response(ResponseError::NotJson { .. }) + ), + "{failure:?}" + ); + assert_eq!( + failure.to_string(), + "Linear returned a non-JSON response (HTTP 200 OK, content type text/html)" + ); + + for case in ["truncated", "empty"] { + let failure = next().await.expect_err(case); + assert!( + matches!( + &failure, + RequestError::Response(ResponseError::MalformedJson(_)) + ), + "{case}: {failure:?}" + ); + } + + let failure = next().await.expect_err("data null"); + assert!( + matches!(&failure, RequestError::Response(ResponseError::MissingData)), + "{failure:?}" + ); + + let failure = next().await.expect_err("wrong shape"); + assert!( + matches!( + &failure, + RequestError::Response(ResponseError::UnexpectedShape(_)) + ), + "{failure:?}" + ); + + assert_eq!(server.finish().len(), 8, "no retries"); +} + +#[tokio::test(flavor = "current_thread")] +async fn temporary_redirect_replays_the_request_at_the_new_location() { + let server = Server::start(vec![ + Reply::status(307, "text/plain", "").header("location", "/moved"), + Reply::json(&json!({"data": teams_data()})), + ]); + let teams: GetTeams = client_to(&server) + .query(teams_variables()) + .await + .expect("redirect followed"); + assert_eq!(teams.teams.nodes.len(), 1); + let requests = server.finish(); + let [first, second] = requests.as_slice() else { + panic!("{requests:?}"); + }; + assert_eq!( + (second.method.as_str(), second.path.as_str()), + ("POST", "/moved") + ); + assert_eq!(second.body, first.body); + assert_eq!(second.header("authorization"), Some(FAKE_KEY)); +} + +#[tokio::test(flavor = "current_thread")] +async fn raw_document_without_variables_returns_exact_bytes() { + let server = Server::start(vec![Reply::json( + &json!({"data": {"viewer": {"id": "user-1"}}}), + )]); + let response = client_to(&server) + .send_request(&raw("{ viewer { id } }", None)) + .await + .expect("200"); + assert_eq!(response.status.as_u16(), 200); + assert_eq!(response.body, br#"{"data":{"viewer":{"id":"user-1"}}}"#); + let value: Value = classify_typed(response).expect("typed as Value"); + assert_eq!(value, json!({"viewer": {"id": "user-1"}})); + let requests = server.finish(); + assert_eq!( + requests.first().map(|request| request.body.as_slice()), + Some(br#"{"query":"{ viewer { id } }"}"#.as_slice()) + ); +} + +#[tokio::test(flavor = "current_thread")] +async fn raw_api_requests_stop_at_the_api_cap_and_deadline() { + let server = Server::start(vec![ + Reply::status(200, "application/json", vec![b' '; 8192]), + Reply::Stall, + ]); + let client = client_for( + &server.url("/graphql"), + config(Duration::from_millis(300), 4096), + ); + let error = client + .fetch_api("{}".to_owned()) + .await + .expect_err("too large"); + assert_eq!( + error.message(), + "Failed to read API response; the request was sent and may have taken effect: \ + response exceeds the 4096 byte limit" + ); + let started = Instant::now(); + let error = client + .fetch_api("{}".to_owned()) + .await + .expect_err("timeout"); + assert!( + error.message().ends_with("did not complete within 300ms"), + "{}", + error.message() + ); + assert!(started.elapsed() < Duration::from_secs(30)); + server.finish(); +} diff --git a/crates/linear-cli/src/client/tests/download.rs b/crates/linear-cli/src/client/tests/download.rs new file mode 100644 index 000000000..8526a9e8e --- /dev/null +++ b/crates/linear-cli/src/client/tests/download.rs @@ -0,0 +1,217 @@ +//! Image and attachment downloads, which have their own deadline and cap +//! instead of the GraphQL ones. + +use std::num::NonZeroUsize; +use std::time::{Duration, Instant}; + +use super::server::{Reply, Server}; +use super::{USER_AGENT_VALUE, client_for, config}; +use crate::client::{ClientConfig, Deadline, LinearClient, ResponseCap}; + +/// A client whose GraphQL deadline and cap would fail any download. +fn strict_client() -> LinearClient { + client_for( + "http://127.0.0.1:9/graphql", + config(Duration::from_millis(10), 1), + ) +} + +fn bytes(body: &[u8]) -> Reply { + Reply::status(200, "application/octet-stream", body) +} + +#[tokio::test(flavor = "current_thread")] +async fn downloads_ignore_the_graphql_deadline_and_cap_and_send_no_api_key() { + let server = Server::start(vec![ + bytes(&[0, 255, 7]).delayed(Duration::from_millis(50)), + bytes(&[0, 255, 7]).delayed(Duration::from_millis(50)), + ]); + let url = server.url("/image"); + let client = strict_client(); + assert_eq!( + client.download_markdown_image(&url).await.expect("image"), + [0, 255, 7] + ); + assert_eq!( + client + .download_issue_attachment(&url) + .await + .expect("attachment"), + [0, 255, 7] + ); + for request in server.finish() { + assert_eq!( + (request.method.as_str(), request.path.as_str()), + ("GET", "/image") + ); + assert_eq!(request.header("user-agent"), Some(USER_AGENT_VALUE)); + assert!( + request + .header("accept-encoding") + .is_some_and(|value| value.contains("gzip")) + ); + assert_eq!(request.header("authorization"), None); + } +} + +#[tokio::test(flavor = "current_thread")] +async fn failures_report_the_http_status_with_a_per_kind_prefix() { + let server = Server::start(vec![ + Reply::status(500, "text/plain", ""), + Reply::status(500, "text/plain", ""), + ]); + let url = server.url("/image"); + let client = strict_client(); + assert_eq!( + client + .download_markdown_image(&url) + .await + .expect_err("500") + .message(), + "Failed to download image: 500 Internal Server Error" + ); + assert_eq!( + client + .download_issue_attachment(&url) + .await + .expect_err("500") + .message(), + "Failed to download: 500 Internal Server Error" + ); + server.finish(); +} + +#[tokio::test(flavor = "current_thread")] +async fn only_http_urls_are_downloaded() { + let client = strict_client(); + for (url, scheme) in [ + ("data:application/octet-stream;base64,AP8H", "data"), + ("file:///work/local.bin", "file"), + ] { + assert_eq!( + client + .download_markdown_image(url) + .await + .expect_err(url) + .message(), + format!("Failed to download image: unsupported URL scheme '{scheme}'") + ); + } + for url in ["foo.png", "http://[bad"] { + assert_eq!( + client + .download_markdown_image(url) + .await + .expect_err(url) + .message(), + format!("Invalid URL: '{url}'") + ); + } +} + +#[tokio::test(flavor = "current_thread")] +async fn url_userinfo_is_sent_as_basic_auth_instead_of_the_api_key() { + let server = Server::start(vec![bytes(b"FAKE")]); + let url = server + .url("/image") + .replacen("http://", "http://fake-user:fake-password@", 1); + assert_eq!( + strict_client() + .download_markdown_image(&url) + .await + .expect("image"), + b"FAKE" + ); + let requests = server.finish(); + let [request] = requests.as_slice() else { + panic!("{requests:?}"); + }; + assert_eq!(request.headers_named("authorization"), 1); + assert_eq!( + request.header("authorization"), + Some("Basic ZmFrZS11c2VyOmZha2UtcGFzc3dvcmQ=") + ); +} + +#[tokio::test(flavor = "current_thread")] +async fn downloads_follow_twenty_redirects_and_refuse_the_twenty_first() { + let hops = |redirects: usize| { + (0..=redirects) + .map(|index| { + if index < redirects { + Reply::status(302, "text/plain", "") + .header("location", &format!("/hop/{}", index + 1)) + } else { + bytes(b"FAKE") + } + }) + .collect::>() + }; + let client = strict_client(); + + let server = Server::start(hops(20)); + assert_eq!( + client + .download_issue_attachment(&server.url("/hop/0")) + .await + .expect("twenty redirects"), + b"FAKE" + ); + let requests = server.finish(); + assert_eq!(requests.len(), 21); + assert!( + requests + .iter() + .enumerate() + .all(|(index, request)| request.path == format!("/hop/{index}")) + ); + + let server = Server::start(hops(21)); + let message = client + .download_markdown_image(&server.url("/hop/0")) + .await + .expect_err("too many redirects") + .message() + .to_owned(); + assert!(message.starts_with("Failed to download image"), "{message}"); + assert!(message.contains("redirect"), "{message}"); + assert_eq!(server.finish().len(), 21); +} + +/// A client whose downloads stop after `cap` bytes or `deadline`. +fn download_client(deadline: Duration, cap: usize) -> LinearClient { + client_for( + "http://127.0.0.1:9/graphql", + ClientConfig { + download_deadline: Deadline(deadline), + max_download_bytes: ResponseCap(NonZeroUsize::new(cap).expect("nonzero cap")), + ..ClientConfig::default() + }, + ) +} + +#[tokio::test(flavor = "current_thread")] +async fn downloads_stop_at_the_download_cap_and_deadline() { + let server = Server::start(vec![bytes(&[7; 8192]), Reply::Stall]); + let url = server.url("/image"); + let client = download_client(Duration::from_millis(300), 4096); + let error = client + .download_markdown_image(&url) + .await + .expect_err("too large"); + assert_eq!( + error.message(), + "Failed to download image: response exceeds the 4096 byte limit" + ); + let started = Instant::now(); + let error = client + .download_issue_attachment(&url) + .await + .expect_err("timeout"); + assert_eq!( + error.message(), + "Failed to download: did not complete within 300ms" + ); + assert!(started.elapsed() < Duration::from_secs(30)); + server.finish(); +} diff --git a/crates/linear-cli/src/client/tests/server.rs b/crates/linear-cli/src/client/tests/server.rs new file mode 100644 index 000000000..a6987e381 --- /dev/null +++ b/crates/linear-cli/src/client/tests/server.rs @@ -0,0 +1,288 @@ +//! A loopback HTTP server that answers each connection with the next scripted +//! [`Reply`] and records what the client sent. + +use std::io::{ErrorKind, Read, Write}; +use std::net::{TcpListener, TcpStream}; +use std::sync::mpsc::{self, RecvTimeoutError}; +use std::thread::{self, JoinHandle}; +use std::time::{Duration, Instant}; + +/// What the server does with one connection. +pub(crate) enum Reply { + /// A complete response with `Content-Length`, then close. + Http { + status: u16, + headers: Vec<(&'static str, String)>, + body: Vec, + delay: Duration, + }, + /// Headers and a body prefix, then hold the connection open. + Stall, + /// Read the request and never answer. + Silent, + /// A chunked body that never ends. + EndlessChunks, +} + +impl Reply { + pub(super) fn status(status: u16, content_type: &str, body: impl Into>) -> Self { + Self::Http { + status, + headers: vec![("content-type", content_type.to_owned())], + body: body.into(), + delay: Duration::ZERO, + } + } + + pub(crate) fn json(body: &serde_json::Value) -> Self { + Self::status(200, "application/json", body.to_string()) + } + + pub(super) fn header(mut self, name: &'static str, value: &str) -> Self { + if let Self::Http { headers, .. } = &mut self { + headers.push((name, value.to_owned())); + } + self + } + + pub(super) fn delayed(mut self, by: Duration) -> Self { + if let Self::Http { delay, .. } = &mut self { + *delay = by; + } + self + } +} + +/// One request as the server received it. +#[derive(Debug)] +pub(crate) struct Request { + pub method: String, + pub path: String, + /// Header names are lowercased. + pub headers: Vec<(String, String)>, + pub body: Vec, + /// Whether the client closed a connection the server held open. + pub client_closed: bool, +} + +impl Request { + pub(super) fn header(&self, name: &str) -> Option<&str> { + self.headers + .iter() + .find(|(key, _)| key == name) + .map(|(_, value)| value.as_str()) + } + + pub(super) fn headers_named(&self, name: &str) -> usize { + self.headers.iter().filter(|(key, _)| key == name).count() + } +} + +pub(crate) struct Server { + port: u16, + stop: mpsc::Sender<()>, + handle: JoinHandle>, +} + +impl Server { + /// Serves one connection per reply, in order, then stops listening. + pub(crate) fn start(replies: Vec) -> Self { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind loopback"); + listener + .set_nonblocking(true) + .expect("nonblocking listener"); + let port = listener.local_addr().expect("local address").port(); + let (stop, stopped) = mpsc::channel(); + let handle = thread::spawn(move || { + let mut requests = Vec::new(); + for reply in replies { + let Some(stream) = accept(&listener, &stopped) else { + break; + }; + requests.push(answer(&stream, reply, &stopped)); + } + requests + }); + Self { port, stop, handle } + } + + pub(super) fn port(&self) -> u16 { + self.port + } + + pub(super) fn url(&self, path: &str) -> String { + format!("http://127.0.0.1:{}{path}", self.port) + } + + /// Whether every reply has been served and every held connection closed. + pub(super) fn is_done(&self) -> bool { + self.handle.is_finished() + } + + /// Stops the server and returns the requests it received. + pub(crate) fn finish(self) -> Vec { + let _ = self.stop.send(()); + self.handle.join().expect("server thread") + } +} + +fn should_stop(stopped: &mpsc::Receiver<()>) -> bool { + !matches!( + stopped.recv_timeout(Duration::from_millis(10)), + Err(RecvTimeoutError::Timeout) + ) +} + +fn accept(listener: &TcpListener, stopped: &mpsc::Receiver<()>) -> Option { + loop { + match listener.accept() { + Ok((stream, _)) => { + stream.set_nonblocking(false).expect("blocking stream"); + stream + .set_read_timeout(Some(Duration::from_millis(20))) + .expect("read timeout"); + return Some(stream); + } + Err(error) if error.kind() == ErrorKind::WouldBlock => { + if should_stop(stopped) { + return None; + } + } + Err(error) => panic!("accept: {error}"), + } + } +} + +fn answer(stream: &TcpStream, reply: Reply, stopped: &mpsc::Receiver<()>) -> Request { + let mut request = read_request(stream); + let mut stream = stream; + match reply { + Reply::Http { + status, + headers, + body, + delay, + } => { + thread::sleep(delay); + let reason = reqwest::StatusCode::from_u16(status) + .ok() + .and_then(|status| status.canonical_reason()) + .unwrap_or("Unknown"); + let mut head = format!("HTTP/1.1 {status} {reason}\r\n"); + for (name, value) in &headers { + head.push_str(&format!("{name}: {value}\r\n")); + } + head.push_str(&format!( + "Content-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + )); + // The client may legitimately hang up early (e.g. an oversized body). + let _ = stream + .write_all(head.as_bytes()) + .and_then(|()| stream.write_all(&body)) + .and_then(|()| stream.flush()); + return request; + } + Reply::Stall => { + write!( + stream, + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 1000\r\n\r\n{{\"data\":" + ) + .expect("partial response"); + stream.flush().expect("flush"); + } + Reply::Silent => {} + Reply::EndlessChunks => { + write!( + stream, + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nTransfer-Encoding: chunked\r\n\r\n" + ) + .expect("chunked head"); + let chunk = vec![b'y'; 512]; + loop { + if should_stop(stopped) { + return request; + } + let written = write!(stream, "{:x}\r\n", chunk.len()) + .and_then(|()| stream.write_all(&chunk)) + .and_then(|()| stream.write_all(b"\r\n")) + .and_then(|()| stream.flush()); + if written.is_err() { + request.client_closed = true; + return request; + } + } + } + } + // Hold the connection open until the client closes it or the test stops. + let mut sink = [0_u8; 64]; + loop { + if should_stop(stopped) { + return request; + } + match stream.read(&mut sink) { + Ok(0) => { + request.client_closed = true; + return request; + } + Ok(_) => {} + Err(error) if matches!(error.kind(), ErrorKind::WouldBlock | ErrorKind::TimedOut) => {} + Err(_) => { + request.client_closed = true; + return request; + } + } + } +} + +/// Reads the request head and its `Content-Length` body. +fn read_request(mut stream: &TcpStream) -> Request { + let mut buffer = Vec::new(); + let mut chunk = [0_u8; 1024]; + let started = Instant::now(); + let (head_end, length) = loop { + match stream.read(&mut chunk) { + Ok(0) => panic!("client closed before sending a request"), + Ok(n) => buffer.extend_from_slice(chunk.get(..n).expect("read within buffer")), + Err(error) if matches!(error.kind(), ErrorKind::WouldBlock | ErrorKind::TimedOut) => {} + Err(error) => panic!("read request: {error}"), + } + assert!( + started.elapsed() < Duration::from_secs(30), + "request never completed" + ); + let Some(head_end) = buffer.windows(4).position(|window| window == b"\r\n\r\n") else { + continue; + }; + let head = String::from_utf8_lossy(&buffer[..head_end]); + let length = head + .lines() + .find_map(|line| { + let (name, value) = line.split_once(':')?; + name.eq_ignore_ascii_case("content-length") + .then(|| value.trim().parse::().expect("content length")) + }) + .unwrap_or(0); + if buffer.len() >= head_end + 4 + length { + break (head_end, length); + } + }; + let head = String::from_utf8(buffer[..head_end].to_vec()).expect("ASCII request head"); + let mut lines = head.lines(); + let mut start = lines.next().expect("request line").split(' '); + let method = start.next().expect("method").to_owned(); + let path = start.next().expect("path").to_owned(); + let headers = lines + .map(|line| { + let (name, value) = line.split_once(':').expect("header line"); + (name.to_ascii_lowercase(), value.trim().to_owned()) + }) + .collect(); + Request { + method, + path, + headers, + body: buffer[head_end + 4..head_end + 4 + length].to_vec(), + client_closed: false, + } +} diff --git a/crates/linear-cli/src/commands/api.rs b/crates/linear-cli/src/commands/api.rs new file mode 100644 index 000000000..7733d9dc5 --- /dev/null +++ b/crates/linear-cli/src/commands/api.rs @@ -0,0 +1,291 @@ +//! `linear api`: send a user-written GraphQL document and print the response. +use crate::client::LinearClient; +use crate::graphql::pagination::{Page, PageInfo, Pages}; +use crate::{ + cli::api::Api, + commands::text_input, + ctx::Ctx, + error::{Error, Result, ResultExt}, +}; +use serde_json::{Map, Number, Value}; + +pub fn run(ctx: &Ctx, args: &Api) -> Result<()> { + request_and_print(ctx, args).context("API request failed") +} + +fn request_and_print(ctx: &Ctx, args: &Api) -> Result<()> { + let query = resolve_query(args.graphql_document.as_deref(), ctx.stdin_tty())?; + let variables = variables(args)?; + let client = ctx.client()?; + let response = ctx.block_on(execute( + client, + &query, + variables, + args.paginate, + ctx.stdout_tty(), + ))?; + // The response is printed either way; the exit status says whether it + // was a success. + let (text, succeeded) = match response { + Response::Data(text) => (text, true), + Response::Errors(text) => (text, false), + Response::HttpError(body) => { + if !args.silent { + ctx.eprint(body)?; + } + return Err(Error::reported()); + } + }; + if !args.silent { + ctx.print(text)?; + } + if succeeded { + Ok(()) + } else { + Err(Error::reported()) + } +} + +/// Parses response or input text as JSON; `None` when it is not JSON. +fn decode(text: &str) -> Option { + serde_json::from_str(text).ok() +} +fn no_query() -> Error { + Error::new("No query provided").with_hint("Provide a query as an argument: linear api '{ viewer { id } }'\n Or pipe from stdin: echo '{ viewer { id } }' | linear api") +} +fn stdin_all() -> Result { + Ok(text_input::read_stdin(std::io::stdin().lock())? + .map(|text| text.trim().to_owned()) + .unwrap_or_default()) +} +fn resolve_query(positional: Option<&str>, stdin_tty: bool) -> Result { + if let Some(query) = positional.filter(|s| !s.is_empty() && *s != "-") { + return Ok(query.to_owned()); + } + let content = if positional == Some("-") || !stdin_tty { + stdin_all()? + } else { + String::new() + }; + if content.is_empty() { + Err(no_query()) + } else { + Ok(content) + } +} +/// File or stdin content is sent as JSON when it parses, otherwise as a string. +fn parsed_or_string(text: String) -> Value { + decode(&text).unwrap_or(Value::String(text)) +} +/// A `--variable` value: booleans, `null` and canonically written numbers are +/// coerced; anything else is a string. +fn plain(text: &str) -> Result { + match text { + "true" => return Ok(Value::Bool(true)), + "false" => return Ok(Value::Bool(false)), + "null" => return Ok(Value::Null), + _ => {} + } + if text.parse::().is_ok_and(|number| !number.is_finite()) { + return Err( + Error::new(format!("Variable value {text} is not a finite number")) + .with_hint("Pass a finite number, or a JSON string through --variables-json."), + ); + } + Ok(match serde_json::from_str::(text) { + Ok(number) if number.to_string() == text => Value::Number(number), + _ => Value::String(text.to_owned()), + }) +} +fn variables(action: &Api) -> Result> { + let mut variables = Map::new(); + if let Some(text) = action.variables_json.as_deref().filter(|s| !s.is_empty()) { + let value = decode(text).ok_or_else(|| { + Error::new(format!("Invalid JSON for --variables-json: {text}")).with_hint( + "Provide a valid JSON object, e.g. --variables-json '{\"key\": \"value\"}'", + ) + })?; + let kind = match value { + Value::Object(object) => { + variables = object; + None + } + Value::Null => Some("null"), + Value::Bool(_) => Some("boolean"), + Value::Number(_) => Some("number"), + Value::String(_) => Some("string"), + Value::Array(_) => Some("array"), + }; + if let Some(kind) = kind { + return Err(Error::new(format!( + "--variables-json must be a JSON object, got {kind}" + )) + .with_hint("Provide a JSON object, e.g. --variables-json '{\"key\": \"value\"}'")); + } + } + for entry in &action.variable { + let raw = &entry.value; + let value = if raw == "@-" { + let text = stdin_all()?; + if text.is_empty() { + return Err(Error::new("No data on stdin for @- value")); + } + parsed_or_string(text) + } else if let Some(path) = raw.strip_prefix('@') { + let text = text_input::read_file(path).map_err(|e| { + if e.kind() == std::io::ErrorKind::NotFound { + Error::new(format!("File not found: {path}")) + } else { + Error::new(format!("Failed to read file: {path}")).with_source(e) + } + })?; + parsed_or_string(text.trim().to_owned()) + } else { + plain(raw)? + }; + variables.insert(entry.key.clone(), value); + } + Ok(variables) +} +fn request(query: &str, variables: &Map) -> String { + let mut body = Map::new(); + body.insert("query".into(), Value::String(query.into())); + if !variables.is_empty() { + body.insert("variables".into(), Value::Object(variables.clone())); + } + Value::Object(body).to_string() +} +enum Response { + /// The response body as printed. + Data(String), + /// GraphQL errors, or a body that is not JSON, as printed. + Errors(String), + /// A failed HTTP status, with the body for stderr. + HttpError(String), +} + +/// The response as printed: pretty on a terminal, compact when piped, and +/// always ending in one newline. +fn json_output(value: &Value, raw: &str, tty: bool) -> String { + let text = if tty { + format!("{value:#}") + } else if value.is_string() { + raw.to_owned() + } else { + value.to_string() + }; + format!("{}\n", text.trim_end_matches('\n')) +} +fn has_errors(value: &Value) -> bool { + value + .get("errors") + .and_then(Value::as_array) + .is_some_and(|errors| !errors.is_empty()) +} +fn is_connection(object: &Map) -> bool { + object.contains_key("nodes") && object.contains_key("pageInfo") +} +fn count_connections(value: &Value) -> usize { + match value { + Value::Object(object) if is_connection(object) => 1, + Value::Object(object) => object.values().map(count_connections).sum(), + Value::Array(values) => values.iter().map(count_connections).sum(), + _ => 0, + } +} +/// The first connection (an object with `nodes` and `pageInfo`) in `value`. +fn find_connection(value: &Value) -> Option<&Map> { + match value { + Value::Object(object) if is_connection(object) => Some(object), + Value::Object(object) => object.values().find_map(find_connection), + Value::Array(values) => values.iter().find_map(find_connection), + _ => None, + } +} + +/// A connection's nodes and page info, which must have the shapes +/// pagination relies on. +fn parse_page(connection: &Map) -> Result> { + let hint = "Select `nodes { ... }` and `pageInfo { hasNextPage endCursor }` on the connection."; + let nodes = match connection.get("nodes") { + Some(Value::Array(nodes)) => nodes.clone(), + _ => { + return Err( + Error::new("The paginated connection's nodes are not a list").with_hint(hint), + ); + } + }; + let page_info = connection + .get("pageInfo") + .cloned() + .map(serde_json::from_value::) + .and_then(std::result::Result::ok) + .ok_or_else(|| { + Error::new("The paginated connection's pageInfo lacks hasNextPage or endCursor") + .with_hint(hint) + })?; + Ok(Page { nodes, page_info }) +} + +async fn execute( + client: &LinearClient, + query: &str, + variables: Map, + paginate: bool, + tty: bool, +) -> Result { + let mut nodes = Vec::new(); + let mut pages = Pages::new(None); + loop { + let mut vars = variables.clone(); + if paginate { + vars.insert( + "after".into(), + pages.after().map_or(Value::Null, Value::String), + ); + } + let (status, text) = client.fetch_api(request(query, &vars)).await?; + if status >= 400 { + return Ok(Response::HttpError(format!("{text}\n"))); + } + let Some(parsed) = decode(&text) else { + return Ok(Response::Errors(format!("{text}\n"))); + }; + if parsed.is_null() { + if paginate { + return Err(Error::new("Linear returned a null response body")); + } + return Ok(Response::Data(format!("{text}\n"))); + } + if has_errors(&parsed) { + return Ok(Response::Errors(json_output(&parsed, &text, tty))); + } + if !paginate { + return Ok(Response::Data(json_output(&parsed, &text, tty))); + } + let first_page = pages.after().is_none(); + if first_page + && parsed + .get("data") + .is_some_and(|data| count_connections(data) > 1) + { + return Err(Error::new("--paginate does not support queries with multiple paginated connections").with_hint("Use cursor-based pagination manually with $after and pageInfo { hasNextPage endCursor }.")); + } + let Some(connection) = find_connection(&parsed) else { + if first_page { + return Ok(Response::Data(json_output(&parsed, &text, tty))); + } + return Err(Error::new( + "A later page of the response has no paginated connection", + )); + }; + let page = parse_page(connection)?; + let more = pages.advance(page.nodes.len(), &page.page_info)?; + nodes.extend(page.nodes); + if !more { + break; + } + } + let all = Value::Array(nodes); + Ok(Response::Data(json_output(&all, &all.to_string(), tty))) +} diff --git a/crates/linear-cli/src/commands/auth/default.rs b/crates/linear-cli/src/commands/auth/default.rs new file mode 100644 index 000000000..0f33194a5 --- /dev/null +++ b/crates/linear-cli/src/commands/auth/default.rs @@ -0,0 +1,56 @@ +//! `auth default`: choose the workspace used when none is named. +use crate::cli::auth::AuthDefault; +use crate::ctx::Ctx; +use crate::error::{Error, Result, ResultExt}; +use crate::platform::prompt::Choice; + +pub fn run(ctx: &Ctx, args: &AuthDefault) -> Result<()> { + set_default(ctx, args).context("Failed to set default workspace") +} + +fn set_default(ctx: &Ctx, args: &AuthDefault) -> Result<()> { + let mut credentials = super::credentials(ctx)?; + let workspaces = credentials.workspaces(); + match workspaces { + [] => { + return Err(Error::auth("No workspaces configured") + .with_hint("Run `linear auth login` to add a workspace.")); + } + [only] => return ctx.print(format!("Only one workspace configured: {only}\n")), + _ => {} + } + let target = match super::named_workspace(ctx, args.workspace_name.as_deref())? { + Some(target) => { + if !credentials.has_workspace(target) { + return Err(Error::not_found("Workspace", target) + .with_hint(format!("Available workspaces: {}", workspaces.join(", ")))); + } + target.to_owned() + } + None => pick(ctx, workspaces, credentials.default())?, + }; + if credentials.default() == Some(target.as_str()) { + return ctx.print(format!("\"{target}\" is already the default workspace\n")); + } + credentials.set_default(&target)?; + ctx.print(format!("Default workspace set to: {target}\n")) +} + +fn pick(ctx: &Ctx, workspaces: &[String], current: Option<&str>) -> Result { + if !ctx.interactive() { + return Err(Error::invalid("No workspace given") + .with_hint("Name it: `linear auth default `.")); + } + let choices = workspaces + .iter() + .map(|name| { + let label = if current == Some(name.as_str()) { + format!("{name} (current)") + } else { + name.clone() + }; + Choice::new(label, name.clone()) + }) + .collect(); + ctx.prompter()?.select("Default workspace:", choices) +} diff --git a/crates/linear-cli/src/commands/auth/list.rs b/crates/linear-cli/src/commands/auth/list.rs new file mode 100644 index 000000000..ee7b31a95 --- /dev/null +++ b/crates/linear-cli/src/commands/auth/list.rs @@ -0,0 +1,150 @@ +//! `auth list`: every stored workspace with the organization and user its +//! key belongs to, checked with one request per key, all at once. +use futures_util::future::join_all; +use reqwest::StatusCode; + +use crate::auth::CredentialStore; +use crate::client::{ApiKey, LinearClient, RequestError}; +use crate::commands::table::{Cell, Column, Table}; +use crate::ctx::Ctx; +use crate::error::{Result, ResultExt}; +use crate::graphql::envelope::graphql_message; +use crate::graphql::operations::user::GetViewerAccount; +use crate::platform::style; + +const EMPTY: &str = "No workspaces configured\nRun `linear auth login` to add a workspace\n"; + +struct Row { + workspace: String, + is_default: bool, + outcome: Outcome, +} + +/// What to do for one workspace. +enum Check { + Request(LinearClient), + /// The key cannot be used; the reason is shown instead of an organization. + Skip(&'static str), +} + +enum Outcome { + Viewer { + organization: String, + name: String, + email: String, + }, + Failed(String), +} + +pub fn run(ctx: &Ctx) -> Result<()> { + list(ctx).context("Failed to list workspaces") +} + +fn list(ctx: &Ctx) -> Result<()> { + let store = ctx.credentials()?; + if store.workspaces().is_empty() { + return ctx.print(EMPTY); + } + let clients = clients(ctx, store)?; + ctx.report_credential_warnings()?; + let outcomes = ctx.spin(true, join_all(clients.iter().map(check))); + let rows: Vec = store + .workspaces() + .iter() + .zip(outcomes) + .map(|(workspace, outcome)| Row { + workspace: workspace.clone(), + is_default: store.default() == Some(workspace.as_str()), + outcome, + }) + .collect(); + ctx.print(render(rows).render_for(ctx)) +} + +/// A client per stored workspace, or why its key cannot be used. +fn clients(ctx: &Ctx, store: &CredentialStore) -> Result> { + let endpoint = ctx.options().endpoint().value(); + let config = ctx.config().network_env.client_config(); + store + .workspaces() + .iter() + .map(|workspace| { + let Some(secret) = store.key(workspace) else { + return Ok(Check::Skip("missing credentials")); + }; + let Ok(key) = ApiKey::new(secret.expose()) else { + return Ok(Check::Skip("invalid API key")); + }; + let client = LinearClient::new(endpoint.clone(), key, config.clone())?; + Ok(Check::Request(client)) + }) + .collect() +} + +async fn check(check: &Check) -> Outcome { + let client = match check { + Check::Request(client) => client, + Check::Skip(reason) => return Outcome::Failed((*reason).to_owned()), + }; + match client.query::(()).await { + Ok(data) => Outcome::Viewer { + organization: data.viewer.organization.name, + name: data.viewer.name, + email: data.viewer.email, + }, + Err(failure) => Outcome::Failed(failure_cell(&failure)), + } +} + +fn rejected(status: StatusCode) -> bool { + status == StatusCode::UNAUTHORIZED || status == StatusCode::FORBIDDEN +} + +/// A short cell for a failed check. A 401 or 403 means the key was refused. +fn failure_cell(failure: &RequestError) -> String { + match failure { + RequestError::GraphQl { status, .. } | RequestError::ResponseTooLarge { status, .. } + if rejected(*status) => + { + "invalid credentials".to_owned() + } + RequestError::Http { response, .. } if rejected(response.status) => { + "invalid credentials".to_owned() + } + RequestError::GraphQl { errors, .. } => { + graphql_message(errors).unwrap_or_else(|| failure.to_string()) + } + RequestError::Http { .. } + | RequestError::ResponseTooLarge { .. } + | RequestError::Response(_) + | RequestError::Timeout { .. } + | RequestError::Network { .. } + | RequestError::RequestBody(_) => failure.to_string(), + } +} + +/// One row per workspace; `*` marks the default. +fn render(rows: Vec) -> Table { + let mut table = Table::new([ + Column::fixed(""), + Column::fixed("WORKSPACE"), + Column::flexible("ORGANIZATION"), + Column::flexible("USER"), + ]); + for row in rows { + let marker = Cell::from(if row.is_default { "*" } else { "" }); + let (organization, user) = match row.outcome { + Outcome::Viewer { + organization, + name, + email, + } => ( + Cell::from(organization), + Cell::from(format!("{name} <{email}>")), + ), + Outcome::Failed(reason) => (Cell::styled(reason, style::red), Cell::from("")), + }; + table.row([marker, Cell::from(row.workspace), organization, user]); + } + table +} diff --git a/crates/linear-cli/src/commands/auth/login.rs b/crates/linear-cli/src/commands/auth/login.rs new file mode 100644 index 000000000..5f8440bb7 --- /dev/null +++ b/crates/linear-cli/src/commands/auth/login.rs @@ -0,0 +1,193 @@ +//! `auth login`: check an API key with Linear, then store it. +use std::io::Read; + +use reqwest::StatusCode; + +use crate::auth::keyring::Keyring; +use crate::auth::mutation::Credentials; +use crate::auth::{ApiKeyInput, CredentialFormat}; +use crate::cli::auth::AuthLogin; +use crate::client::{ApiKey, LinearClient, RequestError}; +use crate::config::ConfigSecret; +use crate::ctx::Ctx; +use crate::error::{Error, Result, ResultExt}; +use crate::graphql::envelope::ResponseGraphQlError; +use crate::graphql::operations::user::GetViewerAccount; +use crate::platform::style; + +const KEY_HINT: &str = "Create one at https://linear.app/settings/account/security"; + +pub fn run(ctx: &Ctx, args: &AuthLogin) -> Result<()> { + login(ctx, args).context("Failed to login") +} + +fn login(ctx: &Ctx, args: &AuthLogin) -> Result<()> { + let mut credentials = super::credentials(ctx)?; + let store = ctx.credentials()?; + let keyring = store.keyring(); + let plaintext = credentials.stores_plaintext(args.plaintext); + if !plaintext && !keyring.available() { + return Err(Error::new("No system keyring found").with_hint( + "Pass --plaintext to store the key in the credentials file, or set LINEAR_API_KEY.", + )); + } + let key = clean_key(match &args.key { + Some(key) => ConfigSecret::new(key.clone()), + None => read_key(ctx)?, + })?; + let client = LinearClient::new( + ctx.options().endpoint().value().clone(), + ApiKey::new(key.expose()).map_err(|error| { + Error::new("API key cannot be used as an HTTP header").with_source(error) + })?, + ctx.config().network_env.client_config(), + )?; + let viewer = ctx + .spin(true, client.query::(())) + .map_err(rejected_key)? + .viewer; + let organization = &viewer.organization; + let existed = credentials.has_workspace(&organization.url_key); + let was_keyring = credentials.format() == CredentialFormat::Metadata; + { + let _spinner = ctx.spinner(true, ""); + credentials.add(&organization.url_key, key, args.plaintext, store, keyring)?; + } + + let color = ctx.color(); + let mut output = if existed { + format!( + "Updated credentials for workspace: {} ({})\n", + organization.name, organization.url_key + ) + } else { + format!( + "Logged in to workspace: {} ({})\n", + organization.name, organization.url_key + ) + }; + output.push_str(&format!(" User: {} <{}>\n", viewer.name, viewer.email)); + if credentials.workspaces().len() == 1 { + output.push_str(" Set as default workspace\n"); + } + if plaintext && !args.plaintext { + output.push_str(&style::yellow( + "Note: Credential stored as plaintext to match existing format.", + color, + )); + output.push('\n'); + } else if args.plaintext && was_keyring && credentials.workspaces().len() > 1 { + output.push_str(&style::yellow( + "Note: Every workspace's key is now stored as plaintext in the credentials file.", + color, + )); + output.push('\n'); + } + ctx.print(output)?; + + if plaintext && !args.plaintext { + offer_migration(ctx, &mut credentials, keyring)?; + } + if matches!(ApiKeyInput::from_options(ctx.options()), ApiKeyInput::Raw { value, .. } if !value.expose().is_empty()) + { + let warning = "Warning: LINEAR_API_KEY is set and takes precedence over stored credentials.\nRemove it from your shell config to use multi-workspace auth."; + ctx.eprint(format!( + "{}\n", + style::warning(warning, ctx.terminal().stderr_color()) + ))?; + } + Ok(()) +} + +/// The key from a prompt on a terminal, or from stdin when it is piped. +fn read_key(ctx: &Ctx) -> Result { + if !ctx.stdin_tty() { + let mut key = String::new(); + std::io::stdin().read_to_string(&mut key).map_err(|error| { + Error::new("Could not read the API key from stdin").with_source(error) + })?; + return Ok(ConfigSecret::new(key)); + } + let key = ctx.prompter()?.secret("API key:", KEY_HINT)?; + Ok(ConfigSecret::new(key)) +} + +/// Trims whitespace and pasted punctuation (quotes, brackets) around the key. +fn clean_key(key: ConfigSecret) -> Result { + let trimmed = key + .expose() + .trim_matches(|c: char| !c.is_ascii_alphanumeric() && c != '_'); + if trimmed.is_empty() { + return Err(Error::new("No API key provided").with_hint(KEY_HINT)); + } + Ok(ConfigSecret::new(trimmed.to_owned())) +} + +/// Linear refused the key: HTTP 401/403 or an authentication error. +fn rejected_key(failure: RequestError) -> Error { + let refused = match &failure { + RequestError::GraphQl { status, errors, .. } => { + refused_status(*status) || errors.iter().any(authentication_error) + } + RequestError::Http { response, .. } => refused_status(response.status), + RequestError::ResponseTooLarge { status, .. } => refused_status(*status), + RequestError::RequestBody(_) + | RequestError::Response(_) + | RequestError::Timeout { .. } + | RequestError::Network { .. } => false, + }; + if refused { + Error::auth("Invalid API key") + .with_hint("Check that your API key is correct and not expired.") + .with_source(failure) + } else { + Error::from(failure) + } +} + +fn refused_status(status: StatusCode) -> bool { + status == StatusCode::UNAUTHORIZED || status == StatusCode::FORBIDDEN +} + +fn authentication_error(error: &ResponseGraphQlError) -> bool { + error + .extensions + .as_ref() + .and_then(|extensions| extensions.get("code")) + .and_then(serde_json::Value::as_str) + == Some("AUTHENTICATION_ERROR") +} + +/// Offers to move plaintext keys to the keyring. Only asked on a terminal; +/// otherwise the command is suggested. +fn offer_migration(ctx: &Ctx, credentials: &mut Credentials, keyring: &dyn Keyring) -> Result<()> { + if !keyring.available() { + return Ok(()); + } + let color = ctx.terminal().stderr_color(); + let notice = style::yellow( + "Your credentials are stored as plaintext in the credentials file.", + color, + ); + if !ctx.interactive() { + return ctx.eprint(format!( + "\n{notice}\nRun `linear auth migrate` to move them to the system keyring.\n" + )); + } + ctx.eprint(format!("\n{notice}\n"))?; + let migrate = ctx.prompter()?.confirm( + "Migrate all credentials to the system keyring for better security?", + true, + )?; + if !migrate { + return Ok(()); + } + let migrated = { + let _spinner = ctx.spinner(true, ""); + credentials.migrate(keyring)? + }; + ctx.print(format!( + "Migrated {} workspace(s) to system keyring.\n", + migrated.len() + )) +} diff --git a/crates/linear-cli/src/commands/auth/logout.rs b/crates/linear-cli/src/commands/auth/logout.rs new file mode 100644 index 000000000..a1949e40d --- /dev/null +++ b/crates/linear-cli/src/commands/auth/logout.rs @@ -0,0 +1,60 @@ +//! `auth logout`: forget a workspace's credential. +use crate::cli::auth::AuthLogout; +use crate::commands::outcome; +use crate::ctx::Ctx; +use crate::error::{Error, Result, ResultExt}; +use crate::platform::prompt::Choice; + +pub fn run(ctx: &Ctx, args: &AuthLogout) -> Result<()> { + logout(ctx, args).context("Failed to logout") +} + +fn logout(ctx: &Ctx, args: &AuthLogout) -> Result<()> { + let mut credentials = super::credentials(ctx)?; + if credentials.workspaces().is_empty() { + return Err(Error::auth("No workspaces configured")); + } + let named = super::named_workspace(ctx, args.workspace_name.as_deref())?; + let workspace = match (named, credentials.workspaces()) { + (Some(name), _) if !credentials.has_workspace(name) => { + return Err(Error::not_found("Workspace", name)); + } + (Some(name), _) => name.to_owned(), + (None, [only]) => only.clone(), + (None, workspaces) => { + if !ctx.interactive() { + return Err(Error::invalid("No workspace given") + .with_hint("Name it: `linear auth logout `.")); + } + pick(ctx, workspaces, credentials.default())? + } + }; + let question = format!("Remove credentials for workspace \"{workspace}\"?"); + if !args.confirm.yes && !ctx.confirm(&question, "--yes")? { + return outcome::canceled(ctx); + } + { + let _spinner = ctx.spinner(true, ""); + credentials.remove(&workspace, ctx.credentials()?.keyring())?; + } + let mut output = outcome::done("Removed", "credentials for workspace", &workspace, None); + if let Some(default) = credentials.default() { + output.push_str(&format!(" Default workspace is now: {default}\n")); + } + ctx.print(output) +} + +fn pick(ctx: &Ctx, workspaces: &[String], default: Option<&str>) -> Result { + let choices = workspaces + .iter() + .map(|name| { + let label = if default == Some(name.as_str()) { + format!("{name} (default)") + } else { + name.clone() + }; + Choice::new(label, name.clone()) + }) + .collect(); + ctx.prompter()?.select("Workspace to remove:", choices) +} diff --git a/crates/linear-cli/src/commands/auth/migrate.rs b/crates/linear-cli/src/commands/auth/migrate.rs new file mode 100644 index 000000000..948185bde --- /dev/null +++ b/crates/linear-cli/src/commands/auth/migrate.rs @@ -0,0 +1,38 @@ +//! `auth migrate`: move plaintext keys from the credentials file to the +//! system keyring. +use crate::auth::CredentialFormat; +use crate::ctx::Ctx; +use crate::error::{Error, Result, ResultExt}; + +pub fn run(ctx: &Ctx) -> Result<()> { + migrate(ctx).context("Failed to migrate credentials") +} + +fn migrate(ctx: &Ctx) -> Result<()> { + let mut credentials = super::credentials(ctx)?; + if credentials.format() != CredentialFormat::Inline { + return ctx.print("Credentials are already using the system keyring.\n"); + } + let keyring = ctx.credentials()?.keyring(); + if !keyring.available() { + return Err(no_keyring()); + } + let migrated = { + let _spinner = ctx.spinner(true, ""); + credentials.migrate(keyring)? + }; + let mut output = format!( + "Migrated {} workspace(s) to system keyring:\n", + migrated.len() + ); + for name in migrated { + output.push_str(&format!(" {name}\n")); + } + ctx.print(output) +} + +pub(super) fn no_keyring() -> Error { + Error::new("No system keyring found").with_hint( + "Install libsecret (e.g. `apt install libsecret-tools` or `pacman -S libsecret`), or set LINEAR_API_KEY instead.", + ) +} diff --git a/crates/linear-cli/src/commands/auth/mod.rs b/crates/linear-cli/src/commands/auth/mod.rs new file mode 100644 index 000000000..884fbd19f --- /dev/null +++ b/crates/linear-cli/src/commands/auth/mod.rs @@ -0,0 +1,47 @@ +//! `linear auth`: stored workspace credentials. +mod default; +mod list; +mod login; +mod logout; +mod migrate; +mod token; +mod whoami; + +use crate::auth::mutation::Credentials; +use crate::cli::auth::AuthCommand; +use crate::ctx::Ctx; +use crate::error::{Error, Result}; + +pub fn run(ctx: &Ctx, command: &AuthCommand) -> Result<()> { + match command { + AuthCommand::Login(args) => login::run(ctx, args), + AuthCommand::Logout(args) => logout::run(ctx, args), + AuthCommand::List(_) => list::run(ctx), + AuthCommand::Default(args) => default::run(ctx, args), + AuthCommand::Token(_) => token::run(ctx), + AuthCommand::Whoami(_) => whoami::run(ctx), + AuthCommand::Migrate(_) => migrate::run(ctx), + } +} + +/// The workspace a command names: its positional argument, or else the +/// global `--workspace`. Naming two different workspaces is an error. +fn named_workspace<'a>(ctx: &'a Ctx, positional: Option<&'a str>) -> Result> { + match (positional, ctx.workspace()) { + (Some(named), Some(flag)) if named != flag => Err(Error::new(format!( + "Two different workspaces given: {named} and --workspace {flag}" + )) + .with_hint("Name the workspace once.")), + (Some(named), _) => Ok(Some(named)), + (None, flag) => Ok(flag), + } +} + +/// The credentials file, ready to change. +fn credentials(ctx: &Ctx) -> Result { + let path = ctx.credentials_path().ok_or_else(|| { + Error::new("Could not determine where the credentials file lives") + .with_hint("Set HOME (or XDG_CONFIG_HOME).") + })?; + Ok(Credentials::new(ctx.credentials()?, path)) +} diff --git a/crates/linear-cli/src/commands/auth/token.rs b/crates/linear-cli/src/commands/auth/token.rs new file mode 100644 index 000000000..8d7aeb501 --- /dev/null +++ b/crates/linear-cli/src/commands/auth/token.rs @@ -0,0 +1,12 @@ +//! `auth token`: print the API key the other commands would use. +use crate::ctx::Ctx; +use crate::error::{Result, ResultExt}; + +pub fn run(ctx: &Ctx) -> Result<()> { + token(ctx).context("Failed to get API token") +} + +fn token(ctx: &Ctx) -> Result<()> { + let key = ctx.api_key()?; + ctx.print(format!("{}\n", key.expose())) +} diff --git a/crates/linear-cli/src/commands/auth/whoami.rs b/crates/linear-cli/src/commands/auth/whoami.rs new file mode 100644 index 000000000..0b9983903 --- /dev/null +++ b/crates/linear-cli/src/commands/auth/whoami.rs @@ -0,0 +1,33 @@ +//! `auth whoami`: the authenticated user and their workspace. +use crate::ctx::Ctx; +use crate::error::{Result, ResultExt}; +use crate::graphql::operations::user::AuthStatus; + +pub fn run(ctx: &Ctx) -> Result<()> { + whoami(ctx).context("Failed to get user info") +} + +fn whoami(ctx: &Ctx) -> Result<()> { + let client = ctx.client()?; + let status: AuthStatus = ctx.spin(true, client.query(()))?; + ctx.print(render(&status)) +} + +fn render(status: &AuthStatus) -> String { + let viewer = &status.viewer; + let organization = &viewer.organization; + let mut output = format!( + "Workspace: {}\n Slug: {}\n URL: https://linear.app/{}\nUser: {}\n", + organization.name, organization.url_key, organization.url_key, viewer.name + ); + if viewer.display_name != viewer.name { + output.push_str(&format!(" Display name: {}\n", viewer.display_name)); + } + output.push_str(&format!(" Email: {}\n", viewer.email)); + if viewer.admin { + output.push_str(" Role: admin\n"); + } else if viewer.guest { + output.push_str(" Role: guest\n"); + } + output +} diff --git a/crates/linear-cli/src/commands/bulk.rs b/crates/linear-cli/src/commands/bulk.rs new file mode 100644 index 000000000..f095bbc0e --- /dev/null +++ b/crates/linear-cli/src/commands/bulk.rs @@ -0,0 +1,355 @@ +//! The bulk archive/delete/move commands: reading the IDs, running every +//! item with a progress line, and the summary. +use std::{collections::HashSet, io::Read, path::Path}; + +use futures_util::{StreamExt, stream}; + +use crate::cli::BulkArgs; +use crate::ctx::Ctx; +use crate::error::{Error, Result}; + +pub struct BulkInput<'a> { + pub argv: Option<&'a [String]>, + pub file: Option<&'a Path>, + pub stdin: bool, +} +impl<'a> From<&'a BulkArgs> for BulkInput<'a> { + fn from(args: &'a BulkArgs) -> Self { + Self { + argv: args.bulk.as_deref(), + file: args.bulk_file.as_deref(), + stdin: args.bulk_stdin, + } + } +} +impl BulkInput<'_> { + pub fn requested(&self) -> bool { + self.argv.is_some_and(|ids| !ids.is_empty()) || self.file.is_some() || self.stdin + } +} +fn parse_ids(text: &str) -> impl Iterator { + let text = text.strip_prefix('\u{feff}').unwrap_or(text); + text.split(|ch: char| ch == ',' || ch.is_whitespace()) + .filter(|id| !id.is_empty()) +} +/// Read and decode every selected input before printing a count or dispatching requests. +/// argv tokens deliberately remain unsplit and untrimmed. +pub fn collect_ids(input: &BulkInput<'_>, stdin: &mut impl Read) -> Result> { + let mut ids = input.argv.unwrap_or_default().to_vec(); + if let Some(path) = input.file { + let bytes = std::fs::read(path).map_err(|error| { + if error.kind() == std::io::ErrorKind::NotFound { + Error::not_found("File", &path.display().to_string()) + } else { + Error::new(format!("Failed to read bulk file: {}", path.display())) + .with_source(error) + } + })?; + let text = String::from_utf8(bytes).map_err(|error| { + Error::new(format!("Bulk file must be valid UTF-8: {}", path.display())) + .with_hint("Re-save the file as UTF-8 text.") + .with_source(error) + })?; + ids.extend(parse_ids(&text).map(str::to_owned)); + } + if input.stdin { + let mut bytes = Vec::new(); + stdin + .read_to_end(&mut bytes) + .map_err(|error| Error::new("Failed to read bulk stdin").with_source(error))?; + let text = String::from_utf8(bytes).map_err(|error| { + Error::new("Bulk stdin must be valid UTF-8") + .with_hint("Provide UTF-8 text on stdin.") + .with_source(error) + })?; + ids.extend(parse_ids(&text).map(str::to_owned)); + } + let mut seen = HashSet::new(); + ids.retain(|id| seen.insert(id.clone())); + Ok(ids) +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum BulkOutcome { + Succeeded, + Failed(String), +} +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BulkResult { + pub id: String, + pub name: Option, + pub outcome: BulkOutcome, +} +impl BulkResult { + pub fn succeeded(&self) -> bool { + matches!(self.outcome, BulkOutcome::Succeeded) + } +} + +/// How a bulk command names what it does: `archive`/`archived`. +#[derive(Clone, Copy, Debug)] +pub struct Verb { + pub present: &'static str, + pub past: &'static str, +} + +/// A listed item that was looked up and can be changed. +pub struct Found { + /// The reference as it was listed. + pub original: String, + /// How the item is shown, like `ENG-1: Fix login`. + pub name: String, + pub item: T, +} + +impl Found { + /// The row for this item once `op` has run. + pub fn result(&self, outcome: std::result::Result<(), Error>) -> BulkResult { + BulkResult { + id: self.original.clone(), + name: Some(self.name.clone()), + outcome: match outcome { + Ok(()) => BulkOutcome::Succeeded, + Err(error) => BulkOutcome::Failed(error.message().to_owned()), + }, + } + } +} + +/// A listed item that could not be looked up, and why. +pub struct Skipped { + pub original: String, + pub reason: String, +} + +impl From for BulkResult { + fn from(skipped: Skipped) -> Self { + Self { + id: skipped.original, + name: None, + outcome: BulkOutcome::Failed(skipped.reason), + } + } +} + +/// Looks up every listed item, five at a time behind a spinner, before +/// anything is confirmed or changed. Both lists keep the input order. +pub fn look_up(ctx: &Ctx, items: Vec, op: F) -> (Vec>, Vec) +where + F: FnMut(I) -> Fut, + Fut: Future, Skipped>>, +{ + let rows: Vec<_> = ctx.spin(true, stream::iter(items).map(op).buffered(5).collect()); + let mut found = Vec::new(); + let mut missing = Vec::new(); + for row in rows { + match row { + Ok(item) => found.push(item), + Err(row) => missing.push(row), + } + } + (found, missing) +} + +/// What a bulk command is about to change, and the listed items it skips +/// because they could not be looked up. +pub fn preview(found: &[Found], missing: &[Skipped], noun: &str, verb: Verb) -> String { + let mut output = String::new(); + if !found.is_empty() { + output.push_str(&format!( + "{} to {}:\n", + count(found.len(), noun), + verb.present + )); + for item in found { + output.push_str(&format!(" {}\n", item.name)); + } + } + if !missing.is_empty() { + output.push_str(&format!( + "Skipping {} that could not be found:\n", + count(missing.len(), noun) + )); + for skipped in missing { + output.push_str(&format!(" {}: {}\n", skipped.original, skipped.reason)); + } + } + output +} + +/// `count` of `noun`, as in `1 issue` or `3 issues`. +pub fn count(count: usize, noun: &str) -> String { + format!("{count} {noun}{}", if count == 1 { "" } else { "s" }) +} + +/// Runs `op` for every item, five at a time, keeping the input order in the +/// results. While stderr is a terminal a progress line counts them off. +pub fn run(ctx: &Ctx, items: Vec, op: F) -> Result> +where + F: FnMut(T) -> Fut, + Fut: Future, +{ + let total = items.len(); + let show_progress = ctx.terminal().stderr_tty; + let results = ctx.block_on(async { + let mut rows = stream::iter(items).map(op).buffered(5); + let mut results: Vec = Vec::with_capacity(total); + while let Some(row) = rows.next().await { + results.push(row); + if show_progress { + let succeeded = results.iter().filter(|row| row.succeeded()).count(); + ctx.eprint(progress(results.len(), total, succeeded))?; + } + } + Ok::<_, Error>(results) + })?; + if show_progress && total > 0 { + let width = progress(total, total, total).chars().count(); + ctx.eprint(format!("\r{}\r", " ".repeat(width)))?; + } + Ok(results) +} + +/// Prints the outcome of every item and fails (already reported) when any did. +pub fn report(ctx: &Ctx, results: &[BulkResult], noun: &str, verb: Verb) -> Result<()> { + let (summary, failed) = summary(results, noun, verb); + // The blank line sets the summary off from the listing and progress on + // stderr; on stdout it would be the first line a script reads. + ctx.eprint("\n")?; + ctx.print(summary)?; + if failed { + return Err(Error::reported()); + } + Ok(()) +} + +/// The summary after a bulk run, and whether anything failed. `noun` is +/// singular, as in `issue`. +pub fn summary(results: &[BulkResult], noun: &str, verb: Verb) -> (String, bool) { + let total = results.len(); + let succeeded = results.iter().filter(|row| row.succeeded()).count(); + let failed = total - succeeded; + let count = |n: usize| count(n, noun); + let mut output = String::new(); + if failed == 0 { + output.push_str(&format!( + "✓ Successfully {} {}\n", + verb.past, + count(succeeded) + )); + return (output, false); + } + if succeeded == 0 { + output.push_str(&format!( + "✗ Failed to {} all {}\n", + verb.present, + count(total) + )); + } else { + output.push_str(&format!( + "Completed: {succeeded}/{} {}\n ✓ Succeeded: {succeeded}\n ✗ Failed: {failed}\n", + count(total), + verb.past + )); + } + output.push_str("\nFailed operations:\n"); + for row in results { + if let BulkOutcome::Failed(error) = &row.outcome { + let name = row + .name + .as_deref() + .filter(|name| !name.is_empty()) + .map_or_else(String::new, |name| format!(" ({name})")); + output.push_str(&format!(" - {}{name}: {error}\n", row.id)); + } + } + (output, true) +} + +fn progress(completed: usize, total: usize, succeeded: usize) -> String { + // The percentage rounded half up, in integer arithmetic. + let percent = (completed * 200 + total) / (total * 2); + format!( + "\r⏳ {completed}/{total} ({percent}%) - ✓ {succeeded} ✗ {}", + completed - succeeded + ) +} + +#[cfg(test)] +mod tests { + use super::{BulkInput, BulkOutcome, BulkResult, Verb, collect_ids, summary}; + + const ARCHIVE: Verb = Verb { + present: "archive", + past: "archived", + }; + + fn row(id: &str, outcome: BulkOutcome) -> BulkResult { + BulkResult { + id: id.to_owned(), + name: Some(format!("{id}: Title")), + outcome, + } + } + + #[test] + fn summaries_count_successes_and_list_failures() { + let ok = row("ENG-1", BulkOutcome::Succeeded); + let failed = row("ENG-2", BulkOutcome::Failed("Issue not found".to_owned())); + assert_eq!( + summary(std::slice::from_ref(&ok), "issue", ARCHIVE), + ("✓ Successfully archived 1 issue\n".to_owned(), false) + ); + assert_eq!( + summary(&[ok, failed.clone()], "issue", ARCHIVE), + ( + "Completed: 1/2 issues archived\n ✓ Succeeded: 1\n ✗ Failed: 1\n\nFailed operations:\n - ENG-2 (ENG-2: Title): Issue not found\n" + .to_owned(), + true + ) + ); + assert!( + summary(&[failed], "issue", ARCHIVE) + .0 + .starts_with("✗ Failed to archive all 1 issue\n") + ); + } + + #[test] + fn ids_come_from_argv_file_and_stdin_without_duplicates() { + let dir = tempfile::tempdir().expect("temp dir"); + let file = dir.path().join("ids"); + std::fs::write(&file, "\u{feff}ENG-1,ENG-2\n ENG-1\tENG-3\n").expect("write ids"); + let argv = vec![" Raw, argv ".to_owned()]; + let input = BulkInput { + argv: Some(&argv), + file: Some(&file), + stdin: true, + }; + assert_eq!( + collect_ids(&input, &mut &b"ENG-4,ENG-2"[..]).expect("ids"), + [" Raw, argv ", "ENG-1", "ENG-2", "ENG-3", "ENG-4"] + ); + } + + #[test] + fn ids_must_be_utf8() { + let dir = tempfile::tempdir().expect("temp dir"); + let file = dir.path().join("ids"); + std::fs::write(&file, b"ENG-1,\xff").expect("write ids"); + let from_file = BulkInput { + argv: None, + file: Some(&file), + stdin: false, + }; + let error = collect_ids(&from_file, &mut &b""[..]).expect_err("invalid file"); + assert!(error.message().starts_with("Bulk file must be valid UTF-8")); + let from_stdin = BulkInput { + argv: None, + file: None, + stdin: true, + }; + let error = collect_ids(&from_stdin, &mut &b"\xff"[..]).expect_err("invalid stdin"); + assert_eq!(error.message(), "Bulk stdin must be valid UTF-8"); + } +} diff --git a/crates/linear-cli/src/commands/color.rs b/crates/linear-cli/src/commands/color.rs new file mode 100644 index 000000000..4255707f4 --- /dev/null +++ b/crates/linear-cli/src/commands/color.rs @@ -0,0 +1,37 @@ +//! The colors offered when a label or initiative is created interactively. +use crate::cli::values::HexColor; +use crate::error::Result; +use crate::platform::prompt::{Prompter, Text}; + +/// Linear's default label color. +pub const INDIGO: &str = "#5E6AD2"; + +/// Named colors, in menu order. +pub const PALETTE: [(&str, &str); 10] = [ + ("Red", "#EB5757"), + ("Orange", "#F2994A"), + ("Yellow", "#F2C94C"), + ("Green", "#27AE60"), + ("Teal", "#0D9488"), + ("Blue", "#2F80ED"), + ("Indigo", INDIGO), + ("Purple", "#8B5CF6"), + ("Pink", "#BB6BD9"), + ("Gray", "#6B6F76"), +]; + +/// The menu label for a palette color: `Red (#EB5757)`. +pub fn label(name: &str, hex: &str) -> String { + format!("{name} ({hex})") +} + +/// Asks for a hex color outside the palette. +pub fn custom(prompter: &Prompter<'_>) -> Result { + Ok(prompter + .parsed( + Text::new("Color (hex, like #FF5733):").required(), + &str::parse::, + )? + .expect("a required answer is never blank") + .into()) +} diff --git a/crates/linear-cli/src/commands/comment_add.rs b/crates/linear-cli/src/commands/comment_add.rs new file mode 100644 index 000000000..6c1a999cc --- /dev/null +++ b/crates/linear-cli/src/commands/comment_add.rs @@ -0,0 +1,285 @@ +//! Shared `comment add` steps: body flags, writing the body in the editor, +//! the single `AddComment` mutation and its output. Each target command +//! checks its flags, resolves its target, and only then opens the editor, so +//! nothing typed is lost to a target that does not exist. +use crate::cli::values::TextSource; +use crate::client::LinearClient; +use crate::commands::{confirm, outcome, text_input}; +use crate::ctx::Ctx; +use crate::error::{Error, Result}; +use crate::graphql::operations::comment::{ + AddComment, AddCommentVariables, CommentCreateInput, CreatedComment, + DocumentCommentTargetVariables, GetCommentVariables, GetDocumentCommentTarget, GetReplyParent, + ReplyParent, +}; +use crate::refs::{is_linear_uuid, reject_comment_url, reject_linear_url}; + +/// Linear requires exactly one target even for replies. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum CommentTarget { + Issue { issue_id: String }, + Document { document_content_id: String }, + Project { project_id: String }, + Initiative { initiative_id: String }, +} + +fn is_blank(value: &str) -> bool { + value.trim().is_empty() +} + +/// Turn `--body` / `--body-file` into a body, or `None` so the caller prompts. +/// Supplied text is returned unchanged; blank supplied input is an error. +pub fn resolve_body( + body: Option<&str>, + body_file: Option<&TextSource>, +) -> Result, Error> { + match (body, body_file) { + (Some(_), Some(_)) => Err(Error::invalid("Cannot specify both --body and --body-file")), + (None, Some(path)) => read_body_file(path).map(Some), + (Some(text), None) if is_blank(text) => Err(Error::invalid("Comment body cannot be empty") + .with_hint("Pass text with --body, or omit it to write the comment in your editor.")), + (Some(text), None) => Ok(Some(text.to_owned())), + (None, None) => Ok(None), + } +} + +/// Invalid UTF-8 is rejected rather than replaced, so a comment never silently changes. +fn read_body_file(source: &TextSource) -> Result { + let shown = source; + let content = text_input::read_source(source).map_err(|error| { + if error.kind() == std::io::ErrorKind::InvalidData { + Error::new("Body file must be valid UTF-8") + .with_hint(format!("Re-save {shown} as UTF-8 text, or use --body.")) + .with_source(error) + } else { + Error::new(format!("Failed to read body file: {shown}")) + .with_hint(format!("Error: {error}")) + } + })?; + content.ok_or_else(|| { + Error::invalid(format!("Body file is empty: {shown}")) + .with_hint("Write the comment into the file, or use --body.") + }) +} + +/// Fails before any lookup when no body was given and none can be written +/// in the editor, naming the flags to pass instead. +pub fn require_editor(ctx: &Ctx) -> Result<()> { + if ctx.interactive() { + Ok(()) + } else { + Err(ctx.missing_value("No comment body given", "--body or --body-file")) + } +} + +/// The body written in the editor, starting from `initial`, once the user +/// confirms `question` (or passed `yes`). `None` when the editor is left +/// empty or unchanged, or the user declines; that is already reported. +pub fn write_in_editor( + ctx: &Ctx, + initial: &str, + yes: bool, + question: &str, +) -> Result> { + let Some(body) = text_input::edited_body(&ctx.edit_text(initial)?) else { + ctx.eprint("No content entered.\n")?; + outcome::canceled(ctx)?; + return Ok(None); + }; + if body == initial.trim() { + ctx.print(outcome::NO_CHANGES)?; + return Ok(None); + } + if !confirm::proceed(ctx, yes, question)? { + return Ok(None); + } + Ok(Some(body)) +} + +/// Checks `--reply-to` before anything is looked up: a pasted comment link +/// gets the specific explanation, any other Linear URL the general one, and +/// anything else that is not a UUID is refused. +pub fn check_parent(parent_id: Option<&str>) -> Result<()> { + if let Some(parent) = parent_id { + reject_comment_url(parent)?; + reject_linear_url(parent, "the UUID of the comment to reply to")?; + if !is_linear_uuid(parent) { + return Err(Error::invalid(format!("Not a comment UUID: {parent}")) + .with_hint("--reply-to takes the UUID of the top-level comment to reply to, as `comment list --json` shows it.")); + } + } + Ok(()) +} + +/// The comment `--reply-to` names, looked up before anything is typed so +/// a reply that cannot be posted fails first. +pub struct Parent<'a> { + id: &'a str, + comment: ReplyParent, +} + +/// Looks up the comment `--reply-to` names, failing when it does not exist. +pub async fn fetch_parent<'a>( + client: &LinearClient, + parent_id: Option<&'a str>, +) -> Result>> { + let Some(id) = parent_id else { + return Ok(None); + }; + let data: GetReplyParent = client + .query(GetCommentVariables { id: id.to_owned() }) + .await + .map_err(|failure| failure.or_not_found("Comment", id))?; + let comment = data + .comment + .ok_or_else(|| Error::not_found("Comment", id))?; + Ok(Some(Parent { id, comment })) +} + +impl Parent<'_> { + /// Fails unless this is a top-level comment on `target` (described as + /// `described`, like `issue ENG-1`), so a reply never lands in another + /// entity's thread. An issue target must hold the issue's UUID. + pub fn check(&self, target: &CommentTarget, described: &str) -> Result<()> { + let Self { id, comment } = self; + if let Some(top) = &comment.parent_id { + return Err(Error::new(format!( + "Comment {id} is a reply; only a top-level comment can be replied to" + )) + .with_hint(format!( + "Reply in its thread with --reply-to {top}, the thread's top-level comment." + ))); + } + let on_target = match target { + CommentTarget::Issue { issue_id } => comment.issue_id.as_ref() == Some(issue_id), + CommentTarget::Document { + document_content_id, + } => comment.document_content_id.as_ref() == Some(document_content_id), + CommentTarget::Project { project_id } => { + comment.project_id.as_ref() == Some(project_id) + } + CommentTarget::Initiative { initiative_id } => { + comment.initiative_id.as_ref() == Some(initiative_id) + } + }; + if on_target { + Ok(()) + } else { + Err( + Error::new(format!("Comment {id} is not on {described}")).with_hint( + "--reply-to takes a top-level comment on the same entity, as its `comment list --json` shows.", + ), + ) + } + } +} + +/// The confirmation after the editor: a comment on `target`, or a reply. +pub fn question(target: &str, parent_id: Option<&str>) -> String { + let what = if parent_id.is_some() { + "reply" + } else { + "comment" + }; + format!("Post this {what} on {target}?") +} + +/// Build the mutation input, with `parent_id` already checked by [`check_parent`]. +pub fn build_input( + target: CommentTarget, + body: String, + parent_id: Option<&str>, + id: Option<&str>, +) -> CommentCreateInput { + let mut input = CommentCreateInput { + body, + parent_id: parent_id.map(str::to_owned), + id: id.map(str::to_owned), + issue_id: None, + document_content_id: None, + project_id: None, + initiative_id: None, + }; + match target { + CommentTarget::Issue { issue_id } => input.issue_id = Some(issue_id), + CommentTarget::Document { + document_content_id, + } => input.document_content_id = Some(document_content_id), + CommentTarget::Project { project_id } => input.project_id = Some(project_id), + CommentTarget::Initiative { initiative_id } => input.initiative_id = Some(initiative_id), + } + input +} + +/// Send the mutation once. A failure after the request may have reached +/// Linear says the comment may already exist; nothing is retried. +pub async fn create( + client: &LinearClient, + input: CommentCreateInput, +) -> Result { + let result: AddComment = client + .mutate(AddCommentVariables { input }) + .await + .map_err(|failure| failure.into_create_error("comment"))?; + if !result.comment_create.success { + return Err(Error::new("Failed to create comment")); + } + Ok(result.comment_create.comment) +} + +/// The success lines, naming the target as the user gave it. +pub fn output( + noun: &str, + original: &str, + parent_id: Option<&str>, + comment: &CreatedComment, +) -> Vec { + let what = if parent_id.is_some() { + "reply to" + } else { + "comment to" + }; + super::outcome::done( + "Added", + what, + &format!("{noun} {original}"), + Some(&comment.url), + ) + .into_bytes() +} + +/// A document as a comment target: the content record comments attach to, +/// and the title to name it by. +pub struct DocumentTarget { + pub document_content_id: String, + pub title: String, +} + +/// `document(id:)` is non-null, so Linear reports a missing document as a +/// GraphQL error; only that becomes NotFound. +pub async fn document_target( + client: &LinearClient, + document: &str, +) -> Result { + let data: GetDocumentCommentTarget = client + .query(DocumentCommentTargetVariables { + id: document.to_owned(), + }) + .await + .map_err(|failure| failure.or_not_found("Document", document))?; + let target = data.document; + let Some(document_content_id) = target.document_content_id else { + return Err(Error::new(format!( + "Document \"{}\" has no content record to comment on", + target.title + )) + .with_hint("Linear attaches document comments to the document's content; open the document in Linear once so it gets one, then retry.")); + }; + Ok(DocumentTarget { + document_content_id, + title: target.title, + }) +} + +#[cfg(test)] +mod tests; diff --git a/crates/linear-cli/src/commands/comment_add/tests.rs b/crates/linear-cli/src/commands/comment_add/tests.rs new file mode 100644 index 000000000..32b6bff73 --- /dev/null +++ b/crates/linear-cli/src/commands/comment_add/tests.rs @@ -0,0 +1,134 @@ +use serde_json::json; + +use crate::cli::values::TextSource; + +use super::{CommentTarget, build_input, check_parent, resolve_body}; + +#[test] +fn body_text_is_kept_exactly_and_blank_text_is_refused() { + let error = resolve_body( + Some("x"), + Some(&TextSource::File("/definitely/missing".into())), + ) + .expect_err("conflict"); + assert_eq!( + error.message(), + "Cannot specify both --body and --body-file" + ); + let literal = " **Bold** `code`\nline two ☃\t "; + assert_eq!( + resolve_body(Some(literal), None).expect("body"), + Some(literal.to_owned()) + ); + for blank in [" \t\n", "\u{a0}\u{3000}", "\u{2028}"] { + let error = resolve_body(Some(blank), None).expect_err("blank"); + assert_eq!(error.message(), "Comment body cannot be empty"); + } + assert_eq!(resolve_body(None, None).expect("no body"), None); +} + +#[test] +fn body_files_drop_a_byte_order_mark_and_trailing_whitespace_and_must_be_utf8_text() { + let dir = tempfile::tempdir().expect("temp dir"); + let write = |name: &str, bytes: &[u8]| { + let path = dir.path().join(name); + std::fs::write(&path, bytes).expect("write body file"); + TextSource::File(path) + }; + let text = write( + "text.md", + b"\xef\xbb\xbf# Title\r\n\n \xe2\x98\x83 *md* \n", + ); + assert_eq!( + resolve_body(None, Some(&text)).expect("body"), + Some("# Title\r\n\n ☃ *md*".to_owned()) + ); + let blank = write("blank.md", b"\xef\xbb\xbf \n"); + let error = resolve_body(None, Some(&blank)).expect_err("blank file"); + assert_eq!(error.message(), format!("Body file is empty: {blank}")); + for (name, bytes) in [ + ("truncated.md", &b"x\xe2\x82"[..]), + ("surrogate.md", b"a\xed\xa0\x80b"), + ("overlong.md", b"c\xc0\xafd"), + ] { + let path = write(name, bytes); + let error = resolve_body(None, Some(&path)).expect_err("invalid UTF-8"); + assert_eq!(error.message(), "Body file must be valid UTF-8", "{name}"); + } + let missing = TextSource::File(dir.path().join("missing.md")); + let error = resolve_body(None, Some(&missing)).expect_err("missing file"); + assert_eq!( + error.message(), + format!("Failed to read body file: {missing}") + ); +} + +#[test] +fn input_names_exactly_one_target_and_omits_absent_optionals() { + let input = |target, parent: Option<&str>, id: Option<&str>| { + serde_json::to_value(build_input(target, "Body".into(), parent, id)).expect("input") + }; + assert_eq!( + input( + CommentTarget::Issue { + issue_id: "i".into() + }, + None, + None + ), + json!({"body": "Body", "issueId": "i"}) + ); + assert_eq!( + input( + CommentTarget::Document { + document_content_id: "d".into() + }, + Some("p"), + None + ), + json!({"body": "Body", "parentId": "p", "documentContentId": "d"}) + ); + assert_eq!( + input( + CommentTarget::Project { + project_id: "pr".into() + }, + None, + Some("fixed") + ), + json!({"body": "Body", "id": "fixed", "projectId": "pr"}) + ); + assert_eq!( + input( + CommentTarget::Initiative { + initiative_id: "in".into() + }, + None, + None + ), + json!({"body": "Body", "initiativeId": "in"}) + ); +} + +#[test] +fn a_parent_comment_link_is_explained_before_other_linear_urls() { + let link = "https://linear.app/acme/issue/ENG-1/title#comment-abcdef12"; + let error = check_parent(Some(link)).expect_err("comment link"); + assert_eq!( + error.message(), + format!( + "\"{link}\" links to a comment, but a comment URL only carries the first eight characters of its ID." + ) + ); + let url = "https://linear.app/acme/issue/ENG-1"; + let error = check_parent(Some(url)).expect_err("Linear URL"); + assert_eq!( + error.message(), + format!("\"{url}\" is a Linear URL, and this command does not take one.") + ); + assert_eq!( + error.hint(), + Some("Pass the UUID of the comment to reply to.") + ); + check_parent(Some("7d2e4f1a-3b5c-4d6e-8f90-a1b2c3d4e5f6")).expect("comment UUID"); +} diff --git a/crates/linear-cli/src/commands/comments.rs b/crates/linear-cli/src/commands/comments.rs new file mode 100644 index 000000000..9b17ac562 --- /dev/null +++ b/crates/linear-cli/src/commands/comments.rs @@ -0,0 +1,359 @@ +//! Comment lists, shared by the issue, project, document and initiative +//! `comment list` commands: fetching every page, and printing threads or JSON. +use crate::graphql::operations::user::UserRef; +use std::collections::{HashMap, HashSet}; +use std::num::NonZeroU16; + +use chrono::{DateTime, Local, Utc}; +use cynic::QueryBuilder; +use serde::Serialize; +use serde::de::DeserializeOwned; +use unicode_width::UnicodeWidthStr; + +use crate::cli::Limit; +use crate::client::LinearClient; +use crate::commands::json; +use crate::commands::relative_time::ago; +use crate::ctx::Ctx; +use crate::error::{Error, Result}; +use crate::graphql::operations::comment::{ + CommentBotActor, CommentConnection, CommentExternalUser, CommentNode, CommentParent, +}; +use crate::graphql::pagination::{self, Page}; +use crate::platform::markdown_terminal::{self, RenderOptions}; +use crate::platform::style::{bold, heading, underline}; +use crate::platform::terminal_text::{multiline, single_line, wrap_parts}; + +/// A comments query for one kind of entity. +pub trait CommentSource: QueryBuilder + DeserializeOwned { + /// The entity, capitalized, as in "Issue not found". + const ENTITY: &'static str; + type Variables: Serialize; + + /// Variables for one page of up to `first` comments after cursor `after`. + fn variables(id: &str, after: Option, first: i32) -> Self::Variables; + + /// The page of comments, or `None` when the entity does not exist. + fn comments(self) -> Option; +} + +/// The comments on entity `id` (which the user called `original`), up to `limit`. +pub async fn fetch( + client: &LinearClient, + original: &str, + id: &str, + limit: Limit, +) -> Result> { + let not_found = || Error::not_found(S::ENTITY, original); + pagination::collect(limit.max(), |after, first| { + let variables = S::variables(id, after, first); + async move { + let response: S = client + .query(variables) + .await + .map_err(|failure| failure.or_not_found(S::ENTITY, original))?; + let connection = response.comments().ok_or_else(not_found)?; + Ok(Page { + nodes: connection.nodes, + page_info: connection.page_info, + }) + } + }) + .await +} + +/// Prints comments as threads, or as JSON. `noun` names the entity in the +/// empty message ("issue"); long threads are paged on a terminal when `paging` +/// is on. +pub fn print( + ctx: &Ctx, + comments: &[CommentNode], + as_json: bool, + noun: &str, + paging: bool, +) -> Result<()> { + if as_json { + ctx.print(render_json(comments)) + } else if comments.is_empty() { + ctx.print(format!("No comments found for this {noun}\n")) + } else if ctx.stdout_tty() { + let rendered = render_terminal(comments, Utc::now(), &ctx.render_options()); + ctx.page(&rendered, paging) + } else { + ctx.print(render_text(comments, Utc::now(), ctx.color())) + } +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct JsonComment<'a> { + id: &'a cynic::Id, + body: &'a str, + quoted_text: &'a Option, + created_at: &'a crate::graphql::scalars::DateTime, + updated_at: &'a crate::graphql::scalars::DateTime, + edited_at: &'a Option, + url: &'a str, + user: &'a Option, + external_user: &'a Option, + bot_actor: &'a Option, + parent: &'a Option, +} + +fn render_json(nodes: &[CommentNode]) -> Vec { + let comments: Vec<_> = nodes + .iter() + .map(|node| JsonComment { + id: &node.id, + body: &node.body, + quoted_text: &node.quoted_text, + created_at: &node.created_at, + updated_at: &node.updated_at, + edited_at: &node.edited_at, + url: &node.url, + user: &node.user, + external_user: &node.external_user, + bot_actor: &node.bot_actor, + parent: &node.parent, + }) + .collect(); + json::render(&comments) +} + +fn nonempty(value: &str) -> Option<&str> { + (!value.is_empty()).then_some(value) +} + +fn author(node: &CommentNode) -> &str { + node.user + .as_ref() + .and_then(|user| nonempty(&user.display_name).or_else(|| nonempty(&user.name))) + .or_else(|| { + node.external_user + .as_ref() + .and_then(|user| nonempty(&user.display_name).or_else(|| nonempty(&user.name))) + }) + .or_else(|| { + node.bot_actor.as_ref().map(|bot| { + bot.name + .as_deref() + .and_then(nonempty) + .unwrap_or(&bot.bot_type) + }) + }) + .unwrap_or("Unknown") +} + +fn indent(text: &str) -> String { + text.split('\n') + .map(|line| format!(" {line}")) + .collect::>() + .join("\n") +} + +fn header(node: &CommentNode, verb: &str, now: DateTime, color: bool) -> String { + format!( + "{} {verb} {} [{}]", + bold(&format!("@{}", single_line(author(node))), color), + ago(node.created_at.0, now, &Local), + node.id.inner() + ) +} + +/// Comments grouped for display: threads oldest first, each with its +/// replies oldest first, then replies whose parent is not in the list. +struct Threads<'a> { + roots: Vec<(&'a CommentNode, Vec<&'a CommentNode>)>, + orphans: Vec<(&'a CommentNode, &'a str)>, +} + +impl<'a> Threads<'a> { + fn new(nodes: &'a [CommentNode]) -> Self { + let mut roots: Vec<&CommentNode> = + nodes.iter().filter(|node| node.parent.is_none()).collect(); + let root_ids: HashSet<&str> = roots.iter().map(|node| node.id.inner()).collect(); + let mut replies: HashMap<&str, Vec<&CommentNode>> = HashMap::new(); + let mut orphans: Vec<(&CommentNode, &str)> = Vec::new(); + for node in nodes { + if let Some(parent) = &node.parent { + if root_ids.contains(parent.id.inner()) { + replies.entry(parent.id.inner()).or_default().push(node); + } else { + orphans.push((node, parent.id.inner())); + } + } + } + // Oldest first, as `issue view` and Linear show a conversation. + roots.sort_by_key(|node| node.created_at); + orphans.sort_by_key(|(node, _)| node.created_at); + let roots = roots + .into_iter() + .map(|root| { + let mut siblings = replies.remove(root.id.inner()).unwrap_or_default(); + siblings.sort_by_key(|node| node.created_at); + (root, siblings) + }) + .collect(); + Self { roots, orphans } + } +} + +fn render_text(nodes: &[CommentNode], now: DateTime, color: bool) -> String { + let threads = Threads::new(nodes); + let mut output = String::new(); + for (root, replies) in threads.roots { + output.push_str(&header(root, "commented", now, color)); + output.push('\n'); + if let Some(quote) = &root.quoted_text { + output.push_str("> "); + output.push_str(&multiline(quote)); + output.push('\n'); + } + output.push_str(&multiline(&root.body)); + output.push('\n'); + if !replies.is_empty() { + output.push('\n'); + for reply in replies { + output.push_str(&indent(&header(reply, "replied", now, color))); + output.push('\n'); + if let Some(quote) = &reply.quoted_text { + output.push_str(&indent(&format!("> {}", multiline(quote)))); + output.push('\n'); + } + output.push_str(&indent(&multiline(&reply.body))); + output.push('\n'); + } + } + output.push('\n'); + } + for (reply, parent) in threads.orphans { + output.push_str(&indent(&header( + reply, + &format!("replied to [{parent}]"), + now, + color, + ))); + output.push('\n'); + if let Some(quote) = &reply.quoted_text { + output.push_str(&indent(&format!("> {}", multiline(quote)))); + output.push('\n'); + } + output.push_str(&indent(&multiline(&reply.body))); + output.push_str("\n\n"); + } + output +} + +/// A comment's ID in brackets, linked to the comment when `links` is on, +/// with its display width. +pub fn id_part(id: &str, url: &str, links: bool) -> (String, usize) { + let text = format!("[{id}]"); + let width = text.width(); + if links { + (markdown_terminal::hyperlink(&text, url), width) + } else { + (text, width) + } +} + +/// A comment's header on the terminal, `@alice commented 3 days ago [id]`: +/// the author, the action, then `suffix`, wrapped to the terminal between +/// parts with every line starting with `indent`. +pub fn terminal_header( + author: &str, + action: &str, + suffix: Vec<(String, usize)>, + indent: &str, + options: &RenderOptions, +) -> String { + let author = format!("@{}", single_line(author)); + let mut parts = vec![ + (heading(&author, options.styled), author.width()), + (underline(action, options.styled), action.width()), + ]; + parts.extend(suffix); + wrap_parts(&parts, indent, usize::from(options.columns.get())) +} + +/// Markdown rendered for the terminal two columns in, as a reply under its +/// thread. +pub fn render_reply(markdown: &str, options: &RenderOptions) -> String { + let narrower = RenderOptions { + columns: NonZeroU16::new(options.columns.get().saturating_sub(2)) + .unwrap_or(NonZeroU16::MIN), + ..options.clone() + }; + markdown_terminal::render(markdown, &narrower) + .lines() + .map(|line| { + if line.is_empty() { + "\n".to_owned() + } else { + format!(" {line}\n") + } + }) + .collect() +} + +/// A comment's body as Markdown, after the text it quotes as a block quote. +fn body_markdown(node: &CommentNode) -> String { + match &node.quoted_text { + Some(quote) => { + let quoted: Vec = quote.lines().map(|line| format!("> {line}")).collect(); + format!("{}\n\n{}", quoted.join("\n"), node.body) + } + None => node.body.clone(), + } +} + +/// Threads for the terminal, laid out like the comments of `issue view`: +/// wrapped headers, then each body rendered as Markdown. +fn render_terminal(nodes: &[CommentNode], now: DateTime, options: &RenderOptions) -> String { + let threads = Threads::new(nodes); + let action = + |verb: &str, node: &CommentNode| format!("{verb} {}", ago(node.created_at.0, now, &Local)); + let id = |node: &CommentNode| vec![id_part(node.id.inner(), &node.url, options.styled)]; + let mut out = String::new(); + for (root, replies) in threads.roots { + if !out.is_empty() { + out.push('\n'); + } + out.push_str(&terminal_header( + author(root), + &action("commented", root), + id(root), + "", + options, + )); + out.push('\n'); + out.push_str(&markdown_terminal::render(&body_markdown(root), options)); + for reply in replies { + out.push('\n'); + out.push_str(&terminal_header( + author(reply), + &action("replied", reply), + id(reply), + " ", + options, + )); + out.push('\n'); + out.push_str(&render_reply(&body_markdown(reply), options)); + } + } + for (reply, parent) in threads.orphans { + if !out.is_empty() { + out.push('\n'); + } + let action = format!("{} to [{parent}]", action("replied", reply)); + out.push_str(&terminal_header( + author(reply), + &action, + id(reply), + " ", + options, + )); + out.push('\n'); + out.push_str(&render_reply(&body_markdown(reply), options)); + } + out +} diff --git a/crates/linear-cli/src/commands/completions.rs b/crates/linear-cli/src/commands/completions.rs new file mode 100644 index 000000000..6429bf4a8 --- /dev/null +++ b/crates/linear-cli/src/commands/completions.rs @@ -0,0 +1,56 @@ +//! `linear completions `: the script that registers `linear` with a +//! shell's completion system. The script asks the binary for candidates on +//! each completion (`COMPLETE= linear -- `, handled in `main`), +//! so completions follow the installed version at every command depth. +use crate::cli::completions::Completions; +use crate::error::{Error, Result}; +use clap_complete::env::{EnvCompleter, Shells}; + +pub const DEFAULT_COMMAND_NAME: &str = "linear"; + +/// The environment variable that switches the binary into completion mode. +const COMPLETE_VAR: &str = "COMPLETE"; + +pub fn script(args: &Completions) -> Result> { + let shell_name = args.shell.to_string(); + let shells = Shells::builtins(); + let shell: &dyn EnvCompleter = shells + .completer(&shell_name) + .expect("every clap_complete shell has a dynamic completer"); + let bin = args.name.as_deref().unwrap_or(DEFAULT_COMMAND_NAME); + let completer = completer()?; + let mut output = Vec::new(); + shell + .write_registration( + COMPLETE_VAR, + DEFAULT_COMMAND_NAME, + bin, + &completer, + &mut output, + ) + .expect("writing to a Vec cannot fail"); + Ok(output) +} + +/// How the shell should invoke this binary for candidates: the name it was +/// run as when that is a bare command found on PATH, otherwise its absolute +/// path. +fn completer() -> Result { + let argv0 = std::env::args_os() + .next() + .ok_or_else(|| Error::new("The program was started without a name"))?; + let path = std::path::PathBuf::from(&argv0); + let path = if path.components().count() > 1 && path.is_relative() { + std::env::current_dir() + .map_err(|error| { + Error::new(format!("Could not read the current directory: {error}")) + .with_source(error) + })? + .join(path) + } else { + path + }; + path.into_os_string() + .into_string() + .map_err(|_| Error::new("The program path is not valid UTF-8")) +} diff --git a/crates/linear-cli/src/commands/config_generate.rs b/crates/linear-cli/src/commands/config_generate.rs new file mode 100644 index 000000000..6a4b22650 --- /dev/null +++ b/crates/linear-cli/src/commands/config_generate.rs @@ -0,0 +1,179 @@ +//! `linear config`: a workspace, team and sort order from flags or prompts, +//! written to a project config file at the repository root. +use std::path::{Path, PathBuf}; + +use serde::Serialize; + +use crate::auth::ApiKeyInput; +use crate::cli::config::Config; +use crate::config::IssueSort; +use crate::config::{RealFileSource, repo_root}; +use crate::ctx::{self, Ctx}; +use crate::error::{Error, Result, ResultExt}; +use crate::graphql::operations::user::GetViewer; +use crate::platform::prompt::Choice; +use crate::refs::{self, team::ResolvedTeam, team::TeamReference}; + +const BANNER: &str = "\n██ ██ ███ ██ ███████ █████ ██████ ██████ ██ ██\n██ ██ ████ ██ ██ ██ ██ ██ ██ ██ ██ ██\n██ ██ ██ ██ ██ █████ ███████ ██████ ██ ██ ██\n██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██\n███████ ██ ██ ████ ███████ ██ ██ ██ ██ ██████ ███████ ██\n\n"; +const HEADER: &str = "# linear cli\n# https://github.com/schpet/linear-cli\n\n"; + +/// The values written to the config file. +#[derive(Serialize)] +struct ProjectConfig { + workspace: String, + team_id: String, + issue_sort: &'static str, +} + +/// The team `--team` named, or the teams to choose from. +enum Team { + Given(ResolvedTeam), + Choose(Vec), +} + +pub fn run(ctx: &Ctx, args: &Config) -> Result<()> { + generate(ctx, args).context("Failed to generate configuration") +} + +fn generate(ctx: &Ctx, args: &Config) -> Result<()> { + let workspaces = stored_workspaces(ctx)?; + let asks = args.team.is_none() || args.sort.is_none() || workspaces.len() > 1; + if asks && !ctx.interactive() { + let mut flags = vec!["--team ", "--sort "]; + if workspaces.len() > 1 { + flags.push("--workspace "); + } + return Err(Error::new( + "Some settings are missing and there is no terminal to ask for them", + ) + .with_hint(format!("Pass {}.", flags.join(" and ")))); + } + let team = args + .team + .as_deref() + .map(|team| TeamReference::parse(team, &ctx.scope()?)) + .transpose()?; + if asks { + ctx.eprint(BANNER)?; + } + let workspace = match workspaces.as_slice() { + [] => ctx.workspace().map(str::to_owned), + [only] => Some(only.clone()), + _ => Some(pick_workspace(ctx, &workspaces)?), + }; + let inputs = ctx::selection_inputs(ctx.options(), workspace.as_deref()); + let key = ctx::select_key(&inputs, ctx.credentials()?); + ctx.report_credential_warnings()?; + let client = ctx::connect(ctx.options(), key?, &ctx.config().network_env)?; + let (url_key, team) = ctx.spin(true, async { + let url_key = client + .query::(()) + .await? + .viewer + .organization + .url_key; + let team = match &team { + Some(lookup) => Team::Given(refs::team::resolve(&client, lookup).await?), + None => Team::Choose(refs::team::fetch_all(&client).await?), + }; + Ok::<_, Error>((url_key, team)) + })?; + let team = match team { + Team::Given(team) => team, + Team::Choose(teams) => pick_team(ctx, teams)?, + }; + let sort = match args.sort { + Some(sort) => sort, + None => ctx.prompter()?.select( + "Sort order:", + vec![ + Choice::new("manual", IssueSort::Manual), + Choice::new("priority", IssueSort::Priority), + ], + )?, + }; + let config = ProjectConfig { + workspace: url_key, + team_id: team.key, + issue_sort: match sort { + IssueSort::Manual => "manual", + IssueSort::Priority => "priority", + }, + }; + let path = destination(ctx.cwd()); + let contents = format!( + "{HEADER}{}", + toml::to_string(&config).expect("project config always serializes as TOML") + ); + std::fs::write(&path, contents).map_err(|error| { + Error::new(format!("Could not write {}: {error}", path.display())).with_source(error) + })?; + ctx.print(format!("Configuration written to {}\n", path.display())) +} + +/// The stored workspaces to choose from: none when `--workspace` or an API +/// key from the environment or a config file already decides. +fn stored_workspaces(ctx: &Ctx) -> Result> { + let decided = ctx.workspace().is_some() + || match ApiKeyInput::from_options(ctx.options()) { + ApiKeyInput::Raw { value, .. } | ApiKeyInput::Sourced { value, .. } => { + !value.expose().is_empty() + } + ApiKeyInput::Absent => false, + }; + if decided { + return Ok(Vec::new()); + } + let store = ctx.credentials()?; + if store.workspaces().is_empty() { + return Err(Error::auth("No authentication configured") + .with_hint("Run `linear auth login` to add a workspace.")); + } + Ok(store.workspaces().to_vec()) +} + +fn pick_workspace(ctx: &Ctx, workspaces: &[String]) -> Result { + let default = ctx.credentials()?.default(); + let start = workspaces + .iter() + .position(|name| default == Some(name.as_str())) + .unwrap_or(0); + let choices = workspaces + .iter() + .map(|name| { + let label = if default == Some(name.as_str()) { + format!("{name} (default)") + } else { + name.clone() + }; + Choice::new(label, name.clone()) + }) + .collect(); + ctx.prompter()?.select_from("Workspace:", choices, start) +} + +fn pick_team(ctx: &Ctx, teams: Vec) -> Result { + if teams.is_empty() { + return Err(refs::team::none_accessible()); + } + let choices = teams + .into_iter() + .map(|team| Choice::new(format!("{} ({})", team.name, team.key), team)) + .collect(); + ctx.prompter()?.select("Team:", choices) +} + +/// `.config/linear.toml` at the repository root when `.config` exists there, +/// else `.linear.toml` at the root, or in `cwd` outside a repository. Each is +/// a place config discovery looks. +fn destination(cwd: &Path) -> PathBuf { + let Some(root) = repo_root(cwd, &RealFileSource) else { + return cwd.join(".linear.toml"); + }; + let config_dir = root.join(".config"); + if config_dir.is_dir() { + config_dir.join("linear.toml") + } else { + root.join(".linear.toml") + } +} diff --git a/crates/linear-cli/src/commands/confirm.rs b/crates/linear-cli/src/commands/confirm.rs new file mode 100644 index 000000000..15b6f7485 --- /dev/null +++ b/crates/linear-cli/src/commands/confirm.rs @@ -0,0 +1,17 @@ +//! The final question before a destructive action, or before sending content +//! that was typed at a prompt or in the editor. +use crate::commands::outcome; +use crate::ctx::Ctx; +use crate::error::Result; + +/// Whether the command goes ahead: `yes` (the `--yes` flag), or a yes at a +/// question that defaults to no, so a stray Enter never deletes anything or +/// sends a half-finished draft. A no reports the cancellation. Commands whose +/// content comes entirely from flags do not ask before creating or updating. +pub fn proceed(ctx: &Ctx, yes: bool, question: &str) -> Result { + if yes || ctx.confirm(question, "--yes")? { + return Ok(true); + } + outcome::canceled(ctx)?; + Ok(false) +} diff --git a/crates/linear-cli/src/commands/cycle/list.rs b/crates/linear-cli/src/commands/cycle/list.rs new file mode 100644 index 000000000..c577dadc5 --- /dev/null +++ b/crates/linear-cli/src/commands/cycle/list.rs @@ -0,0 +1,131 @@ +//! `cycle list`: every page, active cycle first, as a table or JSON. + +use std::cmp::Ordering; + +use crate::cli::cycle::CycleList; +use crate::client::LinearClient; +use crate::commands::json; +use crate::commands::table::{Cell, Column, Table}; +use crate::commands::team_key::team_or_configured; +use crate::ctx::Ctx; +use crate::error::{Result, ResultExt}; +use crate::graphql::operations::cycle::{self, GetTeamCycles, GetTeamCyclesVariables}; +use crate::graphql::pagination::{self, Page}; +use crate::platform::style; +use crate::refs::{self, team::TeamReference}; + +pub fn run(ctx: &Ctx, args: &CycleList) -> Result<()> { + list(ctx, args).context("Failed to list cycles") +} + +fn list(ctx: &Ctx, args: &CycleList) -> Result<()> { + let team = team_or_configured(ctx, args.team.as_deref())?; + let lookup = TeamReference::parse(&team, &ctx.scope()?)?; + let client = ctx.client()?; + let mut cycles = ctx.spin(!args.json, async { + let team = refs::team::resolve(client, &lookup).await?; + fetch(client, &team.id).await + })?; + args.limit.apply(&mut cycles); + if args.json { + ctx.print(json::render(&cycles)) + } else if cycles.is_empty() { + ctx.print("No cycles found for this team.\n") + } else { + ctx.print(render_text(&cycles).render_for(ctx)) + } +} + +/// Every cycle of the team, in [`order`]. +async fn fetch(client: &LinearClient, team_id: &str) -> Result> { + let mut nodes = pagination::collect(None, |after, first| { + let variables = GetTeamCyclesVariables { + team_id: team_id.to_owned(), + first: Some(first), + after, + }; + async move { + let data: GetTeamCycles = client.query(variables).await?; + Ok(Page { + nodes: data.team.cycles.nodes, + page_info: data.team.cycles.page_info, + }) + } + }) + .await?; + nodes.sort_by(order); + Ok(nodes) +} + +/// The active cycle, then upcoming cycles from the soonest, then past ones +/// from the most recent: the cycles that matter now come first, and far-off +/// planned cycles do not push the active one down. +fn order(left: &cycle::Cycle, right: &cycle::Cycle) -> Ordering { + fn phase(cycle: &cycle::Cycle) -> u8 { + if cycle.is_active { + 0 + } else if cycle.is_future { + 1 + } else { + 2 + } + } + phase(left) + .cmp(&phase(right)) + .then_with(|| match phase(left) { + 1 => left.starts_at.cmp(&right.starts_at), + _ => right.starts_at.cmp(&left.starts_at), + }) +} + +fn cycle_name(cycle: &cycle::Cycle, number: &str) -> String { + cycle + .name + .as_deref() + .filter(|name| !name.is_empty()) + .map(str::to_owned) + .unwrap_or_else(|| format!("Cycle {number}")) +} + +fn status(cycle: &cycle::Cycle) -> &'static str { + if cycle.is_active { + "Active" + } else if cycle.is_future { + "Upcoming" + } else if cycle.completed_at.is_some() { + "Completed" + } else if cycle.is_past { + "Past" + } else { + "Unknown" + } +} + +fn render_text(nodes: &[cycle::Cycle]) -> Table { + let mut table = Table::new([ + Column::fixed("#"), + Column::flexible("NAME"), + Column::fixed("START").droppable(1), + Column::fixed("END"), + Column::fixed("STATUS"), + ]); + for cycle in nodes { + let number = cycle.number.to_string(); + let name = cycle_name(cycle, &number); + let status = if cycle.is_active { + Cell::styled(status(cycle), style::green) + } else if cycle.is_past || cycle.completed_at.is_some() { + Cell::styled(status(cycle), style::gray) + } else { + Cell::from(status(cycle)) + }; + table.row([ + Cell::from(number), + Cell::from(name), + Cell::from(cycle.starts_at.0.date_naive().to_string()), + Cell::from(cycle.ends_at.0.date_naive().to_string()), + status, + ]); + } + table +} diff --git a/crates/linear-cli/src/commands/cycle/mod.rs b/crates/linear-cli/src/commands/cycle/mod.rs new file mode 100644 index 000000000..109aba5ce --- /dev/null +++ b/crates/linear-cli/src/commands/cycle/mod.rs @@ -0,0 +1,14 @@ +//! `linear cycle`: a team's cycles. +mod list; +pub mod view; + +use crate::cli::cycle::CycleCommand; +use crate::ctx::Ctx; +use crate::error::Result; + +pub fn run(ctx: &Ctx, command: &CycleCommand) -> Result<()> { + match command { + CycleCommand::List(args) => list::run(ctx, args), + CycleCommand::View(args) => view::run(ctx, args), + } +} diff --git a/crates/linear-cli/src/commands/cycle/view.rs b/crates/linear-cli/src/commands/cycle/view.rs new file mode 100644 index 000000000..a0e39f8b1 --- /dev/null +++ b/crates/linear-cli/src/commands/cycle/view.rs @@ -0,0 +1,215 @@ +//! `cycle view`: one cycle by number, name, URL or relative offset. + +use chrono::{DateTime, TimeZone, Utc}; +use serde::Serialize; + +use crate::cli::cycle::CycleView; +use crate::commands::json; +use crate::commands::relative_time::ago; +use crate::commands::team_key::{configured_team_key, no_team}; +use crate::ctx::Ctx; +use crate::error::{Error, Result, ResultExt}; +use crate::graphql::operations::cycle::{DetailCycle, DetailVariables, GetCycleDetails}; +use crate::graphql::scalars::WholeNumber; +use crate::refs::{self, cycle::CycleReference, team::TeamReference}; + +pub fn run(ctx: &Ctx, args: &CycleView) -> Result<()> { + view(ctx, args).context("Failed to fetch cycle details") +} + +fn view(ctx: &Ctx, args: &CycleView) -> Result<()> { + let scope = ctx.scope()?; + let reference = CycleReference::parse(&args.cycle_ref, &scope)?; + let team = args + .team + .clone() + .or_else(|| reference.url_team_key().map(str::to_owned)) + .or_else(|| configured_team_key(ctx.options())) + .ok_or_else(no_team)?; + let team = TeamReference::parse(&team, &scope)?; + let client = ctx.client()?; + let cycle = ctx.spin(!args.json, async { + let team = refs::team::resolve(client, &team).await?; + let id = refs::cycle::resolve(client, &team.id, &reference).await?; + let details: GetCycleDetails = client.query(DetailVariables { id }).await?; + details + .cycle + .ok_or_else(|| Error::not_found("Cycle", &args.cycle_ref)) + })?; + if args.json { + ctx.print(render_json(&cycle)) + } else { + ctx.show_markdown( + &markdown(&cycle, Utc::now(), &chrono::Local), + !args.no_pager, + ) + } +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct JsonCycle<'a> { + id: &'a cynic::Id, + number: WholeNumber, + name: &'a Option, + description: &'a Option, + starts_at: &'a crate::graphql::scalars::DateTime, + ends_at: &'a crate::graphql::scalars::DateTime, + completed_at: &'a Option, + is_active: bool, + is_future: bool, + is_past: bool, + created_at: &'a crate::graphql::scalars::DateTime, + updated_at: &'a crate::graphql::scalars::DateTime, + team: JsonTeam<'a>, + issues: Vec>, +} +#[derive(Serialize)] +struct JsonTeam<'a> { + id: &'a cynic::Id, + key: &'a str, + name: &'a str, +} +#[derive(Serialize)] +struct JsonIssue<'a> { + id: &'a cynic::Id, + identifier: &'a str, + title: &'a str, + state: JsonState<'a>, +} +#[derive(Serialize)] +struct JsonState<'a> { + name: &'a str, + #[serde(rename = "type")] + state_type: &'a str, +} + +fn render_json(cycle: &DetailCycle) -> Vec { + let projected = JsonCycle { + id: &cycle.id, + number: cycle.number, + name: &cycle.name, + description: &cycle.description, + starts_at: &cycle.starts_at, + ends_at: &cycle.ends_at, + completed_at: &cycle.completed_at, + is_active: cycle.is_active, + is_future: cycle.is_future, + is_past: cycle.is_past, + created_at: &cycle.created_at, + updated_at: &cycle.updated_at, + team: JsonTeam { + id: &cycle.team.id, + key: &cycle.team.key, + name: &cycle.team.name, + }, + issues: cycle + .issues + .nodes + .iter() + .map(|issue| JsonIssue { + id: &issue.id, + identifier: &issue.identifier, + title: &issue.title, + state: JsonState { + name: &issue.state.name, + state_type: &issue.state.state_type, + }, + }) + .collect(), + }; + json::render(&projected) +} + +fn markdown(cycle: &DetailCycle, now: DateTime, zone: &Tz) -> String { + let number = cycle.number; + let title = cycle + .name + .as_deref() + .filter(|name| !name.is_empty()) + .map(str::to_owned) + .unwrap_or_else(|| format!("Cycle {number}")); + let status = if cycle.is_active { + "Active" + } else if cycle.is_future { + "Upcoming" + } else if cycle.completed_at.is_some() { + "Completed" + } else if cycle.is_past { + "Past" + } else { + "Unknown" + }; + let mut lines = vec![ + format!("# {title}"), + String::new(), + format!("**Number:** {number}"), + format!("**Start:** {}", cycle.starts_at.0.date_naive()), + format!("**End:** {}", cycle.ends_at.0.date_naive()), + format!("**Status:** {status}"), + format!("**Team:** {} ({})", cycle.team.name, cycle.team.key), + String::new(), + format!("**Created:** {}", ago(cycle.created_at.0, now, zone)), + format!("**Updated:** {}", ago(cycle.updated_at.0, now, zone)), + ]; + if let Some(description) = cycle + .description + .as_deref() + .filter(|value| !value.is_empty()) + { + lines.extend([ + String::new(), + "## Description".to_owned(), + String::new(), + description.to_owned(), + ]); + } + let issues = &cycle.issues.nodes; + if issues.is_empty() { + lines.extend([String::new(), "_No issues in this cycle yet._".to_owned()]); + } else { + let count = |kind: &str| { + issues + .iter() + .filter(|issue| issue.state.state_type == kind) + .count() + }; + let completed = count("completed"); + let total = issues.len(); + let percent = (completed * 200 + total) / (total * 2); + lines.extend([ + String::new(), + "## Issues".to_owned(), + String::new(), + format!("**Progress:** {completed}/{total} ({percent}%)"), + format!("**Total Issues:** {total}"), + ]); + for (kind, label) in [ + ("completed", "Completed"), + ("started", "In Progress"), + ("unstarted", "To Do"), + ("backlog", "Backlog"), + ("triage", "Triage"), + ("canceled", "Canceled"), + ] { + let value = count(kind); + if value > 0 { + lines.push(format!("**{label}:** {value}")); + } + } + lines.extend([String::new(), "**Issues:**".to_owned(), String::new()]); + for issue in issues.iter().take(10) { + lines.push(format!( + "- {}: {} ({})", + issue.identifier, issue.title, issue.state.name + )); + } + if total > 10 { + lines.extend([ + String::new(), + format!("_...and {} more issues_", total - 10), + ]); + } + } + lines.join("\n") +} diff --git a/crates/linear-cli/src/commands/document/comment/add.rs b/crates/linear-cli/src/commands/document/comment/add.rs new file mode 100644 index 000000000..c471c91ae --- /dev/null +++ b/crates/linear-cli/src/commands/document/comment/add.rs @@ -0,0 +1,57 @@ +//! `document comment add`: a comment or reply on a document. +use crate::cli::document::DocumentCommentAdd; +use crate::commands::comment_add::{self, CommentTarget}; +use crate::ctx::Ctx; +use crate::error::{Error, Result, ResultExt}; + +pub fn run(ctx: &Ctx, args: &DocumentCommentAdd) -> Result<()> { + add(ctx, args).context("Failed to add comment") +} + +fn add(ctx: &Ctx, args: &DocumentCommentAdd) -> Result<()> { + let document = crate::commands::document::common::reference(ctx, &args.document)?; + let body = comment_add::resolve_body(args.body.as_deref(), args.body_file.as_ref())?; + comment_add::check_parent(args.reply_to.as_deref())?; + if body.is_none() { + comment_add::require_editor(ctx)?; + } + let client = ctx.client()?; + let target = ctx.spin(true, async { + let (target, parent) = tokio::try_join!( + comment_add::document_target(client, &document), + comment_add::fetch_parent(client, args.reply_to.as_deref()), + )?; + if let Some(parent) = parent { + let content = CommentTarget::Document { + document_content_id: target.document_content_id.clone(), + }; + parent.check(&content, &format!("document \"{}\"", target.title))?; + } + Ok::<_, Error>(target) + })?; + let body = match body { + Some(body) => body, + None => { + let question = comment_add::question( + &format!("document \"{}\"", target.title), + args.reply_to.as_deref(), + ); + match comment_add::write_in_editor(ctx, "", args.confirm.yes, &question)? { + Some(body) => body, + None => return Ok(()), + } + } + }; + // Linear attaches document comments to the document's content record. + let target = CommentTarget::Document { + document_content_id: target.document_content_id, + }; + let input = comment_add::build_input(target, body, args.reply_to.as_deref(), None); + let comment = ctx.spin(true, comment_add::create(client, input))?; + ctx.print(comment_add::output( + "document", + &document, + args.reply_to.as_deref(), + &comment, + )) +} diff --git a/crates/linear-cli/src/commands/document/comment/list.rs b/crates/linear-cli/src/commands/document/comment/list.rs new file mode 100644 index 000000000..9a3e68a71 --- /dev/null +++ b/crates/linear-cli/src/commands/document/comment/list.rs @@ -0,0 +1,38 @@ +//! `document comment list`: every comment on a document, as threads or JSON. +use crate::cli::document::DocumentCommentList; +use crate::commands::comments::{self, CommentSource}; +use crate::ctx::Ctx; +use crate::error::{Result, ResultExt}; +use crate::graphql::operations::comment::CommentConnection; +use crate::graphql::operations::comment::{GetDocumentComments, GetDocumentCommentsVariables}; + +pub fn run(ctx: &Ctx, args: &DocumentCommentList) -> Result<()> { + list(ctx, args).context("Failed to list comments") +} + +fn list(ctx: &Ctx, args: &DocumentCommentList) -> Result<()> { + let id = crate::commands::document::common::reference(ctx, &args.document)?; + let client = ctx.client()?; + let nodes = ctx.spin( + !args.json, + comments::fetch::(client, &args.document, &id, args.limit), + )?; + comments::print(ctx, &nodes, args.json, "document", !args.no_pager) +} + +impl CommentSource for GetDocumentComments { + const ENTITY: &'static str = "Document"; + type Variables = GetDocumentCommentsVariables; + + fn variables(id: &str, after: Option, first: i32) -> Self::Variables { + GetDocumentCommentsVariables { + id: id.to_owned(), + after, + first, + } + } + + fn comments(self) -> Option { + self.document.map(|document| document.comments) + } +} diff --git a/crates/linear-cli/src/commands/document/comment/mod.rs b/crates/linear-cli/src/commands/document/comment/mod.rs new file mode 100644 index 000000000..413358079 --- /dev/null +++ b/crates/linear-cli/src/commands/document/comment/mod.rs @@ -0,0 +1,14 @@ +//! `linear document comment`: a document's comments. +mod add; +mod list; + +use crate::cli::document::DocumentCommentCommand; +use crate::ctx::Ctx; +use crate::error::Result; + +pub fn run(ctx: &Ctx, command: &DocumentCommentCommand) -> Result<()> { + match command { + DocumentCommentCommand::Add(args) => add::run(ctx, args), + DocumentCommentCommand::List(args) => list::run(ctx, args), + } +} diff --git a/crates/linear-cli/src/commands/document/common.rs b/crates/linear-cli/src/commands/document/common.rs new file mode 100644 index 000000000..220f6c7c9 --- /dev/null +++ b/crates/linear-cli/src/commands/document/common.rs @@ -0,0 +1,63 @@ +//! Helpers shared by the document commands. +use std::path::Path; + +use crate::cli::values::TextSource; +use crate::client::{LinearClient, RequestError}; +use crate::commands::text_input; +use crate::ctx::Ctx; +use crate::error::{Error, Result}; +use crate::graphql::operations::document::DocumentUpdateInput; + +use super::target::{self, Kind, PreparedTarget}; + +/// The document's slug ID or UUID from a document argument, which may be a URL. +pub fn reference(ctx: &Ctx, input: &str) -> Result { + crate::refs::document::parse(input, &ctx.scope()?) +} + +/// `document(id:)` is non-null, so Linear reports a missing document as a +/// GraphQL error. +pub fn not_found(failure: RequestError, original: &str) -> Error { + failure.or_not_found("Document", original) +} + +/// Looks up the target and sets its ID in the field for its kind. +pub async fn attach( + client: &LinearClient, + input: &mut DocumentUpdateInput, + target: Option<&PreparedTarget>, +) -> Result<()> { + let Some(target) = target else { + return Ok(()); + }; + let (kind, id) = target::resolve(target, client).await?; + set_target(input, kind, id); + Ok(()) +} + +/// Sets the attachment's ID in the field for its kind. +pub fn set_target(input: &mut DocumentUpdateInput, kind: Kind, id: String) { + let field = match kind { + Kind::Project => &mut input.project_id, + Kind::Issue => &mut input.issue_id, + Kind::Initiative => &mut input.initiative_id, + Kind::Team => &mut input.team_id, + Kind::Cycle => &mut input.cycle_id, + Kind::Release => &mut input.release_id, + }; + *field = Some(id); +} + +pub fn read_file(path: &Path) -> Result> { + read_source(&TextSource::File(path.to_owned())) +} + +pub fn read_source(source: &TextSource) -> Result> { + text_input::read_source(source).map_err(|error| { + if error.kind() == std::io::ErrorKind::NotFound { + Error::not_found("File", &source.to_string()) + } else { + Error::new(format!("Failed to read {source}: {error}")).with_source(error) + } + }) +} diff --git a/crates/linear-cli/src/commands/document/create.rs b/crates/linear-cli/src/commands/document/create.rs new file mode 100644 index 000000000..32f704c7e --- /dev/null +++ b/crates/linear-cli/src/commands/document/create.rs @@ -0,0 +1,291 @@ +//! `document create`: fields from flags, stdin, an editor or prompts, then +//! one mutation. +use std::path::Path; + +use crate::cli::document::DocumentCreate; +use crate::commands::team_key::configured_team_key; +use crate::commands::text_input; +use crate::commands::{confirm, lookup_prompt, outcome}; +use crate::ctx::Ctx; +use crate::error::{Error, Result, ResultExt}; +use crate::graphql::operations::document::*; +use crate::platform::editor; +use crate::platform::prompt::{Choice, Prompter, Text}; + +use super::common::{read_file, read_source, set_target}; +use super::target::{self, Kind, TargetOptions}; + +pub fn run(ctx: &Ctx, args: &DocumentCreate) -> Result<()> { + create(ctx, args).context("Failed to create document") +} + +fn create(ctx: &Ctx, args: &DocumentCreate) -> Result<()> { + let optional = ctx.optional_prompts(args.interactive)?; + let mut fields = Fields { + title: args.title.clone(), + content: match (&args.content, &args.content_file) { + (Some(content), _) => Some(content.clone()), + (None, Some(source)) => read_source(source)?, + (None, None) => None, + }, + icon: args.icon.clone(), + }; + let flags = Attachment { + project: args.project.clone(), + issue: args.issue.clone(), + initiative: args.initiative.clone(), + team: args.team.clone(), + cycle: args.cycle.clone(), + release: args.release.clone(), + }; + // An empty content file counts as content given: none is asked for. + let given_content = args.content.is_some() || args.content_file.is_some(); + // Fields typed at prompts or in the editor are confirmed before sending. + let mut typed = false; + if ctx.interactive() { + typed = fields.title.is_none() || optional || !flags.options().any(); + prompt(ctx, &ctx.prompter()?, &mut fields, optional, !given_content)?; + } + let title = fields + .title + .clone() + .ok_or_else(|| ctx.missing_value("Title is required", "--title"))?; + // The attachment is found before any content is written for it. + let (kind, target_id) = if flags.options().any() { + let target = target::prepare(ctx, flags.options())?.expect("a target flag is set"); + ctx.spin(true, target::resolve(&target, ctx.client()?))? + } else if ctx.interactive() { + let default_team = configured_team_key(ctx.options()); + ask_attachment(ctx, &ctx.prompter()?, default_team.as_deref())? + } else { + return Err( + Error::invalid("A document attachment target is required").with_hint( + "Pass one of --project, --issue, --initiative, --team, --cycle, or --release.", + ), + ); + }; + if !given_content && !optional { + fields.content = if !ctx.stdin_tty() { + text_input::read_stdin(std::io::stdin().lock())? + } else if ctx.interactive() { + ctx.eprint("Opening editor for document content...\n")?; + typed = true; + let content = optional_editor(ctx)?; + if content.is_none() { + ctx.eprint("No content entered. Creating document without content.\n")?; + } + content + } else { + None + }; + } + if typed + && !confirm::proceed( + ctx, + args.confirm.yes, + &format!("Create document \"{title}\"?"), + )? + { + return Ok(()); + } + let client = ctx.client()?; + let mut input = DocumentUpdateInput { + content: fields.content, + icon: fields.icon, + ..Default::default() + }; + set_target(&mut input, kind, target_id); + let created = ctx.spin(true, async { + let data: CreateDocument = client + .mutate(CreateDocumentVariables { + input: DocumentCreateInput { + title, + content: input.content, + icon: input.icon, + project_id: input.project_id, + issue_id: input.issue_id, + initiative_id: input.initiative_id, + team_id: input.team_id, + cycle_id: input.cycle_id, + release_id: input.release_id, + }, + }) + .await + .map_err(|failure| failure.into_create_error("document"))?; + if !data.document_create.success { + return Err(Error::new("Linear did not create the document")); + } + Ok(data.document_create.document) + })?; + ctx.print(outcome::done( + "Created", + "document", + &created.title, + Some(&created.url), + )) +} + +/// Opens an empty editor; `None` when nothing was written. +fn optional_editor(ctx: &Ctx) -> Result> { + Ok(text_input::edited_body(&ctx.edit_text("")?)) +} + +/// A new document's fields, from flags or prompts. +struct Fields { + title: Option, + content: Option, + icon: Option, +} + +/// What to attach the document to, as flags or answers name it. +#[derive(Default)] +struct Attachment { + project: Option, + issue: Option, + initiative: Option, + team: Option, + cycle: Option, + release: Option, +} + +impl Attachment { + fn options(&self) -> TargetOptions<'_> { + TargetOptions { + project: self.project.as_deref(), + issue: self.issue.as_deref(), + initiative: self.initiative.as_deref(), + team: self.team.as_deref(), + cycle: self.cycle.as_deref(), + release: self.release.as_deref(), + } + } +} + +/// Asks for the title when it is missing, and with `optional` for the icon +/// and, with `ask_content` too, the content. The content can come from the +/// configured editor. +fn prompt( + ctx: &Ctx, + prompter: &Prompter<'_>, + fields: &mut Fields, + optional: bool, + ask_content: bool, +) -> Result<()> { + if fields.title.is_none() { + fields.title = Some(prompter.text(Text::new("Title:").required())?); + } + if optional { + if ask_content { + let editor = editor::configured_name(&ctx.config().child_env); + fields.content = prompt_content(ctx, prompter, editor.as_deref())?; + } + if fields.icon.is_none() { + fields.icon = + text_input::edited_body(&prompter.text(Text::new("Icon (emoji, optional):"))?); + } + } + Ok(()) +} + +/// Asks what to attach the document to until the answer names something +/// that exists. +fn ask_attachment( + ctx: &Ctx, + prompter: &Prompter<'_>, + default_team: Option<&str>, +) -> Result<(Kind, String)> { + let client = ctx.client()?; + let found = lookup_prompt::ask_until_found( + ctx, + || attachment(prompter, default_team).map(Some), + |answer| async move { + let target = target::prepare(ctx, answer.options())?.expect("an answer names a target"); + target::resolve(&target, client).await + }, + )?; + Ok(found.expect("an attachment is never left blank")) +} + +/// One round of attachment questions: the kind, then what names it. +fn attachment(prompter: &Prompter<'_>, default_team: Option<&str>) -> Result { + let targets = vec![ + Choice::new("Project", Kind::Project), + Choice::new("Issue", Kind::Issue), + Choice::new("Team", Kind::Team), + Choice::new("Initiative", Kind::Initiative), + Choice::new("Cycle", Kind::Cycle), + Choice::new("Release", Kind::Release), + ]; + let team = |message| { + let text = Text::new(message).required(); + prompter.text(match default_team { + Some(team) => text.with_default(team), + None => text, + }) + }; + let required = |message| prompter.text(Text::new(message).required()); + let mut answer = Attachment::default(); + match prompter.select("Attach to:", targets)? { + Kind::Project => answer.project = Some(required("Project (ID, slug, or name):")?), + Kind::Issue => answer.issue = Some(required("Issue (like ENG-123):")?), + Kind::Team => answer.team = Some(team("Team (key, like ENG):")?), + Kind::Initiative => { + answer.initiative = Some(required("Initiative (ID, slug, or name):")?); + } + Kind::Cycle => { + answer.team = Some(team("Cycle team (key, like ENG):")?); + answer.cycle = Some(required( + "Cycle (name, number, active, next, or previous):", + )?); + } + Kind::Release => answer.release = Some(required("Release (ID, name, or version):")?), + } + Ok(answer) +} + +/// Asks where the content comes from, then for the content. The editor +/// named `editor` is offered when one is configured. +fn prompt_content( + ctx: &Ctx, + prompter: &Prompter<'_>, + editor: Option<&str>, +) -> Result> { + let mut methods = vec![ + Choice::new("Skip (no content)", Content::Skip), + Choice::new("Enter inline", Content::Inline), + ]; + if let Some(label) = editor { + methods.push(Choice::new(format!("Open {label}"), Content::Editor)); + } + methods.push(Choice::new("Read from file", Content::File)); + Ok(match prompter.select("Content:", methods)? { + Content::Skip => None, + Content::Inline => { + text_input::edited_body(&prompter.text(Text::new("Content (markdown):"))?) + } + Content::File => { + let path = prompter.text(Text::new("File path:").required())?; + read_file(Path::new(&path))? + } + Content::Editor => { + let label = editor.expect("the editor option is offered only with an editor"); + ctx.eprint(format!("Opening {label}...\n"))?; + let content = optional_editor(ctx)?; + if let Some(content) = &content { + ctx.eprint(format!( + "Content entered ({} characters)\n", + content.chars().count() + ))?; + } + content + } + }) +} + +/// Where prompted content comes from. +enum Content { + Skip, + Inline, + Editor, + File, +} diff --git a/crates/linear-cli/src/commands/document/delete.rs b/crates/linear-cli/src/commands/document/delete.rs new file mode 100644 index 000000000..8f0f79e4e --- /dev/null +++ b/crates/linear-cli/src/commands/document/delete.rs @@ -0,0 +1,117 @@ +//! `document delete`: move one document, or many in bulk, to the trash. +use crate::cli::document::DocumentDelete; +use crate::client::LinearClient; +use crate::commands::bulk::{self, BulkInput, BulkResult, Found, Skipped, Verb}; +use crate::commands::outcome; +use crate::ctx::Ctx; +use crate::error::{Error, Result, ResultExt}; +use crate::graphql::operations::common::IdVariables; +use crate::graphql::operations::document::{DeleteDocument, DocumentDetails, GetDocumentForDelete}; +use crate::refs; + +pub fn run(ctx: &Ctx, args: &DocumentDelete) -> Result<()> { + delete(ctx, args).context("Failed to delete document") +} + +fn delete(ctx: &Ctx, args: &DocumentDelete) -> Result<()> { + if !args.confirm.yes { + ctx.require_tty("for confirmation", "--yes")?; + } + let input = BulkInput::from(&args.bulk); + if input.requested() { + return delete_bulk(ctx, args, &input); + } + let original = args.document_id.as_deref().ok_or_else(|| { + Error::invalid("Document ID required").with_hint("Use --bulk for multiple documents.") + })?; + let id = super::common::reference(ctx, original)?; + let client = ctx.client()?; + let document = ctx.spin(true, async { + details(client, &id) + .await? + .ok_or_else(|| Error::not_found("Document", original)) + })?; + let question = format!("Delete document \"{}\"?", document.title); + if !args.confirm.yes && !ctx.confirm(&question, "--yes")? { + return outcome::canceled(ctx); + } + ctx.spin(true, submit(client, document.id.inner()))?; + ctx.print(outcome::done("Deleted", "document", &document.title, None)) +} + +fn delete_bulk(ctx: &Ctx, args: &DocumentDelete, input: &BulkInput<'_>) -> Result<()> { + let ids = bulk::collect_ids(input, &mut std::io::stdin().lock())?; + if ids.is_empty() { + return Err(Error::new("No document IDs provided for bulk delete")); + } + let scope = ctx.scope()?; + let targets: Vec<_> = ids + .into_iter() + .map(|original| { + let id = refs::document::parse(&original, &scope); + (original, id) + }) + .collect(); + let client = ctx.client()?; + let (found, missing) = bulk::look_up(ctx, targets, |(original, id)| { + look_up_item(client, original, id) + }); + let verb = Verb { + present: "delete", + past: "deleted", + }; + ctx.eprint(bulk::preview(&found, &missing, "document", verb))?; + if found.is_empty() { + return Err(Error::new("None of the listed documents could be found")); + } + let question = format!("Delete {}?", bulk::count(found.len(), "document")); + if !args.confirm.yes && !ctx.confirm(&question, "--yes")? { + return outcome::canceled(ctx); + } + let mut results = bulk::run(ctx, found, |found| async move { + let outcome = submit(client, &found.item).await; + found.result(outcome) + })?; + results.extend(missing.into_iter().map(BulkResult::from)); + bulk::report(ctx, &results, "document", verb) +} + +/// Looks up one listed document; the found item is its UUID. +async fn look_up_item( + client: &LinearClient, + original: String, + id: Result, +) -> std::result::Result, Skipped> { + let document = async { details(client, &id?).await }.await; + match document { + Ok(Some(document)) => Ok(Found { + original, + name: document.title, + item: document.id.into_inner(), + }), + Ok(None) => Err(Skipped { + original, + reason: "Document not found".to_owned(), + }), + Err(error) => Err(Skipped { + original, + reason: error.message().to_owned(), + }), + } +} + +async fn details(client: &LinearClient, id: &str) -> Result> { + let data: GetDocumentForDelete = client + .query(IdVariables { id: id.to_owned() }) + .await + .map_err(|failure| super::common::not_found(failure, id))?; + Ok(data.document) +} + +async fn submit(client: &LinearClient, id: &str) -> Result<()> { + let data: DeleteDocument = client.mutate(IdVariables { id: id.to_owned() }).await?; + if !data.document_delete.success { + return Err(Error::new("Linear did not delete the document")); + } + Ok(()) +} diff --git a/crates/linear-cli/src/commands/document/list.rs b/crates/linear-cli/src/commands/document/list.rs new file mode 100644 index 000000000..6189dbbd1 --- /dev/null +++ b/crates/linear-cli/src/commands/document/list.rs @@ -0,0 +1,145 @@ +//! `document list`: one page of documents as a table or JSON. +use crate::client::LinearClient; +use std::time::SystemTime; + +use crate::cli::Limit; +use crate::cli::document::DocumentList; +use crate::commands::{ + json, + relative_time::ago, + table::{Cell, Column, Table}, +}; +use crate::ctx::Ctx; +use crate::error::{Result, ResultExt}; +use crate::graphql::{ + operations::document::*, + pagination::{self, Page, PageInfo}, +}; +use crate::platform::style; + +use super::target::{self, TargetOptions}; + +pub fn run(ctx: &Ctx, args: &DocumentList) -> Result<()> { + list(ctx, args).context("Failed to list documents") +} + +fn list(ctx: &Ctx, args: &DocumentList) -> Result<()> { + let target = target::prepare( + ctx, + TargetOptions { + project: args.project.as_deref(), + issue: args.issue.as_deref(), + initiative: args.initiative.as_deref(), + team: args.team.as_deref(), + cycle: args.cycle.as_deref(), + release: args.release.as_deref(), + }, + )?; + let client = ctx.client()?; + let documents = ctx.spin(!args.json, async { + let filter = match &target { + Some(target) => { + let (kind, id) = target::resolve(target, client).await?; + Some(target::filter(kind, id)) + } + None => None, + }; + fetch(client, filter, args.limit).await + })?; + if args.json { + ctx.print(json::render(&documents)) + } else if documents.is_empty() { + ctx.print("No documents found.\n") + } else { + ctx.print(text(&documents, SystemTime::now()).render_for(ctx)) + } +} + +async fn fetch( + client: &LinearClient, + filter: Option, + limit: Limit, +) -> Result> { + pagination::collect(limit.max(), |after, first| { + let variables = ListDocumentsVariables { + filter: filter.clone(), + first: Some(first), + after, + }; + async move { + let data: ListDocuments = client.query(variables).await?; + Ok(data.documents.map_or_else( + || Page { + nodes: Vec::new(), + page_info: PageInfo { + has_next_page: false, + end_cursor: None, + }, + }, + |connection| Page { + nodes: connection.nodes, + page_info: connection.page_info, + }, + )) + } + }) + .await +} + +fn attachment(doc: &ListedDocument) -> String { + if let Some(project) = &doc.project + && !project.name.is_empty() + { + return format!("Project: {}", project.name); + } + if let Some(issue) = &doc.issue + && !issue.identifier.is_empty() + { + return format!("Issue: {}", issue.identifier); + } + if let Some(initiative) = &doc.initiative + && !initiative.name.is_empty() + { + return format!("Initiative: {}", initiative.name); + } + if let Some(team) = &doc.team { + return format!("Team: {} ({})", team.name, team.key); + } + if let Some(cycle) = &doc.cycle { + let name = cycle + .name + .as_deref() + .filter(|name| !name.is_empty()) + .map_or(String::new(), |name| format!(" — {name}")); + return format!("Cycle: {} #{}{name}", cycle.team.key, cycle.number); + } + if let Some(release) = &doc.release { + let version = release + .version + .as_deref() + .filter(|version| !version.is_empty()) + .map_or(String::new(), |version| format!(" ({version})")); + return format!("Release: {}{version}", release.name); + } + "-".to_owned() +} +fn text(documents: &[ListedDocument], now: SystemTime) -> Table { + let mut table = Table::new([ + Column::fixed("SLUG"), + Column::flexible("TITLE"), + Column::flexible("ATTACHMENT").droppable(1), + Column::fixed("UPDATED").droppable(2), + ]); + for doc in documents { + table.row([ + Cell::from(doc.slug_id.as_str()), + Cell::from(doc.title.as_str()), + Cell::from(attachment(doc)), + Cell::styled( + ago(doc.updated_at.0, now.into(), &chrono::Local), + style::gray, + ), + ]); + } + table +} diff --git a/crates/linear-cli/src/commands/document/mod.rs b/crates/linear-cli/src/commands/document/mod.rs new file mode 100644 index 000000000..a8fcb6e8b --- /dev/null +++ b/crates/linear-cli/src/commands/document/mod.rs @@ -0,0 +1,24 @@ +//! `linear document`: documents and their comments. +mod comment; +mod common; +mod create; +mod delete; +mod list; +mod target; +mod update; +mod view; + +use crate::cli::document::DocumentCommand; +use crate::ctx::Ctx; +use crate::error::Result; + +pub fn run(ctx: &Ctx, command: &DocumentCommand) -> Result<()> { + match command { + DocumentCommand::List(args) => list::run(ctx, args), + DocumentCommand::View(args) => view::run(ctx, args), + DocumentCommand::Create(args) => create::run(ctx, args), + DocumentCommand::Update(args) => update::run(ctx, args), + DocumentCommand::Delete(args) => delete::run(ctx, args), + DocumentCommand::Comment(args) => comment::run(ctx, &args.command), + } +} diff --git a/crates/linear-cli/src/commands/document/target.rs b/crates/linear-cli/src/commands/document/target.rs new file mode 100644 index 000000000..2aac5f16d --- /dev/null +++ b/crates/linear-cli/src/commands/document/target.rs @@ -0,0 +1,194 @@ +//! A document's attachment: exactly one project, issue, initiative, team, +//! cycle or release. Clap rejects more than one; `--team` with `--cycle` +//! names the team to look the cycle up in. +use crate::client::LinearClient; +use crate::commands::team_key::configured_team_key; +use crate::ctx::Ctx; +use crate::error::{Error, Result}; +use crate::graphql::operations::document::*; +use crate::graphql::operations::initiative::IDComparator; +use crate::refs::{ + self, LinearUrlKind, LinearUrlRef, cycle::CycleReference, initiative::Archived, + initiative::InitiativeReference, project::ProjectReference, team::TeamReference, +}; + +#[derive(Debug, Clone, Copy)] +pub enum Kind { + Project, + Issue, + Initiative, + Team, + Cycle, + Release, +} + +/// A target checked locally, ready to look up. +pub enum PreparedTarget { + Project(ProjectReference), + Issue { + original: String, + id: String, + }, + Initiative(InitiativeReference), + Team(TeamReference), + Cycle { + team: TeamReference, + cycle: CycleReference, + }, + Release(String), +} + +/// The target flags as given. +#[derive(Clone, Copy, Debug, Default)] +pub struct TargetOptions<'a> { + pub project: Option<&'a str>, + pub issue: Option<&'a str>, + pub initiative: Option<&'a str>, + pub team: Option<&'a str>, + pub cycle: Option<&'a str>, + pub release: Option<&'a str>, +} + +impl TargetOptions<'_> { + pub fn any(self) -> bool { + self.project.is_some() + || self.issue.is_some() + || self.initiative.is_some() + || self.team.is_some() + || self.cycle.is_some() + || self.release.is_some() + } +} + +pub fn prepare(ctx: &Ctx, target: TargetOptions<'_>) -> Result> { + let scope = ctx.scope()?; + if let Some(original) = target.project { + return Ok(Some(PreparedTarget::Project(ProjectReference::parse( + original, &scope, + )?))); + } + if let Some(original) = target.issue { + let url = refs::expect_url_kind( + original, + LinearUrlKind::Issue, + "an issue URL, identifier like ENG-123, or UUID", + &scope, + |url| match url { + LinearUrlRef::Issue { identifier, .. } => Some(identifier), + _ => None, + }, + )?; + let id = match url { + Some(identifier) => identifier, + None if refs::is_linear_uuid(original) => original.to_owned(), + None => original.to_uppercase(), + }; + return Ok(Some(PreparedTarget::Issue { + original: original.to_owned(), + id, + })); + } + if let Some(original) = target.initiative { + return Ok(Some(PreparedTarget::Initiative( + InitiativeReference::parse(original, &scope)?, + ))); + } + if let Some(reference) = target.cycle { + let configured = configured_team_key(ctx.options()); + let team = target.team.or(configured.as_deref()).ok_or_else(|| { + Error::new("--cycle requires a team to look the cycle up in") + .with_hint("Pass --team or configure a default team.") + })?; + return Ok(Some(PreparedTarget::Cycle { + team: TeamReference::parse(team, &scope)?, + cycle: CycleReference::parse(reference, &scope)?, + })); + } + if let Some(team) = target.team { + return Ok(Some(PreparedTarget::Team(TeamReference::parse( + team, &scope, + )?))); + } + if let Some(original) = target.release { + refs::reject_linear_url(original, "a release name, version, or UUID")?; + return Ok(Some(PreparedTarget::Release(original.to_owned()))); + } + Ok(None) +} + +/// The target's kind and Linear ID. +pub async fn resolve(target: &PreparedTarget, client: &LinearClient) -> Result<(Kind, String)> { + match target { + PreparedTarget::Project(reference) => Ok(( + Kind::Project, + refs::project::resolve(client, reference).await?, + )), + PreparedTarget::Initiative(reference) => Ok(( + Kind::Initiative, + refs::initiative::resolve(client, reference, Archived::Exclude).await?, + )), + PreparedTarget::Team(reference) => { + Ok((Kind::Team, refs::team::resolve(client, reference).await?.id)) + } + PreparedTarget::Issue { original, id } => { + let not_found = || { + Error::not_found("Issue", original) + .with_hint("Provide a valid issue identifier (e.g., TC-123) or UUID.") + }; + let data: GetIssueForDocumentTarget = client + .query(GetDocumentVariables { id: id.clone() }) + .await + .map_err(|failure| { + if failure.is_not_found() { + not_found() + } else { + Error::from(failure) + } + })?; + Ok(( + Kind::Issue, + data.issue.ok_or_else(not_found)?.id.into_inner(), + )) + } + PreparedTarget::Cycle { team, cycle } => { + let team = refs::team::resolve(client, team).await?; + Ok(( + Kind::Cycle, + refs::cycle::resolve(client, &team.id, cycle).await?, + )) + } + PreparedTarget::Release(original) => Ok(( + Kind::Release, + refs::release::resolve(client, original).await?, + )), + } +} + +/// The `document list` filter for one target. +pub fn filter(kind: Kind, id: String) -> DocumentFilter { + let id = IDComparator { + eq: Some(cynic::Id::new(id)), + }; + let mut filter = DocumentFilter::default(); + match kind { + Kind::Project => { + filter.project = Some(DocumentProjectFilter { + id: EntityIdentifierIDComparator { eq: id.eq }, + }) + } + Kind::Issue => { + filter.issue = Some(DocumentIssueFilter { + id: IssueIDComparator { eq: id.eq }, + }) + } + Kind::Initiative => { + filter.initiative = Some(DocumentInitiativeFilter { + id: EntityIdentifierIDComparator { eq: id.eq }, + }) + } + Kind::Team => filter.team = Some(DocumentTeamFilter { id }), + Kind::Cycle => filter.cycle = Some(DocumentCycleFilter { id }), + Kind::Release => filter.release = Some(DocumentReleaseFilter { id }), + } + filter +} diff --git a/crates/linear-cli/src/commands/document/update.rs b/crates/linear-cli/src/commands/document/update.rs new file mode 100644 index 000000000..8240119bb --- /dev/null +++ b/crates/linear-cli/src/commands/document/update.rs @@ -0,0 +1,154 @@ +//! `document update`: new fields from flags, stdin or an editor, then one +//! mutation. +use crate::cli::document::DocumentUpdate; +use crate::client::LinearClient; +use crate::commands::text_input; +use crate::commands::{confirm, outcome}; +use crate::ctx::Ctx; +use crate::error::{Error, Result, ResultExt}; +use crate::graphql::operations::document::*; +use crate::graphql::pagination::{self, Page}; + +use super::common::{self, attach, read_source}; +use super::target::{self, TargetOptions}; + +pub fn run(ctx: &Ctx, args: &DocumentUpdate) -> Result<()> { + update(ctx, args).context("Failed to update document") +} + +fn update(ctx: &Ctx, args: &DocumentUpdate) -> Result<()> { + let id = common::reference(ctx, &args.document_id)?; + let target = target::prepare( + ctx, + TargetOptions { + project: args.project.as_deref(), + issue: args.issue.as_deref(), + initiative: args.initiative.as_deref(), + team: args.team.as_deref(), + cycle: args.cycle.as_deref(), + release: args.release.as_deref(), + }, + )?; + let metadata = args.title.is_some() || args.icon.is_some() || target.is_some(); + let content = match (&args.content, &args.content_file) { + (Some(content), _) => Some(content.clone()), + (None, Some(source)) => read_source(source)?, + // Piped stdin is the new content when nothing else is being changed. + (None, None) if !args.edit && !metadata && !ctx.stdin_tty() => { + text_input::read_stdin(std::io::stdin().lock())? + } + (None, None) => None, + }; + let edit = args.edit && content.is_none(); + if content.is_none() && !edit && !metadata { + return Err(Error::invalid("No changes given").with_hint( + "Use --title, --content, --content-file, --icon, --edit, or re-point the attachment with --project, --issue, --initiative, --team, --cycle, or --release.", + )); + } + let client = ctx.client()?; + let mut input = DocumentUpdateInput { + title: args.title.clone(), + icon: args.icon.clone(), + content, + ..Default::default() + }; + if target.is_some() { + ctx.spin(true, attach(client, &mut input, target.as_ref()))?; + } + if edit { + let document = ctx.spin(true, for_edit(client, &id))?; + let seed = document.content.unwrap_or_default(); + ctx.eprint(format!("Opening {} in editor...\n", document.title))?; + let edited = ctx.edit_text(&seed)?; + if edited != seed { + input.content = text_input::edited_body(&edited); + } + if input.content.is_none() && !metadata { + return ctx.print(outcome::NO_CHANGES); + } + // A terminal user confirms the edit; a script driving the editor + // asked for it with --edit. + if input.content.is_some() + && ctx.interactive() + && !confirm::proceed( + ctx, + args.confirm.yes, + &format!("Save the edited text of \"{}\"?", document.title), + )? + { + return Ok(()); + } + } + let updated = ctx.spin(true, async { + if input.content.is_some() && !args.force { + refuse_inline_comments(client, &id).await?; + } + let data: UpdateDocument = client + .mutate(UpdateDocumentVariables { + id: id.clone(), + input, + }) + .await?; + if !data.document_update.success { + return Err(Error::new("Linear did not update the document")); + } + Ok(data.document_update.document) + })?; + ctx.print(outcome::done( + "Updated", + "document", + &updated.title, + Some(&updated.url), + )) +} + +async fn for_edit(client: &LinearClient, id: &str) -> Result { + let data: GetDocumentForEdit = client + .query(DocumentEditVariables { id: id.to_owned() }) + .await + .map_err(|failure| common::not_found(failure, id))?; + data.document + .ok_or_else(|| Error::not_found("Document", id)) +} + +/// Replacing the Markdown can detach or hide inline comments, so content +/// updates stop while any open comment quotes the document. +async fn refuse_inline_comments(client: &LinearClient, id: &str) -> Result<()> { + let comments = pagination::collect(None, |after, _first| { + let variables = DocumentGuardVariables { + id: id.to_owned(), + after, + }; + async move { + let data: DocumentInlineCommentGuard = client + .query(variables) + .await + .map_err(|failure| common::not_found(failure, id))?; + let document = data + .document + .ok_or_else(|| Error::not_found("Document", id))?; + Ok::<_, Error>(Page { + nodes: document.comments.nodes, + page_info: document.comments.page_info, + }) + } + }) + .await?; + let open_quote = comments.into_iter().find_map(|comment| { + let open = comment.resolved_at.is_none() && comment.archived_at.is_none(); + comment + .quoted_text + .filter(|_| open) + .map(|quoted| (comment.id, quoted)) + }); + match open_quote { + None => Ok(()), + Some((comment, quoted)) => Err(Error::new( + "Refusing to update document content because this document has inline comments.", + ) + .with_hint(format!( + "Updating Markdown content can detach or hide Linear document comments. First review comment {} quoting \"{quoted}\", then rerun with --force if you accept that risk.", + comment.inner() + ))), + } +} diff --git a/crates/linear-cli/src/commands/document/view.rs b/crates/linear-cli/src/commands/document/view.rs new file mode 100644 index 000000000..cac1728a5 --- /dev/null +++ b/crates/linear-cli/src/commands/document/view.rs @@ -0,0 +1,169 @@ +//! `document view`: a document as Markdown, raw content or JSON (with every +//! comment), or opened in the browser. +use chrono::{DateTime, TimeZone, Utc}; + +use crate::cli::document::DocumentView; +use crate::client::LinearClient; +use crate::commands::json; +use crate::ctx::Ctx; +use crate::error::{Error, Result, ResultExt}; +use crate::graphql::operations::document::*; +use crate::graphql::pagination::{self, Page}; +use crate::platform::markdown_assets; + +pub fn run(ctx: &Ctx, args: &DocumentView) -> Result<()> { + view(ctx, args).context("Failed to view document") +} + +fn view(ctx: &Ctx, args: &DocumentView) -> Result<()> { + let original = &args.id; + let id = super::common::reference(ctx, original)?; + let client = ctx.client()?; + if args.web { + let document = ctx.spin(true, body(client, &id, original))?; + return ctx.open_url(&document.url, false); + } + if args.json { + let document = ctx.spin(false, with_comments(client, &id, original))?; + return ctx.print(json::render(&document)); + } + let document = ctx.spin(!args.raw, body(client, &id, original))?; + let download = !args.no_download && ctx.options().download_images(); + let content = match document + .content + .as_deref() + .filter(|content| !content.is_empty()) + { + Some(content) if download => Some(local_images(ctx, client, content)?), + content => content.map(str::to_owned), + }; + if args.raw { + return match content { + Some(content) => ctx.print(format!("{content}\n")), + None => Ok(()), + }; + } + let markdown = markdown(&document, content.as_deref(), Utc::now(), &chrono::Local); + ctx.show_markdown(&markdown, !args.no_pager) +} + +/// The content with its uploaded files downloaded and pointed at the local +/// copies. +fn local_images(ctx: &Ctx, client: &LinearClient, content: &str) -> Result { + let root = ctx.cache_dir("images"); + let paths = ctx.block_on(markdown_assets::download( + client, + root.as_deref(), + &[content], + |line| ctx.eprint(line), + ))?; + Ok(markdown_assets::rewrite(content, &paths)) +} + +async fn body(client: &LinearClient, id: &str, original: &str) -> Result { + let data: GetDocument = client + .query(GetDocumentVariables { id: id.to_owned() }) + .await + .map_err(|failure| super::common::not_found(failure, original))?; + data.document + .ok_or_else(|| Error::not_found("Document", original)) +} + +/// The document with every page of its comments. +async fn with_comments( + client: &LinearClient, + id: &str, + original: &str, +) -> Result { + pagination::collect_within( + None, + |after, _first| { + let variables = GetDocumentCommentsVariables { + id: id.to_owned(), + comments_after: after, + }; + async move { + let data: GetDocumentWithComments = client + .query(variables) + .await + .map_err(|failure| super::common::not_found(failure, original))?; + data.document + .ok_or_else(|| Error::not_found("Document", original)) + } + }, + |document| Page { + nodes: std::mem::take(&mut document.comments.nodes), + page_info: document.comments.page_info.clone(), + }, + |document, page| { + document.comments.nodes = page.nodes; + document.comments.page_info = page.page_info; + }, + ) + .await +} + +fn markdown( + document: &DocumentBody, + content: Option<&str>, + now: DateTime, + zone: &Tz, +) -> String { + let mut lines = vec![ + format!("# {}", document.title), + String::new(), + format!("**Slug:** {}", document.slug_id), + format!("**URL:** {}", document.url), + ]; + if let Some(creator) = &document.creator { + lines.push(format!("**Creator:** {}", creator.name)); + } + if let Some(project) = &document.project { + lines.push(format!("**Project:** {}", project.name)); + } + if let Some(issue) = &document.issue { + lines.push(format!("**Issue:** {} - {}", issue.identifier, issue.title)); + } + if let Some(initiative) = &document.initiative { + lines.push(format!("**Initiative:** {}", initiative.name)); + } + if let Some(team) = &document.team { + lines.push(format!("**Team:** {} ({})", team.name, team.key)); + } + if let Some(cycle) = &document.cycle { + let name = cycle + .name + .as_deref() + .filter(|name| !name.is_empty()) + .map_or(String::new(), |name| format!(" - {name}")); + lines.push(format!( + "**Cycle:** {} #{}{name}", + cycle.team.key, cycle.number + )); + } + if let Some(release) = &document.release { + let version = release + .version + .as_deref() + .filter(|version| !version.is_empty()) + .map_or(String::new(), |version| format!(" ({version})")); + lines.push(format!("**Release:** {}{version}", release.name)); + } + lines.push(format!( + "**Created:** {}", + crate::commands::relative_time::ago(document.created_at.0, now, zone) + )); + lines.push(format!( + "**Updated:** {}", + crate::commands::relative_time::ago(document.updated_at.0, now, zone) + )); + if let Some(content) = content.filter(|content| !content.is_empty()) { + lines.extend([ + String::new(), + "---".to_owned(), + String::new(), + content.to_owned(), + ]); + } + lines.join("\n") +} diff --git a/crates/linear-cli/src/commands/initiative/add_project.rs b/crates/linear-cli/src/commands/initiative/add_project.rs new file mode 100644 index 000000000..adb8a5752 --- /dev/null +++ b/crates/linear-cli/src/commands/initiative/add_project.rs @@ -0,0 +1,45 @@ +//! `initiative add-project`: link a project to an initiative. +use crate::cli::initiative::InitiativeAddProject; +use crate::commands::outcome; +use crate::ctx::Ctx; +use crate::error::{Error, Result, ResultExt}; +use crate::graphql::operations::initiative::{ + AddProjectToInitiative, AddVariables, InitiativeToProjectCreateInput, +}; + +use super::common::Pair; + +pub fn run(ctx: &Ctx, args: &InitiativeAddProject) -> Result<()> { + add(ctx, args).context("Failed to add project to initiative") +} + +fn add(ctx: &Ctx, args: &InitiativeAddProject) -> Result<()> { + let pair = Pair::parse(ctx, &args.initiative, &args.project)?; + let client = ctx.client()?; + let link = ctx.spin(true, pair.link(client))?; + if link.id.is_some() { + return ctx.print(format!( + "Project \"{}\" is already linked to initiative \"{}\"\n", + link.project, link.initiative + )); + } + let result: AddProjectToInitiative = ctx.spin( + true, + client.mutate(AddVariables { + input: InitiativeToProjectCreateInput { + initiative_id: link.initiative_id.clone(), + project_id: link.project_id.clone(), + sort_order: args.sort_order.clone(), + }, + }), + )?; + if !result.initiative_to_project_create.success { + return Err(Error::new("Linear did not link the project")); + } + ctx.print(outcome::done( + "Added", + "project", + &format!("{} to initiative {}", link.project, link.initiative), + None, + )) +} diff --git a/crates/linear-cli/src/commands/initiative/archive.rs b/crates/linear-cli/src/commands/initiative/archive.rs new file mode 100644 index 000000000..376311bd3 --- /dev/null +++ b/crates/linear-cli/src/commands/initiative/archive.rs @@ -0,0 +1,17 @@ +//! `initiative archive`: archive, for one initiative or in bulk. +use crate::cli::initiative::InitiativeArchive; +use crate::commands::bulk::BulkInput; +use crate::ctx::Ctx; +use crate::error::{Result, ResultExt}; + +use super::archive_or_delete::{Mode, Request}; + +pub fn run(ctx: &Ctx, args: &InitiativeArchive) -> Result<()> { + let request = Request { + initiative: args.initiative_id.as_deref(), + yes: args.confirm.yes, + bulk: BulkInput::from(&args.bulk), + }; + super::archive_or_delete::run(ctx, Mode::Archive, &request) + .context("Failed to archive initiative") +} diff --git a/crates/linear-cli/src/commands/initiative/archive_or_delete.rs b/crates/linear-cli/src/commands/initiative/archive_or_delete.rs new file mode 100644 index 000000000..27b83c28a --- /dev/null +++ b/crates/linear-cli/src/commands/initiative/archive_or_delete.rs @@ -0,0 +1,260 @@ +//! What `initiative archive` and `initiative delete` share: one initiative or +//! many, confirmation, and the summary. +use crate::client::LinearClient; +use crate::commands::bulk::{self, BulkInput, BulkResult, Found, Skipped, Verb}; +use crate::commands::outcome; +use crate::ctx::Ctx; +use crate::error::{Error, Result}; +use crate::graphql::operations::common::IdVariables; +use crate::graphql::operations::initiative::{ + ArchiveInitiative, DeleteInitiative, GetInitiativeForArchive, GetInitiativeForDelete, +}; +use crate::platform::prompt::Text; +use crate::refs::{ + self, + initiative::{Archived, InitiativeReference}, +}; + +pub struct Request<'a> { + pub initiative: Option<&'a str>, + /// `--yes`: no prompt. + pub yes: bool, + pub bulk: BulkInput<'a>, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Mode { + Archive, + Delete, +} + +impl Mode { + const fn verb(self) -> &'static str { + match self { + Self::Archive => "archive", + Self::Delete => "delete", + } + } + + const fn past(self) -> &'static str { + match self { + Self::Archive => "archived", + Self::Delete => "deleted", + } + } + + /// Archived initiatives can be deleted but not archived again. + const fn archived(self) -> Archived { + match self { + Self::Archive => Archived::Exclude, + Self::Delete => Archived::Include, + } + } +} + +const PERMANENT: &str = "\n⚠️ This action is PERMANENT and cannot be undone.\n\n"; + +pub fn run(ctx: &Ctx, mode: Mode, request: &Request<'_>) -> Result<()> { + if !request.yes { + ctx.require_tty("for confirmation", "--yes")?; + } + if request.bulk.requested() { + return run_bulk(ctx, mode, request); + } + let original = request.initiative.ok_or_else(|| { + Error::invalid("Initiative ID required").with_hint("Use --bulk for multiple initiatives.") + })?; + let reference = super::common::reference(ctx, original)?; + let client = ctx.client()?; + let details = ctx.spin(true, async { + let id = refs::initiative::resolve(client, &reference, mode.archived()).await?; + details(client, &id, mode) + .await? + .ok_or_else(|| Error::not_found("Initiative", original)) + })?; + if details.already_archived { + return ctx.print(format!( + "Initiative \"{}\" is already archived.\n", + details.name + )); + } + if details.linked_projects > 0 { + ctx.eprint(format!( + "\n⚠️ Initiative \"{}\" has {} linked project(s).\nDeleting the initiative will unlink these projects.\n\n", + details.name, details.linked_projects + ))?; + } + if !request.yes && !confirm_single(ctx, mode, &details.name)? { + return outcome::canceled(ctx); + } + ctx.spin(true, submit(client, &details.id, mode))?; + let done = match mode { + Mode::Archive => "Archived", + Mode::Delete => "Permanently deleted", + }; + ctx.print(outcome::done(done, "initiative", &details.name, None)) +} + +/// Archiving asks once; deleting also asks for the initiative's name. +fn confirm_single(ctx: &Ctx, mode: Mode, name: &str) -> Result { + let question = match mode { + Mode::Archive => format!("Archive initiative \"{name}\"?"), + Mode::Delete => { + ctx.eprint(PERMANENT)?; + format!("Permanently delete initiative \"{name}\"?") + } + }; + if !ctx.confirm(&question, "--yes")? { + return Ok(false); + } + if mode == Mode::Archive { + return Ok(true); + } + let answer = ctx + .prompter()? + .text(Text::new("Type the initiative name to confirm deletion:"))?; + if answer == name.trim() { + Ok(true) + } else { + ctx.eprint("Name does not match.\n")?; + Ok(false) + } +} + +fn run_bulk(ctx: &Ctx, mode: Mode, request: &Request<'_>) -> Result<()> { + let ids = bulk::collect_ids(&request.bulk, &mut std::io::stdin().lock())?; + if ids.is_empty() { + return Err(Error::new(format!( + "No initiative IDs provided for bulk {}", + mode.verb() + ))); + } + let scope = ctx.scope()?; + let targets: Vec<_> = ids + .into_iter() + .map(|original| { + let reference = InitiativeReference::parse(&original, &scope); + (original, reference) + }) + .collect(); + let client = ctx.client()?; + let (found, missing) = bulk::look_up(ctx, targets, |(original, reference)| { + look_up_item(client, original, reference, mode) + }); + let verb = Verb { + present: mode.verb(), + past: mode.past(), + }; + ctx.eprint(bulk::preview(&found, &missing, "initiative", verb))?; + if found.is_empty() { + return Err(Error::new("None of the listed initiatives could be found")); + } + if mode == Mode::Delete { + ctx.eprint(PERMANENT)?; + } + let count = bulk::count(found.len(), "initiative"); + let question = match mode { + Mode::Archive => format!("Archive {count}?"), + Mode::Delete => format!("Permanently delete {count}?"), + }; + if !request.yes && !ctx.confirm(&question, "--yes")? { + return outcome::canceled(ctx); + } + let mut results = bulk::run(ctx, found, |found| async move { + let outcome = if found.item.already_archived { + Ok(()) + } else { + submit(client, &found.item.id, mode).await + }; + found.result(outcome) + })?; + results.extend(missing.into_iter().map(BulkResult::from)); + bulk::report(ctx, &results, "initiative", verb) +} + +/// Looks up one listed initiative. Failures, including an unparseable +/// reference, become the reason it is skipped. +async fn look_up_item( + client: &LinearClient, + original: String, + reference: Result, + mode: Mode, +) -> std::result::Result, Skipped> { + let looked_up = async { + let id = refs::initiative::resolve(client, &reference?, mode.archived()).await?; + details(client, &id, mode).await + }; + match looked_up.await { + Ok(Some(details)) => Ok(Found { + original, + name: if details.already_archived { + format!("{} (already archived)", details.name) + } else { + details.name.clone() + }, + item: details, + }), + Ok(None) => Err(Skipped { + original, + reason: "Initiative not found".to_owned(), + }), + Err(error) => Err(Skipped { + original, + reason: error.message().to_owned(), + }), + } +} + +struct Details { + id: String, + name: String, + already_archived: bool, + linked_projects: usize, +} + +async fn details(client: &LinearClient, id: &str, mode: Mode) -> Result> { + let variables = IdVariables { id: id.to_owned() }; + Ok(match mode { + Mode::Archive => { + let data: GetInitiativeForArchive = client.query(variables).await?; + data.initiative.map(|initiative| Details { + id: initiative.id.into_inner(), + name: initiative.name, + already_archived: initiative.archived_at.is_some(), + linked_projects: 0, + }) + } + Mode::Delete => { + let data: GetInitiativeForDelete = client.query(variables).await?; + data.initiative.map(|initiative| Details { + id: initiative.id.into_inner(), + name: initiative.name, + already_archived: false, + linked_projects: initiative + .projects + .map_or(0, |projects| projects.nodes.len()), + }) + } + }) +} + +async fn submit(client: &LinearClient, id: &str, mode: Mode) -> Result<()> { + let variables = IdVariables { id: id.to_owned() }; + let success = match mode { + Mode::Archive => { + let data: ArchiveInitiative = client.mutate(variables).await?; + data.initiative_archive.success + } + Mode::Delete => { + let data: DeleteInitiative = client.mutate(variables).await?; + data.initiative_delete.success + } + }; + if !success { + return Err(Error::new(format!( + "Linear did not {} the initiative", + mode.verb() + ))); + } + Ok(()) +} diff --git a/crates/linear-cli/src/commands/initiative/comment/add.rs b/crates/linear-cli/src/commands/initiative/comment/add.rs new file mode 100644 index 000000000..0780d6734 --- /dev/null +++ b/crates/linear-cli/src/commands/initiative/comment/add.rs @@ -0,0 +1,59 @@ +//! `initiative comment add`: a comment or reply on an initiative. +use crate::cli::initiative::InitiativeCommentAdd; +use crate::commands::comment_add::{self, CommentTarget}; +use crate::ctx::Ctx; +use crate::error::{Error, Result, ResultExt}; +use crate::refs::{self, initiative::Archived}; + +pub fn run(ctx: &Ctx, args: &InitiativeCommentAdd) -> Result<()> { + add(ctx, args).context("Failed to add comment") +} + +fn add(ctx: &Ctx, args: &InitiativeCommentAdd) -> Result<()> { + let original = &args.initiative; + let body = comment_add::resolve_body(args.body.as_deref(), args.body_file.as_ref())?; + let reference = crate::commands::initiative::common::reference(ctx, original)?; + comment_add::check_parent(args.reply_to.as_deref())?; + if body.is_none() { + comment_add::require_editor(ctx)?; + } + let client = ctx.client()?; + let (initiative_id, name) = ctx.spin(true, async { + let (id, parent) = tokio::try_join!( + refs::initiative::resolve(client, &reference, Archived::Exclude), + comment_add::fetch_parent(client, args.reply_to.as_deref()), + )?; + if let Some(parent) = parent { + let target = CommentTarget::Initiative { + initiative_id: id.clone(), + }; + parent.check(&target, &format!("initiative {original}"))?; + } + let name = match body { + Some(_) => None, + None => Some(refs::initiative::name(client, &id).await?), + }; + Ok::<_, Error>((id, name)) + })?; + let body = match body { + Some(body) => body, + None => { + let name = name.expect("the name is looked up for the editor"); + let question = + comment_add::question(&format!("initiative \"{name}\""), args.reply_to.as_deref()); + match comment_add::write_in_editor(ctx, "", args.confirm.yes, &question)? { + Some(body) => body, + None => return Ok(()), + } + } + }; + let target = CommentTarget::Initiative { initiative_id }; + let input = comment_add::build_input(target, body, args.reply_to.as_deref(), None); + let comment = ctx.spin(true, comment_add::create(client, input))?; + ctx.print(comment_add::output( + "initiative", + original, + args.reply_to.as_deref(), + &comment, + )) +} diff --git a/crates/linear-cli/src/commands/initiative/comment/list.rs b/crates/linear-cli/src/commands/initiative/comment/list.rs new file mode 100644 index 000000000..be71f0942 --- /dev/null +++ b/crates/linear-cli/src/commands/initiative/comment/list.rs @@ -0,0 +1,41 @@ +//! `initiative comment list`: every comment on an initiative, as threads or JSON. +use crate::cli::initiative::InitiativeCommentList; +use crate::commands::comments::{self, CommentSource}; +use crate::ctx::Ctx; +use crate::error::{Result, ResultExt}; +use crate::graphql::operations::comment::CommentConnection; +use crate::graphql::operations::comment::{GetInitiativeComments, GetInitiativeCommentsVariables}; +use crate::refs::{self, initiative::Archived}; + +pub fn run(ctx: &Ctx, args: &InitiativeCommentList) -> Result<()> { + list(ctx, args).context("Failed to list comments") +} + +fn list(ctx: &Ctx, args: &InitiativeCommentList) -> Result<()> { + let original = &args.initiative; + let reference = crate::commands::initiative::common::reference(ctx, original)?; + let client = ctx.client()?; + let nodes = ctx.spin(!args.json, async { + let id = refs::initiative::resolve(client, &reference, Archived::Exclude).await?; + comments::fetch::(client, original, &id, args.limit).await + })?; + comments::print(ctx, &nodes, args.json, "initiative", !args.no_pager) +} + +impl CommentSource for GetInitiativeComments { + const ENTITY: &'static str = "Initiative"; + type Variables = GetInitiativeCommentsVariables; + + fn variables(id: &str, after: Option, first: i32) -> Self::Variables { + GetInitiativeCommentsVariables { + id: id.to_owned(), + filter_id: cynic::Id::new(id), + after, + first, + } + } + + fn comments(self) -> Option { + self.initiative.map(|_| self.comments) + } +} diff --git a/crates/linear-cli/src/commands/initiative/comment/mod.rs b/crates/linear-cli/src/commands/initiative/comment/mod.rs new file mode 100644 index 000000000..6573cf94f --- /dev/null +++ b/crates/linear-cli/src/commands/initiative/comment/mod.rs @@ -0,0 +1,14 @@ +//! `linear initiative comment`: an initiative's comments. +mod add; +mod list; + +use crate::cli::initiative::InitiativeCommentCommand; +use crate::ctx::Ctx; +use crate::error::Result; + +pub fn run(ctx: &Ctx, command: &InitiativeCommentCommand) -> Result<()> { + match command { + InitiativeCommentCommand::Add(args) => add::run(ctx, args), + InitiativeCommentCommand::List(args) => list::run(ctx, args), + } +} diff --git a/crates/linear-cli/src/commands/initiative/common.rs b/crates/linear-cli/src/commands/initiative/common.rs new file mode 100644 index 000000000..2f95ffe7c --- /dev/null +++ b/crates/linear-cli/src/commands/initiative/common.rs @@ -0,0 +1,83 @@ +//! Helpers shared by the initiative commands. +use crate::client::LinearClient; +use crate::ctx::Ctx; +use crate::error::Result; +use crate::graphql::operations::initiative::{GetInitiativeProjectLinks, LinksVariables}; +use crate::graphql::pagination::{self, Page}; +use crate::refs::{ + self, + initiative::{Archived, InitiativeReference}, + project::ProjectReference, +}; + +/// Parses an initiative argument (URL, UUID, slug ID or name) without a request. +pub fn reference(ctx: &Ctx, input: &str) -> Result { + InitiativeReference::parse(input, &ctx.scope()?) +} + +/// The initiative and project arguments, parsed without a request. +pub struct Pair { + initiative: InitiativeReference, + project: ProjectReference, +} + +/// Both sides by ID and name, and the link between them if there is one. +pub struct Link { + pub initiative_id: String, + pub initiative: String, + pub project_id: String, + pub project: String, + pub id: Option, +} + +impl Pair { + pub fn parse(ctx: &Ctx, initiative: &str, project: &str) -> Result { + let scope = ctx.scope()?; + Ok(Self { + initiative: InitiativeReference::parse(initiative, &scope)?, + project: ProjectReference::parse(project, &scope)?, + }) + } + + pub async fn link(&self, client: &LinearClient) -> Result { + let initiative_id = + refs::initiative::resolve(client, &self.initiative, Archived::Exclude).await?; + let project_id = refs::project::resolve(client, &self.project).await?; + let data = pagination::collect_within( + None, + |after, _first| { + let variables = LinksVariables { + initiative_id: initiative_id.clone(), + project_id: project_id.clone(), + after, + }; + async move { + Ok(client + .query::(variables) + .await?) + } + }, + |data| Page { + nodes: std::mem::take(&mut data.project.initiative_to_projects.nodes), + page_info: data.project.initiative_to_projects.page_info.clone(), + }, + |data, page| data.project.initiative_to_projects.nodes = page.nodes, + ) + .await?; + let id = data + .project + .initiative_to_projects + .nodes + .into_iter() + .find(|link| link.initiative.id.inner() == initiative_id) + .map(|link| link.id.into_inner()); + let (initiative, project) = (data.initiative.name, data.project.name); + Ok(Link { + initiative_id, + initiative, + project_id, + project, + id, + }) + } +} diff --git a/crates/linear-cli/src/commands/initiative/create.rs b/crates/linear-cli/src/commands/initiative/create.rs new file mode 100644 index 000000000..32af90f61 --- /dev/null +++ b/crates/linear-cli/src/commands/initiative/create.rs @@ -0,0 +1,196 @@ +//! `initiative create`: fields from flags or prompts, then one mutation. +use crate::commands::outcome; +use crate::commands::{confirm, lookup_prompt}; +use crate::refs::user::UserChoice; +use chrono::NaiveDate; + +use crate::cli::initiative::InitiativeCreate; +use crate::cli::values::{InitiativeStatus, date}; +use crate::client::LinearClient; +use crate::commands::color; +use crate::ctx::Ctx; +use crate::error::{Error, Result, ResultExt}; +use crate::graphql::operations::initiative::{ + CreateInitiative, CreateInitiativeVariables, CreatedInitiative, InitiativeCreateInput, +}; +use crate::graphql::scalars::TimelessDate; +use crate::platform::prompt::{Choice, Prompter, Text}; + +pub fn run(ctx: &Ctx, args: &InitiativeCreate) -> Result<()> { + create(ctx, args).context("Failed to create initiative") +} + +fn create(ctx: &Ctx, args: &InitiativeCreate) -> Result<()> { + let mut fields = Fields { + name: args.name.clone(), + description: args.description.clone(), + status: args.status, + owner: args.owner.clone().map(UserChoice::Given), + target_date: args.target_date, + color: args.color.clone().map(String::from), + icon: args.icon.clone(), + }; + let optional = ctx.optional_prompts(args.interactive)?; + let typed = ctx.interactive() && (fields.name.is_none() || optional); + if typed { + ctx.eprint("\nCreate a new initiative\n\n")?; + prompt(ctx, &mut fields, &ctx.prompter()?, optional)?; + } + let input = validate(fields)?; + let question = format!("Create initiative \"{}\"?", input.name); + if typed && !confirm::proceed(ctx, args.confirm.yes, &question)? { + return Ok(()); + } + let client = ctx.client()?; + let created = ctx.spin(true, async { + let (owner, input) = input.take_owner(); + let owner_id = match owner { + Some(owner) => Some(owner.id(client, "Owner").await?), + None => None, + }; + submit(client, input.into_create(owner_id)).await + })?; + ctx.print(render(&created)) +} + +/// The fields as given on the command line or at the prompts. +struct Fields { + name: Option, + description: Option, + status: Option, + owner: Option, + target_date: Option, + color: Option, + icon: Option, +} + +/// Validated fields, ready to send once the owner is looked up. +struct Valid { + name: String, + description: Option, + status: Option, + owner: Option, + target_date: Option, + color: Option, + icon: Option, +} + +impl Valid { + fn take_owner(mut self) -> (Option, Self) { + (self.owner.take(), self) + } + + fn into_create(self, owner_id: Option) -> InitiativeCreateInput { + InitiativeCreateInput { + name: self.name, + description: self.description, + status: self.status.map(Into::into), + owner_id, + target_date: self.target_date.map(TimelessDate::from), + color: self.color, + icon: self.icon, + } + } +} + +/// Asks for the name when it is missing, and with `all` for every other +/// field not given as a flag. +fn prompt(ctx: &Ctx, options: &mut Fields, prompter: &Prompter<'_>, all: bool) -> Result<()> { + if options.name.as_deref().is_none_or(str::is_empty) { + options.name = Some(prompter.text(Text::new("Name:").required())?); + } + if !all { + return Ok(()); + } + if options.description.as_deref().is_none_or(str::is_empty) { + options.description = optional(prompter.text(Text::new("Description (optional):"))?); + } + if options.status.is_none() { + let choices = [ + ("Planned", InitiativeStatus::Planned), + ("Active", InitiativeStatus::Active), + ("Completed", InitiativeStatus::Completed), + ] + .into_iter() + .map(|(label, status)| Choice::new(label, status)) + .collect(); + options.status = Some(prompter.select("Status:", choices)?); + } + if options.owner.is_none() { + options.owner = lookup_prompt::ask_user( + ctx, + prompter, + "Owner (username, email, or @me; optional):", + "Owner", + )? + .map(UserChoice::Found); + } + if options.target_date.is_none() { + options.target_date = + prompter.parsed(Text::new("Target date (YYYY-MM-DD, optional):"), &date)?; + } + if options.color.as_deref().is_none_or(str::is_empty) { + let mut colors = vec![Choice::new("Skip (use default)", Color::Skip)]; + colors.extend( + color::PALETTE + .into_iter() + .map(|(name, hex)| Choice::new(color::label(name, hex), Color::Hex(hex))), + ); + colors.push(Choice::new("Custom color", Color::Custom)); + options.color = match prompter.select("Color (optional):", colors)? { + Color::Skip => None, + Color::Hex(hex) => Some(hex.to_owned()), + Color::Custom => Some(color::custom(prompter)?), + }; + } + Ok(()) +} + +enum Color { + Skip, + Hex(&'static str), + Custom, +} + +fn optional(value: String) -> Option { + if value.is_empty() { None } else { Some(value) } +} + +fn validate(fields: Fields) -> Result { + let name = fields + .name + .filter(|name| !name.is_empty()) + .ok_or_else(|| Error::invalid("Initiative name is required").with_hint("Pass --name."))?; + let nonempty = |value: Option| value.filter(|value| !value.is_empty()); + Ok(Valid { + name, + description: nonempty(fields.description), + status: fields.status, + owner: fields.owner, + target_date: fields.target_date, + color: nonempty(fields.color), + icon: nonempty(fields.icon), + }) +} + +/// Sends the mutation once. A failure after the request may have reached +/// Linear says the initiative may already exist; nothing is retried. +async fn submit(client: &LinearClient, input: InitiativeCreateInput) -> Result { + let result: CreateInitiative = client + .mutate(CreateInitiativeVariables { input }) + .await + .map_err(|failure| failure.into_create_error("initiative"))?; + if !result.initiative_create.success { + return Err(Error::new("Linear did not create the initiative")); + } + Ok(result.initiative_create.initiative) +} + +fn render(initiative: &CreatedInitiative) -> String { + outcome::done( + "Created", + "initiative", + &initiative.name, + Some(&initiative.url), + ) +} diff --git a/crates/linear-cli/src/commands/initiative/delete.rs b/crates/linear-cli/src/commands/initiative/delete.rs new file mode 100644 index 000000000..17f9980a6 --- /dev/null +++ b/crates/linear-cli/src/commands/initiative/delete.rs @@ -0,0 +1,17 @@ +//! `initiative delete`: permanently delete, for one initiative or in bulk. +use crate::cli::initiative::InitiativeDelete; +use crate::commands::bulk::BulkInput; +use crate::ctx::Ctx; +use crate::error::{Result, ResultExt}; + +use super::archive_or_delete::{Mode, Request}; + +pub fn run(ctx: &Ctx, args: &InitiativeDelete) -> Result<()> { + let request = Request { + initiative: args.initiative_id.as_deref(), + yes: args.confirm.yes, + bulk: BulkInput::from(&args.bulk), + }; + super::archive_or_delete::run(ctx, Mode::Delete, &request) + .context("Failed to delete initiative") +} diff --git a/crates/linear-cli/src/commands/initiative/list.rs b/crates/linear-cli/src/commands/initiative/list.rs new file mode 100644 index 000000000..2bdaec77b --- /dev/null +++ b/crates/linear-cli/src/commands/initiative/list.rs @@ -0,0 +1,260 @@ +//! `initiative list`: every page, sorted by status then name, as a table or JSON. +use serde::Serialize; + +use crate::cli::initiative::InitiativeList; +use crate::cli::values; +use crate::client::LinearClient; +use crate::commands::json; +use crate::commands::status_update::UpdateHealth; +use crate::commands::table::{Cell, Column, Table}; +use crate::ctx::Ctx; +use crate::error::{Error, Result, ResultExt}; +use crate::graphql::operations::initiative::{ + GetInitiativeProjectsPage, GetInitiatives, GetInitiativesVariables, IDComparator, Initiative, + InitiativeFilter, InitiativeOwner, InitiativeProject, InitiativeProjectsPageVariables, + InitiativeStatus, InitiativeUpdateHealthType, NullableUserFilter, +}; +use crate::graphql::operations::team::StringComparator; +use crate::graphql::pagination::{self, Page, PageInfo}; +use crate::graphql::scalars::{DateTime, TimelessDate}; +use crate::platform::{collation, style}; +use crate::refs; + +pub fn run(ctx: &Ctx, args: &InitiativeList) -> Result<()> { + if args.web || args.app { + return ctx + .open_in_linear("initiatives", args.app) + .context("Failed to open initiatives"); + } + list(ctx, args).context("Failed to list initiatives") +} + +fn list(ctx: &Ctx, args: &InitiativeList) -> Result<()> { + let status = status_filter(args.status, args.all_statuses); + let client = ctx.client()?; + let mut initiatives = ctx.spin(!args.json, async { + let owner = match &args.owner { + Some(owner) => Some(refs::user::resolve(client, owner, "Owner").await?), + None => None, + }; + fetch(client, filter(status, owner), args.archived).await + })?; + args.limit.apply(&mut initiatives); + if args.json { + ctx.print(render_json(&initiatives)) + } else if initiatives.is_empty() { + ctx.print("No initiatives found.\n") + } else { + ctx.print(render_text(&initiatives).render_for(ctx)) + } +} + +/// The API status value to filter on: `--status`, every status with +/// `--all-statuses`, and only active initiatives otherwise. +fn status_filter( + status: Option, + all_statuses: bool, +) -> Option<&'static str> { + match status { + Some(values::InitiativeStatus::Active) => Some("Active"), + Some(values::InitiativeStatus::Planned) => Some("Planned"), + Some(values::InitiativeStatus::Completed) => Some("Completed"), + None if all_statuses => None, + None => Some("Active"), + } +} + +fn filter(status: Option<&str>, owner: Option) -> Option { + let status = status.map(|value| StringComparator { + eq: Some(value.to_owned()), + ..Default::default() + }); + let owner = owner.map(|id| NullableUserFilter { + id: Some(IDComparator { + eq: Some(cynic::Id::new(id)), + }), + }); + (status.is_some() || owner.is_some()).then_some(InitiativeFilter { status, owner }) +} + +/// Initiatives per request. Linear prices a request by how many nodes it +/// could return, and each initiative brings up to 50 projects, so a page of +/// 100 is over its complexity limit of 10,000. +const INITIATIVES_PER_REQUEST: i32 = 25; + +/// Every matching initiative, sorted by status then name. +async fn fetch( + client: &LinearClient, + filter: Option, + archived: bool, +) -> Result> { + let mut initiatives = pagination::collect(None, |after, first| { + let variables = GetInitiativesVariables { + filter: filter.clone(), + include_archived: Some(archived), + first: Some(first.min(INITIATIVES_PER_REQUEST)), + after, + }; + async move { + let data: GetInitiatives = client.query(variables).await?; + Ok(data.initiatives.map_or_else( + || Page { + nodes: Vec::new(), + page_info: PageInfo { + has_next_page: false, + end_cursor: None, + }, + }, + |connection| Page { + nodes: connection.nodes, + page_info: connection.page_info, + }, + )) + } + }) + .await?; + for item in &mut initiatives { + if item.projects.page_info.has_next_page { + complete_projects(client, item).await?; + } + } + for item in &initiatives { + if let InitiativeStatus::Unknown(value) = &item.status { + return Err(Error::new(format!( + "Linear returned an unknown initiative status: {value}" + ))); + } + if let Some(InitiativeUpdateHealthType::Unknown(value)) = &item.health { + return Err(Error::new(format!( + "Linear returned an unknown initiative health: {value}" + ))); + } + } + initiatives.sort_by(|left, right| { + left.status + .rank() + .cmp(&right.status.rank()) + .then_with(|| collation::compare(&left.name, &right.name)) + }); + Ok(initiatives) +} + +/// Replaces the first page of `initiative`'s projects with every page. +async fn complete_projects(client: &LinearClient, initiative: &mut Initiative) -> Result<()> { + let first = Page { + nodes: std::mem::take(&mut initiative.projects.nodes), + page_info: initiative.projects.page_info.clone(), + }; + let id = initiative.id.inner().to_owned(); + let nodes = pagination::complete(first, |after, first| { + let variables = InitiativeProjectsPageVariables { + id: id.clone(), + first, + after, + }; + async move { + let data: GetInitiativeProjectsPage = client.query(variables).await?; + let projects = data.initiative.projects; + Ok(Page { + nodes: projects.nodes, + page_info: projects.page_info, + }) + } + }) + .await?; + initiative.projects.nodes = nodes; + initiative.projects.page_info = PageInfo { + has_next_page: false, + end_cursor: None, + }; + Ok(()) +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct JsonInitiative<'a> { + id: &'a cynic::Id, + slug_id: &'a str, + name: &'a str, + description: Option<&'a str>, + status: &'a str, + target_date: Option<&'a TimelessDate>, + health: Option<&'a str>, + color: Option<&'a str>, + icon: Option<&'a str>, + url: &'a str, + archived_at: Option<&'a DateTime>, + owner: Option<&'a InitiativeOwner>, + projects: &'a [InitiativeProject], +} + +fn render_json(initiatives: &[Initiative]) -> Vec { + let initiatives: Vec<_> = initiatives + .iter() + .map(|item| JsonInitiative { + id: &item.id, + slug_id: &item.slug_id, + name: &item.name, + description: item.description.as_deref(), + status: item.status.as_str(), + target_date: item.target_date.as_ref(), + health: item.health.as_ref().map(InitiativeUpdateHealthType::as_str), + color: item.color.as_deref(), + icon: item.icon.as_deref(), + url: &item.url, + archived_at: item.archived_at.as_ref(), + owner: item.owner.as_ref(), + projects: &item.projects.nodes, + }) + .collect(); + json::render(&initiatives) +} + +/// The status column's color, matching the status colors in Linear. +pub(super) fn status_style(status: &InitiativeStatus, text: &str, color: bool) -> String { + match status { + InitiativeStatus::Active => style::green(text, color), + InitiativeStatus::Canceled => style::red(text, color), + InitiativeStatus::Planned => style::blue(text, color), + InitiativeStatus::Completed | InitiativeStatus::Proposed | InitiativeStatus::Unknown(_) => { + style::gray(text, color) + } + } +} + +fn render_text(initiatives: &[Initiative]) -> Table { + let mut table = Table::new([ + Column::fixed("SLUG"), + Column::flexible("NAME"), + Column::fixed("STATUS"), + Column::fixed("HEALTH").droppable(4), + Column::fixed("OWNER").droppable(3), + Column::fixed("PROJ").droppable(1), + Column::fixed("TARGET").droppable(2), + ]); + for item in initiatives { + let owner = item + .owner + .as_ref() + .map(|owner| owner.initials.as_str()) + .filter(|initials| !initials.is_empty()) + .unwrap_or("-"); + let status = item.status.clone(); + table.row([ + Cell::from(item.slug_id.as_str()), + Cell::from(item.name.as_str()), + Cell::styled(item.status.as_str(), move |text, on| { + status_style(&status, text, on) + }), + UpdateHealth::cell(item.health.as_ref().map(UpdateHealth::from)), + Cell::from(owner), + Cell::from(item.projects.nodes.len().to_string()), + Cell::styled( + item.target_date + .map_or_else(|| "-".to_owned(), |date| date.to_string()), + style::gray, + ), + ]); + } + table +} diff --git a/crates/linear-cli/src/commands/initiative/mod.rs b/crates/linear-cli/src/commands/initiative/mod.rs new file mode 100644 index 000000000..46e628294 --- /dev/null +++ b/crates/linear-cli/src/commands/initiative/mod.rs @@ -0,0 +1,32 @@ +//! `linear initiative`: initiatives, their projects and comments. +mod add_project; +mod archive; +mod archive_or_delete; +mod comment; +mod common; +mod create; +mod delete; +mod list; +mod remove_project; +mod unarchive; +mod update; +mod view; + +use crate::cli::initiative::InitiativeCommand; +use crate::ctx::Ctx; +use crate::error::Result; + +pub fn run(ctx: &Ctx, command: &InitiativeCommand) -> Result<()> { + match command { + InitiativeCommand::List(args) => list::run(ctx, args), + InitiativeCommand::View(args) => view::run(ctx, args), + InitiativeCommand::Create(args) => create::run(ctx, args), + InitiativeCommand::Archive(args) => archive::run(ctx, args), + InitiativeCommand::Update(args) => update::run(ctx, args), + InitiativeCommand::Unarchive(args) => unarchive::run(ctx, args), + InitiativeCommand::Delete(args) => delete::run(ctx, args), + InitiativeCommand::AddProject(args) => add_project::run(ctx, args), + InitiativeCommand::RemoveProject(args) => remove_project::run(ctx, args), + InitiativeCommand::Comment(args) => comment::run(ctx, &args.command), + } +} diff --git a/crates/linear-cli/src/commands/initiative/remove_project.rs b/crates/linear-cli/src/commands/initiative/remove_project.rs new file mode 100644 index 000000000..1373e0607 --- /dev/null +++ b/crates/linear-cli/src/commands/initiative/remove_project.rs @@ -0,0 +1,46 @@ +//! `initiative remove-project`: unlink a project from an initiative. +use crate::cli::initiative::InitiativeRemoveProject; +use crate::commands::outcome; +use crate::ctx::Ctx; +use crate::error::{Error, Result, ResultExt}; +use crate::graphql::operations::common::IdVariables; +use crate::graphql::operations::initiative::RemoveProjectFromInitiative; + +use super::common::Pair; + +pub fn run(ctx: &Ctx, args: &InitiativeRemoveProject) -> Result<()> { + remove(ctx, args).context("Failed to remove project from initiative") +} + +fn remove(ctx: &Ctx, args: &InitiativeRemoveProject) -> Result<()> { + if !args.confirm.yes { + ctx.require_tty("for confirmation", "--yes")?; + } + let pair = Pair::parse(ctx, &args.initiative, &args.project)?; + let client = ctx.client()?; + let link = ctx.spin(true, pair.link(client))?; + let Some(link_id) = link.id else { + return Err(Error::new(format!( + "Project \"{}\" is not linked to initiative \"{}\"", + link.project, link.initiative + ))); + }; + let question = format!( + "Remove \"{}\" from initiative \"{}\"?", + link.project, link.initiative + ); + if !args.confirm.yes && !ctx.confirm(&question, "--yes")? { + return outcome::canceled(ctx); + } + let result: RemoveProjectFromInitiative = + ctx.spin(true, client.mutate(IdVariables { id: link_id }))?; + if !result.initiative_to_project_delete.success { + return Err(Error::new("Linear did not unlink the project")); + } + ctx.print(outcome::done( + "Removed", + "project", + &format!("{} from initiative {}", link.project, link.initiative), + None, + )) +} diff --git a/crates/linear-cli/src/commands/initiative/unarchive.rs b/crates/linear-cli/src/commands/initiative/unarchive.rs new file mode 100644 index 000000000..966e842be --- /dev/null +++ b/crates/linear-cli/src/commands/initiative/unarchive.rs @@ -0,0 +1,60 @@ +//! `initiative unarchive`: find the archived initiative, confirm, restore it. +use crate::cli::initiative::InitiativeUnarchive; +use crate::client::LinearClient; +use crate::commands::outcome; +use crate::ctx::Ctx; +use crate::error::{Error, Result, ResultExt}; +use crate::graphql::operations::initiative::{ + ArchivedLookupVariables, GetInitiativeForUnarchive, UnarchiveDetail, UnarchiveInitiative, + UnarchiveVariables, +}; +use crate::refs::{self, initiative::Archived}; + +pub fn run(ctx: &Ctx, args: &InitiativeUnarchive) -> Result<()> { + unarchive(ctx, args).context("Failed to unarchive initiative") +} + +fn unarchive(ctx: &Ctx, args: &InitiativeUnarchive) -> Result<()> { + if !args.confirm.yes { + ctx.require_tty("for confirmation", "--yes")?; + } + let original = &args.initiative_id; + let reference = super::common::reference(ctx, original)?; + let client = ctx.client()?; + let detail = ctx.spin(true, async { + let id = refs::initiative::resolve(client, &reference, Archived::Include).await?; + details(client, &id, original).await + })?; + if detail.archived_at.is_none() { + return ctx.print(format!("Initiative \"{}\" is not archived.\n", detail.name)); + } + let question = format!("Unarchive initiative \"{}\"?", detail.name); + if !args.confirm.yes && !ctx.confirm(&question, "--yes")? { + return outcome::canceled(ctx); + } + let result: UnarchiveInitiative = ctx.spin( + true, + client.mutate(UnarchiveVariables { + id: detail.id.inner().to_owned(), + }), + )?; + if !result.initiative_unarchive.success { + return Err(Error::new("Linear did not unarchive the initiative")); + } + let url = result.initiative_unarchive.entity.map(|entity| entity.url); + let output = outcome::done("Unarchived", "initiative", &detail.name, url.as_deref()); + ctx.print(output) +} + +async fn details(client: &LinearClient, id: &str, original: &str) -> Result { + let data: GetInitiativeForUnarchive = client + .query(ArchivedLookupVariables { + id: cynic::Id::new(id), + }) + .await?; + data.initiatives + .nodes + .into_iter() + .next() + .ok_or_else(|| Error::not_found("Initiative", original)) +} diff --git a/crates/linear-cli/src/commands/initiative/update.rs b/crates/linear-cli/src/commands/initiative/update.rs new file mode 100644 index 000000000..77bc817cd --- /dev/null +++ b/crates/linear-cli/src/commands/initiative/update.rs @@ -0,0 +1,201 @@ +//! `initiative update`: fields from flags or prompts, then one mutation. +use crate::commands::confirm; +use crate::commands::outcome; +use crate::refs::{self, initiative::Archived}; +use chrono::NaiveDate; + +use crate::cli::initiative::InitiativeUpdate; +use crate::cli::values::{HexColor, UserRef, date}; +use crate::client::LinearClient; +use crate::ctx::Ctx; +use crate::error::{Error, Result, ResultExt}; +use crate::graphql::operations::initiative::DetailVariables; +use crate::graphql::operations::initiative::InitiativeStatus; +use crate::graphql::operations::initiative::{ + CurrentInitiative, GetInitiativeForUpdate, InitiativeUpdateInput, UpdateInitiative, + UpdateVariables, UpdatedInitiative, +}; +use crate::graphql::scalars::TimelessDate; +use crate::platform::prompt::{Choice, Prompter, Text}; + +pub fn run(ctx: &Ctx, args: &InitiativeUpdate) -> Result<()> { + update(ctx, args).context("Failed to update initiative") +} + +fn update(ctx: &Ctx, args: &InitiativeUpdate) -> Result<()> { + let original = &args.initiative_id; + let reference = super::common::reference(ctx, original)?; + let flags = Changes { + name: args.name.clone(), + description: args.description.clone(), + status: args.status.map(Into::into), + owner: args.owner.clone(), + target_date: args.target_date, + color: args.color.clone().map(String::from), + icon: args.icon.clone(), + }; + let interactive = ctx.optional_prompts(args.interactive)?; + let prompting = flags.is_empty(); + if prompting && !interactive { + return Err(Error::invalid("No changes given").with_hint( + "Pass the fields to change, such as --name or --status, or -i to be prompted.", + )); + } + let client = ctx.client()?; + let (id, changes) = if prompting { + let (id, current) = ctx.spin(true, async { + let id = refs::initiative::resolve(client, &reference, Archived::Exclude).await?; + let current = details(client, &id, original).await?; + Ok::<_, Error>((id, current)) + })?; + ctx.eprint(format!("\nUpdating initiative: {}\n\n", current.name))?; + let changes = prompt(&ctx.prompter()?, ¤t)?; + if changes.is_empty() { + return ctx.print(outcome::NO_CHANGES); + } + let question = format!("Save these changes to \"{}\"?", current.name); + if !confirm::proceed(ctx, args.confirm.yes, &question)? { + return Ok(()); + } + (id, changes) + } else { + let id = ctx.spin( + true, + refs::initiative::resolve(client, &reference, Archived::Exclude), + )?; + (id, flags) + }; + let updated = ctx.spin(true, async { + let owner_id = match &changes.owner { + Some(owner) => Some(refs::user::resolve(client, owner, "Owner").await?), + None => None, + }; + submit(client, &id, changes.into_input(owner_id)).await + })?; + ctx.print(outcome::done( + "Updated", + "initiative", + &updated.name, + Some(&updated.url), + )) +} + +/// The fields to change; `None` leaves a field as it is. +#[derive(Default)] +struct Changes { + name: Option, + description: Option, + status: Option, + owner: Option, + target_date: Option, + color: Option, + icon: Option, +} + +impl Changes { + fn is_empty(&self) -> bool { + self.name.is_none() + && self.description.is_none() + && self.status.is_none() + && self.owner.is_none() + && self.target_date.is_none() + && self.color.is_none() + && self.icon.is_none() + } + + fn into_input(self, owner_id: Option) -> InitiativeUpdateInput { + InitiativeUpdateInput { + name: self.name, + description: self.description, + status: self.status, + owner_id, + target_date: self.target_date.map(TimelessDate::from), + color: self.color, + icon: self.icon, + } + } +} + +async fn details(client: &LinearClient, id: &str, original: &str) -> Result { + let result: GetInitiativeForUpdate = + client.query(DetailVariables { id: id.to_owned() }).await?; + result + .initiative + .ok_or_else(|| Error::not_found("Initiative", original)) +} + +async fn submit( + client: &LinearClient, + id: &str, + input: InitiativeUpdateInput, +) -> Result { + let result: UpdateInitiative = client + .mutate(UpdateVariables { + id: id.to_owned(), + input, + }) + .await?; + if !result.initiative_update.success { + return Err(Error::new("Linear did not update the initiative")); + } + Ok(result.initiative_update.initiative) +} + +/// Asks for each field with its current value as the default; only changed +/// fields are returned. +fn prompt(prompter: &Prompter<'_>, current: &CurrentInitiative) -> Result { + let mut changes = Changes::default(); + let name = prompter.text(Text::new("Name:").required().with_default(¤t.name))?; + if name != current.name { + changes.name = Some(name); + } + let default = current.description.as_deref().unwrap_or(""); + let value = prompter.text(Text::new("Description:").with_default(default))?; + if value != default { + changes.description = (!value.is_empty()).then_some(value); + } + let statuses = [ + (InitiativeStatus::Planned, "Planned"), + (InitiativeStatus::Active, "Active"), + (InitiativeStatus::Completed, "Completed"), + ]; + let start = statuses + .iter() + .position(|(status, _)| Some(status) == current.status.as_ref()) + .unwrap_or(0); + let choices = statuses + .into_iter() + .map(|(status, label)| Choice::new(label, status)) + .collect(); + let status = prompter.select_from("Status:", choices, start)?; + if Some(&status) != current.status.as_ref() { + changes.status = Some(status); + } + let current_target = current.target_date.map(|date| date.0); + let default = current + .target_date + .map(|date| date.to_string()) + .unwrap_or_default(); + let value = prompter.parsed( + Text::new("Target date (YYYY-MM-DD):").with_default(&default), + &date, + )?; + if let Some(value) = value + && Some(value) != current_target + { + changes.target_date = Some(value); + } + let default = current.color.as_deref().unwrap_or(""); + let value = prompter + .parsed( + Text::new("Color (hex, like #5E6AD2):").with_default(default), + &str::parse::, + )? + .map(String::from); + if let Some(value) = value + && value != default + { + changes.color = Some(value); + } + Ok(changes) +} diff --git a/crates/linear-cli/src/commands/initiative/view.rs b/crates/linear-cli/src/commands/initiative/view.rs new file mode 100644 index 000000000..b8229425f --- /dev/null +++ b/crates/linear-cli/src/commands/initiative/view.rs @@ -0,0 +1,282 @@ +//! `initiative view`: an initiative's details as Markdown or JSON, or opened in Linear. +use chrono::{DateTime, Local, Utc}; +use serde::Serialize; + +use crate::cli::initiative::InitiativeView; +use crate::client::LinearClient; +use crate::commands::json; +use crate::commands::relative_time::ago; +use crate::commands::status_update::UpdateHealth; +use crate::ctx::Ctx; +use crate::error::{Error, Result, ResultExt}; +use crate::graphql::operations::initiative::{ + DetailVariables, GetInitiativeDetails, GetInitiativeViewProjectsPage, InitiativeDetails, + InitiativeProjectsPageVariables, +}; +use crate::graphql::operations::initiative::{InitiativeStatus, InitiativeUpdateHealthType}; +use crate::graphql::operations::project::ProjectStatusType; +use crate::graphql::pagination::{self, Page, PageInfo}; +use crate::graphql::scalars; +use crate::refs::{self, initiative::Archived}; + +pub fn run(ctx: &Ctx, args: &InitiativeView) -> Result<()> { + view(ctx, args).context("Failed to view initiative") +} + +fn view(ctx: &Ctx, args: &InitiativeView) -> Result<()> { + let original = &args.initiative_id; + let reference = super::common::reference(ctx, original)?; + let client = ctx.client()?; + let detail = ctx.spin(!args.json, async { + let id = refs::initiative::resolve(client, &reference, Archived::Exclude).await?; + fetch(client, id, original).await + })?; + if args.web || args.app { + return ctx.open_url(&detail.url, args.app); + } + if args.json { + return ctx.print(render_json(&detail)); + } + let now = Utc::now(); + if !ctx.stdout_tty() { + return ctx.print(format!("{}\n", markdown(&detail, now, false))); + } + let rendered = ctx.render_markdown(&markdown(&detail, now, true)); + let status = format!("**Status:** {}", detail.status.as_str()); + let status = super::list::status_style(&detail.status, &status, ctx.color()); + ctx.page(&format!("{status}\n{rendered}"), !args.no_pager) +} + +async fn fetch(client: &LinearClient, id: String, original: &str) -> Result { + let result: GetInitiativeDetails = client.query(DetailVariables { id }).await?; + let mut detail = result + .initiative + .ok_or_else(|| Error::not_found("Initiative", original))?; + complete_projects(client, &mut detail).await?; + verify_detail(&detail)?; + Ok(detail) +} + +/// Replaces the first page of `detail`'s projects with every page. +async fn complete_projects(client: &LinearClient, detail: &mut InitiativeDetails) -> Result<()> { + let first = Page { + nodes: std::mem::take(&mut detail.projects.nodes), + page_info: detail.projects.page_info.clone(), + }; + let id = detail.id.inner().to_owned(); + detail.projects.nodes = pagination::complete(first, |after, first| { + let variables = InitiativeProjectsPageVariables { + id: id.clone(), + first, + after, + }; + async move { + let data: GetInitiativeViewProjectsPage = client.query(variables).await?; + let projects = data.initiative.projects; + Ok(Page { + nodes: projects.nodes, + page_info: projects.page_info, + }) + } + }) + .await?; + detail.projects.page_info = PageInfo { + has_next_page: false, + end_cursor: None, + }; + Ok(()) +} + +fn verify_detail(detail: &InitiativeDetails) -> Result<()> { + if let InitiativeStatus::Unknown(value) = &detail.status { + return Err(Error::new(format!( + "Linear returned an unknown initiative status: {value}" + ))); + } + if let Some(InitiativeUpdateHealthType::Unknown(value)) = &detail.health { + return Err(Error::new(format!( + "Linear returned an unknown initiative health: {value}" + ))); + } + for project in &detail.projects.nodes { + if let ProjectStatusType::Unknown(value) = &project.status.status_type { + return Err(Error::new(format!( + "Linear returned an unknown project status type: {value}" + ))); + } + } + Ok(()) +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct JsonDetail<'a> { + id: &'a cynic::Id, + slug_id: &'a str, + name: &'a str, + description: Option<&'a str>, + status: &'a str, + target_date: Option<&'a scalars::TimelessDate>, + health: Option<&'a str>, + color: Option<&'a str>, + icon: Option<&'a str>, + url: &'a str, + archived_at: Option<&'a scalars::DateTime>, + created_at: &'a scalars::DateTime, + updated_at: &'a scalars::DateTime, + owner: Option>, + projects: Vec>, +} +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct JsonOwner<'a> { + id: &'a cynic::Id, + name: &'a str, + display_name: &'a str, +} +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct JsonProject<'a> { + id: &'a cynic::Id, + slug_id: &'a str, + name: &'a str, + status: JsonProjectStatus<'a>, +} +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct JsonProjectStatus<'a> { + name: &'a str, + #[serde(rename = "type")] + status_type: &'a str, +} + +fn render_json(detail: &InitiativeDetails) -> Vec { + let projection = JsonDetail { + id: &detail.id, + slug_id: &detail.slug_id, + name: &detail.name, + description: detail.description.as_deref(), + status: detail.status.as_str(), + target_date: detail.target_date.as_ref(), + health: detail + .health + .as_ref() + .map(InitiativeUpdateHealthType::as_str), + color: detail.color.as_deref(), + icon: detail.icon.as_deref(), + url: &detail.url, + archived_at: detail.archived_at.as_ref(), + created_at: &detail.created_at, + updated_at: &detail.updated_at, + owner: detail.owner.as_ref().map(|o| JsonOwner { + id: &o.id, + name: &o.name, + display_name: &o.display_name, + }), + projects: detail + .projects + .nodes + .iter() + .map(|p| JsonProject { + id: &p.id, + slug_id: &p.slug_id, + name: &p.name, + status: JsonProjectStatus { + name: &p.status.name, + status_type: p.status.status_type.as_str(), + }, + }) + .collect(), + }; + json::render(&projection) +} + +fn project_rank(status: &ProjectStatusType) -> u8 { + match status { + ProjectStatusType::Started => 0, + ProjectStatusType::Planned => 1, + ProjectStatusType::Backlog => 2, + ProjectStatusType::Paused => 3, + ProjectStatusType::Completed => 4, + ProjectStatusType::Canceled => 5, + ProjectStatusType::Unknown(_) => 6, + } +} + +fn markdown(detail: &InitiativeDetails, now: DateTime, terminal: bool) -> String { + let icon = detail + .icon + .as_deref() + .filter(|s| !s.is_empty()) + .map(|s| format!("{s} ")) + .unwrap_or_default(); + let mut lines = vec![ + format!("# {icon}{}", detail.name), + String::new(), + format!("**Slug:** {}", detail.slug_id), + format!("**URL:** {}", detail.url), + ]; + if !terminal { + lines.push(format!("**Status:** {}", detail.status.as_str())); + } + if let Some(health) = &detail.health { + lines.push(format!( + "**Health:** {}", + UpdateHealth::from(health).label() + )); + } + if let Some(owner) = &detail.owner { + let display = if owner.display_name.is_empty() { + &owner.name + } else { + &owner.display_name + }; + lines.push(format!("**Owner:** {display}")); + } + if let Some(date) = &detail.target_date { + lines.push(format!("**Target Date:** {date}")); + } + if let Some(date) = &detail.archived_at { + lines.push(format!("**Archived:** {}", ago(date.0, now, &Local))); + } + lines.push(String::new()); + lines.push(format!( + "**Created:** {}", + ago(detail.created_at.0, now, &Local) + )); + lines.push(format!( + "**Updated:** {}", + ago(detail.updated_at.0, now, &Local) + )); + if let Some(description) = detail.description.as_deref().filter(|s| !s.is_empty()) { + lines.extend([ + String::new(), + "## Description".to_owned(), + String::new(), + description.to_owned(), + ]); + } + lines.push(String::new()); + if detail.projects.nodes.is_empty() { + lines.extend([ + "## Projects".to_owned(), + String::new(), + "*No projects linked to this initiative.*".to_owned(), + ]); + } else { + lines.push(format!("## Projects ({})", detail.projects.nodes.len())); + lines.push(String::new()); + let mut projects = detail.projects.nodes.iter().enumerate().collect::>(); + projects + .sort_by_key(|(index, project)| (project_rank(&project.status.status_type), *index)); + for (_, project) in projects { + let status = if project.status.name.is_empty() { + "Unknown" + } else { + &project.status.name + }; + lines.push(format!("- **{}** ({status})", project.name)); + } + } + lines.join("\n") +} diff --git a/crates/linear-cli/src/commands/initiative_update/create.rs b/crates/linear-cli/src/commands/initiative_update/create.rs new file mode 100644 index 000000000..6b22e4700 --- /dev/null +++ b/crates/linear-cli/src/commands/initiative_update/create.rs @@ -0,0 +1,10 @@ +//! `initiative-update create`: post a status update to an initiative. +use crate::cli::initiative_update::InitiativeUpdateCreate; +use crate::commands::status_update::{self, Target}; +use crate::ctx::Ctx; +use crate::error::{Result, ResultExt}; + +pub fn run(ctx: &Ctx, args: &InitiativeUpdateCreate) -> Result<()> { + status_update::create(ctx, Target::Initiative(&args.initiative_id), &args.update) + .context("Failed to create initiative update") +} diff --git a/crates/linear-cli/src/commands/initiative_update/list.rs b/crates/linear-cli/src/commands/initiative_update/list.rs new file mode 100644 index 000000000..79cbcd1a0 --- /dev/null +++ b/crates/linear-cli/src/commands/initiative_update/list.rs @@ -0,0 +1,80 @@ +//! `initiative-update list`: an initiative's latest status updates as a table or JSON. +use chrono::Utc; + +use crate::cli::initiative_update::InitiativeUpdateList; +use crate::commands::json; +use crate::commands::status_update::{self, JsonUpdate, JsonUser, Row, UpdateHealth}; +use crate::ctx::Ctx; +use crate::error::{Error, Result, ResultExt}; +use crate::graphql::operations::status_update::{ + InitiativeUpdateNode, ListInitiativeUpdates, ListInitiativeUpdatesVariables, +}; +use crate::graphql::pagination::{self, Page}; +use crate::refs::{self, initiative::InitiativeReference}; + +pub fn run(ctx: &Ctx, args: &InitiativeUpdateList) -> Result<()> { + list(ctx, args).context("Failed to list initiative updates") +} + +fn list(ctx: &Ctx, args: &InitiativeUpdateList) -> Result<()> { + let original = &args.initiative_id; + let reference = InitiativeReference::parse(original, &ctx.scope()?)?; + let client = ctx.client()?; + let initiative = ctx.spin(!args.json, async { + let id = refs::initiative::resolve(client, &reference, refs::initiative::Archived::Exclude) + .await?; + pagination::collect_within( + args.limit.max(), + |after, first| { + let variables = ListInitiativeUpdatesVariables { + id: id.clone(), + first: Some(first), + after, + }; + async move { + let data: ListInitiativeUpdates = client.query(variables).await?; + data.initiative + .ok_or_else(|| Error::not_found("Initiative", original)) + } + }, + |initiative| Page { + nodes: std::mem::take(&mut initiative.initiative_updates.nodes), + page_info: initiative.initiative_updates.page_info.clone(), + }, + |initiative, page| initiative.initiative_updates.nodes = page.nodes, + ) + .await + })?; + let updates = &initiative.initiative_updates.nodes; + if args.json { + return ctx.print(render_json(updates)); + } + if updates.is_empty() { + return ctx.print(format!("No status updates found for {}\n", initiative.name)); + } + let rows = updates + .iter() + .map(|node| Row { + health: Some(UpdateHealth::from(&node.health)), + created_at: node.created_at.0, + author: status_update::author(node.user.as_ref()), + body: &node.body, + }) + .collect(); + ctx.print(status_update::table(rows, Utc::now()).render_for(ctx)) +} + +fn render_json(updates: &[InitiativeUpdateNode]) -> Vec { + let updates: Vec<_> = updates + .iter() + .map(|node| JsonUpdate { + id: &node.id, + body: &node.body, + health: Some(node.health.as_str()), + url: &node.url, + created_at: &node.created_at, + user: node.user.as_ref().map(JsonUser::from), + }) + .collect(); + json::render(&updates) +} diff --git a/crates/linear-cli/src/commands/initiative_update/mod.rs b/crates/linear-cli/src/commands/initiative_update/mod.rs new file mode 100644 index 000000000..9c78ddbb9 --- /dev/null +++ b/crates/linear-cli/src/commands/initiative_update/mod.rs @@ -0,0 +1,14 @@ +//! `linear initiative-update`. +pub mod create; +pub mod list; + +use crate::cli::initiative_update::InitiativeUpdateCommand; +use crate::ctx::Ctx; +use crate::error::Result; + +pub fn run(ctx: &Ctx, command: &InitiativeUpdateCommand) -> Result<()> { + match command { + InitiativeUpdateCommand::Create(args) => create::run(ctx, args), + InitiativeUpdateCommand::List(args) => list::run(ctx, args), + } +} diff --git a/crates/linear-cli/src/commands/issue/agent_session/list.rs b/crates/linear-cli/src/commands/issue/agent_session/list.rs new file mode 100644 index 000000000..b2988f15c --- /dev/null +++ b/crates/linear-cli/src/commands/issue/agent_session/list.rs @@ -0,0 +1,111 @@ +//! `issue agent-session list`: the sessions started from an issue's comments. +use chrono::{DateTime, Utc}; + +use crate::cli::issue::IssueAgentSessionList; +use crate::client::LinearClient; +use crate::commands::json; +use crate::commands::relative_time::ago; +use crate::commands::table::{Cell, Column, Table}; +use crate::ctx::Ctx; +use crate::error::{Result, ResultExt}; +use crate::graphql::operations::agent_session::{ + AgentSessionStatus, GetIssueAgentSessions, GetIssueAgentSessionsVariables, ListSession, + SessionComment, +}; +use crate::graphql::pagination::{self, Page}; +use crate::platform::style; + +use super::status_name; + +pub fn run(ctx: &Ctx, args: &IssueAgentSessionList) -> Result<()> { + list(ctx, args).context("Failed to list agent sessions") +} + +fn list(ctx: &Ctx, args: &IssueAgentSessionList) -> Result<()> { + let identifier = super::super::require(ctx, args.issue_id.as_deref())?; + let client = ctx.client()?; + let comments = ctx.spin(!args.json, fetch_comments(client, &identifier))?; + let mut sessions = sessions(comments, args.status); + args.limit.apply(&mut sessions); + if args.json { + return ctx.print(json::render(&sessions)); + } + if sessions.is_empty() { + return ctx.print("No agent sessions found for this issue.\n"); + } + ctx.print(table(&sessions, Utc::now()).render_for(ctx)) +} + +/// Every comment on the issue, for the agent sessions they started. +async fn fetch_comments(client: &LinearClient, id: &str) -> Result> { + pagination::collect(None, |after, first| async move { + let data: GetIssueAgentSessions = client + .query(GetIssueAgentSessionsVariables { + issue_id: id.to_owned(), + after, + first, + }) + .await?; + Ok(Page { + nodes: data.issue.comments.nodes, + page_info: data.issue.comments.page_info, + }) + }) + .await +} + +/// The sessions started from the comments, in comment order, with `status` +/// only when given. +fn sessions( + comments: Vec, + status: Option, +) -> Vec { + let status = status.map(|status| match status { + crate::cli::AgentSessionStatus::Pending => AgentSessionStatus::Pending, + crate::cli::AgentSessionStatus::Active => AgentSessionStatus::Active, + crate::cli::AgentSessionStatus::Complete => AgentSessionStatus::Complete, + crate::cli::AgentSessionStatus::AwaitingInput => AgentSessionStatus::AwaitingInput, + crate::cli::AgentSessionStatus::Error => AgentSessionStatus::Error, + crate::cli::AgentSessionStatus::Stale => AgentSessionStatus::Stale, + }); + comments + .into_iter() + .filter_map(|comment| comment.agent_session) + .filter(|session| status.is_none_or(|status| session.status == status)) + .collect() +} + +fn table(sessions: &[ListSession], now: DateTime) -> Table { + let mut table = Table::new([ + Column::fixed("ID"), + Column::fixed("STATUS"), + Column::fixed("AGENT").droppable(2), + Column::fixed("CREATED").droppable(1), + Column::flexible("SUMMARY"), + ]); + for session in sessions { + let status = status_name(session.status); + let status = match session.status { + AgentSessionStatus::Active => Cell::styled(status, style::green), + AgentSessionStatus::Pending | AgentSessionStatus::AwaitingInput => { + Cell::styled(status, style::yellow) + } + AgentSessionStatus::Complete | AgentSessionStatus::Stale => { + Cell::styled(status, style::gray) + } + AgentSessionStatus::Error => Cell::styled(status, style::red), + }; + let summary = match session.summary.as_deref().filter(|value| !value.is_empty()) { + Some(summary) => Cell::from(summary.split_whitespace().collect::>().join(" ")), + None => Cell::styled("-", style::gray), + }; + table.row([ + Cell::from(session.id.inner()), + status, + Cell::from(session.app_user.name.as_str()), + Cell::styled(ago(session.created_at.0, now, &chrono::Local), style::gray), + summary, + ]); + } + table +} diff --git a/crates/linear-cli/src/commands/issue/agent_session/mod.rs b/crates/linear-cli/src/commands/issue/agent_session/mod.rs new file mode 100644 index 000000000..4d4626026 --- /dev/null +++ b/crates/linear-cli/src/commands/issue/agent_session/mod.rs @@ -0,0 +1,26 @@ +//! `issue agent-session`: agent sessions on an issue's comments. +mod list; +mod view; + +use crate::cli::issue::IssueAgentSessionCommand; +use crate::ctx::Ctx; +use crate::error::Result; +use crate::graphql::operations::agent_session::AgentSessionStatus; + +pub fn run(ctx: &Ctx, command: &IssueAgentSessionCommand) -> Result<()> { + match command { + IssueAgentSessionCommand::List(args) => list::run(ctx, args), + IssueAgentSessionCommand::View(args) => view::run(ctx, args), + } +} + +fn status_name(status: AgentSessionStatus) -> &'static str { + match status { + AgentSessionStatus::Pending => "pending", + AgentSessionStatus::Active => "active", + AgentSessionStatus::Complete => "complete", + AgentSessionStatus::AwaitingInput => "awaitingInput", + AgentSessionStatus::Error => "error", + AgentSessionStatus::Stale => "stale", + } +} diff --git a/crates/linear-cli/src/commands/issue/agent_session/view.rs b/crates/linear-cli/src/commands/issue/agent_session/view.rs new file mode 100644 index 000000000..b189e40c0 --- /dev/null +++ b/crates/linear-cli/src/commands/issue/agent_session/view.rs @@ -0,0 +1,198 @@ +//! `issue agent-session view`: one session and its activities. +use chrono::{DateTime, TimeZone, Utc}; + +use crate::cli::issue::IssueAgentSessionView; +use crate::client::LinearClient; +use crate::commands::json; +use crate::commands::relative_time::ago; +use crate::ctx::Ctx; +use crate::error::{Error, Result, ResultExt}; +use crate::graphql::operations::agent_session::{ + AgentActivityContent, AgentActivityType, AgentSession, AgentSessionType, + GetAgentSessionDetails, GetAgentSessionDetailsVariables, +}; +use crate::graphql::pagination::{self, Page}; +use crate::refs::reject_linear_url; + +use super::status_name; + +pub fn run(ctx: &Ctx, args: &IssueAgentSessionView) -> Result<()> { + view(ctx, args).context("Failed to fetch agent session details") +} + +fn view(ctx: &Ctx, args: &IssueAgentSessionView) -> Result<()> { + reject_linear_url(&args.session_id, "an agent session ID")?; + let client = ctx.client()?; + let session = ctx.spin(!args.json, fetch_session(client, &args.session_id))?; + if args.json { + return ctx.print(json::render(&session)); + } + ctx.show_markdown( + &markdown(&session, Utc::now(), &chrono::Local)?, + !args.no_pager, + ) +} + +/// The session with every one of its activities. +async fn fetch_session(client: &LinearClient, id: &str) -> Result { + let session = pagination::collect_within( + None, + |after, first| async move { + let data: GetAgentSessionDetails = client + .query(GetAgentSessionDetailsVariables { + id: id.to_owned(), + first, + after, + }) + .await?; + Ok(data.agent_session) + }, + |session| Page { + nodes: std::mem::take(&mut session.activities.nodes), + page_info: session.activities.page_info.clone(), + }, + |session, page| { + session.activities.nodes = page.nodes; + session.activities.page_info = page.page_info; + }, + ) + .await?; + ensure_supported(&session)?; + Ok(session) +} + +fn ensure_supported(session: &AgentSession) -> Result<(), Error> { + for activity in &session.activities.nodes { + activity.content.ensure_supported()?; + } + Ok(()) +} + +fn activity_type_name(kind: AgentActivityType) -> &'static str { + match kind { + AgentActivityType::Action => "action", + AgentActivityType::Elicitation => "elicitation", + AgentActivityType::Error => "error", + AgentActivityType::Prompt => "prompt", + AgentActivityType::Response => "response", + AgentActivityType::Thought => "thought", + } +} + +fn activity_detail(content: &AgentActivityContent) -> Result<(AgentActivityType, String), Error> { + let body = |kind, body: &str| { + ( + kind, + if body.is_empty() { + String::new() + } else { + format!(" - {}", body.replace('\n', " ")) + }, + ) + }; + Ok(match content { + AgentActivityContent::AgentActivityThoughtContent(value) => { + body(value.activity_type, &value.body) + } + AgentActivityContent::AgentActivityResponseContent(value) => { + body(value.activity_type, &value.body) + } + AgentActivityContent::AgentActivityPromptContent(value) => { + body(value.activity_type, &value.body) + } + AgentActivityContent::AgentActivityErrorContent(value) => { + body(value.activity_type, &value.body) + } + AgentActivityContent::AgentActivityElicitationContent(value) => { + body(value.activity_type, &value.body) + } + AgentActivityContent::AgentActivityActionContent(value) => ( + value.activity_type, + if value.action.is_empty() { + String::new() + } else { + format!(" - {}: {}", value.action, value.parameter) + }, + ), + AgentActivityContent::Unsupported(_) => { + content.ensure_supported()?; + return Err(Error::new("unsupported activity was accepted")); + } + }) +} + +fn markdown( + session: &AgentSession, + now: DateTime, + zone: &Tz, +) -> Result { + ensure_supported(session)?; + let mut lines = vec![ + "# Agent Session".to_owned(), + String::new(), + format!("**ID:** {}", session.id.inner()), + format!("**Status:** {}", status_name(session.status)), + format!( + "**Type:** {}", + match session.session_type { + Some(AgentSessionType::CommentThread) => "commentThread", + None => "null", + } + ), + format!("**Agent:** {}", session.app_user.name), + ]; + if let Some(creator) = &session.creator { + lines.push(format!("**Creator:** {}", creator.name)); + } + if let Some(issue) = &session.issue { + lines.push(format!("**Issue:** {} - {}", issue.identifier, issue.title)); + } + lines.extend([ + String::new(), + format!("**Created:** {}", ago(session.created_at.0, now, zone)), + ]); + for (label, value) in [ + ("Started", &session.started_at), + ("Ended", &session.ended_at), + ("Dismissed", &session.dismissed_at), + ] { + if let Some(value) = value { + lines.push(format!("**{label}:** {}", ago(value.0, now, zone))); + if label == "Dismissed" + && let Some(user) = &session.dismissed_by + { + lines.push(format!("**Dismissed by:** {}", user.name)); + } + } + } + if let Some(link) = session + .external_link + .as_deref() + .filter(|value| !value.is_empty()) + { + lines.extend([String::new(), format!("**External Link:** {link}")]); + } + if let Some(summary) = session.summary.as_deref().filter(|value| !value.is_empty()) { + lines.extend([ + String::new(), + "## Summary".to_owned(), + String::new(), + summary.to_owned(), + ]); + } + if !session.activities.nodes.is_empty() { + lines.extend([String::new(), "## Activities".to_owned(), String::new()]); + for activity in &session.activities.nodes { + let (kind, detail) = activity_detail(&activity.content)?; + lines.push(format!( + "- **{}** ({}){detail}", + activity_type_name(kind), + ago(activity.created_at.0, now, zone) + )); + } + } + Ok(lines.join("\n")) +} + +#[cfg(test)] +mod tests; diff --git a/crates/linear-cli/src/commands/issue/agent_session/view/tests.rs b/crates/linear-cli/src/commands/issue/agent_session/view/tests.rs new file mode 100644 index 000000000..606a73a09 --- /dev/null +++ b/crates/linear-cli/src/commands/issue/agent_session/view/tests.rs @@ -0,0 +1,85 @@ +use chrono::{TimeZone, Utc}; +use serde_json::{Value, json}; + +use super::{ensure_supported, markdown}; +use crate::graphql::envelope::parse_response; +use crate::graphql::operations::agent_session::{AgentActivityContent, GetAgentSessionDetails}; + +const SESSION: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/fixtures/agent_session.json" +)); + +fn session(wire: &Value) -> GetAgentSessionDetails { + parse_response(wire.to_string().as_bytes()).expect("session fixture") +} + +fn wire() -> Value { + serde_json::from_str(SESSION).expect("session fixture JSON") +} + +#[test] +fn markdown_lists_every_activity_kind_in_order() { + let now = Utc.with_ymd_and_hms(2026, 9, 30, 12, 0, 0).unwrap(); + let text = markdown(&session(&wire()).agent_session, now, &Utc).expect("markdown"); + let mut previous = 0; + for kind in [ + "thought", + "action", + "response", + "prompt", + "error", + "elicitation", + ] { + let position = text.find(&format!("- **{kind}**")).expect(kind); + assert!(position > previous, "{kind} out of order"); + previous = position; + } + assert!(text.contains("- **action** (2020-01-02) - run: ")); + assert!(text.contains("- **thought** (2020-01-02) - line one line two")); + assert!(!text.contains("JSON only"), "action results are JSON-only"); + assert!(text.contains("**Dismissed by:** Dismiss User")); + assert!(text.contains("**Creator:** Creator")); +} + +#[test] +fn markdown_omits_empty_details() { + let now = Utc.with_ymd_and_hms(2026, 9, 30, 12, 0, 0).unwrap(); + let mut wire = wire(); + let details = &mut wire["data"]["agentSession"]; + details["type"] = Value::Null; + for field in ["startedAt", "endedAt", "dismissedAt"] { + details[field] = Value::Null; + } + for field in ["summary", "externalLink"] { + details[field] = json!(""); + } + for index in [0, 2, 3, 4, 5] { + details["activities"]["nodes"][index]["content"]["body"] = json!(""); + } + details["activities"]["nodes"][1]["content"]["action"] = json!(""); + let text = markdown(&session(&wire).agent_session, now, &Utc).expect("markdown"); + assert!(text.contains("**Type:** null")); + for word in [ + "**Started:**", + "**Ended:**", + "**Dismissed:", + "**Dismissed by:**", + "## Summary", + "**External Link:**", + " - run:", + " - line one", + ] { + assert!(!text.contains(word), "{word}"); + } +} + +#[test] +fn an_unknown_activity_kind_is_refused() { + let mut value = session(&wire()).agent_session; + value.activities.nodes[5].content = + AgentActivityContent::Unsupported("FutureContent".to_owned()); + let error = ensure_supported(&value).expect_err("unknown activity"); + assert!(error.to_string().contains("FutureContent"), "{error}"); + assert!(markdown(&value, Utc::now(), &Utc).is_err()); +} diff --git a/crates/linear-cli/src/commands/issue/archive.rs b/crates/linear-cli/src/commands/issue/archive.rs new file mode 100644 index 000000000..d4a8dfb22 --- /dev/null +++ b/crates/linear-cli/src/commands/issue/archive.rs @@ -0,0 +1,16 @@ +//! `issue archive`: archive, for one issue or in bulk. +use crate::cli::issue::IssueArchive; +use crate::commands::bulk::BulkInput; +use crate::ctx::Ctx; +use crate::error::{Result, ResultExt}; + +use super::archive_or_delete::{Mode, Request}; + +pub fn run(ctx: &Ctx, args: &IssueArchive) -> Result<()> { + let request = Request { + issue_id: args.issue_id.as_deref(), + yes: args.confirm.yes, + bulk: BulkInput::from(&args.bulk), + }; + super::archive_or_delete::run(ctx, Mode::Archive, &request).context("Failed to archive issue") +} diff --git a/crates/linear-cli/src/commands/issue/archive_or_delete.rs b/crates/linear-cli/src/commands/issue/archive_or_delete.rs new file mode 100644 index 000000000..2c2f87116 --- /dev/null +++ b/crates/linear-cli/src/commands/issue/archive_or_delete.rs @@ -0,0 +1,243 @@ +//! What `issue archive` and `issue delete` share: one issue or a bulk list, +//! confirmed unless `--yes` is given. +use crate::client::{LinearClient, RequestError}; +use crate::graphql::operations::common::IdVariables; +use crate::{ + commands::bulk::{self, BulkInput, BulkResult, Found, Skipped, Verb}, + commands::outcome, + commands::team_key::configured_team_key, + ctx::Ctx, + error::{Error, Result}, + graphql::operations::issue::*, + refs::{self, WorkspaceScope}, +}; + +pub struct Request<'a> { + pub issue_id: Option<&'a str>, + /// `--yes`: no prompt. + pub yes: bool, + pub bulk: BulkInput<'a>, +} + +pub fn run(ctx: &Ctx, mode: Mode, request: &Request<'_>) -> Result<()> { + if !request.yes { + ctx.require_tty("for confirmation", "--yes")?; + } + if request.bulk.requested() { + return run_bulk(ctx, mode, request); + } + let identifier = match (mode, request.issue_id) { + (Mode::Delete, None) => { + return Err( + Error::invalid("Issue ID required").with_hint("Use --bulk for multiple issues.") + ); + } + (Mode::Archive, input) => super::require(ctx, input)?, + (Mode::Delete, Some(input)) => { + super::resolve(ctx, Some(input))?.ok_or_else(|| Error::not_found("Issue", input))? + } + }; + let client = ctx.client()?; + let details = ctx.spin(true, single_details(client, &identifier))?; + if mode == Mode::Archive && details.archived { + return ctx.print(format!( + "Issue \"{}\" is already archived.\n", + details.name() + )); + } + let question = format!("{} issue \"{}\"?", mode.title(), details.name()); + if !request.yes && !ctx.confirm(&question, "--yes")? { + return outcome::canceled(ctx); + } + ctx.spin(true, submit_single(client, &identifier, mode))?; + ctx.print(outcome::done(mode.done(), "issue", &details.name(), None)) +} + +fn run_bulk(ctx: &Ctx, mode: Mode, request: &Request<'_>) -> Result<()> { + let ids = bulk::collect_ids(&request.bulk, &mut std::io::stdin().lock())?; + if ids.is_empty() { + return Err(Error::new(format!( + "No issue identifiers provided for bulk {}", + mode.verb() + ))); + } + let scope = ctx.scope()?; + let team = configured_team_key(ctx.options()); + let targets: Vec<_> = ids + .into_iter() + .map(|id| Target::prepare(id, team.as_deref(), &scope)) + .collect(); + let client = ctx.client()?; + let (found, missing) = bulk::look_up(ctx, targets, |target| look_up_item(client, target)); + let verb = Verb { + present: mode.verb(), + past: mode.past(), + }; + ctx.eprint(bulk::preview(&found, &missing, "issue", verb))?; + if found.is_empty() { + return Err(Error::new("None of the listed issues could be found")); + } + let question = format!("{} {}?", mode.title(), bulk::count(found.len(), "issue")); + if !request.yes && !ctx.confirm(&question, "--yes")? { + return outcome::canceled(ctx); + } + let mut results = bulk::run(ctx, found, |found| apply_item(client, found, mode))?; + results.extend(missing.into_iter().map(BulkResult::from)); + bulk::report(ctx, &results, "issue", verb) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Mode { + Archive, + Delete, +} +impl Mode { + const fn verb(self) -> &'static str { + match self { + Self::Archive => "archive", + Self::Delete => "delete", + } + } + const fn past(self) -> &'static str { + match self { + Self::Archive => "archived", + Self::Delete => "deleted", + } + } + const fn title(self) -> &'static str { + match self { + Self::Archive => "Archive", + Self::Delete => "Delete", + } + } + const fn done(self) -> &'static str { + match self { + Self::Archive => "Archived", + Self::Delete => "Deleted", + } + } +} +enum ReferenceOutcome { + Resolved(String), + Unresolved, + Failed(Error), +} +struct Target { + original: String, + reference: ReferenceOutcome, +} +impl Target { + fn prepare(original: String, team: Option<&str>, scope: &WorkspaceScope<'_>) -> Self { + let reference = match refs::prepare_issue_reference(&original, team, scope) { + Ok(Some(id)) => ReferenceOutcome::Resolved(id), + Ok(None) => ReferenceOutcome::Unresolved, + Err(error) => ReferenceOutcome::Failed(error), + }; + Self { + original, + reference, + } + } +} +struct Details { + identifier: String, + title: String, + archived: bool, +} +impl Details { + fn name(&self) -> String { + format!("{}: {}", self.identifier, self.title) + } +} +/// The issue's identifier, title and archive state; `None` when it does not exist. +async fn summary(client: &LinearClient, id: &str) -> Result, Error> { + let data: GetIssueSummary = match client.query(IdVariables { id: id.to_owned() }).await { + Ok(data) => data, + Err(failure) if failure.is_not_found() => return Ok(None), + Err(failure) => return Err(failure.into()), + }; + Ok(data.issue.map(|issue| Details { + identifier: issue.identifier, + title: issue.title, + archived: issue.archived_at.is_some(), + })) +} +async fn single_details(client: &LinearClient, id: &str) -> Result { + summary(client, id) + .await? + .ok_or_else(|| Error::not_found("Issue", id)) +} +/// Archives or deletes issue `id`; `true` when Linear reports success. +async fn mutate(client: &LinearClient, id: &str, mode: Mode) -> Result { + let variables = IdVariables { id: id.to_owned() }; + Ok(match mode { + Mode::Archive => { + let data: ArchiveIssue = client.mutate(variables).await?; + data.issue_archive.success + } + Mode::Delete => { + let data: DeleteIssue = client.mutate(variables).await?; + data.issue_delete.success + } + }) +} +async fn submit_single(client: &LinearClient, id: &str, mode: Mode) -> Result<(), Error> { + let success = mutate(client, id, mode) + .await + .map_err(|failure| failure.or_not_found("Issue", id))?; + if !success { + return Err(Error::new(format!( + "Linear did not {} the issue", + mode.verb() + ))); + } + Ok(()) +} +/// A listed issue: its identifier, and whether it is already archived. +struct Listed { + id: String, + archived: bool, +} + +async fn look_up_item(client: &LinearClient, target: Target) -> Result, Skipped> { + let skipped = |reason: String| Skipped { + original: target.original.clone(), + reason, + }; + let id = match target.reference { + ReferenceOutcome::Resolved(id) => id, + ReferenceOutcome::Unresolved => return Err(skipped("Issue not found".to_owned())), + // Rows show only the error message, not its suggestion or context. + ReferenceOutcome::Failed(error) => return Err(skipped(error.message().to_owned())), + }; + match summary(client, &id).await { + Ok(Some(details)) => Ok(Found { + name: if details.archived { + format!("{} (already archived)", details.name()) + } else { + details.name() + }, + original: target.original, + item: Listed { + id, + archived: details.archived, + }, + }), + Ok(None) => Err(skipped("Issue not found".to_owned())), + Err(error) => Err(skipped(error.message().to_owned())), + } +} + +/// Archives or deletes one looked-up issue; archiving an archived issue +/// changes nothing and succeeds. +async fn apply_item(client: &LinearClient, found: Found, mode: Mode) -> BulkResult { + if mode == Mode::Archive && found.item.archived { + return found.result(Ok(())); + } + let outcome = match mutate(client, &found.item.id, mode).await { + Ok(true) => Ok(()), + Ok(false) => Err(Error::new(format!("{} operation failed", mode.title()))), + Err(failure) => Err(Error::from(failure)), + }; + found.result(outcome) +} diff --git a/crates/linear-cli/src/commands/issue/attach.rs b/crates/linear-cli/src/commands/issue/attach.rs new file mode 100644 index 000000000..2de23877c --- /dev/null +++ b/crates/linear-cli/src/commands/issue/attach.rs @@ -0,0 +1,121 @@ +//! `issue attach`: upload a file and link it in the issue's sidebar. Also the +//! file uploads `issue comment add --attach` embeds. +use std::path::Path; + +use crate::cli::issue::IssueAttach; +use crate::client::LinearClient; +use crate::commands::outcome; +use crate::commands::upload::{self, UploadedFile}; +use crate::ctx::Ctx; +use crate::error::{Error, Result, ResultExt}; +use crate::graphql::operations::upload::{ + AttachmentCreate, AttachmentCreateInput, AttachmentCreateVariables, CreatedAttachment, + GetIssueId, GetIssueIdVariables, +}; + +pub fn run(ctx: &Ctx, args: &IssueAttach) -> Result<()> { + attach_file(ctx, args).context("Failed to attach file") +} + +fn attach_file(ctx: &Ctx, args: &IssueAttach) -> Result<()> { + let identifier = super::require(ctx, Some(&args.issue_id))?; + upload::validate_file(&args.filepath)?; + let client = ctx.client()?; + let issue_uuid = ctx.spin(true, lookup(client, &identifier))?; + let file = upload_file(ctx, &args.filepath, args.public)?; + let attachment = ctx.spin( + true, + attach( + client, + &issue_uuid, + &file, + args.title.as_deref(), + args.comment.as_deref(), + ), + )?; + ctx.print(attach_output( + &attachment, + &identifier, + &args.filepath, + &file, + )) +} + +/// Uploads one file, printing its result (and any warning) as soon as it is done. +pub(super) fn upload_file(ctx: &Ctx, path: &Path, public: bool) -> Result { + let file = upload::prepare(path, public)?; + let client = ctx.client()?; + let message = format!("Uploading {}...", file.filename); + let uploaded = ctx.spin_with(&message, upload::upload(client, path, file))?; + ctx.print(upload::output(&uploaded))?; + if let Some(warning) = upload::warning(&uploaded) { + ctx.eprint(warning)?; + } + Ok(uploaded) +} +async fn lookup(client: &LinearClient, identifier: &str) -> Result { + let data: GetIssueId = client + .query(GetIssueIdVariables { + id: identifier.to_owned(), + }) + .await + .map_err(|failure| failure.or_not_found("Issue", identifier))?; + data.issue + .map(|x| x.id.into_inner()) + .filter(|x| !x.is_empty()) + .ok_or_else(|| Error::not_found("Issue", identifier)) +} +async fn attach( + client: &LinearClient, + issue_uuid: &str, + file: &UploadedFile, + title: Option<&str>, + comment: Option<&str>, +) -> Result { + let data: AttachmentCreate = client + .mutate(AttachmentCreateVariables { + input: AttachmentCreateInput { + issue_id: issue_uuid.to_owned(), + title: title + .filter(|x| !x.is_empty()) + .unwrap_or(&file.file.filename) + .to_owned(), + url: file.asset_url.clone(), + comment_body: comment.map(str::to_owned), + }, + }) + .await + .map_err(|failure| failure.into_create_error("attachment"))?; + if !data.attachment_create.success { + return Err(Error::new("Linear did not create the attachment")); + } + Ok(data.attachment_create.attachment) +} +fn quote_shell(value: &str) -> String { + if !value.is_empty() + && value + .bytes() + .all(|x| x.is_ascii_alphanumeric() || b"_./:@%+=-".contains(&x)) + { + value.to_owned() + } else { + format!("'{}'", value.replace('\'', "'\\''")) + } +} +fn attach_output( + attachment: &CreatedAttachment, + identifier: &str, + path: &Path, + file: &UploadedFile, +) -> Vec { + let mut output = outcome::done( + "Attached", + "file", + &format!("{} to issue {identifier}", attachment.title), + Some(&attachment.url), + ); + if file.file.content_type.starts_with("image/") { + output.push_str(&format!("Hint: Sidebar link attachments do not render images inline. For inline display, run: linear issue comment add {identifier} --attach {}{}\n",quote_shell(&path.to_string_lossy()),if file.file.public{" --public"}else{""})); + } + output.into_bytes() +} diff --git a/crates/linear-cli/src/commands/issue/comment/add.rs b/crates/linear-cli/src/commands/issue/comment/add.rs new file mode 100644 index 000000000..52a88c76b --- /dev/null +++ b/crates/linear-cli/src/commands/issue/comment/add.rs @@ -0,0 +1,101 @@ +//! `issue comment add`: a comment or reply, optionally with uploaded files. +use crate::cli::issue::IssueCommentAdd; +use crate::commands::comment_add::{self, CommentTarget}; +use crate::commands::issue::attach; +use crate::commands::upload::{self, UploadedFile}; +use crate::ctx::Ctx; +use crate::error::{Error, Result, ResultExt}; + +pub fn run(ctx: &Ctx, args: &IssueCommentAdd) -> Result<()> { + add(ctx, args).context("Failed to add comment") +} + +fn add(ctx: &Ctx, args: &IssueCommentAdd) -> Result<()> { + validate_comment_id(args.id.as_deref())?; + let text = comment_add::resolve_body(args.body.as_deref(), args.body_file.as_ref())?; + comment_add::check_parent(args.reply_to.as_deref())?; + let identifier = crate::commands::issue::require(ctx, args.issue_id.as_deref())?; + if args.public && args.attach.is_empty() { + return Err(Error::new("--public requires at least one --attach") + .with_hint("Add --attach to upload, or remove --public.")); + } + upload::prevalidate(&args.attach, args.public)?; + let editor = text.is_none() && args.attach.is_empty(); + if editor { + comment_add::require_editor(ctx)?; + } + let client = ctx.client()?; + // The issue and the comment replied to must exist before anything is + // typed for them, and a reply must answer a thread on this issue. + if editor || args.reply_to.is_some() { + let (issue_id, parent) = ctx.spin(true, async { + tokio::try_join!( + crate::commands::issue::id::fetch(client, &identifier), + comment_add::fetch_parent(client, args.reply_to.as_deref()), + ) + })?; + if let Some(parent) = parent { + parent.check( + &CommentTarget::Issue { issue_id }, + &format!("issue {identifier}"), + )?; + } + } + let text = if editor { + let question = comment_add::question(&identifier, args.reply_to.as_deref()); + match comment_add::write_in_editor(ctx, "", args.confirm.yes, &question)? { + Some(body) => Some(body), + None => return Ok(()), + } + } else { + text + }; + let files = args + .attach + .iter() + .map(|path| attach::upload_file(ctx, path, args.public)) + .collect::>>()?; + let body = compose_body(text.as_deref(), &files); + let input = comment_add::build_input( + CommentTarget::Issue { + issue_id: identifier.clone(), + }, + body, + args.reply_to.as_deref(), + args.id.as_deref(), + ); + let comment = ctx.spin(true, comment_add::create(client, input))?; + ctx.print(comment_add::output( + "issue", + &identifier, + args.reply_to.as_deref(), + &comment, + )) +} + +fn validate_comment_id(id: Option<&str>) -> Result<(), Error> { + if let Some(id) = id { + let bytes = id.as_bytes(); + if !(crate::refs::is_linear_uuid(id) + && bytes.get(14) == Some(&b'4') + && matches!(bytes.get(19), Some(b'8' | b'9' | b'a' | b'A' | b'b' | b'B'))) + { + return Err(Error::new(format!("Invalid comment ID: {id}")) + .with_hint("--id must be a v4 UUID, like 123e4567-e89b-42d3-a456-426614174000.")); + } + } + Ok(()) +} + +fn compose_body(text: Option<&str>, files: &[UploadedFile]) -> String { + let links = files + .iter() + .map(upload::markdown) + .collect::>() + .join("\n"); + [text.unwrap_or(""), &links] + .into_iter() + .filter(|x| !x.is_empty()) + .collect::>() + .join("\n\n") +} diff --git a/crates/linear-cli/src/commands/issue/comment/delete.rs b/crates/linear-cli/src/commands/issue/comment/delete.rs new file mode 100644 index 000000000..ea7474522 --- /dev/null +++ b/crates/linear-cli/src/commands/issue/comment/delete.rs @@ -0,0 +1,49 @@ +//! `issue comment delete`: delete a comment by its UUID after confirmation. +use crate::cli::issue::IssueCommentDelete; +use crate::commands::table::truncate; +use crate::commands::{confirm, outcome}; +use crate::ctx::Ctx; +use crate::error::{Error, Result, ResultExt}; +use crate::graphql::operations::comment::{ + CommentForDelete, DeleteComment, DeleteCommentVariables, GetCommentForDelete, + GetCommentVariables, +}; +use crate::platform::terminal_text::single_line; +use crate::refs::{reject_comment_url, reject_linear_url}; + +pub fn run(ctx: &Ctx, args: &IssueCommentDelete) -> Result<()> { + delete(ctx, args).context("Failed to delete comment") +} + +fn delete(ctx: &Ctx, args: &IssueCommentDelete) -> Result<()> { + let id = &args.comment_id; + reject_comment_url(id)?; + reject_linear_url(id, "a comment UUID")?; + let client = ctx.client()?; + let data: GetCommentForDelete = ctx + .spin(true, client.query(GetCommentVariables { id: id.clone() })) + .map_err(|failure| failure.or_not_found("Comment", id))?; + let comment = data + .comment + .ok_or_else(|| Error::not_found("Comment", id))?; + if !confirm::proceed(ctx, args.confirm.yes, &question(&comment))? { + return Ok(()); + } + let result: DeleteComment = ctx.spin( + true, + client.mutate(DeleteCommentVariables { id: id.clone() }), + )?; + if !result.comment_delete.success { + return Err(Error::new("Linear did not delete the comment")); + } + ctx.print(outcome::done("Deleted", "comment", id, None)) +} + +/// Names the comment by its issue and how it starts. +fn question(comment: &CommentForDelete) -> String { + let start = truncate(&single_line(comment.body.trim()), 40); + match &comment.issue { + Some(issue) => format!("Delete comment \"{start}\" on {}?", issue.identifier), + None => format!("Delete comment \"{start}\"?"), + } +} diff --git a/crates/linear-cli/src/commands/issue/comment/list.rs b/crates/linear-cli/src/commands/issue/comment/list.rs new file mode 100644 index 000000000..025c325b5 --- /dev/null +++ b/crates/linear-cli/src/commands/issue/comment/list.rs @@ -0,0 +1,38 @@ +//! `issue comment list`: every comment on an issue, as threads or JSON. +use crate::cli::issue::IssueCommentList; +use crate::commands::comments::{self, CommentSource}; +use crate::ctx::Ctx; +use crate::error::{Result, ResultExt}; +use crate::graphql::operations::comment::CommentConnection; +use crate::graphql::operations::comment::{GetIssueComments, GetIssueCommentsVariables}; + +pub fn run(ctx: &Ctx, args: &IssueCommentList) -> Result<()> { + list(ctx, args).context("Failed to list comments") +} + +fn list(ctx: &Ctx, args: &IssueCommentList) -> Result<()> { + let identifier = crate::commands::issue::require(ctx, args.issue_id.as_deref())?; + let client = ctx.client()?; + let nodes = ctx.spin( + !args.json, + comments::fetch::(client, &identifier, &identifier, args.limit), + )?; + comments::print(ctx, &nodes, args.json, "issue", !args.no_pager) +} + +impl CommentSource for GetIssueComments { + const ENTITY: &'static str = "Issue"; + type Variables = GetIssueCommentsVariables; + + fn variables(id: &str, after: Option, first: i32) -> Self::Variables { + GetIssueCommentsVariables { + id: id.to_owned(), + after, + first, + } + } + + fn comments(self) -> Option { + self.issue.map(|issue| issue.comments) + } +} diff --git a/crates/linear-cli/src/commands/issue/comment/mod.rs b/crates/linear-cli/src/commands/issue/comment/mod.rs new file mode 100644 index 000000000..fd88468a2 --- /dev/null +++ b/crates/linear-cli/src/commands/issue/comment/mod.rs @@ -0,0 +1,18 @@ +//! `linear issue comment`: an issue's comments. +mod add; +mod delete; +mod list; +mod update; + +use crate::cli::issue::IssueCommentCommand; +use crate::ctx::Ctx; +use crate::error::Result; + +pub fn run(ctx: &Ctx, command: &IssueCommentCommand) -> Result<()> { + match command { + IssueCommentCommand::Add(args) => add::run(ctx, args), + IssueCommentCommand::Delete(args) => delete::run(ctx, args), + IssueCommentCommand::Update(args) => update::run(ctx, args), + IssueCommentCommand::List(args) => list::run(ctx, args), + } +} diff --git a/crates/linear-cli/src/commands/issue/comment/update.rs b/crates/linear-cli/src/commands/issue/comment/update.rs new file mode 100644 index 000000000..b711b86bb --- /dev/null +++ b/crates/linear-cli/src/commands/issue/comment/update.rs @@ -0,0 +1,89 @@ +//! `issue comment update`: body from a flag, a file or the editor, then one mutation. +use crate::cli::values::TextSource; +use crate::client::LinearClient; +use crate::{ + cli::issue::IssueCommentUpdate, + commands::{comment_add, outcome}, + ctx::Ctx, + error::{Error, Result, ResultExt}, + graphql::operations::comment::*, + refs::{reject_comment_url, reject_linear_url}, +}; + +pub fn run(ctx: &Ctx, args: &IssueCommentUpdate) -> Result<()> { + update(ctx, args).context("Failed to update comment") +} + +fn update(ctx: &Ctx, args: &IssueCommentUpdate) -> Result<()> { + let id = &args.comment_id; + let body = prepare_body(id, args.body.as_deref(), args.body_file.as_ref())?; + if body.is_none() { + comment_add::require_editor(ctx)?; + } + let client = ctx.client()?; + let body = match body { + Some(body) => body, + None => { + let existing = ctx.spin(true, existing_body(client, id))?; + match comment_add::write_in_editor( + ctx, + &existing, + args.confirm.yes, + "Save the edited comment?", + )? { + Some(body) => body, + None => return Ok(()), + } + } + }; + ctx.print(ctx.spin(true, submit(client, id, body))?) +} +/// URL guards and local body/file work happen before the client is built. +pub fn prepare_body( + id: &str, + body: Option<&str>, + file: Option<&TextSource>, +) -> Result, Error> { + reject_comment_url(id)?; + reject_linear_url(id, "a comment UUID")?; + comment_add::resolve_body(body, file) +} +pub async fn existing_body(client: &LinearClient, id: &str) -> Result { + let result: GetComment = client + .query(GetCommentVariables { id: id.to_owned() }) + .await + .map_err(|failure| failure.or_not_found("Comment", id))?; + let comment = result + .comment + .ok_or_else(|| Error::not_found("Comment", id))?; + Ok(comment.body.unwrap_or_default()) +} +pub async fn submit(client: &LinearClient, id: &str, body: String) -> Result, Error> { + let result: UpdateComment = client + .mutate(UpdateCommentVariables { + id: id.to_owned(), + input: CommentUpdateInput { body }, + }) + .await + .map_err(|failure| { + let uncertain = failure.outcome_unknown(); + let mut error = Error::from(failure); + if uncertain { + error.push_message("; the comment may already be updated"); + } + error + })?; + if !result.comment_update.success { + return Err(Error::new("Linear did not update the comment")); + } + // `success: true` with a null comment means the update happened but its + // result is unavailable; report that without retrying. + let comment = result + .comment_update + .comment + .ok_or_else(|| Error::new("Comment update failed - no comment returned"))?; + Ok(outcome::done("Updated", "comment", id, Some(&comment.url)).into_bytes()) +} + +#[cfg(test)] +mod tests; diff --git a/crates/linear-cli/src/commands/issue/comment/update/tests.rs b/crates/linear-cli/src/commands/issue/comment/update/tests.rs new file mode 100644 index 000000000..6b6b99e3b --- /dev/null +++ b/crates/linear-cli/src/commands/issue/comment/update/tests.rs @@ -0,0 +1,65 @@ +use super::existing_body; +use crate::client::{ + client_to, + server::{Reply, Server}, +}; +use serde_json::{Value, json}; + +const COMMENT_ID: &str = "comment-1"; + +fn check_lookup(server: Server) { + let requests = server.finish(); + assert_eq!(requests.len(), 1); + let request = requests.first().expect("one comment lookup"); + let body: Value = serde_json::from_slice(&request.body).expect("GraphQL request"); + assert_eq!(body["operationName"], "GetComment"); + assert_eq!(body["variables"], json!({ "id": COMMENT_ID })); +} + +#[tokio::test(flavor = "current_thread")] +async fn missing_comments_fail_before_the_editor_opens() { + for response in [ + json!({"data": {"comment": null}}), + json!({"data": null, "errors": [{"message": "Entity not found: Comment"}]}), + ] { + let server = Server::start(vec![Reply::json(&response)]); + let failure = existing_body(&client_to(&server), COMMENT_ID) + .await + .expect_err("missing comment"); + assert_eq!(failure.message(), "Comment not found: comment-1"); + check_lookup(server); + } +} + +#[tokio::test(flavor = "current_thread")] +async fn existing_comment_bodies_are_preserved() { + for (comment, expected) in [ + (json!({}), ""), + (json!({"body": null}), ""), + (json!({"body": ""}), ""), + ( + json!({"body": " # café\n\nOriginal *body*\n"}), + " # café\n\nOriginal *body*\n", + ), + ] { + let server = Server::start(vec![Reply::json(&json!({"data": {"comment": comment}}))]); + assert_eq!( + existing_body(&client_to(&server), COMMENT_ID) + .await + .expect("existing comment"), + expected + ); + check_lookup(server); + } +} + +#[tokio::test(flavor = "current_thread")] +async fn unrelated_comment_lookup_errors_are_preserved() { + let response = json!({"data": null, "errors": [{"message": "Internal failure", "extensions": {"userPresentableMessage": "Friendly lookup failure"}}]}); + let server = Server::start(vec![Reply::json(&response)]); + let failure = existing_body(&client_to(&server), COMMENT_ID) + .await + .expect_err("lookup failure"); + assert_eq!(failure.message(), "Friendly lookup failure"); + check_lookup(server); +} diff --git a/crates/linear-cli/src/commands/issue/commits.rs b/crates/linear-cli/src/commands/issue/commits.rs new file mode 100644 index 000000000..c22b48651 --- /dev/null +++ b/crates/linear-cli/src/commands/issue/commits.rs @@ -0,0 +1,96 @@ +//! `issue commits`: the jj commits whose trailers name an issue. +use crate::{ + cli::issue::IssueCommits, + config::Vcs, + ctx::Ctx, + error::{Error, Result, ResultExt}, + platform::process, +}; + +pub fn run(ctx: &Ctx, args: &IssueCommits) -> Result<()> { + show_commits(ctx, args).context("Failed to show commits") +} + +fn show_commits(ctx: &Ctx, args: &IssueCommits) -> Result<()> { + check_vcs(super::vcs(ctx))?; + let identifier = super::require(ctx, args.issue_id.as_deref())?; + let client = ctx.client()?; + ctx.spin(true, super::id::fetch(client, &identifier))?; + ctx.flush()?; + let revset = revset(&identifier); + let jj = || process::command("jj", ctx.cwd(), &ctx.config().child_env); + let probe = process::checked_output(jj().args([ + "log", + "-r", + &revset, + "--no-graph", + "-T", + "commit_id", + ]))?; + if process::text(&probe.stdout).is_empty() { + return Err(Error::not_found("Commits", &identifier)); + } + // jj reports its own failures, so its exit status becomes ours. + process::check_attached(process::status(jj().args([ + "log", + "-r", + &revset, + "-p", + "--git", + "--no-graph", + "-T", + "builtin_log_compact_full_description", + ]))?) +} + +fn check_vcs(vcs: Vcs) -> Result<()> { + match vcs { + Vcs::Jj => Ok(()), + Vcs::Git => Err(Error::new("commits is only supported with jj-vcs") + .with_hint("This command requires jujutsu (jj) version control.")), + } +} + +/// The changes with a `Linear-issue` trailer naming exactly `identifier` +/// (`ENG-1` and not `ENG-10`). +fn revset(identifier: &str) -> String { + let pattern = format!(r"(?mi)^Linear-issue:.*\b{}\b", regex_escape(identifier)); + format!("description(regex:{})", string_literal(&pattern)) +} + +fn regex_escape(text: &str) -> String { + text.chars() + .flat_map(|c| { + let escape = !(c.is_ascii_alphanumeric() || c == '_' || c == '-'); + escape.then_some('\\').into_iter().chain([c]) + }) + .collect() +} + +/// `text` as a double-quoted jj revset string. +fn string_literal(text: &str) -> String { + let mut literal = String::from("\""); + for c in text.chars() { + if matches!(c, '"' | '\\') { + literal.push('\\'); + } + literal.push(c); + } + literal.push('"'); + literal +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_revset_matches_the_whole_identifier() { + assert_eq!( + revset("ENG-1"), + r#"description(regex:"(?mi)^Linear-issue:.*\\bENG-1\\b")"# + ); + assert_eq!(regex_escape("A.B-1*"), r"A\.B-1\*"); + assert_eq!(string_literal(r#"a"b\c"#), r#""a\"b\\c""#); + } +} diff --git a/crates/linear-cli/src/commands/issue/create.rs b/crates/linear-cli/src/commands/issue/create.rs new file mode 100644 index 000000000..fb11c1dab --- /dev/null +++ b/crates/linear-cli/src/commands/issue/create.rs @@ -0,0 +1,501 @@ +use chrono::NaiveDate; + +use super::write::{self as shared, Backend, CreateSettings, Parent, Ui}; +use super::write_network::NetworkBackend; +use crate::{ + cli::{ + issue::IssueCreate, + values::{Priority, UserRef}, + }, + commands::{confirm, outcome, team_key::configured_team_key}, + config::AssignSelf, + ctx::Ctx, + error::{Error, Result, ResultExt}, + graphql::{edit::Edit, scalars::TimelessDate}, + platform::{ + editor, + prompt::{Choice, Prompter, Text}, + spinner::Spinner, + }, + refs, +}; + +pub fn run(ctx: &Ctx, args: &IssueCreate) -> Result<()> { + create(ctx, args).context("Failed to create issue") +} + +fn create(ctx: &Ctx, args: &IssueCreate) -> Result<()> { + // `--interactive` asks for every field; clap keeps it apart from the + // field flags, so it never overrides a given value. + let interactive = ctx.optional_prompts(args.interactive)?; + let mut fields = Fields::from(args); + let description = fields.local()?; + // A title typed at the prompt is confirmed before the issue is created. + let typed = !interactive && fields.needs_title(); + if typed { + if !ctx.interactive() { + return Err(ctx.missing_value( + "Title is required", + "--title (or a --template that sets one)", + )); + } + // Settle the team first, so a missing one does not throw away the + // title once it is typed. + if fields.team.is_none() && configured_team_key(ctx.options()).is_none() { + fields.team = Some(pick_team(ctx)?); + } + fields.title = Some(ctx.prompter()?.text(Text::new("Title:").required())?); + } + let confirm = Confirm { + typed, + yes: args.confirm.yes, + }; + let mut ui = Prompts::new(ctx); + let Some(start) = create_with(ctx, &mut ui, &fields, description, interactive, confirm)? else { + return Ok(()); + }; + // The spinner stops before `--start` runs version control commands. + drop(ui); + if let Some(identifier) = start { + super::start::work_on(ctx, &identifier, None, None)?; + } + Ok(()) +} + +/// Whether to ask before creating: after the `--interactive` wizard, or when +/// the title was `typed` at a prompt, unless `yes` (`--yes`) was given. +#[derive(Clone, Copy)] +struct Confirm { + typed: bool, + yes: bool, +} + +/// Asks which team a new issue goes to when none is given or configured. +fn pick_team(ctx: &Ctx) -> Result { + let teams = ctx.spin(true, refs::team::fetch_all(ctx.client()?))?; + if teams.is_empty() { + return Err(refs::team::none_accessible()); + } + let choices = teams + .into_iter() + .map(|team| Choice::new(format!("{} ({})", team.name, team.key), team.key)) + .collect(); + ctx.prompter()?.select("Team:", choices) +} + +/// Creates the issue; returns the issue to start work on with `--start`, or +/// `None` when the user declined to create it. +fn create_with( + ctx: &Ctx, + ui: &mut Prompts<'_>, + fields: &Fields, + description: Option, + interactive: bool, + confirm: Confirm, +) -> Result>> { + let backend = backend(ctx)?; + let settings = settings(ctx); + let (input, start) = if interactive { + let prompted = ctx.block_on(super::create_prompt::prompt( + &backend, ui, &settings, fields, + ))?; + ui.pause(); + let question = format!( + "Create issue \"{}\" in {}?", + prompted.title, prompted.team_key + ); + if !confirm::proceed(ctx, confirm.yes, &question)? { + return Ok(None); + } + ui.note(&header(&prompted.team_key))?; + (prompted.input, prompted.start) + } else { + let fallback = ctx.interactive(); + let assembled = ctx.block_on(flag_input( + &backend, + ui, + &settings, + fields, + description, + fallback, + ))?; + ui.pause(); + if confirm.typed { + let title = fields.title.as_deref().unwrap_or_default(); + let question = format!("Create issue \"{title}\" in {}?", assembled.team_display); + if !confirm::proceed(ctx, confirm.yes, &question)? { + return Ok(None); + } + } + ui.note(&header(&assembled.team_display))?; + (assembled.input, fields.start) + }; + let issue = ctx.spin(true, backend.create(input))?; + ctx.print(output(&issue))?; + Ok(Some(start.then(|| issue.identifier.clone()))) +} + +/// The API backend issue creation and updates resolve names through. +pub(super) fn backend(ctx: &Ctx) -> Result { + Ok(NetworkBackend { + client: ctx.client()?.clone(), + options: ctx.options().clone(), + workspace: ctx.scope()?.workspace.map(str::to_owned), + }) +} + +fn settings(ctx: &Ctx) -> CreateSettings { + let options = ctx.options(); + CreateSettings { + default_team: configured_team_key(options), + assign_self: options.issue_create_assign_self(), + ask_project: options.issue_create_ask_project(), + } +} + +/// Terminal prompts for issue creation. A spinner runs while issue creation +/// looks things up, pausing for each question and line of output. +struct Prompts<'a> { + ctx: &'a Ctx, + spinner: Option, +} + +impl<'a> Prompts<'a> { + fn new(ctx: &'a Ctx) -> Self { + let mut prompts = Self { ctx, spinner: None }; + prompts.resume(); + prompts + } + + fn pause(&mut self) { + self.spinner = None; + } + + fn resume(&mut self) { + if self.spinner.is_none() { + self.spinner = Some(self.ctx.spinner(true, "")); + } + } + + fn ask(&mut self, question: impl FnOnce(&Prompter<'_>) -> Result) -> Result { + self.pause(); + let answer = question(&self.ctx.prompter()?); + self.resume(); + answer + } +} + +impl Ui for Prompts<'_> { + fn text(&mut self, text: Text<'_>) -> Result { + self.ask(|prompter| prompter.text(text)) + } + + fn parsed( + &mut self, + text: Text<'_>, + parse: &dyn Fn(&str) -> std::result::Result, + ) -> Result> { + self.ask(|prompter| prompter.parsed(text, parse)) + } + + fn choose(&mut self, message: &str, choices: Vec>, default: usize) -> Result { + self.ask(|prompter| prompter.select_from(message, choices, default)) + } + + fn checkbox(&mut self, message: &str, choices: Vec>) -> Result> { + self.ask(|prompter| prompter.multi_select(message, choices)) + } + + fn confirm(&mut self, message: &str, default: bool) -> Result { + self.ask(|prompter| prompter.confirm(message, default)) + } + + fn note(&mut self, text: &str) -> Result<()> { + let spinning = self.spinner.is_some(); + self.pause(); + self.ctx.eprint(text)?; + if spinning { + self.resume(); + } + Ok(()) + } + + fn discover_editor(&mut self) -> Result> { + Ok(editor::configured_name(&self.ctx.config().child_env)) + } + + fn optional_editor(&mut self) -> Result> { + self.pause(); + let edited = self.ctx.edit_text(""); + self.resume(); + Ok(crate::commands::text_input::edited_body(&edited?)) + } +} + +#[derive(Clone, Debug, Default)] +pub struct Fields { + pub title: Option, + pub start: bool, + pub assignee: Option, + pub due_date: Option, + pub parent: Option, + pub priority: Option, + pub estimate: Option, + pub description: Option, + pub description_file: Option, + pub labels: Vec, + pub team: Option, + pub project: Option, + pub state: Option, + pub milestone: Option, + pub cycle: Option, + pub template: Option, + pub use_default_template: bool, +} +pub type Input = crate::graphql::operations::issue::IssueCreateInput; +impl Fields { + pub fn local(&self) -> Result, Error> { + shared::description(self.description.as_deref(), self.description_file.as_ref()) + } + /// Neither `--title` nor a template provides the title. + fn needs_title(&self) -> bool { + self.title.as_deref().is_none_or(str::is_empty) && self.template.is_none() + } +} +// Template resolution intentionally separate from project templates: type ISSUE, +// team availability and all selected fields checked; never parse templateData. +pub trait Templates { + fn issue_template( + &self, + reference: String, + team_id: String, + ) -> impl std::future::Future> + Send; +} +pub async fn parent( + backend: &B, + reference: Option<&str>, +) -> Result<(Option, Option), Error> { + match reference.filter(|reference| !reference.is_empty()) { + None => Ok((None, None)), + Some(reference) => { + let id = backend.parent_id(reference.to_owned()).await?; + let data = backend.parent_metadata(id.clone()).await?; + Ok((Some(id), data)) + } + } +} +pub async fn project( + backend: &B, + ui: &mut U, + value: &str, + interactive: bool, +) -> Result { + if let Some(id) = backend.project(value.to_owned()).await? { + return Ok(id); + } + if interactive { + let options = backend.project_options(value.to_owned()).await?; + if !options.is_empty() + && let Some(id) = select_option(ui, "Project", value, &options)? + { + return Ok(id); + } + } + Err(Error::not_found("Project", value)) +} +pub struct FlagInput { + pub input: Input, + pub team_display: String, +} +pub async fn flag_input( + backend: &B, + ui: &mut U, + settings: &CreateSettings, + fields: &Fields, + description: Option, + interactive_fallback: bool, +) -> Result { + let (team_id, team_reference) = match &fields.team { + Some(value) => { + let team = backend.team(value.clone()).await?; + (team.id, team.key) + } + None => { + let reference = settings + .default_team + .clone() + .filter(|v| !v.is_empty()) + .ok_or_else(crate::commands::team_key::no_team)?; + match backend.find_team(reference.clone()).await? { + Some(team) => (team.id, reference), + None => { + let id = if interactive_fallback { + let options = backend.team_options(reference.clone()).await?; + if options.is_empty() { + None + } else { + select_option(ui, "Team", &reference, &options)? + } + } else { + None + }; + ( + id.ok_or_else(|| Error::not_found("Team", &reference))?, + reference, + ) + } + } + } + }; + let template = match &fields.template { + Some(value) => Some( + backend + .issue_template(value.clone(), team_id.clone()) + .await?, + ), + None => None, + }; + let assignee = if fields.start && fields.assignee.is_none() { + Some(UserRef::Me) + } else { + fields.assignee.clone() + }; + if fields.start && assignee != Some(UserRef::Me) { + return Err(Error::new( + "Cannot use --start with an --assignee other than @me", + )); + } + // State BEFORE always-self Viewer, even when explicit assignee later overrides. + let state = match &fields.state { + Some(value) => Some(backend.state(team_reference.clone(), value.clone()).await?), + None => None, + }; + let mut assignee_id = if settings.assign_self == AssignSelf::Always { + Some(backend.viewer().await?) + } else { + None + }; + if let Some(user) = assignee { + assignee_id = Some(backend.user(user).await?) + } + let mut label_ids = Vec::new(); + for value in &fields.labels { + let mut id = backend.label(team_reference.clone(), value.clone()).await?; + if id.as_deref().is_none_or(str::is_empty) && interactive_fallback { + let options = backend + .label_options(team_reference.clone(), value.clone()) + .await?; + if !options.is_empty() { + id = select_option(ui, "Issue label", value, &options)? + } + } + label_ids.push( + id.filter(|id| !id.is_empty()) + .ok_or_else(|| Error::not_found("Issue label", value))?, + ); + } + let project = match &fields.project { + Some(value) => Some(project(backend, ui, value, interactive_fallback).await?), + None => None, + }; + let milestone = match &fields.milestone { + Some(value) if crate::refs::is_linear_uuid(value) => Some(value.clone()), + Some(value) => { + let project=project.clone().ok_or_else(||Error::new("--milestone requires --project to be set") + .with_hint("Use --project to specify which project the milestone belongs to, or pass a milestone UUID directly."))?; + Some(backend.milestone(project, value.clone()).await?) + } + None => None, + }; + let cycle = match &fields.cycle { + Some(value) => Some(backend.cycle(team_id.clone(), value.clone()).await?), + None => None, + }; + let (parent_id, parent_data) = parent(backend, fields.parent.as_deref()).await?; + let project = match project.filter(|v| !v.is_empty()) { + Some(project) => Edit::Set(project), + None => match parent_data { + Some(parent) => Edit::set_or_clear(parent.project_id), + None => Edit::Unchanged, + }, + }; + Ok(FlagInput { + team_display: team_reference, + input: Input { + title: Edit::set_or_unchanged(fields.title.clone()), + assignee_id: Edit::set_or_unchanged(assignee_id), + due_date: Edit::set_or_unchanged(fields.due_date.map(TimelessDate::from)), + parent_id: Edit::set_or_unchanged(parent_id), + priority: Edit::set_or_unchanged(fields.priority.map(Priority::number)), + estimate: Edit::set_or_unchanged(fields.estimate), + label_ids: Some(label_ids), + team_id, + project_id: project, + project_milestone_id: Edit::set_or_unchanged(milestone), + cycle_id: Edit::set_or_unchanged(cycle), + state_id: Edit::set_or_unchanged(state), + template_id: Edit::set_or_unchanged(template), + use_default_template: if fields.template.is_none() { + Edit::Set(fields.use_default_template) + } else { + Edit::Unchanged + }, + description: Edit::set_or_unchanged(description), + }, + }) +} +fn header(team: &str) -> String { + format!("Creating issue in {team}\n") +} +fn output(issue: &shared::Created) -> String { + outcome::done( + "Created", + "issue", + &format!("{}: {}", issue.identifier, issue.title), + Some(&issue.url), + ) +} + +/// Offers the near matches for a reference that did not resolve. `None` when +/// there are none or the user declines them all. +pub fn select_option( + ui: &mut U, + kind: &str, + original: &str, + options: &[shared::Named], +) -> Result, Error> { + let mut seen = std::collections::HashSet::new(); + let candidates: Vec<&shared::Named> = options + .iter() + .filter(|option| seen.insert(option.id.as_str())) + .collect(); + let Some(suggestions) = shared::suggestions(kind, original, &candidates) else { + return Ok(None); + }; + ui.note(&suggestions.note)?; + ui.choose(&suggestions.question, suggestions.choices, 0) +} + +impl From<&crate::cli::issue::IssueCreate> for Fields { + fn from(action: &crate::cli::issue::IssueCreate) -> Self { + Self { + title: action.title.clone(), + start: action.start, + assignee: action.assignee.clone(), + due_date: action.due_date, + parent: action.parent.clone(), + priority: action.priority, + estimate: action.estimate, + description: action.description.clone(), + description_file: action.description_file.clone(), + labels: action.label.clone(), + team: action.team.clone(), + project: action.project.clone(), + state: action.state.clone(), + milestone: action.milestone.clone(), + cycle: action.cycle.clone(), + template: action.template.clone(), + use_default_template: !action.no_use_default_template, + } + } +} diff --git a/crates/linear-cli/src/commands/issue/create_prompt.rs b/crates/linear-cli/src/commands/issue/create_prompt.rs new file mode 100644 index 000000000..4d79e0c81 --- /dev/null +++ b/crates/linear-cli/src/commands/issue/create_prompt.rs @@ -0,0 +1,330 @@ +use super::{ + create::{self as issue_create, Fields, Input}, + write::{self as shared, Backend, CreateSettings, Label, Named, Parent, State, Ui}, +}; +use crate::cli::values::{Priority, estimate}; +use crate::config::AssignSelf; +use crate::graphql::scalars::WholeNumber; +use crate::platform::prompt::{Choice, Text}; +use crate::{error::Error, graphql::edit::Edit}; +#[derive(Clone, Copy)] +enum Field { + WorkflowState, + Assignee, + Priority, + Labels, + Estimate, + Project, +} + +enum Next { + Submit, + MoreFields, +} + +fn project_menu( + ui: &mut U, + team: &crate::refs::team::ResolvedTeam, + projects: &[Named], +) -> Result, Error> { + if projects.is_empty() { + ui.note(&format!( + "Team {} has no projects, so the issue gets none.\n", + team.key + ))?; + return Ok(None); + } + let options: Vec<&Named> = projects.iter().collect(); + let rows = std::iter::once(Choice::new("No project", None)) + .chain(Named::choices(&options)) + .collect(); + ui.choose("Project:", rows, 0) +} +async fn additional( + backend: &B, + ui: &mut U, + team: &crate::refs::team::ResolvedTeam, + states: &[State], + labels: &[Label], + include_project: bool, + auto: bool, +) -> Result { + let default = shared::default_state(states); + let name = default + .as_ref() + .and_then(|id| states.iter().find(|s| &s.id == id)) + .map(|s| s.name.as_str()); + let mut fields = vec![ + Choice::new( + name.map(|name| format!("Workflow state ({name})")) + .unwrap_or_else(|| "Workflow state".to_owned()), + Field::WorkflowState, + ), + Choice::new( + if auto { + "Assignee (self)" + } else { + "Assignee (unassigned)" + }, + Field::Assignee, + ), + Choice::new("Priority", Field::Priority), + Choice::new("Labels", Field::Labels), + Choice::new("Estimate", Field::Estimate), + ]; + if include_project { + fields.push(Choice::new("Project", Field::Project)) + } + let selected = ui.checkbox("More fields:", fields)?; + let mut more = More::default(); + // Choosing more fields starts them over, including the default state. + if auto { + more.assignee = Some(backend.viewer().await?) + } + for field in selected { + match field { + Field::WorkflowState if !states.is_empty() => { + let options: Vec<_> = states + .iter() + .map(|s| Choice::new(format!("{} ({})", s.name, s.kind), s.id.clone())) + .collect(); + let index = default + .as_ref() + .and_then(|id| states.iter().position(|state| &state.id == id)) + .unwrap_or(0); + more.state = Some(ui.choose("Workflow state:", options, index)?); + } + Field::WorkflowState => ui.note(&format!( + "Team {} has no workflow states to choose from.\n", + team.key + ))?, + Field::Assignee => { + let me = ui.choose( + "Assignee:", + vec![Choice::new("Unassigned", false), Choice::new("Me", true)], + usize::from(auto), + )?; + more.assignee = if me { + Some(backend.viewer().await?) + } else { + None + }; + } + Field::Priority => { + let values = [ + (Priority::None, "No priority"), + (Priority::Urgent, "Urgent"), + (Priority::High, "High"), + (Priority::Medium, "Medium"), + (Priority::Low, "Low"), + ]; + let options = values + .into_iter() + .map(|(value, label)| { + let glyph = + super::list_view::priority(WholeNumber(value.number().unsigned_abs())); + Choice::new(format!("{glyph} {label}"), value) + }) + .collect::>(); + let value = ui.choose("Priority:", options, 0)?; + more.priority = (value != Priority::None).then(|| value.number()); + } + Field::Labels if !labels.is_empty() => { + let options = crate::platform::prompt::distinct_choices( + labels + .iter() + .map(|l| { + let owner = + l.team_key.clone().unwrap_or_else(|| "workspace".to_owned()); + (l.name.clone(), Some(owner), l.id.clone()) + }) + .collect(), + ); + more.labels = ui.checkbox("Labels:", options)?; + } + Field::Labels => ui.note(&format!( + "Team {} has no labels to choose from.\n", + team.key + ))?, + Field::Estimate => { + more.estimate = ui.parsed(Text::new("Estimate (optional):"), &estimate)?; + } + Field::Project => { + let projects = backend.projects(team.key.clone()).await?; + more.project = project_menu(ui, team, &projects)?; + } + } + } + Ok(more) +} +#[derive(Default)] +struct More { + assignee: Option, + priority: Option, + estimate: Option, + labels: Vec, + state: Option, + project: Option, +} +pub struct Interactive { + pub input: Input, + pub start: bool, + /// The title and team key, for the final confirmation. + pub title: String, + pub team_key: String, +} +/// Asks for a new issue's fields, looking up the team's states, labels and +/// projects along the way. +pub async fn prompt( + backend: &B, + ui: &mut U, + settings: &CreateSettings, + fields: &Fields, +) -> Result { + let (parent_id, parent_data) = issue_create::parent(backend, fields.parent.as_deref()).await?; + let initial_project = match &fields.project { + Some(value) => Some(issue_create::project(backend, ui, value, true).await?), + None => None, + }; + let auto = async { + match settings.assign_self { + AssignSelf::Always => Ok(true), + AssignSelf::Never => Ok(false), + AssignSelf::Auto => backend.auto_assign().await, + } + }; + let team = async { + match settings.default_team.clone().filter(|key| !key.is_empty()) { + Some(key) => backend.find_team(key).await, + None => Ok(None), + } + }; + let (auto, team) = tokio::try_join!(auto, team)?; + if let Some(parent) = &parent_data { + ui.note(&format!( + "Creating sub-issue for: {}: {}\n\n", + parent.identifier, parent.title + ))? + } + let team = match team { + Some(team) => team, + None => { + let teams = backend.teams().await?; + if teams.is_empty() { + return Err(crate::refs::team::none_accessible()); + } + let options = teams + .into_iter() + .map(|team| Choice::new(format!("{} ({})", team.name, team.key), team)) + .collect(); + ui.choose("Team:", options, 0)? + } + }; + let title = ui.text(Text::new("Title:").required())?; + let ask_project = settings.ask_project + && parent_data.is_none() + && initial_project.as_deref().is_none_or(str::is_empty); + let projects = async { + if ask_project { + backend.projects(team.key.clone()).await.map(Some) + } else { + Ok(None) + } + }; + let (states, labels, projects) = tokio::try_join!( + backend.states(team.key.clone()), + backend.labels(team.key.clone()), + projects, + )?; + let editor = ui.discover_editor()?; + let editor_label = editor.as_deref(); + let message = editor_label + .map(|label| format!("Description (optional, e to open {label}):")) + .unwrap_or_else(|| "Description (optional):".to_owned()); + let raw = ui.text(Text::new(&message))?; + let description = if raw == "e" { + if let Some(editor) = editor_label { + ui.note(&format!("Opening {editor}...\n"))?; + let text = ui.optional_editor()?; + if let Some(text) = text.filter(|text| !text.is_empty()) { + ui.note(&format!( + "Description entered ({} characters)\n", + text.chars().count() + ))?; + Some(text) + } else { + ui.note("No description entered\n")?; + None + } + } else { + ui.note("No editor found. Please set EDITOR environment variable or configure git editor with: git config --global core.editor \n")?; + None + } + } else { + (!raw.is_empty()).then_some(raw) + }; + let mut project = initial_project.clone(); + if let Some(projects) = projects { + project = project_menu(ui, &team, &projects)?; + } + let next = ui.choose( + "What next?", + vec![ + Choice::new("Submit issue", Next::Submit), + Choice::new("Add more fields", Next::MoreFields), + ], + 0, + )?; + let mut more = More { + state: shared::default_state(&states), + ..Default::default() + }; + if auto { + more.assignee = Some(backend.viewer().await?) + } + match next { + Next::Submit => (), + Next::MoreFields => { + more = additional( + backend, + ui, + &team, + &states, + &labels, + !settings.ask_project + && parent_data.is_none() + && initial_project.as_deref().is_none_or(str::is_empty), + auto, + ) + .await?; + project = more.project.clone().or(project); + } + } + let start = ui.confirm("Start working on this issue now?", false)?; + let project = project.or_else(|| parent_data.and_then(|Parent { project_id, .. }| project_id)); + Ok(Interactive { + start, + team_key: team.key, + input: Input { + title: Edit::Set(title.clone()), + assignee_id: Edit::set_or_unchanged(more.assignee), + due_date: Edit::Unchanged, + parent_id: Edit::set_or_unchanged(parent_id), + priority: Edit::set_or_unchanged(more.priority), + estimate: Edit::set_or_unchanged(more.estimate), + label_ids: Some(more.labels), + team_id: team.id, + project_id: Edit::set_or_clear(project), + project_milestone_id: Edit::Unchanged, + cycle_id: Edit::Unchanged, + state_id: Edit::set_or_unchanged(more.state), + template_id: Edit::Unchanged, + use_default_template: Edit::Set(fields.use_default_template), + description: Edit::set_or_unchanged(description), + }, + title, + }) +} + +#[cfg(test)] +mod tests; diff --git a/crates/linear-cli/src/commands/issue/create_prompt/tests.rs b/crates/linear-cli/src/commands/issue/create_prompt/tests.rs new file mode 100644 index 000000000..5f5529c90 --- /dev/null +++ b/crates/linear-cli/src/commands/issue/create_prompt/tests.rs @@ -0,0 +1,704 @@ +use std::collections::VecDeque; +use std::sync::{Arc, Mutex}; + +use serde_json::{Value, json}; + +use super::prompt; +use crate::cli::values::UserRef; +use crate::commands::issue::create::{self, Fields}; +use crate::commands::issue::write::{ + Backend, CreateSettings, Created, Label, Named, Parent, State, Ui, Updated, +}; +use crate::config::AssignSelf; +use crate::error::Error; +use crate::graphql::operations::issue::IssueUpdateInput; +use crate::platform::prompt::{Choice, Text}; +use crate::refs::team::ResolvedTeam; + +/// Answers the lookups an interactive create makes and records which ran. +#[derive(Clone, Default)] +struct Linear { + calls: Arc>>, + parent: Option, + teams: Vec, + labels: Vec