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/.claude/settings.json b/.claude/settings.json index d3b55161d..6623e3b80 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -6,7 +6,7 @@ "hooks": [ { "type": "command", - "command": "jq -r '.tool_input.file_path | select(endswith(\".js\") or endswith(\".ts\") or endswith(\".jsx\") or endswith(\".tsx\"))' | xargs -r deno fmt" + "command": "jq -r '.tool_input.file_path | select(endswith(\".rs\"))' | xargs -r rustfmt --edition 2024" } ] } diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md index 1365d1fe6..a044cf6d4 100644 --- a/.claude/skills/release/SKILL.md +++ b/.claude/skills/release/SKILL.md @@ -20,6 +20,7 @@ Ensure the following tools are available: - `svbump` for version bumping (installed) - `jj` for version control operations - `just` for running the release tasks +- `jq` for updating the Claude Code plugin versions ## Release Workflow @@ -127,70 +128,22 @@ This updates CHANGELOG.md, converting the Unreleased section to a versioned rele ### Step 6: Execute Tag Process -After the changelog is released, execute the complete tag process from the justfile. This includes: - -1. **Run quality checks:** - ```bash - deno check src/main.ts - deno fmt --check - deno lint - deno task test - ``` - -2. **Update version files:** - ```bash - # Get the latest version from changelog - LATEST_VERSION=$(changelog version latest) - - # Write version to deno.json - svbump write "$LATEST_VERSION" version deno.json - - # Read version from deno.json and write to dist-workspace.toml - DENO_VERSION=$(svbump read version deno.json) - svbump write "$DENO_VERSION" package.version dist-workspace.toml - ``` - -3. **Regenerate skill documentation:** - ```bash - # Generate updated skill docs (includes version from deno.json) - deno task generate-skill-docs - - # Update Claude Code plugin versions - FINAL_VERSION=$(svbump read version deno.json) - svbump write "$FINAL_VERSION" version .claude-plugin/plugin.json - svbump write "$FINAL_VERSION" version .claude-plugin/marketplace.json - # marketplace.json also has version inside plugins[0] — svbump can't do array paths, - # so use jq or edit it manually to match - ``` - -4. **Create commit and tag:** - ```bash - # Get the final version - FINAL_VERSION=$(svbump read version deno.json) - - # Create commit - jj commit -m "chore: Release linear-cli version $FINAL_VERSION" - - # Set main bookmark to parent commit - jj bookmark set main -r @- - - # Create tag on the parent commit - jj tag set "v$FINAL_VERSION" -r @- - ``` - -5. **Push to remote:** - ```bash - # Push the bookmark - jj git push --bookmark main - - # Push tags (using git) - git push origin --tags - ``` - -6. **Report completion:** - ``` - Released v$FINAL_VERSION successfully! - ``` +After the changelog is released, run the tag recipe: + +```bash +just tag +``` + +It does the whole release in order, stopping at the first failure: + +1. **Quality checks** (`just check`): `cargo fmt --all --check`, `cargo clippy --locked --workspace --all-targets -- -D warnings`, and `cargo test --locked --workspace` +2. **Version bump:** writes the latest changelog version to `workspace.package.version` in `Cargo.toml` with `svbump`, then refreshes `Cargo.lock` with `cargo update --workspace` +3. **Skill docs** (`just skill-docs`): regenerates `skills/linear-cli/SKILL.md` and its references from the CLI's help +4. **Plugin versions** (`just plugin-version`): sets `version` in `.claude-plugin/plugin.json` and both `version` and `plugins[0].version` in `.claude-plugin/marketplace.json` +5. **Commit and tag:** `jj commit -m "chore: Release linear-cli version "`, then moves the `main` bookmark and sets the `v` tag on that commit (`@-`) +6. **Push:** `jj git push --bookmark main`, then `git push origin --tags` + +When it finishes it prints `released v`. ## Error Handling @@ -204,8 +157,8 @@ Always stop and report errors clearly. Never continue the release process if a c ## Important Notes -- The justfile `tag` recipe handles the complete process from line 5-21 -- Use `jj` for all version control operations (per project CLAUDE.md) +- The justfile `tag` recipe runs every step above; there is no need to run them by hand +- Use `jj` for all version control operations (per the project AGENTS.md) - Always use `--ignore-working-copy` for read-only jj operations - The workflow creates a commit on the parent (@-) and then creates a new working commit - Both `jj git push` and `git push origin --tags` are needed (jj for bookmark, git for tags) @@ -215,9 +168,9 @@ Always stop and report errors clearly. Never continue the release process if a c After successful release: 1. Verify the tag appears on GitHub -2. Check that GitHub Actions release workflow triggers (if configured) +2. Check that the GitHub Actions release workflow (`.github/workflows/release.yml`) builds the binaries and publishes the Homebrew formula and npm package 3. Confirm the new version is published ## Reference -See `justfile` lines 5-21 for the complete tag recipe implementation. +See the `tag` and `plugin-version` recipes in the `justfile` for the implementation. 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/AGENTS.md b/AGENTS.md index 80c487371..13f36ef07 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,45 +1,49 @@ ## basics -- this is a deno app -- after editing any graphql documents, run `deno task codegen` to get the updated types after it's updated, `const result = await client.request(query, { teamId });` should work and be typed (and not require explicit types) -- graphql/schema.graphql has the graphql schema document for linear's api -- for diagnostics, use `deno check` and `deno lint` (do not use tsc or rely on LSP for this) -- when coloring or styling terminal text, use deno's @std/fmt/colors package -- prefer `foo == null` and `foo != null` over `foo === undefined` and `foo !== undefined` -- import: use dynamic import only when necessary, the static form is preferable -- avoid the typescript `any` type - prefer strict typing, if you can't find a good way to fix a type issue (particularly with graphql data or documents) explain the problem instead of working around it -- for `--json` output, preserve GraphQL field names and nesting instead of inventing CLI-specific JSON shapes -- for paginated `--json` output, preserve connection shape and concatenate `nodes` rather than flattening or renaming fields - -## permissions - -- deno permissions (--allow-env, --allow-net, etc.) are configured in multiple files that must stay in sync -- see [docs/deno-permissions.md](docs/deno-permissions.md) for the full list of files to update when adding new permissions -- key files: `deno.json` (tasks), `dist-workspace.toml` (release builds), test files +- this is a rust cli. the toolchain is pinned in `rust-toolchain.toml`. `mise.toml` pins the `dist` release tool; run `mise trust` once in a fresh checkout (until then mise refuses to load this directory, including its cargo shims) +- `crates/linear-cli` is the cli (the `linear` binary plus a library the tests and examples use); `crates/linear-schema` holds the cynic types generated from `graphql/schema.graphql`, linear's graphql schema +- `xtask/` holds repository maintenance commands, run with `cargo xtask ` (an alias in `.cargo/config.toml`). it is not part of the release +- format with `cargo fmt --all`, then before finishing a change run `just check`, which runs: + - `cargo fmt --all --check` + - `cargo clippy --locked --workspace --all-targets -- -D warnings` + - `cargo test --locked --workspace` +- run the cli from source with `just dev ` (`cargo run -- `) +- `just sync-schema` refreshes `graphql/schema.graphql` from linear's api +- after changing commands, flags or help text, run `just skill-docs` to regenerate `skills/linear-cli/SKILL.md` and its references. edit `skills/linear-cli/SKILL.template.md`, not `SKILL.md` +- release builds generate `THIRD_PARTY_LICENSES.md` (gitignored) with `cargo xtask licenses` and ship it in every archive. it reproduces the license files of every crate in `Cargo.lock`; a crate whose package ships no license file needs its upstream text added to `licenses/supplemental/` and recorded in `sources.json` there, or the command (and CI, which runs it) fails +- add an entry under `## [Unreleased]` in `CHANGELOG.md` for user-facing changes +- ask before adding a new dependency + +## layout of `crates/linear-cli/src` + +- `src/cli/`: clap definitions of every command, flag and help text +- `src/commands/`: one module per command group, doing the work for a parsed command +- `src/graphql/operations/`: cynic queries and mutations, one module per entity. the field order of a selection is the order of its `--json` output +- `src/refs/`: resolving user input (issue ids, team keys, names, urls) to linear ids +- `src/config/` and `src/auth/`: `.linear.toml`, `.env`, `LINEAR_*` env vars, credentials and keyring storage +- `src/platform/`: terminal, pager, editor, prompts, browser and other process integrations + +## coding + +- strict inputs: parse flags into types with clap (`ValueEnum`, typed value parsers) and reject invalid values before any request is sent +- exhaustive matching; no `as` casts, `unwrap`, `panic!` or slice indexing in production code (enforced by clippy in `lib.rs`). `expect("why")` is fine for real invariants +- for `--json` output, keep linear's graphql field names. lists are a json array of entities, views are a single object, and nested connections are plain arrays ## error handling -- never fail silently - if something goes wrong or a lookup fails, throw an error with a helpful message -- when user-provided input (flags, args) doesn't match expected values, error immediately with guidance on how to fix it +- never fail silently. if something goes wrong or a lookup fails, return an `Error` (`src/error.rs`) with a helpful message +- when user-provided input (flags, args) doesn't match expected values, error immediately with guidance on how to fix it, using `with_hint` for the suggestion - avoid falling back to defaults when explicit user input is invalid; explicit input should either work or error -- use custom error classes from src/utils/errors.ts: - - `ValidationError(message, { suggestion })` for bad input - - `NotFoundError(entityType, identifier)` for missing entities - - `AuthError(message)` for auth issues - - `CliError(userMessage, { suggestion, cause })` for others -- wrap command actions in try-catch with `handleError(error, "Failed to ")` -- errors display clean messages to stderr with ✗ prefix, stack traces only shown when `LINEAR_DEBUG=1` +- add context with `.context("Failed to ")`. errors print to stderr with a ✗ prefix; causes are only shown with `LINEAR_DEBUG=1` +- stdout carries only a command's result (data, or the `✓` line and URL). prompts, the spinner, warnings and progress lines ("Creating issue in ENG", "Opening … in web browser") go to stderr with `ctx.eprint` ## cli flags -- never use the same short flag alias (e.g. `-w`) on both a global option and a command-level option — cliffy resolves global options first, so the command-level alias will be shadowed -- before adding a short flag, grep the codebase for that letter to ensure it's not already in use at a conflicting scope +- never use the same short flag (e.g. `-w`) on both a global option and a command-level option +- before adding a short flag, grep `src/cli/` for that letter to ensure it's not already in use at a conflicting scope ## tests -- tests on commands should mirror the directory structure of the src, e.g. - - src/commands/issue/issue-view.ts - - test/commands/issue/issue-view.test.ts -- use `deno task test` instead of `deno test`, use `deno task snapshot` to update snapshots -- use the NO_COLOR variable for snapshot tests so they don't include ansi escape codes -- new feature should get tests +- `crates/linear-cli/tests/cli/` (unix only; windows CI runs the unit tests) runs the built `linear` binary against a mock linear api (`support::MockLinear`) with a cleared environment, `NO_COLOR=1` and a sandboxed home directory (`support::Cli`). prefer these tests for command behavior; there is one module per command group +- unit tests live next to the code in `tests.rs` submodules +- new features should get tests diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c786cca1..ab5e33fb8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,108 @@ ## [Unreleased] +### Upgrading from 2.x + +3.0 is a rewrite in Rust. Commands, credentials, and config files carry over, but several 2.x quirks are gone, `--json` output has one consistent shape, and invalid input is rejected before anything is sent to Linear. Script authors should read the JSON output and command line sections. + +#### Installation and runtime + +- `linear` is now a single native executable with no runtime to install, and it starts noticeably faster than the 2.x TypeScript implementation +- the `@schpet/linear-cli` package on JSR is no longer published, so `deno install` stays on 2.x. Install 3.x with Homebrew, the shell installer, npm, a release binary, or `cargo install` (see the README). The Homebrew formula, npm package, and release archive names are unchanged +- to upgrade an existing install, run `brew upgrade linear` for Homebrew or `npm install -g @schpet/linear-cli@latest` for a global npm install. Shell installer installs keep updating with `linear-update`. Existing credentials keep working +- a project that lists `@schpet/linear-cli` as a dependency needs its range bumped, since `^2` does not match 3.x: `npm install -D @schpet/linear-cli@^3` (likewise with pnpm or bun) +- prereleases (alpha and beta) are published only as GitHub releases, not to npm or Homebrew +- `linear --version` and `-V` print `linear 3.0.0`, not a bare version number, and requests identify as `schpet-linear-cli/3.0.0` +- `--help` works even when a config file is broken, and `completions` and `markdown` do not read config or credentials at all +- release archives and the npm package include `THIRD_PARTY_LICENSES.md`, the license notices of the Rust crates `linear` is built from, which is also attached to each GitHub release + +#### Credentials and config + +Unchanged: + +- both credentials file formats are read and written as before, and system keyring entries keep the same service and account names, so existing logins keep working without `auth login` +- `.linear.toml`/`linear.toml` keys and their `LINEAR_*` environment variables are unchanged. `team_id` keeps its name even though it holds a team key, and `team_id`/`LINEAR_TEAM_ID` are now uppercased on read + +Changed: + +- workspace selection is strict. If the workspace you choose with `--workspace`, the `workspace` setting, or `auth default` has no usable API key (for example, its keyring entry is missing), the command fails and names where that choice came from. 2.x quietly fell back to another key. `--workspace` combined with a `LINEAR_API_KEY` from `.env` is an error that names the file +- config is validated at startup. An invalid value such as an unknown `issue_sort` fails with the file and key, and a malformed config file is an error with its line and column instead of being skipped in favor of the next candidate +- `.env` values are literal. Nothing is expanded, quoted or not. A `LINEAR_`/`GH_`/`GITHUB_` value containing `$NAME` or `${NAME}` without single quotes is ignored with a warning, so single-quote any value that needs a literal `$`. If a key appears more than once, the last entry wins, and an unclosed quote invalidates only its own line +- downloaded images and attachments are cached in `$XDG_CACHE_HOME/linear-cli` (falling back to `~/.cache/linear-cli`, or `%LOCALAPPDATA%\linear-cli` on Windows) instead of `$TMPDIR/linear-cli-images`. Attachments land in `///`, and `attachment_dir` gains the same `/` level. If no cache directory can be found, the command errors and suggests `--no-download` +- the OS certificate store is trusted, `SSL_CERT_FILE` adds extra roots (`DENO_CERT` still works but is deprecated), and `HTTPS_PROXY`/`HTTP_PROXY`/`ALL_PROXY`/`NO_PROXY` are honored. Proxied setups failed in 2.x +- `auth login` reads the key from stdin when it is piped and `--key` is not given, and checks that the keyring is available before contacting Linear +- `linear config` writes `.linear.toml` at the repository root (found by walking up to `.git` or `.jj`) or the current directory, prints the absolute path, and takes `--team` and `--sort` to run without prompts + +#### Command line + +- `--yes`/`-y` is the canonical way to skip a confirmation on every command that asks for one. `--force`, `-f`, and `--confirm` are still accepted, except on `document create`, `document update` and `project create`, where `-f` already names a file. `document update --force` still means "override the safeguard" +- every delete, archive, and removal asks first, defaulting to no, and names what it is about to delete after looking it up; this now includes `issue comment delete` and `issue relation delete`, which deleted at once. Values typed at prompts or written in the editor are confirmed the same way before anything is created, posted, or saved; values given entirely as flags are sent without asking +- the new global `--no-input` (alias `--no-interactive`) never prompts and fails when a required value is missing. It does not grant consent: destructive commands still need `--yes` +- prompts appear only when stdin and stdout are both terminals, and only for missing required values. `-i`/`--interactive` also asks for optional fields and errors without a terminal. Prompts no longer read piped answers; the error names the flag to pass instead. A bare `issue create` on a terminal asks only for the title (and first for the team when none is given or configured), while `issue create -i` runs the full wizard and asks before creating the issue. `issue update` with no changes fails. `project-update create`/`initiative-update create` without `--body` open your editor on a terminal and then ask for the health, and `comment add` without `--body` opens your editor once the issue, project, document, or initiative is found +- `issue list` is the canonical name. `issue mine` and `issue l` still work, and `--assignee`, `-A`/`--all-assignees`, and `-U`/`--unassigned` are back on it. An explicit assignee filter overrides the default of your own issues. It also gains `--json`/`-j`, printing the same array of issues as `issue query --json` +- `@me` (or `self`) means you in `--assignee`, `--lead`, `--member`, and `--owner`. A Linear URL passed as a user is a usage error +- enum values are kebab-case (`--health on-track`, `awaiting-input`), and the old camelCase spellings are still accepted. `--priority` takes `none`/`urgent`/`high`/`medium`/`low` or `0`–`4`, `project list --status` takes a status type (`backlog`, `planned`, `started`, `paused`, `completed`, `canceled`; `in-progress` is accepted) rather than a custom status name, and help prints value names in caps (``, ``) +- `--limit` takes a positive number or `all` on every list command. `--limit 0` is rejected; use `--limit all` where 2.x used `0` for "no limit". Most lists default to `all`; `issue list`, `issue query`, and `document list` default to 50, and status update lists default to 10 +- flag values are checked before any request is made, and invalid ones are usage errors: dates must be `YYYY-MM-DD` (`--created-after`/`--updated-after` also accept RFC 3339), colors `#RRGGBB`, estimates whole numbers. A flag-looking value needs `=`, as in `--title=--draft` or `--cycle=-1` +- renamed or moved flags keep their old spellings as aliases: `label list --all` is `--all-teams`, and comment `--parent`/`-p` is shown together with `--reply-to`. `-j` now works for `--json` on `document list`, `document view`, `project-update list`, and `schema`. `issue query` rejects `--search` together with `--milestone` instead of ignoring one of them +- `auth logout` and `auth default` take the workspace either as an argument or with `--workspace` +- running a command group with no subcommand (`linear issue`) prints its help and exits 2 +- shell completions are dynamic: the shell asks `linear` for candidates as you type, and elvish and PowerShell are now supported. **Regenerate your completion script** and load it from your shell's startup file (see `linear completions --help`). Saved 2.x scripts call a `completions complete` subcommand that no longer exists +- short subcommand aliases (`i`, `p`, `issue l`, `issue v`, …) still work but are no longer listed in help or offered as completions, and a mistyped subcommand suggests only real names + +#### JSON output + +Every `--json` output follows one rule. Lists are a JSON array of entities, without a `{ nodes, pageInfo }` wrapper, `pageInfo`, or `totalCount`; `--limit` decides how many. Views are a single object, mutations print the created or changed entity instead of `{ success, … }`, and nested connections inside an entity are plain arrays. Commands whose shape changed: + +- lists that were `{ nodes, pageInfo }` or `{ nodes }`: `team list`, `team members`, `team states`, `user list`, `label list`, `cycle list`, `milestone list`, `project list`, `initiative list`, `document list`, `issue query`, and every `comment list` +- `project-update list` and `initiative-update list` return the array of updates (each `user` is `{ name, displayName }`), not the parent object with the updates nested in it +- `issue agent-session list` returns an array of sessions with no `null` entries, and `issue agent-session view` returns its activities as an array +- nested lists are arrays: labels, children, attachments, documents, and comments in `issue view`; labels and inverse relations in `issue query`; comments in `document view`; issues in `cycle view` and `milestone view`; projects in `initiative view` and `initiative list`; teams in `project list`; every nested list in `project view`, which warns on stderr when one is cut off at 250 +- `project create --json` prints the project object +- numbers and dates are normalized: integer fields are JSON integers, whole floats have no trailing `.0`, and timestamps are RFC 3339 with milliseconds (`…T12:00:00.000Z`). Search results in `issue query --json` include each state's `position` + +#### Output and UX + +- tables size columns by display width, separate them with two spaces, and leave no trailing whitespace. On a terminal, headers are bold and underlined and only flexible columns are truncated (with `…`, and only when the terminal is too narrow). Piped output is never truncated. "N labels found."-style footers are gone. On narrow terminals, low-value columns (such as UPDATED, LABELS or TEAMS) are hidden before the flexible ones shrink past readability +- dates within the last week are relative ("3 days ago"), and older ones print as a local `YYYY-MM-DD` +- workflow states are listed in workflow order (triage, backlog, unstarted, started, completed, canceled, each by position) in `team states`, state pickers, and invalid-state hints. In the `issue create -i` wizard, "Assign this issue to yourself?" defaults to the configured self-assignment, and a field with nothing to choose from (a team without projects, labels, or states) says so instead of being skipped silently +- the `project view` picker lists projects in the same order as `project list` +- on a terminal, error messages and their hints wrap at spaces to the terminal's width, with later lines indented, instead of breaking mid-word +- comment threads read the same in `issue view` and every `comment list`: oldest thread first, `@alice commented 3 days ago []` for a thread and `@bob replied …` for a reply. `issue view` wraps these header lines to the terminal instead of letting a long ID run past the edge +- the spinner runs from the first request: `issue list` and `issue query` show it while they look up the team, project, cycle, and milestone, and `issue create -i` while it loads the team's states, labels, and projects and creates the issue. Prompts hide a running spinner while they are open. `issue create` (with or without `-i`) prints `Creating issue in ENG` right above the result, without a blank line +- project and initiative health reads "On Track", "At Risk", or "Off Track" (colored in tables) in `project list`, `project view`, `initiative list`, and `initiative view`, as it already did for status updates; `--json` keeps Linear's `onTrack`/`atRisk`/`offTrack`, and `project list` shows `-` rather than `Unknown` for a project with no health +- color is used only when the stream it is written to is a terminal and `NO_COLOR` is unset or empty. Prompts, the spinner, warnings, and progress lines (such as `Creating issue in ENG`, `Opening … in web browser`, editor notices, and the list a bulk command is about to change) go to stderr, so stdout carries only a command's result: its data, or the `✓` line and URL of what it changed +- prompts and pickers are worded alike: a value is asked for with a short label ending in a colon (`Team:`, `Title:`, `Project:`), with any hint in parentheses (`Target date (YYYY-MM-DD, optional):`), and yes/no or what-to-do questions end in a question mark. `issue create -i` asks for the team before the title, like a bare `issue create` +- prompts filter select lists as you type, listing exact and prefix matches of a team key such as `SCH` first, then matches at the start of a word, then the rest (a multi-select filter ignores the spaces in labels, since space toggles a choice there). Text prompts and yes/no questions take readline keys (Ctrl-A, Ctrl-E, Ctrl-B, Ctrl-F, Ctrl-U, Ctrl-K, Ctrl-W, Alt-B, Alt-F, Alt-D) and ignore other control keys instead of typing letters, and pasted tabs and line breaks become spaces. A select list's filter knows only the arrow keys, Home, End, Backspace, and Delete; other control keys type their letter there. Esc, Ctrl-C, or Ctrl-G at any prompt cancels the same way: the prompt collapses to its question, `Canceled.` is printed, and the exit status is 130. The pager is `$PAGER` (default `less -FRX`), and the editor is `$VISUAL`, then `$EDITOR`, then git's `core.editor`, both run through the shell. A pager that fails gets a warning on stderr, and the output is printed without it. Ctrl-C inside the pager goes to the pager, as with git, instead of ending `linear`, and Ctrl-C while the spinner is drawn clears it before exiting. Every long view pages the same way and takes `--no-pager`: `issue view`, `project view`, `document view`, `initiative view`, `cycle view`, `milestone view`, `template view`, `issue agent-session view`, and every `comment list` +- terminal Markdown drops the `#` from headings, renders links as clickable OSC 8 hyperlinks, resolves reference links, and shows task lists as `[x]`/`[ ]` +- terminal Markdown wraps long lines at spaces to the terminal width, keeping list and quote indentation on continuation lines. Tables wider than the terminal shrink their columns and wrap cell text, and print one `Header: value` record per row when the terminal is too narrow for a grid +- success messages share one form, `✓ Created issue ENG-123: Title` followed by the URL on its own line. Declining a confirmation prints `Canceled.` on stderr and exits 0 +- errors read `✗ : `, often with a hint line underneath, and `LINEAR_DEBUG=1` adds the underlying causes. HTTP errors include a short excerpt of the response body. An ambiguous team, project, initiative, release, template, or user name lists the candidates. When a create's outcome is unknown, for example after a dropped connection, the error says the entity "may already exist" +- exit codes: `0` success, `1` error, `2` usage error (bad flags or values, a required value that is missing or empty, a confirmation or question that cannot be asked without a terminal, missing subcommand), `130` cancelled. A closed pipe (`linear issue list | head`) exits quietly + +#### Security + +- besides the API itself, the API key is only attached to requests for `https://uploads.linear.app` (HTTPS, default port), and redirects cannot carry it anywhere else. Redirects from HTTPS to HTTP are refused. Markdown images are downloaded only from Linear's upload hosts and the configured API host +- credentials files are written atomically (private temp file, fsync, rename) with mode `0600`, and the mode of an existing file is tightened to `0600` +- the download cache is a private, owner-checked directory (`0700`, files `0600`) that never follows symlinks and never falls back to a shared `/tmp` path +- text from Linear is sanitized before it reaches the terminal. Control characters print as `�`, and newlines inside table cells become spaces, so a crafted title, description, or comment cannot inject terminal escape sequences. This includes `issue title` and `issue describe`; piped output is printed as is +- on macOS, Keychain writes pass the key to `security` on stdin rather than as an argument, so it no longer appears in the process list +- HTTP error excerpts redact the API key. `linear api` has a 30-second deadline and a 64 MiB response cap, and downloads have a 5-minute deadline and a 256 MiB cap + +#### Bug fixes + +- `issue start` takes the team from the issue itself, so a full ID or URL works without a configured team. An argument that is not an issue ID errors instead of opening the picker, and a failed state update exits 1 (the branch or jj change is still prepared) instead of reporting success +- finding the current issue from jj trailers reads one trailer per line. 2.x joined neighboring trailers (`Fixes A-1Fixes B-2`) and could pick the wrong issue. `issue commits` matches whole IDs, so `ENG-1` no longer matches `ENG-10` +- `issue view` shows every label, child, attachment, document, and comment instead of the first page. `milestone view`, `team states`, issue state lookups, `linear config`'s team list, milestone names, agent session activities, `initiative view`'s projects, and the relations `issue relation list` shows and `issue relation delete` searches also read every page +- `team delete --move-issues` reports a partial failure honestly and keeps the team, and bulk deletes skip items whose lookup failed instead of sending the mutation anyway +- piped stdin is read to end of input, where 2.x could drop input that arrived after 100 ms, and piped status update and document bodies are used verbatim instead of being split on commas +- an ambiguous user name errors with the candidates instead of picking one, and `user list` no longer prints names as `Name (Name)` +- an editor that fails or is cancelled during `issue create` or `document create` aborts the command instead of carrying on +- `project create` in a workspace with no accessible teams explains the problem instead of crashing, and `project update` gains `--priority` +- `issue pull-request --title ""` uses the issue's title instead of producing a trailing space, and `label delete` honors `--team` and the configured team +- keyring helper output that happens to contain "401" is no longer reported as an invalid API key +- `schema` works against a server that reports directive locations newer than the GraphQL specification it knows (such as `DIRECTIVE_DEFINITION`): the SDL leaves them out with a warning naming each one, and `--json` prints them as received + ### Changed - `project list` now orders projects the way Linear's own project list showed them: by `sortOrder` ascending, the manual order projects are dragged into, with status playing no part and name and id only breaking ties. The previous order was a hardcoded one that grouped by status type with in-progress work first, ignored the manual order entirely, and fell back to sorting by name. The rule comes from comparing the app's projects list against a workspace with projects in five different statuses; whether that view had a customised grouping or ordering setting is not yet confirmed, and `sortOrder` and `prioritySortOrder` were identical there, so the observation could not tell those two apart. `project list --json` now carries `sortOrder` @@ -9,26 +111,60 @@ ### Added +- `issue start --team ` picks from that team's unstarted issues and reads a bare issue number in it, instead of always using the configured team - every command that takes an issue, project, document, initiative, team, or cycle now also takes the URL you copied out of Linear — `linear issue view https://linear.app/acme/issue/ENG-123/some-title`, `linear project view `, `--project`, `--parent`, `--team` and the rest, since they all resolve through the same lookups. `issue view ` did not work at all before; project and document URLs happened to work through an undocumented server-side behavior in Linear's API, which the CLI no longer relies on. A URL pointing at the wrong kind of thing now says so ("that is an issue URL, not a project URL") instead of reporting the whole URL as a missing name, as does one from another workspace or a page that names nothing (`/settings`). Cycle URLs work in all three forms the app produces — `/team/ENG/cycle/5`, `/cycle/active` and `/cycle/upcoming` (the CLI's `next`) — and carry their team: `cycle view ` uses it, and a `--team` that names a different team is refused rather than used to look up that team's cycle with the same number. Commands whose identifiers have no URL at all — milestones, labels, templates, releases — say that plainly. A comment link carries only the first eight characters of the comment's ID, so it names its issue but cannot be used as a comment ID. `issue link ` is unchanged: a lone URL there is still the thing being linked -- `project view` now shows what Linear's project page shows: the long-form overview body (`content`), milestones with their status and progress, resources (`externalLinks`), documents, attachments, related projects with their dependency direction, labels, members, initiatives, and Linear's own progress percentage. Only `description` — the 255-character summary — was rendered before, so a project whose body was written with `project create --content-file` displayed nothing of it. A project reference can now be a UUID, slug ID, or exact name everywhere, including with `--web`/`--app`, and long output pages like `issue view` does (`--no-pager` to disable). `--json` keeps the GraphQL field names and the `{ nodes, pageInfo }` shape of every connection +- `project view` now shows what Linear's project page shows: the long-form overview body (`content`), milestones with their status and progress, resources (`externalLinks`), documents, attachments, related projects with their dependency direction, labels, members, initiatives, and Linear's own progress percentage. Only `description` — the 255-character summary — was rendered before, so a project whose body was written with `project create --content-file` displayed nothing of it. A project reference can now be a UUID, slug ID, or exact name everywhere, including with `--web`/`--app`, and long output pages like `issue view` does (`--no-pager` to disable). `--json` keeps the GraphQL field names, with every nested connection as a plain array - `project view` with no argument opens a searchable list of projects to pick from, scoped like `project list` — the configured team, or the whole workspace when no team is set. It only prompts when stdin and stdout are both terminals; piped, redirected, in CI, or with `--json` it says a project is required instead of hanging on a prompt nobody can answer - `issue archive ` archives an issue through Linear's `issueArchive` mutation, distinct from `issue delete`, which trashes it. It resolves identifiers like the other issue commands, prompts with the identifier and title unless `--confirm`/`-y` is passed, reports an already-archived issue instead of silently succeeding, and takes `--bulk`, `--bulk-file`, and `--bulk-stdin` like `issue delete` ([#285](https://github.com/schpet/linear-cli/pull/285); thanks @martin-piliar for the command and the report in [#284](https://github.com/schpet/linear-cli/issues/284)) -- `document comment list|add`, `project comment list|add`, and `initiative comment list|add`, mirroring `issue comment`. Documents take a UUID or slug, projects and initiatives a UUID, slug, or name; `add` takes `--body` or `--body-file`. Every comment `add`, including `issue comment add`, now takes `--reply-to ` to answer in a thread (`-p`/`--parent` remain aliases). Comment lists now fetch every page instead of stopping at 50, and their `--json` nodes, plus the comments in `issue view --json`, carry `quotedText` (the passage an inline comment is anchored to) alongside `parent.id` ([#230](https://github.com/schpet/linear-cli/issues/230)) +- `document comment list|add`, `project comment list|add`, and `initiative comment list|add`, mirroring `issue comment`. Documents take a UUID or slug, projects and initiatives a UUID, slug, or name; `add` takes `--body` or `--body-file`. Every comment `add`, including `issue comment add`, now takes `--reply-to ` to answer in a thread (`-p`/`--parent` remain aliases). Comment lists now fetch every page instead of stopping at 50, and their `--json` entries, plus the comments in `issue view --json`, carry `quotedText` (the passage an inline comment is anchored to) alongside `parent.id` ([#230](https://github.com/schpet/linear-cli/issues/230)) - every command that takes a team now accepts its key, name, or UUID, resolved through one shared lookup: `team states`, `team members`, `team delete`, `label list/create/delete --team`, `cycle list/view --team`, `project list/create/update --team`, `document list/create/update --team`, and `issue query/mine/create/update --team`. Keys stay canonical and win over a same-spelled name; an unknown team errors with the list of valid keys instead of an empty result or a raw API error. Previously only keys worked, which is why [#276](https://github.com/schpet/linear-cli/issues/276) asked for `team list --json` as a name-to-key lookup - `issue query --state` and `issue mine --state` accept a workflow state name or ID as well as the six state types, looked up within the queried team scope (all teams under `--all-teams`, where a name matches every team's same-named state). An unknown name errors and lists the scope's states, and types and names can be mixed - `project update --content ` and `--content-file ` replace a project's long-form overview body, matching the flags `project create` already had. Previously the only way to change the body after creation was a hand-written `projectUpdate` mutation through `linear api` -- `issue update --clear-due-date`, `--clear-estimate`, `--clear-parent`, `--clear-project`, and `--clear-milestone`, plus `project update --clear-lead`, `--clear-start-date`, and `--clear-target-date`, to remove a value the way `--unassign` and `--clear-cycle` already do. Each sends an explicit `null` to Linear and errors when combined with its set flag; `--clear-project` also rejects `--milestone`, since a milestone belongs to the project being removed. Previously these fields could only be changed, never cleared: cliffy treats `--due-date ""` as a missing value +- `issue update --clear-due-date`, `--clear-estimate`, `--clear-parent`, `--clear-project`, and `--clear-milestone`, plus `project update --clear-lead`, `--clear-start-date`, and `--clear-target-date`, to remove a value the way `--unassign` and `--clear-cycle` already do. Each sends an explicit `null` to Linear and errors when combined with its set flag; `--clear-project` also rejects `--milestone`, since a milestone belongs to the project being removed. Previously these fields could only be changed, never cleared: `--due-date ""` was treated as a missing value - `project update` now changes teams, labels, and initiatives with the same three operations `issue update` has for labels: `--team`, `--label`, and `--initiative` replace the whole set, `--add-team`/`--add-label`/`--add-initiative` append, and `--remove-team`/`--remove-label`/`--remove-initiative` detach, all repeatable, with a replace flag rejected alongside its add/remove flags. Linear's project input only accepts a full `teamIds`/`labelIds` list, so add and remove read the project's current set (every page of it) and send the computed set; initiatives have no input field at all and go through the initiative-to-project link mutations, so `project update` previously could not change them after `project create --initiative`. Removing a team, label, or initiative the project does not have errors and lists what it does have, and a link change that fails part-way reports what was applied and what is still pending -- `--json` (`-j`) on `team list`, `cycle list`, `cycle view`, `milestone list`, `milestone view`, and `project view`, the last read commands without machine-readable output. List commands emit the same `{ nodes, pageInfo }` connection shape as the other list commands, after the same filtering and ordering as the table; view commands emit the GraphQL object as fetched, including every issue rather than the ten-item preview, and `milestone view --all --json` includes every page. (A 2.0.0 entry claimed `cycle list --json`; that change never actually landed.) ([#276](https://github.com/schpet/linear-cli/issues/276); thanks @lakardion) +- `--json` (`-j`) on `team list`, `cycle list`, `cycle view`, `milestone list`, `milestone view`, and `project view`, the last read commands without machine-readable output. List commands emit a JSON array of entities like the other list commands, after the same filtering and ordering as the table; view commands emit the GraphQL object as fetched, including every issue rather than the ten-item preview, and `milestone view --all --json` includes every page. (A 2.0.0 entry claimed `cycle list --json`; that change never actually landed.) ([#276](https://github.com/schpet/linear-cli/issues/276); thanks @lakardion) - `template list` and `template view`, and `--template ` on `issue create` and `project create`, mirroring what Linear's MCP server exposes. `template list` shows every issue, project, and document template in the workspace, filtered with `--type` and `--team` (a team's own templates plus workspace-level ones); `template view` prints a template's metadata and every value it pre-fills, with the rich-text body rendered as markdown, and `--json` on both returns the raw GraphQL objects with `templateData` still JSON-encoded. `--template` sends the template's ID so Linear applies it server-side: explicit flags override the template's values, `--label` merges with its labels, `--description` replaces its body (omit it to keep the body), and `issue create --template` makes `--title` optional. Linear rejects `useDefaultTemplate` next to `templateId`, so `--template` takes the place of the team's default template and `--no-use-default-template` is implied. Names resolve exactly and case-insensitively within the target type and team; a name shared by several eligible templates errors with their IDs. Document templates can be listed and viewed only: `DocumentCreateInput` has no `templateId` +- `--body-file -`, `--description-file -`, and `--content-file -` read the text from stdin, on every command that takes those flags ### Fixed +- `issue start` help now explains jj mode (it starts a new jj change with the issue's title and `Linear-issue` trailers instead of creating a git branch), and `--branch`/`--from-ref` are refused with a usage error when jj is the configured VCS instead of being silently ignored +- `linear schema` works again. A standard introspection query costs far more than the 10,000 complexity points Linear allows per request, so the schema is now fetched as the type names first and then the type definitions in batches +- `initiative list` no longer fails with "The query is too complex": it fetches 25 initiatives per request with at most 50 nested projects each, then fetches the remaining projects of any initiative that has more +- `cycle list` shows the active cycle first, then upcoming cycles from the soonest, then past cycles from the most recent, instead of newest first, where far-future cycles pushed the active one down +- `--cycle -1` and `cycle view -1` work without an `=`; negative offsets were rejected as unknown arguments +- piped `linear api` output ends with a newline +- an empty `--title`, `--team`, `--state`, `--project`, `--label`, or similar flag on `issue create`/`issue update` is a usage error instead of being sent (and rejected by Linear) or silently ignored +- a name, title, reference, or body flag whose value is only whitespace (such as `--title ' '`) is a usage error on every command, the same as an empty one, instead of being sent to Linear +- commands that read the issue from the current branch, and `issue start`, say "Not in a git repository" (or jj) outside one instead of printing git's or jj's raw error +- an unknown team names at most ten valid keys and how many more there are, instead of listing every team on one line, and an ambiguous project name says to pass one of the UUIDs it lists - a project name that matches more than one project is now rejected with both projects' IDs instead of silently resolving to whichever Linear returned first. Linear does not require project names to be unique, so this affected every command that accepts a project by name — `project view/update/delete`, `project comment`, `project-update`, `milestone`, and `issue create/update/query/mine --project` - `project view` no longer prints the status line above the project title on a terminal (it was written straight to stdout before the rest of the document was assembled), no longer glues the icon to the name — `Project.icon` holds a Linear icon name such as `Rocket`, never an emoji, so `# Rocket Mobile launch` was being rendered — and no longer undercounts issues, which previously came from a single unpaginated page and so stopped at 50 - `linear api` help now labels its positional `[graphqlDocument]` instead of `[query]`, which read like a subcommand and invited `linear api query '...'` (rejected with "Too many arguments"). The description states that the document is the only argument and that `api` has no subcommands, and an `Examples:` section covers inline, stdin, file, variable, and `--paginate` forms. No parsing change ([#286](https://github.com/schpet/linear-cli/issues/286)) - an unknown document, project, initiative, or issue passed to `document view` or any `comment` command is reported as ` not found: ` instead of Linear's raw "Could not find referenced …" wording, and `document view` no longer exits with a stack trace for an unknown slug (its not-found branch re-threw instead of reporting, and was unreachable until the not-found detection was fixed) - `cycle list` and `milestone list` now paginate instead of taking Linear's default page, so a team with more than 50 cycles or a project with more than 50 milestones is no longer silently truncated +- `project-update create` and `initiative-update create` without a body (nothing or only whitespace piped on stdin, or `--no-input`) is a usage error instead of posting an empty update +- `issue start` without an issue ID off a terminal or with `--no-input` is a usage error (exit 2) like other missing values, reported before the team is looked up +- a missing issue is reported as `Issue not found: ENG-9999` instead of Linear's raw "Could not find referenced Issue." by `issue view`, `issue update`, `issue title`, `issue url`, `issue describe`, `issue start`, and `issue pull-request`, and `issue update` no longer prints an `Updating issue …` line (the `✓` line names the issue) +- `issue list --label` and `issue query --label` with a label that does not exist in the queried teams or the workspace fail with `Issue label not found` instead of silently finding no issues, like an unknown `--state` or `--assignee` +- the label picker of `issue create -i` offers the same labels as `label list --team`: the team's and the workspace's, every page of them, where it showed only the team's first 50 +- `issue archive|delete --bulk`, `document delete --bulk`, and `initiative archive|delete --bulk` look up every listed item before asking, list what they are about to change and which items could not be found (those are skipped and fail the command), and ask about the found ones only; with nothing found they stop without asking +- the confirmation after writing a status update or comment in the editor names the project, initiative, or document (`Post this update to project "Mobile"?`) instead of echoing the ID or slug you typed, and says `Post this reply on …` for a reply +- `--reply-to`/`--parent` on the `comment add` commands must be a comment UUID (anything else is a usage error before any request), and when the comment is written in the editor the comment replied to is looked up first, so nothing is typed for a reply that cannot be posted +- no-op updates follow one rule: an update command given no fields to change fails with `No changes given` (exit 2), and an editor or `-i` session that changes nothing prints `No changes made.` and succeeds without asking. `issue comment update` no longer asks to save text left unchanged in the editor +- `milestone create` asks for the milestone name on a terminal when `--name` is not given, as the README described, confirms typed values (skip with `--yes`), and gains `-i` to also ask for the description and target date. Without a terminal a missing `--name` is still a usage error +- a user typed at the lead prompt of `project create -i` or the owner prompt of `initiative create -i`, and the attachment typed for `document create`, are looked up right away; one that is not found is reported and asked for again, instead of failing after the final confirmation. `document create` also looks up an attachment given as a flag before it opens the editor, so typed content is not lost to a wrong `--project` +- `comment list` commands on a terminal render each comment as Markdown and wrap headers and bodies to the terminal like the comments in `issue view`, instead of printing raw Markdown that broke mid-word +- `--reply-to` on the `comment add` commands is checked before posting: a comment on a different issue, project, document, or initiative is refused, as is a reply (naming its thread's top-level comment to use instead), and the success line says `Added reply to …` +- an unknown `--state` gets the same error from every command (`Workflow state not found: 'x' in team ENG`), listing the states in workflow order and the state types that also work, where `issue list` and `issue update` worded it differently and ordered it differently +- a missing value exits 2 everywhere: an issue command with no issue ID that cannot infer one (outside a repository, or on a branch that names none), `project view` with no project off a terminal, and a delete, archive, or other confirmation without `--yes` off a terminal or with `--no-input` exited 1 +- text read from a file or stdin (`--body-file`, `--description-file`, `--content-file`, `-`, or piped stdin) follows one rule on every command: a byte-order mark and trailing whitespace such as the final newline are dropped, and a file left empty is an error for a required body (`Body file is empty: stdin`) and the same as leaving the flag out for an optional field, where `issue create --description-file -` with nothing piped sent an empty description and `--body-file -` kept the final newline that piped stdin dropped +- pickers tell apart choices that share a name: projects in `issue create -i` and in the "did you mean" suggestions for an unknown `--project` show their slug ID, and labels show their team (or `workspace`). +- a Markdown table on a terminal too narrow for its columns prints one `Header: value` record per row as soon as a grid would split a word, instead of drawing a grid of broken words. Select prompts keep their question short, since a list prompt cannot wrap at words: the "did you mean" project suggestions, the existing-branch choice of `issue start`, and the same-named label choice of `label delete` print their context on a line of their own first +- `milestone create` without `--project` picks the project from a list on a terminal, like `project view`, and off a terminal is a usage error naming `--project` instead of a clap error +- prompts read alike: every delete, archive, and unarchive asks `Delete project "Mobile"?` style questions instead of a mix with "Are you sure you want to…", the `issue create -i` wizard asks `What next?` and asks `Start working on this issue now? (y/N)` as a yes/no question, prompt validation errors start with a capital letter, and a lead or owner that is not found is shown under the question asked again (starting from the refused answer) and cleared once it is edited, like an invalid date. `project list` words its DATE column alike (`Start 2026-09-01`, `Target 2026-12-31`, `Completed 3 days ago`) +- when `gh` (run by `issue pull-request` and `team autolinks`) or the `jj log` of `issue commits` fails, the command fails with status 1 after the tool's own error, instead of passing on its status code (such as `gh`'s 4 for missing authentication); a cancelled or interrupted `gh` exits 130. A mistyped flag's "similar argument" tip names the flag (`--yes`) rather than a hidden alias such as `--confirm` +- the summary of a `--bulk` run is the first thing on stdout; the blank line that sets it off from the listing and progress is written to stderr ## [2.6.0] - 2026-09-02 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8995e136f..17dff5fe1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -12,16 +12,16 @@ Issues outside this scope may be closed. ## Development -linear-cli is written in TypeScript and runs on [Deno](https://deno.com). +linear-cli is written in Rust. The toolchain version is pinned in `rust-toolchain.toml`, which rustup picks up automatically. ```sh -deno task test # run the test suite -deno task validate # type check, format, lint -deno task snapshot # update snapshot tests +cargo run -- issue list # run the CLI from source +cargo test --workspace # run the test suite +just check # format check, clippy, and tests, as CI runs them ``` -After editing a GraphQL document, run `deno task codegen` to regenerate types. +After changing commands, flags, or help text, run `just skill-docs` to regenerate the agent skill documentation. ## Pull requests -Keep changes focused, and add tests for new behavior. Tests mirror the source layout: `src/commands/issue/issue-view.ts` is tested by `test/commands/issue/issue-view.test.ts`. +Keep changes focused, and add tests for new behavior. Command behavior is tested in `crates/linear-cli/tests/cli/`, which runs the `linear` binary against a mock Linear API, with one module per command group. 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/README.md b/README.md index 8f9e7e3cf..eefee8c78 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ here's how it works: ```bash linear config # setup your repo, it writes a config file -linear issue mine # list unstarted issues assigned to you +linear issue list # list unstarted issues assigned to you linear issue query --all-teams # query issues across all teams linear issue query --search "login bug" # search issues in your configured team linear issue start # choose an issue to start, creates a branch @@ -24,9 +24,9 @@ it aims to be a complement to the web and desktop apps that lets you stay on the ## screencast demos
-linear issue create +linear issue create -i -screencast showing the linear issue create command, interactively adding issue details +screencast showing the linear issue create -i command, interactively adding issue details
@@ -45,12 +45,6 @@ it aims to be a complement to the web and desktop apps that lets you stay on the brew install schpet/tap/linear ``` -### deno via jsr - -```bash -deno install -A --reload -f -g -n linear jsr:@schpet/linear-cli -``` - ### npm / bun / pnpm install as a dev dependency to pin a version in your project: @@ -74,18 +68,28 @@ bunx linear issue list package on npm: [@schpet/linear-cli](https://www.npmjs.com/package/@schpet/linear-cli) +### shell installer + +for macOS and Linux: + +```bash +curl --proto '=https' --tlsv1.2 -LsSf https://github.com/schpet/linear-cli/releases/latest/download/linear-installer.sh | sh +``` + ### binaries https://github.com/schpet/linear-cli/releases/latest -### local dev +### from source + +with a [rust toolchain](https://rustup.rs) installed: ```bash -git clone https://github.com/schpet/linear-cli -cd linear-cli -deno task install +cargo install --locked --git https://github.com/schpet/linear-cli linear ``` +upgrading from 2.x? see [upgrading from 2.x](CHANGELOG.md#upgrading-from-2x) in the changelog. + ## setup 1. create an API key at [linear.app/settings/account/security](https://linear.app/settings/account/security)[^1] @@ -138,17 +142,18 @@ linear issue list -a # open issue list in Linear.app linear issue query --search "login bug" # search issues by text in your configured team linear issue query --search "oauth timeout" --team ENG --json # structured search output for agents linear issue query --team "Engineering" --state "In Review" --json # teams by key, name, or ID; states by type, name, or ID -linear issue query --all-teams --json --limit 0 # export all issues as JSON +linear issue query --all-teams --json --limit all # export all issues as JSON linear issue start # create/switch to issue branch and mark as started -linear issue create # create a new issue (interactive prompts) +linear issue create # create a new issue, asking for the title +linear issue create -i # create a new issue, asking for every field linear issue create -t "title" -d "description" # create with flags linear issue create --project "My Project" --milestone "Phase 1" # create with milestone linear issue create --template "Bug report" -t "Login fails" # create from a template (see template commands) -linear issue update # update an issue (interactive prompts) +linear issue update ENG-123 -s started # change only the fields you pass linear issue update ENG-123 --milestone "Phase 2" # set milestone on existing issue linear issue update ENG-123 --clear-due-date --clear-parent # remove values (also --clear-estimate, --clear-project, --clear-milestone, --clear-cycle, --unassign) -linear issue archive ENG-123 --confirm # archive an issue (Linear normally auto-archives closed issues; see docs/usage.md) -linear issue archive --confirm --bulk ENG-123 ENG-124 # archive several issues +linear issue archive ENG-123 --yes # archive an issue (Linear normally auto-archives closed issues; see docs/usage.md) +linear issue archive --yes --bulk ENG-123 ENG-124 # archive several issues linear issue delete # delete an issue linear issue comment list # list comments on current issue linear issue comment add # add a comment to current issue @@ -182,7 +187,7 @@ linear issue comment add ENG-123 -a ./screenshot.png --public # public image U ```bash linear team list # list teams linear team list --json # as JSON, e.g. to map a team name to its key or id in scripts -linear team id # print out the team id (e.g. for scripts) +linear team id # print the configured team key (e.g. for scripts) linear team members # list team members linear team members --all --json # include inactive members, as JSON linear team create # create a new team @@ -245,13 +250,14 @@ linear m list --project # list milestones (alias) linear milestone list --project --json # as JSON linear milestone view # view milestone details linear m view # view milestone (alias) -linear milestone view --all --json # every attached issue, as JSON +linear milestone view --all # list every issue, not just the first 10 +linear milestone view --json # milestone with every issue, as JSON linear milestone create --project --name "Q1 Goals" --target-date "2026-03-31" # create a milestone -linear m create --project # create a milestone (interactive) +linear m create --project # create a milestone, asking for its name linear milestone update --name "New Name" # update milestone name linear m update --target-date "2026-04-15" # update target date linear milestone delete # delete a milestone -linear m delete --force # delete without confirmation +linear m delete --yes # delete without confirmation ``` ### document commands @@ -301,7 +307,6 @@ linear document update --content-file ./updated.md --force # bypass c # delete a document linear document delete # soft delete (move to trash) -linear document delete --permanent # permanent delete linear document delete --bulk # bulk delete ``` @@ -348,13 +353,14 @@ the CLI supports configuration via environment variables or a `.linear.toml` con | Download images | `LINEAR_DOWNLOAD_IMAGES` | `download_images` | `true` or `false` | download images when viewing issues | | PR template | `LINEAR_PR_TEMPLATE` | `pr_template` | `".github/pull_request_template.md"` | template file for `issue pr` bodies (the Linear issue URL is appended; `--no-template` skips it) | -the config file can be placed at (checked in order, first found is used): +settings are read from two config files, a project file and a global file. each option takes the first value it finds in this order: + +1. a command-line flag, where the command has one +2. an environment variable, from the shell or a `.env` file (the shell wins) +3. the project config file: the first that exists of `./linear.toml`, `./.linear.toml`, then `linear.toml`, `.linear.toml`, or `.config/linear.toml` at the repository root +4. the global config file: `$XDG_CONFIG_HOME/linear/linear.toml` (or `~/.config/linear/linear.toml`) on macOS and Linux, `%APPDATA%\linear\linear.toml` on Windows -- `./linear.toml` or `./.linear.toml` (current directory) -- `/linear.toml` or `/.linear.toml` (repository root) -- `/.config/linear.toml` -- `$XDG_CONFIG_HOME/linear/linear.toml` or `~/.config/linear/linear.toml` (Unix) -- `%APPDATA%\linear\linear.toml` (Windows) +so the global file can hold defaults such as `issue_sort`, and a repository's `.linear.toml` overrides them for that project. every value is validated, even one a higher tier overrides, and an invalid value is an error naming its file and key. ## skills @@ -390,32 +396,30 @@ view the skill at [skills.sh/schpet/linear-cli/linear-cli](https://skills.sh/sch ## development -### updating skill documentation - -the skill documentation in `skills/linear-cli/` is automatically generated from the CLI help text. after making changes to commands or help text, regenerate the docs: +linear-cli is written in rust; the toolchain is pinned in `rust-toolchain.toml`. common tasks are in the `justfile`: ```bash -deno task generate-skill-docs +just dev issue list # run the cli from source (cargo run -- issue list) +just install # install this checkout as `linear` +just check # cargo fmt --check, clippy, and tests, as CI runs them ``` -this will: +### updating skill documentation -- discover all commands and subcommands from `linear --help` -- generate reference documentation for each command -- update the `SKILL.md` file from `SKILL.template.md` +the skill's command list and `skills/linear-cli/references/` are generated from the cli's help. edit `skills/linear-cli/SKILL.template.md`, then regenerate: -**important:** the CI checks will fail if the generated docs are out of date, so make sure to run this before committing changes that affect command structure or help text. +```bash +just skill-docs +``` -### code formatting +### updating the graphql schema -ensure code is formatted consistently: +`graphql/schema.graphql` is linear's api schema, used to type-check every query at compile time. refresh it with a logged in cli: ```bash -deno fmt +just sync-schema ``` -the project uses deno's built-in formatter with configuration in `deno.json`. formatting is checked in CI. - ## why linear's UI is incredibly good but it slows me down. i find the following pretty grating to experience frequently: 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..ff7686eaa --- /dev/null +++ b/crates/linear-cli/src/cli/issue.rs @@ -0,0 +1,710 @@ +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: check out its git branch or jj change, then mark it started + /// + /// With git, creates the issue's branch (or switches to it if it exists). + /// With jj (`vcs = "jj"` in config or LINEAR_VCS=jj), starts a new change, + /// reusing an empty undescribed one, and describes it with the issue title + /// and `Linear-issue` trailers; no git branch is created. + 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 (git only) + #[arg(long, short, value_name = "REF", value_parser = NonBlank)] + pub from_ref: Option, + /// Branch name to use instead of the issue's (git only) + #[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