diff --git a/.circleci/README.md b/.circleci/README.md new file mode 100644 index 0000000..de86093 --- /dev/null +++ b/.circleci/README.md @@ -0,0 +1,39 @@ +# CircleCI CI and release pipeline + +CircleCI is the canonical CI and release path for this repository. + +## CI + +The `ci` workflow runs the locked test suite, formatting check, clippy with +warnings denied, and a default-feature build on Linux for non-release refs. + +## Release + +Push an annotated or lightweight tag matching `vX.Y.Z`. The release workflow: + +1. Builds the five supported targets in parallel: + - `x86_64-unknown-linux-gnu` + - `aarch64-unknown-linux-gnu` + - `x86_64-pc-windows-msvc` + - `x86_64-apple-darwin` + - `aarch64-apple-darwin` +2. Verifies that the tag version matches `Cargo.toml`. +3. Requires exactly those five archives, checks their archive roots and target + formats, verifies native Linux/Windows binaries with `--version`, and + generates `SHA256SUMS` plus `release-manifest.json`. +4. Uploads the verified assets to the GitHub release. +5. Publishes the matching crate version to crates.io. + +The GitHub release and crates.io steps are intentionally downstream of the +five-asset gate. They require a CircleCI context named `linear-cli-release` +containing: + +- `GH_TOKEN`: a GitHub token allowed to create/update releases in this repo. +- `CARGO_REGISTRY_TOKEN`: the crates.io publish token. + +Configure that context and connect this repository to CircleCI before pushing +the release tag. The local CircleCI CLI can validate the file with: + +```bash +circleci config validate .circleci/config.yml +``` diff --git a/.circleci/config.yml b/.circleci/config.yml new file mode 100644 index 0000000..f1b3791 --- /dev/null +++ b/.circleci/config.yml @@ -0,0 +1,374 @@ +version: 2.1 + +commands: + install-rust: + description: Install the stable Rust toolchain and CI components. + steps: + - run: + name: Install Rust + command: | + set -euo pipefail + if ! command -v rustup >/dev/null 2>&1; then + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal + fi + export PATH="$HOME/.cargo/bin:$PATH" + rustup toolchain install stable --profile minimal + rustup default stable + rustup component add rustfmt clippy + + install-linux-deps: + description: Install the Linux dependencies used by secure-storage builds. + steps: + - run: + name: Install Linux build dependencies + command: | + set -euo pipefail + sudo apt-get update + sudo apt-get install -y libdbus-1-dev pkg-config + + package-unix: + parameters: + target: + type: string + steps: + - run: + name: Verify and package << parameters.target >> + command: | + set -euo pipefail + target="<< parameters.target >>" + binary="target/$target/release/linear-cli" + artifact_dir="artifacts/$target" + test -x "$binary" + cargo_version="$(awk -F'"' '/^version = "/ { print $2; exit }' Cargo.toml)" + test -n "$cargo_version" + mkdir -p "$artifact_dir" + printf 'linear-cli %s' "$cargo_version" > "$artifact_dir/version.txt" + tar -C "target/$target/release" -czf "$artifact_dir/linear-cli-$target.tar.gz" linear-cli + tar -tzf "$artifact_dir/linear-cli-$target.tar.gz" | grep -qx 'linear-cli' + - persist_to_workspace: + root: . + paths: + - artifacts/<< parameters.target >> + - store_artifacts: + path: artifacts/<< parameters.target >> + +jobs: + test: + machine: + image: ubuntu-2404:current + resource_class: large + steps: + - checkout + - install-linux-deps + - install-rust + - run: + name: Run locked test suite + command: | + set -euo pipefail + export PATH="$HOME/.cargo/bin:$PATH" + cargo test --locked --features secure-storage + - run: + name: Check formatting + command: | + set -euo pipefail + export PATH="$HOME/.cargo/bin:$PATH" + cargo fmt --all -- --check + - run: + name: Check release verifier shell syntax + command: bash -n .circleci/verify-release.sh + - run: + name: Run clippy with warnings denied + command: | + set -euo pipefail + export PATH="$HOME/.cargo/bin:$PATH" + cargo clippy --locked --features secure-storage -- -D warnings + - run: + name: Build default feature set + command: | + set -euo pipefail + export PATH="$HOME/.cargo/bin:$PATH" + cargo build --locked --verbose + + build-linux-x86: + machine: + image: ubuntu-2404:current + resource_class: large + steps: + - checkout + - install-linux-deps + - install-rust + - run: + name: Build x86_64 Linux binary + command: | + set -euo pipefail + export PATH="$HOME/.cargo/bin:$PATH" + rustup target add x86_64-unknown-linux-gnu + cargo build --locked --release --features secure-storage --target x86_64-unknown-linux-gnu + - run: + name: Verify native x86_64 Linux binary + command: | + set -euo pipefail + export PATH="$HOME/.cargo/bin:$PATH" + version="$(awk -F'"' '/^version = "/ { print $2; exit }' Cargo.toml)" + reported="$(target/x86_64-unknown-linux-gnu/release/linear-cli --version)" + test "$reported" = "linear-cli $version" + - package-unix: + target: x86_64-unknown-linux-gnu + + build-linux-arm: + machine: + image: ubuntu-2404:current + resource_class: large + steps: + - checkout + - install-rust + - run: + name: Build aarch64 Linux binary with Cross + command: | + set -euo pipefail + export PATH="$HOME/.cargo/bin:$PATH" + cargo install cross --locked --version 0.2.5 + cross build --locked --release --features secure-storage --target aarch64-unknown-linux-gnu + - package-unix: + target: aarch64-unknown-linux-gnu + + build-macos-x86: + macos: + xcode: 26.6.0 + resource_class: m4pro.medium + steps: + - checkout + - install-rust + - run: + name: Build x86_64 macOS binary + command: | + set -euo pipefail + export PATH="$HOME/.cargo/bin:$PATH" + rustup target add x86_64-apple-darwin + cargo build --locked --release --features secure-storage --target x86_64-apple-darwin + - package-unix: + target: x86_64-apple-darwin + + build-macos-arm: + macos: + xcode: 26.6.0 + resource_class: m4pro.medium + steps: + - checkout + - install-rust + - run: + name: Build aarch64 macOS binary + command: | + set -euo pipefail + export PATH="$HOME/.cargo/bin:$PATH" + rustup target add aarch64-apple-darwin + cargo build --locked --release --features secure-storage --target aarch64-apple-darwin + - package-unix: + target: aarch64-apple-darwin + + build-windows-x86: + machine: + image: windows-server-2022-gui:current + shell: powershell.exe -ExecutionPolicy Bypass + resource_class: windows.medium + steps: + - checkout + - run: + name: Install Rust + command: | + $ErrorActionPreference = 'Stop' + $env:Path = "$env:USERPROFILE\.cargo\bin;$env:Path" + if (-not (Get-Command rustup -ErrorAction SilentlyContinue)) { + $installer = Join-Path $env:TEMP 'rustup-init.exe' + Invoke-WebRequest -Uri 'https://win.rustup.rs/x86_64' -OutFile $installer + & $installer -y --default-toolchain stable --profile minimal + } + rustup default stable + rustup component add rustfmt clippy + rustup target add x86_64-pc-windows-msvc + - run: + name: Build and package Windows binary + command: | + $ErrorActionPreference = 'Stop' + $env:Path = "$env:USERPROFILE\.cargo\bin;$env:Path" + $target = 'x86_64-pc-windows-msvc' + cargo build --locked --release --features secure-storage --target $target + $binary = "target/$target/release/linear-cli.exe" + if (-not (Test-Path -LiteralPath $binary)) { throw "Missing binary: $binary" } + & $binary --version + $artifactDir = "artifacts/$target" + New-Item -ItemType Directory -Force -Path $artifactDir | Out-Null + $versionMatch = Select-String -Path Cargo.toml -Pattern '^version = "([^"]+)"' + if ($null -eq $versionMatch) { throw 'Cargo.toml version is missing' } + $version = $versionMatch.Matches[0].Groups[1].Value + [System.IO.File]::WriteAllText((Join-Path $artifactDir 'version.txt'), "linear-cli $version") + $stage = Join-Path $env:TEMP "linear-cli-package-$PID" + New-Item -ItemType Directory -Force -Path $stage | Out-Null + Copy-Item -LiteralPath $binary -Destination (Join-Path $stage 'linear-cli.exe') + Compress-Archive -Path (Join-Path $stage 'linear-cli.exe') -DestinationPath "$artifactDir/linear-cli-$target.zip" -Force + Add-Type -AssemblyName System.IO.Compression.FileSystem + $archive = [System.IO.Compression.ZipFile]::OpenRead("$artifactDir/linear-cli-$target.zip") + try { + if (($archive.Entries | ForEach-Object FullName) -ne 'linear-cli.exe') { throw 'Windows archive must contain linear-cli.exe at its root' } + } finally { + $archive.Dispose() + } + - persist_to_workspace: + root: . + paths: + - artifacts/x86_64-pc-windows-msvc + - store_artifacts: + path: artifacts/x86_64-pc-windows-msvc + + verify-release: + machine: + image: ubuntu-2404:current + resource_class: medium + steps: + - checkout + - attach_workspace: + at: . + - run: + name: Verify release tag, version, assets, and checksums + command: bash .circleci/verify-release.sh + - persist_to_workspace: + root: . + paths: + - release + - store_artifacts: + path: release + + publish-release: + machine: + image: ubuntu-2404:current + resource_class: small + steps: + - checkout + - attach_workspace: + at: . + - run: + name: Upload verified assets to GitHub Release + command: | + set -euo pipefail + : "${CIRCLE_TAG:?This job must run from a release tag}" + : "${GH_TOKEN:?Set GH_TOKEN in the release context}" + export GH_REPO="${CIRCLE_PROJECT_USERNAME}/${CIRCLE_PROJECT_REPONAME}" + if ! command -v gh >/dev/null 2>&1; then + type -p curl >/dev/null + curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \ + | sudo dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg + sudo chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg + echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \ + | sudo tee /etc/apt/sources.list.d/github-cli.list >/dev/null + sudo apt-get update + sudo apt-get install -y gh + fi + if gh release view "$CIRCLE_TAG" >/dev/null 2>&1; then + gh release upload "$CIRCLE_TAG" release/* --clobber + else + gh release create "$CIRCLE_TAG" release/* --verify-tag --title "$CIRCLE_TAG" --generate-notes + fi + expected=( + SHA256SUMS + release-manifest.json + linear-cli-x86_64-unknown-linux-gnu.tar.gz + linear-cli-aarch64-unknown-linux-gnu.tar.gz + linear-cli-x86_64-pc-windows-msvc.zip + linear-cli-x86_64-apple-darwin.tar.gz + linear-cli-aarch64-apple-darwin.tar.gz + ) + actual="$(gh release view "$CIRCLE_TAG" --json assets --jq '.assets[].name' | sort)" + expected_sorted="$(printf '%s\n' "${expected[@]}" | sort)" + test "$actual" = "$expected_sorted" + + publish-crate: + machine: + image: ubuntu-2404:current + resource_class: small + steps: + - checkout + - install-rust + - run: + name: Publish the version proven by the release tag + command: | + set -euo pipefail + : "${CIRCLE_TAG:?This job must run from a release tag}" + : "${CARGO_REGISTRY_TOKEN:?Set CARGO_REGISTRY_TOKEN in the release context}" + version="${CIRCLE_TAG#v}" + cargo_version="$(awk -F'"' '/^version = "/ { print $2; exit }' Cargo.toml)" + test "$cargo_version" = "$version" + export PATH="$HOME/.cargo/bin:$PATH" + cargo publish --locked + +workflows: + ci: + jobs: + - test: + filters: + branches: + only: /.*/ + tags: + ignore: /^v[0-9]+\.[0-9]+\.[0-9]+$/ + + release: + jobs: + - build-linux-x86: + filters: + branches: + ignore: /.*/ + tags: + only: /^v[0-9]+\.[0-9]+\.[0-9]+$/ + - build-linux-arm: + filters: + branches: + ignore: /.*/ + tags: + only: /^v[0-9]+\.[0-9]+\.[0-9]+$/ + - build-macos-x86: + filters: + branches: + ignore: /.*/ + tags: + only: /^v[0-9]+\.[0-9]+\.[0-9]+$/ + - build-macos-arm: + filters: + branches: + ignore: /.*/ + tags: + only: /^v[0-9]+\.[0-9]+\.[0-9]+$/ + - build-windows-x86: + filters: + branches: + ignore: /.*/ + tags: + only: /^v[0-9]+\.[0-9]+\.[0-9]+$/ + - verify-release: + requires: + - build-linux-x86 + - build-linux-arm + - build-macos-x86 + - build-macos-arm + - build-windows-x86 + filters: + branches: + ignore: /.*/ + tags: + only: /^v[0-9]+\.[0-9]+\.[0-9]+$/ + - publish-release: + requires: + - verify-release + context: linear-cli-release + filters: + branches: + ignore: /.*/ + tags: + only: /^v[0-9]+\.[0-9]+\.[0-9]+$/ + - publish-crate: + requires: + - publish-release + context: linear-cli-release + filters: + branches: + ignore: /.*/ + tags: + only: /^v[0-9]+\.[0-9]+\.[0-9]+$/ diff --git a/.circleci/release-manifest.py b/.circleci/release-manifest.py new file mode 100644 index 0000000..904e090 --- /dev/null +++ b/.circleci/release-manifest.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python3 +"""Generate the exact five-target release manifest.""" + +import hashlib +import json +import pathlib +import sys + + +EXPECTED_ARCHIVES = sorted( + [ + "linear-cli-x86_64-unknown-linux-gnu.tar.gz", + "linear-cli-aarch64-unknown-linux-gnu.tar.gz", + "linear-cli-x86_64-pc-windows-msvc.zip", + "linear-cli-x86_64-apple-darwin.tar.gz", + "linear-cli-aarch64-apple-darwin.tar.gz", + ] +) + + +def target_for(name: str) -> str: + prefix = "linear-cli-" + if name.endswith(".tar.gz"): + suffix = ".tar.gz" + elif name.endswith(".zip"): + suffix = ".zip" + else: + raise ValueError(f"unsupported archive: {name}") + return name[len(prefix) : -len(suffix)] + + +def main() -> int: + if len(sys.argv) != 2: + raise SystemExit("usage: release-manifest.py VERSION") + + version = sys.argv[1] + release_dir = pathlib.Path("release") + archives = sorted( + path.name + for path in release_dir.iterdir() + if path.is_file() and (path.name.endswith(".tar.gz") or path.name.endswith(".zip")) + ) + if archives != EXPECTED_ARCHIVES: + raise SystemExit(f"release archives do not match expected set: {archives!r}") + + manifest = { + "version": version, + "archives": [ + { + "target": target_for(name), + "file": name, + "sha256": hashlib.sha256((release_dir / name).read_bytes()).hexdigest(), + } + for name in EXPECTED_ARCHIVES + ], + } + (release_dir / "release-manifest.json").write_text( + json.dumps(manifest, indent=2) + "\n", encoding="utf-8" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.circleci/verify-release.sh b/.circleci/verify-release.sh new file mode 100644 index 0000000..eb10846 --- /dev/null +++ b/.circleci/verify-release.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${CIRCLE_TAG:?This job must run from a release tag}" +if [[ ! "$CIRCLE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Unsupported release tag: $CIRCLE_TAG" >&2 + exit 1 +fi + +version="${CIRCLE_TAG#v}" +cargo_version="$(awk -F'"' '/^version = "/ { print $2; exit }' Cargo.toml)" +test "$cargo_version" = "$version" + +expected=( + linear-cli-x86_64-unknown-linux-gnu.tar.gz + linear-cli-aarch64-unknown-linux-gnu.tar.gz + linear-cli-x86_64-pc-windows-msvc.zip + linear-cli-x86_64-apple-darwin.tar.gz + linear-cli-aarch64-apple-darwin.tar.gz +) +mkdir -p release +for archive in "${expected[@]}"; do + found="$(find artifacts -type f -name "$archive" -print -quit)" + test -n "$found" + cp -- "$found" "release/$archive" + + proof="$(dirname -- "$found")/version.txt" + test -f "$proof" + test "$(tr -d '\r\n' < "$proof")" = "linear-cli $version" +done + +actual_count="$(find artifacts -type f \( -name 'linear-cli-*.tar.gz' -o -name 'linear-cli-*.zip' \) | wc -l)" +test "$actual_count" -eq "${#expected[@]}" + +command -v file >/dev/null +extract_dir="$(mktemp -d)" +trap 'rm -rf -- "$extract_dir"' EXIT +for archive in "${expected[@]}"; do + target="${archive#linear-cli-}" + target="${target%.tar.gz}" + target="${target%.zip}" + destination="$extract_dir/$target" + mkdir -p "$destination" + if [[ "$archive" == *.tar.gz ]]; then + test "$(tar -tzf "release/$archive")" = "linear-cli" + tar -xzf "release/$archive" -C "$destination" + binary="$destination/linear-cli" + test -x "$binary" + file_description="$(file -b "$binary")" + case "$archive" in + linear-cli-x86_64-unknown-linux-gnu.tar.gz) + grep -Eq 'ELF 64-bit.*x86-64' <<<"$file_description" + reported="$($binary --version)" + test "$reported" = "linear-cli $version" + ;; + linear-cli-aarch64-unknown-linux-gnu.tar.gz) + grep -Eq 'ELF 64-bit.*(ARM aarch64|AArch64)' <<<"$file_description" + ;; + linear-cli-x86_64-apple-darwin.tar.gz) + grep -Eq 'Mach-O 64-bit.*x86_64' <<<"$file_description" + ;; + linear-cli-aarch64-apple-darwin.tar.gz) + grep -Eq 'Mach-O 64-bit.*(arm64|ARM64)' <<<"$file_description" + ;; + esac + else + test "$(unzip -Z1 "release/$archive" | tr -d '\r')" = "linear-cli.exe" + unzip -q "release/$archive" -d "$destination" + binary="$destination/linear-cli.exe" + test -f "$binary" + file_description="$(file -b "$binary")" + grep -Eq 'PE32\+ executable.*x86-64' <<<"$file_description" + fi +done + +sha256sum release/linear-cli-* > release/SHA256SUMS +test "$(wc -l < release/SHA256SUMS)" -eq "${#expected[@]}" +python3 .circleci/release-manifest.py "$version" +test "$(find release -maxdepth 1 -type f | wc -l)" -eq 7 diff --git a/.github/CI.md b/.github/CI.md index 43519e1..125f21e 100644 --- a/.github/CI.md +++ b/.github/CI.md @@ -1,55 +1,44 @@ # CI Operator Guide -linear-cli shares a Blacksmith free-tier runner pool with sibling repos. This -guide tells an operator how to control who runs. +CircleCI is the canonical CI and release path. See +`.circleci/README.md` and `.circleci/config.yml` for the checked-in pipeline. -## Budget mode +## Branch and pull-request CI -Set the org/repo variable `CI_BUDGET_MODE` (Settings → Secrets and variables → -Actions → Variables). Unset/empty is treated as `normal`. +The CircleCI `ci` workflow runs locked tests with `secure-storage`, formatting, +clippy with warnings denied, and a default-feature build on Linux. It runs for +non-release refs. -| Mode | PR Check | Release (dispatch) | When to use | -|----------|----------|--------------------|-----------------------------------------------| -| `normal` | runs | runs | Default. A real gate on every PR. | -| `thin` | skipped | runs | Defer linear-cli so Win-CodexBar gets the pool.| -| `off` | skipped | skipped | Pause all CI for this repo. | +## Tagged releases -- PR Check `if`: `vars.CI_BUDGET_MODE != 'off' && vars.CI_BUDGET_MODE != 'thin'` -- Release `if`: `vars.CI_BUDGET_MODE != 'off'` +Push a tag matching `vX.Y.Z`. CircleCI builds these five archives: -## Intended split +- `x86_64-unknown-linux-gnu` +- `aarch64-unknown-linux-gnu` +- `x86_64-pc-windows-msvc` +- `x86_64-apple-darwin` +- `aarch64-apple-darwin` -The shared Blacksmith free tier is ~3000 runner-minutes/month. The intended -share of that pool across repos is roughly **60 / 30 / 10**: -- **~60%** → Win-CodexBar (the priority repo). -- **~30%** → linear-cli. -- **~10%** → buffer for spikes and overruns. +The release verifier fails closed unless the tag matches `Cargo.toml`, all +five archives are present, each archive contains only the expected root binary, +the target formats match, and native binaries report the tagged version. +Cross-target jobs carry an independently checked version proof from the tagged +source. The GitHub release upload and crates.io publish jobs run only after +this gate. -This is a share of pool minutes per repo, **not** calendar time spent in each -`CI_BUDGET_MODE`. The budget mode is the knob that holds linear-cli near its -~30%: `thin` skips the **entire** linear-cli PR Check job (not individual -matrix legs), and `off` pauses all of this repo's CI. It is a planning target, -not a hard cap — move to `thin` whenever Win-CodexBar has open PRs competing -for the pool. +## Credentials and plan prerequisites -## $0 spend alert +Create the restricted CircleCI context `linear-cli-release` with: -Blacksmith bills the free pool per runner-minute, and **Windows bills ~2x -Linux**. PR Check is Linux-only and macOS release builds stay on GitHub-hosted -`macos-latest` specifically to avoid burning the Blacksmith pool. If you see -free-tier minutes dropping faster than the 60/30/10 plan accounts for, set -`CI_BUDGET_MODE=off` on the non-priority repo first. +- `GH_TOKEN`: permission to create or update releases in `nesszer/linear-cli`. +- `CARGO_REGISTRY_TOKEN`: permission to publish `linear-cli` on crates.io. -## Release +The macOS and Windows executors also need to be enabled for the CircleCI +organization/plan. The pipeline cannot prove that external project wiring or +executor entitlements exist from this repository alone. -Releases are **local by default**. See `docs/manual-release.md` for the -`cargo publish` + `gh release` sequence. The `release.yml` workflow is -**dispatch-only** (Actions → Release → Run workflow) and never auto-runs on a -tag or GitHub release. When dispatched: +## Legacy fallback -- Linux x86_64 / aarch64 → `blacksmith-4vcpu-ubuntu-2404` -- Windows x86_64 → `blacksmith-4vcpu-windows-2025` -- macOS x86_64 / aarch64 → `macos-latest` (GitHub-hosted, not Blacksmith) - -A dispatched release still runs in `thin` mode (operator intent overrides the -defer), but is skipped in `off`. +The GitHub Actions CI file is manual-dispatch only during the migration. The +old GitHub Actions release workflow is disabled so it cannot publish a partial +or incorrectly tagged asset set. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ea10edf..102ce21 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,25 +1,12 @@ name: CI -# Thin PR Check for the linear-cli repo. Runs only on Blacksmith's Linux pool -# so the shared Blacksmith free tier is not burned by macOS/Windows builds. -# See .github/CI.md and docs/adr/0001-pr-check-blacksmith-local-release.md for -# why this is Linux-only and how the budget modes gate it. +# Legacy fallback for the linear-cli repo. CircleCI is canonical; this workflow +# is intentionally manual-dispatch only during the migration. on: - push: - branches: [master, main] - paths-ignore: - - "docs/**" - - "skills/**" - - "**/*.md" - - "LICENSE" - pull_request: - branches: [master, main] - paths-ignore: - - "docs/**" - - "skills/**" - - "**/*.md" - - "LICENSE" + # CircleCI is canonical; keep this as an explicitly dispatched fallback + # while branch-protection checks are migrated. + workflow_dispatch: concurrency: group: ${{ github.workflow }}-${{ github.ref }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index 7bc5096..0000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,121 +0,0 @@ -name: Release - -# Dispatch-only release. Never auto-runs on tag/release so the shared -# Blacksmith free tier is not consumed by surprise release builds. The -# workflow stays local-by-default: an operator dispatches it after a manual -# `cargo publish`. See docs/adr/0001-pr-check-blacksmith-local-release.md. - -on: - workflow_dispatch: - -env: - CARGO_TERM_COLOR: always - -jobs: - build: - name: Build ${{ matrix.target }} - runs-on: ${{ matrix.runner }} - # Budget gate: release only runs when not in `off` mode. `thin` still - # allows a manually dispatched release (operator intent), only PR Check - # honors `thin`. - if: vars.CI_BUDGET_MODE != 'off' - permissions: - contents: read - strategy: - matrix: - include: - - target: x86_64-pc-windows-msvc - runner: blacksmith-4vcpu-windows-2025 - ext: .exe - - target: x86_64-apple-darwin - runner: macos-latest - ext: "" - - target: aarch64-apple-darwin - runner: macos-latest - ext: "" - - target: x86_64-unknown-linux-gnu - runner: blacksmith-4vcpu-ubuntu-2404 - ext: "" - - target: aarch64-unknown-linux-gnu - runner: blacksmith-4vcpu-ubuntu-2404 - ext: "" - use_cross: true - - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - - name: Install Linux keyring build deps - if: runner.os == 'Linux' && !matrix.use_cross - run: sudo apt-get update && sudo apt-get install -y libdbus-1-dev pkg-config - - - name: Install Rust - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable - with: - targets: ${{ matrix.target }} - - - name: Install cross - if: matrix.use_cross - run: cargo install cross --locked --version 0.2.5 - - - name: Build (cross) - if: matrix.use_cross - run: cross build --release --features secure-storage --target ${{ matrix.target }} - - - name: Build (native) - if: ${{ !matrix.use_cross }} - run: cargo build --release --features secure-storage --target ${{ matrix.target }} - - - name: Package (Unix) - if: runner.os != 'Windows' - run: | - cd target/${{ matrix.target }}/release - tar -czvf ../../../linear-cli-${{ matrix.target }}.tar.gz linear-cli${{ matrix.ext }} - - - name: Package (Windows) - if: runner.os == 'Windows' - run: | - cd target/${{ matrix.target }}/release - 7z a ../../../linear-cli-${{ matrix.target }}.zip linear-cli${{ matrix.ext }} - - - name: Upload artifact - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 - with: - name: linear-cli-${{ matrix.target }} - path: linear-cli-${{ matrix.target }}.* - - upload: - name: Upload Release Assets - needs: build - runs-on: ubuntu-latest - if: vars.CI_BUDGET_MODE != 'off' - permissions: - contents: write - - steps: - - name: Download all artifacts - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 - - - name: Upload to release - uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2 - with: - files: | - linear-cli-*/linear-cli-* - - publish: - name: Publish to crates.io - needs: upload - runs-on: ubuntu-latest - if: vars.CI_BUDGET_MODE != 'off' - permissions: - contents: read - - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - - name: Install Rust - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable - - - name: Publish to crates.io - run: cargo publish --allow-dirty - env: - CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} diff --git a/CONTEXT.md b/CONTEXT.md index a01a725..35cac52 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -1,20 +1,28 @@ -# Shared CI Budget +# CI and Release -linear-cli shares a Blacksmith free-tier runner pool with other repos (notably Win-CodexBar). To keep one repo from starving the others, CI runs are gated by a single org/repo variable, `CI_BUDGET_MODE`, and PR Check is trimmed to a single Linux job. Releases are local by default and the release workflow is dispatch-only. This file is the shared vocabulary so operators and contributors mean the same thing across repos. +CircleCI is the canonical CI and release system for linear-cli. The checked-in +configuration is `.circleci/config.yml`; it owns branch validation and tagged +release artifacts. GitHub Actions is retained only as an explicitly dispatched +fallback for CI while repository protection rules are migrated. ## Language -**PR Check**: The gate that runs on every push/PR. For linear-cli it is a single Linux job on `blacksmith-4vcpu-ubuntu-2404`: `cargo test`, `cargo fmt --check`, `cargo clippy` (all with `--features secure-storage`), plus one default-features `cargo build`. Windows/macOS are intentionally absent from PR Check to protect the shared pool. -_Avoid_: "the test matrix", "CI" (CI is the whole workflow, not just the gate). +**CI**: The CircleCI `ci` workflow runs the locked secure-storage test suite, +formatting check, clippy with warnings denied, and a default-feature build. -**Release**: Cutting and publishing a version — `cargo publish` plus GitHub release assets. For linear-cli this is local by default; the `release.yml` workflow exists only as a dispatch fallback and never auto-runs on tag/release. -_Avoid_: "deploy" (nothing is deployed; binaries are uploaded to a GitHub release). +**Release**: A semver tag matching `vX.Y.Z` starts five parallel target builds. +The release gate requires that the tag version equals `Cargo.toml`, that the +exact five archives exist, that each archive has the expected target format and +root binary, and that native binaries report the tagged version. Cross-target +jobs carry an independently checked version proof from their tagged source. +Only then are the GitHub release assets uploaded and the crate published. -**Blacksmith Pool**: The shared free tier of Blacksmith runners (`blacksmith-4vcpu-ubuntu-2404`, `blacksmith-4vcpu-windows-2025`) that linear-cli and sibling repos draw from. Windows builds bill ~2x against this pool versus Linux, which is why PR Check stays Linux-only and macOS release builds stay on GitHub-hosted `macos-latest`. -_Avoid_: "Blacksmith runners" without naming the shared-pool constraint; "the cluster". +**Target set**: `x86_64-unknown-linux-gnu`, `aarch64-unknown-linux-gnu`, +`x86_64-pc-windows-msvc`, `x86_64-apple-darwin`, and `aarch64-apple-darwin`. -**Local Release**: The default way to release — an operator runs `cargo publish` and `gh release create/upload` by hand (see `docs/manual-release.md`). The dispatch-only `release.yml` is a fallback, not the primary path. -_Avoid_: "manual release" interchangeably with the workflow; the workflow is "dispatch release". +**Release context**: The CircleCI context `linear-cli-release` must provide +`GH_TOKEN` for GitHub release uploads and `CARGO_REGISTRY_TOKEN` for crates.io. +Those credentials are used only by downstream release jobs after verification. -**Budget Mode**: The value of org/repo variable `CI_BUDGET_MODE` — `normal` (PR Check runs), `thin` (the **entire** linear-cli PR Check job is skipped — not individual matrix legs — so Win-CodexBar gets priority), `off` (all CI skips). Unset/empty is treated as `normal`. PR Check honors `thin`; a manually dispatched Release still runs in `thin` because the operator asked for it. The intended share of the ~3000 free pool minutes/month is roughly 60 / 30 / 10 (Win-CodexBar / linear-cli / buffer) — a per-repo minute share, not calendar time spent in each mode. -_Avoid_: "spend mode", "throttle". +**Manual fallback**: When CircleCI is unavailable, follow +`docs/manual-release.md` and preserve the same five-asset/version gate. diff --git a/Cargo.lock b/Cargo.lock index d32266a..14a0cf9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1245,7 +1245,7 @@ dependencies = [ [[package]] name = "linear-cli" -version = "0.3.26" +version = "0.3.28" dependencies = [ "anyhow", "base64", diff --git a/Cargo.toml b/Cargo.toml index b84a3bc..f270f87 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,12 +1,12 @@ [package] name = "linear-cli" -version = "0.3.26" +version = "0.3.28" edition = "2021" description = "A powerful CLI for Linear.app - manage issues, projects, cycles, and more from your terminal" authors = ["scwrcd"] license = "MIT" -repository = "https://github.com/Finesssee/linear-cli" -homepage = "https://github.com/Finesssee/linear-cli" +repository = "https://github.com/nesszer/linear-cli" +homepage = "https://github.com/nesszer/linear-cli" readme = "README.md" keywords = ["linear", "cli", "project-management", "issues", "productivity"] categories = ["command-line-utilities", "development-tools"] diff --git a/README.md b/README.md index 22aed2b..841a6d4 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # linear-cli [![Crates.io](https://img.shields.io/crates/v/linear-cli)](https://crates.io/crates/linear-cli) -[![CI](https://github.com/Finesssee/linear-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/Finesssee/linear-cli/actions/workflows/ci.yml) +[![CircleCI](https://dl.circleci.com/status-badge/img/gh/nesszer/linear-cli/tree/master.svg?style=shield)](https://app.circleci.com/pipelines/github/nesszer/linear-cli) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Rust](https://img.shields.io/badge/rust-1.70%2B-orange.svg)](https://www.rust-lang.org/) @@ -20,11 +20,11 @@ cargo install linear-cli cargo install linear-cli --features secure-storage # From source -git clone https://github.com/Finesssee/linear-cli.git +git clone https://github.com/nesszer/linear-cli.git cd linear-cli && cargo build --release ``` -Pre-built binaries for Linux (x86_64, aarch64), macOS (x86_64, aarch64), and Windows (x86_64) are available at [GitHub Releases](https://github.com/Finesssee/linear-cli/releases). [cargo-binstall](https://github.com/cargo-bins/cargo-binstall) downloads these automatically. +Pre-built binaries for Linux (x86_64, aarch64), macOS (x86_64, aarch64), and Windows (x86_64) are available at [GitHub Releases](https://github.com/nesszer/linear-cli/releases). [cargo-binstall](https://github.com/cargo-bins/cargo-binstall) downloads these automatically. ## Updating @@ -83,6 +83,7 @@ linear-cli i get LIN-123 --comments # Inline comments linear-cli i get LIN-1 LIN-2 LIN-3 # Batch fetch linear-cli i create "Fix login" -t ENG -p 1 # Create urgent issue +linear-cli i create "Fix login" -t ENG --project "Q2 Roadmap" linear-cli i update LIN-123 -s Done # Update status linear-cli i update LIN-123 -l bug -l urgent # Add labels linear-cli i update LIN-123 --due tomorrow # Set due date @@ -100,6 +101,8 @@ linear-cli i open LIN-123 # Open in browser linear-cli i link LIN-123 # Print URL ``` +**Create flags:** `--team`, `--description`, `--data`, `--priority`, `--state`, `--assignee`, `--labels`, `--due`, `--estimate`, `--project`, `--template`, `--dry-run` + **List flags:** `--mine`, `--team`, `--state`, `--assignee`, `--project`, `--label`, `--since`, `--view`, `--group-by` (state/priority/assignee/project), `--count-only`, `--archived` ### Projects @@ -360,8 +363,21 @@ linear-cli g branch LIN-123 # Show branch name linear-cli g create LIN-123 # Create branch (no checkout) linear-cli g commits # Commits with Linear trailers (jj) linear-cli g pr LIN-123 --draft # Create GitHub PR +linear-cli g review-url LIN-123 # Linear review URL for the issue's PR ``` +`review-url` reads the review URL from the issue's pull request notifications — +the one public place a pull request is paired with its review page — and falls +back to the pull requests linked to the issue's agent sessions. A pull request +that has produced neither (a brand-new PR with no CI result, comment, or review +activity yet) has nothing to resolve, and the command reports it instead of +guessing a URL. + +Unresolved pull requests are part of the output, not a silent omission: `-o json` +returns `{"resolved": [...], "unresolved": [...]}`, and the plain-text form prints +the review URLs on stdout while naming any unresolved pull request on stderr. The +command fails only when it resolved nothing at all. + ### Import / Export Round-trip CSV and JSON import/export with field resolution for status, assignee, and labels. @@ -579,10 +595,10 @@ linear-cli includes Agent Skills for AI coding assistants (Claude Code, Cursor, ```bash # Install all skills -npx skills add Finesssee/linear-cli +npx skills add nesszer/linear-cli # Install specific skill -npx skills add Finesssee/linear-cli --skill linear-workflow +npx skills add nesszer/linear-cli --skill linear-workflow ``` 38 skills covering issues, git, planning, organization, operations, tracking, and advanced API usage. Skills are 10-50x more token-efficient than MCP tools. See [docs/skills.md](docs/skills.md) for details. diff --git a/docs/ai-agents.md b/docs/ai-agents.md index 7cfa5fe..43e63d4 100644 --- a/docs/ai-agents.md +++ b/docs/ai-agents.md @@ -7,7 +7,7 @@ When using AI coding assistants (Claude Code, Cursor, Windsurf, Copilot, OpenAI The easiest way to integrate linear-cli with your AI agent: ```bash -npx skills add Finesssee/linear-cli +npx skills add nesszer/linear-cli ``` This installs **27 Agent Skills** covering all CLI features. Your agent automatically loads the right skill based on the task. diff --git a/docs/manual-release.md b/docs/manual-release.md index 750992d..d3d3948 100644 --- a/docs/manual-release.md +++ b/docs/manual-release.md @@ -1,132 +1,93 @@ # Manual Release Guide -Use this guide when GitHub Actions is unavailable or when release assets need to be backfilled by hand. +This is the controlled fallback when CircleCI is unavailable. The same +fail-closed contract as `.circleci/config.yml` applies: never publish or +announce a release with a partial asset set. -## Rules +## Release contract -1. Publish the crate to crates.io before creating or updating the matching GitHub release. -2. Only attach binaries built from the exact source for that version tag. -3. Keep Windows release assets on `x86_64-pc-windows-msvc` so `cargo-binstall` metadata stays correct. +1. Set `Cargo.toml` and `Cargo.lock` to the intended version, for example + `0.3.28`. +2. Create and push the matching tag, for example `v0.3.28`, from the exact + source to be released. +3. Build exactly these archives: -## Version Order + - `linear-cli-x86_64-unknown-linux-gnu.tar.gz` + - `linear-cli-aarch64-unknown-linux-gnu.tar.gz` + - `linear-cli-x86_64-pc-windows-msvc.zip` + - `linear-cli-x86_64-apple-darwin.tar.gz` + - `linear-cli-aarch64-apple-darwin.tar.gz` -- Repair a broken old release from its exact tagged source. -- Cut the next release from the current branch only after the old release is consistent again. +4. Verify native binaries with `linear-cli --version`, and verify that their + output equals `linear-cli `. For cross-target builds, verify the + target format with `file` and preserve the version from the tagged + `Cargo.toml` build. +5. Create/upload the GitHub release only after all five artifacts pass. +6. Publish the same version to crates.io with `cargo publish --locked`. -## Repairing `v0.3.16` +The Windows archive must contain `linear-cli.exe` at its archive root. The +other archives must contain `linear-cli` at their archive root. -Build from commit `84f522199e1a5c9332fca76ccefeae924c92115e`. +## Build commands -### Linux x86_64 - -```bash -cargo build --release --target x86_64-unknown-linux-gnu -tar -C target/x86_64-unknown-linux-gnu/release -czf linear-cli-x86_64-unknown-linux-gnu.tar.gz linear-cli -``` - -### Linux aarch64 - -`v0.3.16` still uses OpenSSL-backed TLS, so build it with a temporary `Cross.toml` instead of editing the old tag: - -```toml -[target.aarch64-unknown-linux-gnu] -pre-build = [ - "dpkg --add-architecture $CROSS_DEB_ARCH", - "apt-get update && apt-get --assume-yes install libssl-dev:$CROSS_DEB_ARCH" -] -``` - -```bash -CROSS_CONFIG=/absolute/path/to/Cross.toml cross build --release --target aarch64-unknown-linux-gnu -tar -C target/aarch64-unknown-linux-gnu/release -czf linear-cli-aarch64-unknown-linux-gnu.tar.gz linear-cli -``` - -### Windows x86_64 (MSVC) - -On Ubuntu, `cargo xwin` also needs LLVM's MSVC-compatible entrypoints available on `PATH`: +Run these commands from the release tag. Linux builds need `libdbus-1-dev` and +`pkg-config`; the repository `Cross.toml` supplies the aarch64 Linux setup. ```bash sudo apt-get update -sudo apt-get install -y clang lld -sudo ln -sf /usr/bin/clang-18 /usr/local/bin/clang-cl -sudo ln -sf /usr/bin/llvm-lib-18 /usr/local/bin/llvm-lib -sudo ln -sf /usr/bin/llvm-ar-18 /usr/local/bin/llvm-ar -``` - -```bash -rustup target add x86_64-pc-windows-msvc -cargo xwin build --release --target x86_64-pc-windows-msvc -cd target/x86_64-pc-windows-msvc/release && 7z a ../../../linear-cli-x86_64-pc-windows-msvc.zip linear-cli.exe +sudo apt-get install -y libdbus-1-dev pkg-config + +cargo build --locked --release --features secure-storage \ + --target x86_64-unknown-linux-gnu +tar -C target/x86_64-unknown-linux-gnu/release -czf \ + linear-cli-x86_64-unknown-linux-gnu.tar.gz linear-cli + +cargo install cross --locked --version 0.2.5 +cross build --locked --release --features secure-storage \ + --target aarch64-unknown-linux-gnu +tar -C target/aarch64-unknown-linux-gnu/release -czf \ + linear-cli-aarch64-unknown-linux-gnu.tar.gz linear-cli ``` -### macOS on a real Mac +Build the two Apple targets on a macOS host: ```bash rustup target add x86_64-apple-darwin aarch64-apple-darwin -cargo build --release --target x86_64-apple-darwin -tar -C target/x86_64-apple-darwin/release -czf linear-cli-x86_64-apple-darwin.tar.gz linear-cli - -cargo build --release --target aarch64-apple-darwin -tar -C target/aarch64-apple-darwin/release -czf linear-cli-aarch64-apple-darwin.tar.gz linear-cli -``` - -### Publish and upload - -```bash -cargo publish -gh release upload v0.3.16 \ - linear-cli-x86_64-unknown-linux-gnu.tar.gz \ - linear-cli-aarch64-unknown-linux-gnu.tar.gz \ - linear-cli-x86_64-pc-windows-msvc.zip \ - linear-cli-x86_64-apple-darwin.tar.gz \ - linear-cli-aarch64-apple-darwin.tar.gz +cargo build --locked --release --features secure-storage --target x86_64-apple-darwin +tar -C target/x86_64-apple-darwin/release -czf \ + linear-cli-x86_64-apple-darwin.tar.gz linear-cli +cargo build --locked --release --features secure-storage --target aarch64-apple-darwin +tar -C target/aarch64-apple-darwin/release -czf \ + linear-cli-aarch64-apple-darwin.tar.gz linear-cli ``` -Confirm crates.io shows `0.3.16` before moving on. - -## Releasing `v0.3.17` and newer - -Current `master` uses `reqwest` with `rustls`. Official binaries should build with -`--features secure-storage` so Keychain / Credential Manager / Secret Service work. +Build the Windows target on a Windows host with the MSVC toolchain: -Linux builds need `libdbus-1-dev` (and `pkg-config`). For aarch64 cross builds, use the -repo-root `Cross.toml` which installs `libdbus-1-dev:$CROSS_DEB_ARCH`. - -### Local builds - -```bash -sudo apt-get install -y libdbus-1-dev pkg-config # Linux hosts only - -cargo build --release --features secure-storage --target x86_64-unknown-linux-gnu -tar -C target/x86_64-unknown-linux-gnu/release -czf linear-cli-x86_64-unknown-linux-gnu.tar.gz linear-cli - -cross build --release --features secure-storage --target aarch64-unknown-linux-gnu -tar -C target/aarch64-unknown-linux-gnu/release -czf linear-cli-aarch64-unknown-linux-gnu.tar.gz linear-cli - -cargo xwin build --release --features secure-storage --target x86_64-pc-windows-msvc -cd target/x86_64-pc-windows-msvc/release && 7z a ../../../linear-cli-x86_64-pc-windows-msvc.zip linear-cli.exe +```powershell +rustup target add x86_64-pc-windows-msvc +cargo build --locked --release --features secure-storage --target x86_64-pc-windows-msvc +Compress-Archive -Path target/x86_64-pc-windows-msvc/release/linear-cli.exe ` + -DestinationPath linear-cli-x86_64-pc-windows-msvc.zip -Force ``` -Build both Apple targets on a Mac with `--features secure-storage` as well. +## Verify and upload -### Publish first, then release +Before uploading, check the archive names and versions manually, then create +or update the release with the exact tag: ```bash -cargo publish -gh release create v0.3.17 --title v0.3.17 --notes "Manual release." -gh release upload v0.3.17 \ - linear-cli-x86_64-unknown-linux-gnu.tar.gz \ - linear-cli-aarch64-unknown-linux-gnu.tar.gz \ - linear-cli-x86_64-pc-windows-msvc.zip \ - linear-cli-x86_64-apple-darwin.tar.gz \ - linear-cli-aarch64-apple-darwin.tar.gz +sha256sum linear-cli-* +gh release create v0.3.28 --verify-tag --title v0.3.28 --generate-notes +gh release upload v0.3.28 linear-cli-*.tar.gz linear-cli-*.zip --clobber +cargo publish --locked ``` -## Final Checks +Finally confirm both public surfaces show the same version: ```bash cargo search linear-cli --limit 1 -gh release view v0.3.17 +gh release view v0.3.28 ``` -The crates.io version and the GitHub release tag should match before announcing the release. +The crates.io version and the GitHub release tag must match before announcing +the release. diff --git a/docs/skills.md b/docs/skills.md index bba10f5..3ac54db 100644 --- a/docs/skills.md +++ b/docs/skills.md @@ -6,13 +6,13 @@ ```bash # Install all skills -npx skills add Finesssee/linear-cli +npx skills add nesszer/linear-cli # Install specific skill -npx skills add Finesssee/linear-cli --skill linear-list +npx skills add nesszer/linear-cli --skill linear-list # Install globally (available in all projects) -npx skills add Finesssee/linear-cli -g +npx skills add nesszer/linear-cli -g ``` ## Available Skills (38 total) @@ -154,5 +154,5 @@ npx skills update npx skills remove --skill linear-list # Remove all linear-cli skills -npx skills remove Finesssee/linear-cli +npx skills remove nesszer/linear-cli ``` diff --git a/src/api.rs b/src/api.rs index 0f092e0..46552af 100644 --- a/src/api.rs +++ b/src/api.rs @@ -14,11 +14,28 @@ use crate::error::{CliError, ErrorKind}; use crate::pagination::{paginate_nodes, PaginationOptions}; use crate::retry::{with_retry, RetryConfig}; use crate::text::is_uuid; -use std::sync::OnceLock; +use std::sync::{ + atomic::{AtomicBool, Ordering}, + OnceLock, +}; const LINEAR_API_URL: &str = "https://api.linear.app/graphql"; const LINEAR_UPLOADS_HOST: &str = "uploads.linear.app"; +static DRY_RUN_MODE: AtomicBool = AtomicBool::new(false); + +/// Set the process-wide dry-run mode before dispatching a command. +pub fn set_dry_run(enabled: bool) { + DRY_RUN_MODE.store(enabled, Ordering::Relaxed); +} + +fn mutation_allowed(dry_run: bool) -> Result<()> { + if dry_run { + anyhow::bail!("--dry-run is not supported for this mutation; no changes were made"); + } + Ok(()) +} + /// Configuration for generic ID resolution struct ResolverConfig<'a> { cache_type: CacheType, @@ -747,6 +764,7 @@ impl LinearClient { } pub async fn mutate(&self, mutation: &str, variables: Option) -> Result { + mutation_allowed(DRY_RUN_MODE.load(Ordering::Relaxed))?; // Mutations must not be retried to avoid duplicate side effects self.query_once(mutation, variables).await } @@ -949,6 +967,12 @@ mod tests { assert_eq!(refined.kind, ErrorKind::NotFound); } + #[test] + fn dry_run_blocks_unhandled_mutations() { + let error = mutation_allowed(true).unwrap_err(); + assert!(error.to_string().contains("no changes were made")); + } + #[test] fn test_auth_state_api_key_header() { let state = AuthState::ApiKey("lin_api_key123".to_string()); diff --git a/src/commands/api.rs b/src/commands/api.rs index 6c139b9..0171145 100644 --- a/src/commands/api.rs +++ b/src/commands/api.rs @@ -4,7 +4,7 @@ use serde_json::{json, Map, Value}; use std::io::{self, BufRead, IsTerminal}; use crate::api::LinearClient; -use crate::output::{print_json_owned, OutputOptions}; +use crate::output::{print_json_owned, reject_unsupported_dry_run, OutputOptions}; use crate::pagination::{paginate_nodes, PaginationOptions}; #[derive(Subcommand)] @@ -62,6 +62,7 @@ pub async fn handle(cmd: ApiCommands, output: &OutputOptions) -> Result<()> { page_info_path, } => { let resolved = resolve_query_source(query)?; + reject_mutation_document_for_dry_run(&resolved, output.dry_run)?; run_query( &resolved, &variables, @@ -73,6 +74,7 @@ pub async fn handle(cmd: ApiCommands, output: &OutputOptions) -> Result<()> { .await } ApiCommands::Mutate { query, variables } => { + reject_unsupported_dry_run(output.dry_run, "api mutate")?; let resolved = resolve_query_source(query)?; run_mutate(&resolved, &variables, output).await } @@ -108,6 +110,106 @@ fn read_stdin() -> Result { Ok(query) } +fn reject_mutation_document_for_dry_run(query: &str, dry_run: bool) -> Result<()> { + if dry_run && contains_top_level_mutation_operation(query) { + anyhow::bail!( + "--dry-run is not supported for mutation documents passed to `api query`; use `api mutate` without --dry-run; no changes were made" + ); + } + Ok(()) +} + +fn contains_top_level_mutation_operation(query: &str) -> bool { + let bytes = query.as_bytes(); + let mut index = 0; + let mut brace_depth: usize = 0; + let mut paren_depth: usize = 0; + let mut bracket_depth: usize = 0; + let mut previous_top_level_name: Option = None; + + while index < bytes.len() { + match bytes[index] { + b'#' => { + while index < bytes.len() && bytes[index] != b'\n' { + index += 1; + } + } + b'"' => { + if bytes[index..].starts_with(b"\"\"\"") { + index += 3; + while index < bytes.len() { + if bytes[index..].starts_with(b"\"\"\"") { + index += 3; + break; + } + index += 1; + } + } else { + index += 1; + while index < bytes.len() { + match bytes[index] { + b'\\' => index = (index + 2).min(bytes.len()), + b'"' => { + index += 1; + break; + } + _ => index += 1, + } + } + } + } + b'{' => { + brace_depth += 1; + index += 1; + } + b'}' => { + brace_depth = brace_depth.saturating_sub(1); + index += 1; + } + b'(' => { + paren_depth += 1; + index += 1; + } + b')' => { + paren_depth = paren_depth.saturating_sub(1); + index += 1; + } + b'[' => { + bracket_depth += 1; + index += 1; + } + b']' => { + bracket_depth = bracket_depth.saturating_sub(1); + index += 1; + } + byte if byte.is_ascii_alphabetic() || byte == b'_' => { + let start = index; + index += 1; + while index < bytes.len() + && (bytes[index].is_ascii_alphanumeric() || bytes[index] == b'_') + { + index += 1; + } + if brace_depth == 0 && paren_depth == 0 && bracket_depth == 0 { + let name = &query[start..index]; + if name == "mutation" + && !matches!( + previous_top_level_name.as_deref(), + Some("query") | Some("subscription") | Some("fragment") + ) + { + return true; + } + previous_top_level_name = Some(name.to_string()); + } + } + _ => index += 1, + } + } + + false +} + fn read_query(input: &str) -> Result { if input == "-" { read_stdin() @@ -225,6 +327,41 @@ mod tests { assert!(result.is_none()); } + #[test] + fn dry_run_rejects_mutation_documents() { + let error = reject_mutation_document_for_dry_run( + "mutation CreateIssue { issueCreate(input: {}) { success } }", + true, + ) + .unwrap_err(); + assert!(error + .to_string() + .contains("is not supported for mutation documents")); + } + + #[test] + fn dry_run_accepts_query_documents() { + assert!(reject_mutation_document_for_dry_run("query { viewer { id } }", true).is_ok()); + assert!(reject_mutation_document_for_dry_run("{ viewer { id } }", true).is_ok()); + } + + #[test] + fn mutation_word_in_query_text_is_not_an_operation() { + assert!( + reject_mutation_document_for_dry_run("query mutation { viewer { name } }", true) + .is_ok() + ); + assert!( + reject_mutation_document_for_dry_run("query { viewer { name } } # mutation", true) + .is_ok() + ); + assert!(reject_mutation_document_for_dry_run( + "query { viewer { name } note(text: \"mutation\") }", + true + ) + .is_ok()); + } + #[test] fn test_parse_variables_string() { let vars = vec!["name=hello".to_string()]; diff --git a/src/commands/comments.rs b/src/commands/comments.rs index 504f51a..1cbe6bb 100644 --- a/src/commands/comments.rs +++ b/src/commands/comments.rs @@ -357,47 +357,6 @@ async fn create_comment(issue_id: &str, body: &str, parent_id: Option) - Ok(()) } -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_safe_terminal_value_removes_escape_sequences() { - assert_eq!( - safe_terminal_value("bad\u{1b}]52;c;ZXZpbA==\u{7}title"), - "badtitle" - ); - } - - #[test] - fn comments_status_ok_when_nothing_failed() { - assert!(comments_status(None, &[]).is_ok()); - } - - #[test] - fn comments_status_notfound_when_only_missing() { - let err = comments_status(None, &["LIN-9".to_string()]).unwrap_err(); - assert_eq!(err.downcast_ref::().expect("CliError").code(), 2); - } - - #[test] - fn comments_status_preserves_real_error_over_missing() { - // A real fetch error keeps its kind (rate-limited => 4), not NotFound(2). - let err = comments_status( - Some( - CliError::rate_limited("429") - .with_retry_after(Some(5)) - .into(), - ), - &["LIN-9".to_string()], - ) - .unwrap_err(); - let cli = err.downcast_ref::().expect("CliError"); - assert_eq!(cli.code(), 4); - assert_eq!(cli.retry_after, Some(5)); - } -} - async fn update_comment(id: &str, body: &str, output: &OutputOptions) -> Result<()> { let client = LinearClient::new()?; @@ -466,3 +425,44 @@ async fn delete_comment(id: &str, force: bool) -> Result<()> { Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_safe_terminal_value_removes_escape_sequences() { + assert_eq!( + safe_terminal_value("bad\u{1b}]52;c;ZXZpbA==\u{7}title"), + "badtitle" + ); + } + + #[test] + fn comments_status_ok_when_nothing_failed() { + assert!(comments_status(None, &[]).is_ok()); + } + + #[test] + fn comments_status_notfound_when_only_missing() { + let err = comments_status(None, &["LIN-9".to_string()]).unwrap_err(); + assert_eq!(err.downcast_ref::().expect("CliError").code(), 2); + } + + #[test] + fn comments_status_preserves_real_error_over_missing() { + // A real fetch error keeps its kind (rate-limited => 4), not NotFound(2). + let err = comments_status( + Some( + CliError::rate_limited("429") + .with_retry_after(Some(5)) + .into(), + ), + &["LIN-9".to_string()], + ) + .unwrap_err(); + let cli = err.downcast_ref::().expect("CliError"); + assert_eq!(cli.code(), 4); + assert_eq!(cli.retry_after, Some(5)); + } +} diff --git a/src/commands/documents.rs b/src/commands/documents.rs index d7daa23..2eafc82 100644 --- a/src/commands/documents.rs +++ b/src/commands/documents.rs @@ -428,19 +428,6 @@ async fn create_document( Ok(()) } -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_safe_terminal_value_removes_escape_sequences() { - assert_eq!( - safe_terminal_value("bad\u{1b}]52;c;ZXZpbA==\u{7}title"), - "badtitle" - ); - } -} - #[allow(clippy::too_many_arguments)] async fn update_document( id: &str, @@ -579,3 +566,16 @@ async fn delete_document( Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_safe_terminal_value_removes_escape_sequences() { + assert_eq!( + safe_terminal_value("bad\u{1b}]52;c;ZXZpbA==\u{7}title"), + "badtitle" + ); + } +} diff --git a/src/commands/git.rs b/src/commands/git.rs index 9a23d81..7799a27 100644 --- a/src/commands/git.rs +++ b/src/commands/git.rs @@ -7,9 +7,14 @@ use std::process::Command; use crate::api::LinearClient; use crate::display_options; +use crate::output::OutputOptions; use crate::text::truncate; use crate::vcs::{generate_branch_name, git_branch_exists, run_git_command, validate_branch_name}; +mod review_url; + +use review_url::show_review_url; + /// Version control system type #[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)] pub enum Vcs { @@ -82,6 +87,19 @@ pub enum GitCommands { #[arg(long, value_enum)] vcs: Option, }, + /// Show the Linear review URL for an issue's pull request(s) + #[command(after_help = r#"EXAMPLES: + linear git review-url LIN-123 # Print the review URL(s) + linear g review-url LIN-123 -o json # Resolved and unresolved PRs + +NOTE: The review URL is read from the issue's pull request notifications, falling +back to the pull requests linked to its agent sessions. A pull request that has +produced neither has no review URL to resolve; it is listed as unresolved rather +than dropped, on stderr in plain text and under "unresolved" in JSON."#)] + ReviewUrl { + /// Issue identifier (e.g., "LIN-123") or ID + issue: String, + }, /// Create a GitHub PR from a Linear issue #[command(after_help = r#"EXAMPLES: linear git pr LIN-123 # Create PR for issue @@ -140,8 +158,9 @@ fn get_vcs(vcs_flag: Option) -> Result { } } -pub async fn handle(cmd: GitCommands) -> Result<()> { +pub async fn handle(cmd: GitCommands, output: &OutputOptions) -> Result<()> { match cmd { + GitCommands::ReviewUrl { issue } => show_review_url(&issue, output).await, GitCommands::Checkout { issue, branch, vcs } => { let vcs = get_vcs(vcs)?; checkout_issue(&issue, branch, vcs).await diff --git a/src/commands/git/review_url.rs b/src/commands/git/review_url.rs new file mode 100644 index 0000000..9f4dffd --- /dev/null +++ b/src/commands/git/review_url.rs @@ -0,0 +1,788 @@ +//! `linear git review-url` — resolve the Linear review page for an issue's pull +//! requests. +//! +//! Linear exposes a pull request's review URL in two places, and neither covers +//! every pull request on its own: +//! +//! * a `PullRequestNotification` carries the review URL whole +//! (`review/-`), but only exists once the pull request has +//! produced notification-worthy activity; +//! * an agent session's `PullRequest.slugId` is enough to assemble a review URL, +//! but only pull requests an agent worked on have a session. +//! +//! So notifications are the primary source, agent sessions the fallback, and a +//! pull request neither source resolves is reported as unresolved rather than +//! guessed at. + +use anyhow::Result; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Map, Value}; +use std::collections::{BTreeMap, BTreeSet}; + +use crate::api::LinearClient; +use crate::output::{print_json, OutputOptions}; +use crate::pagination::{paginate_until, PageFlow, PaginationOptions}; + +/// How many notifications `review-url` reads before falling through to agent +/// sessions. +/// +/// The feed has no server-side filter for pull requests, so it is walked +/// newest-first; a pull request whose last notification is older than this is +/// left to the agent-session path. +const NOTIFICATION_LIMIT: usize = 500; + +/// Notifications per request while walking the feed. +const NOTIFICATION_PAGE_SIZE: usize = 100; + +const ISSUE_QUERY: &str = r#" + query($id: String!) { + organization { urlKey } + issue(id: $id) { + identifier + attachments { nodes { url sourceType } } + agentSessions { + nodes { + pullRequests { + nodes { + pullRequest { slugId url number status title } + } + } + } + } + } + } +"#; + +const NOTIFICATIONS_QUERY: &str = r#" + query($first: Int, $after: String) { + notifications(first: $first, after: $after, includeArchived: true) { + pageInfo { hasNextPage endCursor } + nodes { + __typename + ... on PullRequestNotification { + url + pullRequest { url number status title } + } + } + } + } +"#; + +/// A Linear GraphQL connection, reduced to the nodes callers care about. +#[derive(Debug, Deserialize)] +// `#[serde(default)]` on `nodes` would otherwise pull a `T: Default` bound into +// the generated impl; only `Deserialize` is actually needed. +#[serde(bound(deserialize = "T: Deserialize<'de>"))] +struct NodeList { + #[serde(default)] + nodes: Vec, +} + +impl Default for NodeList { + fn default() -> Self { + Self { nodes: Vec::new() } + } +} + +#[derive(Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +struct Issue { + #[serde(default)] + identifier: Option, + #[serde(default)] + attachments: NodeList, + #[serde(default)] + agent_sessions: NodeList, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct Attachment { + #[serde(default)] + source_type: Option, + #[serde(default)] + url: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct AgentSession { + #[serde(default)] + pull_requests: NodeList, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct AgentSessionPullRequest { + #[serde(default)] + pull_request: Option, +} + +/// A pull request as Linear returns it, on either source. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct PullRequest { + #[serde(default)] + slug_id: Option, + #[serde(default)] + url: Option, + #[serde(default)] + number: Option, + #[serde(default)] + status: Option, + #[serde(default)] + title: Option, +} + +/// A node from the notification feed. The feed is heterogeneous, so everything +/// but `__typename` is optional and non-pull-request nodes are dropped. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct Notification { + #[serde(rename = "__typename", default)] + typename: Option, + #[serde(default)] + url: Option, + #[serde(default)] + pull_request: Option, +} + +/// One pull request that resolved to a review page. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +struct ReviewEntry { + review_url: String, + number: Option, + status: Option, + title: Option, + /// The GitHub pull request URL; the identity a merge keys on. + url: String, +} + +impl ReviewEntry { + /// Build an entry for `pr` at an already-resolved `review_url`. + /// + /// A pull request Linear returned without a URL has no identity to merge or + /// report on, so it yields nothing rather than an entry with a null `url`. + fn new(review_url: String, pr: &PullRequest) -> Option { + let url = pr.url.clone().filter(|u| !u.is_empty())?; + Some(Self { + review_url, + number: pr.number, + status: pr.status.clone(), + title: pr.title.clone(), + url, + }) + } +} + +/// The outcome of resolving an issue's pull requests. +/// +/// Unresolved pull requests are part of the output contract, not a silent +/// omission: a caller that resolved two of three attached pull requests can see +/// which one it did not get. +#[derive(Debug, Default, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +struct Resolution { + resolved: Vec, + /// GitHub pull request URLs attached to the issue that neither source resolved. + unresolved: Vec, +} + +impl Resolution { + /// Merge both sources into one entry per pull request. + /// + /// `fallback` (agent sessions) goes in first so a `primary` (notification) + /// result — which carries the review URL whole rather than assembling it — + /// replaces it. Entries are reported in `pr_urls` order, which is the order + /// Linear lists the issue's attachments; a pull request an agent session + /// linked without a corresponding attachment follows them. + fn merge(pr_urls: &[String], fallback: Vec, primary: Vec) -> Self { + let mut by_pr_url: BTreeMap = BTreeMap::new(); + for entry in fallback.into_iter().chain(primary) { + by_pr_url.insert(entry.url.clone(), entry); + } + + let mut resolved = Vec::with_capacity(by_pr_url.len()); + let mut unresolved = Vec::new(); + for url in pr_urls { + match by_pr_url.remove(url) { + Some(entry) => resolved.push(entry), + None => unresolved.push(url.clone()), + } + } + resolved.extend(by_pr_url.into_values()); + + Self { + resolved, + unresolved, + } + } +} + +impl Issue { + /// GitHub pull request URLs attached to the issue. + /// + /// Linear links a pull request to an issue as a `github` attachment as soon + /// as it detects the branch, so this is the full set of the issue's pull + /// requests — but the attachment carries no review slug, which is why the + /// review URL is looked up separately. + fn attached_pr_urls(&self) -> Vec { + let mut urls: Vec = Vec::new(); + for attachment in &self.attachments.nodes { + if attachment.source_type.as_deref() != Some("github") { + continue; + } + let Some(url) = attachment.url.as_deref().filter(|u| u.contains("/pull/")) else { + continue; + }; + if !urls.iter().any(|existing| existing == url) { + urls.push(url.to_string()); + } + } + urls + } + + /// Review entries assembled from the issue's agent sessions. + /// + /// This is the fallback for a pull request with no notification: a session + /// exposes `PullRequest.slugId`, from which the review URL can be built. + fn agent_session_entries(&self, url_key: &str) -> Vec { + let mut entries = Vec::new(); + for session in &self.agent_sessions.nodes { + for link in &session.pull_requests.nodes { + let Some(pr) = link.pull_request.as_ref() else { + continue; + }; + let Some(slug) = pr.slug_id.as_deref().filter(|s| !s.is_empty()) else { + continue; + }; + let review_url = format!("https://linear.app/{}/review/{}", url_key, slug); + if let Some(entry) = ReviewEntry::new(review_url, pr) { + entries.push(entry); + } + } + } + entries + } +} + +/// Pick out review entries for `pr_urls` from a page of notification nodes. +fn review_entries_from_notifications(nodes: &[Value], pr_urls: &[String]) -> Vec { + let mut entries = Vec::new(); + for node in nodes { + let Ok(notification) = serde_json::from_value::(node.clone()) else { + continue; + }; + if notification.typename.as_deref() != Some("PullRequestNotification") { + continue; + } + let Some(pr) = notification.pull_request.as_ref() else { + continue; + }; + if !pr + .url + .as_deref() + .is_some_and(|url| pr_urls.iter().any(|wanted| wanted == url)) + { + continue; + } + let Some(review_url) = notification.url.as_deref().filter(|u| !u.is_empty()) else { + continue; + }; + // A comment notification points at an anchor within the review page + // (`…#comment-`); the page itself is what a caller wants. + let review_url = review_url.split('#').next().unwrap_or(review_url); + if let Some(entry) = ReviewEntry::new(review_url.to_string(), pr) { + entries.push(entry); + } + } + entries +} + +/// Accumulates review entries across pages of the notification feed. +/// +/// The feed is newest-first and a pull request can appear on it many times, so +/// the first entry seen for a pull request is its current one and later pages +/// must not displace it. Once every wanted pull request has an entry there is +/// nothing left to look for, which is what lets the walk stop early. +#[derive(Debug, Default)] +struct NotificationScan { + entries: Vec, + seen_pr_urls: BTreeSet, +} + +impl NotificationScan { + /// Take one page of notification nodes, and report whether to read another. + fn absorb(&mut self, nodes: &[Value], pr_urls: &[String]) -> PageFlow { + for entry in review_entries_from_notifications(nodes, pr_urls) { + if self.seen_pr_urls.insert(entry.url.clone()) { + self.entries.push(entry); + } + } + + if self.seen_pr_urls.len() >= pr_urls.len() { + PageFlow::Stop + } else { + PageFlow::Continue + } + } +} + +/// Walk the notification feed looking for the review URLs of `pr_urls`. +async fn notification_entries( + client: &LinearClient, + pr_urls: &[String], +) -> Result> { + let options = PaginationOptions { + limit: Some(NOTIFICATION_LIMIT), + page_size: Some(NOTIFICATION_PAGE_SIZE), + ..Default::default() + }; + + let mut scan = NotificationScan::default(); + paginate_until( + client, + NOTIFICATIONS_QUERY, + Map::new(), + &["data", "notifications", "nodes"], + &["data", "notifications", "pageInfo"], + &options, + NOTIFICATION_PAGE_SIZE, + |nodes| scan.absorb(&nodes, pr_urls), + ) + .await?; + + Ok(scan.entries) +} + +/// Why `identifier` has no review URL, given the pull requests attached to it. +fn nothing_resolved_error(identifier: &str, pr_urls: &[String]) -> anyhow::Error { + let reason = if pr_urls.is_empty() { + "no pull request is linked to this issue".to_string() + } else { + format!( + "Linear exposes a review URL through pull request notifications, and none of the {} \ + linked pull request(s) has one in the last {} notifications", + pr_urls.len(), + NOTIFICATION_LIMIT + ) + }; + anyhow::anyhow!( + "No review URL for {}: {}. Use the GitHub PR URL instead.", + identifier, + reason + ) +} + +pub async fn show_review_url(issue_id: &str, output: &OutputOptions) -> Result<()> { + let client = LinearClient::new()?; + let result = client + .query(ISSUE_QUERY, Some(json!({ "id": issue_id }))) + .await?; + + if result["data"]["issue"].is_null() { + anyhow::bail!("Issue not found: {}", issue_id); + } + + let issue: Issue = serde_json::from_value(result["data"]["issue"].clone())?; + let url_key = result["data"]["organization"]["urlKey"] + .as_str() + .unwrap_or_default(); + + let pr_urls = issue.attached_pr_urls(); + let notifications = if pr_urls.is_empty() { + Vec::new() + } else { + notification_entries(&client, &pr_urls).await? + }; + + let resolution = Resolution::merge( + &pr_urls, + issue.agent_session_entries(url_key), + notifications, + ); + + if resolution.resolved.is_empty() { + let identifier = issue.identifier.as_deref().unwrap_or(issue_id); + return Err(nothing_resolved_error(identifier, &pr_urls)); + } + + if output.is_json() || output.has_template() { + return print_json(&json!(resolution), output); + } + + for entry in &resolution.resolved { + println!("{}", entry.review_url); + } + + // A partly resolved issue still prints what it has, but never silently: the + // pull requests it could not resolve are named on stderr so a caller reading + // stdout does not mistake the list for the whole set. + if !resolution.unresolved.is_empty() { + eprintln!( + "warning: no review URL for {} of the issue's pull request(s):", + resolution.unresolved.len() + ); + for url in &resolution.unresolved { + eprintln!(" {}", url); + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn issue(value: Value) -> Issue { + serde_json::from_value(value).expect("issue fixture must deserialize") + } + + fn issue_with_sessions(sessions: Value) -> Issue { + issue(json!({ "identifier": "LIN-123", "agentSessions": { "nodes": sessions } })) + } + + fn pr_link(slug: &str, number: u64) -> Value { + json!({ "pullRequest": { + "slugId": slug, + "number": number, + "status": "open", + "url": format!("https://github.com/acme/app/pull/{}", number), + "title": "Fix the thing" + }}) + } + + fn entry(review_url: &str, pr_url: &str) -> ReviewEntry { + ReviewEntry { + review_url: review_url.to_string(), + number: None, + status: None, + title: None, + url: pr_url.to_string(), + } + } + + #[test] + fn test_attached_pr_urls_keeps_github_pull_requests_only() { + let issue = issue(json!({ "attachments": { "nodes": [ + { "sourceType": "github", "url": "https://github.com/acme/app/pull/183" }, + { "sourceType": "github", "url": "https://github.com/acme/app/pull/183" }, + { "sourceType": "github", "url": "https://github.com/acme/app/issues/12" }, + { "sourceType": "sentry", "url": "https://sentry.io/acme/app/pull/1" }, + { "sourceType": "github", "url": "https://github.com/acme/app/pull/184" } + ]}})); + + assert_eq!( + issue.attached_pr_urls(), + vec![ + "https://github.com/acme/app/pull/183", + "https://github.com/acme/app/pull/184" + ] + ); + } + + #[test] + fn test_review_entries_from_notifications_uses_the_notification_url() { + let nodes = vec![ + json!({ + "__typename": "IssueNotification", + "url": "https://linear.app/acme/issue/LIN-1" + }), + json!({ + "__typename": "PullRequestNotification", + "url": "https://linear.app/acme/review/fix-the-thing-72e2bba2372a", + "pullRequest": { + "url": "https://github.com/acme/app/pull/183", + "number": 183, "status": "open", "title": "Fix the thing" + } + }), + json!({ + "__typename": "PullRequestNotification", + "url": "https://linear.app/acme/review/someone-elses-pr-aaaaaaaaaaaa", + "pullRequest": { "url": "https://github.com/acme/app/pull/999", "number": 999 } + }), + ]; + let wanted = vec!["https://github.com/acme/app/pull/183".to_string()]; + + let entries = review_entries_from_notifications(&nodes, &wanted); + + assert_eq!(entries.len(), 1, "only the requested pull request matches"); + assert_eq!( + entries[0].review_url, "https://linear.app/acme/review/fix-the-thing-72e2bba2372a", + "the notification's URL is used verbatim, not reassembled from the slug" + ); + assert_eq!(entries[0].number, Some(183)); + } + + #[test] + fn test_review_entries_from_notifications_drops_a_comment_anchor() { + let nodes = vec![json!({ + "__typename": "PullRequestNotification", + "url": "https://linear.app/acme/review/fix-the-thing-72e2bba2372a#comment-5f63aa7c", + "pullRequest": { "url": "https://github.com/acme/app/pull/183", "number": 183 } + })]; + let wanted = vec!["https://github.com/acme/app/pull/183".to_string()]; + + let entries = review_entries_from_notifications(&nodes, &wanted); + + assert_eq!( + entries[0].review_url, "https://linear.app/acme/review/fix-the-thing-72e2bba2372a", + "a comment notification must still yield the review page URL" + ); + } + + #[test] + fn test_review_entries_from_notifications_skips_a_pull_request_without_a_url() { + // Nothing to key or report on, so it must not become an entry. + let nodes = vec![json!({ + "__typename": "PullRequestNotification", + "url": "https://linear.app/acme/review/fix-the-thing-72e2bba2372a", + "pullRequest": { "number": 183 } + })]; + let wanted = vec!["https://github.com/acme/app/pull/183".to_string()]; + + assert!(review_entries_from_notifications(&nodes, &wanted).is_empty()); + } + + #[test] + fn test_merge_prefers_the_notification_entry_per_pull_request() { + let pr = "https://github.com/acme/app/pull/1"; + let other = "https://github.com/acme/app/pull/2"; + + let merged = Resolution::merge( + &[pr.to_string(), other.to_string()], + vec![ + entry("from-agent-session", pr), + entry("other-from-agent-session", other), + ], + vec![entry("from-notification", pr)], + ); + + assert_eq!( + merged, + Resolution { + resolved: vec![ + entry("from-notification", pr), + entry("other-from-agent-session", other) + ], + unresolved: vec![], + } + ); + } + + #[test] + fn test_merge_reports_an_attached_pull_request_neither_source_resolved() { + let resolved_pr = "https://github.com/acme/app/pull/1"; + let unresolved_pr = "https://github.com/acme/app/pull/2"; + + let merged = Resolution::merge( + &[resolved_pr.to_string(), unresolved_pr.to_string()], + vec![], + vec![entry("from-notification", resolved_pr)], + ); + + assert_eq!( + merged, + Resolution { + resolved: vec![entry("from-notification", resolved_pr)], + unresolved: vec![unresolved_pr.to_string()], + }, + "a partial resolution must name the pull request it could not resolve" + ); + } + + #[test] + fn test_merge_reports_in_attachment_order_then_unattached_pull_requests() { + let second = "https://github.com/acme/app/pull/99"; + let first = "https://github.com/acme/app/pull/183"; + let unattached = "https://github.com/acme/app/pull/7"; + + let merged = Resolution::merge( + // Attachment order, which is not the order the entries arrive in and + // not lexicographic by URL. + &[first.to_string(), second.to_string()], + vec![entry("c", unattached)], + vec![entry("b", second), entry("a", first)], + ); + + assert_eq!( + merged.resolved, + vec![ + entry("a", first), + entry("b", second), + entry("c", unattached) + ] + ); + assert!(merged.unresolved.is_empty()); + } + + #[test] + fn test_agent_session_entries_builds_review_url_from_slug() { + let issue = issue_with_sessions(json!([ + { "pullRequests": { "nodes": [pr_link("7ffd27854fd2", 183)] } } + ])); + + let entries = issue.agent_session_entries("acme"); + + assert_eq!(entries.len(), 1); + assert_eq!( + entries[0].review_url, + "https://linear.app/acme/review/7ffd27854fd2" + ); + assert_eq!(entries[0].number, Some(183)); + assert_eq!(entries[0].url, "https://github.com/acme/app/pull/183"); + } + + #[test] + fn test_agent_session_entries_dedupe_a_pr_linked_by_several_sessions() { + let issue = issue_with_sessions(json!([ + { "pullRequests": { "nodes": [pr_link("aaa111", 7)] } }, + { "pullRequests": { "nodes": [pr_link("aaa111", 7), pr_link("bbb222", 8)] } } + ])); + let pr_urls = vec![ + "https://github.com/acme/app/pull/7".to_string(), + "https://github.com/acme/app/pull/8".to_string(), + ]; + + let merged = Resolution::merge(&pr_urls, issue.agent_session_entries("acme"), vec![]); + + assert_eq!( + merged.resolved.len(), + 2, + "the repeated pull request is listed once" + ); + assert_eq!( + merged.resolved[0].review_url, + "https://linear.app/acme/review/aaa111" + ); + assert_eq!( + merged.resolved[1].review_url, + "https://linear.app/acme/review/bbb222" + ); + } + + #[test] + fn test_agent_session_entries_empty_without_sessions_or_slug() { + assert!(issue_with_sessions(json!([])) + .agent_session_entries("acme") + .is_empty()); + + // A session with no linked pull request, and a link whose slug is missing + // or blank: all unresolvable, and none of them may produce a bogus URL. + let unresolvable = issue_with_sessions(json!([ + { "pullRequests": { "nodes": [] } }, + { "pullRequests": { "nodes": [{ "pullRequest": { "number": 1 } }] } }, + { "pullRequests": { "nodes": [{ "pullRequest": { "slugId": "", "number": 2 } }] } } + ])); + assert!(unresolvable.agent_session_entries("acme").is_empty()); + } + + #[test] + fn test_resolution_serializes_both_halves() { + let resolution = Resolution::merge( + &[ + "https://github.com/acme/app/pull/1".to_string(), + "https://github.com/acme/app/pull/2".to_string(), + ], + vec![], + vec![ReviewEntry { + review_url: "https://linear.app/acme/review/fix-the-thing-72e2bba2372a".to_string(), + number: Some(1), + status: Some("open".to_string()), + title: Some("Fix the thing".to_string()), + url: "https://github.com/acme/app/pull/1".to_string(), + }], + ); + + assert_eq!( + json!(resolution), + json!({ + "resolved": [{ + "reviewUrl": "https://linear.app/acme/review/fix-the-thing-72e2bba2372a", + "number": 1, + "status": "open", + "title": "Fix the thing", + "url": "https://github.com/acme/app/pull/1" + }], + "unresolved": ["https://github.com/acme/app/pull/2"] + }) + ); + } + + #[test] + fn test_nothing_resolved_error_distinguishes_no_pull_request_from_no_review_url() { + assert!(nothing_resolved_error("LIN-123", &[]) + .to_string() + .contains("no pull request is linked to this issue")); + + let attached = vec!["https://github.com/acme/app/pull/1".to_string()]; + let message = nothing_resolved_error("LIN-123", &attached).to_string(); + assert!(message.contains("1 linked pull request(s)")); + assert!(message.contains(&NOTIFICATION_LIMIT.to_string())); + } + + fn notification(review_url: &str, pr_url: &str) -> Value { + json!({ + "__typename": "PullRequestNotification", + "url": review_url, + "pullRequest": { "url": pr_url } + }) + } + + #[test] + fn test_scan_stops_once_every_wanted_pull_request_is_found() { + let first = "https://github.com/acme/app/pull/1"; + let second = "https://github.com/acme/app/pull/2"; + let wanted = vec![first.to_string(), second.to_string()]; + let mut scan = NotificationScan::default(); + + assert_eq!( + scan.absorb(&[notification("review-1", first)], &wanted), + PageFlow::Continue, + "one of two found: the rest of the feed is still worth reading" + ); + assert_eq!( + scan.absorb(&[notification("review-2", second)], &wanted), + PageFlow::Stop, + "both found: no further page may be requested" + ); + assert_eq!( + scan.entries, + vec![entry("review-1", first), entry("review-2", second)] + ); + } + + #[test] + fn test_scan_keeps_the_newest_notification_per_pull_request() { + let pr = "https://github.com/acme/app/pull/1"; + let other = "https://github.com/acme/app/pull/2"; + let wanted = vec![pr.to_string(), other.to_string()]; + let mut scan = NotificationScan::default(); + + // The feed is newest-first, so a later page's older notification for the + // same pull request must not displace the one already held. + scan.absorb(&[notification("newest-review", pr)], &wanted); + scan.absorb(&[notification("older-review", pr)], &wanted); + + assert_eq!(scan.entries, vec![entry("newest-review", pr)]); + } + + #[test] + fn test_scan_ignores_a_page_with_nothing_wanted_on_it() { + let wanted = vec!["https://github.com/acme/app/pull/1".to_string()]; + let mut scan = NotificationScan::default(); + + let flow = scan.absorb( + &[notification( + "review-x", + "https://github.com/acme/app/pull/999", + )], + &wanted, + ); + + assert_eq!(flow, PageFlow::Continue); + assert!(scan.entries.is_empty()); + } +} diff --git a/src/commands/import.rs b/src/commands/import.rs index d49b2a7..f923f48 100644 --- a/src/commands/import.rs +++ b/src/commands/import.rs @@ -43,12 +43,12 @@ pub async fn handle(cmd: ImportCommands, output: &OutputOptions) -> Result<()> { file, team, dry_run, - } => import_csv(&file, &team, dry_run, output).await, + } => import_csv(&file, &team, dry_run || output.dry_run, output).await, ImportCommands::Json { file, team, dry_run, - } => import_json(&file, &team, dry_run, output).await, + } => import_json(&file, &team, dry_run || output.dry_run, output).await, } } diff --git a/src/commands/initiatives.rs b/src/commands/initiatives.rs index f4888d8..909ca60 100644 --- a/src/commands/initiatives.rs +++ b/src/commands/initiatives.rs @@ -66,8 +66,8 @@ struct InitiativeRow { name: String, #[tabled(rename = "Status")] status: String, - #[tabled(rename = "Progress")] - progress: String, + #[tabled(rename = "Health")] + health: String, #[tabled(rename = "Projects")] project_count: String, } @@ -113,7 +113,7 @@ async fn list_initiatives(output: &OutputOptions, pagination: &PaginationOptions description status sortOrder - progress + health projects { nodes { id @@ -143,10 +143,7 @@ async fn list_initiatives(output: &OutputOptions, pagination: &PaginationOptions .iter() .filter_map(|v| { let i = serde_json::from_value::(v.clone()).ok()?; - let progress = format!( - "{}%", - (v["progress"].as_f64().unwrap_or(0.0) * 100.0) as i32 - ); + let health = v["health"].as_str().unwrap_or("-").to_string(); let project_count = v["projects"]["nodes"] .as_array() .map(|a| a.len().to_string()) @@ -155,7 +152,7 @@ async fn list_initiatives(output: &OutputOptions, pagination: &PaginationOptions id: i.id, name: truncate(&i.name, max_width), status: i.status.as_deref().unwrap_or("-").to_string(), - progress, + health, project_count, }) }) @@ -396,4 +393,3 @@ async fn delete_initiative(id: &str, force: bool) -> Result<()> { Ok(()) } - diff --git a/src/commands/issues.rs b/src/commands/issues.rs index 8527275..5dffa7d 100644 --- a/src/commands/issues.rs +++ b/src/commands/issues.rs @@ -103,6 +103,7 @@ pub enum IssueCommands { linear issues create "Fix bug" -t ENG # Create with title and team linear i create "Feature" -t ENG -p 2 # Create with high priority linear i create "Task" -t ENG -a me # Assign to yourself + linear i create "Task" -t ENG --project Q2 # Add to project linear i create "Task" -t ENG --due +3d # Due in 3 days linear i create "Bug" -t ENG --dry-run # Preview without creating"#)] Create { @@ -135,6 +136,9 @@ pub enum IssueCommands { /// Estimate in points (e.g., 1, 2, 3, 5, 8) #[arg(short, long)] estimate: Option, + /// Project name or ID + #[arg(long)] + project: Option, /// Template name to use for default values #[arg(long)] template: Option, @@ -348,6 +352,7 @@ pub async fn handle( labels, due, estimate, + project, template, dry_run, } => { @@ -429,6 +434,7 @@ pub async fn handle( final_labels, due, estimate, + project, output, agent_opts, dry_run, @@ -1031,8 +1037,6 @@ async fn get_issue(id: &str, output: &OutputOptions, history: bool, comments: bo toProject { name } archived trashed - slaBreachesAt - slaStartedAt } }"# } else { @@ -1267,6 +1271,7 @@ async fn create_issue( labels: Vec, due: Option, estimate: Option, + project: Option, output: &OutputOptions, agent_opts: AgentOptions, dry_run: bool, @@ -1356,6 +1361,14 @@ async fn create_issue( if let Some(e) = estimate { input["estimate"] = json!(e); } + if let Some(ref p) = project { + if dry_run { + input["projectId"] = json!(p); + } else { + let project_id = resolve_project_id(&client, p, &output.cache).await?; + input["projectId"] = json!(project_id); + } + } // Dry run: show what would be created without actually creating if dry_run { @@ -1374,41 +1387,43 @@ async fn create_issue( "labels": labels, "dueDate": due, "estimate": estimate, + "project": project, } }), output, )?; } else { println!("{}", "[DRY RUN] Would create issue:".yellow().bold()); - println!(" Title: {}", final_title); - println!(" Team: {} ({})", final_team, team_id); + println!(" Title: {}", safe_terminal_value(&final_title)); + println!( + " Team: {} ({})", + safe_terminal_value(final_team), + safe_terminal_value(&team_id) + ); if let Some(ref desc) = description { - let preview: String = desc.chars().take(50).collect(); - let preview = if preview.len() < desc.len() { - format!("{}...", preview) - } else { - preview - }; - println!(" Description: {}", preview); + println!(" Description: {}", truncate(desc, Some(50))); } if let Some(p) = priority { println!(" Priority: {}", p); } if let Some(ref s) = state { - println!(" State: {}", s); + println!(" State: {}", safe_terminal_value(s)); } if let Some(ref a) = assignee { - println!(" Assignee: {}", a); + println!(" Assignee: {}", safe_terminal_value(a)); } if !labels.is_empty() { - println!(" Labels: {}", labels.join(", ")); + println!(" Labels: {}", safe_terminal_value(&labels.join(", "))); } if let Some(ref d) = due { - println!(" Due: {}", d); + println!(" Due: {}", safe_terminal_value(d)); } if let Some(e) = estimate { println!(" Estimate: {}", e); } + if let Some(ref p) = project { + println!(" Project: {}", safe_terminal_value(p)); + } } return Ok(()); } @@ -2463,4 +2478,12 @@ mod tests { serde_json::json!({ "name": { "eqIgnoreCase": name } }) ); } + + #[test] + fn dry_run_text_values_strip_terminal_controls() { + assert_eq!( + safe_terminal_value("Q1\u{1b}]52;c;ZXZpbA==\u{7} Roadmap"), + "Q1 Roadmap" + ); + } } diff --git a/src/commands/templates.rs b/src/commands/templates.rs index ff82771..39d6a9f 100644 --- a/src/commands/templates.rs +++ b/src/commands/templates.rs @@ -744,16 +744,6 @@ async fn remote_get_template(id: &str, output: &OutputOptions) -> Result<()> { Ok(()) } -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_safe_terminal_value_removes_escape_sequences() { - assert_eq!(safe_terminal_value("bad\u{1b}[31mname\u{1b}[0m"), "badname"); - } -} - async fn remote_create_template( name: &str, template_type: &str, @@ -904,3 +894,13 @@ async fn remote_delete_template(id: &str, force: bool, output: &OutputOptions) - Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_safe_terminal_value_removes_escape_sequences() { + assert_eq!(safe_terminal_value("bad\u{1b}[31mname\u{1b}[0m"), "badname"); + } +} diff --git a/src/commands/update.rs b/src/commands/update.rs index 8b416a6..0766c4d 100644 --- a/src/commands/update.rs +++ b/src/commands/update.rs @@ -12,7 +12,7 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH}; use crate::output::print_json_owned; use crate::{AgentOptions, OutputOptions}; -const RELEASE_API_URL: &str = "https://api.github.com/repos/Finesssee/linear-cli/releases/latest"; +const RELEASE_API_URL: &str = "https://api.github.com/repos/nesszer/linear-cli/releases/latest"; const UPDATE_CHECK_INTERVAL_SECONDS: u64 = 24 * 60 * 60; #[derive(Debug, Deserialize)] diff --git a/src/commands/views.rs b/src/commands/views.rs index 39eef87..37228cf 100644 --- a/src/commands/views.rs +++ b/src/commands/views.rs @@ -605,19 +605,6 @@ pub async fn fetch_view_filter( Ok(filter.clone()) } -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_safe_terminal_value_removes_escape_sequences() { - assert_eq!( - safe_terminal_value("bad\u{1b}]52;c;ZXZpbA==\u{7}title"), - "badtitle" - ); - } -} - /// Fetch the projectFilterData for a custom view (used by projects list --view). pub async fn fetch_view_project_filter( client: &LinearClient, @@ -646,3 +633,16 @@ pub async fn fetch_view_project_filter( Ok(filter.clone()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_safe_terminal_value_removes_escape_sequences() { + assert_eq!( + safe_terminal_value("bad\u{1b}]52;c;ZXZpbA==\u{7}title"), + "badtitle" + ); + } +} diff --git a/src/dry_run.rs b/src/dry_run.rs new file mode 100644 index 0000000..17db842 --- /dev/null +++ b/src/dry_run.rs @@ -0,0 +1,240 @@ +//! Command-level dry-run policy. +//! +//! Commands that own a non-API side effect must reject `--dry-run` before +//! authentication or dispatch. API mutations have a second fail-closed guard +//! in [`crate::api::LinearClient::mutate`], so adding a new mutation cannot +//! accidentally turn a preview into a write if this audit list is missed. + +use crate::commands::{ + attachments, auth, comments, cycles, documents, export, favorites, git, initiatives, labels, + notifications, project_updates, projects, roadmaps, sprint, teams, templates, time, triage, + uploads, webhooks, +}; +use crate::{Commands, ConfigCommands}; + +/// Return the command name for a dry-run-incompatible operation. +/// +/// Keep this list explicit for operations that write local state, start an +/// interactive process, or otherwise have no preview. GraphQL mutations also +/// remain protected by the process-wide client guard, which is the defense in +/// depth for mutation paths added after this list was written. +pub(crate) fn unsupported_command(command: &Commands) -> Option<&'static str> { + match command { + Commands::Attachments { + action: + attachments::AttachmentCommands::Create { .. } + | attachments::AttachmentCommands::Update { .. } + | attachments::AttachmentCommands::Delete { .. } + | attachments::AttachmentCommands::LinkUrl { .. }, + } => Some("attachments"), + Commands::Bulk { .. } => Some("bulk"), + Commands::Cycles { action } + if !matches!( + action, + cycles::CycleCommands::Update { .. } + | cycles::CycleCommands::List { .. } + | cycles::CycleCommands::Get { .. } + | cycles::CycleCommands::Current { .. } + ) => + { + Some("cycles") + } + Commands::Comments { + action: + comments::CommentCommands::Create { .. } + | comments::CommentCommands::Update { .. } + | comments::CommentCommands::Delete { .. }, + } => Some("comments"), + Commands::Favorites { + action: + favorites::FavoriteCommands::Add { .. } | favorites::FavoriteCommands::Remove { .. }, + } => Some("favorites"), + Commands::Labels { + action: + labels::LabelCommands::Create { .. } + | labels::LabelCommands::Delete { .. } + | labels::LabelCommands::Update { .. }, + } => Some("labels"), + Commands::Notifications { + action: + notifications::NotificationCommands::Read { .. } + | notifications::NotificationCommands::ReadAll + | notifications::NotificationCommands::Archive { .. } + | notifications::NotificationCommands::ArchiveAll, + } => Some("notifications"), + Commands::ProjectUpdates { + action: + project_updates::ProjectUpdateCommands::Create { .. } + | project_updates::ProjectUpdateCommands::Update { .. } + | project_updates::ProjectUpdateCommands::Archive { .. } + | project_updates::ProjectUpdateCommands::Unarchive { .. }, + } => Some("project-updates"), + Commands::Projects { action } + if !matches!( + action, + projects::ProjectCommands::Update { .. } + | projects::ProjectCommands::List { .. } + | projects::ProjectCommands::Get { .. } + | projects::ProjectCommands::Open { .. } + | projects::ProjectCommands::Members { .. } + ) => + { + Some("projects") + } + Commands::Documents { + action: documents::DocumentCommands::Create { .. }, + } => Some("documents"), + Commands::Roadmaps { + action: + roadmaps::RoadmapCommands::Create { .. } | roadmaps::RoadmapCommands::Delete { .. }, + } => Some("roadmaps"), + Commands::Initiatives { + action: + initiatives::InitiativeCommands::Create { .. } + | initiatives::InitiativeCommands::Delete { .. }, + } => Some("initiatives"), + Commands::Milestones { + action: crate::commands::milestones::MilestoneCommands::Delete { .. }, + } => Some("milestones"), + Commands::Issues { action } + if !matches!( + action, + crate::commands::issues::IssueCommands::Create { .. } + | crate::commands::issues::IssueCommands::Update { .. } + | crate::commands::issues::IssueCommands::List { .. } + | crate::commands::issues::IssueCommands::Get { .. } + | crate::commands::issues::IssueCommands::Open { .. } + | crate::commands::issues::IssueCommands::Link { .. } + ) => + { + Some("issues") + } + Commands::Relations { action } + if !matches!( + action, + crate::commands::relations::RelationCommands::List { .. } + ) => + { + Some("relations") + } + Commands::Sprint { + action: sprint::SprintCommands::CarryOver { .. }, + } => Some("sprint"), + Commands::Teams { + action: + teams::TeamCommands::Create { .. } + | teams::TeamCommands::Update { .. } + | teams::TeamCommands::Delete { .. }, + } => Some("teams"), + Commands::Time { + action: + time::TimeCommands::Log { .. } + | time::TimeCommands::Delete { .. } + | time::TimeCommands::Update { .. }, + } => Some("time"), + Commands::Triage { + action: triage::TriageCommands::Claim { .. } | triage::TriageCommands::Snooze { .. }, + } => Some("triage"), + Commands::Api { + action: crate::commands::api::ApiCommands::Mutate { .. }, + } => Some("api mutate"), + Commands::Interactive { .. } => Some("interactive"), + Commands::Git { + action: + git::GitCommands::Checkout { .. } + | git::GitCommands::Create { .. } + | git::GitCommands::Pr { .. }, + } => Some("git"), + Commands::Done { .. } => Some("done"), + Commands::Setup => Some("setup"), + Commands::Auth { action } if !matches!(action, auth::AuthCommands::Status { .. }) => { + Some("auth") + } + Commands::Cache { + action: crate::commands::cache::CacheCommands::Clear { .. }, + } => Some("cache"), + Commands::Config { + action: + ConfigCommands::SetKey + | ConfigCommands::Set { .. } + | ConfigCommands::WorkspaceAdd { .. } + | ConfigCommands::WorkspaceSwitch { .. } + | ConfigCommands::WorkspaceRemove { .. }, + } => Some("config"), + Commands::Update { check: false } => Some("update"), + Commands::Doctor { fix: true, .. } => Some("doctor"), + Commands::Webhooks { + action: + webhooks::WebhookCommands::RotateSecret { .. } + | webhooks::WebhookCommands::Listen { .. }, + } => Some("webhooks"), + Commands::Templates { + action: + templates::TemplateCommands::Delete { .. } + | templates::TemplateCommands::RemoteCreate { .. } + | templates::TemplateCommands::RemoteUpdate { .. } + | templates::TemplateCommands::RemoteDelete { .. }, + } => Some("templates"), + Commands::Uploads { + action: uploads::UploadCommands::Fetch { file: Some(_), .. }, + } => Some("uploads"), + Commands::Export { + action: + export::ExportCommands::Csv { file: Some(_), .. } + | export::ExportCommands::Markdown { file: Some(_), .. } + | export::ExportCommands::Json { file: Some(_), .. } + | export::ExportCommands::ProjectsCsv { file: Some(_), .. }, + } => Some("export"), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::commands::relations::RelationCommands; + + #[test] + fn mutation_families_are_rejected_before_dispatch() { + let cases = [ + ( + Commands::Bulk { + action: crate::commands::bulk::BulkCommands::Assign { + user: "me".to_string(), + issues: vec!["LIN-1".to_string()], + }, + }, + "bulk", + ), + ( + Commands::Relations { + action: RelationCommands::List { + id: "LIN-1".to_string(), + }, + }, + "", + ), + ( + Commands::Api { + action: crate::commands::api::ApiCommands::Mutate { + query: Some("mutation { noop }".to_string()), + variables: vec![], + }, + }, + "api mutate", + ), + ]; + + assert_eq!(unsupported_command(&cases[0].0), Some(cases[0].1)); + assert_eq!(unsupported_command(&cases[1].0), None); + assert_eq!(unsupported_command(&cases[2].0), Some(cases[2].1)); + } + + #[test] + fn read_only_variants_are_not_rejected() { + let command = Commands::Teams { + action: teams::TeamCommands::List, + }; + assert_eq!(unsupported_command(&command), None); + } +} diff --git a/src/main.rs b/src/main.rs index 76ceab1..aa697d9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,6 +3,7 @@ mod cache; mod commands; mod config; mod dates; +mod dry_run; mod error; mod input; mod json_path; @@ -269,7 +270,7 @@ pub fn display_options() -> DisplayOptions { } #[derive(Subcommand)] -enum Commands { +pub(crate) enum Commands { /// Show common tasks and examples #[command(alias = "tasks")] Common, @@ -776,7 +777,7 @@ ENV OVERRIDES: } #[derive(Subcommand)] -enum ConfigCommands { +pub(crate) enum ConfigCommands { /// Set API key #[command(after_help = r#"EXAMPLE: printf '%s\n' "$LINEAR_API_KEY" | linear config set-key"#)] @@ -935,6 +936,7 @@ fn main() -> Result<()> { async fn async_main() -> Result { let cli = Cli::parse(); + api::set_dry_run(cli.dry_run); if cli.no_color || cli.color_mode == ColorChoice::Never { colored::control::set_override(false); } else if cli.color_mode == ColorChoice::Always { @@ -1105,6 +1107,10 @@ async fn run_command( agent_opts: AgentOptions, retry: u32, ) -> Result<()> { + if let Some(command_name) = dry_run::unsupported_command(&command) { + output::reject_unsupported_dry_run(output.dry_run, command_name)?; + } + match command { Commands::Common => { println!("Common tasks:"); @@ -1160,7 +1166,7 @@ async fn run_command( Commands::Search { action } => search::handle(action, output).await?, Commands::Sync { action } => sync::handle(action, output).await?, Commands::Statuses { action } => statuses::handle(action, output).await?, - Commands::Git { action } => git::handle(action).await?, + Commands::Git { action } => git::handle(action, output).await?, Commands::Bulk { action } => bulk::handle(action, output).await?, Commands::Cache { action } => commands::cache::handle(action).await?, Commands::Notifications { action } => notifications::handle(action, output).await?, diff --git a/src/output.rs b/src/output.rs index d9b4cdb..f834dbf 100644 --- a/src/output.rs +++ b/src/output.rs @@ -85,6 +85,15 @@ impl OutputOptions { } } +/// Reject a mutation when the caller requested a preview but the command has +/// no implementation for producing one. +pub fn reject_unsupported_dry_run(dry_run: bool, command: &str) -> Result<()> { + if dry_run { + anyhow::bail!("--dry-run is not supported for `{command}`; no changes were made"); + } + Ok(()) +} + #[derive(Debug, Clone)] pub enum FilterOp { Eq, diff --git a/src/pagination.rs b/src/pagination.rs index 73f4008..db0b6bc 100644 --- a/src/pagination.rs +++ b/src/pagination.rs @@ -150,6 +150,128 @@ pub async fn paginate_nodes( Ok(items) } +/// Whether a short-circuiting paginator should ask for another page. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PageFlow { + /// Read the next page, if the connection has one and the limit allows. + Continue, + /// Everything the caller wanted is in hand; stop without another request. + Stop, +} + +/// Walk a connection forward, letting the caller stop as soon as it has enough. +/// +/// Same cursor rules as [`paginate_nodes`], but each page is handed to +/// `accumulate` before the next request goes out and `accumulate` owns whatever +/// it collects. A caller scanning an unfilterable feed for a known set of +/// records therefore pays for the pages it actually needs rather than +/// `limit / page_size` of them every time. +/// +/// # Example +/// +/// ```ignore +/// let mut found = Vec::new(); +/// paginate_until( +/// &client, +/// query, +/// Map::new(), +/// &["data", "notifications", "nodes"], +/// &["data", "notifications", "pageInfo"], +/// &options, +/// 100, +/// |nodes| { +/// found.extend(nodes.into_iter().filter(is_wanted)); +/// if found.len() == wanted { PageFlow::Stop } else { PageFlow::Continue } +/// }, +/// ) +/// .await?; +/// ``` +#[allow(clippy::too_many_arguments)] +pub async fn paginate_until( + client: &LinearClient, + query: &str, + base_variables: Map, + nodes_path: &[&str], + page_info_path: &[&str], + options: &PaginationOptions, + default_page_size: usize, + mut accumulate: F, +) -> Result<()> +where + F: FnMut(Vec) -> PageFlow, +{ + let limit = if options.all { None } else { options.limit }; + let page_size = options.effective_page_size(default_page_size); + let mut after = options.after.clone(); + let mut read: usize = 0; + + loop { + let batch_size = limit + .map(|l| l.saturating_sub(read).min(page_size)) + .unwrap_or(page_size) + .max(1); + + let mut page_vars = Map::with_capacity(base_variables.len() + 2); + page_vars.insert( + "first".to_string(), + Value::Number(serde_json::Number::from(batch_size as u64)), + ); + if let Some(ref cursor) = after { + page_vars.insert("after".to_string(), Value::String(cursor.clone())); + } + for (k, v) in &base_variables { + page_vars.insert(k.clone(), v.clone()); + } + + let result = client.query(query, Some(Value::Object(page_vars))).await?; + + let mut nodes = get_path(&result, nodes_path) + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default(); + if nodes.is_empty() { + break; + } + if let Some(l) = limit { + nodes.truncate(l.saturating_sub(read)); + } + read += nodes.len(); + + if accumulate(nodes) == PageFlow::Stop { + break; + } + + if limit.is_some_and(|l| read >= l) { + break; + } + + // An unbounded request (no `--limit`, no `--all`) is one page, as in + // `paginate_nodes`. + if !options.all && options.limit.is_none() { + break; + } + + let Some(page_info) = get_path(&result, page_info_path).and_then(|v| v.as_object()) else { + break; + }; + if !page_info + .get("hasNextPage") + .and_then(|v| v.as_bool()) + .unwrap_or(false) + { + break; + } + // A connection that claims another page without handing back a cursor + // would otherwise reread the first page until the limit ran out. + let Some(cursor) = page_info.get("endCursor").and_then(|v| v.as_str()) else { + break; + }; + after = Some(cursor.to_string()); + } + + Ok(()) +} + /// Stream paginated results, calling a handler for each batch of nodes. /// /// This is memory-efficient for large exports because it processes each page diff --git a/src/vcs.rs b/src/vcs.rs index 831ab98..e78d38b 100644 --- a/src/vcs.rs +++ b/src/vcs.rs @@ -48,6 +48,8 @@ pub fn git_branch_exists(branch: &str) -> bool { } pub fn generate_branch_name(identifier: &str, title: &str) -> String { + const MAX_SLUG_CHARS: usize = 50; + // Convert title to kebab-case for branch name let slug: String = title .to_lowercase() @@ -59,12 +61,42 @@ pub fn generate_branch_name(identifier: &str, title: &str) -> String { .collect::>() .join("-"); - // Truncate if too long - let slug = if slug.len() > 50 { - slug[..50].trim_end_matches('-').to_string() - } else { - slug - }; + // Truncate on character boundaries. A title can contain multibyte + // characters even though the generated slug is otherwise ASCII today. + let slug: String = slug + .chars() + .take(MAX_SLUG_CHARS) + .collect::() + .trim_end_matches('-') + .to_string(); + let slug = if slug.is_empty() { "update" } else { &slug }; format!("{}/{}", identifier.to_lowercase(), slug) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn long_titles_are_truncated_without_invalid_boundaries() { + let branch = generate_branch_name("LIN-1", &"fix ".repeat(20)); + let slug = branch.strip_prefix("lin-1/").expect("identifier prefix"); + assert!(slug.chars().count() <= 50); + assert!(!slug.ends_with('-')); + assert!(validate_branch_name(&branch).is_ok()); + } + + #[test] + fn multibyte_titles_do_not_panic_when_truncated() { + let branch = generate_branch_name("LIN-2", &"界".repeat(60)); + assert_eq!(branch, format!("lin-2/{}", "界".repeat(50))); + } + + #[test] + fn punctuation_only_titles_get_a_valid_fallback_slug() { + let branch = generate_branch_name("LIN-3", "!!! --- ???"); + assert_eq!(branch, "lin-3/update"); + assert!(validate_branch_name(&branch).is_ok()); + } +} diff --git a/tests/cli_tests.rs b/tests/cli_tests.rs index ef5925c..cd0a78c 100644 --- a/tests/cli_tests.rs +++ b/tests/cli_tests.rs @@ -73,6 +73,13 @@ fn test_issues_help() { assert!(stdout.contains("stop")); } +#[test] +fn test_issues_create_help_includes_project() { + let (code, stdout, _stderr) = run_cli(&["issues", "create", "--help"]); + assert_eq!(code, 0); + assert!(stdout.contains("--project")); +} + #[test] fn test_teams_help() { let (code, stdout, _stderr) = run_cli(&["teams", "--help"]); @@ -1350,6 +1357,43 @@ fn test_dry_run_output() { ); } +#[test] +fn test_unsupported_dry_run_fails_closed_before_auth() { + let cases: [&[&str]; 3] = [ + &[ + "relations", + "add", + "LIN-1", + "-r", + "blocks", + "LIN-2", + "--dry-run", + ], + &["bulk", "assign", "me", "-i", "LIN-1", "--dry-run"], + &[ + "api", + "query", + "mutation { issueCreate(input: {}) { success } }", + "--dry-run", + ], + ]; + + for args in cases { + let (code, stdout, stderr) = run_cli(args); + assert_ne!(code, 0, "unsupported dry-run must fail: {args:?}"); + let combined = format!("{stdout}\n{stderr}"); + assert!( + combined.contains("--dry-run is not supported") + && combined.contains("no changes were made"), + "dry-run rejection should be explicit: args={args:?}, output={combined:?}" + ); + assert!( + !combined.contains("No API key configured"), + "rejection should happen before auth: args={args:?}, output={combined:?}" + ); + } +} + #[test] fn test_json_output_format() { // --output json should be accepted without error on help